mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: secret manager routes migration completed
This commit is contained in:
8
frontend-v2/package-lock.json
generated
8
frontend-v2/package-lock.json
generated
@@ -97,6 +97,7 @@
|
||||
"@types/argon2-browser": "^1.18.4",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/jsrp": "^0.2.6",
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/picomatch": "^3.0.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.12",
|
||||
@@ -3942,6 +3943,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "0.7.34",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz",
|
||||
"integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"@types/argon2-browser": "^1.18.4",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/jsrp": "^0.2.6",
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/picomatch": "^3.0.1",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.12",
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { ParsedUrlQuery } from "querystring";
|
||||
|
||||
import { useState } from "react";
|
||||
import { faAngleRight, faCheck, faCopy, faLock } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { createNotification } from "../notifications";
|
||||
import { IconButton, Select, SelectItem, Tooltip } from "../v2";
|
||||
@@ -59,7 +58,10 @@ export default function NavHeader({
|
||||
const [isCopied, { timedToggle: toggleIsCopied }] = useToggle(false);
|
||||
const [isHoveringCopyButton, setIsHoveringCopyButton] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const routerEnvSlug = useParams({
|
||||
from: "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secrets/$envSlug/",
|
||||
select: (el) => el.envSlug
|
||||
});
|
||||
|
||||
const secretPathSegments = secretPath.split("/").filter(Boolean);
|
||||
|
||||
@@ -91,10 +93,8 @@ export default function NavHeader({
|
||||
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-3 text-sm text-gray-400" />
|
||||
{pageName === "Secrets" ? (
|
||||
<Link
|
||||
to={{
|
||||
pathname: "/project/$projectId/secrets/overview",
|
||||
params: { id: currentWorkspace?.id }
|
||||
}}
|
||||
to={`/${ProjectType.SecretManager}/$projectId/overview` as const}
|
||||
params={{ projectId: currentWorkspace.id }}
|
||||
>
|
||||
<a className="text-sm font-semibold text-primary/80 hover:text-primary">{pageName}</a>
|
||||
</Link>
|
||||
@@ -128,10 +128,8 @@ export default function NavHeader({
|
||||
<div className="flex items-center space-x-3">
|
||||
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
|
||||
<Link
|
||||
to={{
|
||||
pathname: "/project/[id]/secrets/[env]" as const,
|
||||
query: { id: navigate.query.id, env: navigate.query.env }
|
||||
}}
|
||||
to={`/${ProjectType.SecretManager}/$projectId/secrets/$envSlug` as const}
|
||||
params={{ projectId: currentWorkspace.id, envSlug: routerEnvSlug }}
|
||||
className="text-sm font-semibold text-primary/80 hover:text-primary"
|
||||
>
|
||||
{userAvailableEnvs?.find(({ slug }) => slug === currentEnv)?.name}
|
||||
@@ -140,10 +138,7 @@ export default function NavHeader({
|
||||
)}
|
||||
{isFolderMode &&
|
||||
secretPathSegments?.map((folderName, index) => {
|
||||
const query: ParsedUrlQuery & { secretPath: string } = {
|
||||
...navigate.query,
|
||||
secretPath: `/${secretPathSegments.slice(0, index + 1).join("/")}`
|
||||
};
|
||||
const newSecretPath = `/${secretPathSegments.slice(0, index + 1).join("/")}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -174,7 +169,7 @@ export default function NavHeader({
|
||||
onClick={() => {
|
||||
if (isCopied) return;
|
||||
|
||||
navigator.clipboard.writeText(query.secretPath);
|
||||
navigator.clipboard.writeText(newSecretPath);
|
||||
|
||||
createNotification({
|
||||
text: "Copied secret path to clipboard",
|
||||
@@ -195,9 +190,12 @@ export default function NavHeader({
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
passHref
|
||||
legacyBehavior
|
||||
href={{ pathname: "/project/[id]/secrets/[env]", query }}
|
||||
to={`/${ProjectType.SecretManager}/$projectId/secrets/$envSlug` as const}
|
||||
params={{
|
||||
projectId: currentWorkspace.id,
|
||||
envSlug: routerEnvSlug
|
||||
}}
|
||||
search={(query) => ({ ...query, secretPath: newSecretPath })}
|
||||
>
|
||||
<a
|
||||
className={twMerge(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import { EmptyState } from "@app/components/v2";
|
||||
|
||||
import { TFormSchema } from "./ProjectRoleModifySection.utils";
|
||||
|
||||
// This is made into seperate component because watch subscribes to all permissions
|
||||
// thus keeping in top level casues render on all ones
|
||||
export const PermissionEmptyState = () => {
|
||||
const { watch } = useFormContext<TFormSchema>();
|
||||
const isNotEmptyPermissions = Object.entries(watch("permissions") || {}).some(
|
||||
([key, value]) => key && value?.length > 0
|
||||
);
|
||||
|
||||
if (isNotEmptyPermissions) return <div />;
|
||||
|
||||
return <EmptyState title="No policies applied" className="py-8" />;
|
||||
};
|
||||
@@ -0,0 +1,642 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context";
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
TPermissionCondition,
|
||||
TPermissionConditionOperators
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { TProjectPermission } from "@app/hooks/api/roles/types";
|
||||
|
||||
const GeneralPolicyActionSchema = z.object({
|
||||
read: z.boolean().optional(),
|
||||
edit: z.boolean().optional(),
|
||||
delete: z.boolean().optional(),
|
||||
create: z.boolean().optional()
|
||||
});
|
||||
|
||||
const CmekPolicyActionSchema = z.object({
|
||||
read: z.boolean().optional(),
|
||||
edit: z.boolean().optional(),
|
||||
delete: z.boolean().optional(),
|
||||
create: z.boolean().optional(),
|
||||
encrypt: z.boolean().optional(),
|
||||
decrypt: z.boolean().optional()
|
||||
});
|
||||
|
||||
const DynamicSecretPolicyActionSchema = z.object({
|
||||
[ProjectPermissionDynamicSecretActions.ReadRootCredential]: z.boolean().optional(),
|
||||
[ProjectPermissionDynamicSecretActions.EditRootCredential]: z.boolean().optional(),
|
||||
[ProjectPermissionDynamicSecretActions.DeleteRootCredential]: z.boolean().optional(),
|
||||
[ProjectPermissionDynamicSecretActions.CreateRootCredential]: z.boolean().optional(),
|
||||
[ProjectPermissionDynamicSecretActions.Lease]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const SecretRollbackPolicyActionSchema = z.object({
|
||||
read: z.boolean().optional(),
|
||||
create: z.boolean().optional()
|
||||
});
|
||||
|
||||
const WorkspacePolicyActionSchema = z.object({
|
||||
edit: z.boolean().optional(),
|
||||
delete: z.boolean().optional()
|
||||
});
|
||||
|
||||
const ConditionSchema = z
|
||||
.object({
|
||||
operator: z.string(),
|
||||
lhs: z.string(),
|
||||
rhs: z.string().min(1)
|
||||
})
|
||||
.array()
|
||||
.optional()
|
||||
.default([])
|
||||
.refine(
|
||||
(el) => {
|
||||
const lhsOperatorSet = new Set<string>();
|
||||
for (let i = 0; i < el.length; i += 1) {
|
||||
const { lhs, operator } = el[i];
|
||||
if (lhsOperatorSet.has(`${lhs}-${operator}`)) {
|
||||
return false;
|
||||
}
|
||||
lhsOperatorSet.add(`${lhs}-${operator}`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "Duplicate operator found for a condition" }
|
||||
);
|
||||
|
||||
export const projectRoleFormSchema = z.object({
|
||||
name: z.string().trim(),
|
||||
description: z.string().trim().optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }),
|
||||
permissions: z
|
||||
.object({
|
||||
[ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({
|
||||
inverted: z.boolean().optional(),
|
||||
conditions: ConditionSchema
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
[ProjectPermissionSub.SecretFolders]: GeneralPolicyActionSchema.extend({
|
||||
inverted: z.boolean().optional(),
|
||||
conditions: ConditionSchema
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
[ProjectPermissionSub.SecretImports]: GeneralPolicyActionSchema.extend({
|
||||
inverted: z.boolean().optional(),
|
||||
conditions: ConditionSchema
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
[ProjectPermissionSub.DynamicSecrets]: DynamicSecretPolicyActionSchema.extend({
|
||||
inverted: z.boolean().optional(),
|
||||
conditions: ConditionSchema
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
[ProjectPermissionSub.Identity]: GeneralPolicyActionSchema.extend({
|
||||
inverted: z.boolean().optional(),
|
||||
conditions: ConditionSchema
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
[ProjectPermissionSub.Member]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Groups]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Role]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Integrations]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Webhooks]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.ServiceTokens]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Settings]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Environments]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.AuditLogs]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.IpAllowList]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.CertificateAuthorities]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Certificates]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.PkiAlerts]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.PkiCollections]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.CertificateTemplates]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([])
|
||||
})
|
||||
.partial()
|
||||
.optional()
|
||||
});
|
||||
|
||||
export type TFormSchema = z.infer<typeof projectRoleFormSchema>;
|
||||
|
||||
type TConditionalFields =
|
||||
| ProjectPermissionSub.Secrets
|
||||
| ProjectPermissionSub.SecretFolders
|
||||
| ProjectPermissionSub.SecretImports
|
||||
| ProjectPermissionSub.DynamicSecrets
|
||||
| ProjectPermissionSub.Identity;
|
||||
|
||||
export const isConditionalSubjects = (
|
||||
subject: ProjectPermissionSub
|
||||
): subject is TConditionalFields =>
|
||||
subject === (ProjectPermissionSub.Secrets as const) ||
|
||||
subject === ProjectPermissionSub.DynamicSecrets ||
|
||||
subject === ProjectPermissionSub.SecretImports ||
|
||||
subject === ProjectPermissionSub.SecretFolders ||
|
||||
subject === ProjectPermissionSub.Identity;
|
||||
|
||||
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
|
||||
const formConditions: z.infer<typeof ConditionSchema> = [];
|
||||
Object.entries(caslConditions).forEach(([type, condition]) => {
|
||||
if (typeof condition === "string") {
|
||||
formConditions.push({
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
lhs: type,
|
||||
rhs: condition
|
||||
});
|
||||
} else {
|
||||
Object.keys(condition).forEach((conditionOperator) => {
|
||||
const rhs = condition[conditionOperator as PermissionConditionOperators];
|
||||
formConditions.push({
|
||||
operator: conditionOperator,
|
||||
lhs: type,
|
||||
rhs: typeof rhs === "string" ? rhs : rhs.join(",")
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
return formConditions;
|
||||
};
|
||||
|
||||
// convert role permission to form compatible data structure
|
||||
export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
const formVal: Partial<TFormSchema["permissions"]> = {};
|
||||
|
||||
permissions.forEach((permission) => {
|
||||
const { subject: caslSub, action, conditions, inverted } = permission;
|
||||
const subject = (typeof caslSub === "string" ? caslSub : caslSub[0]) as ProjectPermissionSub;
|
||||
if (!action.length) return;
|
||||
|
||||
if (
|
||||
[
|
||||
ProjectPermissionSub.Secrets,
|
||||
ProjectPermissionSub.DynamicSecrets,
|
||||
ProjectPermissionSub.SecretFolders,
|
||||
ProjectPermissionSub.SecretImports,
|
||||
ProjectPermissionSub.Member,
|
||||
ProjectPermissionSub.Groups,
|
||||
ProjectPermissionSub.Identity,
|
||||
ProjectPermissionSub.Role,
|
||||
ProjectPermissionSub.Integrations,
|
||||
ProjectPermissionSub.Webhooks,
|
||||
ProjectPermissionSub.ServiceTokens,
|
||||
ProjectPermissionSub.Settings,
|
||||
ProjectPermissionSub.Environments,
|
||||
ProjectPermissionSub.AuditLogs,
|
||||
ProjectPermissionSub.IpAllowList,
|
||||
ProjectPermissionSub.CertificateAuthorities,
|
||||
ProjectPermissionSub.Certificates,
|
||||
ProjectPermissionSub.PkiAlerts,
|
||||
ProjectPermissionSub.PkiCollections,
|
||||
ProjectPermissionSub.CertificateTemplates,
|
||||
ProjectPermissionSub.SecretApproval,
|
||||
ProjectPermissionSub.Tags,
|
||||
ProjectPermissionSub.SecretRotation,
|
||||
ProjectPermissionSub.Kms
|
||||
].includes(subject)
|
||||
) {
|
||||
// from above statement we are sure it won't be undefined
|
||||
if (isConditionalSubjects(subject)) {
|
||||
if (!formVal[subject]) formVal[subject] = [];
|
||||
|
||||
if (subject === ProjectPermissionSub.DynamicSecrets) {
|
||||
const canRead = action.includes(ProjectPermissionDynamicSecretActions.ReadRootCredential);
|
||||
const canEdit = action.includes(ProjectPermissionDynamicSecretActions.EditRootCredential);
|
||||
const canDelete = action.includes(
|
||||
ProjectPermissionDynamicSecretActions.DeleteRootCredential
|
||||
);
|
||||
const canCreate = action.includes(
|
||||
ProjectPermissionDynamicSecretActions.CreateRootCredential
|
||||
);
|
||||
const canLease = action.includes(ProjectPermissionDynamicSecretActions.Lease);
|
||||
|
||||
// from above statement we are sure it won't be undefined
|
||||
formVal[subject]!.push({
|
||||
[ProjectPermissionDynamicSecretActions.ReadRootCredential]: canRead,
|
||||
[ProjectPermissionDynamicSecretActions.CreateRootCredential]: canCreate,
|
||||
[ProjectPermissionDynamicSecretActions.EditRootCredential]: canEdit,
|
||||
[ProjectPermissionDynamicSecretActions.DeleteRootCredential]: canDelete,
|
||||
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
|
||||
inverted,
|
||||
[ProjectPermissionDynamicSecretActions.Lease]: canLease
|
||||
});
|
||||
return;
|
||||
}
|
||||
// for other subjects
|
||||
const canRead = action.includes(ProjectPermissionActions.Read);
|
||||
const canEdit = action.includes(ProjectPermissionActions.Edit);
|
||||
const canDelete = action.includes(ProjectPermissionActions.Delete);
|
||||
const canCreate = action.includes(ProjectPermissionActions.Create);
|
||||
|
||||
// remove this condition later
|
||||
// keeping when old routes create permission with folder read
|
||||
if (
|
||||
subject === ProjectPermissionSub.SecretFolders &&
|
||||
canRead &&
|
||||
!canEdit &&
|
||||
!canDelete &&
|
||||
!canCreate
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
formVal[subject]!.push({
|
||||
read: canRead,
|
||||
create: canCreate,
|
||||
edit: canEdit,
|
||||
delete: canDelete,
|
||||
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
|
||||
inverted
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// deduplicate multiple rules for other policies
|
||||
// because they don't have condition it doesn't make sense for multiple rules
|
||||
const canRead = action.includes(ProjectPermissionActions.Read);
|
||||
const canEdit = action.includes(ProjectPermissionActions.Edit);
|
||||
const canDelete = action.includes(ProjectPermissionActions.Delete);
|
||||
const canCreate = action.includes(ProjectPermissionActions.Create);
|
||||
|
||||
if (!formVal[subject]) formVal[subject] = [{}];
|
||||
if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true;
|
||||
if (canEdit) formVal[subject as ProjectPermissionSub.Member]![0].edit = true;
|
||||
if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true;
|
||||
if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.Project) {
|
||||
const canEdit = action.includes(ProjectPermissionActions.Edit);
|
||||
const canDelete = action.includes(ProjectPermissionActions.Delete);
|
||||
if (!formVal[subject]) formVal[subject] = [{}];
|
||||
|
||||
// from above statement we are sure it won't be undefined
|
||||
if (canEdit) formVal[subject as ProjectPermissionSub.Project]![0].edit = true;
|
||||
if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.SecretRollback) {
|
||||
const canRead = action.includes(ProjectPermissionActions.Read);
|
||||
const canCreate = action.includes(ProjectPermissionActions.Create);
|
||||
if (!formVal[subject]) formVal[subject] = [{}];
|
||||
|
||||
// from above statement we are sure it won't be undefined
|
||||
if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true;
|
||||
if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.Cmek) {
|
||||
const canRead = action.includes(ProjectPermissionCmekActions.Read);
|
||||
const canEdit = action.includes(ProjectPermissionCmekActions.Edit);
|
||||
const canDelete = action.includes(ProjectPermissionCmekActions.Delete);
|
||||
const canCreate = action.includes(ProjectPermissionCmekActions.Create);
|
||||
const canEncrypt = action.includes(ProjectPermissionCmekActions.Encrypt);
|
||||
const canDecrypt = action.includes(ProjectPermissionCmekActions.Decrypt);
|
||||
|
||||
if (!formVal[subject]) formVal[subject] = [{}];
|
||||
|
||||
// from above statement we are sure it won't be undefined
|
||||
if (canRead) formVal[subject]![0].read = true;
|
||||
if (canEdit) formVal[subject]![0].edit = true;
|
||||
if (canCreate) formVal[subject]![0].create = true;
|
||||
if (canDelete) formVal[subject]![0].delete = true;
|
||||
if (canEncrypt) formVal[subject]![0].encrypt = true;
|
||||
if (canDecrypt) formVal[subject]![0].decrypt = true;
|
||||
}
|
||||
});
|
||||
return formVal;
|
||||
};
|
||||
|
||||
const convertFormOperatorToCaslCondition = (
|
||||
conditions: { lhs: string; rhs: string; operator: string }[]
|
||||
) => {
|
||||
const caslCondition: Record<string, Partial<TPermissionConditionOperators>> = {};
|
||||
conditions.forEach((el) => {
|
||||
if (!caslCondition[el.lhs]) caslCondition[el.lhs] = {};
|
||||
if (
|
||||
el.operator === PermissionConditionOperators.$IN ||
|
||||
el.operator === PermissionConditionOperators.$ALL
|
||||
) {
|
||||
caslCondition[el.lhs][el.operator] = el.rhs.split(",");
|
||||
} else {
|
||||
caslCondition[el.lhs][
|
||||
el.operator as Exclude<
|
||||
PermissionConditionOperators,
|
||||
PermissionConditionOperators.$ALL | PermissionConditionOperators.$IN
|
||||
>
|
||||
] = el.rhs;
|
||||
}
|
||||
});
|
||||
return caslCondition;
|
||||
};
|
||||
|
||||
export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
|
||||
const permissions: TProjectPermission[] = [];
|
||||
// other than workspace everything else follows same
|
||||
// if in future there is a different follow the above on how workspace is done
|
||||
Object.entries(formVal || {}).forEach(([subject, rules]) => {
|
||||
rules.forEach((actions) => {
|
||||
const caslActions = Object.keys(actions).filter(
|
||||
(el) => actions?.[el as keyof typeof actions] && el !== "conditions" && el !== "inverted"
|
||||
);
|
||||
const caslConditions =
|
||||
"conditions" in actions
|
||||
? convertFormOperatorToCaslCondition(actions.conditions)
|
||||
: undefined;
|
||||
|
||||
permissions.push({
|
||||
action: caslActions,
|
||||
subject,
|
||||
inverted: (actions as { inverted?: boolean })?.inverted,
|
||||
conditions: caslConditions
|
||||
});
|
||||
});
|
||||
});
|
||||
return permissions;
|
||||
};
|
||||
|
||||
export type TProjectPermissionObject = {
|
||||
[K in ProjectPermissionSub]: {
|
||||
title: string;
|
||||
actions: {
|
||||
label: string;
|
||||
value: keyof Omit<
|
||||
NonNullable<NonNullable<TFormSchema["permissions"]>[K]>[number],
|
||||
"conditions" | "inverted"
|
||||
>;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
[ProjectPermissionSub.Secrets]: {
|
||||
title: "Secrets",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretFolders]: {
|
||||
title: "Secret Folders",
|
||||
actions: [
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretImports]: {
|
||||
title: "Secret Imports",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.DynamicSecrets]: {
|
||||
title: "Dynamic Secrets",
|
||||
actions: [
|
||||
{
|
||||
label: "Read root credentials",
|
||||
value: ProjectPermissionDynamicSecretActions.ReadRootCredential
|
||||
},
|
||||
{
|
||||
label: "Create root credentials",
|
||||
value: ProjectPermissionDynamicSecretActions.CreateRootCredential
|
||||
},
|
||||
{
|
||||
label: "Modify root credentials",
|
||||
value: ProjectPermissionDynamicSecretActions.EditRootCredential
|
||||
},
|
||||
{
|
||||
label: "Remove root credentials",
|
||||
value: ProjectPermissionDynamicSecretActions.DeleteRootCredential
|
||||
},
|
||||
{ label: "Manage Leases", value: ProjectPermissionDynamicSecretActions.Lease }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Cmek]: {
|
||||
title: "KMS",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" },
|
||||
{ label: "Encrypt", value: "encrypt" },
|
||||
{ label: "Decrypt", value: "decrypt" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Kms]: {
|
||||
title: "Project KMS Configuration",
|
||||
actions: [{ label: "Modify", value: "edit" }]
|
||||
},
|
||||
[ProjectPermissionSub.Integrations]: {
|
||||
title: "Integrations",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Project]: {
|
||||
title: "Project",
|
||||
actions: [
|
||||
{ label: "Update project details", value: "edit" },
|
||||
{ label: "Delete project", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Role]: {
|
||||
title: "Roles",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Member]: {
|
||||
title: "User Management",
|
||||
actions: [
|
||||
{ label: "View all members", value: "read" },
|
||||
{ label: "Invite members", value: "create" },
|
||||
{ label: "Edit members", value: "edit" },
|
||||
{ label: "Remove members", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Identity]: {
|
||||
title: "Machine Identity Management",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Add", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Groups]: {
|
||||
title: "Group Management",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Webhooks]: {
|
||||
title: "Webhooks",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.ServiceTokens]: {
|
||||
title: "Service Tokens",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Settings]: {
|
||||
title: "Settings",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Modify", value: "edit" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Environments]: {
|
||||
title: "Environment Management",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Tags]: {
|
||||
title: "Tags",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.AuditLogs]: {
|
||||
title: "Audit Logs",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.IpAllowList]: {
|
||||
title: "IP Allowlist",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.CertificateAuthorities]: {
|
||||
title: "Certificate Authorities",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Certificates]: {
|
||||
title: "Certificates",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.CertificateTemplates]: {
|
||||
title: "Certificate Templates",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.PkiCollections]: {
|
||||
title: "PKI Collections",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.PkiAlerts]: {
|
||||
title: "PKI Alerts",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretApproval]: {
|
||||
title: "Secret Protect policy",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretRotation]: {
|
||||
title: "Secret Rotation",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretRollback]: {
|
||||
title: "Secret Rollback",
|
||||
actions: [
|
||||
{ label: "Perform rollback", value: "create" },
|
||||
{ label: "View", value: "read" }
|
||||
]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { faPlus, faSave } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
|
||||
|
||||
import { GeneralPermissionConditions } from "./components/GeneralPermissionConditions";
|
||||
import { GeneralPermissionPolicies } from "./components/GeneralPermissionPolicies";
|
||||
import { IdentityManagementPermissionConditions } from "./components/IdentityManagementPermissionConditions";
|
||||
import { SecretPermissionConditions } from "./components/SecretPermissionConditions";
|
||||
import { PermissionEmptyState } from "./PermissionEmptyState";
|
||||
import {
|
||||
formRolePermission2API,
|
||||
isConditionalSubjects,
|
||||
PROJECT_PERMISSION_OBJECT,
|
||||
projectRoleFormSchema,
|
||||
rolePermission2Form,
|
||||
TFormSchema
|
||||
} from "./ProjectRoleModifySection.utils";
|
||||
|
||||
type Props = {
|
||||
roleSlug: string;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const renderConditionalComponents = (
|
||||
subject: ProjectPermissionSub,
|
||||
isDisabled?: boolean
|
||||
) => {
|
||||
if (subject === ProjectPermissionSub.Secrets)
|
||||
return <SecretPermissionConditions isDisabled={isDisabled} />;
|
||||
|
||||
if (isConditionalSubjects(subject)) {
|
||||
if (subject === ProjectPermissionSub.Identity) {
|
||||
return <IdentityManagementPermissionConditions isDisabled={isDisabled} />;
|
||||
}
|
||||
|
||||
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const { data: role, isLoading } = useGetProjectRoleBySlug(
|
||||
currentWorkspace?.id ?? "",
|
||||
roleSlug as string
|
||||
);
|
||||
|
||||
const form = useForm<TFormSchema>({
|
||||
values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined,
|
||||
resolver: zodResolver(projectRoleFormSchema)
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isDirty, isSubmitting },
|
||||
reset
|
||||
} = form;
|
||||
|
||||
const { mutateAsync: updateRole } = useUpdateProjectRole();
|
||||
|
||||
const onSubmit = async (el: TFormSchema) => {
|
||||
try {
|
||||
if (!projectId || !role?.id) return;
|
||||
await updateRole({
|
||||
id: role?.id as string,
|
||||
projectId,
|
||||
...el,
|
||||
permissions: formRolePermission2API(el.permissions)
|
||||
});
|
||||
createNotification({ type: "success", text: "Successfully updated role" });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({ type: "error", text: "Failed to update role" });
|
||||
}
|
||||
};
|
||||
|
||||
const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(role?.slug ?? "");
|
||||
|
||||
const onNewPolicy = (selectedSubject: ProjectPermissionSub) => {
|
||||
const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`);
|
||||
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) {
|
||||
form.setValue(
|
||||
`permissions.${selectedSubject}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
[...rootPolicyValue, ...[]],
|
||||
{ shouldDirty: true, shouldTouch: true }
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
`permissions.${selectedSubject}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
[{}],
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
<FormProvider {...form}>
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Policies</h3>
|
||||
<div className="flex items-center space-x-4">
|
||||
{isCustomRole && (
|
||||
<>
|
||||
{isDirty && (
|
||||
<Button
|
||||
className="mr-4 text-mineshaft-300"
|
||||
variant="link"
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
isLoading={isSubmitting}
|
||||
leftIcon={<FontAwesomeIcon icon={faSave} />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
className="h-10 rounded-l-none"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
New policy
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
|
||||
{Object.keys(PROJECT_PERMISSION_OBJECT)
|
||||
.sort((a, b) =>
|
||||
PROJECT_PERMISSION_OBJECT[
|
||||
a as keyof typeof PROJECT_PERMISSION_OBJECT
|
||||
].title
|
||||
.toLowerCase()
|
||||
.localeCompare(
|
||||
PROJECT_PERMISSION_OBJECT[
|
||||
b as keyof typeof PROJECT_PERMISSION_OBJECT
|
||||
].title.toLowerCase()
|
||||
)
|
||||
)
|
||||
.map((subject) => (
|
||||
<DropdownMenuItem
|
||||
key={`permission-create-${subject}`}
|
||||
className="py-3"
|
||||
onClick={() => onNewPolicy(subject as ProjectPermissionSub)}
|
||||
>
|
||||
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
{!isLoading && <PermissionEmptyState />}
|
||||
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
|
||||
<GeneralPermissionPolicies
|
||||
subject={subject}
|
||||
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
|
||||
title={PROJECT_PERMISSION_OBJECT[subject].title}
|
||||
key={`project-permission-${subject}`}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{renderConditionalComponents(subject, isDisabled)}
|
||||
</GeneralPermissionPolicies>
|
||||
))}
|
||||
</div>
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
import { TFormSchema } from "../ProjectRoleModifySection.utils";
|
||||
import {
|
||||
getConditionOperatorHelperInfo,
|
||||
renderOperatorSelectItems
|
||||
} from "./PermissionConditionHelpers";
|
||||
|
||||
type Props = {
|
||||
position?: number;
|
||||
isDisabled?: boolean;
|
||||
type:
|
||||
| ProjectPermissionSub.DynamicSecrets
|
||||
| ProjectPermissionSub.SecretFolders
|
||||
| ProjectPermissionSub.SecretImports;
|
||||
};
|
||||
|
||||
export const GeneralPermissionConditions = ({ position = 0, isDisabled, type }: Props) => {
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
formState: { errors }
|
||||
} = useFormContext<TFormSchema>();
|
||||
const items = useFieldArray({
|
||||
control,
|
||||
name: `permissions.${type}.${position}.conditions`
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
|
||||
<p className="mt-2 text-gray-300">Conditions</p>
|
||||
<p className="mb-2 text-sm text-mineshaft-400">
|
||||
When this policy should apply (always if no conditions are added).
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{items.fields.map((el, index) => {
|
||||
const condition =
|
||||
(watch(`permissions.${type}.${position}.conditions.${index}`) as {
|
||||
lhs: string;
|
||||
rhs: string;
|
||||
operator: string;
|
||||
}) || {};
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
<div className="w-1/4">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${type}.${position}.conditions.${index}.lhs`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value="environment">Environment Slug</SelectItem>
|
||||
<SelectItem value="secretPath">Secret Path</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-36 items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${type}.${position}.conditions.${index}.operator`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{renderOperatorSelectItems(condition.lhs)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={getConditionOperatorHelperInfo(
|
||||
condition?.operator as PermissionConditionOperators
|
||||
)}
|
||||
className="max-w-xs"
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${type}.${position}.conditions.${index}.rhs`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
ariaLabel="plus"
|
||||
variant="outline_bg"
|
||||
className="p-2.5"
|
||||
onClick={() => items.remove(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{errors?.permissions?.[type]?.[position]?.conditions?.message && (
|
||||
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
|
||||
<FontAwesomeIcon icon={faWarning} className="text-red" />
|
||||
<span>{errors?.permissions?.[type]?.[position]?.conditions?.message}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>{}</div>
|
||||
<div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
isDisabled={isDisabled}
|
||||
onClick={() =>
|
||||
items.append({
|
||||
lhs: "environment",
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
rhs: ""
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { cloneElement } from "react";
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import {
|
||||
faChevronDown,
|
||||
faChevronRight,
|
||||
faInfoCircle,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Button, Checkbox, Select, SelectItem, Tag, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
import {
|
||||
isConditionalSubjects,
|
||||
TFormSchema,
|
||||
TProjectPermissionObject
|
||||
} from "../ProjectRoleModifySection.utils";
|
||||
|
||||
type Props<T extends ProjectPermissionSub> = {
|
||||
title: string;
|
||||
subject: T;
|
||||
actions: TProjectPermissionObject[T]["actions"];
|
||||
children?: JSX.Element;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchema["permissions"]>>({
|
||||
subject,
|
||||
actions,
|
||||
children,
|
||||
title,
|
||||
isDisabled
|
||||
}: Props<T>) => {
|
||||
const { control } = useFormContext<TFormSchema>();
|
||||
const items = useFieldArray({
|
||||
control,
|
||||
name: `permissions.${subject}`
|
||||
});
|
||||
const [isOpen, setIsOpen] = useToggle();
|
||||
|
||||
if (!items.fields.length) return <div />;
|
||||
|
||||
return (
|
||||
<div className="border border-mineshaft-600 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md">
|
||||
<div
|
||||
className="flex cursor-pointer items-center space-x-8 px-5 py-4 text-sm text-gray-300"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setIsOpen.toggle()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setIsOpen.toggle();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<FontAwesomeIcon icon={isOpen ? faChevronDown : faChevronRight} />
|
||||
</div>
|
||||
<div className="flex-grow">{title}</div>
|
||||
{items.fields.length > 1 && (
|
||||
<div>
|
||||
<Tag size="xs" className="px-2">
|
||||
{items.fields.length} rules
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div key={`select-${subject}-type`} className="flex flex-col space-y-4 bg-bunker-800 p-6">
|
||||
{items.fields.map((el, rootIndex) => (
|
||||
<div key={el.id} className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md">
|
||||
{isConditionalSubjects(subject) && (
|
||||
<div className="mt-4 mb-6 flex w-full items-center text-gray-300">
|
||||
<div className="w-1/4">Permission</div>
|
||||
<div className="mr-4 w-1/4">
|
||||
<Controller
|
||||
defaultValue={false as any}
|
||||
name={`permissions.${subject}.${rootIndex}.inverted`}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(val === "true")}
|
||||
containerClassName="w-full"
|
||||
className="w-full"
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<SelectItem value="false">Allow</SelectItem>
|
||||
<SelectItem value="true">Forbid</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
Whether to allow or forbid the selected actions when the following
|
||||
conditions (if any) are met.
|
||||
</p>
|
||||
<p className="mt-2">Forbid rules must come after allow rules.</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex text-gray-300">
|
||||
<div className="w-1/4">Actions</div>
|
||||
<div className="flex flex-grow flex-wrap justify-start gap-8">
|
||||
{actions.map(({ label, value }) => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
return (
|
||||
<Controller
|
||||
key={`${el.id}-${label}`}
|
||||
name={`permissions.${subject}.${rootIndex}.${value}` as any}
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isDisabled={isDisabled}
|
||||
isChecked={Boolean(field.value)}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`permissions.${subject}.${rootIndex}.${String(value)}`}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{children &&
|
||||
cloneElement(children, {
|
||||
position: rootIndex
|
||||
})}
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-4 flex justify-start space-x-4",
|
||||
isConditionalSubjects(subject) && "justify-end"
|
||||
)}
|
||||
>
|
||||
{!isDisabled && isConditionalSubjects(subject) && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
items.insert(rootIndex + 1, [
|
||||
{ read: false, edit: false, create: false, delete: false } as any
|
||||
]);
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Add policy
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="mt-2 hover:border-red"
|
||||
onClick={() => items.remove(rootIndex)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Remove policy
|
||||
</Button>
|
||||
)}{" "}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
import { TFormSchema } from "../ProjectRoleModifySection.utils";
|
||||
import { getConditionOperatorHelperInfo } from "./PermissionConditionHelpers";
|
||||
|
||||
type Props = {
|
||||
position?: number;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const IdentityManagementPermissionConditions = ({ position = 0, isDisabled }: Props) => {
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
formState: { errors }
|
||||
} = useFormContext<TFormSchema>();
|
||||
const permissionSubject = ProjectPermissionSub.Identity;
|
||||
const items = useFieldArray({
|
||||
control,
|
||||
name: `permissions.${permissionSubject}.${position}.conditions`
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
|
||||
<p className="mt-2 text-gray-300">Conditions</p>
|
||||
<p className="mb-2 text-sm text-mineshaft-400">
|
||||
When this policy should apply (always if no conditions are added).
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{items.fields.map((el, index) => {
|
||||
const condition =
|
||||
(watch(`permissions.${permissionSubject}.${position}.conditions.${index}`) as {
|
||||
lhs: string;
|
||||
rhs: string;
|
||||
operator: string;
|
||||
}) || {};
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
<div className="w-1/4">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${permissionSubject}.${position}.conditions.${index}.lhs`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value="identityId">Identity ID</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-36 items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${permissionSubject}.${position}.conditions.${index}.operator`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value={PermissionConditionOperators.$EQ}>Equal</SelectItem>
|
||||
<SelectItem value={PermissionConditionOperators.$NEQ}>Not Equal</SelectItem>
|
||||
<SelectItem value={PermissionConditionOperators.$IN}>In</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={getConditionOperatorHelperInfo(
|
||||
condition?.operator as PermissionConditionOperators
|
||||
)}
|
||||
className="max-w-xs"
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${permissionSubject}.${position}.conditions.${index}.rhs`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
ariaLabel="plus"
|
||||
variant="outline_bg"
|
||||
className="p-2.5"
|
||||
onClick={() => items.remove(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message && (
|
||||
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
|
||||
<FontAwesomeIcon icon={faWarning} className="text-red" />
|
||||
<span>{errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>{}</div>
|
||||
<div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
isDisabled={isDisabled}
|
||||
onClick={() =>
|
||||
items.append({
|
||||
lhs: "identityId",
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
rhs: ""
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
ModalClose,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
|
||||
import {
|
||||
isConditionalSubjects,
|
||||
PROJECT_PERMISSION_OBJECT,
|
||||
projectRoleFormSchema,
|
||||
TFormSchema
|
||||
} from "../ProjectRoleModifySection.utils";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const NewPermissionRule = ({ onClose }: Props) => {
|
||||
const rootForm = useFormContext<TFormSchema>();
|
||||
|
||||
const form = useForm<{
|
||||
type: ProjectPermissionSub;
|
||||
permissions: NonNullable<TFormSchema["permissions"]>;
|
||||
}>({
|
||||
resolver: zodResolver(
|
||||
projectRoleFormSchema
|
||||
.pick({ permissions: true })
|
||||
.extend({ type: z.nativeEnum(ProjectPermissionSub) })
|
||||
),
|
||||
defaultValues: {
|
||||
type: ProjectPermissionSub.Secrets
|
||||
}
|
||||
});
|
||||
|
||||
const selectedSubject = form.watch("type");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="type"
|
||||
defaultValue={ProjectPermissionSub.Secrets}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Subject" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{Object.keys(PROJECT_PERMISSION_OBJECT).map((subject) => (
|
||||
<SelectItem value={subject} key={`permission-create-${subject}`}>
|
||||
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<FormLabel label="Actions" className="my-2" />
|
||||
<div className="flex flex-grow flex-wrap justify-start gap-8">
|
||||
{PROJECT_PERMISSION_OBJECT?.[selectedSubject]?.actions?.map(({ label, value }) => (
|
||||
<Controller
|
||||
key={`create-permission-${selectedSubject}-${label}`}
|
||||
name={`permissions.${selectedSubject}.0.${value as any}` as any}
|
||||
control={form.control}
|
||||
defaultValue={false}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`new-permissions.${selectedSubject}.0.${String(value)}`}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flex space-x-4">
|
||||
<Button
|
||||
onClick={form.handleSubmit((el) => {
|
||||
const rootPolicyValue = rootForm.getValues("permissions")?.[el.type];
|
||||
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) {
|
||||
rootForm.setValue(
|
||||
`permissions.${el.type}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
[...rootPolicyValue, ...(el?.permissions[el.type] || [])],
|
||||
{ shouldDirty: true, shouldTouch: true }
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
rootForm.setValue(`permissions.${el.type}`, el?.permissions?.[el.type], {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
})}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { GlobPermissionInfo } from "@app/components/permissions";
|
||||
import { SelectItem } from "@app/components/v2";
|
||||
import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
export const getConditionOperatorHelperInfo = (type: PermissionConditionOperators) => {
|
||||
switch (type) {
|
||||
case PermissionConditionOperators.$EQ:
|
||||
return "Value should equal specified value.";
|
||||
case PermissionConditionOperators.$NEQ:
|
||||
return "Value should not equal specified value.";
|
||||
case PermissionConditionOperators.$IN:
|
||||
return "List of comma-separated values that match a given value.";
|
||||
case PermissionConditionOperators.$GLOB:
|
||||
return <GlobPermissionInfo />;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const renderOperatorSelectItems = (type: string) => {
|
||||
if (type === "secretTags") {
|
||||
return <SelectItem value={PermissionConditionOperators.$IN}>Contains</SelectItem>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectItem value={PermissionConditionOperators.$EQ}>Equal</SelectItem>
|
||||
<SelectItem value={PermissionConditionOperators.$NEQ}>Not Equal</SelectItem>
|
||||
<SelectItem value={PermissionConditionOperators.$GLOB}>Glob Match</SelectItem>
|
||||
<SelectItem value={PermissionConditionOperators.$IN}>In</SelectItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
import { TFormSchema } from "../ProjectRoleModifySection.utils";
|
||||
import {
|
||||
getConditionOperatorHelperInfo,
|
||||
renderOperatorSelectItems
|
||||
} from "./PermissionConditionHelpers";
|
||||
|
||||
type Props = {
|
||||
position?: number;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) => {
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors }
|
||||
} = useFormContext<TFormSchema>();
|
||||
const items = useFieldArray({
|
||||
control,
|
||||
name: `permissions.secrets.${position}.conditions`
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
|
||||
<p className="mt-2 text-gray-300">Conditions</p>
|
||||
<p className="mb-2 text-sm text-mineshaft-400">
|
||||
When this policy should apply (always if no conditions are added).
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{items.fields.map((el, index) => {
|
||||
const condition = watch(`permissions.secrets.${position}.conditions.${index}`) as {
|
||||
lhs: string;
|
||||
rhs: string;
|
||||
operator: string;
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
<div className="w-1/4">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.secrets.${position}.conditions.${index}.lhs`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
setValue(
|
||||
`permissions.secrets.${position}.conditions.${index}.operator`,
|
||||
PermissionConditionOperators.$IN as never
|
||||
);
|
||||
field.onChange(e);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value="environment">Environment Slug</SelectItem>
|
||||
<SelectItem value="secretPath">Secret Path</SelectItem>
|
||||
<SelectItem value="secretName">Secret Name</SelectItem>
|
||||
<SelectItem value="secretTags">Secret Tags</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-36 items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.secrets.${position}.conditions.${index}.operator`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{renderOperatorSelectItems(condition.lhs)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={getConditionOperatorHelperInfo(
|
||||
condition?.operator as PermissionConditionOperators
|
||||
)}
|
||||
className="max-w-xs"
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.secrets.${position}.conditions.${index}.rhs`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
ariaLabel="plus"
|
||||
variant="outline_bg"
|
||||
className="p-2.5"
|
||||
onClick={() => items.remove(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{errors?.permissions?.secrets?.[position]?.conditions?.message && (
|
||||
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
|
||||
<FontAwesomeIcon icon={faWarning} className="text-red" />
|
||||
<span>{errors?.permissions?.secrets?.[position]?.conditions?.message}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
isDisabled={isDisabled}
|
||||
onClick={() =>
|
||||
items.append({
|
||||
lhs: "environment",
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
rhs: ""
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { RolePermissionsSection } from "./RolePermissionsSection";
|
||||
@@ -0,0 +1,133 @@
|
||||
/* credits: https://iamkate.com/code/tree-views/ */
|
||||
.tree {
|
||||
--spacing: 1.5rem;
|
||||
--radius: 4px;
|
||||
}
|
||||
|
||||
.tree li {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-left: calc(2 * var(--spacing) - var(--radius) - 2px);
|
||||
}
|
||||
|
||||
.tree ul {
|
||||
margin-left: calc(var(--radius) - var(--spacing));
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.tree ul li {
|
||||
border-left: 2px solid #888;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.tree ul li:last-child {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.tree ul li::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(var(--spacing) / -1);
|
||||
left: -2px;
|
||||
width: calc(var(--spacing) + 2px);
|
||||
height: calc(var(--spacing) + 13px);
|
||||
border: solid #888;
|
||||
border-radius: 0 0 0 8px;
|
||||
border-width: 0 0 2px 2px;
|
||||
transition: all 200ms linear;
|
||||
}
|
||||
|
||||
.details[open] summary ~ * {
|
||||
animation: sweep 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes sweep {
|
||||
0% {
|
||||
opacity: 0;
|
||||
margin-left: -10px;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.tree summary {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.tree summary::marker,
|
||||
.tree summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tree summary:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tree summary:focus-visible {
|
||||
outline: 1px dotted #000;
|
||||
}
|
||||
|
||||
.tree li::after,
|
||||
.tree summary::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(var(--spacing) / 2 - var(--radius));
|
||||
left: calc(var(--spacing) - var(--radius) - 1px);
|
||||
width: calc(2 * var(--radius));
|
||||
height: calc(2 * var(--radius));
|
||||
border-radius: 50%;
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.tree summary::before {
|
||||
z-index: 1;
|
||||
background: #ddd 0 0;
|
||||
}
|
||||
|
||||
.tree details[open] > summary::before {
|
||||
background-position: calc(-2 * var(--radius)) 0;
|
||||
}
|
||||
|
||||
.collapsibleContent {
|
||||
/*overflow-y: hidden;*/
|
||||
}
|
||||
.collapsibleContent[data-state="open"] {
|
||||
animation: slideDown 300ms ease-out;
|
||||
}
|
||||
.collapsibleContent[data-state="closed"] {
|
||||
animation: slideUp 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
0% {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from "react";
|
||||
import { faChevronRight, faEye, faEyeSlash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { FormControl, FormLabel, SecretInput, Spinner, Tooltip } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useGetSecretReferenceTree } from "@app/hooks/api";
|
||||
import { TSecretReferenceTraceNode } from "@app/hooks/api/types";
|
||||
|
||||
import style from "./SecretReferenceDetails.module.css";
|
||||
|
||||
type Props = {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretKey: string;
|
||||
};
|
||||
|
||||
const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/;
|
||||
export const hasSecretReference = (value: string | undefined) =>
|
||||
value ? INTERPOLATION_SYNTAX_REG.test(value) : false;
|
||||
|
||||
export const SecretReferenceNode = ({
|
||||
node,
|
||||
isRoot,
|
||||
secretKey
|
||||
}: {
|
||||
node: TSecretReferenceTraceNode;
|
||||
isRoot?: boolean;
|
||||
secretKey?: string;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Collapsible.Root open={isOpen} className="" onOpenChange={setIsOpen}>
|
||||
<Collapsible.Trigger
|
||||
className={twMerge(
|
||||
hasChildren && "decoration-bunker-4ø00 underline-offset-4 data-[state=open]:underline",
|
||||
"[&>svg]:data-[state=open]:rotate-[90deg] [&>svg]:data-[state=open]:text-yellow-500"
|
||||
)}
|
||||
disabled={!hasChildren}
|
||||
>
|
||||
{hasChildren && (
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronRight}
|
||||
className="d mr-2 text-mineshaft-400 transition-transform duration-300 ease-linear"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{isRoot
|
||||
? secretKey
|
||||
: `${node.environment}${
|
||||
node.secretPath === "/" ? "" : node.secretPath.split("/").join(".")
|
||||
}.${node.key}`}
|
||||
<Tooltip className="max-w-md break-words" content={node.value}>
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-1 px-1 text-xs text-mineshaft-400",
|
||||
!node.value && "text-red-400"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={node.value ? faEye : faEyeSlash} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className={twMerge("mt-4", style.collapsibleContent)}>
|
||||
{hasChildren && (
|
||||
<ul>
|
||||
{node.children.map((el, index) => (
|
||||
<SecretReferenceNode node={el} key={`${el.key}-${index + 1}`} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
|
||||
const { data, isPending } = useGetSecretReferenceTree({
|
||||
secretPath,
|
||||
environmentSlug: environment,
|
||||
projectId,
|
||||
secretKey
|
||||
});
|
||||
|
||||
const tree = data?.tree;
|
||||
const secretValue = data?.value;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Spinner size="xs" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormControl label="Expanded value">
|
||||
<SecretInput
|
||||
key="value-overriden"
|
||||
isReadOnly
|
||||
value={secretValue}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-700 px-2 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="mb-2" label="Reference Tree" />
|
||||
<div className="thin-scrollbar relative max-h-96 overflow-auto rounded-md border border-mineshaft-600 bg-bunker-700 py-6 text-sm text-mineshaft-200">
|
||||
{tree && (
|
||||
<ul className={style.tree}>
|
||||
<SecretReferenceNode node={tree} isRoot secretKey={secretKey} />
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-mineshaft-400">
|
||||
Click a secret key to view its sub-references.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { hasSecretReference,SecretReferenceTree } from "./SecretReferenceDetails";
|
||||
@@ -134,7 +134,7 @@ export const ProjectLayout = () => {
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={`/${currentWorkspace.type}/$projectId/members` as const}
|
||||
to={`/${currentWorkspace.type}/$projectId/access` as const}
|
||||
params={{
|
||||
projectId: currentWorkspace.id
|
||||
}}
|
||||
|
||||
@@ -45,14 +45,23 @@ import { Route as AuthenticateCtxOrgDetailsOrganizationLayoutOrgMembersIndexImpo
|
||||
import { Route as AuthenticateCtxOrgDetailsOrganizationLayoutOrgBillingIndexImport } from './routes/_authenticate/_ctx-org-details/organization/_layout-org/billing/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsOrganizationLayoutOrgAuditLogsIndexImport } from './routes/_authenticate/_ctx-org-details/organization/_layout-org/audit-logs/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsOrganizationLayoutOrgAdminIndexImport } from './routes/_authenticate/_ctx-org-details/organization/_layout-org/admin/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewImport } from './routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview'
|
||||
import { Route as AuthenticateCtxOrgDetailsOrganizationLayoutOrgSecretManagerOverviewImport } from './routes/_authenticate/_ctx-org-details/organization/_layout-org/secret-manager/overview'
|
||||
import { Route as AuthenticateCtxOrgDetailsOrganizationLayoutOrgKmsOverviewImport } from './routes/_authenticate/_ctx-org-details/organization/_layout-org/kms/overview'
|
||||
import { Route as AuthenticateCtxOrgDetailsOrganizationLayoutOrgCertManagerOverviewImport } from './routes/_authenticate/_ctx-org-details/organization/_layout-org/cert-manager/overview'
|
||||
import { Route as AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSettingsIndexImport } from './routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/settings/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretRotationIndexImport } from './routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewIndexImport } from './routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerApprovalIndexImport } from './routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAllowlistIndexImport } from './routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAccessIndexImport } from './routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsOrganizationLayoutOrgRolesRoleIdIndexImport } from './routes/_authenticate/_ctx-org-details/organization/_layout-org/roles/$roleId/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsOrganizationLayoutOrgMembershipsMembershipIdIndexImport } from './routes/_authenticate/_ctx-org-details/organization/_layout-org/memberships/$membershipId/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsOrganizationLayoutOrgIdentitiesIdentityIdIndexImport } from './routes/_authenticate/_ctx-org-details/organization/_layout-org/identities/$identityId/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsOrganizationLayoutOrgGroupsGroupIdIndexImport } from './routes/_authenticate/_ctx-org-details/organization/_layout-org/groups/$groupId/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretsEnvSlugIndexImport } from './routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secrets.$envSlug/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRolesRoleSlugIndexImport } from './routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerMembersMembershipIdIndexImport } from './routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/index'
|
||||
import { Route as AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerIdentitiesIdentityIdIndexImport } from './routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/index'
|
||||
|
||||
// Create Virtual Routes
|
||||
|
||||
@@ -309,16 +318,6 @@ const AuthenticateCtxOrgDetailsOrganizationLayoutOrgAdminIndexRoute =
|
||||
getParentRoute: () => AuthenticateCtxOrgDetailsOrganizationLayoutOrgRoute,
|
||||
} as any)
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewRoute =
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewImport.update(
|
||||
{
|
||||
id: '/overview',
|
||||
path: '/overview',
|
||||
getParentRoute: () =>
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRoute,
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsOrganizationLayoutOrgSecretManagerOverviewRoute =
|
||||
AuthenticateCtxOrgDetailsOrganizationLayoutOrgSecretManagerOverviewImport.update(
|
||||
{
|
||||
@@ -344,6 +343,66 @@ const AuthenticateCtxOrgDetailsOrganizationLayoutOrgCertManagerOverviewRoute =
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSettingsIndexRoute =
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSettingsIndexImport.update(
|
||||
{
|
||||
id: '/settings/',
|
||||
path: '/settings/',
|
||||
getParentRoute: () =>
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRoute,
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretRotationIndexRoute =
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretRotationIndexImport.update(
|
||||
{
|
||||
id: '/secret-rotation/',
|
||||
path: '/secret-rotation/',
|
||||
getParentRoute: () =>
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRoute,
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewIndexRoute =
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewIndexImport.update(
|
||||
{
|
||||
id: '/overview/',
|
||||
path: '/overview/',
|
||||
getParentRoute: () =>
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRoute,
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerApprovalIndexRoute =
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerApprovalIndexImport.update(
|
||||
{
|
||||
id: '/approval/',
|
||||
path: '/approval/',
|
||||
getParentRoute: () =>
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRoute,
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAllowlistIndexRoute =
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAllowlistIndexImport.update(
|
||||
{
|
||||
id: '/allowlist/',
|
||||
path: '/allowlist/',
|
||||
getParentRoute: () =>
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRoute,
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAccessIndexRoute =
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAccessIndexImport.update(
|
||||
{
|
||||
id: '/access/',
|
||||
path: '/access/',
|
||||
getParentRoute: () =>
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRoute,
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsOrganizationLayoutOrgRolesRoleIdIndexRoute =
|
||||
AuthenticateCtxOrgDetailsOrganizationLayoutOrgRolesRoleIdIndexImport.update({
|
||||
id: '/roles/$roleId/',
|
||||
@@ -378,6 +437,46 @@ const AuthenticateCtxOrgDetailsOrganizationLayoutOrgGroupsGroupIdIndexRoute =
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretsEnvSlugIndexRoute =
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretsEnvSlugIndexImport.update(
|
||||
{
|
||||
id: '/secrets/$envSlug/',
|
||||
path: '/secrets/$envSlug/',
|
||||
getParentRoute: () =>
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRoute,
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRolesRoleSlugIndexRoute =
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRolesRoleSlugIndexImport.update(
|
||||
{
|
||||
id: '/roles/$roleSlug/',
|
||||
path: '/roles/$roleSlug/',
|
||||
getParentRoute: () =>
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRoute,
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerMembersMembershipIdIndexRoute =
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerMembersMembershipIdIndexImport.update(
|
||||
{
|
||||
id: '/members/$membershipId/',
|
||||
path: '/members/$membershipId/',
|
||||
getParentRoute: () =>
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRoute,
|
||||
} as any,
|
||||
)
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerIdentitiesIdentityIdIndexRoute =
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerIdentitiesIdentityIdIndexImport.update(
|
||||
{
|
||||
id: '/identities/$identityId/',
|
||||
path: '/identities/$identityId/',
|
||||
getParentRoute: () =>
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRoute,
|
||||
} as any,
|
||||
)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -585,13 +684,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgSecretManagerOverviewImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview': {
|
||||
id: '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview'
|
||||
path: '/overview'
|
||||
fullPath: '/secret-manager/$projectId/overview'
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/organization/_layout-org/admin/': {
|
||||
id: '/_authenticate/_ctx-org-details/organization/_layout-org/admin/'
|
||||
path: '/admin'
|
||||
@@ -683,6 +775,76 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgRolesRoleIdIndexImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/': {
|
||||
id: '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/'
|
||||
path: '/access'
|
||||
fullPath: '/secret-manager/$projectId/access'
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAccessIndexImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/allowlist/': {
|
||||
id: '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/allowlist/'
|
||||
path: '/allowlist'
|
||||
fullPath: '/secret-manager/$projectId/allowlist'
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAllowlistIndexImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/approval/': {
|
||||
id: '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/approval/'
|
||||
path: '/approval'
|
||||
fullPath: '/secret-manager/$projectId/approval'
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerApprovalIndexImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview/': {
|
||||
id: '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview/'
|
||||
path: '/overview'
|
||||
fullPath: '/secret-manager/$projectId/overview'
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewIndexImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secret-rotation/': {
|
||||
id: '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secret-rotation/'
|
||||
path: '/secret-rotation'
|
||||
fullPath: '/secret-manager/$projectId/secret-rotation'
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretRotationIndexImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/settings/': {
|
||||
id: '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/settings/'
|
||||
path: '/settings'
|
||||
fullPath: '/secret-manager/$projectId/settings'
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSettingsIndexImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/identities/$identityId/': {
|
||||
id: '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/identities/$identityId/'
|
||||
path: '/identities/$identityId'
|
||||
fullPath: '/secret-manager/$projectId/identities/$identityId'
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerIdentitiesIdentityIdIndexImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/members/$membershipId/': {
|
||||
id: '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/members/$membershipId/'
|
||||
path: '/members/$membershipId'
|
||||
fullPath: '/secret-manager/$projectId/members/$membershipId'
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerMembersMembershipIdIndexImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/roles/$roleSlug/': {
|
||||
id: '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/roles/$roleSlug/'
|
||||
path: '/roles/$roleSlug'
|
||||
fullPath: '/secret-manager/$projectId/roles/$roleSlug'
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRolesRoleSlugIndexImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerImport
|
||||
}
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secrets/$envSlug/': {
|
||||
id: '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secrets/$envSlug/'
|
||||
path: '/secrets/$envSlug'
|
||||
fullPath: '/secret-manager/$projectId/secrets/$envSlug'
|
||||
preLoaderRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretsEnvSlugIndexImport
|
||||
parentRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,13 +929,40 @@ const AuthenticateCtxOrgDetailsOrganizationRouteWithChildren =
|
||||
)
|
||||
|
||||
interface AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRouteChildren {
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewRoute
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAccessIndexRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAccessIndexRoute
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAllowlistIndexRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAllowlistIndexRoute
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerApprovalIndexRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerApprovalIndexRoute
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewIndexRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewIndexRoute
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretRotationIndexRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretRotationIndexRoute
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSettingsIndexRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSettingsIndexRoute
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerIdentitiesIdentityIdIndexRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerIdentitiesIdentityIdIndexRoute
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerMembersMembershipIdIndexRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerMembersMembershipIdIndexRoute
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRolesRoleSlugIndexRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRolesRoleSlugIndexRoute
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretsEnvSlugIndexRoute: typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretsEnvSlugIndexRoute
|
||||
}
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRouteChildren: AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRouteChildren =
|
||||
{
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewRoute:
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewRoute,
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAccessIndexRoute:
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAccessIndexRoute,
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAllowlistIndexRoute:
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAllowlistIndexRoute,
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerApprovalIndexRoute:
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerApprovalIndexRoute,
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewIndexRoute:
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewIndexRoute,
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretRotationIndexRoute:
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretRotationIndexRoute,
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSettingsIndexRoute:
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSettingsIndexRoute,
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerIdentitiesIdentityIdIndexRoute:
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerIdentitiesIdentityIdIndexRoute,
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerMembersMembershipIdIndexRoute:
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerMembersMembershipIdIndexRoute,
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRolesRoleSlugIndexRoute:
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRolesRoleSlugIndexRoute,
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretsEnvSlugIndexRoute:
|
||||
AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretsEnvSlugIndexRoute,
|
||||
}
|
||||
|
||||
const AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRouteWithChildren =
|
||||
@@ -928,7 +1117,6 @@ export interface FileRoutesByFullPath {
|
||||
'/organization/cert-manager/overview': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgCertManagerOverviewRoute
|
||||
'/organization/kms/overview': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgKmsOverviewRoute
|
||||
'/organization/secret-manager/overview': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgSecretManagerOverviewRoute
|
||||
'/secret-manager/$projectId/overview': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewRoute
|
||||
'/organization/admin': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgAdminIndexRoute
|
||||
'/organization/audit-logs': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgAuditLogsIndexRoute
|
||||
'/organization/billing': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgBillingIndexRoute
|
||||
@@ -942,6 +1130,16 @@ export interface FileRoutesByFullPath {
|
||||
'/organization/identities/$identityId': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgIdentitiesIdentityIdIndexRoute
|
||||
'/organization/memberships/$membershipId': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgMembershipsMembershipIdIndexRoute
|
||||
'/organization/roles/$roleId': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgRolesRoleIdIndexRoute
|
||||
'/secret-manager/$projectId/access': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAccessIndexRoute
|
||||
'/secret-manager/$projectId/allowlist': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAllowlistIndexRoute
|
||||
'/secret-manager/$projectId/approval': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerApprovalIndexRoute
|
||||
'/secret-manager/$projectId/overview': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewIndexRoute
|
||||
'/secret-manager/$projectId/secret-rotation': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretRotationIndexRoute
|
||||
'/secret-manager/$projectId/settings': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSettingsIndexRoute
|
||||
'/secret-manager/$projectId/identities/$identityId': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerIdentitiesIdentityIdIndexRoute
|
||||
'/secret-manager/$projectId/members/$membershipId': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerMembersMembershipIdIndexRoute
|
||||
'/secret-manager/$projectId/roles/$roleSlug': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRolesRoleSlugIndexRoute
|
||||
'/secret-manager/$projectId/secrets/$envSlug': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretsEnvSlugIndexRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
@@ -967,7 +1165,6 @@ export interface FileRoutesByTo {
|
||||
'/organization/cert-manager/overview': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgCertManagerOverviewRoute
|
||||
'/organization/kms/overview': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgKmsOverviewRoute
|
||||
'/organization/secret-manager/overview': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgSecretManagerOverviewRoute
|
||||
'/secret-manager/$projectId/overview': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewRoute
|
||||
'/organization/admin': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgAdminIndexRoute
|
||||
'/organization/audit-logs': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgAuditLogsIndexRoute
|
||||
'/organization/billing': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgBillingIndexRoute
|
||||
@@ -981,6 +1178,16 @@ export interface FileRoutesByTo {
|
||||
'/organization/identities/$identityId': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgIdentitiesIdentityIdIndexRoute
|
||||
'/organization/memberships/$membershipId': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgMembershipsMembershipIdIndexRoute
|
||||
'/organization/roles/$roleId': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgRolesRoleIdIndexRoute
|
||||
'/secret-manager/$projectId/access': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAccessIndexRoute
|
||||
'/secret-manager/$projectId/allowlist': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAllowlistIndexRoute
|
||||
'/secret-manager/$projectId/approval': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerApprovalIndexRoute
|
||||
'/secret-manager/$projectId/overview': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewIndexRoute
|
||||
'/secret-manager/$projectId/secret-rotation': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretRotationIndexRoute
|
||||
'/secret-manager/$projectId/settings': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSettingsIndexRoute
|
||||
'/secret-manager/$projectId/identities/$identityId': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerIdentitiesIdentityIdIndexRoute
|
||||
'/secret-manager/$projectId/members/$membershipId': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerMembersMembershipIdIndexRoute
|
||||
'/secret-manager/$projectId/roles/$roleSlug': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRolesRoleSlugIndexRoute
|
||||
'/secret-manager/$projectId/secrets/$envSlug': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretsEnvSlugIndexRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
@@ -1014,7 +1221,6 @@ export interface FileRoutesById {
|
||||
'/_authenticate/_ctx-org-details/organization/_layout-org/cert-manager/overview': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgCertManagerOverviewRoute
|
||||
'/_authenticate/_ctx-org-details/organization/_layout-org/kms/overview': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgKmsOverviewRoute
|
||||
'/_authenticate/_ctx-org-details/organization/_layout-org/secret-manager/overview': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgSecretManagerOverviewRoute
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewRoute
|
||||
'/_authenticate/_ctx-org-details/organization/_layout-org/admin/': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgAdminIndexRoute
|
||||
'/_authenticate/_ctx-org-details/organization/_layout-org/audit-logs/': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgAuditLogsIndexRoute
|
||||
'/_authenticate/_ctx-org-details/organization/_layout-org/billing/': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgBillingIndexRoute
|
||||
@@ -1028,6 +1234,16 @@ export interface FileRoutesById {
|
||||
'/_authenticate/_ctx-org-details/organization/_layout-org/identities/$identityId/': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgIdentitiesIdentityIdIndexRoute
|
||||
'/_authenticate/_ctx-org-details/organization/_layout-org/memberships/$membershipId/': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgMembershipsMembershipIdIndexRoute
|
||||
'/_authenticate/_ctx-org-details/organization/_layout-org/roles/$roleId/': typeof AuthenticateCtxOrgDetailsOrganizationLayoutOrgRolesRoleIdIndexRoute
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAccessIndexRoute
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/allowlist/': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerAllowlistIndexRoute
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/approval/': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerApprovalIndexRoute
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview/': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerOverviewIndexRoute
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secret-rotation/': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretRotationIndexRoute
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/settings/': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSettingsIndexRoute
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/identities/$identityId/': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerIdentitiesIdentityIdIndexRoute
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/members/$membershipId/': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerMembersMembershipIdIndexRoute
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/roles/$roleSlug/': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerRolesRoleSlugIndexRoute
|
||||
'/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secrets/$envSlug/': typeof AuthenticateCtxOrgDetailsSecretManagerProjectIdLayoutSecretManagerSecretsEnvSlugIndexRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
@@ -1057,7 +1273,6 @@ export interface FileRouteTypes {
|
||||
| '/organization/cert-manager/overview'
|
||||
| '/organization/kms/overview'
|
||||
| '/organization/secret-manager/overview'
|
||||
| '/secret-manager/$projectId/overview'
|
||||
| '/organization/admin'
|
||||
| '/organization/audit-logs'
|
||||
| '/organization/billing'
|
||||
@@ -1071,6 +1286,16 @@ export interface FileRouteTypes {
|
||||
| '/organization/identities/$identityId'
|
||||
| '/organization/memberships/$membershipId'
|
||||
| '/organization/roles/$roleId'
|
||||
| '/secret-manager/$projectId/access'
|
||||
| '/secret-manager/$projectId/allowlist'
|
||||
| '/secret-manager/$projectId/approval'
|
||||
| '/secret-manager/$projectId/overview'
|
||||
| '/secret-manager/$projectId/secret-rotation'
|
||||
| '/secret-manager/$projectId/settings'
|
||||
| '/secret-manager/$projectId/identities/$identityId'
|
||||
| '/secret-manager/$projectId/members/$membershipId'
|
||||
| '/secret-manager/$projectId/roles/$roleSlug'
|
||||
| '/secret-manager/$projectId/secrets/$envSlug'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
@@ -1095,7 +1320,6 @@ export interface FileRouteTypes {
|
||||
| '/organization/cert-manager/overview'
|
||||
| '/organization/kms/overview'
|
||||
| '/organization/secret-manager/overview'
|
||||
| '/secret-manager/$projectId/overview'
|
||||
| '/organization/admin'
|
||||
| '/organization/audit-logs'
|
||||
| '/organization/billing'
|
||||
@@ -1109,6 +1333,16 @@ export interface FileRouteTypes {
|
||||
| '/organization/identities/$identityId'
|
||||
| '/organization/memberships/$membershipId'
|
||||
| '/organization/roles/$roleId'
|
||||
| '/secret-manager/$projectId/access'
|
||||
| '/secret-manager/$projectId/allowlist'
|
||||
| '/secret-manager/$projectId/approval'
|
||||
| '/secret-manager/$projectId/overview'
|
||||
| '/secret-manager/$projectId/secret-rotation'
|
||||
| '/secret-manager/$projectId/settings'
|
||||
| '/secret-manager/$projectId/identities/$identityId'
|
||||
| '/secret-manager/$projectId/members/$membershipId'
|
||||
| '/secret-manager/$projectId/roles/$roleSlug'
|
||||
| '/secret-manager/$projectId/secrets/$envSlug'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -1140,7 +1374,6 @@ export interface FileRouteTypes {
|
||||
| '/_authenticate/_ctx-org-details/organization/_layout-org/cert-manager/overview'
|
||||
| '/_authenticate/_ctx-org-details/organization/_layout-org/kms/overview'
|
||||
| '/_authenticate/_ctx-org-details/organization/_layout-org/secret-manager/overview'
|
||||
| '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview'
|
||||
| '/_authenticate/_ctx-org-details/organization/_layout-org/admin/'
|
||||
| '/_authenticate/_ctx-org-details/organization/_layout-org/audit-logs/'
|
||||
| '/_authenticate/_ctx-org-details/organization/_layout-org/billing/'
|
||||
@@ -1154,6 +1387,16 @@ export interface FileRouteTypes {
|
||||
| '/_authenticate/_ctx-org-details/organization/_layout-org/identities/$identityId/'
|
||||
| '/_authenticate/_ctx-org-details/organization/_layout-org/memberships/$membershipId/'
|
||||
| '/_authenticate/_ctx-org-details/organization/_layout-org/roles/$roleId/'
|
||||
| '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/'
|
||||
| '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/allowlist/'
|
||||
| '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/approval/'
|
||||
| '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview/'
|
||||
| '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secret-rotation/'
|
||||
| '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/settings/'
|
||||
| '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/identities/$identityId/'
|
||||
| '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/members/$membershipId/'
|
||||
| '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/roles/$roleSlug/'
|
||||
| '/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secrets/$envSlug/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
@@ -1336,7 +1579,16 @@ export const routeTree = rootRoute
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId",
|
||||
"children": [
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview"
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/",
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/allowlist/",
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/approval/",
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview/",
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secret-rotation/",
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/settings/",
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/identities/$identityId/",
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/members/$membershipId/",
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/roles/$roleSlug/",
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secrets/$envSlug/"
|
||||
]
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/organization/_layout-org/": {
|
||||
@@ -1355,10 +1607,6 @@ export const routeTree = rootRoute
|
||||
"filePath": "_authenticate/_ctx-org-details/organization/_layout-org/secret-manager/overview.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/organization/_layout-org"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview": {
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/organization/_layout-org/admin/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/organization/_layout-org/admin/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/organization/_layout-org"
|
||||
@@ -1410,6 +1658,46 @@ export const routeTree = rootRoute
|
||||
"/_authenticate/_ctx-org-details/organization/_layout-org/roles/$roleId/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/organization/_layout-org/roles/$roleId/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/organization/_layout-org"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/allowlist/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/approval/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secret-rotation/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/settings/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/settings/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/identities/$identityId/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/members/$membershipId/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/roles/$roleSlug/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager"
|
||||
},
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secrets/$envSlug/": {
|
||||
"filePath": "_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secrets.$envSlug/index.tsx",
|
||||
"parent": "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export const OrgAdminProjects = withPermission(
|
||||
projectId
|
||||
});
|
||||
await navigate({
|
||||
to: `/${type}/$projectId/secrets/overview` as const,
|
||||
to: `/${type}/$projectId/overview` as const,
|
||||
params: {
|
||||
projectId
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import { format } from "date-fns";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { IconButton, Tag, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||
|
||||
export enum TabSections {
|
||||
Member = "members",
|
||||
@@ -59,7 +59,7 @@ export const IdentityProjectRow = ({
|
||||
onClick={() => {
|
||||
if (isAccessible) {
|
||||
navigate({
|
||||
to: `/${project?.type}/${project.id}/members` as const,
|
||||
to: `/${project?.type}/${project.id}/access` as const,
|
||||
search: {
|
||||
selectedTab: TabSections.Identities
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ const MembersPage = () => {
|
||||
};
|
||||
|
||||
const MembersPageQuerySchema = z.object({
|
||||
selectedTab: z.string().catch(TabSections.Member)
|
||||
selectedTab: z.string().catch(TabSections.Member),
|
||||
action: z.string().catch("")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { IconButton, Tag, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { TabSections } from "@app/types/org";
|
||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||
|
||||
type Props = {
|
||||
membership: TWorkspaceUser;
|
||||
@@ -52,7 +52,7 @@ export const UserProjectRow = ({
|
||||
onClick={() => {
|
||||
if (isAccessible) {
|
||||
navigate({
|
||||
to: `/${project.type}/$projectId/members` as const,
|
||||
to: `/${project.type}/$projectId/access` as const,
|
||||
params: {
|
||||
projectId: project.id
|
||||
},
|
||||
|
||||
@@ -98,10 +98,7 @@ export const OrgUserPage = withPermission(
|
||||
|
||||
handlePopUpClose("removeMember");
|
||||
navigate({
|
||||
to: "/organization/$organizationId/members" as const,
|
||||
params: {
|
||||
organizationId: currentOrg.id
|
||||
},
|
||||
to: "/organization/members" as const,
|
||||
search: {
|
||||
selectedTab: TabSections.Member
|
||||
}
|
||||
@@ -127,10 +124,7 @@ export const OrgUserPage = withPermission(
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: "/organization/$organizationId/members" as const,
|
||||
params: {
|
||||
organizationId: currentOrg.id
|
||||
},
|
||||
to: "/organization/members" as const,
|
||||
search: {
|
||||
selectedTab: TabSections.Member
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input } from "@app/components/v2";
|
||||
import { useImportEnvKey } from "@app/hooks/api/migration/mutations";
|
||||
// TODO(rbr): Bring back this later
|
||||
// import { GenericDropzone } from "@app/views/SecretMainPage/components/SecretDropzone/GenericDropzone";
|
||||
|
||||
import { GenericDropzone } from "./GenericDropzone";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
@@ -95,15 +95,12 @@ export const EnvKeyPlatformModal = ({ onClose }: Props) => {
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{
|
||||
// <GenericDropzone
|
||||
// ref={fileUploadRef}
|
||||
// text="Select Env Key export file"
|
||||
// onData={onImportFileDrop}
|
||||
// isSmaller
|
||||
// />
|
||||
}
|
||||
<GenericDropzone
|
||||
ref={fileUploadRef}
|
||||
text="Select Env Key export file"
|
||||
onData={onImportFileDrop}
|
||||
isSmaller
|
||||
/>
|
||||
<div className="mt-6 flex items-center space-x-4">
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
DragEvent,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
accept?: string;
|
||||
onData: (file: File) => void;
|
||||
isSmaller: boolean;
|
||||
text?: string;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const GenericDropzone = forwardRef<HTMLInputElement, Props>(
|
||||
({ onData, isSmaller, text, isDisabled, accept }: Props, ref): JSX.Element => {
|
||||
const [isDragActive, setDragActive] = useToggle();
|
||||
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
|
||||
|
||||
const updateSelectedFileName = () => {
|
||||
if (inputRef.current?.files?.[0]) {
|
||||
setSelectedFileName(inputRef.current.files[0].name);
|
||||
} else {
|
||||
setSelectedFileName(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrag = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive.on();
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive.off();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!e.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setDragActive.off();
|
||||
const file = e.dataTransfer.files[0];
|
||||
onData(file);
|
||||
setSelectedFileName(file.name);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!e.target?.files?.[0]) {
|
||||
return;
|
||||
}
|
||||
onData(e.target.files[0]);
|
||||
updateSelectedFileName();
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
updateSelectedFileName();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={twMerge(
|
||||
"relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 px-2 py-4 text-sm text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100",
|
||||
isDragActive && "opacity-100",
|
||||
!isSmaller && "mx-auto w-full max-w-3xl flex-col space-y-4 py-20"
|
||||
)}
|
||||
>
|
||||
{selectedFileName ? (
|
||||
<p>{selectedFileName}</p>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center space-y-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="">{text}</p>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
disabled={isDisabled}
|
||||
id="fileSelect"
|
||||
type="file"
|
||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||
accept={accept}
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
GenericDropzone.displayName = "GenericDropzone";
|
||||
@@ -6,22 +6,22 @@ import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { GeneralPermissionPolicies } from "@app/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionPolicies";
|
||||
import { NewPermissionRule } from "@app/components/permissions/ProjectRolePermissionsSection/components/NewPermissionRule";
|
||||
import { PermissionEmptyState } from "@app/components/permissions/ProjectRolePermissionsSection/PermissionEmptyState";
|
||||
import {
|
||||
formRolePermission2API,
|
||||
PROJECT_PERMISSION_OBJECT,
|
||||
projectRoleFormSchema,
|
||||
rolePermission2Form
|
||||
} from "@app/components/permissions/ProjectRolePermissionsSection/ProjectRoleModifySection.utils";
|
||||
import { renderConditionalComponents } from "@app/components/permissions/ProjectRolePermissionsSection/RolePermissionsSection";
|
||||
import { Button, FormControl, Input, Modal, ModalContent, ModalTrigger } from "@app/components/v2";
|
||||
import { ProjectPermissionSub } from "@app/context";
|
||||
import { isCustomProjectRole } from "@app/helpers/roles";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { TProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
import { GeneralPermissionPolicies } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies";
|
||||
import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule";
|
||||
import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState";
|
||||
import {
|
||||
formRolePermission2API,
|
||||
PROJECT_PERMISSION_OBJECT,
|
||||
projectRoleFormSchema,
|
||||
rolePermission2Form
|
||||
} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils";
|
||||
import { renderConditionalComponents } from "@app/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection";
|
||||
|
||||
type Props = {
|
||||
projectTemplate: TProjectTemplate;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { GroupsSection } from "./components";
|
||||
|
||||
export const GroupsTab = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-groups"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<GroupsSection />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddGroupToWorkspace,
|
||||
useGetOrganizationGroups,
|
||||
useGetProjectRoles,
|
||||
useListWorkspaceGroups
|
||||
} from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z.object({
|
||||
group: z.object({ id: z.string(), name: z.string() }),
|
||||
role: z.object({ slug: z.string(), name: z.string() })
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["group"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
// TODO: update backend to support adding multiple roles at once
|
||||
|
||||
const Content = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: groups } = useGetOrganizationGroups(orgId);
|
||||
const { data: groupMemberships } = useListWorkspaceGroups(currentWorkspace?.id || "");
|
||||
|
||||
const { data: roles } = useGetProjectRoles(currentWorkspace?.id || "");
|
||||
|
||||
const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace();
|
||||
|
||||
const filteredGroupMembershipOrgs = useMemo(() => {
|
||||
const wsGroupIds = new Map();
|
||||
|
||||
groupMemberships?.forEach((groupMembership) => {
|
||||
wsGroupIds.set(groupMembership.group.id, true);
|
||||
});
|
||||
|
||||
return (groups || []).filter(({ id }) => !wsGroupIds.has(id));
|
||||
}, [groups, groupMemberships]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ group, role }: FormData) => {
|
||||
try {
|
||||
await addGroupToWorkspaceMutateAsync({
|
||||
projectId: currentWorkspace?.id || "",
|
||||
groupId: group.id,
|
||||
role: role.slug || undefined
|
||||
});
|
||||
|
||||
reset();
|
||||
handlePopUpToggle("group", false);
|
||||
|
||||
createNotification({
|
||||
text: "Successfully added group to project",
|
||||
type: "success"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Failed to add group to project",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return filteredGroupMembershipOrgs.length ? (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="group"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl label="Group" errorText={error?.message} isError={Boolean(error)}>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
options={filteredGroupMembershipOrgs}
|
||||
placeholder="Select group..."
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
options={roles}
|
||||
placeholder="Select role..."
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{popUp?.group?.data ? "Update" : "Add"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("group", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="text-sm">
|
||||
All groups in your organization have already been added to this project.
|
||||
</div>
|
||||
<Link to={"/organization/members" as const}>
|
||||
<Button variant="outline_bg">Create a new group</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.group?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("group", isOpen)}
|
||||
>
|
||||
<ModalContent bodyClassName="overflow-visible" title="Add Group to Project">
|
||||
<Content popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetProjectRoles, useUpdateGroupWorkspaceRole } from "@app/hooks/api";
|
||||
import { TGroupMembership } from "@app/hooks/api/groups/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
import { groupBy } from "@app/lib/fn/array";
|
||||
|
||||
const temporaryRoleFormSchema = z.object({
|
||||
temporaryRange: z.string().min(1, "Required")
|
||||
});
|
||||
|
||||
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
|
||||
|
||||
type TTemporaryRoleFormProps = {
|
||||
temporaryConfig?: {
|
||||
isTemporary?: boolean;
|
||||
temporaryAccessEndTime?: string | null;
|
||||
temporaryAccessStartTime?: string | null;
|
||||
temporaryRange?: string | null;
|
||||
};
|
||||
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
|
||||
onRemoveTemporary: () => void;
|
||||
};
|
||||
|
||||
const IdentityTemporaryRoleForm = ({
|
||||
temporaryConfig: defaultValues = {},
|
||||
onSetTemporary,
|
||||
onRemoveTemporary
|
||||
}: TTemporaryRoleFormProps) => {
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
|
||||
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
|
||||
resolver: zodResolver(temporaryRoleFormSchema),
|
||||
values: {
|
||||
temporaryRange: defaultValues.temporaryRange || "1h"
|
||||
}
|
||||
});
|
||||
const isTemporaryFieldValue = defaultValues.isTemporary;
|
||||
const isExpired =
|
||||
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={popUp.setTempRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("setTempRole", isOpen);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<IconButton ariaLabel="role-temp" variant="plain" size="md">
|
||||
<Tooltip content={isExpired ? "Access Expired" : "Grant Temporary Access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
isTemporaryFieldValue && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Set Role Temporarily
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={control}
|
||||
name="temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Validity"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
helperText={
|
||||
<span>
|
||||
1m, 2h, 3d.{" "}
|
||||
<a
|
||||
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-700"
|
||||
>
|
||||
More
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isTemporaryFieldValue && (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime: new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})();
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{!isTemporaryFieldValue ? (
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
onClick={() =>
|
||||
handleSubmit(({ temporaryRange }) => {
|
||||
onSetTemporary({
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime:
|
||||
defaultValues.temporaryAccessStartTime || new Date().toISOString()
|
||||
});
|
||||
handlePopUpToggle("setTempRole");
|
||||
})()
|
||||
}
|
||||
>
|
||||
Grant access
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
onRemoveTemporary();
|
||||
handlePopUpToggle("setTempRole");
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const formSchema = z.record(
|
||||
z.object({
|
||||
isChecked: z.boolean().optional(),
|
||||
temporaryAccess: z.union([
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.boolean()
|
||||
])
|
||||
})
|
||||
);
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
export type TMemberRolesProp = {
|
||||
disableEdit?: boolean;
|
||||
groupId: string;
|
||||
roles: TGroupMembership["roles"];
|
||||
};
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
|
||||
export const GroupRoles = ({ roles = [], disableEdit = false, groupId }: TMemberRolesProp) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isSubmitting, isDirty }
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(
|
||||
currentWorkspace?.id ?? ""
|
||||
);
|
||||
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
|
||||
|
||||
const updateGroupWorkspaceRole = useUpdateGroupWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TForm) => {
|
||||
const selectedRoles = Object.keys(data)
|
||||
.filter((el) => Boolean(data[el].isChecked))
|
||||
.map((el) => {
|
||||
const isTemporary = Boolean(data[el].temporaryAccess);
|
||||
if (!isTemporary) {
|
||||
return { role: el, isTemporary: false as const };
|
||||
}
|
||||
|
||||
const tempCfg = data[el].temporaryAccess as {
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
};
|
||||
|
||||
return {
|
||||
role: el,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: tempCfg.temporaryRange,
|
||||
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await updateGroupWorkspaceRole.mutateAsync({
|
||||
projectId: currentWorkspace?.id || "",
|
||||
groupId,
|
||||
roles: selectedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated group role", type: "success" });
|
||||
handlePopUpToggle("editRole");
|
||||
setSearchRoles("");
|
||||
} catch {
|
||||
createNotification({ text: "Failed to update group role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
return role;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip content={isExpired ? "Expired Temporary Access" : "Temporary Access"}>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={isExpired ? "Expired Temporary Access" : "Temporary Access"}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() > new Date(temporaryAccessEndTime as string) &&
|
||||
"text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
})}{" "}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<div>
|
||||
<Popover
|
||||
open={popUp.editRole.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("editRole", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
{!disableEdit && (
|
||||
<PopoverTrigger>
|
||||
<IconButton size="sm" variant="plain" ariaLabel="update">
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
<PopoverContent hideCloseBtn className="pt-4">
|
||||
{isRolesLoading ? (
|
||||
<div className="flex h-8 w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
|
||||
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
|
||||
{projectRoles
|
||||
?.filter(
|
||||
({ name, slug }) =>
|
||||
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
|
||||
slug.toLowerCase().includes(searchRoles.toLowerCase())
|
||||
)
|
||||
?.map(({ id, name, slug }) => {
|
||||
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-4">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue={Boolean(userProjectRoleDetails?.id)}
|
||||
name={`${slug}.isChecked`}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
id={slug}
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => {
|
||||
field.onChange(isChecked);
|
||||
setValue(`${slug}.temporaryAccess`, false);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`${slug}.temporaryAccess`}
|
||||
defaultValue={
|
||||
userProjectRoleDetails?.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime:
|
||||
userProjectRoleDetails.temporaryAccessStartTime as string,
|
||||
temporaryRange:
|
||||
userProjectRoleDetails.temporaryRange as string,
|
||||
temporaryAccessEndTime:
|
||||
userProjectRoleDetails.temporaryAccessEndTime
|
||||
}
|
||||
: false
|
||||
}
|
||||
render={({ field }) => (
|
||||
<IdentityTemporaryRoleForm
|
||||
temporaryConfig={
|
||||
typeof field.value === "boolean"
|
||||
? { isTemporary: field.value }
|
||||
: field.value
|
||||
}
|
||||
onSetTemporary={(data) => {
|
||||
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
|
||||
field.onChange({ isTemporary: true, ...data });
|
||||
}}
|
||||
onRemoveTemporary={() => {
|
||||
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
|
||||
field.onChange(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
|
||||
<div>
|
||||
<Input
|
||||
className="w-full p-1.5 pl-8"
|
||||
size="xs"
|
||||
value={searchRoles}
|
||||
onChange={(el) => setSearchRoles(el.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
placeholder="Search roles.."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
size="xs"
|
||||
type="submit"
|
||||
form="role-update-form"
|
||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
||||
isDisabled={!isDirty || isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteGroupFromWorkspace } from "@app/hooks/api";
|
||||
|
||||
import { GroupModal } from "./GroupModal";
|
||||
import { GroupTable } from "./GroupsTable";
|
||||
|
||||
export const GroupsSection = () => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteGroupFromWorkspace();
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"group",
|
||||
"deleteGroup",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const handleAddGroupModal = () => {
|
||||
if (!subscription?.groups) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description:
|
||||
"You can manage users more efficiently with groups if you upgrade your Infisical plan."
|
||||
});
|
||||
} else {
|
||||
handlePopUpOpen("group");
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoveGroupSubmit = async (groupId: string) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
groupId,
|
||||
projectId: currentWorkspace?.id || ""
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed identity from project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteGroup");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to remove group from project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">User Groups</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Groups}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handleAddGroupModal()}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Group
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<GroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<GroupTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteGroup.isOpen}
|
||||
title={`Are you sure want to remove the group ${
|
||||
(popUp?.deleteGroup?.data as { name: string })?.name || ""
|
||||
} from the project?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onRemoveGroupSubmit((popUp?.deleteGroup?.data as { id: string })?.id)
|
||||
}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faTrash,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useListWorkspaceGroups } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { GroupRoles } from "./GroupRoles";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteGroup", "group"]>,
|
||||
data?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
enum GroupsOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export const GroupTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
orderBy,
|
||||
toggleOrderDirection
|
||||
} = usePagination(GroupsOrderBy.Name, { initPerPage: 20 });
|
||||
|
||||
const { data: groupMemberships = [], isPending } = useListWorkspaceGroups(
|
||||
currentWorkspace?.id || ""
|
||||
);
|
||||
|
||||
const filteredGroupMemberships = useMemo(() => {
|
||||
const filtered = search
|
||||
? groupMemberships?.filter(
|
||||
({ group: { name, slug } }) =>
|
||||
name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
slug.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: groupMemberships;
|
||||
|
||||
const ordered = filtered?.sort((a, b) =>
|
||||
a.group.name.toLowerCase().localeCompare(b.group.name.toLowerCase())
|
||||
);
|
||||
|
||||
return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse();
|
||||
}, [search, groupMemberships, orderBy, orderDirection]);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredGroupMemberships.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Added on</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="project-groups" />}
|
||||
{!isPending &&
|
||||
filteredGroupMemberships &&
|
||||
filteredGroupMemberships.length > 0 &&
|
||||
filteredGroupMemberships
|
||||
.slice(offset, perPage * page)
|
||||
.map(({ group: { id, name }, roles, createdAt }) => {
|
||||
return (
|
||||
<Tr className="group h-10" key={`st-v3-${id}`}>
|
||||
<Td>{name}</Td>
|
||||
<Td>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<GroupRoles roles={roles} disableEdit={!isAllowed} groupId={id} />
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||
<Td className="flex justify-end">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<Tooltip content="Remove">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
handlePopUpOpen("deleteGroup", {
|
||||
id,
|
||||
name
|
||||
});
|
||||
}}
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGroupMemberships.length) && (
|
||||
<Pagination
|
||||
count={filteredGroupMemberships.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredGroupMemberships?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
groupMemberships.length
|
||||
? "No project groups match search..."
|
||||
: "No project groups found"
|
||||
}
|
||||
icon={groupMemberships.length ? faSearch : faUsers}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupsSection } from "./GroupsSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupsSection } from "./GroupsSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupsTab } from "./GroupsTab";
|
||||
@@ -0,0 +1,443 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faArrowUpRightFromSquare,
|
||||
faClock,
|
||||
faEllipsisV,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faServer,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { format } from "date-fns";
|
||||
import { motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Spinner,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectIdentityOrderBy } from "@app/hooks/api/workspace/types";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityModal } from "./components/IdentityModal";
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
if (role === ProjectMembershipRole.NoAccess) return "No access";
|
||||
return role;
|
||||
};
|
||||
export const IdentityTab = withProjectPermission(
|
||||
() => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
offset,
|
||||
limit,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
orderDirection,
|
||||
setOrderDirection,
|
||||
search,
|
||||
debouncedSearch,
|
||||
setPage,
|
||||
setSearch,
|
||||
perPage,
|
||||
page,
|
||||
setPerPage
|
||||
} = usePagination(ProjectIdentityOrderBy.Name);
|
||||
|
||||
const workspaceId = currentWorkspace?.id ?? "";
|
||||
|
||||
const { data, isPending, isFetching } = useGetWorkspaceIdentityMemberships(
|
||||
{
|
||||
workspaceId: currentWorkspace?.id || "",
|
||||
offset,
|
||||
limit,
|
||||
orderDirection,
|
||||
orderBy,
|
||||
search: debouncedSearch
|
||||
},
|
||||
{ placeholderData: (prevData) => prevData }
|
||||
);
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"identity",
|
||||
"deleteIdentity",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const onRemoveIdentitySubmit = async (identityId: string) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
identityId,
|
||||
workspaceId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully removed identity from project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteIdentity");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (column: ProjectIdentityOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
setOrderDirection((prev) =>
|
||||
prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="identity-role-panel"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
|
||||
<div className="flex w-full justify-end pr-4">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://infisical.com/docs/documentation/platform/identities/overview"
|
||||
>
|
||||
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
|
||||
Documentation{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.06rem] ml-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("identity")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Identity
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<Input
|
||||
containerClassName="mb-4"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search identities by name..."
|
||||
/>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr className="h-14">
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${
|
||||
orderBy === ProjectIdentityOrderBy.Name ? "" : "opacity-30"
|
||||
}`}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(ProjectIdentityOrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
orderDirection === OrderByDirection.DESC &&
|
||||
orderBy === ProjectIdentityOrderBy.Name
|
||||
? faArrowUp
|
||||
: faArrowDown
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/3">Role</Th>
|
||||
<Th>Added on</Th>
|
||||
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={4} innerKey="project-identities" />}
|
||||
{!isPending &&
|
||||
data &&
|
||||
data.identityMemberships.length > 0 &&
|
||||
data.identityMemberships.map((identityMember) => {
|
||||
const {
|
||||
identity: { id, name },
|
||||
roles,
|
||||
createdAt
|
||||
} = identityMember;
|
||||
return (
|
||||
<Tr
|
||||
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
key={`st-v3-${id}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
navigate({
|
||||
to: `/${currentWorkspace?.type}/$projectId/identities/$identityId` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
identityId: id
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: `/${currentWorkspace?.type}/$projectId/identities/$identityId` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
identityId: id
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Td>{name}</Td>
|
||||
|
||||
<Td>
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({
|
||||
role,
|
||||
customRoleName,
|
||||
id: roleId,
|
||||
isTemporary,
|
||||
temporaryAccessEndTime
|
||||
}) => {
|
||||
const isExpired =
|
||||
new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={roleId}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="capitalize">
|
||||
{formatRoleName(role, customRoleName)}
|
||||
</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired
|
||||
? "Timed role expired"
|
||||
: "Timed role access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({
|
||||
role,
|
||||
customRoleName,
|
||||
id: roleId,
|
||||
isTemporary,
|
||||
temporaryAccessEndTime
|
||||
}) => {
|
||||
const isExpired =
|
||||
new Date() >
|
||||
new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={roleId} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired
|
||||
? "Access expired"
|
||||
: "Temporary access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() >
|
||||
new Date(
|
||||
temporaryAccessEndTime as string
|
||||
) && "text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||
<Td className="flex justify-end space-x-2 opacity-0 duration-300 group-hover:opacity-100">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId: id
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
handlePopUpOpen("deleteIdentity", {
|
||||
identityId: id,
|
||||
name
|
||||
});
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<IconButton ariaLabel="more-icon" variant="plain">
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && data && totalCount > 0 && (
|
||||
<Pagination
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
/>
|
||||
)}
|
||||
{!isPending && data && data?.identityMemberships.length === 0 && (
|
||||
<EmptyState
|
||||
title={
|
||||
debouncedSearch.trim().length > 0
|
||||
? "No identities match search filter"
|
||||
: "No identities have been added to this project"
|
||||
}
|
||||
icon={faServer}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteIdentity.isOpen}
|
||||
title={`Are you sure want to remove ${
|
||||
(popUp?.deleteIdentity?.data as { name: string })?.name || ""
|
||||
} from the project?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onRemoveIdentitySubmit(
|
||||
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
|
||||
);
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
Spinner
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddIdentityToWorkspace,
|
||||
useGetIdentityMembershipOrgs,
|
||||
useGetProjectRoles,
|
||||
useGetWorkspaceIdentityMemberships
|
||||
} from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z.object({
|
||||
identity: z.object({ name: z.string(), id: z.string() }),
|
||||
role: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["identity"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
const Content = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const organizationId = currentOrg?.id || "";
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: identityMembershipOrgsData, isPending: isMembershipsLoading } =
|
||||
useGetIdentityMembershipOrgs({
|
||||
organizationId,
|
||||
limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR
|
||||
});
|
||||
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships;
|
||||
const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({
|
||||
workspaceId,
|
||||
limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will optimize in PR referenced above
|
||||
});
|
||||
const identityMemberships = identityMembershipsData?.identityMemberships;
|
||||
|
||||
const { data: roles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
|
||||
const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace();
|
||||
|
||||
const filteredIdentityMembershipOrgs = useMemo(() => {
|
||||
const wsIdentityIds = new Map();
|
||||
|
||||
identityMemberships?.forEach((identityMembership) => {
|
||||
wsIdentityIds.set(identityMembership.identity.id, true);
|
||||
});
|
||||
|
||||
return (identityMembershipOrgs || []).filter(({ identity: i }) => !wsIdentityIds.has(i.id));
|
||||
}, [identityMembershipOrgs, identityMemberships]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ identity, role }: FormData) => {
|
||||
try {
|
||||
await addIdentityToWorkspaceMutateAsync({
|
||||
workspaceId,
|
||||
identityId: identity.id,
|
||||
role: role.slug || undefined
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully added identity to project",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
const nextAvailableMembership = filteredIdentityMembershipOrgs.filter(
|
||||
(membership) => membership.identity.id !== identity.id
|
||||
)[0];
|
||||
|
||||
// prevents combobox from displaying previously added identity
|
||||
reset({
|
||||
identity: {
|
||||
name: nextAvailableMembership?.identity.name,
|
||||
id: nextAvailableMembership?.identity.id
|
||||
}
|
||||
});
|
||||
handlePopUpToggle("identity", false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to add identity to project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isMembershipsLoading || isRolesLoading)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center py-10">
|
||||
<Spinner className="text-mineshaft-400" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return filteredIdentityMembershipOrgs.length ? (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="identity"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select identity..."
|
||||
options={filteredIdentityMembershipOrgs.map((membership) => membership.identity)}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={roles}
|
||||
placeholder="Select role..."
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{popUp?.identity?.data ? "Update" : "Add"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="text-sm">
|
||||
All identities in your organization have already been added to this project.
|
||||
</div>
|
||||
<Link to={"/organization/members" as const}>
|
||||
<Button isDisabled={isRolesLoading} isLoading={isRolesLoading} variant="outline_bg">
|
||||
Create a new identity
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.identity?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("identity", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Add Identity to Project" bodyClassName="overflow-visible">
|
||||
<Content popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { IdentityTab } from "./IdentityTab";
|
||||
@@ -0,0 +1,17 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { MembersSection } from "./components";
|
||||
|
||||
export const MembersTab = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-project-members"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<MembersSection />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddUsersToOrg,
|
||||
useGetOrgUsers,
|
||||
useGetProjectRoles,
|
||||
useGetWorkspaceUsers
|
||||
} from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const addMemberFormSchema = z.object({
|
||||
orgMemberships: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).min(1),
|
||||
projectRoleSlugs: z.array(z.object({ slug: z.string().trim(), name: z.string().trim() })).min(1)
|
||||
});
|
||||
|
||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["addMember"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const orgId = currentOrg?.id || "";
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
||||
const { data: orgUsers } = useGetOrgUsers(orgId);
|
||||
|
||||
const { data: roles } = useGetProjectRoles(currentWorkspace?.id || "");
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<TAddMemberForm>({
|
||||
resolver: zodResolver(addMemberFormSchema),
|
||||
defaultValues: { orgMemberships: [], projectRoleSlugs: [] }
|
||||
});
|
||||
|
||||
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
|
||||
|
||||
const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => {
|
||||
if (!currentWorkspace) return;
|
||||
if (!currentOrg?.id) return;
|
||||
|
||||
const selectedMembers = orgMemberships.map((orgMembership) =>
|
||||
orgUsers?.find((orgUser) => orgUser.id === orgMembership.value)
|
||||
);
|
||||
|
||||
if (!selectedMembers) return;
|
||||
|
||||
try {
|
||||
if (currentWorkspace.version === ProjectVersion.V1) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Please upgrade your project to invite new members to the project."
|
||||
});
|
||||
} else {
|
||||
const inviteeEmails = selectedMembers
|
||||
.map((member) => member?.user.username as string)
|
||||
.filter(Boolean);
|
||||
if (inviteeEmails.length) {
|
||||
await addMembersToProject({
|
||||
inviteeEmails,
|
||||
organizationId: orgId,
|
||||
organizationRoleSlug: ProjectMembershipRole.Member, // ? This doesn't apply in this case, because we know the users being added are already part of the organization
|
||||
projects: [
|
||||
{
|
||||
slug: currentWorkspace.slug,
|
||||
id: currentWorkspace.id,
|
||||
projectRoleSlug: projectRoleSlugs.map((role) => role.slug)
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
createNotification({
|
||||
text: "Successfully added user to the project",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to add user to project",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
handlePopUpToggle("addMember", false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const filteredOrgUsers = useMemo(() => {
|
||||
const wsUserUsernames = new Map();
|
||||
members?.forEach((member) => {
|
||||
wsUserUsernames.set(member.user.username, true);
|
||||
});
|
||||
return (orgUsers || [])
|
||||
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
|
||||
.map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
|
||||
value: id,
|
||||
label:
|
||||
firstName && lastName
|
||||
? `${firstName} ${lastName}`
|
||||
: firstName || lastName || email || inviteEmail
|
||||
}));
|
||||
}, [orgUsers, members]);
|
||||
|
||||
const selectedOrgMemberships = watch("orgMemberships");
|
||||
const selectedRoleSlugs = watch("projectRoleSlugs");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.addMember?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addMember", isOpen)}
|
||||
>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title={t("section.members.add-dialog.add-member-to-project") as string}
|
||||
subTitle={t("section.members.add-dialog.user-will-email")}
|
||||
>
|
||||
{filteredOrgUsers.length ? (
|
||||
<form onSubmit={handleSubmit(onAddMembers)}>
|
||||
<div className="flex w-full flex-col items-start gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="orgMemberships"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
className="w-full"
|
||||
isError={!!errors.orgMemberships?.length}
|
||||
errorText={errors.orgMemberships?.[0]?.message}
|
||||
label="Invite users to project"
|
||||
>
|
||||
<FilterableSelect
|
||||
className="w-full"
|
||||
placeholder="Add one or more users..."
|
||||
isMulti
|
||||
name="members"
|
||||
options={filteredOrgUsers}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectRoleSlugs"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="w-full"
|
||||
label="Select roles"
|
||||
tooltipText="Select the roles that you wish to assign to the users"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<FilterableSelect
|
||||
options={roles}
|
||||
placeholder="Select roles..."
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isMulti
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={
|
||||
isSubmitting ||
|
||||
selectedOrgMemberships.length === 0 ||
|
||||
selectedRoleSlugs.length === 0
|
||||
}
|
||||
>
|
||||
Add Members
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("addMember", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div>All the users in your organization are already invited.</div>
|
||||
<Link to={"/organization/members" as const}>
|
||||
<Button variant="outline_bg">Add users to organization</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
|
||||
const roleFormSchema = z.object({
|
||||
roles: z
|
||||
.object({
|
||||
slug: z.string(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
})
|
||||
.array()
|
||||
});
|
||||
type TRoleForm = z.infer<typeof roleFormSchema>;
|
||||
|
||||
type Props = {
|
||||
projectMember: TWorkspaceUser;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Member
|
||||
);
|
||||
|
||||
const roleForm = useForm<TRoleForm>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
values: {
|
||||
roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
|
||||
slug: customRoleSlug || role,
|
||||
temporaryAccess: dto.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryRange: dto.temporaryRange,
|
||||
temporaryAccessEndTime: dto.temporaryAccessEndTime,
|
||||
temporaryAccessStartTime: dto.temporaryAccessStartTime
|
||||
}
|
||||
: {
|
||||
isTemporary: dto.isTemporary
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
const selectedRoleList = useFieldArray({
|
||||
name: "roles",
|
||||
control: roleForm.control
|
||||
});
|
||||
|
||||
const formRoleField = roleForm.watch("roles");
|
||||
|
||||
const updateMembershipRole = useUpdateUserWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TRoleForm) => {
|
||||
if (updateMembershipRole.isPending) return;
|
||||
|
||||
const sanitizedRoles = data.roles.map((el) => {
|
||||
const { isTemporary } = el.temporaryAccess;
|
||||
if (!isTemporary) {
|
||||
return { role: el.slug, isTemporary: false as const };
|
||||
}
|
||||
return {
|
||||
role: el.slug,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
const hasCustomRoleSelected = sanitizedRoles.some(
|
||||
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
|
||||
);
|
||||
|
||||
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
|
||||
onOpenUpgradeModal(
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMembershipRole.mutateAsync({
|
||||
workspaceId,
|
||||
membershipId: projectMember.id,
|
||||
roles: sanitizedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated roles", type: "success" });
|
||||
roleForm.reset(undefined, { keepValues: true });
|
||||
} catch {
|
||||
createNotification({ text: "Failed to update role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (isRolesLoading)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-lg font-medium">Roles</div>
|
||||
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
|
||||
<div>
|
||||
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{selectedRoleList.fields.map(({ id }, index) => {
|
||||
const { temporaryAccess } = formRoleField[index];
|
||||
const isTemporary = temporaryAccess?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccess.isTemporary &&
|
||||
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
name={`roles.${index}.slug`}
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500"
|
||||
>
|
||||
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
|
||||
<SelectItem value={slug} key={projectRoleId}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled} asChild>
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Timed Access Expired"
|
||||
: `Until ${format(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`
|
||||
: "Non expiry access"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Access Expired"
|
||||
: formatDistance(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
)
|
||||
: "Permanent"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
defaultValue="1h"
|
||||
name={`roles.${index}.temporaryAccess.temporaryRange`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = roleForm.getValues(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
roleForm.setError(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`,
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
|
||||
roleForm.setValue(
|
||||
`roles.${index}.temporaryAccess`,
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccess.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
roleForm.setValue(`roles.${index}.temporaryAccess`, {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-role"
|
||||
isDisabled={isMemberEditDisabled || selectedRoleList.fields.length === 1}
|
||||
onClick={() => {
|
||||
if (selectedRoleList.fields.length > 1) {
|
||||
selectedRoleList.remove(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
isDisabled={!isAllowed}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
selectedRoleList.append({
|
||||
slug: ProjectMembershipRole.Member,
|
||||
temporaryAccess: { isTemporary: false }
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Button
|
||||
type="submit"
|
||||
className={twMerge(
|
||||
"transition-all",
|
||||
"cursor-default opacity-0",
|
||||
roleForm.formState.isDirty && "cursor-pointer opacity-100"
|
||||
)}
|
||||
isDisabled={!roleForm.formState.isDirty}
|
||||
isLoading={roleForm.formState.isSubmitting}
|
||||
>
|
||||
Save Roles
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
import { Alert, AlertDescription } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
|
||||
import { MemberRbacSection } from "./MemberRbacSection";
|
||||
|
||||
type Props = {
|
||||
projectMember: TWorkspaceUser;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const MemberRoleForm = ({ projectMember, onOpenUpgradeModal }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
return (
|
||||
<div>
|
||||
<MemberRbacSection projectMember={projectMember} onOpenUpgradeModal={onOpenUpgradeModal} />
|
||||
<Alert
|
||||
title="Additional privileges have been moved and now offer full permission customization."
|
||||
className="mt-4 border-primary/50 bg-primary/10"
|
||||
>
|
||||
<AlertDescription>
|
||||
<Link
|
||||
to={`/${currentWorkspace.type}/$projectId/members/$membershipId` as const}
|
||||
params={{
|
||||
projectId: currentWorkspace.id,
|
||||
membershipId: projectMember.id
|
||||
}}
|
||||
>
|
||||
<span className="cursor-pointer text-primary underline underline-offset-2">
|
||||
Click here to access them now
|
||||
</span>
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,572 @@
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
faArrowRotateLeft,
|
||||
faCaretDown,
|
||||
faCheck,
|
||||
faClock,
|
||||
faLockOpen,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DeleteActionModal,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { removeTrailingSlash } from "@app/helpers/string";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
TProjectUserPrivilege,
|
||||
useCreateAccessRequest,
|
||||
useDeleteProjectUserAdditionalPrivilege
|
||||
} from "@app/hooks/api";
|
||||
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
|
||||
|
||||
const secretPermissionSchema = z.object({
|
||||
secretPath: z.string().optional(),
|
||||
environmentSlug: z.string(),
|
||||
[ProjectPermissionActions.Edit]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Create]: z.boolean().optional(),
|
||||
[ProjectPermissionActions.Delete]: z.boolean().optional(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
});
|
||||
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
|
||||
export const SpecificPrivilegeSecretForm = ({
|
||||
privilege,
|
||||
policies,
|
||||
onClose
|
||||
}: {
|
||||
privilege?: TProjectUserPrivilege;
|
||||
policies?: TAccessApprovalPolicy[];
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"deletePrivilege",
|
||||
"requestAccess"
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled =
|
||||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege;
|
||||
|
||||
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
|
||||
const requestAccess = useCreateAccessRequest();
|
||||
|
||||
const privilegeForm = useForm<TSecretPermissionForm>({
|
||||
resolver: zodResolver(secretPermissionSchema),
|
||||
values: {
|
||||
...(privilege
|
||||
? {
|
||||
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
|
||||
// secret path will be inside $glob operator
|
||||
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob
|
||||
? removeTrailingSlash(privilege.permissions?.[0]?.conditions?.secretPath?.$glob)
|
||||
: "",
|
||||
read: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Read)
|
||||
),
|
||||
edit: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Edit)
|
||||
),
|
||||
create: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Create)
|
||||
),
|
||||
delete: privilege.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Delete)
|
||||
),
|
||||
// zod will pick it
|
||||
temporaryAccess: privilege
|
||||
}
|
||||
: {
|
||||
environmentSlug: currentWorkspace.environments?.[0]?.slug,
|
||||
read: false,
|
||||
edit: false,
|
||||
create: false,
|
||||
delete: false,
|
||||
temporaryAccess: {
|
||||
isTemporary: false
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
|
||||
const selectedEnvironment = privilegeForm.watch("environmentSlug");
|
||||
const secretPath = privilegeForm.watch("secretPath");
|
||||
|
||||
const readAccess = privilegeForm.watch("read");
|
||||
const createAccess = privilegeForm.watch("create");
|
||||
const editAccess = privilegeForm.watch("edit");
|
||||
const deleteAccess = privilegeForm.watch("delete");
|
||||
|
||||
const accessSelected = readAccess || createAccess || editAccess || deleteAccess;
|
||||
|
||||
const selectablePaths = useMemo(() => {
|
||||
if (!policies) return [];
|
||||
const environmentPolicies = policies.filter(
|
||||
(policy) => policy.environment.slug === selectedEnvironment
|
||||
);
|
||||
|
||||
privilegeForm.setValue("secretPath", "", {
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
return [...environmentPolicies.map((policy) => policy.secretPath)];
|
||||
}, [policies, selectedEnvironment]);
|
||||
|
||||
const isTemporary = temporaryAccessField?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccessField.isTemporary &&
|
||||
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
|
||||
|
||||
const handleDeletePrivilege = async () => {
|
||||
if (!privilege) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "No privilege to delete found.",
|
||||
title: "Error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteUserPrivilege.isPending) return;
|
||||
try {
|
||||
await deleteUserPrivilege.mutateAsync({
|
||||
privilegeId: privilege.id,
|
||||
projectMembershipId: privilege.projectMembershipId
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted privilege"
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete privilege"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// This is used for requesting access additional privileges, not directly creating a privilege!
|
||||
const handleRequestAccess = async (data: TSecretPermissionForm) => {
|
||||
if (!policies) return;
|
||||
if (!currentWorkspace) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "No workspace found.",
|
||||
title: "Error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.secretPath) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Please select a secret path",
|
||||
title: "Error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = [
|
||||
{ action: ProjectPermissionActions.Read, allowed: data.read },
|
||||
{ action: ProjectPermissionActions.Create, allowed: data.create },
|
||||
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
|
||||
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
|
||||
];
|
||||
const conditions: Record<string, any> = { environment: data.environmentSlug };
|
||||
if (data.secretPath) {
|
||||
conditions.secretPath = { $glob: data.secretPath };
|
||||
}
|
||||
await requestAccess.mutateAsync({
|
||||
...data,
|
||||
...(data.temporaryAccess.isTemporary && {
|
||||
temporaryRange: data.temporaryAccess.temporaryRange
|
||||
}),
|
||||
projectSlug: currentWorkspace.slug,
|
||||
isTemporary: data.temporaryAccess.isTemporary,
|
||||
permissions: actions
|
||||
.filter(({ allowed }) => allowed)
|
||||
.map(({ action }) => ({
|
||||
action,
|
||||
subject: [ProjectPermissionSub.Secrets],
|
||||
conditions
|
||||
}))
|
||||
});
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully requested access"
|
||||
});
|
||||
privilegeForm.reset();
|
||||
if (onClose) onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: TSecretPermissionForm) => {
|
||||
handleRequestAccess(data);
|
||||
};
|
||||
|
||||
const getAccessLabel = (exactTime = false) => {
|
||||
if (isExpired) return "Access expired";
|
||||
if (!temporaryAccessField?.isTemporary) return "Permanent";
|
||||
|
||||
if (exactTime && !policies) {
|
||||
return `Until ${format(
|
||||
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`;
|
||||
}
|
||||
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 w-full">
|
||||
<form onSubmit={privilegeForm.handleSubmit(handleSubmit)}>
|
||||
<div className={twMerge("flex items-start gap-4", !privilege && "flex-wrap")}>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="environmentSlug"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormControl label="Environment">
|
||||
<Select
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className="bg-mineshaft-600 hover:bg-mineshaft-500"
|
||||
onValueChange={(e) => onChange(e)}
|
||||
>
|
||||
{currentWorkspace?.environments?.map(({ slug, id, name }) => (
|
||||
<SelectItem value={slug} key={id}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="secretPath"
|
||||
render={({ field }) => {
|
||||
if (policies) {
|
||||
return (
|
||||
<Tooltip
|
||||
isDisabled={!!selectablePaths.length}
|
||||
content="The selected environment doesn't have any policies."
|
||||
>
|
||||
<div>
|
||||
<FormControl label="Secret Path">
|
||||
<Select
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled || !selectablePaths.length}
|
||||
className="w-48"
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
>
|
||||
{selectablePaths.map((path) => (
|
||||
<SelectItem value={path} key={path}>
|
||||
{path}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormControl label="Secret Path">
|
||||
<SecretPathInput
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
containerClassName="w-48"
|
||||
environment={selectedEnvironment}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-grow justify-between">
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="read"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="View" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-read"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="create"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Create" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-create"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="edit"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Modify" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-modify"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
name="delete"
|
||||
render={({ field }) => (
|
||||
<div className="flex flex-col items-center">
|
||||
<FormLabel label="Delete" className="mb-4" />
|
||||
<Checkbox
|
||||
isDisabled={isMemberEditDisabled}
|
||||
id="secret-delete"
|
||||
className="h-5 w-5"
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(isChecked) => field.onChange(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||
<div>
|
||||
<Tooltip content={getAccessLabel(true)}>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{getAccessLabel(false)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure timed access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={privilegeForm.control}
|
||||
defaultValue="1h"
|
||||
name="temporaryAccess.temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = privilegeForm.getValues(
|
||||
"temporaryAccess.temporaryRange"
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
privilegeForm.setError(
|
||||
"temporaryAccess.temporaryRange",
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
privilegeForm.clearErrors("temporaryAccess.temporaryRange");
|
||||
privilegeForm.setValue(
|
||||
"temporaryAccess",
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccessField.isTemporary && !policies ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
|
||||
{temporaryAccessField.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
privilegeForm.setValue("temporaryAccess", {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{privilegeForm.formState.isDirty && privilege ? (
|
||||
<>
|
||||
<Tooltip content="Cancel" className="mr-4">
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-2.5 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-privilege"
|
||||
isDisabled={privilegeForm.formState.isSubmitting}
|
||||
onClick={() => privilegeForm.reset()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowRotateLeft} className="py-0.5" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={isMemberEditDisabled ? "Access restricted" : "Save"}
|
||||
className="mr-4"
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className="border-none py-3"
|
||||
ariaLabel="save-privilege"
|
||||
type="submit"
|
||||
>
|
||||
{privilegeForm.formState.isSubmitting ? (
|
||||
<Spinner size="xs" className="m-0 h-3 w-3 text-slate-500" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} className="px-0.5" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : // eslint-disable-next-line no-nested-ternary
|
||||
privilege ? (
|
||||
<Tooltip
|
||||
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
|
||||
className="mr-4"
|
||||
>
|
||||
<IconButton
|
||||
isDisabled={isMemberEditDisabled}
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-privilege"
|
||||
onClick={() => handlePopUpOpen("deletePrivilege")}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!!policies && (
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={privilegeForm.formState.isSubmitting || requestAccess.isPending}
|
||||
isDisabled={
|
||||
isMemberEditDisabled ||
|
||||
!policies.length ||
|
||||
!privilegeForm.formState.isValid ||
|
||||
!secretPath ||
|
||||
!accessSelected
|
||||
}
|
||||
className="mt-4"
|
||||
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
|
||||
>
|
||||
Request access
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePrivilege.isOpen}
|
||||
title="Remove user additional privilege"
|
||||
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
|
||||
deleteKey="delete"
|
||||
onClose={() => handlePopUpClose("deletePrivilege")}
|
||||
onDeleteApproved={handleDeletePrivilege}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { MemberRoleForm } from "./MemberRoleForm";
|
||||
@@ -0,0 +1,91 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useOrganization,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteUserFromWorkspace } from "@app/hooks/api";
|
||||
|
||||
import { AddMemberModal } from "./AddMemberModal";
|
||||
import { MembersTable } from "./MembersTable";
|
||||
|
||||
export const MembersSection = () => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"addMember",
|
||||
"removeMember",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const handleRemoveUser = async () => {
|
||||
const username = (popUp?.removeMember?.data as { username: string })?.username;
|
||||
if (!currentOrg?.id) return;
|
||||
if (!currentWorkspace?.id) return;
|
||||
|
||||
try {
|
||||
await removeUserFromWorkspace({
|
||||
workspaceId: currentWorkspace.id,
|
||||
usernames: [username],
|
||||
orgId: currentOrg.id
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully removed user from project",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to remove user from the project",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
handlePopUpClose("removeMember");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Users</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Member}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addMember")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Member
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<MembersTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<AddMemberModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMember.isOpen}
|
||||
deleteKey="remove"
|
||||
title="Do you want to remove this user from the project?"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
|
||||
onDeleteApproved={handleRemoveUser}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faClock,
|
||||
faEllipsisV,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faTrash,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
EmptyState,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useUser,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetWorkspaceUsers } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
if (role === ProjectMembershipRole.NoAccess) return "No access";
|
||||
return role;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["removeMember", "upgradePlan"]>,
|
||||
data?: object
|
||||
) => void;
|
||||
};
|
||||
|
||||
enum MembersOrderBy {
|
||||
Name = "firstName",
|
||||
Email = "email"
|
||||
}
|
||||
|
||||
export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { user } = useUser();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const userId = user?.id || "";
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
setOrderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, { initPerPage: 20 });
|
||||
|
||||
const { data: members = [], isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
|
||||
|
||||
const filteredUsers = useMemo(
|
||||
() =>
|
||||
members
|
||||
?.filter(
|
||||
({ user: u, inviteEmail }) =>
|
||||
u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.username?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u?.email?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
inviteEmail?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
let valueOne: string;
|
||||
let valueTwo: string;
|
||||
|
||||
switch (orderBy) {
|
||||
case MembersOrderBy.Email:
|
||||
valueOne = memberOne.user.email || memberOne.inviteEmail;
|
||||
valueTwo = memberTwo.user.email || memberTwo.inviteEmail;
|
||||
break;
|
||||
case MembersOrderBy.Name:
|
||||
default:
|
||||
valueOne = memberOne.user.firstName;
|
||||
valueTwo = memberTwo.user.firstName;
|
||||
}
|
||||
|
||||
if (!valueOne) return 1;
|
||||
if (!valueTwo) return -1;
|
||||
|
||||
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
|
||||
}),
|
||||
[members, search, orderDirection, orderBy]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredUsers.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const handleSort = (column: MembersOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search members..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === MembersOrderBy.Name ? "" : "opacity-30"}`}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(MembersOrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Name
|
||||
? faArrowUp
|
||||
: faArrowDown
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Email
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === MembersOrderBy.Email ? "" : "opacity-30"}`}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(MembersOrderBy.Email)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Email
|
||||
? faArrowUp
|
||||
: faArrowDown
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>Role</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
|
||||
{!isMembersLoading &&
|
||||
filteredUsers.slice(offset, perPage * page).map((projectMember) => {
|
||||
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
|
||||
const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-";
|
||||
const email = u?.email || inviteEmail;
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={`membership-${membershipId}`}
|
||||
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
navigate({
|
||||
to: `/${currentWorkspace.type}/$projectId/members/$membershipId` as const,
|
||||
params: {
|
||||
projectId: workspaceId,
|
||||
membershipId
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: `/${currentWorkspace.type}/$projectId/members/$membershipId` as const,
|
||||
params: {
|
||||
projectId: workspaceId,
|
||||
membershipId
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Td>{name}</Td>
|
||||
<Td>{email}</Td>
|
||||
<Td>
|
||||
<div className="flex items-center space-x-2">
|
||||
{roles
|
||||
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||
const isExpired =
|
||||
new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="capitalize">
|
||||
{formatRoleName(role, customRoleName)}
|
||||
</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired ? "Timed role expired" : "Timed role access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(isExpired && "text-red-600")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||
{roles
|
||||
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||
.map(
|
||||
({
|
||||
role,
|
||||
customRoleName,
|
||||
id,
|
||||
isTemporary,
|
||||
temporaryAccessEndTime
|
||||
}) => {
|
||||
const isExpired =
|
||||
new Date() >
|
||||
new Date(temporaryAccessEndTime || ("" as string));
|
||||
return (
|
||||
<Tag key={id} className="capitalize">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>{formatRoleName(role, customRoleName)}</div>
|
||||
{isTemporary && (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={
|
||||
isExpired ? "Access expired" : "Temporary access"
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faClock}
|
||||
className={twMerge(
|
||||
new Date() >
|
||||
new Date(temporaryAccessEndTime as string) &&
|
||||
"text-red-600"
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{userId !== u?.id && (
|
||||
<div className="flex items-center space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Member}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
className="ml-4"
|
||||
isDisabled={userId === u?.id || !isAllowed}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
handlePopUpOpen("removeMember", { username: u.username });
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<IconButton ariaLabel="more-icon" variant="plain">
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredUsers.length) && (
|
||||
<Pagination
|
||||
count={filteredUsers.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isMembersLoading && !filteredUsers?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
members.length ? "No project members match search..." : "No project members found"
|
||||
}
|
||||
icon={members.length ? faSearch : faUsers}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { MembersSection } from "./MembersSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { MembersTab } from "./MembersTab";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
|
||||
import { ProjectRoleList } from "./components/ProjectRoleList";
|
||||
|
||||
export const ProjectRoleListTab = withProjectPermission(
|
||||
() => {
|
||||
return (
|
||||
<motion.div
|
||||
key="role-list"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: -30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<ProjectRoleList />
|
||||
</motion.div>
|
||||
);
|
||||
},
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role }
|
||||
);
|
||||
@@ -0,0 +1,184 @@
|
||||
import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api";
|
||||
import { TProjectRole } from "@app/hooks/api/roles/types";
|
||||
import { RoleModal } from "@app/routes/_authenticate/_ctx-org-details/organization/_layout-org/roles/$roleId/-components";
|
||||
|
||||
export const ProjectRoleList = () => {
|
||||
const navigate = useNavigate();
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"role",
|
||||
"deleteRole"
|
||||
] as const);
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: roles, isPending: isRolesLoading } = useGetProjectRoles(projectId);
|
||||
|
||||
const { mutateAsync: deleteRole } = useDeleteProjectRole();
|
||||
|
||||
const handleRoleDelete = async () => {
|
||||
const { id } = popUp?.deleteRole?.data as TProjectRole;
|
||||
try {
|
||||
await deleteRole({
|
||||
projectId,
|
||||
id
|
||||
});
|
||||
createNotification({ type: "success", text: "Successfully removed the role" });
|
||||
handlePopUpClose("deleteRole");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({ type: "error", text: "Failed to delete role" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Project Roles</p>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("role")}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th aria-label="actions" className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
|
||||
{roles?.map((role) => {
|
||||
const { id, name, slug } = role;
|
||||
const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={`role-list-${id}`}
|
||||
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
roleSlug: slug
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<Td>{name}</Td>
|
||||
<Td>{slug}</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Role}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate({
|
||||
to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
roleSlug: slug
|
||||
}
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
{`${isNonMutatable ? "View" : "Edit"} Role`}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
{!isNonMutatable && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Role}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteRole", role);
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Delete Role
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteRole.isOpen}
|
||||
title={`Are you sure want to delete ${
|
||||
(popUp?.deleteRole?.data as TProjectRole)?.name || " "
|
||||
} role?`}
|
||||
deleteKey={(popUp?.deleteRole?.data as TProjectRole)?.slug || ""}
|
||||
onClose={() => handlePopUpClose("deleteRole")}
|
||||
onDeleteApproved={handleRoleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ProjectRoleList } from "./ProjectRoleList";
|
||||
@@ -0,0 +1 @@
|
||||
export { ProjectRoleListTab } from "./ProjectRoleListTab";
|
||||
@@ -0,0 +1,49 @@
|
||||
// import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
// import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { ServiceTokenSection } from "./components";
|
||||
|
||||
export const ServiceTokenTab = () => {
|
||||
return (
|
||||
<motion.div
|
||||
key="panel-service-token"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* <div className="flex w-full flex-row items-center rounded-md border border-primary-600/70 bg-primary/[.07] p-4 text-base text-white">
|
||||
<FontAwesomeIcon icon={faWarning} className="pr-6 text-4xl text-white/80" />
|
||||
<div className="flex w-full flex-col text-sm">
|
||||
<span className="mb-4 text-lg font-semibold">Deprecation Notice</span>
|
||||
<p>
|
||||
Service Tokens are being deprecated in favor of Machine Identities.
|
||||
<br />
|
||||
They will be removed in the future in accordance with the deprecation notice and
|
||||
timeline stated{" "}
|
||||
<a
|
||||
href="https://infisical.com/blog/deprecating-api-keys"
|
||||
target="_blank"
|
||||
className="font-semibold text-primary-400" rel="noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
<br />
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/identities/overview"
|
||||
target="_blank"
|
||||
className="font-semibold text-primary-400" rel="noreferrer"
|
||||
>
|
||||
Learn more about Machine Identities
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div> */}
|
||||
<ServiceTokenSection />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,380 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faCheck, faCopy, faPlus, faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosError } from "axios";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptSymmetric
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { useCreateServiceToken, useGetUserWsKey } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const apiTokenExpiry = [
|
||||
{ label: "1 Day", value: 86400 },
|
||||
{ label: "7 Days", value: 604800 },
|
||||
{ label: "1 Month", value: 2592000 },
|
||||
{ label: "6 months", value: 15552000 },
|
||||
{ label: "12 months", value: 31104000 },
|
||||
{ label: "Never", value: null }
|
||||
];
|
||||
|
||||
// TODO(rbr): test this form
|
||||
const schema = z.object({
|
||||
name: z.string().max(100),
|
||||
scopes: z
|
||||
.object({
|
||||
environment: z.string().max(50),
|
||||
secretPath: z
|
||||
.string()
|
||||
.default("/")
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
)
|
||||
})
|
||||
.array()
|
||||
.min(1),
|
||||
expiresIn: z.string().optional(),
|
||||
permissions: z
|
||||
.object({
|
||||
read: z.boolean(),
|
||||
write: z.boolean()
|
||||
})
|
||||
.required()
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["createAPIToken"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createAPIToken"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
scopes: [
|
||||
{
|
||||
secretPath: "/",
|
||||
environment: currentWorkspace?.environments?.[0]?.slug
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const { fields: tokenScopes, append, remove } = useFieldArray({ control, name: "scopes" });
|
||||
|
||||
const [newToken, setToken] = useState("");
|
||||
const [isTokenCopied, setIsTokenCopied] = useToggle(false);
|
||||
|
||||
const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?.id ?? "");
|
||||
const createServiceToken = useCreateServiceToken();
|
||||
const hasServiceToken = Boolean(newToken);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isTokenCopied) {
|
||||
timer = setTimeout(() => setIsTokenCopied.off(), 2000);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isTokenCopied]);
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
navigator.clipboard.writeText(newToken);
|
||||
setIsTokenCopied.on();
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ name, scopes, expiresIn, permissions }: FormData) => {
|
||||
try {
|
||||
if (!currentWorkspace?.id) return;
|
||||
if (!latestFileKey) return;
|
||||
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: localStorage.getItem("PRIVATE_KEY") as string
|
||||
});
|
||||
|
||||
const randomBytes = crypto.randomBytes(16).toString("hex");
|
||||
|
||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||
plaintext: key,
|
||||
key: randomBytes
|
||||
});
|
||||
|
||||
const { serviceToken } = await createServiceToken.mutateAsync({
|
||||
encryptedKey: ciphertext,
|
||||
iv,
|
||||
tag,
|
||||
scopes,
|
||||
expiresIn: Number(expiresIn),
|
||||
name,
|
||||
workspaceId: currentWorkspace.id,
|
||||
randomBytes,
|
||||
permissions: Object.entries(permissions)
|
||||
.filter(([, permissionsValue]) => permissionsValue)
|
||||
.map(([permissionsKey]) => permissionsKey)
|
||||
});
|
||||
|
||||
setToken(serviceToken);
|
||||
createNotification({
|
||||
text: "Successfully created a service token",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const axiosError = err as AxiosError;
|
||||
if (axiosError?.response?.status === 401) {
|
||||
createNotification({
|
||||
text: "You do not have access to the selected environment/path",
|
||||
type: "error"
|
||||
});
|
||||
} else {
|
||||
createNotification({
|
||||
text: "Failed to create a service token",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.createAPIToken?.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
handlePopUpToggle("createAPIToken", open);
|
||||
reset();
|
||||
setToken("");
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title={
|
||||
t("section.token.add-dialog.title", {
|
||||
target: currentWorkspace?.name
|
||||
}) as string
|
||||
}
|
||||
subTitle={t("section.token.add-dialog.description") as string}
|
||||
>
|
||||
{!hasServiceToken ? (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={t("section.token.add-dialog.name")}
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="Type your token name" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{tokenScopes.map(({ id }, index) => (
|
||||
<div className="mb-3 flex items-end space-x-2" key={id}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`scopes.${index}.environment`}
|
||||
defaultValue={currentWorkspace?.environments?.[0]?.slug}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0"
|
||||
label={index === 0 ? "Environment" : undefined}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{currentWorkspace?.environments.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`scopes.${index}.secretPath`}
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="mb-0 flex-grow"
|
||||
label={index === 0 ? "Secrets Path" : undefined}
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<IconButton
|
||||
className="p-3"
|
||||
ariaLabel="remove"
|
||||
colorSchema="danger"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} size="sm" />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-4 ml-1">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() =>
|
||||
append({
|
||||
environment: currentWorkspace?.environments?.[0]?.slug || "",
|
||||
secretPath: ""
|
||||
})
|
||||
}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
size="xs"
|
||||
>
|
||||
Add Scope
|
||||
</Button>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresIn"
|
||||
defaultValue={String(apiTokenExpiry?.[0]?.value)}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Expiration" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{apiTokenExpiry.map(({ label, value }) => (
|
||||
<SelectItem value={String(value)} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="permissions"
|
||||
defaultValue={{
|
||||
read: true,
|
||||
write: false
|
||||
}}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => {
|
||||
const options = [
|
||||
{
|
||||
label: "Read (default)",
|
||||
value: "read"
|
||||
},
|
||||
{
|
||||
label: "Write (optional)",
|
||||
value: "write"
|
||||
}
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
label="Permissions"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<>
|
||||
{options.map(({ label, value: optionValue }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
id={String(value[optionValue])}
|
||||
key={optionValue}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
isChecked={value[optionValue]}
|
||||
isDisabled={optionValue === "read"}
|
||||
onCheckedChange={(state) => {
|
||||
onChange({
|
||||
...value,
|
||||
[optionValue]: state
|
||||
});
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="mb-3 mr-2 mt-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
||||
<p className="mr-4 break-all">{newToken}</p>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
colorSchema="secondary"
|
||||
className="group relative"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
|
||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
||||
{t("common.click-to-copy")}
|
||||
</span>
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteServiceToken } from "@app/hooks/api";
|
||||
|
||||
import { AddServiceTokenModal } from "./AddServiceTokenModal";
|
||||
import { ServiceTokenTable } from "./ServiceTokenTable";
|
||||
|
||||
type DeleteModalData = { name: string; id: string };
|
||||
|
||||
export const ServiceTokenSection = withProjectPermission(
|
||||
() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const deleteServiceToken = useDeleteServiceToken();
|
||||
|
||||
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
|
||||
"createAPIToken",
|
||||
"deleteAPITokenConfirmation"
|
||||
] as const);
|
||||
|
||||
const onDeleteApproved = async () => {
|
||||
try {
|
||||
deleteServiceToken.mutateAsync(
|
||||
(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id
|
||||
);
|
||||
createNotification({
|
||||
text: "Successfully deleted service token",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteAPITokenConfirmation");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to delete service token",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-2 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.ServiceTokens}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("createAPIToken");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create token
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<p className="mb-8 text-gray-400">{t("section.token.service-tokens-description")}</p>
|
||||
<ServiceTokenTable handlePopUpOpen={handlePopUpOpen} />
|
||||
<AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteAPITokenConfirmation.isOpen}
|
||||
title={`Delete ${
|
||||
(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name || " "
|
||||
} service token?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteAPITokenConfirmation", isOpen)}
|
||||
deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name}
|
||||
onClose={() => handlePopUpClose("deleteAPITokenConfirmation")}
|
||||
onDeleteApproved={onDeleteApproved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens }
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useGetUserWsServiceTokens } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteAPITokenConfirmation"]>,
|
||||
{
|
||||
name,
|
||||
id
|
||||
}: {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data, isLoading } = useGetUserWsServiceTokens({
|
||||
workspaceID: currentWorkspace?.id || ""
|
||||
});
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Token Name</Th>
|
||||
<Th>Environment - Secret Path</Th>
|
||||
<Th>Valid Until</Th>
|
||||
<Th aria-label="button" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
|
||||
{!isLoading &&
|
||||
data &&
|
||||
data.map((row) => (
|
||||
<Tr key={row.id}>
|
||||
<Td>{row.name}</Td>
|
||||
<Td>
|
||||
<div className="mb-2 flex flex-col flex-wrap space-y-1">
|
||||
{row?.scopes.map(({ secretPath, environment }) => (
|
||||
<div
|
||||
key={`${row.id}-${environment}-${secretPath}`}
|
||||
className="inline-flex items-center space-x-1 rounded-md border border-mineshaft-600 p-1 px-2"
|
||||
>
|
||||
<div className="mr-2 border-r border-mineshaft-600 pr-2">{environment}</div>
|
||||
<FontAwesomeIcon icon={faFolder} size="sm" />
|
||||
<span className="pl-2">{secretPath}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td>
|
||||
<Td>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.ServiceTokens}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteAPITokenConfirmation", {
|
||||
name: row.name,
|
||||
id: row.id
|
||||
})
|
||||
}
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
{!isLoading && data && data?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
|
||||
<EmptyState title="No service tokens found" icon={faKey} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ServiceTokenSection } from "./ServiceTokenSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { ServiceTokenSection } from "./ServiceTokenSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { ServiceTokenTab } from "./ServiceTokenTab";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { GroupsTab } from "./GroupsTab";
|
||||
export { IdentityTab } from "./IdentityTab";
|
||||
export { MembersTab } from "./MembersTab";
|
||||
export { ProjectRoleListTab } from "./ProjectRoleListTab";
|
||||
export { ServiceTokenTab } from "./ServiceTokenTab";
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { getProjectTitle } from "@app/helpers/project";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { ProjectAccessControlTabs } from "@app/types/project";
|
||||
|
||||
import {
|
||||
GroupsTab,
|
||||
IdentityTab,
|
||||
MembersTab,
|
||||
ProjectRoleListTab,
|
||||
ServiceTokenTab
|
||||
} from "./-components";
|
||||
|
||||
const MembersPage = withProjectPermission(
|
||||
() => {
|
||||
const navigate = useNavigate({
|
||||
from: "/secret-manager/$projectId/access"
|
||||
});
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const selectedTab = useSearch({
|
||||
from: "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/",
|
||||
select: (el) => el.selectedTab
|
||||
});
|
||||
|
||||
const updateSelectedTab = (tab: string) => {
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, selectedTab: tab })
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6">
|
||||
<p className="mb-4 mr-4 text-3xl font-semibold text-white">
|
||||
{currentWorkspace?.type ? getProjectTitle(currentWorkspace?.type) : "Project"} Access
|
||||
Control
|
||||
</p>
|
||||
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
|
||||
<TabList>
|
||||
<Tab value={ProjectAccessControlTabs.Member}>Users</Tab>
|
||||
<Tab value={ProjectAccessControlTabs.Groups}>Groups</Tab>
|
||||
<Tab value={ProjectAccessControlTabs.Identities}>
|
||||
<div className="flex items-center">
|
||||
<p>Machine Identities</p>
|
||||
</div>
|
||||
</Tab>
|
||||
{currentWorkspace?.type === ProjectType.SecretManager && (
|
||||
<Tab value={ProjectAccessControlTabs.ServiceTokens}>Service Tokens</Tab>
|
||||
)}
|
||||
<Tab value={ProjectAccessControlTabs.Roles}>Project Roles</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={ProjectAccessControlTabs.Member}>
|
||||
<MembersTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={ProjectAccessControlTabs.Groups}>
|
||||
<GroupsTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={ProjectAccessControlTabs.Identities}>
|
||||
<IdentityTab />
|
||||
</TabPanel>
|
||||
{currentWorkspace?.type === ProjectType.SecretManager && (
|
||||
<TabPanel value={ProjectAccessControlTabs.ServiceTokens}>
|
||||
<ServiceTokenTab />
|
||||
</TabPanel>
|
||||
)}
|
||||
<TabPanel value={ProjectAccessControlTabs.Roles}>
|
||||
<ProjectRoleListTab />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{
|
||||
action: ProjectPermissionActions.Read,
|
||||
subject: ProjectPermissionSub.Member
|
||||
}
|
||||
);
|
||||
|
||||
const WorkspaceMembersRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<MembersPage />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkspaceMembersRouteQuerySchema = z.object({
|
||||
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/"
|
||||
)({
|
||||
component: WorkspaceMembersRoute,
|
||||
validateSearch: zodValidator(WorkspaceMembersRouteQuerySchema)
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useAddTrustedIp, useGetMyIp, useUpdateTrustedIp } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
ipAddress: z.string(),
|
||||
comment: z.string()
|
||||
})
|
||||
.required();
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["trustedIp"]>;
|
||||
handlePopUpClose: (popUpName: keyof UsePopUpState<["trustedIp"]>) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["trustedIp"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const IPAllowlistModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
|
||||
const { data, isLoading } = useGetMyIp();
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const addTrustedIp = useAddTrustedIp();
|
||||
const updateTrustedIp = useUpdateTrustedIp();
|
||||
|
||||
const {
|
||||
control,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const trustedIpData = popUp?.trustedIp?.data as {
|
||||
ipAddress: string;
|
||||
comment: string;
|
||||
prefix: number;
|
||||
};
|
||||
|
||||
if (popUp?.trustedIp?.data) {
|
||||
reset({
|
||||
ipAddress: `${trustedIpData.ipAddress}${
|
||||
trustedIpData.prefix !== undefined ? `/${trustedIpData.prefix}` : ""
|
||||
}`,
|
||||
comment: trustedIpData.comment
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
ipAddress: "",
|
||||
comment: ""
|
||||
});
|
||||
}
|
||||
}, [popUp?.trustedIp?.data]);
|
||||
|
||||
const onIPAllowlistModalSubmit = async ({ ipAddress, comment }: FormData) => {
|
||||
try {
|
||||
if (!currentWorkspace?.id) return;
|
||||
|
||||
if (popUp?.trustedIp?.data) {
|
||||
await updateTrustedIp.mutateAsync({
|
||||
workspaceId: currentWorkspace.id,
|
||||
trustedIpId: (popUp?.trustedIp?.data as { trustedIpId: string })?.trustedIpId,
|
||||
ipAddress,
|
||||
comment,
|
||||
isActive: true
|
||||
});
|
||||
} else {
|
||||
await addTrustedIp.mutateAsync({
|
||||
workspaceId: currentWorkspace.id,
|
||||
ipAddress,
|
||||
comment,
|
||||
isActive: true
|
||||
});
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${popUp?.trustedIp?.data ? "updated" : "added"} trusted IP`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
reset();
|
||||
handlePopUpClose("trustedIp");
|
||||
} catch {
|
||||
createNotification({
|
||||
text: `Failed to ${popUp?.trustedIp?.data ? "update" : "add"} trusted IP`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.trustedIp?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("trustedIp", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title={popUp?.trustedIp?.data ? "Update IP" : "Add IP"}>
|
||||
<form onSubmit={handleSubmit(onIPAllowlistModalSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="ipAddress"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="IPv4/IPv6 Address / CIDR Notation"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="123.456.789.0" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{!isLoading && data && (
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
type="button"
|
||||
onClick={() => setValue("ipAddress", data)}
|
||||
className="mb-8"
|
||||
>
|
||||
Add current IP address
|
||||
</Button>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="comment"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Comment" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="My IP address" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{popUp?.trustedIp?.data ? "Update" : "Add"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpClose("trustedIp")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useDeleteTrustedIp } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IPAllowlistModal } from "./IPAllowlistModal";
|
||||
import { IPAllowlistTable } from "./IPAllowlistTable";
|
||||
|
||||
export const IPAllowlistSection = () => {
|
||||
const { mutateAsync } = useDeleteTrustedIp();
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"trustedIp",
|
||||
"deleteTrustedIp",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const onDeleteTrustedIpSubmit = async (trustedIpId: string) => {
|
||||
try {
|
||||
if (!currentWorkspace?.id) return;
|
||||
|
||||
await mutateAsync({
|
||||
workspaceId: currentWorkspace.id,
|
||||
trustedIpId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully deleted IP access range",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteTrustedIp");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
text: "Failed to delete IP access range",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-8 flex items-center">
|
||||
<h2 className="flex-1 text-xl font-semibold text-white">IP Allowlist</h2>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.IpAllowList}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription?.ipAllowlisting) {
|
||||
handlePopUpOpen("trustedIp");
|
||||
} else {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}
|
||||
}}
|
||||
colorSchema="secondary"
|
||||
isLoading={false}
|
||||
isDisabled={!isAllowed}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
Add IP
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<IPAllowlistTable
|
||||
popUp={popUp}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<IPAllowlistModal
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteTrustedIp.isOpen}
|
||||
title={`Are you sure want to delete ${
|
||||
(popUp?.deleteTrustedIp?.data as { name: string })?.name || " "
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteTrustedIp", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onDeleteTrustedIpSubmit(
|
||||
(popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId
|
||||
)
|
||||
}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { faGlobe, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useGetTrustedIps } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["upgradePlan"]>;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>,
|
||||
data?: {
|
||||
trustedIpId: string;
|
||||
ipAddress?: string;
|
||||
comment?: string;
|
||||
isActive?: boolean;
|
||||
prefix?: number;
|
||||
}
|
||||
) => void;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const IPAllowlistTable = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data, isLoading } = useGetTrustedIps(currentWorkspace?.id ?? "");
|
||||
|
||||
const formatType = (type: string, prefix?: number) => {
|
||||
return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${
|
||||
prefix !== undefined ? "CIDR" : ""
|
||||
}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="flex-1">IP Address / Range</Th>
|
||||
<Th className="flex-1">Format</Th>
|
||||
<Th className="flex-1">Comment</Th>
|
||||
{/* <Th className="flex-1">Status</Th> */}
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{!isLoading &&
|
||||
data &&
|
||||
data?.length > 0 &&
|
||||
data
|
||||
.sort((a, b) => a.ipAddress.localeCompare(b.ipAddress))
|
||||
.map(({ id, ipAddress, comment, type, prefix, isActive }) => {
|
||||
return (
|
||||
<Tr key={`ip-access-range-${id}`} className="h-10">
|
||||
<Td>{`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`}</Td>
|
||||
<Td>{formatType(type, prefix)}</Td>
|
||||
<Td>{comment}</Td>
|
||||
{/* <Td>
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon
|
||||
icon={faCircle}
|
||||
color="#2ecc71"
|
||||
/>
|
||||
<p className="ml-4">Active</p>
|
||||
</div>
|
||||
</Td> */}
|
||||
<Td className="flex items-center">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.IpAllowList}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
className="mr-3 py-2"
|
||||
onClick={() => {
|
||||
if (subscription?.ipAllowlisting) {
|
||||
handlePopUpOpen("trustedIp", {
|
||||
trustedIpId: id,
|
||||
ipAddress,
|
||||
comment,
|
||||
prefix,
|
||||
isActive
|
||||
});
|
||||
} else {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}
|
||||
}}
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.IpAllowList}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (subscription?.ipAllowlisting) {
|
||||
handlePopUpOpen("deleteTrustedIp", {
|
||||
trustedIpId: id
|
||||
});
|
||||
} else {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}
|
||||
}}
|
||||
size="lg"
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
ariaLabel="update"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{isLoading && (
|
||||
<TableSkeleton innerKey="ip-access-table" columns={4} key="ip-access-ranges" />
|
||||
)}
|
||||
{!isLoading && data && data?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={5}>
|
||||
<EmptyState title="No IP addresses added" icon={faGlobe} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { IPAllowlistSection } from "./IPAllowlistSection";
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
|
||||
import { IPAllowlistSection } from "./-components";
|
||||
|
||||
const IPAllowlistPage = withProjectPermission(
|
||||
() => {
|
||||
return (
|
||||
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
|
||||
<div className="w-full max-w-7xl px-6">
|
||||
<div className="my-6">
|
||||
<p className="text-3xl font-semibold text-gray-200">IP Allowlist</p>
|
||||
<div />
|
||||
</div>
|
||||
<IPAllowlistSection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{
|
||||
action: ProjectPermissionActions.Read,
|
||||
subject: ProjectPermissionSub.IpAllowList
|
||||
}
|
||||
);
|
||||
|
||||
const IPAllowlistRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<IPAllowlistPage />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/allowlist/"
|
||||
)({
|
||||
component: () => IPAllowlistRoute
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
faChevronDown,
|
||||
faLock,
|
||||
faPlus
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useUser,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetWorkspaceUsers } from "@app/hooks/api";
|
||||
import {
|
||||
accessApprovalKeys,
|
||||
useGetAccessApprovalPolicies,
|
||||
useGetAccessApprovalRequests,
|
||||
useGetAccessRequestsCount
|
||||
} from "@app/hooks/api/accessApproval/queries";
|
||||
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
|
||||
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
|
||||
import { queryClient } from "@app/hooks/api/reactQuery";
|
||||
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
|
||||
|
||||
import { RequestAccessModal } from "./components/RequestAccessModal";
|
||||
import { ReviewAccessRequestModal } from "./components/ReviewAccessModal";
|
||||
|
||||
const generateRequestText = (request: TAccessApprovalRequest, userId: string) => {
|
||||
const { isTemporary } = request;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between text-sm">
|
||||
<div>
|
||||
Requested {isTemporary ? "temporary" : "permanent"} access to{" "}
|
||||
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
|
||||
{request.policy.secretPath}
|
||||
</code>
|
||||
in
|
||||
<code className="mx-1 rounded-sm bg-primary-500/20 px-1.5 py-0.5 font-mono text-xs text-primary">
|
||||
{request.environmentName}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
{request.requestedByUserId === userId && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<Badge className="ml-1">Requested By You</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccessApprovalRequest = ({
|
||||
projectSlug,
|
||||
projectId
|
||||
}: {
|
||||
projectSlug: string;
|
||||
projectId: string;
|
||||
}) => {
|
||||
const [selectedRequest, setSelectedRequest] = useState<
|
||||
| (TAccessApprovalRequest & {
|
||||
user: TWorkspaceUser["user"] | null;
|
||||
isRequestedByCurrentUser: boolean;
|
||||
isApprover: boolean;
|
||||
})
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const { handlePopUpOpen, popUp, handlePopUpClose } = usePopUp([
|
||||
"requestAccess",
|
||||
"reviewRequest",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const { user } = useUser();
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { data: members } = useGetWorkspaceUsers(projectId, true);
|
||||
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
|
||||
(prev, curr) => ({ ...prev, [curr.user.id]: curr }),
|
||||
{}
|
||||
);
|
||||
|
||||
console.log("membersGroupById", membersGroupById);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<"open" | "close">("open");
|
||||
const [requestedByFilter, setRequestedByFilter] = useState<string | undefined>(undefined);
|
||||
const [envFilter, setEnvFilter] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data: requestCount } = useGetAccessRequestsCount({
|
||||
projectSlug
|
||||
});
|
||||
|
||||
const { data: policies, isPending: policiesLoading } = useGetAccessApprovalPolicies({
|
||||
projectSlug
|
||||
});
|
||||
|
||||
const { data: requests } = useGetAccessApprovalRequests({
|
||||
projectSlug,
|
||||
authorProjectMembershipId: requestedByFilter,
|
||||
envSlug: envFilter
|
||||
});
|
||||
|
||||
const filteredRequests = useMemo(() => {
|
||||
if (statusFilter === "open")
|
||||
return requests?.filter(
|
||||
(request) =>
|
||||
!request.policy.deletedAt &&
|
||||
!request.isApproved &&
|
||||
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||
);
|
||||
if (statusFilter === "close")
|
||||
return requests?.filter(
|
||||
(request) =>
|
||||
request.policy.deletedAt ||
|
||||
request.isApproved ||
|
||||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||
);
|
||||
|
||||
return requests;
|
||||
}, [requests, statusFilter, requestedByFilter, envFilter]);
|
||||
|
||||
const generateRequestDetails = (request: TAccessApprovalRequest) => {
|
||||
const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
|
||||
const isRejectedByAnyone = request.reviewers.some(
|
||||
({ status }) => status === ApprovalStatus.REJECTED
|
||||
);
|
||||
const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1;
|
||||
const isAccepted = request.isApproved;
|
||||
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
|
||||
const isRequestedByCurrentUser = request.requestedByUserId === user.id;
|
||||
|
||||
const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status;
|
||||
|
||||
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
|
||||
label: "",
|
||||
type: "primary"
|
||||
};
|
||||
|
||||
const isExpired =
|
||||
request.privilege &&
|
||||
request.isApproved &&
|
||||
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
|
||||
|
||||
if (isExpired) displayData = { label: "Access Expired", type: "danger" };
|
||||
else if (isAccepted) displayData = { label: "Access Granted", type: "success" };
|
||||
else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" };
|
||||
else if (userReviewStatus === ApprovalStatus.APPROVED) {
|
||||
displayData = {
|
||||
label: `Pending ${request.policy.approvals - request.reviewers.length} review${
|
||||
request.policy.approvals - request.reviewers.length > 1 ? "s" : ""
|
||||
}`,
|
||||
type: "primary"
|
||||
};
|
||||
} else if (!isReviewedByUser)
|
||||
displayData = {
|
||||
label: "Review Required",
|
||||
type: "primary"
|
||||
};
|
||||
|
||||
return {
|
||||
displayData,
|
||||
isReviewedByUser,
|
||||
isRejectedByAnyone,
|
||||
isApprover,
|
||||
userReviewStatus,
|
||||
isAccepted,
|
||||
isSoftEnforcement,
|
||||
isRequestedByCurrentUser
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-semibold text-mineshaft-100">Access Requests</span>
|
||||
<div className="mt-2 text-sm text-bunker-300">
|
||||
Request access to secrets in sensitive environments and folders.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
content="To submit Access Requests, your project needs to create Access Request policies first."
|
||||
isDisabled={policiesLoading || !!policies?.length}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.secretApproval) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("requestAccess");
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={policiesLoading || !policies?.length}
|
||||
>
|
||||
Request access
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="approval-changes-list"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="rounded-md text-gray-300"
|
||||
>
|
||||
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("open")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("open");
|
||||
}}
|
||||
className={
|
||||
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||
{!!requestCount && requestCount?.pendingCount} Pending
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("close")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("close");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||
{!!requestCount && requestCount.finalizedCount} Completed
|
||||
</div>
|
||||
<div className="flex flex-grow justify-end space-x-8">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className="text-bunker-300"
|
||||
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
|
||||
>
|
||||
Environments
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
|
||||
{currentWorkspace?.environments.map(({ slug, name }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
|
||||
key={`request-filter-${slug}`}
|
||||
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
{name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={requestedByFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||
}
|
||||
>
|
||||
Requested By
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
||||
{members?.map(({ user: membershipUser, id }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setRequestedByFilter((state) => (state === id ? undefined : id))
|
||||
}
|
||||
key={`request-filter-member-${id}`}
|
||||
icon={requestedByFilter === id && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
{membershipUser.username}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
|
||||
{filteredRequests?.length === 0 && (
|
||||
<div className="py-12">
|
||||
<EmptyState title="No more access requests pending." />
|
||||
</div>
|
||||
)}
|
||||
{!!filteredRequests?.length &&
|
||||
filteredRequests?.map((request) => {
|
||||
const details = generateRequestDetails(request);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-disabled={
|
||||
details.isReviewedByUser || details.isRejectedByAnyone || details.isAccepted
|
||||
}
|
||||
key={request.id}
|
||||
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (
|
||||
(!details.isApprover ||
|
||||
details.isReviewedByUser ||
|
||||
details.isRejectedByAnyone ||
|
||||
details.isAccepted) &&
|
||||
!(
|
||||
details.isSoftEnforcement &&
|
||||
details.isRequestedByCurrentUser &&
|
||||
!details.isAccepted
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (membersGroupById?.[request.requestedByUserId].user) {
|
||||
setSelectedRequest({
|
||||
...request,
|
||||
user: membersGroupById?.[request.requestedByUserId].user,
|
||||
isRequestedByCurrentUser: details.isRequestedByCurrentUser,
|
||||
isApprover: details.isApprover
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpOpen("reviewRequest");
|
||||
}}
|
||||
onKeyDown={(evt) => {
|
||||
if (
|
||||
!details.isApprover ||
|
||||
details.isAccepted ||
|
||||
details.isReviewedByUser ||
|
||||
details.isRejectedByAnyone
|
||||
)
|
||||
return;
|
||||
if (evt.key === "Enter") {
|
||||
if (membersGroupById?.[request.requestedByUserId].user) {
|
||||
setSelectedRequest({
|
||||
...request,
|
||||
user: membersGroupById?.[request.requestedByUserId].user,
|
||||
isRequestedByCurrentUser: details.isRequestedByCurrentUser,
|
||||
isApprover: details.isApprover
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpOpen("reviewRequest");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex w-full flex-col justify-between">
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<FontAwesomeIcon icon={faLock} className="mr-2" />
|
||||
{generateRequestText(request, user.id)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500">
|
||||
{membersGroupById?.[request.requestedByUserId]?.user && (
|
||||
<>
|
||||
Requested {formatDistance(new Date(request.createdAt), new Date())}{" "}
|
||||
ago by{" "}
|
||||
{membersGroupById?.[request.requestedByUserId]?.user?.firstName}{" "}
|
||||
{membersGroupById?.[request.requestedByUserId]?.user?.lastName} (
|
||||
{membersGroupById?.[request.requestedByUserId]?.user?.email}){" "}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{details.isApprover && (
|
||||
<Badge variant={details.displayData.type}>
|
||||
{details.displayData.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{!!policies && (
|
||||
<RequestAccessModal
|
||||
policies={policies}
|
||||
isOpen={popUp.requestAccess.isOpen}
|
||||
onOpenChange={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accessApprovalKeys.getAccessApprovalRequests(
|
||||
projectSlug,
|
||||
envFilter,
|
||||
requestedByFilter
|
||||
)
|
||||
});
|
||||
handlePopUpClose("requestAccess");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!selectedRequest && (
|
||||
<ReviewAccessRequestModal
|
||||
selectedEnvSlug={envFilter}
|
||||
selectedRequester={requestedByFilter}
|
||||
projectSlug={projectSlug}
|
||||
request={selectedRequest}
|
||||
isOpen={popUp.reviewRequest.isOpen}
|
||||
onOpenChange={() => {
|
||||
handlePopUpClose("reviewRequest");
|
||||
setSelectedRequest(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UpgradePlanModal
|
||||
text="You need to upgrade your plan to access this feature"
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={() => handlePopUpClose("upgradePlan")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
|
||||
|
||||
import { SpecificPrivilegeSecretForm } from "../../../../access/-components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection";
|
||||
|
||||
export const RequestAccessModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
policies
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
policies: TAccessApprovalPolicy[];
|
||||
}) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-4xl"
|
||||
title="Request Access"
|
||||
subTitle="Your role has limited permissions, please contact your administrator to gain access"
|
||||
>
|
||||
<SpecificPrivilegeSecretForm onClose={() => onOpenChange(false)} policies={policies} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import ms from "ms";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Checkbox, Modal, ModalContent } from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
import { ProjectPermissionActions } from "@app/context";
|
||||
import { useReviewAccessRequest } from "@app/hooks/api";
|
||||
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
|
||||
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
|
||||
export const ReviewAccessRequestModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
request,
|
||||
projectSlug,
|
||||
selectedRequester,
|
||||
selectedEnvSlug
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
request: TAccessApprovalRequest & {
|
||||
user: TWorkspaceUser["user"] | null;
|
||||
isRequestedByCurrentUser: boolean;
|
||||
isApprover: boolean;
|
||||
};
|
||||
projectSlug: string;
|
||||
selectedRequester: string | undefined;
|
||||
selectedEnvSlug: string | undefined;
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
|
||||
const [byPassApproval, setByPassApproval] = useState(false);
|
||||
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
|
||||
|
||||
const accessDetails = {
|
||||
env: request.environmentName,
|
||||
// secret path will be inside $glob operator
|
||||
secretPath: request.policy.secretPath,
|
||||
read: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)),
|
||||
edit: request.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)),
|
||||
create: request.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Create)
|
||||
),
|
||||
delete: request.permissions?.some(({ action }) =>
|
||||
action.includes(ProjectPermissionActions.Delete)
|
||||
),
|
||||
|
||||
temporaryAccess: {
|
||||
isTemporary: request.isTemporary,
|
||||
temporaryRange: request.temporaryRange
|
||||
}
|
||||
};
|
||||
|
||||
const requestedAccess = useMemo(() => {
|
||||
const access: string[] = [];
|
||||
if (accessDetails.read) access.push("Read");
|
||||
if (accessDetails.edit) access.push("Edit");
|
||||
if (accessDetails.create) access.push("Create");
|
||||
if (accessDetails.delete) access.push("Delete");
|
||||
|
||||
return access.join(", ");
|
||||
}, [accessDetails]);
|
||||
|
||||
const getAccessLabel = () => {
|
||||
if (!accessDetails.temporaryAccess.isTemporary || !accessDetails.temporaryAccess.temporaryRange)
|
||||
return "Permanent";
|
||||
|
||||
// convert the range to human readable format
|
||||
ms(ms(accessDetails.temporaryAccess.temporaryRange), { long: true });
|
||||
|
||||
return (
|
||||
<Badge>
|
||||
{`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), {
|
||||
long: true
|
||||
})} after approval`}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const reviewAccessRequest = useReviewAccessRequest();
|
||||
|
||||
const handleReview = useCallback(async (status: "approved" | "rejected") => {
|
||||
setIsLoading(status);
|
||||
try {
|
||||
await reviewAccessRequest.mutateAsync({
|
||||
requestId: request.id,
|
||||
status,
|
||||
projectSlug,
|
||||
envSlug: selectedEnvSlug,
|
||||
requestedBy: selectedRequester
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setIsLoading(null);
|
||||
return;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
title: `Request ${status}`,
|
||||
text: `The request has been ${status}`,
|
||||
type: status === "approved" ? "success" : "info"
|
||||
});
|
||||
|
||||
setIsLoading(null);
|
||||
onOpenChange(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
className="max-w-4xl"
|
||||
title="Review Request"
|
||||
subTitle="Review the request and approve or deny access."
|
||||
>
|
||||
<div className="text-sm">
|
||||
<span>
|
||||
<span className="font-bold">
|
||||
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
|
||||
</span>{" "}
|
||||
is requesting access to the following resource:
|
||||
</span>
|
||||
|
||||
<div className="mb-2 mt-4 border-l border-blue-500 bg-blue-500/20 px-3 py-2 text-mineshaft-200">
|
||||
<div className="mb-1 lowercase">
|
||||
<span className="font-bold capitalize">Requested path: </span>
|
||||
<Badge>{accessDetails.env + accessDetails.secretPath || ""}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mb-1">
|
||||
<span className="font-bold">Permissions: </span>
|
||||
<Badge>{requestedAccess}</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-bold">Access Type: </span>
|
||||
<span>{getAccessLabel()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
isLoading={isLoading === "approved"}
|
||||
isDisabled={
|
||||
!!isLoading || (!request.isApprover && !byPassApproval && isSoftEnforcement)
|
||||
}
|
||||
onClick={() => handleReview("approved")}
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
colorSchema={!request.isApprover && isSoftEnforcement ? "danger" : "primary"}
|
||||
>
|
||||
Approve Request
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading === "rejected"}
|
||||
isDisabled={!!isLoading}
|
||||
onClick={() => handleReview("rejected")}
|
||||
className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200"
|
||||
size="sm"
|
||||
>
|
||||
Reject Request
|
||||
</Button>
|
||||
</div>
|
||||
{isSoftEnforcement && request.isRequestedByCurrentUser && !request.isApprover && (
|
||||
<div className="mt-4">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => setByPassApproval(checked === true)}
|
||||
isChecked={byPassApproval}
|
||||
id="byPassApproval"
|
||||
checkIndicatorBg="text-white"
|
||||
className={byPassApproval ? "border-red bg-red hover:bg-red-600" : ""}
|
||||
>
|
||||
<span className="text-sm text-red">
|
||||
Approve without waiting for requirements to be met (bypass policy protection)
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { AccessApprovalRequest } from "./AccessApprovalRequest";
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faChevronDown,
|
||||
faFileShield,
|
||||
faPlus
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
TProjectPermission,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useDeleteAccessApprovalPolicy,
|
||||
useDeleteSecretApprovalPolicy,
|
||||
useGetSecretApprovalPolicies,
|
||||
useGetWorkspaceUsers,
|
||||
useListWorkspaceGroups
|
||||
} from "@app/hooks/api";
|
||||
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
|
||||
import { PolicyType } from "@app/hooks/api/policies/enums";
|
||||
import { TAccessApprovalPolicy, Workspace } from "@app/hooks/api/types";
|
||||
|
||||
import { AccessPolicyForm } from "./components/AccessPolicyModal";
|
||||
import { ApprovalPolicyRow } from "./components/ApprovalPolicyRow";
|
||||
|
||||
interface IProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: Workspace) => {
|
||||
const { data: accessPolicies, isPending: isAccessPoliciesLoading } = useGetAccessApprovalPolicies(
|
||||
{
|
||||
projectSlug: currentWorkspace?.slug as string,
|
||||
options: {
|
||||
enabled:
|
||||
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
|
||||
!!currentWorkspace?.slug
|
||||
}
|
||||
}
|
||||
);
|
||||
const { data: secretPolicies, isPending: isSecretPoliciesLoading } = useGetSecretApprovalPolicies(
|
||||
{
|
||||
workspaceId: currentWorkspace?.id as string,
|
||||
options: {
|
||||
enabled:
|
||||
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
|
||||
!!currentWorkspace?.id
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// merge data sorted by updatedAt
|
||||
const policies = [
|
||||
...(accessPolicies?.map((policy) => ({ ...policy, policyType: PolicyType.AccessPolicy })) ||
|
||||
[]),
|
||||
...(secretPolicies?.map((policy) => ({ ...policy, policyType: PolicyType.ChangePolicy })) || [])
|
||||
].sort((a, b) => {
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
|
||||
return {
|
||||
policies,
|
||||
isLoading: isAccessPoliciesLoading || isSecretPoliciesLoading
|
||||
};
|
||||
};
|
||||
|
||||
export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
const { handlePopUpToggle, handlePopUpOpen, handlePopUpClose, popUp } = usePopUp([
|
||||
"policyForm",
|
||||
"deletePolicy",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const { data: members } = useGetWorkspaceUsers(workspaceId, true);
|
||||
const { data: groups } = useListWorkspaceGroups(currentWorkspace?.id || "");
|
||||
|
||||
const { policies, isLoading: isPoliciesLoading } = useApprovalPolicies(
|
||||
permission,
|
||||
currentWorkspace
|
||||
);
|
||||
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
|
||||
const filteredPolicies = useMemo(() => {
|
||||
return filterType ? policies.filter((policy) => policy.policyType === filterType) : policies;
|
||||
}, [policies, filterType]);
|
||||
|
||||
const { mutateAsync: deleteSecretApprovalPolicy } = useDeleteSecretApprovalPolicy();
|
||||
const { mutateAsync: deleteAccessApprovalPolicy } = useDeleteAccessApprovalPolicy();
|
||||
|
||||
const handleDeletePolicy = async () => {
|
||||
const { id, policyType } = popUp.deletePolicy.data as TAccessApprovalPolicy;
|
||||
if (!currentWorkspace?.slug) return;
|
||||
|
||||
try {
|
||||
if (policyType === PolicyType.ChangePolicy) {
|
||||
await deleteSecretApprovalPolicy({
|
||||
workspaceId,
|
||||
id
|
||||
});
|
||||
} else {
|
||||
await deleteAccessApprovalPolicy({
|
||||
projectSlug: currentWorkspace?.slug,
|
||||
id
|
||||
});
|
||||
}
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted policy"
|
||||
});
|
||||
handlePopUpClose("deletePolicy");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete policy"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-semibold text-mineshaft-100">Policies</span>
|
||||
<div className="mt-2 text-sm text-bunker-300">
|
||||
Implement granular policies for access requests and secrets management.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.secretApproval) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("policyForm");
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create Policy
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Environment</Th>
|
||||
<Th>Secret Path</Th>
|
||||
<Th className="w-[18%]">Eligible Approvers</Th>
|
||||
<Th className="w-[18%]">Eligible Group Approvers</Th>
|
||||
<Th>Approval Required</Th>
|
||||
<Th>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className="text-xs font-semibold uppercase text-bunker-300"
|
||||
rightIcon={
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||
}
|
||||
>
|
||||
Type
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Select a type</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFilterType(null)}
|
||||
icon={!filterType && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
All
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFilterType(PolicyType.AccessPolicy)}
|
||||
icon={
|
||||
filterType === PolicyType.AccessPolicy && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Access Policy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setFilterType(PolicyType.ChangePolicy)}
|
||||
icon={
|
||||
filterType === PolicyType.ChangePolicy && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Change Policy
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPoliciesLoading && (
|
||||
<TableSkeleton columns={6} innerKey="secret-policies" className="bg-mineshaft-700" />
|
||||
)}
|
||||
{!isPoliciesLoading && !filteredPolicies?.length && (
|
||||
<Tr>
|
||||
<Td colSpan={6}>
|
||||
<EmptyState title="No policies found" icon={faFileShield} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!!currentWorkspace &&
|
||||
filteredPolicies?.map((policy) => (
|
||||
<ApprovalPolicyRow
|
||||
policy={policy}
|
||||
key={policy.id}
|
||||
members={members}
|
||||
groups={groups}
|
||||
onEdit={() => handlePopUpOpen("policyForm", policy)}
|
||||
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<AccessPolicyForm
|
||||
projectId={currentWorkspace.id}
|
||||
projectSlug={currentWorkspace.slug}
|
||||
isOpen={popUp.policyForm.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("policyForm", isOpen)}
|
||||
members={members}
|
||||
editValues={popUp.policyForm.data as TAccessApprovalPolicy}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePolicy.isOpen}
|
||||
deleteKey="remove"
|
||||
title="Do you want to remove this policy?"
|
||||
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
|
||||
onDeleteApproved={handleDeletePolicy}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can add secret approval policy if you switch to Infisical's Enterprise plan."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { getMemberLabel } from "@app/helpers/members";
|
||||
import { policyDetails } from "@app/helpers/policies";
|
||||
import {
|
||||
useCreateSecretApprovalPolicy,
|
||||
useListWorkspaceGroups,
|
||||
useUpdateSecretApprovalPolicy
|
||||
} from "@app/hooks/api";
|
||||
import {
|
||||
useCreateAccessApprovalPolicy,
|
||||
useUpdateAccessApprovalPolicy
|
||||
} from "@app/hooks/api/accessApproval";
|
||||
import { ApproverType, TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types";
|
||||
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
onToggle: (isOpen: boolean) => void;
|
||||
members?: TWorkspaceUser[];
|
||||
projectId: string;
|
||||
projectSlug: string;
|
||||
editValues?: TAccessApprovalPolicy;
|
||||
};
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
environment: z.object({ slug: z.string(), name: z.string() }),
|
||||
name: z.string().optional(),
|
||||
secretPath: z.string().optional(),
|
||||
approvals: z.number().min(1),
|
||||
userApprovers: z
|
||||
.object({ type: z.literal(ApproverType.User), id: z.string() })
|
||||
.array()
|
||||
.default([]),
|
||||
groupApprovers: z
|
||||
.object({ type: z.literal(ApproverType.Group), id: z.string() })
|
||||
.array()
|
||||
.default([]),
|
||||
policyType: z.nativeEnum(PolicyType),
|
||||
enforcementLevel: z.nativeEnum(EnforcementLevel)
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!(data.groupApprovers.length || data.userApprovers.length)) {
|
||||
ctx.addIssue({
|
||||
path: ["userApprovers"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one approver should be provided"
|
||||
});
|
||||
ctx.addIssue({
|
||||
path: ["groupApprovers"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one approver should be provided"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
export const AccessPolicyForm = ({
|
||||
isOpen,
|
||||
onToggle,
|
||||
members = [],
|
||||
projectId,
|
||||
projectSlug,
|
||||
editValues
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: editValues
|
||||
? {
|
||||
...editValues,
|
||||
environment: editValues.environment,
|
||||
userApprovers:
|
||||
editValues?.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.User)
|
||||
.map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [],
|
||||
groupApprovers:
|
||||
editValues?.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.Group)
|
||||
.map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [],
|
||||
approvals: editValues?.approvals
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { data: groups } = useListWorkspaceGroups(projectId);
|
||||
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
const isEditMode = Boolean(editValues);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !isEditMode) reset({});
|
||||
}, [isOpen, isEditMode]);
|
||||
|
||||
const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy();
|
||||
const { mutateAsync: updateAccessApprovalPolicy } = useUpdateAccessApprovalPolicy();
|
||||
|
||||
const { mutateAsync: createSecretApprovalPolicy } = useCreateSecretApprovalPolicy();
|
||||
const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy();
|
||||
|
||||
const policyName = policyDetails[watch("policyType")]?.name || "Policy";
|
||||
|
||||
const approversRequired = watch("approvals") || 1;
|
||||
|
||||
const handleCreatePolicy = async ({
|
||||
environment,
|
||||
groupApprovers,
|
||||
userApprovers,
|
||||
...data
|
||||
}: TFormSchema) => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
if (data.policyType === PolicyType.ChangePolicy) {
|
||||
await createSecretApprovalPolicy({
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
environment: environment.slug,
|
||||
workspaceId: currentWorkspace?.id || ""
|
||||
});
|
||||
} else {
|
||||
await createAccessApprovalPolicy({
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
environment: environment.slug,
|
||||
projectSlug
|
||||
});
|
||||
}
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created policy"
|
||||
});
|
||||
onToggle(false);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create policy"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePolicy = async ({
|
||||
environment,
|
||||
userApprovers,
|
||||
groupApprovers,
|
||||
...data
|
||||
}: TFormSchema) => {
|
||||
if (!projectId || !projectSlug) return;
|
||||
if (!editValues?.id) return;
|
||||
|
||||
try {
|
||||
if (data.policyType === PolicyType.ChangePolicy) {
|
||||
await updateSecretApprovalPolicy({
|
||||
id: editValues?.id,
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
workspaceId: currentWorkspace?.id || ""
|
||||
});
|
||||
} else {
|
||||
await updateAccessApprovalPolicy({
|
||||
id: editValues?.id,
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
environment: environment.slug,
|
||||
projectSlug
|
||||
});
|
||||
}
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated policy"
|
||||
});
|
||||
onToggle(false);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "failed to update policy"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
if (isEditMode) {
|
||||
await handleUpdatePolicy(data);
|
||||
} else {
|
||||
await handleCreatePolicy(data);
|
||||
}
|
||||
};
|
||||
|
||||
const memberOptions = useMemo(
|
||||
() =>
|
||||
members.map((member) => ({
|
||||
id: member.user.id,
|
||||
type: ApproverType.User
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() =>
|
||||
groups?.map(({ group }) => ({
|
||||
id: group.id,
|
||||
type: ApproverType.Group
|
||||
})),
|
||||
[groups]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onToggle}>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
bodyClassName="overflow-visible"
|
||||
title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
|
||||
>
|
||||
<div className="flex flex-col space-y-3">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="grid grid-cols-2 gap-x-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="policyType"
|
||||
defaultValue={PolicyType.ChangePolicy}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Type"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
tooltipText="Change polices govern secret changes within a given environment and secret path. Access polices allow underprivileged user to request access to environment/secret path."
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val as PolicyType)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{Object.values(PolicyType).map((policyType) => {
|
||||
return (
|
||||
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
|
||||
{policyDetails[policyType].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="approvals"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Minimum Approvals Required"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enforcementLevel"
|
||||
defaultValue={EnforcementLevel.Hard}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Enforcement Level"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText={
|
||||
<>
|
||||
<p>
|
||||
Determines the level of enforcement for required approvers of a request:
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="font-bold">Hard</span> enforcement requires at least{" "}
|
||||
<span className="font-bold"> {approversRequired}</span> approver(s) to
|
||||
approve the request.`
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="font-bold">Soft</span> enforcement At least{" "}
|
||||
<span className="font-bold">{approversRequired}</span> approver(s) must
|
||||
approve the request; however, the requester can bypass approval
|
||||
requirements in emergencies.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{Object.values(EnforcementLevel).map((level) => {
|
||||
return (
|
||||
<SelectItem value={level} key={`enforcement-level-${level}`}>
|
||||
<span className="capitalize">{level}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select environment..."
|
||||
options={environments}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
|
||||
label="Secret Path"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<p>Approvers</p>
|
||||
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
|
||||
Select members or groups that are allowed to approve requests from this policy.
|
||||
</p>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="userApprovers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="User Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members that are allowed to approve requests..."
|
||||
options={memberOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => {
|
||||
const member = members?.find((m) => m.user.id === option.id);
|
||||
|
||||
if (!member) return option.id;
|
||||
|
||||
return getMemberLabel(member);
|
||||
}}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="groupApprovers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Group Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select groups that are allowed to approve requests..."
|
||||
options={groupOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) =>
|
||||
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => onToggle(false)} variant="outline_bg">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useMemo } from "react";
|
||||
import { faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { getMemberLabel } from "@app/helpers/members";
|
||||
import { policyDetails } from "@app/helpers/policies";
|
||||
import { Approver } from "@app/hooks/api/accessApproval/types";
|
||||
import { TGroupMembership } from "@app/hooks/api/groups/types";
|
||||
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
|
||||
import { ApproverType } from "@app/hooks/api/secretApproval/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||
|
||||
interface IPolicy {
|
||||
id: string;
|
||||
name: string;
|
||||
environment: WorkspaceEnv;
|
||||
projectId?: string;
|
||||
secretPath?: string;
|
||||
approvals: number;
|
||||
approvers?: Approver[];
|
||||
updatedAt: Date;
|
||||
policyType: PolicyType;
|
||||
enforcementLevel: EnforcementLevel;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
policy: IPolicy;
|
||||
members?: TWorkspaceUser[];
|
||||
groups?: TGroupMembership[];
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export const ApprovalPolicyRow = ({
|
||||
policy,
|
||||
members = [],
|
||||
groups = [],
|
||||
onEdit,
|
||||
onDelete
|
||||
}: Props) => {
|
||||
const labels = useMemo(() => {
|
||||
const usersInPolicy = policy.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.User)
|
||||
.map((approver) => approver.id);
|
||||
|
||||
const groupsInPolicy = policy.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.Group)
|
||||
.map((approver) => approver.id);
|
||||
|
||||
const memberLabels = usersInPolicy?.length
|
||||
? members
|
||||
.filter((member) => usersInPolicy?.includes(member.user.id))
|
||||
.map((member) => getMemberLabel(member))
|
||||
.join(", ")
|
||||
: null;
|
||||
|
||||
const groupLabels = groupsInPolicy?.length
|
||||
? groups
|
||||
.filter(({ group }) => groupsInPolicy?.includes(group.id))
|
||||
.map(({ group }) => group.name)
|
||||
.join(", ")
|
||||
: null;
|
||||
|
||||
return {
|
||||
members: memberLabels,
|
||||
groups: groupLabels
|
||||
};
|
||||
}, [policy, members, groups]);
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>{policy.name}</Td>
|
||||
<Td>{policy.environment.slug}</Td>
|
||||
<Td>{policy.secretPath || "*"}</Td>
|
||||
<Td className="max-w-0">
|
||||
<Tooltip
|
||||
side="left"
|
||||
content={labels.members ?? "No users are assigned as approvers for this policy"}
|
||||
>
|
||||
<p className="truncate">{labels.members ?? "-"}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td className="max-w-0">
|
||||
<Tooltip
|
||||
side="left"
|
||||
content={labels.groups ?? "No groups are assigned as approvers for this policy"}
|
||||
>
|
||||
<p className="truncate">{labels.groups ?? "-"}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>{policy.approvals}</Td>
|
||||
<Td>
|
||||
<Badge className={policyDetails[policy.policyType].className}>
|
||||
{policyDetails[policy.policyType].name}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
|
||||
<div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Edit Policy
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Delete Policy
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ApprovalPolicyList } from "./ApprovalPolicyList";
|
||||
@@ -0,0 +1,291 @@
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import {
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
faChevronDown,
|
||||
faCodeBranch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
Skeleton
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useUser,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import {
|
||||
useGetSecretApprovalRequestCount,
|
||||
useGetSecretApprovalRequests,
|
||||
useGetWorkspaceUsers
|
||||
} from "@app/hooks/api";
|
||||
import { ApprovalStatus } from "@app/hooks/api/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import {
|
||||
generateCommitText,
|
||||
SecretApprovalRequestChanges
|
||||
} from "./components/SecretApprovalRequestChanges";
|
||||
|
||||
export const SecretApprovalRequest = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const [selectedApprovalId, setSelectedApprovalId] = useState<string | null>(null);
|
||||
|
||||
// filters
|
||||
const [statusFilter, setStatusFilter] = useState<"open" | "close">("open");
|
||||
const [envFilter, setEnvFilter] = useState<string>();
|
||||
const [committerFilter, setCommitterFilter] = useState<string>();
|
||||
const [usingUrlRequestId, setUsingUrlRequestId] = useState(false);
|
||||
|
||||
const {
|
||||
data: secretApprovalRequests,
|
||||
isFetchingNextPage: isFetchingNextApprovalRequest,
|
||||
fetchNextPage: fetchNextApprovalRequest,
|
||||
hasNextPage: hasNextApprovalPage,
|
||||
isPending: isApprovalRequestLoading,
|
||||
refetch
|
||||
} = useGetSecretApprovalRequests({
|
||||
workspaceId,
|
||||
status: statusFilter,
|
||||
environment: envFilter,
|
||||
committer: committerFilter
|
||||
});
|
||||
const { data: secretApprovalRequestCount, isSuccess: isSecretApprovalReqCountSuccess } =
|
||||
useGetSecretApprovalRequestCount({ workspaceId });
|
||||
const { user: userSession } = useUser();
|
||||
const search = useSearch({
|
||||
from: `/_authenticate/_ctx-org-details/${ProjectType.SecretManager}/$projectId/_layout-secret-manager/approval/` as const
|
||||
});
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const { data: members } = useGetWorkspaceUsers(workspaceId);
|
||||
const isSecretApprovalScreen = Boolean(selectedApprovalId);
|
||||
const { requestId } = search;
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestId || usingUrlRequestId) return;
|
||||
|
||||
setSelectedApprovalId(requestId as string);
|
||||
setUsingUrlRequestId(true);
|
||||
}, [requestId]);
|
||||
|
||||
const handleGoBackSecretRequestDetail = () => {
|
||||
setSelectedApprovalId(null);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const isRequestListEmpty =
|
||||
!isApprovalRequestLoading && secretApprovalRequests?.pages[0]?.length === 0;
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{isSecretApprovalScreen ? (
|
||||
<motion.div
|
||||
key="approval-changes-details"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<SecretApprovalRequestChanges
|
||||
workspaceId={workspaceId}
|
||||
approvalRequestId={selectedApprovalId || ""}
|
||||
onGoBack={handleGoBackSecretRequestDetail}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="approval-changes-list"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="rounded-md text-gray-300"
|
||||
>
|
||||
<div className="flex items-center space-x-8 rounded-t-md border-x border-t border-mineshaft-600 bg-mineshaft-800 p-4 px-8">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("open")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("open");
|
||||
}}
|
||||
className={
|
||||
statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
statusFilter === "open" ? "text-gray-500 duration-100 hover:text-gray-400" : ""
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setStatusFilter("close")}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setStatusFilter("close");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||
{isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed
|
||||
</div>
|
||||
<div className="flex flex-grow justify-end space-x-8">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={envFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />}
|
||||
>
|
||||
Environments
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Select an environment</DropdownMenuLabel>
|
||||
{currentWorkspace?.environments.map(({ slug, name }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEnvFilter((state) => (state === slug ? undefined : slug))}
|
||||
key={`request-filter-${slug}`}
|
||||
icon={envFilter === slug && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
{name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className={committerFilter ? "text-white" : "text-bunker-300"}
|
||||
rightIcon={
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" className="ml-2" />
|
||||
}
|
||||
>
|
||||
Author
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Select an author</DropdownMenuLabel>
|
||||
{members?.map(({ user, id }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setCommitterFilter((state) => (state === user.id ? undefined : user.id))
|
||||
}
|
||||
key={`request-filter-member-${id}`}
|
||||
icon={
|
||||
committerFilter === user.id && <FontAwesomeIcon icon={faCheckCircle} />
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
{user.username}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-b-md border-x border-b border-t border-mineshaft-600 bg-mineshaft-800">
|
||||
{isRequestListEmpty && (
|
||||
<div className="py-12">
|
||||
<EmptyState title="No more requests pending." />
|
||||
</div>
|
||||
)}
|
||||
{secretApprovalRequests?.pages?.map((group, i) => (
|
||||
<Fragment key={`secret-approval-request-${i + 1}`}>
|
||||
{group?.map((secretApproval) => {
|
||||
const {
|
||||
id: reqId,
|
||||
commits,
|
||||
createdAt,
|
||||
reviewers,
|
||||
status,
|
||||
committerUser,
|
||||
isReplicated: isReplication
|
||||
} = secretApproval;
|
||||
const isReviewed = reviewers.some(
|
||||
({ status: reviewStatus, userId }) =>
|
||||
userId === userSession.id && reviewStatus === ApprovalStatus.APPROVED
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={reqId}
|
||||
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedApprovalId(secretApproval.id)}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id);
|
||||
}}
|
||||
>
|
||||
<div className="mb-1">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
{generateCommitText(commits)}
|
||||
<span className="text-xs text-bunker-300"> #{secretApproval.slug}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "}
|
||||
{committerUser?.firstName || ""} {committerUser?.lastName || ""} (
|
||||
{committerUser?.email}){isReplication && " via replication"}
|
||||
{!isReviewed && status === "open" && " - Review required"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
{(isFetchingNextApprovalRequest || isApprovalRequestLoading) && (
|
||||
<div>
|
||||
{Array.apply(0, Array(3)).map((_x, index) => (
|
||||
<div
|
||||
key={`approval-request-loading-${index + 1}`}
|
||||
className="flex flex-col px-8 py-4 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div className="mb-2 flex items-center">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="mr-2" />
|
||||
<Skeleton className="w-1/4 bg-mineshaft-600" />
|
||||
</div>
|
||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasNextApprovalPage && (
|
||||
<Button
|
||||
className="mt-4 text-sm"
|
||||
isFullWidth
|
||||
variant="star"
|
||||
isLoading={isFetchingNextApprovalRequest}
|
||||
isDisabled={isFetchingNextApprovalRequest || !hasNextApprovalPage}
|
||||
onClick={() => fetchNextApprovalRequest()}
|
||||
>
|
||||
{hasNextApprovalPage ? "Load More" : "End of history"}
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
faCheck,
|
||||
faClose,
|
||||
faLandMineOn,
|
||||
faLockOpen,
|
||||
faSquareCheck,
|
||||
faSquareXmark,
|
||||
faTriangleExclamation,
|
||||
faUserLock
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Checkbox, FormControl, Input } from "@app/components/v2";
|
||||
import {
|
||||
usePerformSecretApprovalRequestMerge,
|
||||
useUpdateSecretApprovalRequestStatus
|
||||
} from "@app/hooks/api";
|
||||
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
|
||||
|
||||
type Props = {
|
||||
approvalRequestId: string;
|
||||
hasMerged?: boolean;
|
||||
isMergable?: boolean;
|
||||
status: "close" | "open";
|
||||
approvals: number;
|
||||
canApprove?: boolean;
|
||||
statusChangeByEmail?: string;
|
||||
workspaceId: string;
|
||||
enforcementLevel: EnforcementLevel;
|
||||
};
|
||||
|
||||
export const SecretApprovalRequestAction = ({
|
||||
approvalRequestId,
|
||||
hasMerged,
|
||||
status,
|
||||
isMergable,
|
||||
approvals,
|
||||
statusChangeByEmail,
|
||||
workspaceId,
|
||||
enforcementLevel,
|
||||
canApprove
|
||||
}: Props) => {
|
||||
const { mutateAsync: performSecretApprovalMerge, isPending: isMerging } =
|
||||
usePerformSecretApprovalRequestMerge();
|
||||
|
||||
const { mutateAsync: updateSecretStatusChange, isPending: isStatusChanging } =
|
||||
useUpdateSecretApprovalRequestStatus();
|
||||
|
||||
const [byPassApproval, setByPassApproval] = useState(false);
|
||||
const [bypassReason, setBypassReason] = useState("");
|
||||
|
||||
const isValidBypassReason = (value: string) => {
|
||||
const trimmedValue = value.trim();
|
||||
return trimmedValue.length >= 10;
|
||||
};
|
||||
|
||||
const handleSecretApprovalRequestMerge = async () => {
|
||||
try {
|
||||
await performSecretApprovalMerge({
|
||||
id: approvalRequestId,
|
||||
workspaceId,
|
||||
bypassReason: byPassApproval ? bypassReason : undefined
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully merged the request"
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update the request status"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecretApprovalStatusChange = async (reqState: "open" | "close") => {
|
||||
try {
|
||||
await updateSecretStatusChange({
|
||||
id: approvalRequestId,
|
||||
status: reqState,
|
||||
workspaceId
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated the request"
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update the request status"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isSoftEnforcement = enforcementLevel === EnforcementLevel.Soft;
|
||||
|
||||
if (!hasMerged && status === "open") {
|
||||
return (
|
||||
<div className="flex w-full items-start justify-between transition-all">
|
||||
<div className="flex items-start space-x-4">
|
||||
<FontAwesomeIcon
|
||||
icon={isMergable ? faSquareCheck : faSquareXmark}
|
||||
className={twMerge("pt-1 text-2xl", isMergable ? "text-primary" : "text-red-600")}
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
{isMergable ? "Good to merge" : "Review required"}
|
||||
<span className="inline-block text-xs text-bunker-200">
|
||||
At least {approvals} approving review required
|
||||
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
|
||||
</span>
|
||||
{isSoftEnforcement && !isMergable && (
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => setByPassApproval(checked === true)}
|
||||
isChecked={byPassApproval}
|
||||
id="byPassApproval"
|
||||
checkIndicatorBg="text-white"
|
||||
className={twMerge(
|
||||
"mr-2",
|
||||
byPassApproval ? "border-red bg-red hover:bg-red-600" : ""
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-red">
|
||||
Merge without waiting for approval (bypass secret change policy)
|
||||
</span>
|
||||
</Checkbox>
|
||||
{byPassApproval && (
|
||||
<FormControl
|
||||
label="Reason for bypass"
|
||||
className="mt-2"
|
||||
isRequired
|
||||
tooltipText="Enter a reason for bypassing the secret change policy"
|
||||
>
|
||||
<Input
|
||||
value={bypassReason}
|
||||
onChange={(e) => setBypassReason(e.target.value)}
|
||||
placeholder="Enter reason for bypass (min 10 chars)"
|
||||
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{canApprove || isSoftEnforcement ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleSecretApprovalStatusChange("close")}
|
||||
isLoading={isStatusChanging}
|
||||
variant="outline_bg"
|
||||
colorSchema="secondary"
|
||||
leftIcon={<FontAwesomeIcon icon={faClose} />}
|
||||
>
|
||||
Close request
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={!canApprove ? faLandMineOn : faCheck} />}
|
||||
isDisabled={
|
||||
!(
|
||||
(isMergable && canApprove) ||
|
||||
(isSoftEnforcement && byPassApproval && isValidBypassReason(bypassReason))
|
||||
)
|
||||
}
|
||||
isLoading={isMerging}
|
||||
onClick={handleSecretApprovalRequestMerge}
|
||||
colorSchema={isSoftEnforcement && !canApprove ? "danger" : "primary"}
|
||||
variant="solid"
|
||||
>
|
||||
Merge
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div>Only approvers can merge</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasMerged && status === "close")
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<FontAwesomeIcon icon={faCheck} className="pt-1 text-2xl text-primary" />
|
||||
<span className="flex flex-col">
|
||||
Secret approval merged
|
||||
<span className="inline-block text-xs text-bunker-200">
|
||||
Merged by {statusChangeByEmail}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<FontAwesomeIcon icon={faUserLock} className="pt-1 text-2xl text-primary" />
|
||||
<span className="flex flex-col">
|
||||
Secret approval has been closed
|
||||
<span className="inline-block text-xs text-bunker-200">
|
||||
Closed by {statusChangeByEmail}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-6">
|
||||
<Button
|
||||
onClick={() => handleSecretApprovalStatusChange("open")}
|
||||
isLoading={isStatusChanging}
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
|
||||
>
|
||||
Reopen request
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { faExclamationTriangle, faInfo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
SecretInput,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { CommitType, SecretV3Raw, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types";
|
||||
|
||||
export type Props = {
|
||||
op: CommitType;
|
||||
secretVersion?: SecretV3Raw;
|
||||
newVersion?: Omit<TSecretApprovalSecChange, "tags"> & { tags?: WsTag[] };
|
||||
presentSecretVersionNumber: number;
|
||||
hasMerged?: boolean;
|
||||
conflicts: Array<{ secretId: string; op: CommitType }>;
|
||||
};
|
||||
|
||||
const generateItemTitle = (op: CommitType) => {
|
||||
let text = { label: "", color: "" };
|
||||
if (op === CommitType.CREATE) text = { label: "create", color: "#16a34a" };
|
||||
else if (op === CommitType.UPDATE) text = { label: "change", color: "#ea580c" };
|
||||
else text = { label: "deletion", color: "#b91c1c" };
|
||||
|
||||
return (
|
||||
<span>
|
||||
Request for <span style={{ color: text.color }}>secret {text.label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const generateConflictText = (op: CommitType) => {
|
||||
if (op === CommitType.CREATE) return <div>Secret already exist</div>;
|
||||
if (op === CommitType.UPDATE) return <div>Secret not found</div>;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SecretApprovalRequestChangeItem = ({
|
||||
op,
|
||||
secretVersion,
|
||||
newVersion,
|
||||
presentSecretVersionNumber,
|
||||
hasMerged,
|
||||
conflicts
|
||||
}: Props) => {
|
||||
// meaning request has changed
|
||||
const isStale = (secretVersion?.version || 1) < presentSecretVersionNumber;
|
||||
const itemConflict =
|
||||
hasMerged && conflicts.find((el) => el.op === op && el.secretId === newVersion?.id);
|
||||
const hasConflict = Boolean(itemConflict);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-bunker-500 px-4 pb-4 pt-2">
|
||||
<div className="flex items-center px-1 py-3">
|
||||
<div className="flex-grow">{generateItemTitle(op)}</div>
|
||||
{!hasMerged && isStale && (
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon icon={faInfo} className="text-sm text-primary-600" />
|
||||
<span className="ml-2 text-xs">Secret has been changed(stale)</span>
|
||||
</div>
|
||||
)}
|
||||
{hasMerged && hasConflict && (
|
||||
<div className="flex items-center space-x-2 text-sm text-bunker-300">
|
||||
<Tooltip content="Merge Conflict">
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className="text-red-700" />
|
||||
</Tooltip>
|
||||
<div>{generateConflictText(op)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
{op === CommitType.UPDATE && <Th className="w-12" />}
|
||||
<Th className="min-table-row">Secret</Th>
|
||||
<Th>Value</Th>
|
||||
<Th className="min-table-row">Comment</Th>
|
||||
<Th className="min-table-row">Tags</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
{op === CommitType.UPDATE ? (
|
||||
<TBody>
|
||||
<Tr>
|
||||
<Td className="text-red-600">OLD</Td>
|
||||
<Td>{secretVersion?.secretKey}</Td>
|
||||
<Td>
|
||||
<SecretInput isReadOnly value={secretVersion?.secretValue} />
|
||||
</Td>
|
||||
<Td>{secretVersion?.secretComment}</Td>
|
||||
<Td className="flex flex-wrap gap-2">
|
||||
{secretVersion?.tags?.map(({ slug, id: tagId, color }) => (
|
||||
<Tag
|
||||
className="flex w-min items-center space-x-2"
|
||||
key={`${secretVersion.id}-${tagId}`}
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: color || "#bec2c8" }}
|
||||
/>
|
||||
<div className="text-sm">{slug}</div>
|
||||
</Tag>
|
||||
))}
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td className="text-green-600">NEW</Td>
|
||||
<Td>{newVersion?.secretKey}</Td>
|
||||
<Td>
|
||||
<SecretInput isReadOnly value={newVersion?.secretValue} />
|
||||
</Td>
|
||||
<Td>{newVersion?.secretComment}</Td>
|
||||
<Td className="flex flex-wrap gap-2">
|
||||
{newVersion?.tags?.map(({ slug, id: tagId, color }) => (
|
||||
<Tag
|
||||
className="flex w-min items-center space-x-2"
|
||||
key={`${newVersion.id}-${tagId}`}
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: color || "#bec2c8" }}
|
||||
/>
|
||||
<div className="text-sm">{slug}</div>
|
||||
</Tag>
|
||||
))}
|
||||
</Td>
|
||||
</Tr>
|
||||
</TBody>
|
||||
) : (
|
||||
<TBody>
|
||||
<Tr>
|
||||
<Td>
|
||||
{op === CommitType.CREATE ? newVersion?.secretKey : secretVersion?.secretKey}
|
||||
</Td>
|
||||
<Td>
|
||||
<SecretInput
|
||||
isReadOnly
|
||||
value={
|
||||
op === CommitType.CREATE
|
||||
? newVersion?.secretValue
|
||||
: secretVersion?.secretValue
|
||||
}
|
||||
/>
|
||||
</Td>
|
||||
<Td>
|
||||
{op === CommitType.CREATE
|
||||
? newVersion?.secretComment
|
||||
: secretVersion?.secretComment}
|
||||
</Td>
|
||||
<Td>
|
||||
{(op === CommitType.CREATE ? newVersion?.tags : secretVersion?.tags)?.map(
|
||||
({ slug, id: tagId, color }) => (
|
||||
<Tag
|
||||
className="flex w-min items-center space-x-2"
|
||||
key={`${
|
||||
op === CommitType.CREATE ? newVersion?.id : secretVersion?.id
|
||||
}-${tagId}`}
|
||||
>
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: color || "#bec2c8" }}
|
||||
/>
|
||||
<div className="text-sm">{slug}</div>
|
||||
</Tag>
|
||||
)
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
</TBody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,317 @@
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
faArrowLeft,
|
||||
faCheck,
|
||||
faCheckCircle,
|
||||
faCircle,
|
||||
faCodeBranch,
|
||||
faFolder,
|
||||
faXmarkCircle
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, ContentLoader, EmptyState, IconButton, Tooltip } from "@app/components/v2";
|
||||
import { useUser } from "@app/context";
|
||||
import {
|
||||
useGetSecretApprovalRequestDetails,
|
||||
useUpdateSecretApprovalReviewStatus
|
||||
} from "@app/hooks/api";
|
||||
import { ApprovalStatus, CommitType } from "@app/hooks/api/types";
|
||||
import { formatReservedPaths } from "@app/lib/fn/string";
|
||||
|
||||
import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction";
|
||||
import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem";
|
||||
|
||||
export const generateCommitText = (commits: { op: CommitType }[] = []) => {
|
||||
const score: Record<string, number> = {};
|
||||
commits.forEach(({ op }) => {
|
||||
score[op] = (score?.[op] || 0) + 1;
|
||||
});
|
||||
const text: ReactNode[] = [];
|
||||
if (score[CommitType.CREATE])
|
||||
text.push(
|
||||
<span key="created-commit">
|
||||
{score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"}
|
||||
<span style={{ color: "#16a34a" }}> created</span>
|
||||
</span>
|
||||
);
|
||||
if (score[CommitType.UPDATE])
|
||||
text.push(
|
||||
<span key="updated-commit">
|
||||
{Boolean(text.length) && ","}
|
||||
{score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
|
||||
<span style={{ color: "#ea580c" }} className="text-orange-600">
|
||||
{" "}
|
||||
updated
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
if (score[CommitType.DELETE])
|
||||
text.push(
|
||||
<span className="deleted-commit">
|
||||
{Boolean(text.length) && "and"}
|
||||
{score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"}
|
||||
<span style={{ color: "#b91c1c" }}> deleted</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
|
||||
if (status === ApprovalStatus.APPROVED)
|
||||
return <FontAwesomeIcon icon={faCheckCircle} size="xs" style={{ color: "#15803d" }} />;
|
||||
if (status === ApprovalStatus.REJECTED)
|
||||
return <FontAwesomeIcon icon={faXmarkCircle} size="xs" style={{ color: "#b91c1c" }} />;
|
||||
return <FontAwesomeIcon icon={faCircle} size="xs" style={{ color: "#c2410c" }} />;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
workspaceId: string;
|
||||
approvalRequestId: string;
|
||||
onGoBack: () => void;
|
||||
};
|
||||
|
||||
export const SecretApprovalRequestChanges = ({
|
||||
approvalRequestId,
|
||||
onGoBack,
|
||||
workspaceId
|
||||
}: Props) => {
|
||||
const { user: userSession } = useUser();
|
||||
const {
|
||||
data: secretApprovalRequestDetails,
|
||||
isSuccess: isSecretApprovalRequestSuccess,
|
||||
isPending: isSecretApprovalRequestLoading
|
||||
} = useGetSecretApprovalRequestDetails({
|
||||
id: approvalRequestId
|
||||
});
|
||||
|
||||
const {
|
||||
mutateAsync: updateSecretApprovalRequestStatus,
|
||||
isPending: isUpdatingRequestStatus,
|
||||
variables
|
||||
} = useUpdateSecretApprovalReviewStatus();
|
||||
|
||||
const isApproving = variables?.status === ApprovalStatus.APPROVED && isUpdatingRequestStatus;
|
||||
const isRejecting = variables?.status === ApprovalStatus.REJECTED && isUpdatingRequestStatus;
|
||||
|
||||
// membership of present user
|
||||
const canApprove = secretApprovalRequestDetails?.policy?.approvers?.some(
|
||||
({ userId }) => userId === userSession.id
|
||||
);
|
||||
const reviewedUsers = secretApprovalRequestDetails?.reviewers?.reduce<
|
||||
Record<string, ApprovalStatus>
|
||||
>(
|
||||
(prev, curr) => ({
|
||||
...prev,
|
||||
[curr.userId]: curr.status
|
||||
}),
|
||||
{}
|
||||
);
|
||||
const hasApproved = reviewedUsers?.[userSession.id] === ApprovalStatus.APPROVED;
|
||||
const hasRejected = reviewedUsers?.[userSession.id] === ApprovalStatus.REJECTED;
|
||||
|
||||
const handleSecretApprovalStatusUpdate = async (status: ApprovalStatus) => {
|
||||
try {
|
||||
await updateSecretApprovalRequestStatus({
|
||||
id: approvalRequestId,
|
||||
status
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: `Successfully ${status} the request`
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update the request status"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isSecretApprovalRequestLoading) {
|
||||
return (
|
||||
<div>
|
||||
<ContentLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSecretApprovalRequestSuccess)
|
||||
return (
|
||||
<div>
|
||||
<EmptyState title="Failed to load approvals" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const isMergable =
|
||||
secretApprovalRequestDetails?.policy?.approvals <=
|
||||
secretApprovalRequestDetails?.policy?.approvers?.filter(
|
||||
({ userId }) => reviewedUsers?.[userId] === ApprovalStatus.APPROVED
|
||||
).length;
|
||||
const hasMerged = secretApprovalRequestDetails?.hasMerged;
|
||||
|
||||
return (
|
||||
<div className="flex space-x-6">
|
||||
<div className="flex-grow">
|
||||
<div className="sticky top-0 z-20 flex items-center space-x-4 bg-bunker-800 pb-6 pt-2">
|
||||
<IconButton variant="outline_bg" ariaLabel="go-back" onClick={onGoBack}>
|
||||
<FontAwesomeIcon icon={faArrowLeft} />
|
||||
</IconButton>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center space-x-2 rounded-3xl px-4 py-2 text-white",
|
||||
secretApprovalRequestDetails.status === "close" ? "bg-red-600" : "bg-green-600"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCodeBranch} size="sm" />
|
||||
<span className="capitalize">
|
||||
{secretApprovalRequestDetails.status === "close"
|
||||
? "closed"
|
||||
: secretApprovalRequestDetails.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-grow flex-col">
|
||||
<div className="mb-1 text-lg">
|
||||
{generateCommitText(secretApprovalRequestDetails.commits)}
|
||||
{secretApprovalRequestDetails.isReplicated && (
|
||||
<span className="text-sm text-bunker-300"> (replication)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-bunker-300">
|
||||
{secretApprovalRequestDetails?.committerUser?.firstName || ""}
|
||||
{secretApprovalRequestDetails?.committerUser?.lastName || ""} (
|
||||
{secretApprovalRequestDetails?.committerUser?.email}) wants to change{" "}
|
||||
{secretApprovalRequestDetails.commits.length} secret values in
|
||||
<span className="mx-1 rounded bg-primary-600/60 px-1 text-primary-300">
|
||||
{secretApprovalRequestDetails.environment}
|
||||
</span>
|
||||
<div className="flex w-min items-center rounded border border-mineshaft-500 pl-1 pr-2">
|
||||
<div className="border-r border-mineshaft-500 pr-1">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-primary" size="sm" />
|
||||
</div>
|
||||
<Tooltip content={formatReservedPaths(secretApprovalRequestDetails.secretPath)}>
|
||||
<div className="truncate pb-0.5 pl-2 text-sm" style={{ maxWidth: "10rem" }}>
|
||||
{formatReservedPaths(secretApprovalRequestDetails.secretPath)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!hasMerged && secretApprovalRequestDetails.status === "open" && (
|
||||
<>
|
||||
<Button
|
||||
size="xs"
|
||||
leftIcon={hasApproved && <FontAwesomeIcon icon={faCheck} />}
|
||||
onClick={() => handleSecretApprovalStatusUpdate(ApprovalStatus.APPROVED)}
|
||||
isLoading={isApproving}
|
||||
isDisabled={isApproving || hasApproved || !canApprove}
|
||||
>
|
||||
{hasApproved ? "Approved" : "Approve"}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
colorSchema="danger"
|
||||
leftIcon={hasRejected && <FontAwesomeIcon icon={faCheck} />}
|
||||
onClick={() => handleSecretApprovalStatusUpdate(ApprovalStatus.REJECTED)}
|
||||
isLoading={isRejecting}
|
||||
isDisabled={isRejecting || hasRejected || !canApprove}
|
||||
>
|
||||
{hasRejected ? "Rejected" : "Reject"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
{secretApprovalRequestDetails.commits.map(
|
||||
({ op, secretVersion, secret, ...newVersion }, index) => (
|
||||
<SecretApprovalRequestChangeItem
|
||||
op={op}
|
||||
conflicts={secretApprovalRequestDetails.conflicts}
|
||||
hasMerged={hasMerged}
|
||||
secretVersion={secretVersion}
|
||||
presentSecretVersionNumber={secret?.version || 0}
|
||||
newVersion={newVersion}
|
||||
key={`${op}-${index + 1}-${secretVersion?.id}`}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center space-x-6 rounded-lg bg-mineshaft-800 px-5 py-6">
|
||||
<SecretApprovalRequestAction
|
||||
canApprove={canApprove}
|
||||
approvalRequestId={secretApprovalRequestDetails.id}
|
||||
hasMerged={hasMerged}
|
||||
approvals={secretApprovalRequestDetails.policy.approvals || 0}
|
||||
status={secretApprovalRequestDetails.status}
|
||||
isMergable={isMergable}
|
||||
statusChangeByEmail={secretApprovalRequestDetails.statusChangedByUser?.email}
|
||||
enforcementLevel={secretApprovalRequestDetails.policy.enforcementLevel}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky top-0 w-1/5 pt-4" style={{ minWidth: "240px" }}>
|
||||
<div className="text-sm text-bunker-300">Reviewers</div>
|
||||
<div className="mt-2 flex flex-col space-y-2 text-sm">
|
||||
{secretApprovalRequestDetails?.policy?.approvers.map((requiredApprover) => {
|
||||
const status = reviewedUsers?.[requiredApprover.userId];
|
||||
return (
|
||||
<div
|
||||
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
|
||||
key={`required-approver-${requiredApprover.userId}`}
|
||||
>
|
||||
<div className="flex-grow text-sm">
|
||||
<Tooltip
|
||||
content={`${requiredApprover.firstName || ""} ${
|
||||
requiredApprover.lastName || ""
|
||||
}`}
|
||||
>
|
||||
<span>{requiredApprover?.email} </span>
|
||||
</Tooltip>
|
||||
<span className="text-red">*</span>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip content={status || ApprovalStatus.PENDING}>
|
||||
{getReviewedStatusSymbol(status)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{secretApprovalRequestDetails?.reviewers
|
||||
.filter(
|
||||
(reviewer) =>
|
||||
!secretApprovalRequestDetails?.policy?.approvers?.some(
|
||||
({ userId }) => userId === reviewer.userId
|
||||
)
|
||||
)
|
||||
.map((reviewer) => {
|
||||
const status = reviewedUsers?.[reviewer.userId];
|
||||
return (
|
||||
<div
|
||||
className="flex flex-nowrap items-center space-x-2 rounded bg-mineshaft-800 px-2 py-1"
|
||||
key={`required-approver-${reviewer.userId}`}
|
||||
>
|
||||
<div className="flex-grow text-sm">
|
||||
<Tooltip content={`${reviewer.firstName || ""} ${reviewer.lastName || ""}`}>
|
||||
<span>{reviewer?.email} </span>
|
||||
</Tooltip>
|
||||
<span className="text-red">*</span>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip content={status || ApprovalStatus.PENDING}>
|
||||
{getReviewedStatusSymbol(status)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretApprovalRequest } from "./SecretApprovalRequest";
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useGetAccessRequestsCount, useGetSecretApprovalRequestCount } from "@app/hooks/api";
|
||||
|
||||
import { AccessApprovalRequest } from "./-components/AccessApprovalRequest";
|
||||
import { ApprovalPolicyList } from "./-components/ApprovalPolicyList";
|
||||
import { SecretApprovalRequest } from "./-components/SecretApprovalRequest";
|
||||
|
||||
enum TabSection {
|
||||
SecretApprovalRequests = "approval-requests",
|
||||
SecretPolicies = "approval-rules",
|
||||
ResourcePolicies = "resource-rules",
|
||||
ResourceApprovalRequests = "resource-requests",
|
||||
Policies = "policies"
|
||||
}
|
||||
|
||||
export const SecretApprovalPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const projectSlug = currentWorkspace?.slug || "";
|
||||
const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({
|
||||
workspaceId: projectId
|
||||
});
|
||||
const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug });
|
||||
const defaultTab =
|
||||
(accessApprovalRequestCount?.pendingCount || 0) > (secretApprovalReqCount?.open || 0)
|
||||
? TabSection.ResourceApprovalRequests
|
||||
: TabSection.SecretApprovalRequests;
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: t("approval.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
<meta property="og:title" content={String(t("approval.og-title"))} />
|
||||
<meta name="og:description" content={String(t("approval.og-description"))} />
|
||||
</Helmet>
|
||||
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 px-6 text-white">
|
||||
<div className="flex items-center justify-between py-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h2 className="text-3xl font-semibold text-gray-200">Approval Workflows</h2>
|
||||
<p className="text-bunker-300">
|
||||
Create approval policies for any modifications to secrets in sensitive environments
|
||||
and folders.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-max justify-center">
|
||||
<a
|
||||
href="https://infisical.com/docs/documentation/platform/pr-workflows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
|
||||
Documentation{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.06rem] ml-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs defaultValue={defaultTab}>
|
||||
<TabList>
|
||||
<Tab value={TabSection.SecretApprovalRequests}>
|
||||
Secret Requests
|
||||
{Boolean(secretApprovalReqCount?.open) && (
|
||||
<Badge className="ml-2">{secretApprovalReqCount?.open}</Badge>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab value={TabSection.ResourceApprovalRequests}>
|
||||
Access Requests
|
||||
{Boolean(accessApprovalRequestCount?.pendingCount) && (
|
||||
<Badge className="ml-2">{accessApprovalRequestCount?.pendingCount}</Badge>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab value={TabSection.Policies}>Policies</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={TabSection.SecretApprovalRequests}>
|
||||
<SecretApprovalRequest />
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSection.ResourceApprovalRequests}>
|
||||
<AccessApprovalRequest projectId={projectId} projectSlug={projectSlug} />
|
||||
</TabPanel>
|
||||
<TabPanel value={TabSection.Policies}>
|
||||
<ApprovalPolicyList workspaceId={projectId} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SecretApprovalPageQueryParams = z.object({
|
||||
requestId: z.string().catch("")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/approval/"
|
||||
)({
|
||||
component: SecretApprovalPage,
|
||||
validateSearch: zodValidator(SecretApprovalPageQueryParams)
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faCaretDown,
|
||||
faChevronLeft,
|
||||
faClock,
|
||||
faPlus,
|
||||
faSave
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { GeneralPermissionPolicies } from "@app/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionPolicies";
|
||||
import { PermissionEmptyState } from "@app/components/permissions/ProjectRolePermissionsSection/PermissionEmptyState";
|
||||
import {
|
||||
formRolePermission2API,
|
||||
isConditionalSubjects,
|
||||
PROJECT_PERMISSION_OBJECT,
|
||||
projectRoleFormSchema,
|
||||
rolePermission2Form
|
||||
} from "@app/components/permissions/ProjectRolePermissionsSection/ProjectRoleModifySection.utils";
|
||||
import { renderConditionalComponents } from "@app/components/permissions/ProjectRolePermissionsSection/RolePermissionsSection";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import {
|
||||
useCreateIdentityProjectAdditionalPrivilege,
|
||||
useGetIdentityProjectPrivilegeDetails,
|
||||
useUpdateIdentityProjectAdditionalPrivilege
|
||||
} from "@app/hooks/api";
|
||||
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types";
|
||||
|
||||
type Props = {
|
||||
privilegeId?: string;
|
||||
identityId: string;
|
||||
onGoBack: () => void;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const formSchema = z.object({
|
||||
slug: z.string().optional(),
|
||||
temporaryAccess: z
|
||||
.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
.default({ isTemporary: false }),
|
||||
permissions: projectRoleFormSchema.shape.permissions
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
privilegeId = "",
|
||||
onGoBack,
|
||||
identityId,
|
||||
isDisabled
|
||||
}: Props) => {
|
||||
const isCreate = !privilegeId;
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const { data: privilegeDetails, isPending } = useGetIdentityProjectPrivilegeDetails({
|
||||
identityId,
|
||||
projectId,
|
||||
privilegeId
|
||||
});
|
||||
const { permission } = useProjectPermission();
|
||||
const isIdentityEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
|
||||
const form = useForm<TFormSchema>({
|
||||
values: privilegeDetails
|
||||
? {
|
||||
...privilegeDetails,
|
||||
permissions: rolePermission2Form(privilegeDetails.permissions),
|
||||
temporaryAccess: privilegeDetails.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryRange: privilegeDetails.temporaryRange || "",
|
||||
temporaryAccessEndTime: privilegeDetails.temporaryAccessEndTime || "",
|
||||
temporaryAccessStartTime: privilegeDetails.temporaryAccessStartTime || ""
|
||||
}
|
||||
: {
|
||||
isTemporary: privilegeDetails.isTemporary
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isDirty, isSubmitting }
|
||||
} = form;
|
||||
|
||||
const { mutateAsync: updateIdentityProjectAdditionalPrivilege } =
|
||||
useUpdateIdentityProjectAdditionalPrivilege();
|
||||
const { mutateAsync: createIdentityProjectAdditionalPrivilege } =
|
||||
useCreateIdentityProjectAdditionalPrivilege();
|
||||
|
||||
const onSubmit = async (el: TFormSchema) => {
|
||||
const accessType = !el.temporaryAccess.isTemporary
|
||||
? { isTemporary: false as const }
|
||||
: {
|
||||
isTemporary: true as const,
|
||||
temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative,
|
||||
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||
};
|
||||
|
||||
try {
|
||||
if (isCreate) {
|
||||
await createIdentityProjectAdditionalPrivilege({
|
||||
permissions: formRolePermission2API(el.permissions),
|
||||
identityId,
|
||||
projectId,
|
||||
slug: el.slug || undefined,
|
||||
type: accessType
|
||||
});
|
||||
createNotification({ type: "success", text: "Successfully created privilege" });
|
||||
} else {
|
||||
if (!projectId || !privilegeDetails?.id) return;
|
||||
await updateIdentityProjectAdditionalPrivilege({
|
||||
privilegeId: privilegeDetails.id,
|
||||
permissions: formRolePermission2API(el.permissions),
|
||||
projectId,
|
||||
identityId,
|
||||
slug: el.slug || undefined,
|
||||
type: accessType
|
||||
});
|
||||
createNotification({ type: "success", text: "Successfully updated privilege" });
|
||||
}
|
||||
onGoBack();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({ type: "error", text: "Failed to update privilege" });
|
||||
}
|
||||
};
|
||||
|
||||
const privilegeTemporaryAccess = form.watch("temporaryAccess");
|
||||
const isTemporary = privilegeTemporaryAccess?.isTemporary;
|
||||
const isExpired =
|
||||
privilegeTemporaryAccess?.isTemporary &&
|
||||
new Date() > new Date(privilegeTemporaryAccess.temporaryAccessEndTime || "");
|
||||
let text = "Permanent";
|
||||
let toolTipText = "Non-Expiring Access";
|
||||
|
||||
if (isTemporary) {
|
||||
if (isExpired) {
|
||||
text = "Access Expired";
|
||||
toolTipText = "Timed Access Expired";
|
||||
} else {
|
||||
text = formatDistance(
|
||||
new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
);
|
||||
toolTipText = `Until ${format(
|
||||
new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd hh:mm:ss aaa"
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const onNewPolicy = (selectedSubject: ProjectPermissionSub) => {
|
||||
const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`);
|
||||
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) {
|
||||
form.setValue(
|
||||
`permissions.${selectedSubject}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
[...rootPolicyValue, ...[]],
|
||||
{ shouldDirty: true, shouldTouch: true }
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
`permissions.${selectedSubject}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
[{}],
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
<FormProvider {...form}>
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
className="text-lg font-semibold text-mineshaft-100"
|
||||
variant="link"
|
||||
onClick={onGoBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center space-x-4">
|
||||
{isDirty && (
|
||||
<Button
|
||||
className="mr-4 text-mineshaft-300"
|
||||
variant="link"
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
onClick={onGoBack}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
|
||||
isDisabled={isSubmitting || !isDirty || isDisabled}
|
||||
isLoading={isSubmitting}
|
||||
leftIcon={<FontAwesomeIcon icon={faSave} />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
className="h-10 rounded-l-none"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
New policy
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
|
||||
{Object.keys(PROJECT_PERMISSION_OBJECT)
|
||||
.sort((a, b) =>
|
||||
PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title
|
||||
.toLowerCase()
|
||||
.localeCompare(
|
||||
PROJECT_PERMISSION_OBJECT[
|
||||
b as keyof typeof PROJECT_PERMISSION_OBJECT
|
||||
].title.toLowerCase()
|
||||
)
|
||||
)
|
||||
.map((permissionSubject) => (
|
||||
<DropdownMenuItem
|
||||
key={`permission-create-${permissionSubject}`}
|
||||
className="py-3"
|
||||
onClick={() => onNewPolicy(permissionSubject as ProjectPermissionSub)}
|
||||
>
|
||||
{PROJECT_PERMISSION_OBJECT[permissionSubject as ProjectPermissionSub].title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 border-b border-gray-800 p-4 pt-2 first:rounded-t-md last:rounded-b-md">
|
||||
<div className="text-lg">Overview</div>
|
||||
<p className="mb-4 text-sm text-mineshaft-300">
|
||||
Additional privileges take precedence over roles when permissions conflict
|
||||
</p>
|
||||
<div className="flex items-end space-x-6">
|
||||
<div className="w-full max-w-md">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Privilege Name" isOptional className="mb-0">
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isIdentityEditDisabled} asChild>
|
||||
<div className="w-full max-w-md flex-grow">
|
||||
<FormLabel label="Duration" />
|
||||
<Tooltip content={toolTipText}>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isIdentityEditDisabled}
|
||||
className={twMerge(
|
||||
"w-full border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure Timed Access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={form.control}
|
||||
defaultValue="1h"
|
||||
name="temporaryAccess.temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = form.getValues("temporaryAccess.temporaryRange");
|
||||
if (!temporaryRange) {
|
||||
form.setError(
|
||||
"temporaryAccess.temporaryRange",
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.clearErrors("temporaryAccess.temporaryRange");
|
||||
form.setValue(
|
||||
"temporaryAccess",
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
form.setValue(
|
||||
"temporaryAccess",
|
||||
{
|
||||
isTemporary: false
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-lg">Policies</div>
|
||||
{(isCreate || !isPending) && <PermissionEmptyState />}
|
||||
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map(
|
||||
(permissionSubject) => (
|
||||
<GeneralPermissionPolicies
|
||||
subject={permissionSubject}
|
||||
actions={PROJECT_PERMISSION_OBJECT[permissionSubject].actions}
|
||||
title={PROJECT_PERMISSION_OBJECT[permissionSubject].title}
|
||||
key={`project-permission-${permissionSubject}`}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{renderConditionalComponents(permissionSubject, isDisabled)}
|
||||
</GeneralPermissionPolicies>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import { faEllipsisV, faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteIdentityProjectAdditionalPrivilege } from "@app/hooks/api";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
import { useListIdentityProjectPrivileges } from "@app/hooks/api/identityProjectAdditionalPrivilege/queries";
|
||||
|
||||
import { IdentityProjectAdditionalPrivilegeModifySection } from "./IdentityProjectAdditionalPrivilegeModifySection";
|
||||
|
||||
type Props = {
|
||||
identityMembershipDetails: IdentityMembership;
|
||||
};
|
||||
|
||||
export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDetails }: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"deletePrivilege",
|
||||
"modifyPrivilege"
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
const identityId = identityMembershipDetails?.identity?.id;
|
||||
const projectId = identityMembershipDetails?.project?.id;
|
||||
|
||||
const { mutateAsync: deletePrivilege } = useDeleteIdentityProjectAdditionalPrivilege();
|
||||
|
||||
const { data: identityProjectPrivileges, isPending } = useListIdentityProjectPrivileges({
|
||||
identityId: identityMembershipDetails?.identity?.id,
|
||||
projectId: identityMembershipDetails?.project?.id
|
||||
});
|
||||
|
||||
const handlePrivilegeDelete = async () => {
|
||||
const { id } = popUp?.deletePrivilege?.data as { id: string };
|
||||
try {
|
||||
await deletePrivilege({
|
||||
privilegeId: id,
|
||||
projectId,
|
||||
identityId
|
||||
});
|
||||
createNotification({ type: "success", text: "Successfully removed the privilege" });
|
||||
handlePopUpClose("deletePrivilege");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({ type: "error", text: "Failed to delete privilege" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<AnimatePresence>
|
||||
{popUp?.modifyPrivilege.isOpen ? (
|
||||
<motion.div
|
||||
key="privilege-modify"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="absolute min-h-[10rem] w-full"
|
||||
>
|
||||
<IdentityProjectAdditionalPrivilegeModifySection
|
||||
onGoBack={() => handlePopUpClose("modifyPrivilege")}
|
||||
identityId={identityId}
|
||||
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
|
||||
isDisabled={permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Identity, {
|
||||
identityId
|
||||
})
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="privilege-list"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 0 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
className="absolute w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">
|
||||
Project Additional Privileges
|
||||
</h3>
|
||||
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Add Privilege"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("modifyPrivilege");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Duration</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && (
|
||||
<TableSkeleton columns={3} innerKey="user-project-identity-memberships" />
|
||||
)}
|
||||
{!isPending &&
|
||||
identityProjectPrivileges?.map((privilegeDetails) => {
|
||||
const isTemporary = privilegeDetails?.isTemporary;
|
||||
const isExpired =
|
||||
privilegeDetails.isTemporary &&
|
||||
new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
|
||||
|
||||
let text = "Permanent";
|
||||
let toolTipText = "Non-Expiring Access";
|
||||
if (privilegeDetails.isTemporary) {
|
||||
if (isExpired) {
|
||||
text = "Access Expired";
|
||||
toolTipText = "Timed Access Expired";
|
||||
} else {
|
||||
text = formatDistance(
|
||||
new Date(privilegeDetails.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
);
|
||||
toolTipText = `Until ${format(
|
||||
new Date(privilegeDetails.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd hh:mm:ss aaa"
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={`user-project-privilege-${privilegeDetails?.id}`}
|
||||
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
handlePopUpOpen("modifyPrivilege", privilegeDetails);
|
||||
}
|
||||
}}
|
||||
onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)}
|
||||
>
|
||||
<Td>{privilegeDetails.slug}</Td>
|
||||
<Td>
|
||||
<Tooltip asChild={false} content={toolTipText}>
|
||||
<Tag
|
||||
className={twMerge(
|
||||
"capitalize",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Remove Role"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
colorSchema="danger"
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handlePopUpOpen("deletePrivilege", {
|
||||
id: privilegeDetails?.id,
|
||||
slug: privilegeDetails?.slug
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<IconButton ariaLabel="more-icon" variant="plain">
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && !identityProjectPrivileges?.length && (
|
||||
<EmptyState title="This identity has no additional privileges" icon={faFolder} />
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePrivilege.isOpen}
|
||||
deleteKey="remove"
|
||||
title={`Do you want to remove privilege ${
|
||||
(popUp?.deletePrivilege?.data as { slug: string; id: string })?.slug
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
|
||||
onDeleteApproved={() => handlePrivilegeDelete()}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { IdentityProjectAdditionalPrivilegeSection } from "./IdentityProjectAdditionalPrivilegeSection";
|
||||
@@ -0,0 +1,238 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { formatProjectRoleName } from "@app/helpers/roles";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
import { TProjectRole } from "@app/hooks/api/roles/types";
|
||||
|
||||
import { IdentityRoleModify } from "./IdentityRoleModify";
|
||||
|
||||
type Props = {
|
||||
identityMembershipDetails: IdentityMembership;
|
||||
isMembershipDetailsLoading?: boolean;
|
||||
};
|
||||
|
||||
export const IdentityRoleDetailsSection = ({
|
||||
identityMembershipDetails,
|
||||
isMembershipDetailsLoading
|
||||
}: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"deleteRole",
|
||||
"modifyRole"
|
||||
] as const);
|
||||
const { mutateAsync: updateIdentityWorkspaceRole } = useUpdateIdentityWorkspaceRole();
|
||||
|
||||
const handleRoleDelete = async () => {
|
||||
const { id } = popUp?.deleteRole?.data as TProjectRole;
|
||||
try {
|
||||
const updatedRoles = identityMembershipDetails?.roles?.filter((el) => el.id !== id);
|
||||
await updateIdentityWorkspaceRole({
|
||||
workspaceId: currentWorkspace?.id || "",
|
||||
identityId: identityMembershipDetails.identity.id,
|
||||
roles: updatedRoles.map(
|
||||
({
|
||||
role,
|
||||
customRoleSlug,
|
||||
isTemporary,
|
||||
temporaryMode,
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime,
|
||||
temporaryAccessEndTime
|
||||
}) => ({
|
||||
role: role === "custom" ? customRoleSlug : role,
|
||||
...(isTemporary
|
||||
? {
|
||||
isTemporary,
|
||||
temporaryMode,
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime,
|
||||
temporaryAccessEndTime
|
||||
}
|
||||
: {
|
||||
isTemporary
|
||||
})
|
||||
})
|
||||
)
|
||||
});
|
||||
createNotification({ type: "success", text: "Successfully removed role" });
|
||||
handlePopUpClose("deleteRole");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({ type: "error", text: "Failed to delete role" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Project Roles</h3>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId: identityMembershipDetails.identity.id
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Edit Role(s)"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("modifyRole");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Role</Th>
|
||||
<Th>Duration</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isMembershipDetailsLoading && (
|
||||
<TableSkeleton columns={3} innerKey="user-project-identities" />
|
||||
)}
|
||||
{!isMembershipDetailsLoading &&
|
||||
identityMembershipDetails?.roles?.map((roleDetails) => {
|
||||
const isTemporary = roleDetails?.isTemporary;
|
||||
const isExpired =
|
||||
roleDetails.isTemporary &&
|
||||
new Date() > new Date(roleDetails.temporaryAccessEndTime || "");
|
||||
|
||||
let text = "Permanent";
|
||||
let toolTipText = "Non-Expiring Access";
|
||||
if (roleDetails.isTemporary) {
|
||||
if (isExpired) {
|
||||
text = "Access Expired";
|
||||
toolTipText = "Timed Access Expired";
|
||||
} else {
|
||||
text = formatDistance(
|
||||
new Date(roleDetails.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
);
|
||||
toolTipText = `Until ${format(
|
||||
new Date(roleDetails.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd hh:mm:ss aaa"
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr className="group h-10" key={`user-project-identity-${roleDetails?.id}`}>
|
||||
<Td className="capitalize">
|
||||
{roleDetails.role === "custom"
|
||||
? roleDetails.customRoleName
|
||||
: formatProjectRoleName(roleDetails.role)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip asChild={false} content={toolTipText}>
|
||||
<Tag
|
||||
className={twMerge(
|
||||
"capitalize",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId: identityMembershipDetails.identity.id
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Remove Role"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
colorSchema="danger"
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteRole", {
|
||||
id: roleDetails?.id,
|
||||
slug: roleDetails?.customRoleName || roleDetails?.role
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isMembershipDetailsLoading && !identityMembershipDetails?.roles?.length && (
|
||||
<EmptyState title="This user has no roles" icon={faFolder} />
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteRole.isOpen}
|
||||
deleteKey="remove"
|
||||
title={`Do you want to remove role ${(popUp?.deleteRole?.data as TProjectRole)?.slug}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)}
|
||||
onDeleteApproved={() => handleRoleDelete()}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp.modifyRole.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("modifyRole", isOpen)}
|
||||
>
|
||||
<ModalContent
|
||||
title="Roles"
|
||||
subTitle="Select one or more of the pre-defined or custom roles to configure project permissions."
|
||||
>
|
||||
<IdentityRoleModify identityProjectMembership={identityMembershipDetails} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,332 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
|
||||
const roleFormSchema = z.object({
|
||||
roles: z
|
||||
.object({
|
||||
slug: z.string(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
})
|
||||
.array()
|
||||
});
|
||||
type TRoleForm = z.infer<typeof roleFormSchema>;
|
||||
|
||||
type Props = {
|
||||
identityProjectMembership: IdentityMembership;
|
||||
};
|
||||
|
||||
export const IdentityRoleModify = ({ identityProjectMembership }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const { permission } = useProjectPermission();
|
||||
const isIdentityEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Identity
|
||||
);
|
||||
|
||||
const roleForm = useForm<TRoleForm>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
values: {
|
||||
roles: identityProjectMembership?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
|
||||
slug: customRoleSlug || role,
|
||||
temporaryAccess: dto.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryRange: dto.temporaryRange,
|
||||
temporaryAccessEndTime: dto.temporaryAccessEndTime,
|
||||
temporaryAccessStartTime: dto.temporaryAccessStartTime
|
||||
}
|
||||
: {
|
||||
isTemporary: dto.isTemporary
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
const selectedRoleList = useFieldArray({
|
||||
name: "roles",
|
||||
control: roleForm.control
|
||||
});
|
||||
|
||||
const formRoleField = roleForm.watch("roles");
|
||||
|
||||
const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TRoleForm) => {
|
||||
if (updateIdentityWorkspaceRole.isPending) return;
|
||||
|
||||
const sanitizedRoles = data.roles.map((el) => {
|
||||
const { isTemporary } = el.temporaryAccess;
|
||||
if (!isTemporary) {
|
||||
return { role: el.slug, isTemporary: false as const };
|
||||
}
|
||||
return {
|
||||
role: el.slug,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await updateIdentityWorkspaceRole.mutateAsync({
|
||||
workspaceId,
|
||||
identityId: identityProjectMembership.identity.id,
|
||||
roles: sanitizedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated roles", type: "success" });
|
||||
} catch {
|
||||
createNotification({ text: "Failed to update roles", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (isRolesLoading)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{selectedRoleList.fields.map(({ id }, index) => {
|
||||
const { temporaryAccess } = formRoleField[index];
|
||||
const isTemporary = temporaryAccess?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccess.isTemporary &&
|
||||
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
name={`roles.${index}.slug`}
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
isDisabled={isIdentityEditDisabled}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500"
|
||||
containerClassName="w-1/2"
|
||||
>
|
||||
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
|
||||
<SelectItem value={slug} key={projectRoleId}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isIdentityEditDisabled} asChild>
|
||||
<div className="flex-grow">
|
||||
<Tooltip
|
||||
content={
|
||||
temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Timed Access Expired"
|
||||
: `Until ${format(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`
|
||||
: "Non-Expiring Access"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isIdentityEditDisabled}
|
||||
className={twMerge(
|
||||
"w-full border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Access Expired"
|
||||
: formatDistance(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
)
|
||||
: "Permanent"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure Timed Access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
defaultValue="1h"
|
||||
name={`roles.${index}.temporaryAccess.temporaryRange`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = roleForm.getValues(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
roleForm.setError(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`,
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
|
||||
roleForm.setValue(
|
||||
`roles.${index}.temporaryAccess`,
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccess.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
roleForm.setValue(`roles.${index}.temporaryAccess`, {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-role"
|
||||
isDisabled={isIdentityEditDisabled || selectedRoleList.fields.length === 1}
|
||||
onClick={() => {
|
||||
if (selectedRoleList.fields.length > 1) {
|
||||
selectedRoleList.remove(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Identity}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
isDisabled={!isAllowed}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
selectedRoleList.append({
|
||||
slug: ProjectMembershipRole.Member,
|
||||
temporaryAccess: { isTemporary: false }
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Button
|
||||
type="submit"
|
||||
className={twMerge(
|
||||
"transition-all",
|
||||
"cursor-default opacity-0",
|
||||
roleForm.formState.isDirty && "cursor-pointer opacity-100"
|
||||
)}
|
||||
isDisabled={!roleForm.formState.isDirty}
|
||||
isLoading={roleForm.formState.isSubmitting}
|
||||
>
|
||||
Save Roles
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { IdentityRoleDetailsSection } from "./IdentityRoleDetailsSection";
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { createFileRoute, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal, EmptyState, Spinner } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { getProjectTitle } from "@app/helpers/project";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useDeleteIdentityFromWorkspace,
|
||||
useGetWorkspaceIdentityMembershipDetails
|
||||
} from "@app/hooks/api";
|
||||
|
||||
import { IdentityProjectAdditionalPrivilegeSection } from "./-components/IdentityProjectAdditionalPrivilegeSection";
|
||||
import { IdentityRoleDetailsSection } from "./-components/IdentityRoleDetailsSection";
|
||||
|
||||
export const IdentityDetailsPage = withProjectPermission(
|
||||
() => {
|
||||
const navigate = useNavigate();
|
||||
const identityId = useParams({
|
||||
strict: false,
|
||||
select: (el) => el.identityId as string
|
||||
});
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: identityMembershipDetails, isPending: isMembershipDetailsLoading } =
|
||||
useGetWorkspaceIdentityMembershipDetails(workspaceId, identityId);
|
||||
|
||||
const { mutateAsync: deleteMutateAsync, isPending: isDeletingIdentity } =
|
||||
useDeleteIdentityFromWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"deleteIdentity",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const onRemoveIdentitySubmit = async () => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
identityId,
|
||||
workspaceId
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully removed identity from project",
|
||||
type: "success"
|
||||
});
|
||||
handlePopUpClose("deleteIdentity");
|
||||
navigate({
|
||||
to: `/${currentWorkspace.type}/$projectId/access` as const,
|
||||
params: {
|
||||
projectId: workspaceId
|
||||
},
|
||||
search: {
|
||||
selectedTab: "identities"
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isMembershipDetailsLoading) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-24">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex max-w-7xl flex-col justify-between bg-bunker-800 p-6 text-white">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
variant="link"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: `/${currentWorkspace.type}/$projectId/access` as const,
|
||||
params: {
|
||||
projectId: workspaceId
|
||||
},
|
||||
search: {
|
||||
selectedTab: "identities"
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="mb-4"
|
||||
>
|
||||
{currentWorkspace?.type ? getProjectTitle(currentWorkspace?.type) : "Project"} Access
|
||||
Control
|
||||
</Button>
|
||||
</div>
|
||||
{identityMembershipDetails ? (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold text-mineshaft-100">
|
||||
Project Identity Access
|
||||
</h3>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId: identityMembershipDetails?.identity?.id
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Remove from project"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
isDisabled={!isAllowed}
|
||||
isLoading={isDeletingIdentity}
|
||||
onClick={() => handlePopUpOpen("deleteIdentity")}
|
||||
>
|
||||
Remove Identity
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-12">
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-gray-400">Name</span>
|
||||
{identityMembershipDetails && (
|
||||
<p className="text-lg capitalize">
|
||||
{identityMembershipDetails?.identity?.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-400">
|
||||
Joined on{" "}
|
||||
{identityMembershipDetails?.createdAt &&
|
||||
format(new Date(identityMembershipDetails?.createdAt || ""), "yyyy-MM-dd")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<IdentityRoleDetailsSection
|
||||
identityMembershipDetails={identityMembershipDetails}
|
||||
isMembershipDetailsLoading={isMembershipDetailsLoading}
|
||||
/>
|
||||
<IdentityProjectAdditionalPrivilegeSection
|
||||
identityMembershipDetails={identityMembershipDetails}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteIdentity.isOpen}
|
||||
title={`Are you sure want to remove ${identityMembershipDetails?.identity?.name} from the project?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
|
||||
deleteKey="remove"
|
||||
onDeleteApproved={() => onRemoveIdentitySubmit()}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState title="Error: Unable to find the identity." className="py-12" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{
|
||||
action: ProjectPermissionActions.Read,
|
||||
subject: ProjectPermissionSub.Identity
|
||||
}
|
||||
);
|
||||
|
||||
const IdentityDetailsRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<IdentityDetailsPage />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/identities/$identityId/"
|
||||
)({
|
||||
component: IdentityDetailsRoute
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { faEllipsisV, faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useUser
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useDeleteProjectUserAdditionalPrivilege,
|
||||
useListProjectUserPrivileges
|
||||
} from "@app/hooks/api";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
|
||||
import { MembershipProjectAdditionalPrivilegeModifySection } from "./MembershipProjectAdditionalPrivilegeModifySection";
|
||||
|
||||
type Props = {
|
||||
membershipDetails: TWorkspaceUser;
|
||||
};
|
||||
|
||||
export const MemberProjectAdditionalPrivilegeSection = ({ membershipDetails }: Props) => {
|
||||
const { user } = useUser();
|
||||
const userId = user?.id;
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"deletePrivilege",
|
||||
"modifyPrivilege"
|
||||
] as const);
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const { mutateAsync: deletePrivilege } = useDeleteProjectUserAdditionalPrivilege();
|
||||
|
||||
const { data: userProjectPrivileges, isPending } = useListProjectUserPrivileges(
|
||||
membershipDetails?.id
|
||||
);
|
||||
|
||||
const isOwnProjectMembershipDetails = userId === membershipDetails?.user?.id;
|
||||
|
||||
const handlePrivilegeDelete = async () => {
|
||||
const { id } = popUp?.deletePrivilege?.data as { id: string };
|
||||
try {
|
||||
await deletePrivilege({
|
||||
privilegeId: id,
|
||||
projectMembershipId: membershipDetails.id
|
||||
});
|
||||
createNotification({ type: "success", text: "Successfully removed the privilege" });
|
||||
handlePopUpClose("deletePrivilege");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({ type: "error", text: "Failed to delete privilege" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<AnimatePresence>
|
||||
{popUp?.modifyPrivilege.isOpen ? (
|
||||
<motion.div
|
||||
key="privilege-modify"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="absolute min-h-[10rem] w-full"
|
||||
>
|
||||
<MembershipProjectAdditionalPrivilegeModifySection
|
||||
onGoBack={() => handlePopUpClose("modifyPrivilege")}
|
||||
projectMembershipId={membershipDetails?.id}
|
||||
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
|
||||
isDisabled={
|
||||
isOwnProjectMembershipDetails ||
|
||||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member)
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="privilege-list"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 0 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
className="absolute w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">
|
||||
Project Additional Privileges
|
||||
</h3>
|
||||
{userId !== membershipDetails?.user?.id &&
|
||||
membershipDetails?.status !== "invited" && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Member}
|
||||
renderTooltip
|
||||
allowedLabel="Add Privilege"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("modifyPrivilege");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Duration</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
|
||||
{!isPending &&
|
||||
userProjectPrivileges?.map((privilegeDetails) => {
|
||||
const isTemporary = privilegeDetails?.isTemporary;
|
||||
const isExpired =
|
||||
privilegeDetails.isTemporary &&
|
||||
new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
|
||||
|
||||
let text = "Permanent";
|
||||
let toolTipText = "Non-Expiring Access";
|
||||
if (privilegeDetails.isTemporary) {
|
||||
if (isExpired) {
|
||||
text = "Access Expired";
|
||||
toolTipText = "Timed Access Expired";
|
||||
} else {
|
||||
text = formatDistance(
|
||||
new Date(privilegeDetails.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
);
|
||||
toolTipText = `Until ${format(
|
||||
new Date(privilegeDetails.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd hh:mm:ss aaa"
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={`user-project-privilege-${privilegeDetails?.id}`}
|
||||
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") {
|
||||
handlePopUpOpen("modifyPrivilege", privilegeDetails);
|
||||
}
|
||||
}}
|
||||
onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)}
|
||||
>
|
||||
<Td>{privilegeDetails.slug}</Td>
|
||||
<Td>
|
||||
<Tooltip asChild={false} content={toolTipText}>
|
||||
<Tag
|
||||
className={twMerge(
|
||||
"capitalize",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Member}
|
||||
renderTooltip
|
||||
allowedLabel="Remove Role"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete-icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handlePopUpOpen("deletePrivilege", {
|
||||
id: privilegeDetails?.id,
|
||||
slug: privilegeDetails?.slug
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<IconButton
|
||||
ariaLabel="more-icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isPending && !userProjectPrivileges?.length && (
|
||||
<EmptyState title="This user has no additional privileges" icon={faFolder} />
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deletePrivilege.isOpen}
|
||||
deleteKey="remove"
|
||||
title={`Do you want to remove role ${
|
||||
(popUp?.deletePrivilege?.data as { slug: string; id: string })?.slug
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
|
||||
onDeleteApproved={() => handlePrivilegeDelete()}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,429 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import {
|
||||
faCaretDown,
|
||||
faChevronLeft,
|
||||
faClock,
|
||||
faPlus,
|
||||
faSave
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { GeneralPermissionPolicies } from "@app/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionPolicies";
|
||||
import { PermissionEmptyState } from "@app/components/permissions/ProjectRolePermissionsSection/PermissionEmptyState";
|
||||
import {
|
||||
formRolePermission2API,
|
||||
isConditionalSubjects,
|
||||
PROJECT_PERMISSION_OBJECT,
|
||||
projectRoleFormSchema,
|
||||
rolePermission2Form
|
||||
} from "@app/components/permissions/ProjectRolePermissionsSection/ProjectRoleModifySection.utils";
|
||||
import { renderConditionalComponents } from "@app/components/permissions/ProjectRolePermissionsSection/RolePermissionsSection";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import {
|
||||
useCreateProjectUserAdditionalPrivilege,
|
||||
useGetProjectUserPrivilegeDetails,
|
||||
useUpdateProjectUserAdditionalPrivilege
|
||||
} from "@app/hooks/api";
|
||||
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types";
|
||||
|
||||
type Props = {
|
||||
privilegeId?: string;
|
||||
projectMembershipId: string;
|
||||
onGoBack: () => void;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const formSchema = z.object({
|
||||
slug: z.string().optional(),
|
||||
temporaryAccess: z
|
||||
.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
.default({ isTemporary: false }),
|
||||
permissions: projectRoleFormSchema.shape.permissions
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
export const MembershipProjectAdditionalPrivilegeModifySection = ({
|
||||
privilegeId,
|
||||
onGoBack,
|
||||
projectMembershipId,
|
||||
isDisabled
|
||||
}: Props) => {
|
||||
const isCreate = !privilegeId;
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const { data: privilegeDetails, isPending } = useGetProjectUserPrivilegeDetails(
|
||||
privilegeId || ""
|
||||
);
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Member
|
||||
);
|
||||
|
||||
const form = useForm<TFormSchema>({
|
||||
values: privilegeDetails
|
||||
? {
|
||||
...privilegeDetails,
|
||||
permissions: rolePermission2Form(privilegeDetails.permissions),
|
||||
temporaryAccess: privilegeDetails.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryRange: privilegeDetails.temporaryRange || "",
|
||||
temporaryAccessEndTime: privilegeDetails.temporaryAccessEndTime || "",
|
||||
temporaryAccessStartTime: privilegeDetails.temporaryAccessStartTime || ""
|
||||
}
|
||||
: {
|
||||
isTemporary: privilegeDetails.isTemporary
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isDirty, isSubmitting }
|
||||
} = form;
|
||||
|
||||
const { mutateAsync: updateUserProjectAdditionalPrivilege } =
|
||||
useUpdateProjectUserAdditionalPrivilege();
|
||||
const { mutateAsync: createUserProjectAdditionalPrivilege } =
|
||||
useCreateProjectUserAdditionalPrivilege();
|
||||
|
||||
const onSubmit = async (el: TFormSchema) => {
|
||||
const accessType = !el.temporaryAccess.isTemporary
|
||||
? { isTemporary: false as const }
|
||||
: {
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative,
|
||||
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||
};
|
||||
|
||||
try {
|
||||
if (isCreate) {
|
||||
await createUserProjectAdditionalPrivilege({
|
||||
permissions: formRolePermission2API(el.permissions),
|
||||
projectMembershipId,
|
||||
slug: el.slug || undefined,
|
||||
type: accessType
|
||||
});
|
||||
createNotification({ type: "success", text: "Successfully created privilege" });
|
||||
} else {
|
||||
if (!projectId || !privilegeDetails?.id) return;
|
||||
await updateUserProjectAdditionalPrivilege({
|
||||
privilegeId: privilegeDetails.id,
|
||||
permissions: formRolePermission2API(el.permissions),
|
||||
projectMembershipId,
|
||||
slug: el.slug || undefined,
|
||||
type: accessType
|
||||
});
|
||||
createNotification({ type: "success", text: "Successfully updated privilege" });
|
||||
}
|
||||
onGoBack();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({ type: "error", text: "Failed to update privilege" });
|
||||
}
|
||||
};
|
||||
|
||||
const onNewPolicy = (selectedSubject: ProjectPermissionSub) => {
|
||||
const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`);
|
||||
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) {
|
||||
form.setValue(
|
||||
`permissions.${selectedSubject}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
[...rootPolicyValue, ...[]],
|
||||
{ shouldDirty: true, shouldTouch: true }
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
`permissions.${selectedSubject}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
[{}],
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const privilegeTemporaryAccess = form.watch("temporaryAccess");
|
||||
const isTemporary = privilegeTemporaryAccess?.isTemporary;
|
||||
const isExpired =
|
||||
privilegeTemporaryAccess?.isTemporary &&
|
||||
new Date() > new Date(privilegeTemporaryAccess.temporaryAccessEndTime || "");
|
||||
let text = "Permanent";
|
||||
let toolTipText = "Non-Expiring Access";
|
||||
|
||||
if (isTemporary) {
|
||||
if (isExpired) {
|
||||
text = "Access Expired";
|
||||
toolTipText = "Timed Access Expired";
|
||||
} else {
|
||||
text = formatDistance(
|
||||
new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
);
|
||||
toolTipText = `Until ${format(
|
||||
new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd hh:mm:ss aaa"
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
<FormProvider {...form}>
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
className="text-lg font-semibold text-mineshaft-100"
|
||||
variant="link"
|
||||
onClick={onGoBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center space-x-4">
|
||||
{isDirty && (
|
||||
<Button
|
||||
className="mr-4 text-mineshaft-300"
|
||||
variant="link"
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
onClick={onGoBack}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
|
||||
isDisabled={isSubmitting || !isDirty || isDisabled}
|
||||
isLoading={isSubmitting}
|
||||
leftIcon={<FontAwesomeIcon icon={faSave} />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
className="h-10 rounded-l-none"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
>
|
||||
New policy
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
|
||||
{Object.keys(PROJECT_PERMISSION_OBJECT)
|
||||
.sort((a, b) =>
|
||||
PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title
|
||||
.toLowerCase()
|
||||
.localeCompare(
|
||||
PROJECT_PERMISSION_OBJECT[
|
||||
b as keyof typeof PROJECT_PERMISSION_OBJECT
|
||||
].title.toLowerCase()
|
||||
)
|
||||
)
|
||||
.map((subject) => (
|
||||
<DropdownMenuItem
|
||||
key={`permission-create-${subject}`}
|
||||
className="py-3"
|
||||
onClick={() => onNewPolicy(subject as ProjectPermissionSub)}
|
||||
>
|
||||
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 border-b border-gray-800 p-4 pt-2 first:rounded-t-md last:rounded-b-md">
|
||||
<div className="text-lg">Overview</div>
|
||||
<p className="mb-4 text-sm text-mineshaft-300">
|
||||
Additional privileges take precedence over roles when permissions conflict
|
||||
</p>
|
||||
<div className="flex items-end space-x-6">
|
||||
<div className="w-full max-w-md">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormControl label="Privilege Name" isOptional className="mb-0">
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled} asChild>
|
||||
<div className="w-full max-w-md flex-grow">
|
||||
<FormLabel label="Duration" />
|
||||
<Tooltip content={toolTipText}>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"w-full border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure Timed Access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={form.control}
|
||||
defaultValue="1h"
|
||||
name="temporaryAccess.temporaryRange"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = form.getValues("temporaryAccess.temporaryRange");
|
||||
if (!temporaryRange) {
|
||||
form.setError(
|
||||
"temporaryAccess.temporaryRange",
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
form.clearErrors("temporaryAccess.temporaryRange");
|
||||
form.setValue(
|
||||
"temporaryAccess",
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
form.setValue(
|
||||
"temporaryAccess",
|
||||
{
|
||||
isTemporary: false
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-lg">Policies</div>
|
||||
{(isCreate || !isPending) && <PermissionEmptyState />}
|
||||
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
|
||||
<GeneralPermissionPolicies
|
||||
subject={subject}
|
||||
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
|
||||
title={PROJECT_PERMISSION_OBJECT[subject].title}
|
||||
key={`project-permission-${subject}`}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{renderConditionalComponents(subject, isDisabled)}
|
||||
</GeneralPermissionPolicies>
|
||||
))}
|
||||
</div>
|
||||
</FormProvider>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { MemberProjectAdditionalPrivilegeSection } from "./MemberProjectAdditionalPrivilegeSection";
|
||||
@@ -0,0 +1,249 @@
|
||||
import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useUser,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { formatProjectRoleName } from "@app/helpers/roles";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useUpdateUserWorkspaceRole } from "@app/hooks/api";
|
||||
import { TProjectRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
|
||||
import { MemberRoleModify } from "./MemberRoleModify";
|
||||
|
||||
type Props = {
|
||||
membershipDetails: TWorkspaceUser;
|
||||
isMembershipDetailsLoading?: boolean;
|
||||
onOpenUpgradeModal: () => void;
|
||||
};
|
||||
|
||||
export const MemberRoleDetailsSection = ({
|
||||
membershipDetails,
|
||||
isMembershipDetailsLoading,
|
||||
onOpenUpgradeModal
|
||||
}: Props) => {
|
||||
const { user } = useUser();
|
||||
const userId = user?.id;
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
"deleteRole",
|
||||
"modifyRole"
|
||||
] as const);
|
||||
const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
|
||||
|
||||
const isOwnProjectMembershipDetails = userId === membershipDetails?.user?.id;
|
||||
|
||||
const handleRoleDelete = async () => {
|
||||
const { id } = popUp?.deleteRole?.data as TProjectRole;
|
||||
try {
|
||||
const updatedRoles = membershipDetails?.roles?.filter((el) => el.id !== id);
|
||||
await updateUserWorkspaceRole({
|
||||
workspaceId: currentWorkspace?.id || "",
|
||||
roles: updatedRoles.map(
|
||||
({
|
||||
role,
|
||||
customRoleSlug,
|
||||
isTemporary,
|
||||
temporaryMode,
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime,
|
||||
temporaryAccessEndTime
|
||||
}) => ({
|
||||
role: role === "custom" ? customRoleSlug : role,
|
||||
...(isTemporary
|
||||
? {
|
||||
isTemporary,
|
||||
temporaryMode,
|
||||
temporaryRange,
|
||||
temporaryAccessStartTime,
|
||||
temporaryAccessEndTime
|
||||
}
|
||||
: {
|
||||
isTemporary
|
||||
})
|
||||
})
|
||||
),
|
||||
membershipId: membershipDetails.id
|
||||
});
|
||||
createNotification({ type: "success", text: "Successfully removed role" });
|
||||
handlePopUpClose("deleteRole");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({ type: "error", text: "Failed to delete role" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Project Roles</h3>
|
||||
{!isOwnProjectMembershipDetails && membershipDetails?.status !== "invited" && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Member}
|
||||
renderTooltip
|
||||
allowedLabel="Edit Role(s)"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("modifyRole");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Role</Th>
|
||||
<Th>Duration</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isMembershipDetailsLoading && (
|
||||
<TableSkeleton columns={3} innerKey="user-project-memberships" />
|
||||
)}
|
||||
{!isMembershipDetailsLoading &&
|
||||
membershipDetails?.roles?.map((roleDetails) => {
|
||||
const isTemporary = roleDetails?.isTemporary;
|
||||
const isExpired =
|
||||
roleDetails.isTemporary &&
|
||||
new Date() > new Date(roleDetails.temporaryAccessEndTime || "");
|
||||
|
||||
let text = "Permanent";
|
||||
let toolTipText = "Non-Expiring Access";
|
||||
if (roleDetails.isTemporary) {
|
||||
if (isExpired) {
|
||||
text = "Access Expired";
|
||||
toolTipText = "Timed Access Expired";
|
||||
} else {
|
||||
text = formatDistance(
|
||||
new Date(roleDetails.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
);
|
||||
toolTipText = `Until ${format(
|
||||
new Date(roleDetails.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd hh:mm:ss aaa"
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr className="group h-10" key={`user-project-membership-${roleDetails?.id}`}>
|
||||
<Td className="capitalize">
|
||||
{roleDetails.role === "custom"
|
||||
? roleDetails.customRoleName
|
||||
: formatProjectRoleName(roleDetails.role)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip asChild={false} content={toolTipText}>
|
||||
<Tag
|
||||
className={twMerge(
|
||||
"capitalize",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Member}
|
||||
renderTooltip
|
||||
allowedLabel="Remove Role"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
colorSchema="danger"
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteRole", {
|
||||
id: roleDetails?.id,
|
||||
slug: roleDetails?.customRoleName || roleDetails?.role
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isMembershipDetailsLoading && !membershipDetails?.roles?.length && (
|
||||
<EmptyState title="This user has no roles" icon={faFolder} />
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteRole.isOpen}
|
||||
deleteKey="remove"
|
||||
title={`Do you want to remove role ${(popUp?.deleteRole?.data as TProjectRole)?.slug}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)}
|
||||
onDeleteApproved={() => handleRoleDelete()}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp.modifyRole.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("modifyRole", isOpen)}
|
||||
>
|
||||
<ModalContent
|
||||
title="Roles"
|
||||
subTitle="Select one or more of the pre-defined or custom roles to configure project permissions."
|
||||
>
|
||||
<MemberRoleModify
|
||||
projectMember={membershipDetails}
|
||||
onOpenUpgradeModal={onOpenUpgradeModal}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format, formatDistance } from "date-fns";
|
||||
import ms from "ms";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Spinner,
|
||||
Tag,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
|
||||
|
||||
const roleFormSchema = z.object({
|
||||
roles: z
|
||||
.object({
|
||||
slug: z.string(),
|
||||
temporaryAccess: z.discriminatedUnion("isTemporary", [
|
||||
z.object({
|
||||
isTemporary: z.literal(true),
|
||||
temporaryRange: z.string().min(1),
|
||||
temporaryAccessStartTime: z.string().datetime(),
|
||||
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||
}),
|
||||
z.object({
|
||||
isTemporary: z.literal(false)
|
||||
})
|
||||
])
|
||||
})
|
||||
.array()
|
||||
});
|
||||
type TRoleForm = z.infer<typeof roleFormSchema>;
|
||||
|
||||
type Props = {
|
||||
projectMember: TWorkspaceUser;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
|
||||
export const MemberRoleModify = ({ projectMember, onOpenUpgradeModal }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Member
|
||||
);
|
||||
|
||||
const roleForm = useForm<TRoleForm>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
values: {
|
||||
roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
|
||||
slug: customRoleSlug || role,
|
||||
temporaryAccess: dto.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryRange: dto.temporaryRange,
|
||||
temporaryAccessEndTime: dto.temporaryAccessEndTime,
|
||||
temporaryAccessStartTime: dto.temporaryAccessStartTime
|
||||
}
|
||||
: {
|
||||
isTemporary: dto.isTemporary
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
const selectedRoleList = useFieldArray({
|
||||
name: "roles",
|
||||
control: roleForm.control
|
||||
});
|
||||
|
||||
const formRoleField = roleForm.watch("roles");
|
||||
|
||||
const updateMembershipRole = useUpdateUserWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TRoleForm) => {
|
||||
if (updateMembershipRole.isPending) return;
|
||||
|
||||
const sanitizedRoles = data.roles.map((el) => {
|
||||
const { isTemporary } = el.temporaryAccess;
|
||||
if (!isTemporary) {
|
||||
return { role: el.slug, isTemporary: false as const };
|
||||
}
|
||||
return {
|
||||
role: el.slug,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
const hasCustomRoleSelected = sanitizedRoles.some(
|
||||
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
|
||||
);
|
||||
|
||||
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
|
||||
onOpenUpgradeModal(
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMembershipRole.mutateAsync({
|
||||
workspaceId,
|
||||
membershipId: projectMember.id,
|
||||
roles: sanitizedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated roles", type: "success" });
|
||||
} catch {
|
||||
createNotification({ text: "Failed to update roles", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (isRolesLoading)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{selectedRoleList.fields.map(({ id }, index) => {
|
||||
const { temporaryAccess } = formRoleField[index];
|
||||
const isTemporary = temporaryAccess?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccess.isTemporary &&
|
||||
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
name={`roles.${index}.slug`}
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500"
|
||||
containerClassName="w-1/2"
|
||||
>
|
||||
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
|
||||
<SelectItem value={slug} key={projectRoleId}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled} asChild>
|
||||
<div className="flex-grow">
|
||||
<Tooltip
|
||||
content={
|
||||
temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Timed Access Expired"
|
||||
: `Until ${format(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}`
|
||||
: "Non-Expiring Access"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
|
||||
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
className={twMerge(
|
||||
"w-full border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
|
||||
isTemporary && "text-primary",
|
||||
isExpired && "text-red-600"
|
||||
)}
|
||||
>
|
||||
{temporaryAccess?.isTemporary
|
||||
? isExpired
|
||||
? "Access Expired"
|
||||
: formatDistance(
|
||||
new Date(temporaryAccess.temporaryAccessEndTime || ""),
|
||||
new Date()
|
||||
)
|
||||
: "Permanent"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
arrowClassName="fill-gray-600"
|
||||
side="right"
|
||||
sideOffset={12}
|
||||
hideCloseBtn
|
||||
className="border border-gray-600 pt-4"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||
Configure Timed Access
|
||||
</div>
|
||||
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
defaultValue="1h"
|
||||
name={`roles.${index}.temporaryAccess.temporaryRange`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Validity" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
const temporaryRange = roleForm.getValues(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`
|
||||
);
|
||||
if (!temporaryRange) {
|
||||
roleForm.setError(
|
||||
`roles.${index}.temporaryAccess.temporaryRange`,
|
||||
{ type: "required", message: "Required" },
|
||||
{ shouldFocus: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
|
||||
roleForm.setValue(
|
||||
`roles.${index}.temporaryAccess`,
|
||||
{
|
||||
isTemporary: true,
|
||||
temporaryAccessStartTime: new Date().toISOString(),
|
||||
temporaryRange,
|
||||
temporaryAccessEndTime: new Date(
|
||||
new Date().getTime() + ms(temporaryRange)
|
||||
).toISOString()
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
|
||||
</Button>
|
||||
{temporaryAccess.isTemporary && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
onClick={() => {
|
||||
roleForm.setValue(`roles.${index}.temporaryAccess`, {
|
||||
isTemporary: false
|
||||
});
|
||||
}}
|
||||
>
|
||||
Revoke Access
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
|
||||
ariaLabel="delete-role"
|
||||
isDisabled={isMemberEditDisabled || selectedRoleList.fields.length === 1}
|
||||
onClick={() => {
|
||||
if (selectedRoleList.fields.length > 1) {
|
||||
selectedRoleList.remove(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
isDisabled={!isAllowed}
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() =>
|
||||
selectedRoleList.append({
|
||||
slug: ProjectMembershipRole.Member,
|
||||
temporaryAccess: { isTemporary: false }
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Button
|
||||
type="submit"
|
||||
className={twMerge(
|
||||
"transition-all",
|
||||
"cursor-default opacity-0",
|
||||
roleForm.formState.isDirty && "cursor-pointer opacity-100"
|
||||
)}
|
||||
isDisabled={!roleForm.formState.isDirty}
|
||||
isLoading={roleForm.formState.isSubmitting}
|
||||
>
|
||||
Save Roles
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { MemberRoleDetailsSection } from "./MemberRoleDetailsSection";
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { createFileRoute, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Button, DeleteActionModal, EmptyState, Spinner } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useOrganization,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { getProjectTitle } from "@app/helpers/project";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteUserFromWorkspace, useGetWorkspaceUserDetails } from "@app/hooks/api";
|
||||
|
||||
import { MemberProjectAdditionalPrivilegeSection } from "./-components/MemberProjectAdditionalPrivilegeSection";
|
||||
import { MemberRoleDetailsSection } from "./-components/MemberRoleDetailsSection";
|
||||
|
||||
export const MemberDetailsPage = withProjectPermission(
|
||||
() => {
|
||||
const navigate = useNavigate();
|
||||
const membershipId = useParams({
|
||||
strict: false,
|
||||
select: (el) => el.membershipId as string
|
||||
});
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const { data: membershipDetails, isPending: isMembershipDetailsLoading } =
|
||||
useGetWorkspaceUserDetails(workspaceId, membershipId);
|
||||
|
||||
const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } =
|
||||
useDeleteUserFromWorkspace();
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"removeMember",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const handleRemoveUser = async () => {
|
||||
if (!currentOrg?.id || !currentWorkspace?.id || !membershipDetails?.user?.username) return;
|
||||
|
||||
try {
|
||||
await removeUserFromWorkspace({
|
||||
workspaceId: currentWorkspace.id,
|
||||
usernames: [membershipDetails?.user?.username],
|
||||
orgId: currentOrg.id
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully removed user from project",
|
||||
type: "success"
|
||||
});
|
||||
navigate({
|
||||
to: `/${currentWorkspace.type}/$projectId/access` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to remove user from the project",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
handlePopUpClose("removeMember");
|
||||
};
|
||||
|
||||
if (isMembershipDetailsLoading) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-24">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex max-w-7xl flex-col justify-between bg-bunker-800 p-6 text-white">
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
variant="link"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
onClick={() => {
|
||||
navigate({
|
||||
to: `/${currentWorkspace.type}/$projectId/access` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id
|
||||
}
|
||||
});
|
||||
}}
|
||||
className="mb-4"
|
||||
>
|
||||
{currentWorkspace?.type ? getProjectTitle(currentWorkspace?.type) : "Project"} Access
|
||||
Control
|
||||
</Button>
|
||||
</div>
|
||||
{membershipDetails ? (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold text-mineshaft-100">Project User Access</h3>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Member}
|
||||
renderTooltip
|
||||
allowedLabel="Remove from project"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
isDisabled={!isAllowed}
|
||||
isLoading={isRemovingUserFromWorkspace}
|
||||
onClick={() => handlePopUpOpen("removeMember")}
|
||||
>
|
||||
Remove User
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-12">
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-gray-400">Name</span>
|
||||
{membershipDetails && (
|
||||
<p className="text-lg capitalize">
|
||||
{membershipDetails.user.firstName || membershipDetails.user.lastName
|
||||
? `${membershipDetails.user.firstName} ${membershipDetails.user.lastName}`
|
||||
: "-"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-gray-400">Email</span>
|
||||
{membershipDetails && (
|
||||
<p className="text-lg">{membershipDetails?.user?.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-400">
|
||||
Joined on{" "}
|
||||
{membershipDetails?.createdAt &&
|
||||
format(new Date(membershipDetails?.createdAt || ""), "yyyy-MM-dd")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MemberRoleDetailsSection
|
||||
membershipDetails={membershipDetails}
|
||||
isMembershipDetailsLoading={isMembershipDetailsLoading}
|
||||
onOpenUpgradeModal={() =>
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description:
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
})
|
||||
}
|
||||
/>
|
||||
<MemberProjectAdditionalPrivilegeSection membershipDetails={membershipDetails} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMember.isOpen}
|
||||
deleteKey="remove"
|
||||
title="Do you want to remove this user from the project?"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
|
||||
onDeleteApproved={handleRemoveUser}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState title="Error: Unable to find the user." className="py-12" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{
|
||||
action: ProjectPermissionActions.Read,
|
||||
subject: ProjectPermissionSub.Member
|
||||
}
|
||||
);
|
||||
|
||||
const MemberDetailsPageRoute = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<MemberDetailsPage />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/members/$membershipId/"
|
||||
)({
|
||||
component: MemberDetailsPageRoute
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview"
|
||||
)({
|
||||
component: RouteComponent
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div>
|
||||
Hello
|
||||
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview"!
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { ClipboardEvent, useRef } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { getKeyValue } from "@app/helpers/parseEnvVar";
|
||||
import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
|
||||
import { SecretType } from "@app/hooks/api/types";
|
||||
|
||||
const typeSchema = z
|
||||
.object({
|
||||
key: z.string().trim().min(1, "Key is required"),
|
||||
value: z.string().optional(),
|
||||
environments: z.object({ name: z.string(), slug: z.string() }).array(),
|
||||
tags: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).optional()
|
||||
})
|
||||
.refine((data) => data.key !== undefined, {
|
||||
message: "Please enter secret name"
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof typeSchema>;
|
||||
|
||||
type Props = {
|
||||
secretPath?: string;
|
||||
// modal props
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { permission } = useProjectPermission();
|
||||
const canReadTags = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
||||
const { mutateAsync: createFolder } = useCreateFolder();
|
||||
const { data: projectTags, isLoading: isTagsLoading } = useGetWsTags(
|
||||
canReadTags ? workspaceId : ""
|
||||
);
|
||||
|
||||
const secretKeyInputRef = useRef<HTMLInputElement>(null);
|
||||
const { ref: setSecretKeyHookRef, ...secretKeyRegisterRest } = register("key");
|
||||
|
||||
const secretKey = watch("key");
|
||||
|
||||
const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: TFormSchema) => {
|
||||
const promises = selectedEnv.map(async (env) => {
|
||||
const environment = env.slug;
|
||||
// create folder if not existing
|
||||
if (secretPath !== "/") {
|
||||
// /hello/world -> [hello","world"]
|
||||
const pathSegment = secretPath.split("/").filter(Boolean);
|
||||
const parentPath = `/${pathSegment.slice(0, -1).join("/")}`;
|
||||
const folderName = pathSegment.at(-1);
|
||||
const canCreateFolder = permission.can(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.SecretFolders, {
|
||||
environment: env.slug,
|
||||
secretPath: parentPath
|
||||
})
|
||||
);
|
||||
|
||||
if (folderName && parentPath && canCreateFolder) {
|
||||
await createFolder({
|
||||
projectId: workspaceId,
|
||||
path: parentPath,
|
||||
environment,
|
||||
name: folderName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: add back ability to overwrite - need to fetch secrets by key to check for conflicts as previous method broke with pagination
|
||||
|
||||
return {
|
||||
...(await createSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretKey: key,
|
||||
secretValue: value || "",
|
||||
secretComment: "",
|
||||
type: SecretType.Shared,
|
||||
tagIds: tags?.map((el) => el.value)
|
||||
})),
|
||||
environment
|
||||
};
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const forApprovalEnvs = results
|
||||
.map((result) =>
|
||||
result.status === "fulfilled" && "approval" in result.value
|
||||
? result.value.environment
|
||||
: undefined
|
||||
)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const updatedEnvs = results
|
||||
.map((result) =>
|
||||
result.status === "fulfilled" && !("approval" in result.value)
|
||||
? result.value.environment
|
||||
: undefined
|
||||
)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
if (forApprovalEnvs.length) {
|
||||
createNotification({
|
||||
type: "info",
|
||||
text: `Change request submitted for ${
|
||||
forApprovalEnvs.length > 1 ? "environments" : "environment"
|
||||
}: ${forApprovalEnvs.join(", ")}`
|
||||
});
|
||||
}
|
||||
|
||||
if (updatedEnvs.length) {
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: `Secrets created in ${
|
||||
updatedEnvs.length > 1 ? "environments" : "environment"
|
||||
}: ${updatedEnvs.join(", ")}`
|
||||
});
|
||||
}
|
||||
|
||||
if (!updatedEnvs.length && !forApprovalEnvs.length) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create secrets"
|
||||
});
|
||||
} else {
|
||||
onClose();
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
||||
const delimitters = [":", "="];
|
||||
const pastedContent = e.clipboardData.getData("text");
|
||||
const { key, value } = getKeyValue(pastedContent, delimitters);
|
||||
|
||||
const isWholeKeyHighlighted =
|
||||
secretKeyInputRef.current &&
|
||||
secretKeyInputRef.current.selectionStart === 0 &&
|
||||
secretKeyInputRef.current.selectionEnd === secretKeyInputRef.current.value.length;
|
||||
|
||||
if (!secretKey || isWholeKeyHighlighted) {
|
||||
e.preventDefault();
|
||||
|
||||
setValue("key", key);
|
||||
if (value) {
|
||||
setValue("value", value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createWsTag = useCreateWsTag();
|
||||
const slugSchema = z.string().trim().toLowerCase().min(1);
|
||||
const createNewTag = async (slug: string) => {
|
||||
// TODO: Replace with slugSchema generic
|
||||
try {
|
||||
const parsedSlug = slugSchema.parse(slug);
|
||||
await createWsTag.mutateAsync({
|
||||
workspaceID: workspaceId,
|
||||
tagSlug: parsedSlug,
|
||||
tagColor: ""
|
||||
});
|
||||
} catch {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create new tag"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} noValidate>
|
||||
<FormControl
|
||||
label="Key"
|
||||
isRequired
|
||||
isError={Boolean(errors?.key)}
|
||||
errorText={errors?.key?.message}
|
||||
>
|
||||
<Input
|
||||
{...secretKeyRegisterRest}
|
||||
ref={(e) => {
|
||||
setSecretKeyHookRef(e);
|
||||
// @ts-expect-error this is for multiple ref single component
|
||||
secretKeyInputRef.current = e;
|
||||
}}
|
||||
placeholder="Type your secret name"
|
||||
onPaste={handlePaste}
|
||||
autoCapitalization={currentWorkspace?.autoCapitalization}
|
||||
/>
|
||||
</FormControl>
|
||||
<Controller
|
||||
control={control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
label="Value"
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
>
|
||||
<InfisicalSecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="tags"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
label="Tags"
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
helperText={
|
||||
!canReadTags ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} className="text-yellow-400" />
|
||||
<span>You do not have permission to read tags.</span>
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)
|
||||
}
|
||||
>
|
||||
<CreatableSelect
|
||||
isMulti
|
||||
className="w-full"
|
||||
placeholder="Select tags to assign to secret..."
|
||||
isValidNewOption={(v) => slugSchema.safeParse(v).success}
|
||||
name="tagIds"
|
||||
isDisabled={!canReadTags}
|
||||
isLoading={isTagsLoading && canReadTags}
|
||||
options={projectTags?.map((el) => ({ label: el.slug, value: el.id }))}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onCreateOption={createNewTag}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl label="Environments" isError={Boolean(error)} errorText={error?.message}>
|
||||
<FilterableSelect
|
||||
isMulti
|
||||
options={environments.filter((environment) =>
|
||||
permission.can(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: environment.slug,
|
||||
secretPath,
|
||||
secretName: "*",
|
||||
secretTags: ["*"]
|
||||
})
|
||||
)
|
||||
)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select environments to create secret in..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
name="environments"
|
||||
/>
|
||||
<div className="mt-7 flex items-center">
|
||||
<Button
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
key="layout-create-project-submit"
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
>
|
||||
Create Secret
|
||||
</Button>
|
||||
<Button
|
||||
key="layout-cancel-create-project"
|
||||
onClick={onClose}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CreateSecretForm } from "./CreateSecretForm";
|
||||
@@ -0,0 +1,53 @@
|
||||
import { faFolderOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
type Props = {
|
||||
secretPath: string;
|
||||
onResetSearch: (path: string) => void;
|
||||
};
|
||||
|
||||
export const FolderBreadCrumbs = ({ secretPath = "/", onResetSearch }: Props) => {
|
||||
const navigate = useNavigate({
|
||||
from: "/secret-manager/$projectId/overview"
|
||||
});
|
||||
|
||||
const onFolderCrumbClick = (index: number) => {
|
||||
const newSecPath = `/${secretPath.split("/").filter(Boolean).slice(0, index).join("/")}`;
|
||||
if (secretPath === newSecPath) return;
|
||||
navigate({
|
||||
search: (prev) => ({ ...prev, secretPath: newSecPath })
|
||||
}).then(() => onResetSearch(newSecPath));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="breadcrumb relative z-20 border-solid border-mineshaft-600 bg-mineshaft-800 py-1 pl-5 pr-2 text-sm hover:bg-mineshaft-600"
|
||||
onClick={() => onFolderCrumbClick(0)}
|
||||
onKeyDown={() => null}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFolderOpen} className="text-primary-700" />
|
||||
</div>
|
||||
{(secretPath || "")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((path, index, arr) => (
|
||||
<div
|
||||
key={`secret-path-${index + 1}`}
|
||||
className={`breadcrumb relative z-20 ${
|
||||
index + 1 === arr.length ? "cursor-default" : "cursor-pointer"
|
||||
} border-solid border-mineshaft-600 py-1 pl-5 pr-2 text-sm text-mineshaft-200`}
|
||||
onClick={() => onFolderCrumbClick(index + 1)}
|
||||
onKeyDown={() => null}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{path}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { FolderBreadCrumbs } from "./FolderBreadCrumbs";
|
||||
@@ -0,0 +1,50 @@
|
||||
import { faCheck, faFingerprint, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Td, Tr } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
dynamicSecretName: string;
|
||||
environments: { name: string; slug: string }[];
|
||||
isDynamicSecretInEnv: (name: string, env: string) => boolean;
|
||||
};
|
||||
|
||||
export const SecretOverviewDynamicSecretRow = ({
|
||||
dynamicSecretName,
|
||||
environments = [],
|
||||
isDynamicSecretInEnv
|
||||
}: Props) => {
|
||||
return (
|
||||
<Tr isHoverable isSelectable className="group">
|
||||
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
|
||||
<div className="flex items-center space-x-5 border-r border-mineshaft-600 px-5 py-2.5">
|
||||
<div className="text-yellow-700">
|
||||
<FontAwesomeIcon icon={faFingerprint} />
|
||||
</div>
|
||||
<div>{dynamicSecretName}</div>
|
||||
</div>
|
||||
</Td>
|
||||
{environments.map(({ slug }, i) => {
|
||||
const isPresent = isDynamicSecretInEnv(dynamicSecretName, slug);
|
||||
|
||||
return (
|
||||
<Td
|
||||
key={`sec-overview-${slug}-${i + 1}-folder`}
|
||||
className={twMerge(
|
||||
"border-r border-mineshaft-600 py-3 group-hover:bg-mineshaft-700",
|
||||
isPresent ? "text-green-600" : "text-red-600"
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<FontAwesomeIcon
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
icon={isPresent ? faCheck : faXmark}
|
||||
/>
|
||||
</div>
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretOverviewDynamicSecretRow } from "./SecretOverviewDynamicSecretRow";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user