diff --git a/frontend-v2/package-lock.json b/frontend-v2/package-lock.json index b95b7afb5..602db4d0a 100644 --- a/frontend-v2/package-lock.json +++ b/frontend-v2/package-lock.json @@ -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", diff --git a/frontend-v2/package.json b/frontend-v2/package.json index eaa579d12..c208a007c 100644 --- a/frontend-v2/package.json +++ b/frontend-v2/package.json @@ -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", diff --git a/frontend-v2/src/components/navigation/NavHeader.tsx b/frontend-v2/src/components/navigation/NavHeader.tsx index 0077c4bf3..310894f8a 100644 --- a/frontend-v2/src/components/navigation/NavHeader.tsx +++ b/frontend-v2/src/components/navigation/NavHeader.tsx @@ -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({ {pageName === "Secrets" ? ( {pageName} @@ -128,10 +128,8 @@ export default function NavHeader({
{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 (
{ 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({
) : ( ({ ...query, secretPath: newSecretPath })} > { + const { watch } = useFormContext(); + const isNotEmptyPermissions = Object.entries(watch("permissions") || {}).some( + ([key, value]) => key && value?.length > 0 + ); + + if (isNotEmptyPermissions) return
; + + return ; +}; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/ProjectRoleModifySection.utils.tsx b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/ProjectRoleModifySection.utils.tsx new file mode 100644 index 000000000..e7f4fc91a --- /dev/null +++ b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/ProjectRoleModifySection.utils.tsx @@ -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(); + 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; + +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 = []; + 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 = {}; + + 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> = {}; + 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[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" } + ] + } +}; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/RolePermissionsSection.tsx b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/RolePermissionsSection.tsx new file mode 100644 index 000000000..16397ac24 --- /dev/null +++ b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/RolePermissionsSection.tsx @@ -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 ; + + if (isConditionalSubjects(subject)) { + if (subject === ProjectPermissionSub.Identity) { + return ; + } + + return ; + } + + 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({ + 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 ( +
+ +
+

Policies

+
+ {isCustomRole && ( + <> + {isDirty && ( + + )} +
+ + + + + + + {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) => ( + onNewPolicy(subject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} + + ))} + + +
+ + )} +
+
+
+ {!isLoading && } + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( + + {renderConditionalComponents(subject, isDisabled)} + + ))} +
+
+
+ ); +}; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionConditions.tsx b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionConditions.tsx new file mode 100644 index 000000000..2ac518038 --- /dev/null +++ b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionConditions.tsx @@ -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(); + const items = useFieldArray({ + control, + name: `permissions.${type}.${position}.conditions` + }); + + return ( +
+

Conditions

+

+ When this policy should apply (always if no conditions are added). +

+
+ {items.fields.map((el, index) => { + const condition = + (watch(`permissions.${type}.${position}.conditions.${index}`) as { + lhs: string; + rhs: string; + operator: string; + }) || {}; + return ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+ + + +
+
+
+ ( + + + + )} + /> +
+
+ items.remove(index)} + > + + +
+
+ ); + })} +
+ {errors?.permissions?.[type]?.[position]?.conditions?.message && ( +
+ + {errors?.permissions?.[type]?.[position]?.conditions?.message} +
+ )} +
{}
+
+ +
+
+ ); +}; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionPolicies.tsx b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionPolicies.tsx new file mode 100644 index 000000000..15d819fc8 --- /dev/null +++ b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionPolicies.tsx @@ -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 = { + title: string; + subject: T; + actions: TProjectPermissionObject[T]["actions"]; + children?: JSX.Element; + isDisabled?: boolean; +}; + +export const GeneralPermissionPolicies = >({ + subject, + actions, + children, + title, + isDisabled +}: Props) => { + const { control } = useFormContext(); + const items = useFieldArray({ + control, + name: `permissions.${subject}` + }); + const [isOpen, setIsOpen] = useToggle(); + + if (!items.fields.length) return
; + + return ( +
+
setIsOpen.toggle()} + onKeyDown={(e) => { + if (e.key === "Enter") { + setIsOpen.toggle(); + } + }} + > +
+ +
+
{title}
+ {items.fields.length > 1 && ( +
+ + {items.fields.length} rules + +
+ )} +
+ {isOpen && ( +
+ {items.fields.map((el, rootIndex) => ( +
+ {isConditionalSubjects(subject) && ( +
+
Permission
+
+ ( + + )} + /> +
+
+ +

+ Whether to allow or forbid the selected actions when the following + conditions (if any) are met. +

+

Forbid rules must come after allow rules.

+ + } + > + +
+
+
+ )} +
+
Actions
+
+ {actions.map(({ label, value }) => { + if (typeof value !== "string") return undefined; + return ( + ( +
+ + {label} + +
+ )} + /> + ); + })} +
+
+ {children && + cloneElement(children, { + position: rootIndex + })} +
+ {!isDisabled && isConditionalSubjects(subject) && ( + + )} + {!isDisabled && ( + + )}{" "} +
+
+ ))} +
+ )} +
+ ); +}; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/IdentityManagementPermissionConditions.tsx b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/IdentityManagementPermissionConditions.tsx new file mode 100644 index 000000000..8a62c6ad9 --- /dev/null +++ b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/IdentityManagementPermissionConditions.tsx @@ -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(); + const permissionSubject = ProjectPermissionSub.Identity; + const items = useFieldArray({ + control, + name: `permissions.${permissionSubject}.${position}.conditions` + }); + + return ( +
+

Conditions

+

+ When this policy should apply (always if no conditions are added). +

+
+ {items.fields.map((el, index) => { + const condition = + (watch(`permissions.${permissionSubject}.${position}.conditions.${index}`) as { + lhs: string; + rhs: string; + operator: string; + }) || {}; + return ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+ + + +
+
+
+ ( + + + + )} + /> +
+
+ items.remove(index)} + > + + +
+
+ ); + })} +
+ {errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message && ( +
+ + {errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message} +
+ )} +
{}
+
+ +
+
+ ); +}; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/NewPermissionRule.tsx b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/NewPermissionRule.tsx new file mode 100644 index 000000000..91008e247 --- /dev/null +++ b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/NewPermissionRule.tsx @@ -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(); + + const form = useForm<{ + type: ProjectPermissionSub; + permissions: NonNullable; + }>({ + resolver: zodResolver( + projectRoleFormSchema + .pick({ permissions: true }) + .extend({ type: z.nativeEnum(ProjectPermissionSub) }) + ), + defaultValues: { + type: ProjectPermissionSub.Secrets + } + }); + + const selectedSubject = form.watch("type"); + + return ( +
+ ( + + + + )} + /> + +
+ {PROJECT_PERMISSION_OBJECT?.[selectedSubject]?.actions?.map(({ label, value }) => ( + ( +
+ + {label} + +
+ )} + /> + ))} +
+
+ + + + +
+
+ ); +}; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/PermissionConditionHelpers.tsx b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/PermissionConditionHelpers.tsx new file mode 100644 index 000000000..9120f0364 --- /dev/null +++ b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/PermissionConditionHelpers.tsx @@ -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 ; + default: + return ""; + } +}; + +export const renderOperatorSelectItems = (type: string) => { + if (type === "secretTags") { + return Contains; + } + + return ( + <> + Equal + Not Equal + Glob Match + In + + ); +}; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/SecretPermissionConditions.tsx b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/SecretPermissionConditions.tsx new file mode 100644 index 000000000..b742b829d --- /dev/null +++ b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/components/SecretPermissionConditions.tsx @@ -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(); + const items = useFieldArray({ + control, + name: `permissions.secrets.${position}.conditions` + }); + + return ( +
+

Conditions

+

+ When this policy should apply (always if no conditions are added). +

+
+ {items.fields.map((el, index) => { + const condition = watch(`permissions.secrets.${position}.conditions.${index}`) as { + lhs: string; + rhs: string; + operator: string; + }; + return ( +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+ + + +
+
+
+ ( + + + + )} + /> +
+
+ items.remove(index)} + > + + +
+
+ ); + })} +
+ {errors?.permissions?.secrets?.[position]?.conditions?.message && ( +
+ + {errors?.permissions?.secrets?.[position]?.conditions?.message} +
+ )} +
+ +
+
+ ); +}; diff --git a/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/index.tsx b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/index.tsx new file mode 100644 index 000000000..104e2144e --- /dev/null +++ b/frontend-v2/src/components/permissions/ProjectRolePermissionsSection/index.tsx @@ -0,0 +1 @@ +export { RolePermissionsSection } from "./RolePermissionsSection"; diff --git a/frontend-v2/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.module.css b/frontend-v2/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.module.css new file mode 100644 index 000000000..00ca0b770 --- /dev/null +++ b/frontend-v2/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.module.css @@ -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; + } +} diff --git a/frontend-v2/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx b/frontend-v2/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx new file mode 100644 index 000000000..8164353d8 --- /dev/null +++ b/frontend-v2/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx @@ -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 ( +
  • + + svg]:data-[state=open]:rotate-[90deg] [&>svg]:data-[state=open]:text-yellow-500" + )} + disabled={!hasChildren} + > + {hasChildren && ( + + )} + {isRoot + ? secretKey + : `${node.environment}${ + node.secretPath === "/" ? "" : node.secretPath.split("/").join(".") + }.${node.key}`} + + + + + + + + {hasChildren && ( +
      + {node.children.map((el, index) => ( + + ))} +
    + )} +
    +
    +
  • + ); +}; + +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 ( +
    + +
    + ); + } + + return ( +
    + + + + +
    + {tree && ( +
      + +
    + )} +
    +
    + Click a secret key to view its sub-references. +
    +
    + ); +}; diff --git a/frontend-v2/src/components/secrets/SecretReferenceDetails/index.tsx b/frontend-v2/src/components/secrets/SecretReferenceDetails/index.tsx new file mode 100644 index 000000000..6238ff612 --- /dev/null +++ b/frontend-v2/src/components/secrets/SecretReferenceDetails/index.tsx @@ -0,0 +1 @@ +export { hasSecretReference,SecretReferenceTree } from "./SecretReferenceDetails"; diff --git a/frontend-v2/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend-v2/src/layouts/ProjectLayout/ProjectLayout.tsx index cec706cae..bb286ebdf 100644 --- a/frontend-v2/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend-v2/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -134,7 +134,7 @@ export const ProjectLayout = () => { )} 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" } } } diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/admin/-components/OrgAdminProjects/OrgAdminProjects.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/admin/-components/OrgAdminProjects/OrgAdminProjects.tsx index 1d2fc1ffb..4e33e5688 100644 --- a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/admin/-components/OrgAdminProjects/OrgAdminProjects.tsx +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/admin/-components/OrgAdminProjects/OrgAdminProjects.tsx @@ -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 } diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/identities/$identityId/-components/IdentityProjectsSection/IdentityProjectRow.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/identities/$identityId/-components/IdentityProjectsSection/IdentityProjectRow.tsx index 117a8e961..0b3aab8b2 100644 --- a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/identities/$identityId/-components/IdentityProjectsSection/IdentityProjectRow.tsx +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/identities/$identityId/-components/IdentityProjectsSection/IdentityProjectRow.tsx @@ -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 } diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/members/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/members/index.tsx index cc7e7aa6c..2bc392338 100644 --- a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/members/index.tsx +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/members/index.tsx @@ -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( diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/memberships/$membershipId/-components/UserProjectsSection/UserProjectRow.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/memberships/$membershipId/-components/UserProjectsSection/UserProjectRow.tsx index 59ad5f22c..2587719ff 100644 --- a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/memberships/$membershipId/-components/UserProjectsSection/UserProjectRow.tsx +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/memberships/$membershipId/-components/UserProjectsSection/UserProjectRow.tsx @@ -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 }, diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/memberships/$membershipId/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/memberships/$membershipId/index.tsx index 3dc5c0fdc..cff8a8815 100644 --- a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/memberships/$membershipId/index.tsx +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/memberships/$membershipId/index.tsx @@ -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={} onClick={() => { navigate({ - to: "/organization/$organizationId/members" as const, - params: { - organizationId: currentOrg.id - }, + to: "/organization/members" as const, search: { selectedTab: TabSections.Member } diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/settings/-components/ImportTab/components/EnvKeyPlatformModal.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/settings/-components/ImportTab/components/EnvKeyPlatformModal.tsx index c05fd6ab0..55b360365 100644 --- a/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/settings/-components/ImportTab/components/EnvKeyPlatformModal.tsx +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/organization/_layout-org/settings/-components/ImportTab/components/EnvKeyPlatformModal.tsx @@ -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) => { )} /> - - { - // - } +
    + +
    + + ) : ( +
    +
    + All groups in your organization have already been added to this project. +
    + + + +
    + ); +}; + +export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { + return ( + handlePopUpToggle("group", isOpen)} + > + + + + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/GroupRoles.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/GroupRoles.tsx new file mode 100644 index 000000000..f7a941586 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/GroupRoles.tsx @@ -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; + +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({ + resolver: zodResolver(temporaryRoleFormSchema), + values: { + temporaryRange: defaultValues.temporaryRange || "1h" + } + }); + const isTemporaryFieldValue = defaultValues.isTemporary; + const isExpired = + isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); + + return ( + { + handlePopUpToggle("setTempRole", isOpen); + }} + > + + + + + + + + +
    +
    + Set Role Temporarily +
    + {isExpired && Expired} + ( + + 1m, 2h, 3d.{" "} +
    + More + + + } + > + + + )} + /> +
    + {isTemporaryFieldValue && ( + + )} + {!isTemporaryFieldValue ? ( + + ) : ( + + )} +
    +
    + + + ); +}; + +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; + +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({ + 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 ( +
    + {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 ( + +
    +
    {formatRoleName(role, customRoleName)}
    + {isTemporary && ( +
    + + + +
    + )} +
    +
    + ); + })} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {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 ( + +
    +
    {formatRoleName(role, customRoleName)}
    + {isTemporary && ( +
    + + new Date(temporaryAccessEndTime as string) && + "text-red-600" + )} + /> + +
    + )} +
    +
    + ); + })}{" "} +
    +
    + )} +
    + { + handlePopUpToggle("editRole", isOpen); + reset(); + }} + > + {!disableEdit && ( + + + + + + )} + + {isRolesLoading ? ( +
    + +
    + ) : ( +
    +
    + {projectRoles + ?.filter( + ({ name, slug }) => + name.toLowerCase().includes(searchRoles.toLowerCase()) || + slug.toLowerCase().includes(searchRoles.toLowerCase()) + ) + ?.map(({ id, name, slug }) => { + const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; + + return ( +
    +
    + ( + { + field.onChange(isChecked); + setValue(`${slug}.temporaryAccess`, false); + }} + > + {name} + + )} + /> +
    +
    + ( + { + setValue(`${slug}.isChecked`, true, { shouldDirty: true }); + field.onChange({ isTemporary: true, ...data }); + }} + onRemoveTemporary={() => { + setValue(`${slug}.isChecked`, false, { shouldDirty: true }); + field.onChange(false); + }} + /> + )} + /> +
    +
    + ); + })} +
    +
    +
    + setSearchRoles(el.target.value)} + leftIcon={} + placeholder="Search roles.." + /> +
    +
    + +
    +
    +
    + )} +
    +
    +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/GroupsSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/GroupsSection.tsx new file mode 100644 index 000000000..8ae272725 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/GroupsSection.tsx @@ -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 ( +
    +
    +

    User Groups

    + + {(isAllowed) => ( + + )} + +
    + + + handlePopUpToggle("deleteGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveGroupSubmit((popUp?.deleteGroup?.data as { id: string })?.id) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/GroupsTable.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/GroupsTable.tsx new file mode 100644 index 000000000..24f459a93 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/GroupsTable.tsx @@ -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 ( +
    + setSearch(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + + + + + + + + + + + {isPending && } + {!isPending && + filteredGroupMemberships && + filteredGroupMemberships.length > 0 && + filteredGroupMemberships + .slice(offset, perPage * page) + .map(({ group: { id, name }, roles, createdAt }) => { + return ( + + + + + + + ); + })} + +
    +
    + Name + + + +
    +
    RoleAdded on +
    {name} + + {(isAllowed) => ( + + )} + + {format(new Date(createdAt), "yyyy-MM-dd")} + + {(isAllowed) => ( +
    + + { + handlePopUpOpen("deleteGroup", { + id, + name + }); + }} + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + +
    + )} +
    +
    + {Boolean(filteredGroupMemberships.length) && ( + + )} + {!isPending && !filteredGroupMemberships?.length && ( + + )} +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/index.tsx new file mode 100644 index 000000000..7726fd170 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/GroupsSection/index.tsx @@ -0,0 +1 @@ +export { GroupsSection } from "./GroupsSection"; \ No newline at end of file diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/index.tsx new file mode 100644 index 000000000..7726fd170 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/components/index.tsx @@ -0,0 +1 @@ +export { GroupsSection } from "./GroupsSection"; \ No newline at end of file diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/index.tsx new file mode 100644 index 000000000..419579efc --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/GroupsTab/index.tsx @@ -0,0 +1 @@ +export { GroupsTab } from "./GroupsTab"; \ No newline at end of file diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/IdentityTab/IdentityTab.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/IdentityTab/IdentityTab.tsx new file mode 100644 index 000000000..d3ad8d0e5 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/IdentityTab/IdentityTab.tsx @@ -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 ( + +
    +
    +

    Identities

    + + + {(isAllowed) => ( + + )} + +
    + setSearch(e.target.value)} + leftIcon={} + placeholder="Search identities by name..." + /> + + + + + + + + + + + + {isPending && } + {!isPending && + data && + data.identityMemberships.length > 0 && + data.identityMemberships.map((identityMember) => { + const { + identity: { id, name }, + roles, + createdAt + } = identityMember; + return ( + { + 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 + } + }) + } + > + + + + + + + ); + })} + +
    +
    + Name + handleSort(ProjectIdentityOrderBy.Name)} + > + + +
    +
    RoleAdded on{isFetching ? : null}
    {name} +
    + {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 ( + +
    +
    + {formatRoleName(role, customRoleName)} +
    + {isTemporary && ( +
    + + + +
    + )} +
    +
    + ); + } + )} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {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 ( + +
    +
    {formatRoleName(role, customRoleName)}
    + {isTemporary && ( +
    + + + new Date( + temporaryAccessEndTime as string + ) && "text-red-600" + )} + /> + +
    + )} +
    +
    + ); + } + )} +
    +
    + )} +
    +
    {format(new Date(createdAt), "yyyy-MM-dd")} + + {(isAllowed) => ( + { + evt.stopPropagation(); + evt.preventDefault(); + handlePopUpOpen("deleteIdentity", { + identityId: id, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + )} + + + + +
    + {!isPending && data && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && data && data?.identityMemberships.length === 0 && ( + 0 + ? "No identities match search filter" + : "No identities have been added to this project" + } + icon={faServer} + /> + )} +
    + + handlePopUpToggle("deleteIdentity", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveIdentitySubmit( + (popUp?.deleteIdentity?.data as { identityId: string })?.identityId + ) + } + /> +
    +
    + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity } +); diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/IdentityTab/components/IdentityModal.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/IdentityTab/components/IdentityModal.tsx new file mode 100644 index 000000000..bd161a5ec --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/IdentityTab/components/IdentityModal.tsx @@ -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; + +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({ + 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 ( +
    + +
    + ); + + return filteredIdentityMembershipOrgs.length ? ( +
    + ( + + membership.identity)} + getOptionValue={(option) => option.id} + getOptionLabel={(option) => option.name} + /> + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> +
    + + + + +
    + + ) : ( +
    +
    + All identities in your organization have already been added to this project. +
    + + + +
    + ); +}; + +export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { + return ( + { + handlePopUpToggle("identity", isOpen); + }} + > + + + + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/IdentityTab/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/IdentityTab/index.tsx new file mode 100644 index 000000000..86d3c710a --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/IdentityTab/index.tsx @@ -0,0 +1 @@ +export { IdentityTab } from "./IdentityTab"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/MembersTab.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/MembersTab.tsx new file mode 100644 index 000000000..6b5660c83 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/MembersTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { MembersSection } from "./components"; + +export const MembersTab = () => { + return ( + + + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/AddMemberModal.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/AddMemberModal.tsx new file mode 100644 index 000000000..b448200d8 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/AddMemberModal.tsx @@ -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; + +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({ + 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 ( + handlePopUpToggle("addMember", isOpen)} + > + + {filteredOrgUsers.length ? ( +
    +
    + ( + + + + )} + /> + + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> +
    +
    + + +
    +
    + ) : ( +
    +
    All the users in your organization are already invited.
    + + + +
    + )} +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx new file mode 100644 index 000000000..70155eb13 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx @@ -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; + +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({ + 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 ( +
    + +
    + ); + + return ( +
    +
    Roles
    +

    Select one of the pre-defined or custom roles.

    +
    +
    +
    + {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
    + ( + + )} + /> + + +
    + + + +
    +
    + +
    +
    + Configure timed access +
    + {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
    + + {temporaryAccess.isTemporary && ( + + )} +
    +
    +
    +
    + { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + +
    + ); + })} +
    +
    + + {(isAllowed) => ( + + )} + + +
    +
    +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/MemberRoleForm.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/MemberRoleForm.tsx new file mode 100644 index 000000000..93839337b --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/MemberRoleForm.tsx @@ -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 ( +
    + + + + + + Click here to access them now + + + + +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx new file mode 100644 index 000000000..06641be7a --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -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; +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({ + 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 = { 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 ( +
    +
    +
    + ( + + + + )} + /> + { + if (policies) { + return ( + +
    + + + +
    +
    + ); + } + return ( + + + + ); + }} + /> +
    + ( +
    + + field.onChange(isChecked)} + /> +
    + )} + /> + ( +
    + + field.onChange(isChecked)} + /> +
    + )} + /> + ( +
    + + field.onChange(isChecked)} + /> +
    + )} + /> + ( +
    + + field.onChange(isChecked)} + /> +
    + )} + /> +
    +
    + + +
    + + + +
    +
    + +
    +
    + Configure timed access +
    + {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
    + + + {temporaryAccessField.isTemporary && ( + + )} +
    +
    +
    +
    + {/* eslint-disable-next-line no-nested-ternary */} + {privilegeForm.formState.isDirty && privilege ? ( + <> + + privilegeForm.reset()} + > + + + + + + {privilegeForm.formState.isSubmitting ? ( + + ) : ( + + )} + + + + ) : // eslint-disable-next-line no-nested-ternary + privilege ? ( + + handlePopUpOpen("deletePrivilege")} + > + + + + ) : ( +
    + )} +
    +
    + {!!policies && ( + + )} + + handlePopUpToggle("deletePrivilege", isOpen)} + deleteKey="delete" + onClose={() => handlePopUpClose("deletePrivilege")} + onDeleteApproved={handleDeletePrivilege} + /> +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/index.tsx new file mode 100644 index 000000000..765b4061c --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MemberRoleForm/index.tsx @@ -0,0 +1 @@ +export { MemberRoleForm } from "./MemberRoleForm"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MembersSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MembersSection.tsx new file mode 100644 index 000000000..ee1f791d3 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MembersSection.tsx @@ -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 ( +
    +
    +

    Users

    + + {(isAllowed) => ( + + )} + +
    + + + handlePopUpToggle("removeMember", isOpen)} + onDeleteApproved={handleRemoveUser} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MembersTable.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MembersTable.tsx new file mode 100644 index 000000000..c7937bc9d --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/MembersTable.tsx @@ -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.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 ( +
    + setSearch(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + + + + + + + + + + + {isMembersLoading && } + {!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 ( + { + 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 + } + }) + } + > + + + + + + ); + })} + +
    +
    + Name + handleSort(MembersOrderBy.Name)} + > + + +
    +
    +
    + Email + handleSort(MembersOrderBy.Email)} + > + + +
    +
    Role +
    {name}{email} +
    + {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 ( + +
    +
    + {formatRoleName(role, customRoleName)} +
    + {isTemporary && ( +
    + + + +
    + )} +
    +
    + ); + } + )} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {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 ( + +
    +
    {formatRoleName(role, customRoleName)}
    + {isTemporary && ( +
    + + + new Date(temporaryAccessEndTime as string) && + "text-red-600" + )} + /> + +
    + )} +
    +
    + ); + } + )} +
    +
    + )} +
    +
    + {userId !== u?.id && ( +
    + + {(isAllowed) => ( + { + evt.preventDefault(); + evt.stopPropagation(); + handlePopUpOpen("removeMember", { username: u.username }); + }} + > + + + )} + + + + +
    + )} +
    + {Boolean(filteredUsers.length) && ( + + )} + {!isMembersLoading && !filteredUsers?.length && ( + + )} +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/index.tsx new file mode 100644 index 000000000..ccb1cc6cf --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/components/index.tsx @@ -0,0 +1 @@ +export { MembersSection } from "./MembersSection"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/index.tsx new file mode 100644 index 000000000..f557950c8 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/MembersTab/index.tsx @@ -0,0 +1 @@ +export { MembersTab } from "./MembersTab"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/ProjectRoleListTab.tsx new file mode 100644 index 000000000..4a5fc37ac --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/ProjectRoleListTab.tsx @@ -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 ( + + + + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role } +); diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx new file mode 100644 index 000000000..312de70ac --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -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 ( +
    +
    +

    Project Roles

    + + {(isAllowed) => ( + + )} + +
    + + + + + + + + + + {isRolesLoading && } + {roles?.map((role) => { + const { id, name, slug } = role; + const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug); + + return ( + + navigate({ + to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const, + params: { + projectId: currentWorkspace.id, + roleSlug: slug + } + }) + } + > + + + + + ); + })} + +
    NameSlug +
    {name}{slug} + + +
    + +
    +
    + + + {(isAllowed) => ( + { + e.stopPropagation(); + navigate({ + to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const, + params: { + projectId: currentWorkspace.id, + roleSlug: slug + } + }); + }} + disabled={!isAllowed} + > + {`${isNonMutatable ? "View" : "Edit"} Role`} + + )} + + {!isNonMutatable && ( + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteRole", role); + }} + disabled={!isAllowed} + > + Delete Role + + )} + + )} + +
    +
    +
    + + handlePopUpClose("deleteRole")} + onDeleteApproved={handleRoleDelete} + /> +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/components/ProjectRoleList/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/components/ProjectRoleList/index.tsx new file mode 100644 index 000000000..9f8e88a82 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/components/ProjectRoleList/index.tsx @@ -0,0 +1 @@ +export { ProjectRoleList } from "./ProjectRoleList"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/index.tsx new file mode 100644 index 000000000..5dc87a2d7 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ProjectRoleListTab/index.tsx @@ -0,0 +1 @@ +export { ProjectRoleListTab } from "./ProjectRoleListTab"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/ServiceTokenTab.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/ServiceTokenTab.tsx new file mode 100644 index 000000000..f30c853e6 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/ServiceTokenTab.tsx @@ -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 ( + +
    + {/*
    + +
    + Deprecation Notice +

    + Service Tokens are being deprecated in favor of Machine Identities. +
    + They will be removed in the future in accordance with the deprecation notice and + timeline stated{" "} + + here + + . +
    + + Learn more about Machine Identities + +

    +
    +
    */} + +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx new file mode 100644 index 000000000..571adf5f3 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx @@ -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; + +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({ + 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 ( + { + handlePopUpToggle("createAPIToken", open); + reset(); + setToken(""); + }} + > + + {!hasServiceToken ? ( +
    + ( + + + + )} + /> + {tokenScopes.map(({ id }, index) => ( +
    + ( + + + + )} + /> + ( + + + + )} + /> + remove(index)} + > + + +
    + ))} +
    + +
    + ( + + + + )} + /> + { + const options = [ + { + label: "Read (default)", + value: "read" + }, + { + label: "Write (optional)", + value: "write" + } + ] as const; + + return ( + + <> + {options.map(({ label, value: optionValue }) => { + return ( + { + onChange({ + ...value, + [optionValue]: state + }); + }} + > + {label} + + ); + })} + + + ); + }} + /> +
    + + + + +
    + + ) : ( +
    +

    {newToken}

    + + + + {t("common.click-to-copy")} + + +
    + )} +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx new file mode 100644 index 000000000..46e9e2ceb --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -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 ( +
    +
    +

    Service Tokens

    + + {(isAllowed) => ( + + )} + +
    +

    {t("section.token.service-tokens-description")}

    + + + handlePopUpToggle("deleteAPITokenConfirmation", isOpen)} + deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose("deleteAPITokenConfirmation")} + onDeleteApproved={onDeleteApproved} + /> +
    + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens } +); diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx new file mode 100644 index 000000000..51459e766 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx @@ -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 ( + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.map((row) => ( + + + + + + + ))} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
    Token NameEnvironment - Secret PathValid Until +
    {row.name} +
    + {row?.scopes.map(({ secretPath, environment }) => ( +
    +
    {environment}
    + + {secretPath} +
    + ))} +
    +
    {row.expiresAt && new Date(row.expiresAt).toUTCString()} + + {(isAllowed) => ( + + handlePopUpOpen("deleteAPITokenConfirmation", { + name: row.name, + id: row.id + }) + } + colorSchema="danger" + ariaLabel="delete" + isDisabled={!isAllowed} + > + + + )} + +
    + +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/index.tsx new file mode 100644 index 000000000..ddc3bdedb --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/ServiceTokenSection/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenSection } from "./ServiceTokenSection"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/index.tsx new file mode 100644 index 000000000..ddc3bdedb --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/components/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenSection } from "./ServiceTokenSection"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/index.tsx new file mode 100644 index 000000000..1c093e700 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/ServiceTokenTab/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenTab } from "./ServiceTokenTab"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/index.tsx new file mode 100644 index 000000000..edb14ec8f --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/-components/index.tsx @@ -0,0 +1,5 @@ +export { GroupsTab } from "./GroupsTab"; +export { IdentityTab } from "./IdentityTab"; +export { MembersTab } from "./MembersTab"; +export { ProjectRoleListTab } from "./ProjectRoleListTab"; +export { ServiceTokenTab } from "./ServiceTokenTab"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/index.tsx new file mode 100644 index 000000000..d0f4a7d61 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/access/index.tsx @@ -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 ( +
    +
    +

    + {currentWorkspace?.type ? getProjectTitle(currentWorkspace?.type) : "Project"} Access + Control +

    + + + Users + Groups + +
    +

    Machine Identities

    +
    +
    + {currentWorkspace?.type === ProjectType.SecretManager && ( + Service Tokens + )} + Project Roles +
    + + + + + + + + + + {currentWorkspace?.type === ProjectType.SecretManager && ( + + + + )} + + + +
    +
    +
    + ); + }, + { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.Member + } +); + +const WorkspaceMembersRoute = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +}; + +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) +}); diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/IPAllowlistModal.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/IPAllowlistModal.tsx new file mode 100644 index 000000000..70f75271f --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/IPAllowlistModal.tsx @@ -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; + +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({ + 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 ( + { + handlePopUpToggle("trustedIp", isOpen); + reset(); + }} + > + +
    + ( + + + + )} + /> + {!isLoading && data && ( + + )} + ( + + + + )} + /> +
    + + +
    + +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/IPAllowlistSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/IPAllowlistSection.tsx new file mode 100644 index 000000000..9fba3f3fe --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/IPAllowlistSection.tsx @@ -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 ( +
    +
    +

    IP Allowlist

    + + {(isAllowed) => ( + + )} + +
    + + + handlePopUpToggle("deleteTrustedIp", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteTrustedIpSubmit( + (popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/IPAllowlistTable.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/IPAllowlistTable.tsx new file mode 100644 index 000000000..39dab6fee --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/IPAllowlistTable.tsx @@ -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 ( +
    + + + + + + + + {/* */} + + + + {!isLoading && + data && + data?.length > 0 && + data + .sort((a, b) => a.ipAddress.localeCompare(b.ipAddress)) + .map(({ id, ipAddress, comment, type, prefix, isActive }) => { + return ( + + + + + {/* */} + + + ); + })} + {isLoading && ( + + )} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
    IP Address / RangeFormatCommentStatus +
    {`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`}{formatType(type, prefix)}{comment} +
    + +

    Active

    +
    +
    + + {(isAllowed) => ( + { + if (subscription?.ipAllowlisting) { + handlePopUpOpen("trustedIp", { + trustedIpId: id, + ipAddress, + comment, + prefix, + isActive + }); + } else { + handlePopUpOpen("upgradePlan"); + } + }} + colorSchema="primary" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + + + {(isAllowed) => ( + { + if (subscription?.ipAllowlisting) { + handlePopUpOpen("deleteTrustedIp", { + trustedIpId: id + }); + } else { + handlePopUpOpen("upgradePlan"); + } + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + +
    + +
    +
    + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/index.tsx new file mode 100644 index 000000000..488a07e71 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/-components/index.tsx @@ -0,0 +1 @@ +export { IPAllowlistSection } from "./IPAllowlistSection"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/index.tsx new file mode 100644 index 000000000..10398e3d0 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/allowlist/index.tsx @@ -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 ( +
    +
    +
    +

    IP Allowlist

    +
    +
    + +
    +
    + ); + }, + { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.IpAllowList + } +); + +const IPAllowlistRoute = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.project.title") })} + + + + + ); +}; + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/allowlist/" +)({ + component: () => IPAllowlistRoute +}); diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/AccessApprovalRequest.tsx new file mode 100644 index 000000000..a84487c21 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -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 ( +
    +
    + Requested {isTemporary ? "temporary" : "permanent"} access to{" "} + + {request.policy.secretPath} + + in + + {request.environmentName} + +
    +
    + {request.requestedByUserId === userId && ( + + Requested By You + + )} +
    +
    + ); +}; + +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>( + (prev, curr) => ({ ...prev, [curr.user.id]: curr }), + {} + ); + + console.log("membersGroupById", membersGroupById); + + const [statusFilter, setStatusFilter] = useState<"open" | "close">("open"); + const [requestedByFilter, setRequestedByFilter] = useState(undefined); + const [envFilter, setEnvFilter] = useState(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 ( +
    +
    +
    + Access Requests +
    + Request access to secrets in sensitive environments and folders. +
    +
    +
    + + + +
    +
    + + + +
    +
    setStatusFilter("open")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("open"); + }} + className={ + statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" + } + > + + {!!requestCount && requestCount?.pendingCount} Pending +
    +
    setStatusFilter("close")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("close"); + }} + > + + {!!requestCount && requestCount.finalizedCount} Completed +
    +
    + + + + + + Select an environment + {currentWorkspace?.environments.map(({ slug, name }) => ( + setEnvFilter((state) => (state === slug ? undefined : slug))} + key={`request-filter-${slug}`} + icon={envFilter === slug && } + iconPos="right" + > + {name} + + ))} + + + {!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( + + + + + + Select an author + {members?.map(({ user: membershipUser, id }) => ( + + setRequestedByFilter((state) => (state === id ? undefined : id)) + } + key={`request-filter-member-${id}`} + icon={requestedByFilter === id && } + iconPos="right" + > + {membershipUser.username} + + ))} + + + )} +
    +
    +
    + {filteredRequests?.length === 0 && ( +
    + +
    + )} + {!!filteredRequests?.length && + filteredRequests?.map((request) => { + const details = generateRequestDetails(request); + + return ( +
    { + 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"); + } + }} + > +
    +
    +
    + + {generateRequestText(request, user.id)} +
    +
    +
    + {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}){" "} + + )} +
    +
    + {details.isApprover && ( + + {details.displayData.label} + + )} +
    +
    +
    +
    +
    + ); + })} +
    +
    +
    + + {!!policies && ( + { + queryClient.invalidateQueries({ + queryKey: accessApprovalKeys.getAccessApprovalRequests( + projectSlug, + envFilter, + requestedByFilter + ) + }); + handlePopUpClose("requestAccess"); + }} + /> + )} + + {!!selectedRequest && ( + { + handlePopUpClose("reviewRequest"); + setSelectedRequest(null); + }} + /> + )} + + handlePopUpClose("upgradePlan")} + /> +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/components/RequestAccessModal.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/components/RequestAccessModal.tsx new file mode 100644 index 000000000..486ab5be6 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/components/RequestAccessModal.tsx @@ -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 ( + + + onOpenChange(false)} policies={policies} /> + + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/components/ReviewAccessModal.tsx new file mode 100644 index 000000000..e2b135f2a --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -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 ( + + {`Valid for ${ms(ms(accessDetails.temporaryAccess.temporaryRange), { + long: true + })} after approval`} + + ); + }; + + 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 ( + + +
    + + + {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) + {" "} + is requesting access to the following resource: + + +
    +
    + Requested path: + {accessDetails.env + accessDetails.secretPath || ""} +
    + +
    + Permissions: + {requestedAccess} +
    + +
    + Access Type: + {getAccessLabel()} +
    +
    + +
    + + +
    + {isSoftEnforcement && request.isRequestedByCurrentUser && !request.isApprover && ( +
    + setByPassApproval(checked === true)} + isChecked={byPassApproval} + id="byPassApproval" + checkIndicatorBg="text-white" + className={byPassApproval ? "border-red bg-red hover:bg-red-600" : ""} + > + + Approve without waiting for requirements to be met (bypass policy protection) + + +
    + )} +
    +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/index.tsx new file mode 100644 index 000000000..ec0a8d744 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/AccessApprovalRequest/index.tsx @@ -0,0 +1 @@ +export { AccessApprovalRequest } from "./AccessApprovalRequest"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/ApprovalPolicyList.tsx new file mode 100644 index 000000000..8e03c9974 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -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(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 ( +
    +
    +
    + Policies +
    + Implement granular policies for access requests and secrets management. +
    +
    +
    + + {(isAllowed) => ( + + )} + +
    +
    + + + + + + + + + + + + + + + {isPoliciesLoading && ( + + )} + {!isPoliciesLoading && !filteredPolicies?.length && ( + + + + )} + {!!currentWorkspace && + filteredPolicies?.map((policy) => ( + handlePopUpOpen("policyForm", policy)} + onDelete={() => handlePopUpOpen("deletePolicy", policy)} + /> + ))} + +
    NameEnvironmentSecret PathEligible ApproversEligible Group ApproversApproval Required + + + + + + Select a type + setFilterType(null)} + icon={!filterType && } + iconPos="right" + > + All + + setFilterType(PolicyType.AccessPolicy)} + icon={ + filterType === PolicyType.AccessPolicy && ( + + ) + } + iconPos="right" + > + Access Policy + + setFilterType(PolicyType.ChangePolicy)} + icon={ + filterType === PolicyType.ChangePolicy && ( + + ) + } + iconPos="right" + > + Change Policy + + + + +
    + +
    +
    + handlePopUpToggle("policyForm", isOpen)} + members={members} + editValues={popUp.policyForm.data as TAccessApprovalPolicy} + /> + handlePopUpToggle("deletePolicy", isOpen)} + onDeleteApproved={handleDeletePolicy} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add secret approval policy if you switch to Infisical's Enterprise plan." + /> +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/components/AccessPolicyModal.tsx new file mode 100644 index 000000000..9f5aeec5a --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -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; + +export const AccessPolicyForm = ({ + isOpen, + onToggle, + members = [], + projectId, + projectSlug, + editValues +}: Props) => { + const { + control, + handleSubmit, + reset, + watch, + formState: { isSubmitting } + } = useForm({ + 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 ( + + +
    +
    +
    + ( + + + + )} + /> + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> + ( + + + + )} + /> + ( + +

    + Determines the level of enforcement for required approvers of a request: +

    +

    + Hard enforcement requires at least{" "} + {approversRequired} approver(s) to + approve the request.` +

    +

    + Soft enforcement At least{" "} + {approversRequired} approver(s) must + approve the request; however, the requester can bypass approval + requirements in emergencies. +

    + + } + > + +
    + )} + /> + + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> + ( + + + + )} + /> +
    +
    +

    Approvers

    +

    + Select members or groups that are allowed to approve requests from this policy. +

    +
    + ( + + 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} + /> + + )} + /> + ( + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id + } + value={value} + onChange={onChange} + /> + + )} + /> +
    + + +
    + +
    +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx new file mode 100644 index 000000000..865e92994 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -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 ( + + {policy.name} + {policy.environment.slug} + {policy.secretPath || "*"} + + +

    {labels.members ?? "-"}

    +
    + + + +

    {labels.groups ?? "-"}

    +
    + + {policy.approvals} + + + {policyDetails[policy.policyType].name} + + + + + +
    + +
    +
    + + + {(isAllowed) => ( + { + e.stopPropagation(); + onEdit(); + }} + disabled={!isAllowed} + > + Edit Policy + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + onDelete(); + }} + disabled={!isAllowed} + > + Delete Policy + + )} + + +
    + + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/index.tsx new file mode 100644 index 000000000..2817ec627 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/ApprovalPolicyList/index.tsx @@ -0,0 +1 @@ +export { ApprovalPolicyList } from "./ApprovalPolicyList"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/SecretApprovalRequest.tsx new file mode 100644 index 000000000..618ff15f4 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -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(null); + + // filters + const [statusFilter, setStatusFilter] = useState<"open" | "close">("open"); + const [envFilter, setEnvFilter] = useState(); + const [committerFilter, setCommitterFilter] = useState(); + 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 ( + + {isSecretApprovalScreen ? ( + + + + ) : ( + +
    +
    setStatusFilter("open")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("open"); + }} + className={ + statusFilter === "close" ? "text-gray-500 duration-100 hover:text-gray-400" : "" + } + > + + {isSecretApprovalReqCountSuccess && secretApprovalRequestCount?.open} Open +
    +
    setStatusFilter("close")} + onKeyDown={(evt) => { + if (evt.key === "Enter") setStatusFilter("close"); + }} + > + + {isSecretApprovalReqCountSuccess && secretApprovalRequestCount.closed} Closed +
    +
    + + + + + + Select an environment + {currentWorkspace?.environments.map(({ slug, name }) => ( + setEnvFilter((state) => (state === slug ? undefined : slug))} + key={`request-filter-${slug}`} + icon={envFilter === slug && } + iconPos="right" + > + {name} + + ))} + + + {!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( + + + + + + Select an author + {members?.map(({ user, id }) => ( + + setCommitterFilter((state) => (state === user.id ? undefined : user.id)) + } + key={`request-filter-member-${id}`} + icon={ + committerFilter === user.id && + } + iconPos="right" + > + {user.username} + + ))} + + + )} +
    +
    +
    + {isRequestListEmpty && ( +
    + +
    + )} + {secretApprovalRequests?.pages?.map((group, i) => ( + + {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 ( +
    setSelectedApprovalId(secretApproval.id)} + onKeyDown={(evt) => { + if (evt.key === "Enter") setSelectedApprovalId(secretApproval.id); + }} + > +
    + + {generateCommitText(commits)} + #{secretApproval.slug} +
    + + Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} + {committerUser?.firstName || ""} {committerUser?.lastName || ""} ( + {committerUser?.email}){isReplication && " via replication"} + {!isReviewed && status === "open" && " - Review required"} + +
    + ); + })} +
    + ))} + {(isFetchingNextApprovalRequest || isApprovalRequestLoading) && ( +
    + {Array.apply(0, Array(3)).map((_x, index) => ( +
    +
    + + +
    + +
    + ))} +
    + )} +
    + {hasNextApprovalPage && ( + + )} +
    + )} +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx new file mode 100644 index 000000000..35b8bf2e0 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -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 ( +
    +
    + + + {isMergable ? "Good to merge" : "Review required"} + + At least {approvals} approving review required + {Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`} + + {isSoftEnforcement && !isMergable && ( +
    + setByPassApproval(checked === true)} + isChecked={byPassApproval} + id="byPassApproval" + checkIndicatorBg="text-white" + className={twMerge( + "mr-2", + byPassApproval ? "border-red bg-red hover:bg-red-600" : "" + )} + > + + Merge without waiting for approval (bypass secret change policy) + + + {byPassApproval && ( + + setBypassReason(e.target.value)} + placeholder="Enter reason for bypass (min 10 chars)" + leftIcon={} + /> + + )} +
    + )} +
    +
    +
    + {canApprove || isSoftEnforcement ? ( + <> + + + + ) : ( +
    Only approvers can merge
    + )} +
    +
    + ); + } + + if (hasMerged && status === "close") + return ( +
    +
    + + + Secret approval merged + + Merged by {statusChangeByEmail} + + +
    +
    + ); + + return ( +
    +
    + + + Secret approval has been closed + + Closed by {statusChangeByEmail} + + +
    +
    + +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx new file mode 100644 index 000000000..b663109a8 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx @@ -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 & { 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 ( + + Request for secret {text.label} + + ); +}; + +const generateConflictText = (op: CommitType) => { + if (op === CommitType.CREATE) return
    Secret already exist
    ; + if (op === CommitType.UPDATE) return
    Secret not found
    ; + 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 ( +
    +
    +
    {generateItemTitle(op)}
    + {!hasMerged && isStale && ( +
    + + Secret has been changed(stale) +
    + )} + {hasMerged && hasConflict && ( +
    + + + +
    {generateConflictText(op)}
    +
    + )} +
    + + + + + {op === CommitType.UPDATE && + + + + + + {op === CommitType.UPDATE ? ( + + + + + + + + + + + + + + + + + ) : ( + + + + + + + + + )} +
    } + SecretValueCommentTags
    OLD{secretVersion?.secretKey} + + {secretVersion?.secretComment} + {secretVersion?.tags?.map(({ slug, id: tagId, color }) => ( + +
    +
    {slug}
    + + ))} +
    NEW{newVersion?.secretKey} + + {newVersion?.secretComment} + {newVersion?.tags?.map(({ slug, id: tagId, color }) => ( + +
    +
    {slug}
    + + ))} +
    + {op === CommitType.CREATE ? newVersion?.secretKey : secretVersion?.secretKey} + + + + {op === CommitType.CREATE + ? newVersion?.secretComment + : secretVersion?.secretComment} + + {(op === CommitType.CREATE ? newVersion?.tags : secretVersion?.tags)?.map( + ({ slug, id: tagId, color }) => ( + +
    +
    {slug}
    + + ) + )} +
    +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx new file mode 100644 index 000000000..04bee3042 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -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 = {}; + commits.forEach(({ op }) => { + score[op] = (score?.[op] || 0) + 1; + }); + const text: ReactNode[] = []; + if (score[CommitType.CREATE]) + text.push( + + {score[CommitType.CREATE]} secret{score[CommitType.CREATE] !== 1 && "s"} + created + + ); + if (score[CommitType.UPDATE]) + text.push( + + {Boolean(text.length) && ","} + {score[CommitType.UPDATE]} secret{score[CommitType.UPDATE] !== 1 && "s"} + + {" "} + updated + + + ); + if (score[CommitType.DELETE]) + text.push( + + {Boolean(text.length) && "and"} + {score[CommitType.DELETE]} secret{score[CommitType.UPDATE] !== 1 && "s"} + deleted + + ); + + return text; +}; + +const getReviewedStatusSymbol = (status?: ApprovalStatus) => { + if (status === ApprovalStatus.APPROVED) + return ; + if (status === ApprovalStatus.REJECTED) + return ; + return ; +}; + +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 + >( + (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 ( +
    + +
    + ); + } + + if (!isSecretApprovalRequestSuccess) + return ( +
    + +
    + ); + + const isMergable = + secretApprovalRequestDetails?.policy?.approvals <= + secretApprovalRequestDetails?.policy?.approvers?.filter( + ({ userId }) => reviewedUsers?.[userId] === ApprovalStatus.APPROVED + ).length; + const hasMerged = secretApprovalRequestDetails?.hasMerged; + + return ( +
    +
    +
    + + + +
    + + + {secretApprovalRequestDetails.status === "close" + ? "closed" + : secretApprovalRequestDetails.status} + +
    +
    +
    + {generateCommitText(secretApprovalRequestDetails.commits)} + {secretApprovalRequestDetails.isReplicated && ( + (replication) + )} +
    +
    + {secretApprovalRequestDetails?.committerUser?.firstName || ""} + {secretApprovalRequestDetails?.committerUser?.lastName || ""} ( + {secretApprovalRequestDetails?.committerUser?.email}) wants to change{" "} + {secretApprovalRequestDetails.commits.length} secret values in + + {secretApprovalRequestDetails.environment} + +
    +
    + +
    + +
    + {formatReservedPaths(secretApprovalRequestDetails.secretPath)} +
    +
    +
    +
    +
    + {!hasMerged && secretApprovalRequestDetails.status === "open" && ( + <> + + + + )} +
    +
    + {secretApprovalRequestDetails.commits.map( + ({ op, secretVersion, secret, ...newVersion }, index) => ( + + ) + )} +
    +
    + +
    +
    +
    +
    Reviewers
    +
    + {secretApprovalRequestDetails?.policy?.approvers.map((requiredApprover) => { + const status = reviewedUsers?.[requiredApprover.userId]; + return ( +
    +
    + + {requiredApprover?.email} + + * +
    +
    + + {getReviewedStatusSymbol(status)} + +
    +
    + ); + })} + {secretApprovalRequestDetails?.reviewers + .filter( + (reviewer) => + !secretApprovalRequestDetails?.policy?.approvers?.some( + ({ userId }) => userId === reviewer.userId + ) + ) + .map((reviewer) => { + const status = reviewedUsers?.[reviewer.userId]; + return ( +
    +
    + + {reviewer?.email} + + * +
    +
    + + {getReviewedStatusSymbol(status)} + +
    +
    + ); + })} +
    +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/index.tsx new file mode 100644 index 000000000..5752309ca --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/-components/SecretApprovalRequest/index.tsx @@ -0,0 +1 @@ +export { SecretApprovalRequest } from "./SecretApprovalRequest"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/index.tsx new file mode 100644 index 000000000..1c6d5c473 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/approval/index.tsx @@ -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 ( +
    + + {t("common.head-title", { title: t("approval.title") })} + + + + + +
    +
    +
    +

    Approval Workflows

    +

    + Create approval policies for any modifications to secrets in sensitive environments + and folders. +

    +
    + +
    + + + + Secret Requests + {Boolean(secretApprovalReqCount?.open) && ( + {secretApprovalReqCount?.open} + )} + + + Access Requests + {Boolean(accessApprovalRequestCount?.pendingCount) && ( + {accessApprovalRequestCount?.pendingCount} + )} + + Policies + + + + + + + + + + + +
    +
    + ); +}; + +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) +}); diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx new file mode 100644 index 000000000..a1969f970 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx @@ -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; + +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({ + 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 ( +
    + +
    + +
    + {isDirty && ( + + )} +
    + + + + + + + {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) => ( + onNewPolicy(permissionSubject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[permissionSubject as ProjectPermissionSub].title} + + ))} + + +
    +
    +
    +
    +
    Overview
    +

    + Additional privileges take precedence over roles when permissions conflict +

    +
    +
    + ( + + + + )} + /> +
    +
    + + +
    + + + + +
    +
    + +
    +
    + Configure Timed Access +
    + {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
    + + {isTemporary && ( + + )} +
    +
    +
    +
    +
    +
    +
    +
    +
    Policies
    + {(isCreate || !isPending) && } + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map( + (permissionSubject) => ( + + {renderConditionalComponents(permissionSubject, isDisabled)} + + ) + )} +
    +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx new file mode 100644 index 000000000..ef566be71 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx @@ -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 ( +
    + + {popUp?.modifyPrivilege.isOpen ? ( + + handlePopUpClose("modifyPrivilege")} + identityId={identityId} + privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} + isDisabled={permission.cannot( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Identity, { + identityId + }) + )} + /> + + ) : ( + +
    +

    + Project Additional Privileges +

    + + + {(isAllowed) => ( + { + handlePopUpOpen("modifyPrivilege"); + }} + isDisabled={!isAllowed} + > + + + )} + +
    +
    + + + + + + + + + + {isPending && ( + + )} + {!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 ( + { + if (evt.key === "Enter") { + handlePopUpOpen("modifyPrivilege", privilegeDetails); + } + }} + onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)} + > + + + + + ); + })} + +
    NameDuration +
    {privilegeDetails.slug} + + + {text} + + + +
    + + {(isAllowed) => ( + { + e.stopPropagation(); + e.preventDefault(); + handlePopUpOpen("deletePrivilege", { + id: privilegeDetails?.id, + slug: privilegeDetails?.slug + }); + }} + > + + + )} + + + + +
    +
    + {!isPending && !identityProjectPrivileges?.length && ( + + )} +
    +
    + handlePopUpToggle("deletePrivilege", isOpen)} + onDeleteApproved={() => handlePrivilegeDelete()} + /> +
    + )} +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityProjectAdditionalPrivilegeSection/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityProjectAdditionalPrivilegeSection/index.tsx new file mode 100644 index 000000000..94f239c3d --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityProjectAdditionalPrivilegeSection/index.tsx @@ -0,0 +1 @@ +export { IdentityProjectAdditionalPrivilegeSection } from "./IdentityProjectAdditionalPrivilegeSection"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx new file mode 100644 index 000000000..1fa98628e --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx @@ -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 ( +
    +
    +

    Project Roles

    + + {(isAllowed) => ( + { + handlePopUpOpen("modifyRole"); + }} + isDisabled={!isAllowed} + > + + + )} + +
    +
    + + + + + + + + + + {isMembershipDetailsLoading && ( + + )} + {!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 ( + + + + + + ); + })} + +
    RoleDuration +
    + {roleDetails.role === "custom" + ? roleDetails.customRoleName + : formatProjectRoleName(roleDetails.role)} + + + + {text} + + + +
    + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteRole", { + id: roleDetails?.id, + slug: roleDetails?.customRoleName || roleDetails?.role + }); + }} + > + + + )} + +
    +
    + {!isMembershipDetailsLoading && !identityMembershipDetails?.roles?.length && ( + + )} +
    +
    + handlePopUpToggle("deleteRole", isOpen)} + onDeleteApproved={() => handleRoleDelete()} + /> + handlePopUpToggle("modifyRole", isOpen)} + > + + + + +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityRoleDetailsSection/IdentityRoleModify.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityRoleDetailsSection/IdentityRoleModify.tsx new file mode 100644 index 000000000..5aa4ffc32 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityRoleDetailsSection/IdentityRoleModify.tsx @@ -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; + +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({ + 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 ( +
    + +
    + ); + + return ( +
    +
    + {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
    + ( + + )} + /> + + +
    + + + +
    +
    + +
    +
    + Configure Timed Access +
    + {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
    + + {temporaryAccess.isTemporary && ( + + )} +
    +
    +
    +
    + { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + +
    + ); + })} +
    +
    + + {(isAllowed) => ( + + )} + + +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityRoleDetailsSection/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityRoleDetailsSection/index.tsx new file mode 100644 index 000000000..45a444e73 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/-components/IdentityRoleDetailsSection/index.tsx @@ -0,0 +1 @@ +export { IdentityRoleDetailsSection } from "./IdentityRoleDetailsSection"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/index.tsx new file mode 100644 index 000000000..0368eb3ad --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/identities.$identityId/index.tsx @@ -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 ( +
    + +
    + ); + } + + return ( +
    +
    + +
    + {identityMembershipDetails ? ( + <> +
    +
    +
    +

    + Project Identity Access +

    +
    + + {(isAllowed) => ( + + )} + +
    +
    +
    +
    + Name + {identityMembershipDetails && ( +

    + {identityMembershipDetails?.identity?.name} +

    + )} +
    +
    +
    + Joined on{" "} + {identityMembershipDetails?.createdAt && + format(new Date(identityMembershipDetails?.createdAt || ""), "yyyy-MM-dd")} +
    +
    +
    + + + handlePopUpToggle("deleteIdentity", isOpen)} + deleteKey="remove" + onDeleteApproved={() => onRemoveIdentitySubmit()} + /> + + ) : ( + + )} +
    + ); + }, + { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.Identity + } +); + +const IdentityDetailsRoute = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +}; + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/identities/$identityId/" +)({ + component: IdentityDetailsRoute +}); diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx new file mode 100644 index 000000000..95a7ffa66 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx @@ -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 ( +
    + + {popUp?.modifyPrivilege.isOpen ? ( + + handlePopUpClose("modifyPrivilege")} + projectMembershipId={membershipDetails?.id} + privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} + isDisabled={ + isOwnProjectMembershipDetails || + permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) + } + /> + + ) : ( + +
    +

    + Project Additional Privileges +

    + {userId !== membershipDetails?.user?.id && + membershipDetails?.status !== "invited" && ( + + {(isAllowed) => ( + { + handlePopUpOpen("modifyPrivilege"); + }} + isDisabled={!isAllowed} + > + + + )} + + )} +
    +
    + + + + + + + + + + {isPending && } + {!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 ( + { + if (evt.key === "Enter") { + handlePopUpOpen("modifyPrivilege", privilegeDetails); + } + }} + onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)} + > + + + + + ); + })} + +
    NameDuration +
    {privilegeDetails.slug} + + + {text} + + + +
    + + {(isAllowed) => ( + { + e.stopPropagation(); + e.preventDefault(); + handlePopUpOpen("deletePrivilege", { + id: privilegeDetails?.id, + slug: privilegeDetails?.slug + }); + }} + > + + + )} + + + + +
    +
    + {!isPending && !userProjectPrivileges?.length && ( + + )} +
    +
    + handlePopUpToggle("deletePrivilege", isOpen)} + onDeleteApproved={() => handlePrivilegeDelete()} + /> +
    + )} +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx new file mode 100644 index 000000000..e6ccda691 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx @@ -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; + +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({ + 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 ( +
    + +
    + +
    + {isDirty && ( + + )} +
    + + + + + + + {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) => ( + onNewPolicy(subject as ProjectPermissionSub)} + > + {PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title} + + ))} + + +
    +
    +
    +
    +
    Overview
    +

    + Additional privileges take precedence over roles when permissions conflict +

    +
    +
    + ( + + + + )} + /> +
    +
    + + +
    + + + + +
    +
    + +
    +
    + Configure Timed Access +
    + {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
    + + {isTemporary && ( + + )} +
    +
    +
    +
    +
    +
    +
    +
    +
    Policies
    + {(isCreate || !isPending) && } + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( + + {renderConditionalComponents(subject, isDisabled)} + + ))} +
    +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberProjectAdditionalPrivilegeSection/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberProjectAdditionalPrivilegeSection/index.tsx new file mode 100644 index 000000000..c563ab035 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberProjectAdditionalPrivilegeSection/index.tsx @@ -0,0 +1 @@ +export { MemberProjectAdditionalPrivilegeSection } from "./MemberProjectAdditionalPrivilegeSection"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx new file mode 100644 index 000000000..5ff78360d --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx @@ -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 ( +
    +
    +

    Project Roles

    + {!isOwnProjectMembershipDetails && membershipDetails?.status !== "invited" && ( + + {(isAllowed) => ( + { + handlePopUpOpen("modifyRole"); + }} + isDisabled={!isAllowed} + > + + + )} + + )} +
    +
    + + + + + + + + + + {isMembershipDetailsLoading && ( + + )} + {!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 ( + + + + + + ); + })} + +
    RoleDuration +
    + {roleDetails.role === "custom" + ? roleDetails.customRoleName + : formatProjectRoleName(roleDetails.role)} + + + + {text} + + + +
    + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteRole", { + id: roleDetails?.id, + slug: roleDetails?.customRoleName || roleDetails?.role + }); + }} + > + + + )} + +
    +
    + {!isMembershipDetailsLoading && !membershipDetails?.roles?.length && ( + + )} +
    +
    + handlePopUpToggle("deleteRole", isOpen)} + onDeleteApproved={() => handleRoleDelete()} + /> + handlePopUpToggle("modifyRole", isOpen)} + > + + + + +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberRoleDetailsSection/MemberRoleModify.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberRoleDetailsSection/MemberRoleModify.tsx new file mode 100644 index 000000000..3a140ba56 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberRoleDetailsSection/MemberRoleModify.tsx @@ -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; + +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({ + 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 ( +
    + +
    + ); + + return ( +
    +
    + {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
    + ( + + )} + /> + + +
    + + + +
    +
    + +
    +
    + Configure Timed Access +
    + {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
    + + {temporaryAccess.isTemporary && ( + + )} +
    +
    +
    +
    + { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + +
    + ); + })} +
    +
    + + {(isAllowed) => ( + + )} + + +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberRoleDetailsSection/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberRoleDetailsSection/index.tsx new file mode 100644 index 000000000..cdeb3a958 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/-components/MemberRoleDetailsSection/index.tsx @@ -0,0 +1 @@ +export { MemberRoleDetailsSection } from "./MemberRoleDetailsSection"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/index.tsx new file mode 100644 index 000000000..a6c59a17b --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/members.$membershipId/index.tsx @@ -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 ( +
    + +
    + ); + } + + return ( +
    +
    + +
    + {membershipDetails ? ( + <> +
    +
    +
    +

    Project User Access

    +
    + + {(isAllowed) => ( + + )} + +
    +
    +
    +
    + Name + {membershipDetails && ( +

    + {membershipDetails.user.firstName || membershipDetails.user.lastName + ? `${membershipDetails.user.firstName} ${membershipDetails.user.lastName}` + : "-"} +

    + )} +
    +
    + Email + {membershipDetails && ( +

    {membershipDetails?.user?.email}

    + )} +
    +
    +
    + Joined on{" "} + {membershipDetails?.createdAt && + format(new Date(membershipDetails?.createdAt || ""), "yyyy-MM-dd")} +
    +
    +
    + + handlePopUpOpen("upgradePlan", { + description: + "You can assign custom roles to members if you upgrade your Infisical plan." + }) + } + /> + + handlePopUpToggle("removeMember", isOpen)} + onDeleteApproved={handleRemoveUser} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> + + ) : ( + + )} +
    + ); + }, + { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.Member + } +); + +const MemberDetailsPageRoute = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("settings.members.title") })} + + + + + ); +}; + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/members/$membershipId/" +)({ + component: MemberDetailsPageRoute +}); diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview.tsx deleted file mode 100644 index 1071cab12..000000000 --- a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview.tsx +++ /dev/null @@ -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 ( -
    - Hello - "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview"! -
    - ); -} diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/CreateSecretForm/CreateSecretForm.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/CreateSecretForm/CreateSecretForm.tsx new file mode 100644 index 000000000..aabd7899e --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/CreateSecretForm/CreateSecretForm.tsx @@ -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; + +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({ 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(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) => { + 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 ( +
    + + { + 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} + /> + + ( + + + + )} + /> + ( + + + You do not have permission to read tags. +
    + ) : ( + "" + ) + } + > + 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} + /> + + )} + /> + ( + + + 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} + /> + + )} + name="environments" + /> +
    + + +
    + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/CreateSecretForm/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/CreateSecretForm/index.tsx new file mode 100644 index 000000000..a7851ed77 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/CreateSecretForm/index.tsx @@ -0,0 +1 @@ +export { CreateSecretForm } from "./CreateSecretForm"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/FolderBreadCrumbs/FolderBreadCrumbs.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/FolderBreadCrumbs/FolderBreadCrumbs.tsx new file mode 100644 index 000000000..8f6908142 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/FolderBreadCrumbs/FolderBreadCrumbs.tsx @@ -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 ( +
    +
    onFolderCrumbClick(0)} + onKeyDown={() => null} + role="button" + tabIndex={0} + > + +
    + {(secretPath || "") + .split("/") + .filter(Boolean) + .map((path, index, arr) => ( +
    onFolderCrumbClick(index + 1)} + onKeyDown={() => null} + role="button" + tabIndex={0} + > + {path} +
    + ))} +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/FolderBreadCrumbs/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/FolderBreadCrumbs/index.tsx new file mode 100644 index 000000000..8224cdb25 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/FolderBreadCrumbs/index.tsx @@ -0,0 +1 @@ +export { FolderBreadCrumbs } from "./FolderBreadCrumbs"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewDynamicSecretRow/SecretOverviewDynamicSecretRow.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewDynamicSecretRow/SecretOverviewDynamicSecretRow.tsx new file mode 100644 index 000000000..f814b336b --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewDynamicSecretRow/SecretOverviewDynamicSecretRow.tsx @@ -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 ( + + +
    +
    + +
    +
    {dynamicSecretName}
    +
    + + {environments.map(({ slug }, i) => { + const isPresent = isDynamicSecretInEnv(dynamicSecretName, slug); + + return ( + +
    + +
    + + ); + })} + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewDynamicSecretRow/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewDynamicSecretRow/index.tsx new file mode 100644 index 000000000..f2375625b --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewDynamicSecretRow/index.tsx @@ -0,0 +1 @@ +export { SecretOverviewDynamicSecretRow } from "./SecretOverviewDynamicSecretRow"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx new file mode 100644 index 000000000..2625d14c6 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx @@ -0,0 +1,84 @@ +import { faCheck, faFolder, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, IconButton, Td, Tr } from "@app/components/v2"; + +type Props = { + folderName: string; + environments: { name: string; slug: string }[]; + isFolderPresentInEnv: (name: string, env: string) => boolean; + onClick: (path: string) => void; + isSelected: boolean; + onToggleFolderSelect: (folderName: string) => void; + onToggleFolderEdit: (name: string) => void; +}; + +export const SecretOverviewFolderRow = ({ + folderName, + environments = [], + isFolderPresentInEnv, + isSelected, + onToggleFolderSelect, + onToggleFolderEdit, + onClick +}: Props) => { + return ( + onClick(folderName)}> + +
    +
    + { + onToggleFolderSelect(folderName); + }} + onClick={(e) => { + e.stopPropagation(); + }} + className={twMerge("hidden group-hover:flex", isSelected && "flex")} + /> + +
    +
    {folderName}
    + { + onToggleFolderEdit(folderName); + e.stopPropagation(); + }} + > + + +
    + + {environments.map(({ slug }, i) => { + const isPresent = isFolderPresentInEnv(folderName, slug); + + return ( + +
    + +
    + + ); + })} + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewFolderRow/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewFolderRow/index.tsx new file mode 100644 index 000000000..3b2b00ded --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewFolderRow/index.tsx @@ -0,0 +1 @@ +export { SecretOverviewFolderRow } from "./SecretOverviewFolderRow"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretEditRow.tsx new file mode 100644 index 000000000..d3dfd49e7 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretEditRow.tsx @@ -0,0 +1,295 @@ +import { useCallback, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { subject } from "@casl/ability"; +import { + faCheck, + faCopy, + faProjectDiagram, + faTrash, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + hasSecretReference, + SecretReferenceTree +} from "@app/components/secrets/SecretReferenceDetails"; +import { + DeleteActionModal, + IconButton, + Modal, + ModalContent, + ModalTrigger, + Tooltip +} from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { SecretType } from "@app/hooks/api/types"; + +type Props = { + defaultValue?: string | null; + secretName: string; + secretId?: string; + isOverride?: boolean; + isCreatable?: boolean; + isVisible?: boolean; + isImportedSecret: boolean; + environment: string; + secretPath: string; + onSecretCreate: (env: string, key: string, value: string) => Promise; + onSecretUpdate: ( + env: string, + key: string, + value: string, + type?: SecretType, + secretId?: string + ) => Promise; + onSecretDelete: (env: string, key: string, secretId?: string) => Promise; +}; + +export const SecretEditRow = ({ + defaultValue, + isCreatable, + isOverride, + isImportedSecret, + onSecretUpdate, + secretName, + onSecretCreate, + onSecretDelete, + environment, + secretPath, + isVisible, + secretId +}: Props) => { + const { + handleSubmit, + control, + reset, + getValues, + formState: { isDirty, isSubmitting } + } = useForm({ + values: { + value: defaultValue || null + } + }); + const [isDeleting, setIsDeleting] = useToggle(); + const [isModalOpen, setIsModalOpen] = useState(false); + + const toggleModal = useCallback(() => { + setIsModalOpen((prev) => !prev); + }, []); + + const handleFormReset = () => { + reset(); + }; + + const handleCopySecretToClipboard = async () => { + const { value } = getValues(); + if (value) { + try { + await window.navigator.clipboard.writeText(value); + createNotification({ type: "success", text: "Copied secret to clipboard" }); + } catch (error) { + console.log(error); + createNotification({ type: "error", text: "Failed to copy secret to clipboard" }); + } + } + }; + + const handleFormSubmit = async ({ value }: { value?: string | null }) => { + if ((value || value === "") && secretName) { + if (isCreatable) { + await onSecretCreate(environment, secretName, value); + } else { + await onSecretUpdate( + environment, + secretName, + value, + isOverride ? SecretType.Personal : SecretType.Shared, + secretId + ); + } + } + reset({ value }); + }; + + const handleDeleteSecret = useCallback(async () => { + setIsDeleting.on(); + setIsModalOpen(false); + + try { + await onSecretDelete(environment, secretName, secretId); + reset({ value: null }); + } finally { + setIsDeleting.off(); + } + }, [onSecretDelete, environment, secretName, secretId, reset, setIsDeleting]); + + return ( +
    + + +
    + ( + + )} + /> +
    +
    + {isDirty ? ( + <> + + {(isAllowed) => ( +
    + + + + + +
    + )} +
    +
    + + + + + +
    + + ) : ( + <> +
    + + + + + +
    + + {(isAllowed) => ( +
    + + +
    + + + + + +
    +
    + e.preventDefault()} // prevents secret input from displaying value on open + > + + +
    +
    + )} +
    + + {(isAllowed) => ( +
    + + + + + +
    + )} +
    + + )} +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretNoAccessOverviewTableRow.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretNoAccessOverviewTableRow.tsx new file mode 100644 index 000000000..c6c32ea2e --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretNoAccessOverviewTableRow.tsx @@ -0,0 +1,51 @@ +import { faCircle } from "@fortawesome/free-regular-svg-icons"; +import { faLock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Td, Tooltip, Tr } from "@app/components/v2"; + +type Props = { + environments: { name: string; slug: string }[]; + count: number; +}; + +export const SecretNoAccessOverviewTableRow = ({ environments = [], count }: Props) => { + return ( + <> + {Array.from(Array(count)).map((_, j) => ( + + +
    + +
    +
    + +
    +
    NO ACCESS
    +
    +
    +
    + + {environments.map(({ slug }, i) => { + return ( + +
    +
    + +
    +
    + + ); + })} + + ))} + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretOverviewTableRow.tsx new file mode 100644 index 000000000..2ffee7f9c --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -0,0 +1,251 @@ +import { faCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faAngleDown, + faCheck, + faEye, + faEyeSlash, + faFileImport, + faKey, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; +import { WorkspaceEnv } from "@app/hooks/api/types"; + +import { SecretEditRow } from "./SecretEditRow"; +import SecretRenameRow from "./SecretRenameRow"; + +type Props = { + secretKey: string; + secretPath: string; + environments: { name: string; slug: string }[]; + isSelected: boolean; + onToggleSecretSelect: (key: string) => void; + getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined; + onSecretCreate: (env: string, key: string, value: string) => Promise; + onSecretUpdate: ( + env: string, + key: string, + value: string, + type?: SecretType, + secretId?: string + ) => Promise; + onSecretDelete: (env: string, key: string, secretId?: string) => Promise; + isImportedSecretPresentInEnv: (env: string, secretName: string) => boolean; + getImportedSecretByKey: ( + env: string, + secretName: string + ) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined; + scrollOffset: number; +}; + +export const SecretOverviewTableRow = ({ + secretKey, + environments = [], + secretPath, + getSecretByKey, + onSecretUpdate, + onSecretCreate, + onSecretDelete, + isImportedSecretPresentInEnv, + getImportedSecretByKey, + scrollOffset, + onToggleSecretSelect, + isSelected +}: Props) => { + const [isFormExpanded, setIsFormExpanded] = useToggle(); + const totalCols = environments.length + 1; // secret key row + const [isSecretVisible, setIsSecretVisible] = useToggle(); + + return ( + <> + setIsFormExpanded.toggle()} className="group"> + +
    +
    +
    + { + onToggleSecretSelect(secretKey); + }} + onClick={(e) => { + e.stopPropagation(); + }} + className={twMerge("hidden group-hover:flex", isSelected && "flex")} + /> + +
    +
    {secretKey}
    +
    +
    + + {environments.map(({ slug }, i) => { + const secret = getSecretByKey(slug, secretKey); + + const isSecretImported = isImportedSecretPresentInEnv(slug, secretKey); + + const isSecretPresent = Boolean(secret); + const isSecretEmpty = secret?.value === ""; + return ( + +
    +
    + {!isSecretEmpty && ( + + + + )} + {isSecretEmpty && ( + + + + )} +
    +
    + + ); + })} + + {isFormExpanded && ( + + +
    + + + + + + + +
    + +
    + + + + {environments.map(({ name, slug }) => { + const secret = getSecretByKey(slug, secretKey); + const isCreatable = !secret; + + const isImportedSecret = isImportedSecretPresentInEnv(slug, secretKey); + const importedSecret = getImportedSecretByKey(slug, secretKey); + + return ( + + + + + ); + })} + +
    + Environment + + Value +
    +
    + {name} + {isImportedSecret && ( + + + + )} +
    +
    + +
    +
    +
    + + + )} + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretRenameRow.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretRenameRow.tsx new file mode 100644 index 000000000..2295a963c --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/SecretRenameRow.tsx @@ -0,0 +1,259 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { subject } from "@casl/ability"; +import { faCheck, faClose, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { IconButton, Input, Spinner, Tooltip } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { useToggle } from "@app/hooks"; +import { useUpdateSecretV3 } from "@app/hooks/api"; +import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types"; + +enum SecretActionType { + Created = "created", + Modified = "modified", + Deleted = "deleted" +} + +type Props = { + secretKey: string; + secretPath: string; + environments: { name: string; slug: string }[]; + getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined; +}; + +export const formSchema = z.object({ + key: z.string().trim().min(1, { message: "Secret key is required" }) +}); + +type TFormSchema = z.infer; + +function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }: Props) { + const { currentWorkspace } = useWorkspace(); + const { permission } = useProjectPermission(); + + const secrets = environments.map((env) => getSecretByKey(env.slug, secretKey)); + + const isReadOnly = environments.some((env) => { + const environment = env.slug; + const secretDetails = getSecretByKey(environment, secretKey); + const secretPermissionSubject = subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: secretKey, + secretTags: (secretDetails?.tags || []).map((i) => i.slug) + }); + const isSecretInEnvReadOnly = + permission.can(ProjectPermissionActions.Read, secretPermissionSubject) && + permission.cannot(ProjectPermissionActions.Edit, secretPermissionSubject); + if (isSecretInEnvReadOnly) { + return true; + } + return false; + }); + + const isOverriden = secrets.some( + (secret) => + secret?.overrideAction === SecretActionType.Created || + secret?.overrideAction === SecretActionType.Modified + ); + const workspaceId = currentWorkspace?.id || ""; + + const [isSecNameCopied, setIsSecNameCopied] = useToggle(false); + + const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); + + const { + handleSubmit, + control, + reset, + trigger, + getValues, + formState: { isDirty, isSubmitting, errors } + } = useForm({ + defaultValues: { key: secretKey }, + values: { key: secretKey }, + resolver: zodResolver(formSchema) + }); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isSecNameCopied) { + timer = setTimeout(() => setIsSecNameCopied.off(), 2000); + } + return () => clearTimeout(timer); + }, [isSecNameCopied]); + + const handleFormSubmit = async (data: TFormSchema) => { + if (!data.key) { + createNotification({ + type: "error", + text: "Secret name cannot be empty" + }); + return; + } + + const promises = secrets + .filter((secret) => !!secret) + .map((secret) => { + if (!secret) return null; + + return updateSecretV3({ + environment: secret?.env, + workspaceId, + secretPath, + secretKey: secret.key, + secretValue: secret.value || "", + type: SecretType.Shared, + tagIds: secret.tags?.map((tag) => tag.id), + secretComment: secret.comment, + secretReminderRepeatDays: secret.reminderRepeatDays, + secretReminderNote: secret.reminderNote, + skipMultilineEncoding: secret.skipMultilineEncoding, + newSecretName: data.key + }); + }); + + await Promise.all(promises) + .then(() => { + createNotification({ + type: "success", + text: "Successfully renamed the secret" + }); + }) + .catch(() => { + createNotification({ + type: "error", + text: "Error renaming the secret" + }); + }); + }; + + const copyTokenToClipboard = () => { + const [key] = getValues(["key"]); + navigator.clipboard.writeText(key as string); + setIsSecNameCopied.on(); + }; + + return ( +
    +
    + + Key + + + ( + trigger("key")} + isError={Boolean(error)} + {...field} + className="w-full px-2 placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent" + /> + )} + /> +
    + + {isReadOnly || isOverriden ? ( + Read Only + ) : ( +
    + + {!isDirty ? ( + + + + + + + + ) : ( + + + + {isSubmitting ? ( + + ) : ( + + )} + + + + reset()} + isDisabled={isSubmitting} + > + + + + + )} + +
    + )} +
    + ); +} + +export default SecretRenameRow; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/index.tsx new file mode 100644 index 000000000..5b7f3847f --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretOverviewTableRow/index.tsx @@ -0,0 +1,2 @@ +export { SecretNoAccessOverviewTableRow } from "./SecretNoAccessOverviewTableRow"; +export { SecretOverviewTableRow } from "./SecretOverviewTableRow"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/SecretSearchInput.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/SecretSearchInput.tsx new file mode 100644 index 000000000..9c8562e06 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/SecretSearchInput.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; +import { faCircleXmark, faFolderTree, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Combobox, Transition } from "@headlessui/react"; +import { twMerge } from "tailwind-merge"; + +import { IconButton, Tooltip } from "@app/components/v2"; + +import { QuickSearchModal, QuickSearchModalProps } from "./components"; + +type ModalProps = Omit< + QuickSearchModalProps, + "isOpen" | "onClose" | "onOpenChange" | "initialValue" +> & { + value: string; + onChange: (search: string) => void; + className?: string; +}; + +export const SecretSearchInput = ({ + value, + onChange, + className, + isSingleEnv, + ...props +}: ModalProps) => { + const [isOpen, setIsOpen] = useState(false); + const hasSearch = Boolean(value.trim()); + + return ( +
    + + {({ activeIndex }) => ( + <> +
    + + + + +
    + { + if (activeIndex === 0 && e.key === "Enter") setIsOpen(true); + }} + autoComplete="off" + className="input text-md h-[2.3rem] w-full rounded-md rounded-l-none bg-mineshaft-800 py-[0.375rem] pl-2.5 pr-8 text-gray-400 placeholder-mineshaft-50 placeholder-opacity-50 outline-none duration-200 placeholder:text-sm hover:ring-bunker-400/60 focus:bg-mineshaft-700/80 focus:ring-1 focus:ring-primary-400/50" + placeholder="Search by secret/folder name..." + value={value} + onChange={(e) => onChange(e.target.value)} + /> + {hasSearch && ( + onChange("")} + className="absolute right-2 text-primary" + ariaLabel="Clear search" + > + + + )} +
    +
    + + + setIsOpen(true)} + value={value} + className={({ active }) => + `flex w-full cursor-pointer items-start rounded-sm px-4 py-2 font-inter text-sm text-mineshaft-200 outline-none hover:bg-mineshaft-400 ${ + active ? "bg-mineshaft-500" : "" + }` + } + > + + {value.trim() + ? `Search for "${ + value.length > 10 ? `${value.substring(0, 10)}...` : value + }" in all folders` + : "Search in all folders"} + + + + + )} +
    + { + setIsOpen(false); + onChange(""); + }} + {...props} + /> +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchDynamicSecretItem.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchDynamicSecretItem.tsx new file mode 100644 index 000000000..dcc0b40fa --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchDynamicSecretItem.tsx @@ -0,0 +1,61 @@ +import { faChevronRight, faFingerprint, faFolder } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +import { Td, Tooltip, Tr } from "@app/components/v2"; +import { reverseTruncate } from "@app/helpers/reverseTruncate"; +import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types"; + +type Props = { + dynamicSecretGroup: TDashboardProjectSecretsQuickSearch["dynamicSecrets"][string]; + onClose: () => void; +}; + +export const QuickSearchDynamicSecretItem = ({ + dynamicSecretGroup, + + onClose +}: Props) => { + const navigate = useNavigate({ + from: "/secret-manager/$projectId/overview" + }); + + const [groupDynamicSecret] = dynamicSecretGroup; + + const handleNavigate = () => { + navigate({ + search: (prev) => ({ + ...prev, + secretPath: groupDynamicSecret.path, + search: groupDynamicSecret.name + }) + }); + onClose(); + }; + + return ( + + +
    + + + {groupDynamicSecret.name} + + + {" "} + + {reverseTruncate(groupDynamicSecret.path)} + + +
    + + + + + + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchFolderItem.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchFolderItem.tsx new file mode 100644 index 000000000..fa469500b --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchFolderItem.tsx @@ -0,0 +1,45 @@ +import { faChevronRight, faFolder } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; + +import { Td, Tooltip, Tr } from "@app/components/v2"; +import { reverseTruncate } from "@app/helpers/reverseTruncate"; +import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types"; + +type Props = { + folderGroup: TDashboardProjectSecretsQuickSearch["folders"][string]; + onClose: () => void; +}; + +export const QuickSearchFolderItem = ({ folderGroup, onClose }: Props) => { + const navigate = useNavigate({ + from: "/secret-manager/$projectId/overview" + }); + + const [groupFolder] = folderGroup; + + const handleNavigate = () => { + navigate({ + search: (prev) => ({ ...prev, secretPath: groupFolder.path }) + }); + onClose(); + }; + + return ( + + + + +
    {reverseTruncate(groupFolder.path)}
    +
    + + + + + + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchModal.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchModal.tsx new file mode 100644 index 000000000..f047eab74 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchModal.tsx @@ -0,0 +1,288 @@ +import { useState } from "react"; +import { + faCheckCircle, + faChevronLeft, + faFilter, + faFingerprint, + faFolder, + faKey, + faMagnifyingGlass +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger, + EmptyState, + IconButton, + Input, + Modal, + ModalContent, + Spinner, + Table, + TableContainer, + Tooltip +} from "@app/components/v2"; +import { useDebounce } from "@app/hooks"; +import { useGetProjectSecretsQuickSearch } from "@app/hooks/api/dashboard"; +import { WsTag } from "@app/hooks/api/tags/types"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; + +import { RowType } from "../../../../secrets.$envSlug/-components/SecretMainPage.types"; +import { QuickSearchDynamicSecretItem } from "./QuickSearchDynamicSecretItem"; +import { QuickSearchFolderItem } from "./QuickSearchFolderItem"; +import { QuickSearchSecretItem } from "./QuickSearchSecretItem"; + +export type QuickSearchModalProps = { + environments: WorkspaceEnv[]; + projectId: string; + tags?: WsTag[]; + isSingleEnv?: boolean; + initialValue: string; + onClose: () => void; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ResourceType = RowType.Secret | RowType.DynamicSecret | RowType.Folder; + +const Content = ({ + environments, + projectId, + onClose, + tags, + initialValue = "", + isSingleEnv +}: Omit) => { + const [search, setSearch] = useState(initialValue); + const [debouncedSearch] = useDebounce(search); + const [filterTags, setFilterTags] = useState>({}); + const [showFilter, setShowFilter] = useState>({ + [RowType.Secret]: true, + [RowType.Folder]: true, + [RowType.DynamicSecret]: true + }); + const isEnabled = Boolean(search.trim()) || Boolean(Object.values(filterTags).length); + const { data, isLoading } = useGetProjectSecretsQuickSearch( + { + secretPath: "/", + environments: environments.map((env) => env.slug), + projectId, + search: debouncedSearch, + tags: filterTags + }, + { enabled: isEnabled } + ); + + const { folders = {}, secrets = {}, dynamicSecrets = {} } = data ?? {}; + + const isEmpty = + (!showFilter[RowType.Folder] || Object.values(folders).length === 0) && + (!showFilter[RowType.Secret] || Object.values(secrets).length === 0) && + (!showFilter[RowType.DynamicSecret] || Object.values(dynamicSecrets).length === 0); + + const handleToggleTag = (tag: string) => { + setFilterTags((prev) => { + const updated = { ...prev }; + if (prev[tag]) delete updated[tag]; + else updated[tag] = true; + return updated; + }); + }; + + const handleToggleShowType = (type: ResourceType) => { + setShowFilter((prev) => ({ + ...prev, + [type]: !prev[type] + })); + }; + + return ( +
    +
    + setSearch(e.target.value)} + leftIcon={ + + } + /> + + + + !show)) && + "border-primary/50 text-primary" + )} + > + + + + + + Filter By + { + e.preventDefault(); + handleToggleShowType(RowType.Folder); + }} + icon={showFilter[RowType.Folder] && } + iconPos="right" + > +
    + + Folders +
    +
    + { + e.preventDefault(); + handleToggleShowType(RowType.DynamicSecret); + }} + icon={showFilter[RowType.DynamicSecret] && } + iconPos="right" + > +
    + + Dynamic Secrets +
    +
    + { + e.preventDefault(); + handleToggleShowType(RowType.Secret); + }} + icon={showFilter[RowType.Secret] && } + iconPos="right" + > +
    + + Secrets +
    +
    + {tags && tags.length > 0 && ( + + } + > + Tags + + + Filter Secrets by Tag(s) + {tags.map(({ id, slug, color }) => ( + { + evt.preventDefault(); + handleToggleTag(slug); + }} + key={id} + icon={filterTags[slug] && } + iconPos="right" + > +
    +
    + {slug} +
    + + ))} + + + )} + + +
    + +
    + {/* eslint-disable-next-line no-nested-ternary */} + {isEnabled ? ( + // eslint-disable-next-line no-nested-ternary + isLoading ? ( + + ) : isEmpty ? ( + + ) : ( + + + {showFilter[RowType.Folder] && + Object.entries(folders).map(([key, folderGroup]) => ( + + ))} + {showFilter[RowType.DynamicSecret] && + Object.entries(dynamicSecrets).map(([key, dynamicSecretGroup]) => ( + + ))} + {showFilter[RowType.Secret] && + Object.entries(secrets).map(([key, secretGroup]) => ( + + ))} +
    +
    + ) + ) : ( + + )} +
    +
    + ); +}; + +export const QuickSearchModal = ({ + isOpen, + isSingleEnv, + onOpenChange, + ...props +}: QuickSearchModalProps) => { + return ( + + + + + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchSecretItem.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchSecretItem.tsx new file mode 100644 index 000000000..e901a4ff7 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/QuickSearchSecretItem.tsx @@ -0,0 +1,215 @@ +import { + faCheck, + faChevronRight, + faCopy, + faEye, + faFolder, + faKey, + faTags +} 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 { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + IconButton, + Td, + Tooltip, + Tr +} from "@app/components/v2"; +import { reverseTruncate } from "@app/helpers/reverseTruncate"; +import { useTimedReset } from "@app/hooks"; +import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; + +type Props = { + environments: WorkspaceEnv[]; + secretGroup: TDashboardProjectSecretsQuickSearch["secrets"][string]; + onClose: () => void; + isSingleEnv?: boolean; + tags: string[]; + search: string; +}; + +export const QuickSearchSecretItem = ({ + secretGroup, + environments, + onClose, + tags, + isSingleEnv, + search +}: Props) => { + const navigate = useNavigate({ from: "/secret-manager/$projectId/overview" }); + const envSlugMap = new Map(environments.map((env) => [env.slug, env])); + const [isUrlCopied, , setIsUrlCopied] = useTimedReset({ + initialState: false + }); + + const [groupSecret] = secretGroup; + + const handleNavigate = () => { + navigate({ + search: (prev) => ({ + ...prev, + secretPath: groupSecret.path, + search: groupSecret.key, + tags: tags.length ? tags.join(",") : undefined + }) + }); + onClose(); + }; + + const handleCopy = (value: string, env: string) => { + navigator.clipboard.writeText(value); + createNotification({ + type: "info", + title: isSingleEnv ? "Secret value copied." : `Secret value copied from ${env}.`, + text: "" + }); + setIsUrlCopied(true); + }; + + const secretGroupTags = secretGroup.flatMap((secret) => secret.tags); + + const tagMatch = + search.trim() && + secretGroupTags?.find((tag) => tag && tag.slug.toLowerCase().includes(search.toLowerCase())); + + return ( + + +
    + + + {groupSecret.key} + + + {" "} + + {reverseTruncate(groupSecret.path ?? "")} + + +
    + + +
    + {tagMatch && ( + + + {tagMatch.slug} + + )} + {isSingleEnv ? ( + { + e.stopPropagation(); + const el = envSlugMap.get(groupSecret.env)?.name; + if (el) { + handleCopy(groupSecret.value!, el); + } + }} + > + + + ) : ( + + + + + + + + Copy Value From... + {secretGroup.map((secret) => ( + { + e.stopPropagation(); + const el = envSlugMap.get(secret.env)?.name; + if (el) { + handleCopy(secret.value!, el); + } + }} + key={secret.id} + > +

    {envSlugMap.get(secret.env)?.name}

    +
    + ))} +
    +
    + )} + + + + + + + + Hover to Reveal... + {secretGroup.map((secret) => ( + { + e.stopPropagation(); + const el = envSlugMap.get(secret.env)?.name; + if (el) { + handleCopy(secret.value!, el); + } + }} + key={secret.id} + > + +
    + {!isSingleEnv && ( + + {envSlugMap.get(secret.env)?.name} + + )} +

    + {secret.value || "EMPTY"} +

    +

    + *************************** +

    +
    +
    +
    + ))} +
    +
    +
    + + + + + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/index.tsx new file mode 100644 index 000000000..cc6805274 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/components/index.tsx @@ -0,0 +1,4 @@ +export * from "./QuickSearchDynamicSecretItem"; +export * from "./QuickSearchFolderItem"; +export * from "./QuickSearchModal"; +export * from "./QuickSearchSecretItem"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/index.tsx new file mode 100644 index 000000000..013b845ea --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretSearchInput/index.tsx @@ -0,0 +1 @@ +export { SecretSearchInput } from "./SecretSearchInput"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretTableResourceCount/SecretTableResourceCount.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretTableResourceCount/SecretTableResourceCount.tsx new file mode 100644 index 000000000..4d6be2574 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretTableResourceCount/SecretTableResourceCount.tsx @@ -0,0 +1,87 @@ +import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Tooltip } from "@app/components/v2"; + +type Props = { + folderCount?: number; + importCount?: number; + secretCount?: number; + dynamicSecretCount?: number; +}; + +export const SecretTableResourceCount = ({ + folderCount = 0, + dynamicSecretCount = 0, + secretCount = 0, + importCount = 0 +}: Props) => { + return ( +
    + {importCount > 0 && ( + + Total import count{" "} + (matching filters) +

    + } + > +
    + + {importCount} +
    +
    + )} + {folderCount > 0 && ( + + Total folder count{" "} + (matching filters) +

    + } + > +
    + + {folderCount} +
    +
    + )} + {dynamicSecretCount > 0 && ( + + Total dynamic secret count{" "} + (matching filters) +

    + } + > +
    + + {dynamicSecretCount} +
    +
    + )} + {secretCount > 0 && ( + + Total secret count{" "} + (matching filters) +

    + } + > +
    + + {secretCount} +
    +
    + )} +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretTableResourceCount/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretTableResourceCount/index.tsx new file mode 100644 index 000000000..d2b5705fa --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretTableResourceCount/index.tsx @@ -0,0 +1 @@ +export { SecretTableResourceCount } from "./SecretTableResourceCount"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretV2MigrationSection/SecretV2MigrationSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretV2MigrationSection/SecretV2MigrationSection.tsx new file mode 100644 index 000000000..8c0a0fadf --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretV2MigrationSection/SecretV2MigrationSection.tsx @@ -0,0 +1,198 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Checkbox, Modal, ModalContent, Spinner } from "@app/components/v2"; +import { useProjectPermission, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetWorkspaceById, useMigrateProjectToV3, workspaceKeys } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types"; + +enum ProjectUpgradeStatus { + InProgress = "IN_PROGRESS", + // Completed -> Will be null if completed. So a completed status is not needed + Failed = "FAILED" +} + +const formSchema = z.object({ + isCLIChecked: z.literal(true), + isOperatorChecked: z.literal(true), + shouldCloseOpenApprovals: z.literal(true) +}); + +export const SecretV2MigrationSection = () => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["migrationInfo"] as const); + const { currentWorkspace } = useWorkspace(); + const queryClient = useQueryClient(); + const { data: workspaceDetails, refetch } = useGetWorkspaceById( + // if v3 no need to fetch + currentWorkspace?.version === ProjectVersion.V3 ? "" : currentWorkspace?.id || "", + { + refetchInterval: + currentWorkspace?.upgradeStatus === ProjectUpgradeStatus.InProgress ? 2000 : false + } + ); + const { membership } = useProjectPermission(); + const migrateProjectToV3 = useMigrateProjectToV3(); + const { handleSubmit, control, reset } = useForm({ resolver: zodResolver(formSchema) }); + useEffect(() => { + if (!popUp.migrationInfo.isOpen) { + reset(); + } + }, [popUp.migrationInfo.isOpen]); + + const isProjectUpgraded = workspaceDetails?.version === ProjectVersion.V3; + + useEffect(() => { + if (isProjectUpgraded && migrateProjectToV3.data) { + createNotification({ type: "success", text: "Project upgrade completed successfully" }); + migrateProjectToV3.reset(); + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getAllUserWorkspace(ProjectType.SecretManager) + }); + } + }, [isProjectUpgraded, Boolean(migrateProjectToV3.data)]); + + if (isProjectUpgraded || currentWorkspace?.version === ProjectVersion.V3) return null; + + const isUpgrading = workspaceDetails?.upgradeStatus === ProjectUpgradeStatus.InProgress; + const didProjectUpgradeFailed = workspaceDetails?.upgradeStatus === ProjectUpgradeStatus.Failed; + + const handleMigrationSecretV2 = async () => { + try { + handlePopUpToggle("migrationInfo"); + await migrateProjectToV3.mutateAsync({ workspaceId: currentWorkspace?.id || "" }); + refetch(); + createNotification({ + text: "Project upgrade started", + type: "success" + }); + } catch { + createNotification({ + text: "Failed to upgrade project", + type: "error" + }); + } + }; + + const isAdmin = membership?.roles.includes(ProjectMembershipRole.Admin); + return ( +
    + {isUpgrading && ( +
    + +
    +
    Please wait
    + Upgrading secrets engine... +
    +
    + )} +
    + +

    Upgrade secrets engine version

    +
    +

    + Your existing workflows to fetch secrets will continue to work. However, viewing secrets on + the UI requires you to upgrade your project's secrets engine version. +

    +

    + Upgrading is free and enables the use of Infisical's new secrets engine, which is 10x + faster and allows you to encrypt secrets with your own KMS provider. +

    +

    + The upgrade takes only 1-2 minutes and will not cause any downtime. +

    + + {didProjectUpgradeFailed && ( +

    + + Secrets engine upgrade unsuccessful. For assistance, please contact the Infisical support + team. +

    + )} + handlePopUpToggle("migrationInfo", isOpen)} + > + +
    +
    +
    + ( + + Infisical CLI version is v0.25.0 or above. + + )} + /> + ( + + Infisical Kubernetes Operator version is v0.7.0 or above. + + )} + /> + ( + + Close/merge all open approval/access requests. + + )} + /> +
    +
    + + +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretV2MigrationSection/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretV2MigrationSection/index.tsx new file mode 100644 index 000000000..71c7bdc9c --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SecretV2MigrationSection/index.tsx @@ -0,0 +1 @@ +export { SecretV2MigrationSection } from "./SecretV2MigrationSection"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SelectionPanel/SelectionPanel.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SelectionPanel/SelectionPanel.tsx new file mode 100644 index 000000000..b2ea0e917 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/-components/SelectionPanel/SelectionPanel.tsx @@ -0,0 +1,206 @@ +import { subject } from "@casl/ability"; +import { faMinusSquare, faTrash } 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, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api"; +import { + SecretType, + SecretV3RawSanitized, + TDeleteSecretBatchDTO, + TSecretFolder +} from "@app/hooks/api/types"; + +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} + +type Props = { + secretPath: string; + resetSelectedEntries: () => void; + selectedEntries: { + [EntryType.FOLDER]: Record>; + [EntryType.SECRET]: Record>; + }; +}; + +export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries }: Props) => { + const { permission } = useProjectPermission(); + + const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ + "bulkDeleteEntries" + ] as const); + + const selectedFolderCount = Object.keys(selectedEntries.folder).length; + const selectedKeysCount = Object.keys(selectedEntries.secret).length; + const selectedCount = selectedFolderCount + selectedKeysCount; + + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const userAvailableEnvs = currentWorkspace?.environments || []; + const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { mutateAsync: deleteFolder } = useDeleteFolder(); + + const isMultiSelectActive = selectedCount > 0; + + // user should have the ability to delete secrets/folders in at least one of the envs + const shouldShowDelete = userAvailableEnvs.some((env) => + permission.can( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { + environment: env.slug, + secretPath, + secretName: "*", + secretTags: ["*"] + }) + ) + ); + + const getDeleteModalTitle = () => { + if (selectedFolderCount > 0 && selectedKeysCount > 0) { + return "Do you want to delete the selected secrets and folders across environments?"; + } + if (selectedKeysCount > 0) { + return "Do you want to delete the selected secrets across environments?"; + } + return "Do you want to delete the selected folders across environments?"; + }; + + const handleBulkDelete = async () => { + let processedEntries = 0; + + const promises = userAvailableEnvs.map(async (env) => { + // additional check: ensure that bulk delete is only executed on envs that user has access to + + if ( + permission.can( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.SecretFolders, { environment: env.slug, secretPath }) + ) + ) { + await Promise.all( + Object.values(selectedEntries.folder).map(async (folderRecord) => { + const folder = folderRecord[env.slug]; + if (folder) { + processedEntries += 1; + await deleteFolder({ + folderId: folder?.id, + path: secretPath, + environment: env.slug, + projectId: workspaceId + }); + } + }) + ); + } + + const secretsToDelete = Object.values(selectedEntries.secret).reduce( + (accum: TDeleteSecretBatchDTO["secrets"], secretRecord) => { + const entry = secretRecord[env.slug]; + const canDeleteSecret = permission.can( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { + environment: env.slug, + secretPath, + secretName: entry.key, + secretTags: (entry?.tags || []).map((i) => i.slug) + }) + ); + + if (entry && canDeleteSecret) { + return [ + ...accum, + { + secretKey: entry.key, + type: SecretType.Shared + } + ]; + } + return accum; + }, + [] + ); + + if (secretsToDelete.length > 0) { + processedEntries += secretsToDelete.length; + await deleteBatchSecretV3({ + secretPath, + workspaceId, + environment: env.slug, + secrets: secretsToDelete + }); + } + }); + + const results = await Promise.allSettled(promises); + const areEntriesDeleted = results.some((result) => result.status === "fulfilled"); + if (processedEntries === 0) { + handlePopUpClose("bulkDeleteEntries"); + createNotification({ + type: "info", + text: "You don't have access to delete selected items" + }); + } else if (areEntriesDeleted) { + handlePopUpClose("bulkDeleteEntries"); + resetSelectedEntries(); + createNotification({ + type: "success", + text: "Successfully deleted selected secrets and folders" + }); + } else { + createNotification({ + type: "error", + text: "Failed to delete selected secrets and folders" + }); + } + }; + + return ( + <> +
    +
    + + + + + +
    {selectedCount} Selected
    + {shouldShowDelete && ( + + )} +
    +
    + handlePopUpToggle("bulkDeleteEntries", isOpen)} + onDeleteApproved={handleBulkDelete} + /> + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/index.tsx new file mode 100644 index 000000000..ce5ea65d7 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/overview/index.tsx @@ -0,0 +1,1193 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { subject } from "@casl/ability"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faAngleDown, + faArrowDown, + faArrowUp, + faFingerprint, + faFolder, + faFolderBlank, + faFolderPlus, + faKey, + faList, + faPlus +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { createFileRoute, Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router"; +import { zodValidator } from "@tanstack/zod-adapter"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import NavHeader from "@app/components/navigation/NavHeader"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + Checkbox, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + IconButton, + Modal, + ModalContent, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + TFoot, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; +import { + useCreateFolder, + useCreateSecretV3, + useDeleteSecretV3, + useGetImportedSecretsAllEnvs, + useGetWsTags, + useUpdateSecretV3 +} from "@app/hooks/api"; +import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries"; +import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; +import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; +import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types"; +import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types"; +import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils"; + +import { FolderForm } from "../secrets.$envSlug/-components/ActionBar/FolderForm"; +import { CreateSecretForm } from "./-components/CreateSecretForm"; +import { FolderBreadCrumbs } from "./-components/FolderBreadCrumbs"; +import { SecretOverviewDynamicSecretRow } from "./-components/SecretOverviewDynamicSecretRow"; +import { SecretOverviewFolderRow } from "./-components/SecretOverviewFolderRow"; +import { + SecretNoAccessOverviewTableRow, + SecretOverviewTableRow +} from "./-components/SecretOverviewTableRow"; +import { SecretSearchInput } from "./-components/SecretSearchInput"; +import { SecretTableResourceCount } from "./-components/SecretTableResourceCount"; +import { SecretV2MigrationSection } from "./-components/SecretV2MigrationSection"; +import { SelectionPanel } from "./-components/SelectionPanel/SelectionPanel"; + +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} + +enum RowType { + Folder = "folder", + DynamicSecret = "dynamic", + Secret = "secret" +} + +type Filter = { + [key in RowType]: boolean; +}; + +const DEFAULT_FILTER_STATE = { + [RowType.Folder]: true, + [RowType.DynamicSecret]: true, + [RowType.Secret]: true +}; + +export const SecretOverviewPage = () => { + const { t } = useTranslation(); + + const router = useRouter(); + const navigate = useNavigate({ + from: "/secret-manager/$projectId/overview" + }); + const routerSearch = useSearch({ + from: "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview/", + select: (el) => ({ + secretPath: el.secretPath, + search: el.search + }) + }); + const [scrollOffset, setScrollOffset] = useState(0); + const [debouncedScrollOffset] = useDebounce(scrollOffset); + const { permission } = useProjectPermission(); + + const { currentWorkspace } = useWorkspace(); + const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3; + const workspaceId = currentWorkspace?.id as string; + const projectSlug = currentWorkspace?.slug as string; + const [searchFilter, setSearchFilter] = useState(""); + const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(searchFilter); + const secretPath = (routerSearch?.secretPath as string) || "/"; + + const [filter, setFilter] = useState(DEFAULT_FILTER_STATE); + const [filterHistory, setFilterHistory] = useState< + Map + >(new Map()); + + const [selectedEntries, setSelectedEntries] = useState<{ + // selectedEntries[name/key][envSlug][resource] + [EntryType.FOLDER]: Record>; + [EntryType.SECRET]: Record>; + }>({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + + const { + offset, + limit, + orderDirection, + setOrderDirection, + setPage, + perPage, + page, + setPerPage, + orderBy + } = usePagination(DashboardSecretsOrderBy.Name); + + const resetSelectedEntries = useCallback(() => { + setSelectedEntries({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + }, []); + + useEffect(() => { + const onRouteChangeStart = () => { + resetSelectedEntries(); + }; + + const unsubscribeRouterEvent = router.subscribe("onLoad", onRouteChangeStart); + + return () => { + unsubscribeRouterEvent(); + }; + }, []); + + const userAvailableEnvs = currentWorkspace?.environments || []; + + const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs); + + useEffect(() => { + setVisibleEnvs(userAvailableEnvs); + }, [userAvailableEnvs]); + + const { isImportedSecretPresentInEnv, getImportedSecretByKey, getEnvImportedSecretKeyCount } = + useGetImportedSecretsAllEnvs({ + projectId: workspaceId, + path: secretPath, + environments: (userAvailableEnvs || []).map(({ slug }) => slug) + }); + + const { isLoading: isOverviewLoading, data: overview } = useGetProjectSecretsOverview( + { + projectId: workspaceId, + environments: visibleEnvs.map((env) => env.slug), + secretPath, + orderDirection, + orderBy, + includeFolders: filter.folder, + includeDynamicSecrets: filter.dynamic, + includeSecrets: filter.secret, + search: debouncedSearchFilter, + limit, + offset + }, + { enabled: isProjectV3 } + ); + + const { + secrets, + folders, + dynamicSecrets, + totalFolderCount, + totalSecretCount, + totalDynamicSecretCount, + totalCount = 0, + totalUniqueFoldersInPage, + totalUniqueSecretsInPage, + totalUniqueDynamicSecretsInPage + } = overview ?? {}; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const { folderNames, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders); + + const { dynamicSecretNames, isDynamicSecretPresentInEnv } = + useDynamicSecretOverview(dynamicSecrets); + + const { secKeys, getSecretByKey, getEnvSecretKeyCount } = useSecretOverview(secrets); + const { data: tags } = useGetWsTags( + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : "" + ); + + const { mutateAsync: createSecretV3 } = useCreateSecretV3(); + const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); + const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3(); + const { mutateAsync: createFolder } = useCreateFolder(); + const { mutateAsync: updateFolderBatch } = useUpdateFolderBatch(); + + const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ + "addSecretsInAllEnvs", + "addFolder", + "misc", + "updateFolder" + ] as const); + + const handleFolderCreate = async (folderName: string) => { + const promises = userAvailableEnvs.map((env) => { + const environment = env.slug; + return createFolder({ + name: folderName, + path: secretPath, + environment, + projectId: workspaceId + }); + }); + + const results = await Promise.allSettled(promises); + const isFoldersAdded = results.some((result) => result.status === "fulfilled"); + + if (isFoldersAdded) { + handlePopUpClose("addFolder"); + createNotification({ + type: "success", + text: "Successfully created folder" + }); + } else { + createNotification({ + type: "error", + text: "Failed to create folder" + }); + } + }; + + const handleFolderUpdate = async (newFolderName: string) => { + const { name: oldFolderName } = popUp.updateFolder.data as TSecretFolder; + + const updatedFolders: TUpdateFolderBatchDTO["folders"] = []; + userAvailableEnvs.forEach((env) => { + if ( + permission.can( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.SecretFolders, { environment: env.slug, secretPath }) + ) + ) { + const folder = getFolderByNameAndEnv(oldFolderName, env.slug); + if (folder) { + updatedFolders.push({ + environment: env.slug, + name: newFolderName, + id: folder.id, + path: secretPath + }); + } + } + }); + + if (updatedFolders.length === 0) { + createNotification({ + type: "info", + text: "You don't have access to rename selected folder" + }); + + handlePopUpClose("updateFolder"); + return; + } + + try { + await updateFolderBatch({ + projectSlug, + folders: updatedFolders, + projectId: workspaceId + }); + createNotification({ + type: "success", + text: "Successfully renamed folder across environments" + }); + } catch { + createNotification({ + type: "error", + text: "Failed to rename folder across environments" + }); + } finally { + handlePopUpClose("updateFolder"); + } + }; + + const handleSecretCreate = async (env: string, key: string, value: string) => { + try { + // 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); + if (folderName && parentPath) { + await createFolder({ + projectId: workspaceId, + path: parentPath, + environment: env, + name: folderName + }); + } + } + const result = await createSecretV3({ + environment: env, + workspaceId, + secretPath, + secretKey: key, + secretValue: value, + secretComment: "", + type: SecretType.Shared + }); + + if ("approval" in result) { + createNotification({ + type: "info", + text: "Requested change has been sent for review" + }); + } else { + createNotification({ + type: "success", + text: "Successfully created secret" + }); + } + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to create secret" + }); + } + }; + + const handleEnvSelect = (envId: string) => { + if (visibleEnvs.map((env) => env.id).includes(envId)) { + setVisibleEnvs(visibleEnvs.filter((env) => env.id !== envId)); + } else { + setVisibleEnvs(visibleEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId))); + } + }; + + const handleSecretUpdate = async ( + env: string, + key: string, + value: string, + type = SecretType.Shared + ) => { + try { + const result = await updateSecretV3({ + environment: env, + workspaceId, + secretPath, + secretKey: key, + secretValue: value, + type + }); + + if ("approval" in result) { + createNotification({ + type: "info", + text: "Requested change has been sent for review" + }); + } else { + createNotification({ + type: "success", + text: "Successfully updated secret" + }); + } + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to update secret" + }); + } + }; + + const handleSecretDelete = async (env: string, key: string, secretId?: string) => { + try { + const result = await deleteSecretV3({ + environment: env, + workspaceId, + secretPath, + secretKey: key, + secretId, + type: SecretType.Shared + }); + + if ("approval" in result) { + createNotification({ + type: "info", + text: "Requested change has been sent for review" + }); + } else { + createNotification({ + type: "success", + text: "Successfully deleted secret" + }); + } + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to delete secret" + }); + } + }; + + const handleResetSearch = (path: string) => { + const restore = filterHistory.get(path); + setFilter(restore?.filter ?? DEFAULT_FILTER_STATE); + const el = restore?.searchFilter ?? ""; + setSearchFilter(el); + setDebouncedSearchFilter(el); + }; + + const handleFolderClick = (path: string) => { + // store for breadcrumb nav to restore previously used filters + setFilterHistory((prev) => { + const curr = new Map(prev); + curr.set(secretPath, { filter, searchFilter }); + return curr; + }); + + navigate({ + search: (prev) => ({ + ...prev, + secretPath: `${routerSearch?.secretPath || ""}/${path}` + }) + }).then(() => { + setFilter(DEFAULT_FILTER_STATE); + setSearchFilter(""); + setDebouncedSearchFilter(""); + }); + }; + + const handleExploreEnvClick = async (slug: string) => { + if (secretPath !== "/") { + 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: slug, + secretPath: parentPath + }) + ); + if (folderName && parentPath && canCreateFolder) { + await createFolder({ + projectId: workspaceId, + environment: slug, + path: parentPath, + name: folderName + }); + } + } + + const query: Record = { ...routerSearch, env: slug, searchFilter }; + const envIndex = visibleEnvs.findIndex((el) => slug === el.slug); + if (envIndex !== -1) { + navigate({ + to: `/${ProjectType.SecretManager}/$projectId/secrets/$envSlug` as const, + params: { + projectId: workspaceId, + envSlug: slug + }, + search: query + }); + } + }; + + const handleToggleRowType = useCallback( + (rowType: RowType) => + setFilter((state) => { + return { + ...state, + [rowType]: !state[rowType] + }; + }), + [] + ); + + const allRowsSelectedOnPage = useMemo(() => { + if (!secrets?.length && !folders?.length) return { isChecked: false, isIndeterminate: false }; + + if ( + (!secrets?.length || + secrets?.every((secret) => selectedEntries[EntryType.SECRET][secret.key])) && + (!folders?.length || + folders?.every((folder) => selectedEntries[EntryType.FOLDER][folder.name])) + ) + return { isChecked: true, isIndeterminate: false }; + + if ( + secrets?.some((secret) => selectedEntries[EntryType.SECRET][secret.key]) || + folders?.some((folder) => selectedEntries[EntryType.FOLDER][folder.name]) + ) + return { isChecked: true, isIndeterminate: true }; + + return { isChecked: false, isIndeterminate: false }; + }, [selectedEntries, secrets, folders]); + + const toggleSelectedEntry = useCallback( + (type: EntryType, key: string) => { + const isChecked = Boolean(selectedEntries[type]?.[key]); + const newChecks = { ...selectedEntries }; + + // remove selection if its present else add it + if (isChecked) { + delete newChecks[type][key]; + } else { + newChecks[type][key] = {}; + userAvailableEnvs.forEach((env) => { + const resource = + type === EntryType.SECRET + ? getSecretByKey(env.slug, key) + : getFolderByNameAndEnv(key, env.slug); + + if (resource) newChecks[type][key][env.slug] = resource; + }); + } + + setSelectedEntries(newChecks); + }, + [selectedEntries, getFolderByNameAndEnv, getSecretByKey] + ); + + const toggleSelectAllRows = () => { + const newChecks = { ...selectedEntries }; + + userAvailableEnvs.forEach((env) => { + secrets?.forEach((secret) => { + if (allRowsSelectedOnPage.isChecked) { + delete newChecks[EntryType.SECRET][secret.key]; + } else { + if (!newChecks[EntryType.SECRET][secret.key]) + newChecks[EntryType.SECRET][secret.key] = {}; + + const resource = getSecretByKey(env.slug, secret.key); + + if (resource) newChecks[EntryType.SECRET][secret.key][env.slug] = resource; + } + }); + + folders?.forEach((folder) => { + if (allRowsSelectedOnPage.isChecked) { + delete newChecks[EntryType.FOLDER][folder.name]; + } else { + if (!newChecks[EntryType.FOLDER][folder.name]) + newChecks[EntryType.FOLDER][folder.name] = {}; + + const resource = getFolderByNameAndEnv(folder.name, env.slug); + + if (resource) newChecks[EntryType.FOLDER][folder.name][env.slug] = resource; + } + }); + }); + + setSelectedEntries(newChecks); + }; + + useEffect(() => { + if (routerSearch.search) { + const { search, ...query } = routerSearch; + // temp workaround until we transition state to query params + navigate({ + search: query + }); + setFilter(DEFAULT_FILTER_STATE); + setSearchFilter(routerSearch.search as string); + setDebouncedSearchFilter(routerSearch.search as string); + } + }, [routerSearch.search]); + + if (isProjectV3 && visibleEnvs.length > 0 && isOverviewLoading) { + return ( +
    + loading animation +
    + ); + } + + const canViewOverviewPage = Boolean(userAvailableEnvs.length); + // This is needed to also show imports from other paths – right now those are missing. + // const combinedKeys = [...secKeys, ...secretImports.map((impSecrets) => impSecrets?.data?.map((impSec) => impSec.secrets?.map((impSecKey) => impSecKey.key))).flat().flat()]; + + const isTableEmpty = totalCount === 0; + + const isTableFiltered = + Boolean(Object.values(filter).filter((enabled) => !enabled).length) || + userAvailableEnvs.length !== visibleEnvs.length; + + if (!isProjectV3) + return ( +
    + +
    + ); + + return ( + <> +
    +
    + +
    +
    +
    +
    +

    Secrets Overview

    +

    + Inject your secrets using + + Infisical CLI + + , + + Infisical API + + , + + Infisical SDKs + + , and + + more + + . +

    +
    +
    +
    + +
    + {userAvailableEnvs.length > 0 && ( + + + + + + + + + + {/* + + */} + Filter project resources + { + e.preventDefault(); + handleToggleRowType(RowType.Folder); + }} + icon={filter[RowType.Folder] && } + iconPos="right" + > +
    + + Folders +
    +
    + { + e.preventDefault(); + handleToggleRowType(RowType.DynamicSecret); + }} + icon={ + filter[RowType.DynamicSecret] && + } + iconPos="right" + > +
    + + Dynamic Secrets +
    +
    + { + e.preventDefault(); + handleToggleRowType(RowType.Secret); + }} + icon={filter[RowType.Secret] && } + iconPos="right" + > +
    + + Secrets +
    +
    + Choose visible environments + {userAvailableEnvs.map((availableEnv) => { + const { id: envId, name } = availableEnv; + + const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); + return ( + { + e.preventDefault(); + handleEnvSelect(envId); + }} + key={envId} + disabled={visibleEnvs?.length === 1} + icon={isEnvSelected && } + iconPos="right" + > +
    {name}
    +
    + ); + })} +
    +
    + )} + + {userAvailableEnvs.length > 0 && ( +
    + + handlePopUpToggle("misc", isOpen)} + > + + + + + + +
    + + {(isAllowed) => ( + + )} + +
    +
    +
    +
    + )} +
    +
    +
    + +
    + setScrollOffset(e.currentTarget.scrollLeft)} + className="thin-scrollbar rounded-b-none" + > + + + + + {visibleEnvs?.map(({ name, slug }, index) => { + const envSecKeyCount = getEnvSecretKeyCount(slug); + const importedSecKeyCount = getEnvImportedSecretKeyCount(slug); + const missingKeyCount = secKeys.length - envSecKeyCount - importedSecKeyCount; + + return ( + + ); + })} + + + + {canViewOverviewPage && isOverviewLoading && ( + + )} + {userAvailableEnvs.length > 0 && visibleEnvs.length === 0 && ( + + + + )} + {userAvailableEnvs.length === 0 && ( + + + + )} + {isTableEmpty && !isOverviewLoading && visibleEnvs.length > 0 && ( + + + + )} + {!isOverviewLoading && visibleEnvs.length > 0 && ( + <> + {folderNames.map((folderName, index) => ( + + toggleSelectedEntry(EntryType.FOLDER, folderName) + } + environments={visibleEnvs} + key={`overview-${folderName}-${index + 1}`} + onClick={handleFolderClick} + onToggleFolderEdit={(name: string) => + handlePopUpOpen("updateFolder", { name }) + } + /> + ))} + {dynamicSecretNames.map((dynamicSecretName, index) => ( + + ))} + {secKeys.map((key, index) => ( + toggleSelectedEntry(EntryType.SECRET, key)} + secretPath={secretPath} + getImportedSecretByKey={getImportedSecretByKey} + isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} + onSecretCreate={handleSecretCreate} + onSecretDelete={handleSecretDelete} + onSecretUpdate={handleSecretUpdate} + key={`overview-${key}-${index + 1}`} + environments={visibleEnvs} + secretKey={key} + getSecretByKey={getSecretByKey} + scrollOffset={debouncedScrollOffset} + /> + ))} + totalCount ? totalCount % perPage : perPage) - + (totalUniqueFoldersInPage || 0) - + (totalUniqueDynamicSecretsInPage || 0) - + (totalUniqueSecretsInPage || 0), + 0 + )} + /> + + )} + + + + + {visibleEnvs?.map(({ name, slug }) => ( + + ))} + + +
    +
    + 0 + ? `${ + !allRowsSelectedOnPage.isChecked ? "Select" : "Unselect" + } all folders and secrets on page` + : "" + } + > +
    + +
    +
    + Name + + setOrderDirection((prev) => + prev === OrderByDirection.ASC + ? OrderByDirection.DESC + : OrderByDirection.ASC + ) + } + > + + +
    +
    +
    + + {missingKeyCount > 0 && ( + +
    + {missingKeyCount} +
    +
    + )} +
    +
    + +
    + + + + + +
    + + + +
    +
    +
    +
    + +
    +
    +
    + {!isOverviewLoading && totalCount > 0 && ( + + } + className="rounded-b-md border-t border-solid border-t-mineshaft-600" + count={totalCount} + page={page} + perPage={perPage} + onChangePage={(newPage) => setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} +
    +
    + handlePopUpToggle("addSecretsInAllEnvs", isOpen)} + > + e.preventDefault()} + > + handlePopUpClose("addSecretsInAllEnvs")} + /> + + + handlePopUpToggle("addFolder", isOpen)} + > + + + + + handlePopUpToggle("updateFolder", isOpen)} + > + + )?.name} + onUpdateFolder={handleFolderUpdate} + /> + + + + ); +}; + +const SecretOverviewRoute = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("dashboard.title") })} + + + + + +
    + +
    + + ); +}; + +const SecretOverviewPageQuerySchema = z.object({ + search: z.string().catch(""), + secretPath: z.string().catch("/") +}); + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview/" +)({ + component: SecretOverviewRoute, + validateSearch: zodValidator(SecretOverviewPageQuerySchema) +}); diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/RoleDetailsSection.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/RoleDetailsSection.tsx new file mode 100644 index 000000000..47a33dd4f --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/RoleDetailsSection.tsx @@ -0,0 +1,95 @@ +import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { useGetProjectRoleBySlug } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + roleSlug: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["role"]>, data?: object) => void; +}; + +export const RoleDetailsSection = ({ roleSlug, handlePopUpOpen }: Props) => { + const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + const { currentWorkspace } = useWorkspace(); + const { data } = useGetProjectRoleBySlug(currentWorkspace?.id ?? "", roleSlug as string); + + const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? ""); + + return data ? ( +
    +
    +

    Project Role Details

    + {isCustomRole && ( + + {(isAllowed) => { + return ( + + { + handlePopUpOpen("role", { + roleSlug + }); + }} + > + + + + ); + }} + + )} +
    +
    +
    +

    Role ID

    +
    +

    {data.id}

    +
    + + { + navigator.clipboard.writeText(data.id); + setCopyTextId("Copied"); + }} + > + + + +
    +
    +
    +
    +

    Name

    +

    {data.name}

    +
    +
    +

    Slug

    +

    {data.slug}

    +
    +
    +

    Description

    +

    + {data.description?.length ? data.description : "-"} +

    +
    +
    +
    + ) : ( +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/RoleModal.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/RoleModal.tsx new file mode 100644 index 000000000..25e33aed4 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/RoleModal.tsx @@ -0,0 +1,204 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate } from "@tanstack/react-router"; +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 { + useCreateProjectRole, + useGetProjectRoleBySlug, + useUpdateProjectRole +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; + +const schema = z + .object({ + name: z.string(), + description: z.string(), + slug: slugSchema({ min: 1 }) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["role"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["role"]>, state?: boolean) => void; +}; + +export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => { + const navigate = useNavigate(); + + const popupData = popUp?.role?.data as { + roleSlug: string; + }; + + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + + const { data: role } = useGetProjectRoleBySlug(projectId, popupData?.roleSlug ?? ""); + + const { mutateAsync: createProjectRole } = useCreateProjectRole(); + const { mutateAsync: updateProjectRole } = useUpdateProjectRole(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + description: "" + } + }); + + useEffect(() => { + if (role) { + reset({ + name: role.name, + description: role.description, + slug: role.slug + }); + } else { + reset({ + name: "", + description: "", + slug: "" + }); + } + }, [role]); + + const onFormSubmit = async ({ name, description, slug }: FormData) => { + try { + if (!projectId) return; + + if (role) { + // update + await updateProjectRole({ + id: role.id, + projectId, + name, + description, + slug + }); + + handlePopUpToggle("role", false); + } else { + // create + const newRole = await createProjectRole({ + projectId, + name, + description, + slug, + permissions: [] + }); + + navigate({ + to: `/${currentWorkspace.type}/$projectId/roles/$roleSlug` as const, + params: { + roleSlug: newRole.slug, + projectId + } + }); + handlePopUpToggle("role", false); + } + + createNotification({ + text: `Successfully ${popUp?.role?.data ? "updated" : "created"} role`, + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + const error = err as any; + const text = + error?.response?.data?.message ?? + `Failed to ${popUp?.role?.data ? "update" : "create"} role`; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("role", isOpen); + reset(); + }} + > + +
    + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
    + + +
    + +
    +
    + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/index.tsx new file mode 100644 index 000000000..2151a1c3e --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/-components/index.tsx @@ -0,0 +1,2 @@ +export { RoleDetailsSection } from "./RoleDetailsSection"; +export { RoleModal } from "./RoleModal"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/index.tsx new file mode 100644 index 000000000..292c31bda --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/roles/$roleSlug/index.tsx @@ -0,0 +1,199 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { createFileRoute, useNavigate, useParams } 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, + Tooltip +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useDeleteProjectRole, useGetProjectRoleBySlug } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { ProjectAccessControlTabs } from "@app/types/project"; + +import { RoleDetailsSection, RoleModal, RolePermissionsSection } from "./-components"; + +const RolePage = withProjectPermission( + () => { + const navigate = useNavigate(); + const roleSlug = useParams({ + from: "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/roles/$roleSlug/", + select: (el) => el.roleSlug + }); + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + + const { data } = useGetProjectRoleBySlug(projectId, roleSlug as string); + + const { mutateAsync: deleteProjectRole } = useDeleteProjectRole(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "role", + "deleteRole" + ] as const); + + const onDeleteRoleSubmit = async () => { + try { + if (!currentWorkspace?.slug || !data?.id) return; + + await deleteProjectRole({ + projectId, + id: data.id + }); + + createNotification({ + text: "Successfully deleted project role", + type: "success" + }); + handlePopUpClose("deleteRole"); + navigate({ + to: `/${currentWorkspace?.type}/$projectId/access` as const, + params: { + projectId + }, + search: { + selectedTab: ProjectAccessControlTabs.Roles + } + }); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to delete project role"; + + createNotification({ + text, + type: "error" + }); + } + }; + + const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? ""); + + return ( +
    + {data && ( +
    + +
    +

    {data.name}

    + {isCustomRole && ( + + +
    + + + +
    +
    + + + {(isAllowed) => ( + + handlePopUpOpen("role", { + roleSlug + }) + } + disabled={!isAllowed} + > + Edit Role + + )} + + + {(isAllowed) => ( + handlePopUpOpen("deleteRole")} + disabled={!isAllowed} + > + Delete Role + + )} + + +
    + )} +
    +
    +
    + +
    + +
    +
    + )} + + handlePopUpToggle("deleteRole", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => onDeleteRoleSubmit()} + /> +
    + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role } +); + +const RolePageRoute = () => { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: "Project Settings" })} + + + + + ); +}; + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/roles/$roleSlug/" +)({ + component: RolePageRoute +}); diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/CreateRotationForm.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/CreateRotationForm.tsx new file mode 100644 index 000000000..952c869db --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/CreateRotationForm.tsx @@ -0,0 +1,144 @@ +import { useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; + +import { createNotification } from "@app/components/notifications"; +import { Modal, ModalContent, Step, Stepper } from "@app/components/v2"; +import { useCreateSecretRotation } from "@app/hooks/api"; +import { TSecretRotationProviderTemplate } from "@app/hooks/api/types"; + +import { RotationInputForm } from "./steps/RotationInputForm"; +import { + RotationOutputForm, + TFormSchema as TRotationOutputSchema +} from "./steps/RotationOutputForm"; + +const WIZARD_STEPS = [ + { + title: "Inputs", + description: "Provider secrets" + }, + { + title: "Outputs", + description: "Map rotated secrets to keys" + } +]; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; + customProvider?: string; + workspaceId: string; + provider: TSecretRotationProviderTemplate; +}; + +export const CreateRotationForm = ({ + isOpen, + onToggle, + provider, + workspaceId, + customProvider +}: Props) => { + const [wizardStep, setWizardStep] = useState(0); + const wizardData = useRef<{ + input?: Record; + output?: TRotationOutputSchema; + }>({}); + + const { mutateAsync: createSecretRotation } = useCreateSecretRotation(); + + const handleFormCancel = () => { + onToggle(false); + setWizardStep(0); + wizardData.current = {}; + }; + + const handleFormSubmit = async () => { + if (!wizardData.current.input || !wizardData.current.output) return; + try { + await createSecretRotation({ + workspaceId, + provider: provider.name, + customProvider, + secretPath: wizardData.current.output.secretPath, + environment: wizardData.current.output.environment, + interval: wizardData.current.output.interval, + inputs: wizardData.current.input, + outputs: wizardData.current.output.secrets + }); + setWizardStep(0); + onToggle(false); + wizardData.current = {}; + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to create secret rotation" + }); + } + }; + + return ( + { + onToggle(state); + setWizardStep(0); + wizardData.current = {}; + }} + > + + + {WIZARD_STEPS.map(({ title, description }, index) => ( + + ))} + + + {wizardStep === 0 && ( + + { + wizardData.current.input = data; + setWizardStep((state) => state + 1); + }} + inputSchema={provider.template?.inputs || {}} + /> + + )} + {wizardStep === 1 && ( + + { + wizardData.current.output = data; + await handleFormSubmit(); + }} + /> + + )} + + + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/index.tsx new file mode 100644 index 000000000..8392c4cdb --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/index.tsx @@ -0,0 +1 @@ +export { CreateRotationForm } from "./CreateRotationForm"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/steps/RotationInputForm.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/steps/RotationInputForm.tsx new file mode 100644 index 000000000..3b336a750 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/steps/RotationInputForm.tsx @@ -0,0 +1,78 @@ +import { Controller, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, FormLabel, SecretInput, Tooltip } from "@app/components/v2"; + +type Props = { + onSubmit: (data: Record) => void; + onCancel: () => void; + inputSchema: { + properties: Record; + required: string[]; + }; +}; + +const formSchema = z.record(z.string().trim().optional()); + +export const RotationInputForm = ({ onSubmit, onCancel, inputSchema }: Props) => { + const { + control, + handleSubmit, + formState: { isSubmitting } + } = useForm>({ + resolver: zodResolver(formSchema) + }); + + return ( +
    + {Object.keys(inputSchema.properties || {}).map((inputName) => ( + ( + + + {Boolean(inputSchema.properties[inputName]?.desc) && ( + + + + )} +
    + } + > + + + )} + /> + ))} +
    + + +
    + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/steps/RotationOutputForm.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/steps/RotationOutputForm.tsx new file mode 100644 index 000000000..fc7d653b4 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/-components/CreateRotationForm/steps/RotationOutputForm.tsx @@ -0,0 +1,151 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, Input, Select, SelectItem, Spinner } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; +import { useWorkspace } from "@app/context"; +import { useGetProjectSecrets } from "@app/hooks/api"; + +const formSchema = z.object({ + environment: z.string().trim(), + secretPath: z.string().trim().default("/"), + interval: z.number().min(1), + secrets: z.record(z.string()) +}); + +export type TFormSchema = z.infer; +type Props = { + outputSchema: Record; + onSubmit: (data: TFormSchema) => void; + onCancel: () => void; +}; + +export const RotationOutputForm = ({ onSubmit, onCancel, outputSchema = {} }: Props) => { + const { currentWorkspace } = useWorkspace(); + const environments = currentWorkspace?.environments || []; + const workspaceId = currentWorkspace?.id || ""; + const { + control, + handleSubmit, + watch, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const environment = watch("environment", environments?.[0]?.slug); + const secretPath = watch("secretPath"); + const selectedSecrets = watch("secrets"); + + const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ + workspaceId, + environment, + secretPath + }); + + return ( +
    + ( + + + + )} + /> + ( + + + + )} + /> + ( + + field.onChange(parseInt(evt.target.value, 10))} + /> + + )} + /> +
    +
    Mapping
    +
    Select keys for rotated value to get saved
    +
    + {Object.keys(outputSchema).map((outputName) => ( + ( + + + + )} + /> + ))} +
    + + +
    + + ); +}; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/index.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/index.tsx new file mode 100644 index 000000000..f25ed42f7 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secret-rotation/index.tsx @@ -0,0 +1,398 @@ +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { + faArrowsSpin, + faArrowUpRightFromSquare, + faExclamationTriangle, + faFolder, + faInfoCircle, + faPlus, + faRotate, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { createFileRoute } from "@tanstack/react-router"; +import { formatDistance } from "date-fns"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DeleteActionModal, + EmptyState, + IconButton, + Skeleton, + Spinner, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useSubscription, + useWorkspace +} from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { usePopUp } from "@app/hooks"; +import { + useDeleteSecretRotation, + useGetSecretRotationProviders, + useGetSecretRotations, + useRestartSecretRotation +} from "@app/hooks/api"; +import { TSecretRotationProviderTemplate } from "@app/hooks/api/types"; + +import { CreateRotationForm } from "./-components/CreateRotationForm"; + +export const SecretRotationPage = withProjectPermission( + () => { + const { currentWorkspace } = useWorkspace(); + const { permission } = useProjectPermission(); + + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "createRotation", + "activeBot", + "deleteRotation", + "upgradePlan" + ] as const); + const workspaceId = currentWorkspace?.id || ""; + const canCreateRotation = permission.can( + ProjectPermissionActions.Create, + ProjectPermissionSub.SecretRotation + ); + const { subscription } = useSubscription(); + + const { data: secretRotationProviders, isPending: isRotationProviderLoading } = + useGetSecretRotationProviders({ workspaceId }); + const { data: secretRotations, isPending: isRotationLoading } = useGetSecretRotations({ + workspaceId + }); + + const { + mutateAsync: deleteSecretRotation, + variables: deleteSecretRotationVars, + isPending: isDeletingRotation + } = useDeleteSecretRotation(); + const { + mutateAsync: restartSecretRotation, + variables: restartSecretRotationVar, + isPending: isRestartingRotation + } = useRestartSecretRotation(); + + const handleDeleteRotation = async () => { + const { id } = popUp.deleteRotation.data as { id: string }; + try { + await deleteSecretRotation({ + id, + workspaceId + }); + handlePopUpClose("deleteRotation"); + createNotification({ + type: "success", + text: "Successfully removed rotation" + }); + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to remove rotation" + }); + } + }; + + const handleRestartRotation = async (id: string) => { + try { + await restartSecretRotation({ + id, + workspaceId + }); + createNotification({ + type: "success", + text: "Secret rotation initiated" + }); + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to restart rotation" + }); + } + }; + + const handleCreateRotation = async (provider: TSecretRotationProviderTemplate) => { + if (subscription && !subscription?.secretRotation) { + handlePopUpOpen("upgradePlan"); + return; + } + if (!canCreateRotation) { + createNotification({ type: "error", text: "Access permission denied!!" }); + return; + } + handlePopUpOpen("createRotation", provider); + }; + + return ( +
    +
    +
    +

    Secret Rotation

    +

    + Stop manually rotating secrets and automate credential rotation. +

    +
    + +
    +
    +
    Rotated Secrets
    +
    + + + + + + + + + + + + + + {isRotationLoading && ( + + )} + {!isRotationLoading && secretRotations?.length === 0 && ( + + + + )} + {secretRotations?.map( + ({ + environment, + secretPath, + outputs, + provider, + id, + lastRotatedAt, + status, + statusMessage + }) => { + const isDeleting = deleteSecretRotationVars?.id === id && isDeletingRotation; + const isRestarting = + restartSecretRotationVar?.id === id && isRestartingRotation; + return ( + + + + + + + + + ); + } + )} + +
    Secret NameEnvironmentProviderStatusLast RotationAction
    + +
    + {outputs + .map(({ key }) => key) + .join(",") + .toUpperCase()} + +
    +
    {environment.slug}
    +
    + + {secretPath} +
    +
    +
    {provider} +
    + {status} + {status === "failed" && ( + + + + )} +
    +
    + {lastRotatedAt + ? formatDistance(new Date(lastRotatedAt), new Date()) + : "-"} + +
    + + {(isAllowed) => ( + handleRestartRotation(id)} + > + {isRestarting ? ( + + ) : ( + + )} + + )} + + + {(isAllowed) => ( + handlePopUpOpen("deleteRotation", { id })} + > + {isDeleting ? ( + + ) : ( + + )} + + )} + +
    +
    +
    +
    +
    +
    + Infisical Rotation Providers +
    +
    + {isRotationProviderLoading && + Array.from({ length: 12 }).map((_, index) => ( + + ))} + {!isRotationProviderLoading && + secretRotationProviders?.providers.map((provider) => ( +
    { + if (evt.key === "Enter") handlePopUpOpen("createRotation", provider); + }} + onClick={() => handleCreateRotation(provider)} + > + rotation provider logo +
    + {provider.title} +
    +
    + + + +
    +
    + ))} + +
    + +
    + Request or create your own template +
    +
    +
    +
    + handlePopUpToggle("createRotation", isOpen)} + provider={(popUp.createRotation.data as TSecretRotationProviderTemplate) || {}} + /> + handlePopUpToggle("deleteRotation", isOpen)} + deleteKey="delete" + onDeleteApproved={handleDeleteRotation} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add secret rotation if you switch to Infisical's Team plan." + /> +
    + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SecretRotation } +); + +const SecretRotationRoute = () => { + const { t } = useTranslation(); + + return ( +
    + + {t("common.head-title", { title: t("settings.project.title") })} + + + + +
    + ); +}; + +export const Route = createFileRoute( + "/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secret-rotation/" +)({ + component: SecretRotationRoute +}); diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secrets.$envSlug/-components/ActionBar/ActionBar.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secrets.$envSlug/-components/ActionBar/ActionBar.tsx new file mode 100644 index 000000000..960eea6a4 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secrets.$envSlug/-components/ActionBar/ActionBar.tsx @@ -0,0 +1,662 @@ +import { TypeOptions } from "react-toastify"; +import { subject } from "@casl/ability"; +import { + faAngleDown, + faAnglesRight, + faCheckCircle, + faChevronRight, + faCodeCommit, + faDownload, + faEye, + faEyeSlash, + faFileImport, + faFilter, + faFingerprint, + faFolder, + faFolderPlus, + faKey, + faMinusSquare, + faPlus, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import FileSaver from "file-saver"; +import { twMerge } from "tailwind-merge"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger, + IconButton, + Modal, + ModalContent, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionDynamicSecretActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api"; +import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries"; +import { SecretType, WsTag } from "@app/hooks/api/types"; + +import { SecretSearchInput } from "../../../overview/-components/SecretSearchInput"; +import { + PopUpNames, + usePopUpAction, + useSelectedSecretActions, + useSelectedSecrets +} from "../SecretMainPage.store"; +import { Filter, RowType } from "../SecretMainPage.types"; +import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm"; +import { CreateSecretImportForm } from "./CreateSecretImportForm"; +import { FolderForm } from "./FolderForm"; +import { MoveSecretsModal } from "./MoveSecretsModal"; + +type Props = { + // switch the secrets type as it gets decrypted after api call + environment: string; + // @depreciated will be moving all these details to zustand + workspaceId: string; + projectSlug: string; + secretPath?: string; + filter: Filter; + tags?: WsTag[]; + isVisible?: boolean; + snapshotCount: number; + isSnapshotCountLoading?: boolean; + onSearchChange: (term: string) => void; + onToggleTagFilter: (tagId: string) => void; + onVisibilityToggle: () => void; + onToggleRowType: (rowType: RowType) => void; + onClickRollbackMode: () => void; +}; + +export const ActionBar = ({ + environment, + workspaceId, + projectSlug, + secretPath = "/", + filter, + tags = [], + isVisible, + snapshotCount, + isSnapshotCountLoading, + onSearchChange, + onToggleTagFilter, + onVisibilityToggle, + onClickRollbackMode, + onToggleRowType +}: Props) => { + const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ + "addFolder", + "addDynamicSecret", + "addSecretImport", + "bulkDeleteSecrets", + "moveSecrets", + "misc", + "upgradePlan" + ] as const); + const { subscription } = useSubscription(); + const { openPopUp } = usePopUpAction(); + + const { mutateAsync: createFolder } = useCreateFolder(); + const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { mutateAsync: moveSecrets } = useMoveSecrets(); + + const selectedSecrets = useSelectedSecrets(); + const { reset: resetSelectedSecret } = useSelectedSecretActions(); + const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length); + + const { currentWorkspace } = useWorkspace(); + + const handleFolderCreate = async (folderName: string) => { + try { + await createFolder({ + name: folderName, + path: secretPath, + environment, + projectId: workspaceId + }); + handlePopUpClose("addFolder"); + createNotification({ + type: "success", + text: "Successfully created folder" + }); + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to create folder" + }); + } + }; + + const handleSecretDownload = async () => { + const { secrets: localSecrets, imports: localImportedSecrets } = await fetchProjectSecrets({ + workspaceId, + expandSecretReferences: true, + includeImports: true, + environment, + secretPath + }); + const secretsPicked = new Set(); + const secretsToDownload: { key: string; value?: string; comment?: string }[] = []; + localSecrets.forEach((el) => { + secretsPicked.add(el.secretKey); + secretsToDownload.push({ + key: el.secretKey, + value: el.secretValue, + comment: el.secretComment + }); + }); + + for (let i = localImportedSecrets.length - 1; i >= 0; i -= 1) { + for (let j = localImportedSecrets[i].secrets.length - 1; j >= 0; j -= 1) { + const secret = localImportedSecrets[i].secrets[j]; + if (!secretsPicked.has(secret.secretKey)) { + secretsToDownload.push({ + key: secret.secretKey, + value: secret.secretValue, + comment: secret.secretComment + }); + } + secretsPicked.add(secret.secretKey); + } + } + + const file = secretsToDownload + .sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase())) + .reduce( + (prev, { key, comment, value }, index) => + prev + + (comment + ? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n` + : `${key}=${value}\n`), + "" + ); + + const blob = new Blob([file], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, `${environment}.env`); + }; + + const handleSecretBulkDelete = async () => { + const bulkDeletedSecrets = Object.values(selectedSecrets); + try { + await deleteBatchSecretV3({ + secretPath, + workspaceId, + environment, + secrets: bulkDeletedSecrets.map(({ key }) => ({ secretKey: key, type: SecretType.Shared })) + }); + resetSelectedSecret(); + handlePopUpClose("bulkDeleteSecrets"); + createNotification({ + type: "success", + text: "Successfully deleted secrets" + }); + } catch (error) { + console.log(error); + createNotification({ + type: "error", + text: "Failed to delete secrets" + }); + } + }; + + const handleSecretsMove = async ({ + destinationEnvironment, + destinationSecretPath, + shouldOverwrite + }: { + destinationEnvironment: string; + destinationSecretPath: string; + shouldOverwrite: boolean; + }) => { + try { + const secretsToMove = Object.values(selectedSecrets); + const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({ + projectSlug, + shouldOverwrite, + sourceEnvironment: environment, + sourceSecretPath: secretPath, + destinationEnvironment, + destinationSecretPath, + projectId: workspaceId, + secretIds: secretsToMove.map((sec) => sec.id) + }); + + let notificationMessage = ""; + let notificationType: TypeOptions = "info"; + + if (isDestinationUpdated && isSourceUpdated) { + notificationMessage = "Successfully moved selected secrets"; + notificationType = "success"; + } else if (isDestinationUpdated) { + notificationMessage = + "Successfully created secrets in destination. A secret approval request has been generated for the source."; + } else if (isSourceUpdated) { + notificationMessage = "A secret approval request has been generated in the destination"; + } else { + notificationMessage = + "A secret approval request has been generated in both the source and the destination."; + } + + createNotification({ + type: notificationType, + text: notificationMessage + }); + + resetSelectedSecret(); + } catch (error) { + console.error(error); + } + }; + + return ( + <> +
    + env.slug === environment)!]} + projectId={workspaceId} + tags={tags} + /> +
    + + + !include).length) && + "border-primary/50 text-primary" + )} + > + + + + + Filter By + { + e.preventDefault(); + onToggleRowType(RowType.Import); + }} + icon={filter?.include[RowType.Import] && } + iconPos="right" + > +
    + + Imports +
    +
    + { + e.preventDefault(); + onToggleRowType(RowType.Folder); + }} + icon={filter?.include[RowType.Folder] && } + iconPos="right" + > +
    + + Folders +
    +
    + { + e.preventDefault(); + onToggleRowType(RowType.DynamicSecret); + }} + icon={ + filter?.include[RowType.DynamicSecret] && + } + iconPos="right" + > +
    + + Dynamic Secrets +
    +
    + { + e.preventDefault(); + onToggleRowType(RowType.Secret); + }} + icon={filter?.include[RowType.Secret] && } + iconPos="right" + > +
    + + Secrets +
    +
    + + } + > + Tags + + + + Apply Tags to Filter Secrets + + {tags.map(({ id, slug, color }) => ( + { + evt.preventDefault(); + onToggleTagFilter(slug); + }} + key={id} + icon={filter?.tags[slug] && } + iconPos="right" + > +
    +
    + {slug} +
    + + ))} + + + + +
    +
    +
    + + + +
    +
    + + + +
    +
    + + {(isAllowed) => ( + + )} + +
    +
    + + {(isAllowed) => ( + + )} + + handlePopUpToggle("misc", isOpen)} + > + + + + + + +
    + + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} + +
    +
    +
    +
    +
    +
    +
    + + + + + +
    + {Object.keys(selectedSecrets).length} Selected +
    + + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} + +
    +
    + {/* all the side triggers from actions like modals etc */} + handlePopUpOpen("upgradePlan")} + isOpen={popUp.addSecretImport.isOpen} + onClose={() => handlePopUpClose("addSecretImport")} + onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)} + /> + handlePopUpToggle("addDynamicSecret", isOpen)} + projectSlug={projectSlug} + environment={environment} + secretPath={secretPath} + /> + handlePopUpToggle("addFolder", isOpen)} + > + + + + + handlePopUpToggle("bulkDeleteSecrets", isOpen)} + onDeleteApproved={handleSecretBulkDelete} + /> + + {subscription && ( + handlePopUpToggle("upgradePlan", isOpen)} + text={ + subscription.slug === null + ? "You can perform this action under an Enterprise license" + : "You can perform this action if you switch to Infisical's Team plan" + } + /> + )} + + ); +}; + +ActionBar.displayName = "ActionBar"; diff --git a/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secrets.$envSlug/-components/ActionBar/CreateDynamicSecretForm/AwsElastiCacheInputForm.tsx b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secrets.$envSlug/-components/ActionBar/CreateDynamicSecretForm/AwsElastiCacheInputForm.tsx new file mode 100644 index 000000000..2716910b1 --- /dev/null +++ b/frontend-v2/src/routes/_authenticate/_ctx-org-details/secret-manager.$projectId/_layout-secret-manager/secrets.$envSlug/-components/ActionBar/CreateDynamicSecretForm/AwsElastiCacheInputForm.tsx @@ -0,0 +1,318 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input, + SecretInput, + TextArea +} from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + provider: z.object({ + clusterName: z.string().trim().min(1), + accessKeyId: z.string().trim().min(1), + secretAccessKey: z.string().trim().min(1), + + region: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + ca: z.string().optional() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environment: string; +}; + +export const AwsElastiCacheInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting, errors }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + creationStatement: `{ + "UserId": "{{username}}", + "UserName": "{{username}}", + "Engine": "redis", + "Passwords": ["{{password}}"], + "AccessString": "on ~* +@all" +}`, + revocationStatement: `{ + "UserId": "{{username}}" +}` + } + } + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + console.log("formState", errors); + const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => { + // wait till previous request is finished + if (createDynamicSecret.isPending) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.AwsElastiCache, inputs: provider }, + maxTTL, + name, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment + }); + onCompleted(); + } catch { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
    +
    +
    +
    +
    + ( + + + + )} + /> +
    +
    + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
    +
    + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
    +
    +
    +
    + Configuration +
    +
    +
    + ( + + + + )} + /> + ( + + + + )} + /> +
    +
    + ( + + + + )} + /> + ( + + + + )} + /> +
    +
    + ( + + + + )} + /> + + + Modify ElastiCache Statements + + ( + +