diff --git a/.changeset/dark-wombats-slide.md b/.changeset/dark-wombats-slide.md new file mode 100644 index 000000000..80cc48679 --- /dev/null +++ b/.changeset/dark-wombats-slide.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-core": minor +--- + +Added the optional `strict` parameter to the `checkPermission` function, which, when specified as false, disables audit logging, and throwing an exception if the principal has no permission. diff --git a/.changeset/jolly-pens-crash.md b/.changeset/jolly-pens-crash.md new file mode 100644 index 000000000..f817f18d5 --- /dev/null +++ b/.changeset/jolly-pens-crash.md @@ -0,0 +1,5 @@ +--- +"@svenvw/fdm-app": minor +--- + +Various UI elements are now hidden or grayed out if the current logged-in user doesn't have permission to perform the actions that they represent. diff --git a/fdm-app/app/components/blocks/cultivation/card-details.tsx b/fdm-app/app/components/blocks/cultivation/card-details.tsx index 487182ec8..7bfc3cfba 100644 --- a/fdm-app/app/components/blocks/cultivation/card-details.tsx +++ b/fdm-app/app/components/blocks/cultivation/card-details.tsx @@ -22,6 +22,7 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select" +import { cn } from "~/lib/utils" import { CultivationDetailsFormSchema, type CultivationDetailsFormSchemaType, @@ -30,9 +31,11 @@ import { export function CultivationDetailsCard({ cultivation, b_lu_variety_options, + editable = true, }: { cultivation: Cultivation b_lu_variety_options: { value: string; label: string }[] + editable?: boolean }) { const fetcher = useFetcher() const form = useRemixForm({ @@ -72,7 +75,7 @@ export function CultivationDetailsCard({ {cultivation.b_lu_name} - {!isCreateWizard ? ( + {!isCreateWizard && editable ? (
@@ -132,6 +138,7 @@ export function CultivationDetailsCard({ field.onChange } disabled={ + !editable || form.formState .isSubmitting || fetcher.state === @@ -158,8 +165,9 @@ export function CultivationDetailsCard({ onValueChange={field.onChange} value={field.value ?? undefined} disabled={ + !editable || b_lu_variety_options.length === - 0 + 0 } > @@ -196,7 +204,12 @@ export function CultivationDetailsCard({ )} />
-
+
- + +
diff --git a/fdm-app/app/components/blocks/cultivation/card-list.tsx b/fdm-app/app/components/blocks/cultivation/card-list.tsx index b7471aced..6f652eff0 100644 --- a/fdm-app/app/components/blocks/cultivation/card-list.tsx +++ b/fdm-app/app/components/blocks/cultivation/card-list.tsx @@ -14,10 +14,12 @@ export function CultivationListCard({ cultivationsCatalogueOptions, cultivations, harvests, + editable = true, }: { cultivationsCatalogueOptions: CultivationOption[] cultivations: Cultivation[] harvests: Harvest[] + editable?: boolean }) { return ( @@ -25,7 +27,7 @@ export function CultivationListCard({ Gewassen - {cultivations.length !== 0 ? ( + {cultivations.length !== 0 && editable ? ( diff --git a/fdm-app/app/components/blocks/cultivation/form-add.tsx b/fdm-app/app/components/blocks/cultivation/form-add.tsx index 435e28043..684e0669c 100644 --- a/fdm-app/app/components/blocks/cultivation/form-add.tsx +++ b/fdm-app/app/components/blocks/cultivation/form-add.tsx @@ -17,7 +17,9 @@ import { import { CultivationAddFormSchema } from "./schema" import type { CultivationsFormProps } from "./types" -export function CultivationAddFormDialog({ options }: CultivationsFormProps) { +export function CultivationAddFormDialog({ + options, +}: CultivationsFormProps) { const [isOpen, setIsOpen] = useState(false) return ( @@ -41,7 +43,8 @@ export function CultivationAddFormDialog({ options }: CultivationsFormProps) { function CultivationAddForm({ options, onSuccess, -}: CultivationsFormProps & { onSuccess?: () => void }) { + editable = true, +}: CultivationsFormProps & { editable?: boolean; onSuccess?: () => void }) { const form = useRemixForm>({ mode: "onTouched", resolver: zodResolver(CultivationAddFormSchema), @@ -70,7 +73,7 @@ function CultivationAddForm({ onSubmit={form.handleSubmit} method="post" > -
+
}) { const fetcher = useFetcher() const location = useLocation() @@ -108,14 +113,26 @@ export function FertilizerApplicationCard({ if (savedFormValues && !isDialogOpen) { if (savedFormValues.p_app_id) { // Do not open the form if there is a risk it will create a new application - if (applicationToEdit) { + if ( + applicationToEdit && + (canModifyFertilizerApplication[ + applicationToEdit.p_app_id + ] ?? + true) + ) { setIsDialogOpen(true) } - } else { + } else if (canCreateFertilizerApplication) { setIsDialogOpen(true) } } - }, [savedFormValues, applicationToEdit, isDialogOpen]) + }, [ + savedFormValues, + applicationToEdit, + isDialogOpen, + canCreateFertilizerApplication, + canModifyFertilizerApplication, + ]) function handleDialogOpenChange(state: boolean) { if (!state && params.b_id_farm && b_id_or_b_lu_catalogue) { @@ -143,7 +160,13 @@ export function FertilizerApplicationCard({ onOpenChange={handleDialogOpenChange} > - @@ -181,6 +204,9 @@ export function FertilizerApplicationCard({ fertilizers={fertilizers} handleDelete={handleDelete} handleEdit={handleEdit} + canModifyFertilizerApplication={ + canModifyFertilizerApplication + } /> diff --git a/fdm-app/app/components/blocks/fertilizer-applications/list.tsx b/fdm-app/app/components/blocks/fertilizer-applications/list.tsx index 8c5d54bda..2dd9984e1 100644 --- a/fdm-app/app/components/blocks/fertilizer-applications/list.tsx +++ b/fdm-app/app/components/blocks/fertilizer-applications/list.tsx @@ -4,6 +4,7 @@ import { format } from "date-fns" import { nl } from "date-fns/locale" import { Circle, Diamond, Square, Trash, Triangle } from "lucide-react" import { useFetcher } from "react-router" +import { LoadingSpinner } from "~/components/custom/loadingspinner" import { Button } from "~/components/ui/button" import { Empty, @@ -26,12 +27,13 @@ import { TooltipProvider, TooltipTrigger, } from "~/components/ui/tooltip" -import { LoadingSpinner } from "../../custom/loadingspinner" +import { cn } from "~/lib/utils" export function FertilizerApplicationsList({ fertilizerApplications, applicationMethodOptions, fertilizers, + canModifyFertilizerApplication = {}, handleDelete, handleEdit, }: { @@ -41,6 +43,7 @@ export function FertilizerApplicationsList({ label: string }[] fertilizers: Fertilizer[] + canModifyFertilizerApplication?: Record handleDelete: (p_app_id: string | string[]) => void handleEdit: (fertilizerApplication: FertilizerApplication) => () => void }) { @@ -57,6 +60,10 @@ export function FertilizerApplicationsList({ if (!fertilizer) { return null } + const editable = + canModifyFertilizerApplication[ + application.p_app_id + ] ?? true return (
@@ -82,8 +89,9 @@ export function FertilizerApplicationsList({ variant="link" className="p-0 mt-0" disabled={ + !editable || fetcher.state === - "submitting" + "submitting" } onClick={handleEdit( application, @@ -117,7 +125,11 @@ export function FertilizerApplicationsList({

- + diff --git a/fdm-app/app/components/blocks/fertilizer/form.tsx b/fdm-app/app/components/blocks/fertilizer/form.tsx index 3b20583c0..ed471faf0 100644 --- a/fdm-app/app/components/blocks/fertilizer/form.tsx +++ b/fdm-app/app/components/blocks/fertilizer/form.tsx @@ -22,6 +22,7 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select" +import { cn } from "~/lib/utils" export interface FertilizerParameterDescriptionItem { parameter: FertilizerParameters @@ -258,19 +259,20 @@ export function FertilizerForm({ ))}
- {editable && ( -
- -
- )} +
+ +
diff --git a/fdm-app/app/components/blocks/fertilizer/table.tsx b/fdm-app/app/components/blocks/fertilizer/table.tsx index 52e0440cd..e81c570e7 100644 --- a/fdm-app/app/components/blocks/fertilizer/table.tsx +++ b/fdm-app/app/components/blocks/fertilizer/table.tsx @@ -21,15 +21,18 @@ import { TableHeader, TableRow, } from "~/components/ui/table" +import { cn } from "~/lib/utils" interface DataTableProps { columns: ColumnDef[] data: TData[] + canAddItem: boolean } export function DataTable({ columns, data, + canAddItem, }: DataTableProps) { const [sorting, setSorting] = useState([]) const [columnFilters, setColumnFilters] = useState([]) @@ -65,7 +68,7 @@ export function DataTable({ } className="max-w-sm" /> -
+
- - - +
+ + + + + - - - - + + + + + @@ -581,7 +588,7 @@ export function HarvestFormDialog(props: HarvestFormDialogProps) { } export function HarvestForm(props: HarvestFormDialogProps) { - const { b_lu_harvest_date, action } = props + const { b_lu_harvest_date, action, editable = true } = props const fetcher = useFetcher() const form = useHarvestRemixForm(props) @@ -603,7 +610,7 @@ export function HarvestForm(props: HarvestFormDialogProps) { action={action} >
{form.formState.isSubmitting || diff --git a/fdm-app/app/components/blocks/rotation/table.tsx b/fdm-app/app/components/blocks/rotation/table.tsx index 83cfbdeb9..fa4877077 100644 --- a/fdm-app/app/components/blocks/rotation/table.tsx +++ b/fdm-app/app/components/blocks/rotation/table.tsx @@ -51,11 +51,13 @@ import { useFieldFilterStore } from "@/app/store/field-filter" interface DataTableProps { columns: ColumnDef[] data: TData[] + canAddItem: boolean } export function DataTable({ columns, data, + canAddItem, }: DataTableProps) { const [sorting, setSorting] = useState([]) const [columnFilters, setColumnFilters] = useState([]) @@ -279,7 +281,9 @@ export function DataTable({ -
+
{isFertilizerButtonDisabled ? (
-
diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.$a_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.$a_id.tsx index 159030a65..df75e8b2d 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.$a_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.soil.analysis.$a_id.tsx @@ -1,4 +1,5 @@ import { + checkPermission, getField, getSoilAnalysis, getSoilParametersDescription, @@ -98,11 +99,22 @@ export async function loader({ request, params }: LoaderFunctionArgs) { (item: { parameter: string }) => soilAnalysis[item.parameter], ) + const soilAnalysisWritePermission = await checkPermission( + fdm, + "soil_analysis", + "write", + a_id, + session.principal_id, + new URL(request.url).pathname, + false, + ) + // Return user information from loader return { field: field, soilParameterDescription: soilParameterDescription, soilAnalysis: soilAnalysis, + soilAnalysisWritePermission: soilAnalysisWritePermission, } } catch (error) { throw handleLoaderError(error) @@ -139,6 +151,7 @@ export default function FarmFieldSoilOverviewBlock() { soilAnalysis={loaderData.soilAnalysis} soilParameterDescription={loaderData.soilParameterDescription} action="." + editable={loaderData.soilAnalysisWritePermission} />
) diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.tsx index 9636fa63d..3216a7897 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field.$b_id.tsx @@ -1,4 +1,9 @@ -import { getFarms, getField, getFields } from "@svenvw/fdm-core" +import { + checkPermission, + getFarms, + getField, + getFields, +} from "@svenvw/fdm-core" import { data, type LoaderFunctionArgs, @@ -150,11 +155,24 @@ export async function loader({ request, params }: LoaderFunctionArgs) { to: `/farm/${b_id_farm}/${calendar}/field/${b_id}/atlas`, title: "Kaart", }, - { + ] + + const fieldWritePermission = await checkPermission( + fdm, + "field", + "write", + b_id, + session.principal_id, + new URL(request.url).pathname, + false, + ) + + if (fieldWritePermission) { + sidebarPageItems.push({ to: `/farm/${b_id_farm}/${calendar}/field/${b_id}/delete`, title: "Verwijderen", - }, - ] + }) + } // Return user information from loader return { diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx index ff9bbf882..247ab717c 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.field._index.tsx @@ -1,4 +1,5 @@ import { + checkPermission, getCultivations, getCurrentSoilData, getFarms, @@ -29,6 +30,7 @@ import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { useFieldFilterStore } from "~/store/field-filter" +import { cn } from "~/lib/utils" export const meta: MetaFunction = () => { return [ @@ -57,6 +59,7 @@ export const meta: MetaFunction = () => { * - farmOptions: An array of validated farm options. * - fieldOptions: A sorted array of processed field options. * - userName: The name of the current user. + * - farmWritePermission: A Boolean indicating if the user is able to add fields to the farm. Set to true if the information could not be obtained. */ export async function loader({ request, params }: LoaderFunctionArgs) { try { @@ -154,6 +157,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) { (x) => x.parameter === "b_soiltype_agr", )?.value ?? null + const has_write_permission = await checkPermission( + fdm, + "field", + "write", + field.b_id, + session.principal_id, + new URL(request.url).pathname, + false, + ) return { b_id: field.b_id, b_name: field.b_name, @@ -163,10 +175,21 @@ export async function loader({ request, params }: LoaderFunctionArgs) { b_soiltype_agr: b_soiltype_agr, b_area: Math.round(field.b_area * 10) / 10, b_isproductive: field.b_isproductive ?? true, + has_write_permission: has_write_permission, } }), ) + const farmWritePermission = await checkPermission( + fdm, + "farm", + "write", + b_id_farm, + session.principal_id, + new URL(request.url).pathname, + false, + ) + // Return user information from loader return { b_id_farm: b_id_farm, @@ -174,6 +197,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { fieldOptions: fieldOptions, fieldsExtended: fieldsExtended, userName: session.userName, + farmWritePermission: farmWritePermission, } } catch (error) { throw handleLoaderError(error) @@ -241,9 +265,18 @@ export default function FarmFieldIndex() {
- - - +
{/*

*/} @@ -262,6 +295,7 @@ export default function FarmFieldIndex() { diff --git a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx index 92d4594f9..ebeba99ae 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.$calendar.rotation._index.tsx @@ -1,5 +1,6 @@ import { type CultivationCatalogue, + checkPermission, getCultivations, getCultivationsFromCatalogue, getCurrentSoilData, @@ -18,13 +19,13 @@ import { } from "react-router" import { FarmContent } from "~/components/blocks/farm/farm-content" import { FarmTitle } from "~/components/blocks/farm/farm-title" +import { Header } from "~/components/blocks/header/base" +import { HeaderFarm } from "~/components/blocks/header/farm" import { columns, type RotationExtended, } from "~/components/blocks/rotation/columns" import { DataTable } from "~/components/blocks/rotation/table" -import { Header } from "~/components/blocks/header/base" -import { HeaderFarm } from "~/components/blocks/header/farm" import { BreadcrumbItem, BreadcrumbSeparator } from "~/components/ui/breadcrumb" import { Button } from "~/components/ui/button" import { SidebarInset } from "~/components/ui/sidebar" @@ -33,6 +34,7 @@ import { getCalendar, getTimeframe } from "~/lib/calendar" import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" +import { cn } from "~/lib/utils" export const meta: MetaFunction = () => { return [ @@ -61,6 +63,7 @@ export const meta: MetaFunction = () => { * - farmOptions: An array of validated farm options. * - fieldOptions: A sorted array of processed field options. * - userName: The name of the current user. + * - farmWritePermission: A Boolean indicating if the user is able to add things to the farm. Set to true if the information could not be obtained. */ export async function loader({ request, params }: LoaderFunctionArgs) { try { @@ -303,6 +306,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { fieldsExtended, cultivationCatalogue, ) + + const farmWritePermission = await checkPermission( + fdm, + "farm", + "write", + b_id_farm, + session.principal_id, + new URL(request.url).pathname, + false, + ) // Return user information from loader return { @@ -311,6 +324,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { fieldOptions: fieldOptions, rotationExtended: rotationExtended, // Return filtered data userName: session.userName, + farmWritePermission: farmWritePermission, } } catch (error) { throw handleLoaderError(error) @@ -370,9 +384,18 @@ export default function FarmRotationIndex() {
- - - +
@@ -389,6 +412,7 @@ export default function FarmRotationIndex() { diff --git a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx index a352c8df7..aae40d014 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.fertilizers.$p_id.tsx @@ -1,5 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod" import { + checkPermission, getFarm, getFarms, getFertilizer, @@ -110,6 +111,20 @@ export async function loader({ request, params }: LoaderFunctionArgs) { if (fertilizer.p_source === b_id_farm) { editable = true } + if ( + editable && + !(await checkPermission( + fdm, + "farm", + "write", + b_id_farm, + session.principal_id, + new URL(request.url).pathname, + false, + )) + ) { + editable = false + } // Return user information from loader return { diff --git a/fdm-app/app/routes/farm.$b_id_farm.fertilizers._index.tsx b/fdm-app/app/routes/farm.$b_id_farm.fertilizers._index.tsx index 584be095b..e57fe4860 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.fertilizers._index.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.fertilizers._index.tsx @@ -1,4 +1,5 @@ import { + checkPermission, getFarm, getFarms, getFertilizerParametersDescription, @@ -102,12 +103,23 @@ export async function loader({ request, params }: LoaderFunctionArgs) { })), ) + const farmWritePermission = await checkPermission( + fdm, + "farm", + "write", + b_id_farm, + session.principal_id, + new URL(request.url).pathname, + false, + ) + // Return user information from loader return { farm: farm, b_id_farm: b_id_farm, farmOptions: farmOptions, fertilizers: fertilizers, + farmWritePermission: farmWritePermission, } } catch (error) { throw handleLoaderError(error) @@ -143,6 +155,7 @@ export default function FarmFertilizersIndexPage({ diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.derogation.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.derogation.tsx index c03a88750..9e1dc027d 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.derogation.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.derogation.tsx @@ -1,5 +1,6 @@ import { addDerogation, + checkPermission, listDerogations, removeDerogation, } from "@svenvw/fdm-core" @@ -40,7 +41,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { session.principal_id, b_id_farm, ) - return { b_id_farm, derogations } + const farmWritePermission = await checkPermission( + fdm, + "farm", + "write", + b_id_farm, + session.principal_id, + new URL(request.url).pathname, + false, + ) + return { b_id_farm, derogations, farmWritePermission } } catch (error) { throw handleLoaderError(error) } @@ -86,7 +96,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } export default function DerogationSettings() { - const { derogations } = useLoaderData() + const { derogations, farmWritePermission } = useLoaderData() const fetcher = useFetcher() const years = getCalendarSelection() @@ -99,9 +109,11 @@ export default function DerogationSettings() { Derogatie - Schakel derogatie in voor de jaren waarvoor dit bedrijf - in aanmerking komt. Dit heeft invloed op de berekening - van je gebruiksruimte. + {farmWritePermission + ? "Schakel derogatie in voor de jaren waarvoor dit bedrijf in aanmerking komt." + : "Hieronder staan de jaren waarin dit bedrijf derogatie heeft."}{" "} + Dit heeft invloed op de berekening van je + gebruiksruimte. @@ -121,25 +133,33 @@ export default function DerogationSettings() { {year} - + {farmWritePermission ? ( + + { + fetcher.submit( + { + year: String( + year, + ), + hasDerogation: + String( + hasDerogation, + ), + }, + { + method: "post", + }, + ) + }} + /> + + ) : ( { - fetcher.submit( - { - year: String( - year, - ), - hasDerogation: - String( - hasDerogation, - ), - }, - { method: "post" }, - ) - }} /> - + )} ) diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.grazing-intention.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.grazing-intention.tsx index d5c04db3f..0dc1bc5f2 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.grazing-intention.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.grazing-intention.tsx @@ -1,4 +1,8 @@ -import { getGrazingIntentions, setGrazingIntention } from "@svenvw/fdm-core" +import { + checkPermission, + getGrazingIntentions, + setGrazingIntention, +} from "@svenvw/fdm-core" import { type ActionFunctionArgs, type LoaderFunctionArgs, @@ -39,7 +43,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { session.principal_id, b_id_farm, ) - return { b_id_farm, grazingIntentions } + const farmWritePermission = await checkPermission( + fdm, + "farm", + "write", + b_id_farm, + session.principal_id, + new URL(request.url).pathname, + false, + ) + return { b_id_farm, grazingIntentions, farmWritePermission } } catch (error) { throw handleLoaderError(error) } @@ -78,7 +91,8 @@ export async function action({ request, params }: ActionFunctionArgs) { } export default function GrazingIntentionSettings() { - const { grazingIntentions } = useLoaderData() + const { grazingIntentions, farmWritePermission } = + useLoaderData() const fetcher = useFetcher() const currentYear = new Date().getFullYear() @@ -92,9 +106,10 @@ export default function GrazingIntentionSettings() { Beweiding - Geef hier aan of je voor een bepaald jaar hebt beweid of - van plan bent te gaan beweiden. Dit heeft invloed op de - berekeningen. + {farmWritePermission + ? "Geef hier aan of je voor een bepaald jaar hebt beweid of van plan bent te gaan beweiden." + : "Hieronder staan de jaren waarin beweiding gepland is of heeft plaatsgevonden."}{" "} + Dit heeft invloed op de berekeningen. @@ -117,27 +132,37 @@ export default function GrazingIntentionSettings() { {year} - + {farmWritePermission ? ( + + { + fetcher.submit( + { + year: String( + year, + ), + hasGrazingIntention: + String( + hasGrazingIntention, + ), + }, + { + method: "post", + }, + ) + }} + /> + + ) : ( { - fetcher.submit( - { - year: String( - year, - ), - hasGrazingIntention: - String( - hasGrazingIntention, - ), - }, - { method: "post" }, - ) - }} /> - + )} ) diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.organic-certification.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.organic-certification.tsx index 39fa32106..66ca969eb 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.organic-certification.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.organic-certification.tsx @@ -1,6 +1,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { addOrganicCertification, + checkPermission, listOrganicCertifications, removeOrganicCertification, } from "@svenvw/fdm-core" @@ -63,6 +64,7 @@ import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" +import { cn } from "~/lib/utils" export const meta: MetaFunction = () => { return [ @@ -93,7 +95,17 @@ export async function loader({ request, params }: LoaderFunctionArgs) { // For now we expect that a farm can have only 1 certification const organicCertification = organicCertifications[0] - return { b_id_farm, organicCertification } + const farmWritePermission = await checkPermission( + fdm, + "farm", + "write", + b_id_farm, + session.principal_id, + new URL(request.url).pathname, + false, + ) + + return { b_id_farm, organicCertification, farmWritePermission } } catch (error) { throw handleLoaderError(error) } @@ -155,7 +167,8 @@ export async function action({ request, params }: ActionFunctionArgs) { } export default function OrganicCertificationSettings() { - const { organicCertification } = useLoaderData() + const { organicCertification, farmWritePermission } = + useLoaderData() const navigation = useNavigation() const isDeleting = @@ -309,13 +322,20 @@ export default function OrganicCertificationSettings() { Dit bedrijf heeft geen bio-certificaat - Als dit bedrijf wel een bio-certificaat heeft, - kunt u deze toevoegen. + {farmWritePermission + ? "Als dit bedrijf wel een bio-certificaat heeft, kunt u deze toevoegen." + : "U heeft geen toestemming om een bio-certificaat voor dit bedrijf toe te voegen."} - + diff --git a/fdm-app/app/routes/farm.$b_id_farm.settings.properties.tsx b/fdm-app/app/routes/farm.$b_id_farm.settings.properties.tsx index ecaa41a09..08cbd1632 100644 --- a/fdm-app/app/routes/farm.$b_id_farm.settings.properties.tsx +++ b/fdm-app/app/routes/farm.$b_id_farm.settings.properties.tsx @@ -1,5 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { getFarm, updateFarm } from "@svenvw/fdm-core" +import { checkPermission, getFarm, updateFarm } from "@svenvw/fdm-core" import { useEffect } from "react" import { Form } from "react-hook-form" import { @@ -30,6 +30,7 @@ import { getSession } from "~/lib/auth.server" import { handleActionError, handleLoaderError } from "~/lib/error" import { fdm } from "~/lib/fdm.server" import { extractFormValuesFromRequest } from "~/lib/form" +import { cn } from "~/lib/utils" const { isPostalCode } = validator @@ -80,10 +81,20 @@ export async function loader({ request, params }: LoaderFunctionArgs) { statusText: "Farm is not found", }) } + const farmWritePermission = await checkPermission( + fdm, + "farm", + "write", + b_id_farm, + session.principal_id, + new URL(request.url).pathname, + false, + ) // Return user information from loader return { farm: farm, + farmWritePermission: farmWritePermission, } } catch (error) { throw handleLoaderError(error) @@ -145,7 +156,12 @@ export default function FarmSettingsPropertiesBlock() { onSubmit={form.handleSubmit} method="POST" > -
+
{form.formState.isSubmitting && } Bijwerken diff --git a/fdm-app/app/routes/farm.tsx b/fdm-app/app/routes/farm.tsx index 904ab5319..4547118e4 100644 --- a/fdm-app/app/routes/farm.tsx +++ b/fdm-app/app/routes/farm.tsx @@ -20,7 +20,7 @@ import { clientConfig } from "~/lib/config" import { handleLoaderError } from "~/lib/error" import { useCalendarStore } from "~/store/calendar" import { useFarmStore } from "~/store/farm" -import { fdm } from "../lib/fdm.server" +import { fdm } from "~/lib/fdm.server" export const meta: MetaFunction = () => { return [ diff --git a/fdm-core/src/authorization.test.ts b/fdm-core/src/authorization.test.ts index f519f25a3..d68da4f00 100644 --- a/fdm-core/src/authorization.test.ts +++ b/fdm-core/src/authorization.test.ts @@ -139,6 +139,20 @@ describe("Authorization Functions", () => { ) }) + it("should not throw an error in non-strict mode if principal does not have the required role", async () => { + await expect( + checkPermission( + fdm, + "farm", + "read", + farm_id, + createId(), + "test", + false, + ), + ).resolves.toBe(false) + }) + it("should grant access through the organization", async () => { await grantRole(fdm, "farm", "owner", farm_id, organization_id) const invitation_id = await inviteUserToOrganization( @@ -263,6 +277,29 @@ describe("Authorization Functions", () => { expect(auditLogs[0].action).toBe("read") expect(auditLogs[0].allowed).toBe(false) }) + + it("should not store the audit log if non-strict", async () => { + const principal_id_new = createId() + + await expect( + checkPermission( + fdm, + "farm", + "read", + farm_id, + principal_id_new, + "test", + false, + ), + ).resolves.toBe(false) + + const auditLogs = await fdm + .select() + .from(authZSchema.audit) + .where(eq(authZSchema.audit.principal_id, principal_id_new)) + .orderBy(desc(authZSchema.audit.audit_timestamp)) + expect(auditLogs).toHaveLength(0) + }) }) describe("grantRole", () => { diff --git a/fdm-core/src/authorization.ts b/fdm-core/src/authorization.ts index 2d72d8f70..8a82f6b78 100644 --- a/fdm-core/src/authorization.ts +++ b/fdm-core/src/authorization.ts @@ -142,7 +142,8 @@ export const permissions: Permission[] = [ * * This function retrieves the valid roles for the specified action and resource, constructs the resource hierarchy, * and iterates through the chain to verify if any level grants the required permission for the principal(s). It records - * the permission check details in the audit log and throws an error if the permission is denied. + * the permission check details in the audit log and throws an error if the permission is denied. `strict` may be + * specified as false in order to disable the exception. * * @param fdm The FDM instance providing the connection to the database. The instance can be created with {@link createFdmServer}. * @param resource - The type of resource being accessed. @@ -150,6 +151,7 @@ export const permissions: Permission[] = [ * @param resource_id - The unique identifier of the specific resource. * @param principal_id - The principal identifier(s); supports a single ID or an array. * @param origin - The source origin used for audit logging the permission check. + * @param strict - When set to false, the function will not perform an audit log, or throw an exception if the user has no permission. * @returns Resolves to true if the principal is permitted to perform the action. * * @throws {Error} When the principal does not have the required permission. @@ -161,86 +163,42 @@ export async function checkPermission( resource_id: string, principal_id: PrincipalId, origin: string, -): Promise { + strict = true, +) { const start = performance.now() - - let isAllowed = false - let granting_resource = "" - let granting_resource_id = "" try { - const roles = getRolesForAction(action, resource) - const chain = await getResourceChain(fdm, resource, resource_id) - - // Convert principal_id to array - const principal_ids = Array.isArray(principal_id) - ? principal_id - : [principal_id] + const permission = await getPermission( + fdm, + resource, + action, + resource_id, + principal_id, + ) - await fdm.transaction(async (tx: FdmType) => { - for (const bead of chain) { - const check = await tx - .select({ - resource_id: authZSchema.role.resource_id, - }) - .from(authZSchema.role) - .leftJoin( - authNSchema.member, - eq( - authZSchema.role.principal_id, - authNSchema.member.organizationId, - ), - ) - .where( - and( - eq(authZSchema.role.resource, bead.resource), - eq(authZSchema.role.resource_id, bead.resource_id), - or( - inArray( - authZSchema.role.principal_id, - principal_ids, - ), - and( - isNotNull(authNSchema.member.userId), - inArray( - authNSchema.member.userId, - principal_ids, - ), - ), - ), - inArray(authZSchema.role.role, roles), - isNull(authZSchema.role.deleted), - ), - ) - .limit(1) - - if (check.length > 0) { - isAllowed = true - granting_resource = bead.resource - granting_resource_id = bead.resource_id - break - } - } - }) + const granting_resource = permission?.granting_resource ?? "" + const granting_resource_id = permission?.granting_resource_id ?? "" // Store check in audit - await fdm.insert(authZSchema.audit).values({ - audit_id: createId(), - audit_origin: origin, - principal_id: principal_id, - target_resource: resource, - target_resource_id: resource_id, - granting_resource: granting_resource, - granting_resource_id: granting_resource_id, - action: action, - allowed: isAllowed, - duration: Math.round(performance.now() - start), - }) + if (strict) { + await fdm.insert(authZSchema.audit).values({ + audit_id: createId(), + audit_origin: origin, + principal_id: principal_id, + target_resource: resource, + target_resource_id: resource_id, + granting_resource: granting_resource, + granting_resource_id: granting_resource_id, + action: action, + allowed: !!permission, + duration: Math.round(performance.now() - start), + }) - if (!isAllowed) { - throw new Error("Permission denied") + if (!permission) { + throw new Error("Permission denied") + } } - return isAllowed + return !!permission } catch (err) { let message = "Exception for checkPermission" if (err instanceof Error && err.message === "Permission denied") { @@ -256,6 +214,87 @@ export async function checkPermission( } } +/** + * Gets the granting resource type and ID if the principal has permission to perform the action in the given resource. + * + * @param fdm The FDM instance providing the connection to the database. The instance can be created with {@link createFdmServer}. + * @param resource - The type of resource being accessed. + * @param action - The action the principal intends to perform. + * @param resource_id - The unique identifier of the specific resource. + * @param principal_id - The principal identifier(s); supports a single ID or an array. + * @returns `granting_resource` is the resource type, `granting_resource_id` is the id of the specific granting resource. + * `null` is returned if the principal does not have the permission. + */ +async function getPermission( + fdm: FdmType, + resource: Resource, + action: Action, + resource_id: string, + principal_id: PrincipalId, +): Promise<{ + granting_resource: string + granting_resource_id: string +} | null> { + let isAllowed = false + let granting_resource = "" + let granting_resource_id = "" + const roles = getRolesForAction(action, resource) + const chain = await getResourceChain(fdm, resource, resource_id) + + // Convert principal_id to array + const principal_ids = Array.isArray(principal_id) + ? principal_id + : [principal_id] + + await fdm.transaction(async (tx: FdmType) => { + for (const bead of chain) { + const check = await tx + .select({ + resource_id: authZSchema.role.resource_id, + }) + .from(authZSchema.role) + .leftJoin( + authNSchema.member, + eq( + authZSchema.role.principal_id, + authNSchema.member.organizationId, + ), + ) + .where( + and( + eq(authZSchema.role.resource, bead.resource), + eq(authZSchema.role.resource_id, bead.resource_id), + or( + inArray( + authZSchema.role.principal_id, + principal_ids, + ), + and( + isNotNull(authNSchema.member.userId), + inArray( + authNSchema.member.userId, + principal_ids, + ), + ), + ), + inArray(authZSchema.role.role, roles), + isNull(authZSchema.role.deleted), + ), + ) + .limit(1) + + if (check.length > 0) { + isAllowed = true + granting_resource = bead.resource + granting_resource_id = bead.resource_id + break + } + } + }) + + return isAllowed ? { granting_resource, granting_resource_id } : null +} + /** * Retrieves a list of roles a principal has for a specific resource. * diff --git a/fdm-core/src/index.ts b/fdm-core/src/index.ts index c0793f36c..1d87cb97e 100644 --- a/fdm-core/src/index.ts +++ b/fdm-core/src/index.ts @@ -22,6 +22,7 @@ export { updateUserProfile, } from "./authentication" export type { FdmAuth } from "./authentication.d" +export { checkPermission } from "./authorization" export type { PrincipalId } from "./authorization.d" export { getCachedCalculation,