diff --git a/backend/src/ee/services/permission/permission-types.ts b/backend/src/ee/services/permission/permission-types.ts index 7b1f1a5df..60fcbec85 100644 --- a/backend/src/ee/services/permission/permission-types.ts +++ b/backend/src/ee/services/permission/permission-types.ts @@ -11,31 +11,37 @@ export enum PermissionConditionOperators { } export const PermissionConditionSchema = { - [PermissionConditionOperators.$IN]: z.string().array(), - [PermissionConditionOperators.$ALL]: z.string().array(), - [PermissionConditionOperators.$REGEX]: z.string().refine( - (el) => { - try { - // eslint-disable-next-line no-new - new RegExp(el); - return true; - } catch { - return false; - } - }, - { message: "Invalid regex pattern" } - ), - [PermissionConditionOperators.$EQ]: z.string(), - [PermissionConditionOperators.$NEQ]: z.string(), - [PermissionConditionOperators.$GLOB]: z.string().refine( - (el) => { - try { - picomatch.parse([el]); - return true; - } catch { - return false; - } - }, - { message: "Invalid glob pattern" } - ) + [PermissionConditionOperators.$IN]: z.string().min(1).array(), + [PermissionConditionOperators.$ALL]: z.string().min(1).array(), + [PermissionConditionOperators.$REGEX]: z + .string() + .min(1) + .refine( + (el) => { + try { + // eslint-disable-next-line no-new + new RegExp(el); + return true; + } catch { + return false; + } + }, + { message: "Invalid regex pattern" } + ), + [PermissionConditionOperators.$EQ]: z.string().min(1), + [PermissionConditionOperators.$NEQ]: z.string().min(1), + [PermissionConditionOperators.$GLOB]: z + .string() + .min(1) + .refine( + (el) => { + try { + picomatch.parse([el]); + return true; + } catch { + return false; + } + }, + { message: "Invalid glob pattern" } + ) }; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState.tsx new file mode 100644 index 000000000..9a1bdab49 --- /dev/null +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { EmptyState } from "@app/components/v2"; + +import { TFormSchema } from "./ProjectRoleModifySection.utils"; + +// This is made into seperate component because watch subscribes to all permissions +// thus keeping in top level casues render on all ones +export const PermissionEmptyState = () => { + const { watch } = useFormContext(); + const isNotEmptyPermissions = Object.entries(watch("permissions") || {}).some( + ([key, value]) => key && value?.length > 0 + ); + + if (isNotEmptyPermissions) return
; + + return ; +}; diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts index 94a118058..9a4a6cff8 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.ts @@ -32,7 +32,7 @@ const WorkspacePolicyActionSchema = z.object({ const ConditionSchema = z.object({ operator: z.string(), lhs: z.string(), - rhs: z.string() + rhs: z.string().min(1) }); export const formSchema = z.object({ @@ -46,7 +46,23 @@ export const formSchema = z.object({ permissions: z .object({ [ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({ - conditions: ConditionSchema.array().optional().default([]) + conditions: ConditionSchema.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" } + ) }) .array() .default([]), diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index a9d843530..d906a19fa 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -1,10 +1,10 @@ import { FormProvider, useForm } from "react-hook-form"; -import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faCancel, faPlus, faSave } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { createNotification } from "@app/components/notifications"; -import { Button, EmptyState, Modal, ModalContent, ModalTrigger } from "@app/components/v2"; +import { Button, Modal, ModalContent, ModalTrigger, Spinner } from "@app/components/v2"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; @@ -12,6 +12,7 @@ import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; import { GeneralPermissionOptions } from "./components/GeneralPermissionOptions"; import { NewPermissionRule } from "./components/NewPermissionRule"; import { SecretPermissionConditions } from "./components/SecretPermissionConditions"; +import { PermissionEmptyState } from "./PermissionEmptyState"; import { formRolePermission2API, formSchema, @@ -38,6 +39,7 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined, resolver: zodResolver(formSchema) }); + const { handleSubmit, formState: { isDirty, isSubmitting }, @@ -81,6 +83,7 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { type="submit" isDisabled={isSubmitting || !isDirty} isLoading={isSubmitting} + leftIcon={} > Save @@ -90,6 +93,7 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { isDisabled={isSubmitting || !isDirty} isLoading={isSubmitting} onClick={() => reset()} + leftIcon={} > Cancel @@ -97,35 +101,44 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { )}
-
- {!isLoading && !role?.permissions?.length && } - {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( - + +

Saving your policy.

+
+ ) : ( + <> +
+ {!isLoading && } + {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( + + {subject === ProjectPermissionSub.Secrets ? ( + + ) : undefined} + + ))} +
+ handlePopUpToggle("createPolicy", isOpen)} > - {subject === ProjectPermissionSub.Secrets ? ( - - ) : undefined} - - ))} - - handlePopUpToggle("createPolicy", isOpen)} - > - - - - - handlePopUpToggle("createPolicy")} /> - - + + + + + handlePopUpToggle("createPolicy")} /> + + + + )} ); diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionOptions.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionOptions.tsx index 5e9220458..3dc4dcb86 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionOptions.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionOptions.tsx @@ -125,7 +125,7 @@ export const GeneralPermissionOptions = - Add rule + Add policy )} {!isDisabled && ( @@ -137,7 +137,7 @@ export const GeneralPermissionOptions = items.remove(rootIndex)} isDisabled={isDisabled} > - Remove Rule + Remove policy )}{" "} diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/SecretPermissionConditions.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/SecretPermissionConditions.tsx index c58e96331..54b1c49af 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/SecretPermissionConditions.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/components/SecretPermissionConditions.tsx @@ -1,6 +1,7 @@ import { Controller, useFieldArray, useFormContext } from "react-hook-form"; -import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types"; @@ -19,14 +20,23 @@ const getValueLabel = (type: string) => { }; export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) => { - const { control, watch } = useFormContext(); + const { + control, + watch, + formState: { errors } + } = useFormContext(); const items = useFieldArray({ control, name: `permissions.secrets.${position}.conditions` }); return ( -
+
{items.fields.map((el, index) => { const lhs = watch(`permissions.secrets.${position}.conditions.${index}.lhs`); @@ -51,7 +61,7 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) onValueChange={(e) => field.onChange(e)} className="w-full" > - Environment + Environment Slug Secret Path @@ -114,6 +124,13 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) ); })}
+ {errors?.permissions?.secrets?.[position]?.conditions?.message && ( +
+ + {errors?.permissions?.secrets?.[position]?.conditions?.message} +
+ )} +
{}