Implement PermissionsProvider and use in assets page

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-12-02 13:54:48 +04:00
parent e01bec9f88
commit accc8292b5
3 changed files with 103 additions and 49 deletions

View File

@@ -1,53 +1,54 @@
import { Link, Navigate, Outlet, useParams } from "react-router"; import { useTranslate } from "@probo/i18n";
import { import {
Avatar,
Badge,
Button,
Dropdown,
DropdownItem,
DropdownSeparator, DropdownSeparator,
IconArrowBoxLeft, IconArrowBoxLeft,
IconBank, IconBank,
IconBook, IconBook,
IconBox,
IconCalendar1,
IconCheckmark1, IconCheckmark1,
IconChevronGrabberVertical,
IconCircleProgress,
IconCircleQuestionmark, IconCircleQuestionmark,
IconClock, IconClock,
IconCrossLargeX, IconCrossLargeX,
IconFire3, IconFire3,
IconGroup1, IconGroup1,
IconInboxEmpty, IconInboxEmpty,
IconKey,
IconListStack,
IconLock,
IconMedal,
IconPageTextLine, IconPageTextLine,
IconPeopleAdd,
IconPlusLarge,
IconRotateCw,
IconSettingsGear2, IconSettingsGear2,
IconShield,
IconStore, IconStore,
IconTodo, IconTodo,
IconListStack,
IconBox,
IconShield,
IconRotateCw,
IconCircleProgress,
IconMedal,
IconCalendar1,
Layout, Layout,
SidebarItem, SidebarItem,
UserDropdown as UserDropdownRoot,
UserDropdownItem,
Skeleton, Skeleton,
Dropdown, UserDropdownItem,
Button, UserDropdown as UserDropdownRoot,
DropdownItem, useToast,
IconChevronGrabberVertical,
IconPlusLarge,
Avatar,
IconPeopleAdd,
Badge,
IconKey,
IconLock,
} from "@probo/ui"; } from "@probo/ui";
import { useTranslate } from "@probo/i18n"; import { Suspense, useEffect, useState } from "react";
import { graphql } from "relay-runtime";
import { useLazyLoadQuery } from "react-relay";
import type { MainLayoutQuery as MainLayoutQueryType } from "./__generated__/MainLayoutQuery.graphql";
import { Suspense, useState, useEffect } from "react";
import { useToast } from "@probo/ui";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { useLazyLoadQuery } from "react-relay";
import { Link, Navigate, Outlet, useParams } from "react-router";
import { graphql } from "relay-runtime";
import type { MainLayoutQuery as MainLayoutQueryType } from "./__generated__/MainLayoutQuery.graphql";
import { PageError } from "/components/PageError"; import { PageError } from "/components/PageError";
import { buildEndpoint } from "/providers/RelayProviders";
import { Authorized } from "/permissions"; import { Authorized } from "/permissions";
import { PermissionsProvider } from "/providers/PermissionsProvider";
import { buildEndpoint } from "/providers/RelayProviders";
const MainLayoutQuery = graphql` const MainLayoutQuery = graphql`
query MainLayoutQuery($organizationId: ID!) { query MainLayoutQuery($organizationId: ID!) {
@@ -82,7 +83,9 @@ export function MainLayout() {
return ( return (
<Suspense fallback={<Skeleton className="w-full h-screen" />}> <Suspense fallback={<Skeleton className="w-full h-screen" />}>
<MainLayoutContent organizationId={organizationId} prefix={prefix} /> <PermissionsProvider>
<MainLayoutContent organizationId={organizationId} prefix={prefix} />
</PermissionsProvider>
</Suspense> </Suspense>
); );
} }

View File

@@ -1,28 +1,29 @@
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { import {
Button, Button,
IconPlusLarge, IconPlusLarge,
PageHeader, PageHeader,
} from "@probo/ui"; } from "@probo/ui";
import { useTranslate } from "@probo/i18n"; import { use } from "react";
import { usePageTitle } from "@probo/hooks";
import { import {
graphql, graphql,
type PreloadedQuery,
usePaginationFragment, usePaginationFragment,
usePreloadedQuery, usePreloadedQuery,
type PreloadedQuery,
} from "react-relay"; } from "react-relay";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { useParams } from "react-router"; import { useParams } from "react-router";
import { AssetsTable } from "../../../components/assets/AssetsTable";
import { ReadOnlyAssetsTable } from "../../../components/assets/ReadOnlyAssetsTable";
import type { AssetsPageFragment$key } from "./__generated__/AssetsPageFragment.graphql";
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { import {
assetsQuery, assetsQuery,
} from "/hooks/graph/AssetGraph"; } from "/hooks/graph/AssetGraph";
import type { AssetGraphListQuery } from "/hooks/graph/__generated__/AssetGraphListQuery.graphql"; import type { AssetGraphListQuery } from "/hooks/graph/__generated__/AssetGraphListQuery.graphql";
import type { AssetsPageFragment$key } from "./__generated__/AssetsPageFragment.graphql"; import { useOrganizationId } from "/hooks/useOrganizationId";
import { SnapshotBanner } from "/components/SnapshotBanner"; import { PermissionsContext } from "/providers/PermissionsProvider";
import { CreateAssetDialog } from "./dialogs/CreateAssetDialog";
import { Authorized, isAuthorized } from "/permissions";
import { AssetsTable } from "../../../components/assets/AssetsTable";
import { ReadOnlyAssetsTable } from "../../../components/assets/ReadOnlyAssetsTable";
const paginatedAssetsFragment = graphql` const paginatedAssetsFragment = graphql`
fragment AssetsPageFragment on Organization fragment AssetsPageFragment on Organization
@@ -90,9 +91,10 @@ export default function AssetsPage(props: Props) {
const assets = pagination.data.assets?.edges.map((edge) => edge.node); const assets = pagination.data.assets?.edges.map((edge) => edge.node);
const connectionId = pagination.data.assets.__id; const connectionId = pagination.data.assets.__id;
const { isAuthorized } = use(PermissionsContext);
const canWrite = ( const canWrite = (
isAuthorized(organizationId, "Asset", "updateAsset") || isAuthorized("Asset", "updateAsset") ||
isAuthorized(organizationId, "Asset", "deleteAsset") isAuthorized("Asset", "deleteAsset")
); );
usePageTitle(__("Assets")); usePageTitle(__("Assets"));
@@ -105,15 +107,13 @@ export default function AssetsPage(props: Props) {
"Manage your organization's assets and their classifications.", "Manage your organization's assets and their classifications.",
)} )}
> >
{!isSnapshotMode && ( {!isSnapshotMode && isAuthorized("Organization", "createAsset") && (
<Authorized entity="Organization" action="createAsset"> <CreateAssetDialog
<CreateAssetDialog connection={connectionId}
connection={connectionId} organizationId={organizationId}
organizationId={organizationId} >
> <Button icon={IconPlusLarge}>{__("Add asset")}</Button>
<Button icon={IconPlusLarge}>{__("Add asset")}</Button> </CreateAssetDialog>
</CreateAssetDialog>
</Authorized>
)} )}
</PageHeader> </PageHeader>
{isSnapshotMode || !canWrite ? {isSnapshotMode || !canWrite ?

View File

@@ -0,0 +1,51 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { createContext, type PropsWithChildren } from "react";
import { useOrganizationId } from "/hooks/useOrganizationId";
enum Role {
OWNER = "OWNER",
ADMIN = "ADMIN",
VIEWER = "VIEWER",
}
type PermissionsResponse = {
permissions: Record<string, Record<string, boolean>>;
role: Role;
};
type PermissionsContextType = {
isAuthorized: (entity: string, action: string) => boolean;
} & PermissionsResponse;
export const PermissionsContext = createContext<PermissionsContextType>({
permissions: {},
role: Role.VIEWER,
isAuthorized: () => false,
});
export function PermissionsProvider(props: PropsWithChildren) {
const { children } = props;
const organizationId = useOrganizationId();
const { data } = useSuspenseQuery<PermissionsResponse>({
queryKey: ["permissions", organizationId],
queryFn: async () => {
const response = await fetch(`/authz/${organizationId}/permissions`, { credentials: "include" });
if (!response.ok) {
throw new Error("Failed to fetch permissions");
}
return response.json() as Promise<PermissionsResponse>;
},
});
const isAuthorized = (entity: string, action: string) => {
return data.permissions[entity]?.[action] ?? false;
}
return (
<PermissionsContext value={{ ...data, isAuthorized }}>
{children}
</PermissionsContext>
);
}