feat: added validation to check dedupe operators, loading indicator, string required rhs

This commit is contained in:
=
2024-10-02 00:39:44 +05:30
parent 39ea7a032f
commit 62d6e3763b
6 changed files with 135 additions and 65 deletions
@@ -11,31 +11,37 @@ export enum PermissionConditionOperators {
} }
export const PermissionConditionSchema = { export const PermissionConditionSchema = {
[PermissionConditionOperators.$IN]: z.string().array(), [PermissionConditionOperators.$IN]: z.string().min(1).array(),
[PermissionConditionOperators.$ALL]: z.string().array(), [PermissionConditionOperators.$ALL]: z.string().min(1).array(),
[PermissionConditionOperators.$REGEX]: z.string().refine( [PermissionConditionOperators.$REGEX]: z
(el) => { .string()
try { .min(1)
// eslint-disable-next-line no-new .refine(
new RegExp(el); (el) => {
return true; try {
} catch { // eslint-disable-next-line no-new
return false; new RegExp(el);
} return true;
}, } catch {
{ message: "Invalid regex pattern" } return false;
), }
[PermissionConditionOperators.$EQ]: z.string(), },
[PermissionConditionOperators.$NEQ]: z.string(), { message: "Invalid regex pattern" }
[PermissionConditionOperators.$GLOB]: z.string().refine( ),
(el) => { [PermissionConditionOperators.$EQ]: z.string().min(1),
try { [PermissionConditionOperators.$NEQ]: z.string().min(1),
picomatch.parse([el]); [PermissionConditionOperators.$GLOB]: z
return true; .string()
} catch { .min(1)
return false; .refine(
} (el) => {
}, try {
{ message: "Invalid glob pattern" } picomatch.parse([el]);
) return true;
} catch {
return false;
}
},
{ message: "Invalid glob pattern" }
)
}; };
@@ -0,0 +1,18 @@
import { useFormContext } from "react-hook-form";
import { EmptyState } from "@app/components/v2";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
// This is made into seperate component because watch subscribes to all permissions
// thus keeping in top level casues render on all ones
export const PermissionEmptyState = () => {
const { watch } = useFormContext<TFormSchema>();
const isNotEmptyPermissions = Object.entries(watch("permissions") || {}).some(
([key, value]) => key && value?.length > 0
);
if (isNotEmptyPermissions) return <div />;
return <EmptyState title="No policies applied" className="py-8" />;
};
@@ -32,7 +32,7 @@ const WorkspacePolicyActionSchema = z.object({
const ConditionSchema = z.object({ const ConditionSchema = z.object({
operator: z.string(), operator: z.string(),
lhs: z.string(), lhs: z.string(),
rhs: z.string() rhs: z.string().min(1)
}); });
export const formSchema = z.object({ export const formSchema = z.object({
@@ -46,7 +46,23 @@ export const formSchema = z.object({
permissions: z permissions: z
.object({ .object({
[ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({ [ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({
conditions: ConditionSchema.array().optional().default([]) conditions: ConditionSchema.array()
.optional()
.default([])
.refine(
(el) => {
const lhsOperatorSet = new Set<string>();
for (let i = 0; i < el.length; i += 1) {
const { lhs, operator } = el[i];
if (lhsOperatorSet.has(`${lhs}-${operator}`)) {
return false;
}
lhsOperatorSet.add(`${lhs}-${operator}`);
}
return true;
},
{ message: "Duplicate operator found for a condition" }
)
}) })
.array() .array()
.default([]), .default([]),
@@ -1,10 +1,10 @@
import { FormProvider, useForm } from "react-hook-form"; 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications"; 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 { ProjectPermissionSub, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
@@ -12,6 +12,7 @@ import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import { GeneralPermissionOptions } from "./components/GeneralPermissionOptions"; import { GeneralPermissionOptions } from "./components/GeneralPermissionOptions";
import { NewPermissionRule } from "./components/NewPermissionRule"; import { NewPermissionRule } from "./components/NewPermissionRule";
import { SecretPermissionConditions } from "./components/SecretPermissionConditions"; import { SecretPermissionConditions } from "./components/SecretPermissionConditions";
import { PermissionEmptyState } from "./PermissionEmptyState";
import { import {
formRolePermission2API, formRolePermission2API,
formSchema, formSchema,
@@ -38,6 +39,7 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined, values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined,
resolver: zodResolver(formSchema) resolver: zodResolver(formSchema)
}); });
const { const {
handleSubmit, handleSubmit,
formState: { isDirty, isSubmitting }, formState: { isDirty, isSubmitting },
@@ -81,6 +83,7 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
type="submit" type="submit"
isDisabled={isSubmitting || !isDirty} isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting} isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
> >
Save Save
</Button> </Button>
@@ -90,6 +93,7 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
isDisabled={isSubmitting || !isDirty} isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting} isLoading={isSubmitting}
onClick={() => reset()} onClick={() => reset()}
leftIcon={<FontAwesomeIcon icon={faCancel} />}
> >
Cancel Cancel
</Button> </Button>
@@ -97,35 +101,44 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
)} )}
</div> </div>
</div> </div>
<div className="py-4"> {isSubmitting ? (
{!isLoading && !role?.permissions?.length && <EmptyState title="No policies applied" />} <div className="flex items-center justify-center space-x-4 pt-16">
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( <Spinner size="lg" />
<GeneralPermissionOptions <p className="text-sm text-gray-400">Saving your policy.</p>
subject={subject} </div>
actions={PROJECT_PERMISSION_OBJECT[subject].actions} ) : (
title={PROJECT_PERMISSION_OBJECT[subject].title} <>
key={`project-permission-${subject}`} <div className="py-4">
isDisabled={isDisabled} {!isLoading && <PermissionEmptyState />}
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionOptions
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{subject === ProjectPermissionSub.Secrets ? (
<SecretPermissionConditions isDisabled={isDisabled} />
) : undefined}
</GeneralPermissionOptions>
))}
</div>
<Modal
isOpen={popUp.createPolicy.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("createPolicy", isOpen)}
> >
{subject === ProjectPermissionSub.Secrets ? ( <ModalTrigger asChild disabled={isDisabled}>
<SecretPermissionConditions isDisabled={isDisabled} /> <Button isDisabled={isDisabled} leftIcon={<FontAwesomeIcon icon={faPlus} />}>
) : undefined} Add Policy
</GeneralPermissionOptions> </Button>
))} </ModalTrigger>
</div> <ModalContent title="New Policy" subTitle="Policies grant additional permissions.">
<Modal <NewPermissionRule onClose={() => handlePopUpToggle("createPolicy")} />
isOpen={popUp.createPolicy.isOpen} </ModalContent>
onOpenChange={(isOpen) => handlePopUpToggle("createPolicy", isOpen)} </Modal>
> </>
<ModalTrigger asChild disabled={isDisabled}> )}
<Button isDisabled={isDisabled} leftIcon={<FontAwesomeIcon icon={faPlus} />}>
Add Policy
</Button>
</ModalTrigger>
<ModalContent title="New Policy" subTitle="Policies grant additional permissions.">
<NewPermissionRule onClose={() => handlePopUpToggle("createPolicy")} />
</ModalContent>
</Modal>
</FormProvider> </FormProvider>
</form> </form>
); );
@@ -125,7 +125,7 @@ export const GeneralPermissionOptions = <T extends keyof NonNullable<TFormSchema
}} }}
isDisabled={isDisabled} isDisabled={isDisabled}
> >
Add rule Add policy
</Button> </Button>
)} )}
{!isDisabled && ( {!isDisabled && (
@@ -137,7 +137,7 @@ export const GeneralPermissionOptions = <T extends keyof NonNullable<TFormSchema
onClick={() => items.remove(rootIndex)} onClick={() => items.remove(rootIndex)}
isDisabled={isDisabled} isDisabled={isDisabled}
> >
Remove Rule Remove policy
</Button> </Button>
)}{" "} )}{" "}
</div> </div>
@@ -1,6 +1,7 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form"; 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types"; import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types";
@@ -19,14 +20,23 @@ const getValueLabel = (type: string) => {
}; };
export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) => { export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) => {
const { control, watch } = useFormContext<TFormSchema>(); const {
control,
watch,
formState: { errors }
} = useFormContext<TFormSchema>();
const items = useFieldArray({ const items = useFieldArray({
control, control,
name: `permissions.secrets.${position}.conditions` name: `permissions.secrets.${position}.conditions`
}); });
return ( return (
<div className="mt-6 border-t border-t-gray-800 bg-mineshaft-800 pt-2"> <div
className={twMerge(
"mt-6 bg-mineshaft-800",
items.fields.length && " border-t border-t-gray-800 pt-2"
)}
>
<div className="mt-2 flex flex-col space-y-2"> <div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => { {items.fields.map((el, index) => {
const lhs = watch(`permissions.secrets.${position}.conditions.${index}.lhs`); 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)} onValueChange={(e) => field.onChange(e)}
className="w-full" className="w-full"
> >
<SelectItem value="environment">Environment</SelectItem> <SelectItem value="environment">Environment Slug</SelectItem>
<SelectItem value="secretPath">Secret Path</SelectItem> <SelectItem value="secretPath">Secret Path</SelectItem>
</Select> </Select>
</FormControl> </FormControl>
@@ -114,6 +124,13 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
); );
})} })}
</div> </div>
{errors?.permissions?.secrets?.[position]?.conditions?.message && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{errors?.permissions?.secrets?.[position]?.conditions?.message}</span>
</div>
)}
<div>{}</div>
<div> <div>
<Button <Button
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}