Browse Source

feat(dashboard): Add permission guard component and permissions hook for route protection

Michael Bromley 10 months ago
parent
commit
851dcdc490

+ 21 - 0
packages/dashboard/src/components/shared/permission-guard.tsx

@@ -0,0 +1,21 @@
+import { usePermissions } from "@/hooks/use-permissions.js";
+import { Permission } from "@vendure/common/lib/generated-types";
+
+export interface PermissionGuardProps {
+    requires: Permission | string | string[] | Permission[];
+    children: React.ReactNode;
+}
+
+/**
+ * @description
+ * This component is used to protect a route from unauthorized access.
+ * It will render the children if the user has the required permissions.
+ */
+export function PermissionGuard({ requires, children }: PermissionGuardProps ) {
+    const { hasPermissions } = usePermissions();
+    const permissions = Array.isArray(requires) ? requires : [requires];
+    if (!hasPermissions(permissions)) {
+        return null;
+    }
+    return children;
+}

+ 19 - 0
packages/dashboard/src/hooks/use-permissions.ts

@@ -0,0 +1,19 @@
+import { useAuth } from '@/hooks/use-auth.js';
+import { Permission } from '@vendure/common/lib/generated-types';
+
+import { useUserSettings } from './use-user-settings.js';
+
+export function usePermissions() {
+    const { channels } = useAuth();
+    const { settings } = useUserSettings();
+
+    function hasPermissions(permissions: string[]) {
+        const activeChannel = (channels ?? []).find(channel => channel.id === settings.activeChannelId);
+        if (!activeChannel) {
+            return false;
+        }
+        return permissions.some(permission => activeChannel.permissions.includes(permission as Permission));
+    }
+
+    return { hasPermissions };
+}

+ 12 - 12
packages/dashboard/src/providers/auth.tsx

@@ -1,5 +1,6 @@
 import { api } from '@/graphql/api.js';
 import { ResultOf, graphql } from '@/graphql/graphql.js';
+import { useUserSettings } from '@/hooks/use-user-settings.js';
 import { useMutation, useQuery } from '@tanstack/react-query';
 import * as React from 'react';
 
@@ -9,7 +10,8 @@ export interface AuthContext {
     isAuthenticated: boolean;
     login: (username: string, password: string, onSuccess?: () => void) => void;
     logout: (onSuccess?: () => void) => Promise<void>;
-    user: ResultOf<typeof ActiveAdministratorQuery>['activeAdministrator'] | undefined;
+    user: ResultOf<typeof CurrentUserQuery>['activeAdministrator'] | undefined;
+    channels: NonNullable<ResultOf<typeof CurrentUserQuery>['me']>['channels'] | undefined;
 }
 
 const LoginMutation = graphql(`
@@ -37,7 +39,7 @@ const LogOutMutation = graphql(`
 `);
 
 const CurrentUserQuery = graphql(`
-    query CurrentUser {
+    query CurrentUserInformation {
         me {
             id
             identifier
@@ -48,11 +50,6 @@ const CurrentUserQuery = graphql(`
                 permissions
             }
         }
-    }
-`);
-
-const ActiveAdministratorQuery = graphql(`
-    query ActiveAdministrator {
         activeAdministrator {
             id
             firstName
@@ -67,6 +64,7 @@ export const AuthContext = React.createContext<AuthContext | null>(null);
 export function AuthProvider({ children }: { children: React.ReactNode }) {
     const [status, setStatus] = React.useState<AuthContext['status']>('verifying');
     const [authenticationError, setAuthenticationError] = React.useState<string | undefined>();
+    const { settings, setActiveChannelId } = useUserSettings();
     const onLoginSuccessFn = React.useRef<() => void>(() => {});
     const onLogoutSuccessFn = React.useRef<() => void>(() => {});
     const isAuthenticated = status === 'authenticated';
@@ -77,10 +75,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         retry: false,
     });
 
-    const { data: administratorData, isLoading: isAdministratorLoading } = useQuery({
-        queryKey: ['administrator'],
-        queryFn: () => api.query(ActiveAdministratorQuery),
-    });
+    React.useEffect(() => {
+        if (!settings.activeChannelId && currentUserData?.me?.channels?.length) {
+            setActiveChannelId(currentUserData.me.channels[0].id);
+        }
+    }, [settings.activeChannelId, currentUserData?.me?.channels]);
 
     const loginMutationFn = api.mutate(LoginMutation);
     const loginMutation = useMutation({
@@ -141,7 +140,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
                 isAuthenticated,
                 authenticationError,
                 status,
-                user: administratorData?.activeAdministrator,
+                user: currentUserData?.activeAdministrator,
+                channels: currentUserData?.me?.channels,
                 login,
                 logout,
             }}

+ 4 - 0
packages/dashboard/src/providers/user-settings.tsx

@@ -8,6 +8,7 @@ export interface UserSettings {
     theme: Theme;
     displayUiExtensionPoints: boolean;
     mainNavExpanded: boolean;
+    activeChannelId: string;
 }
 
 const defaultSettings: UserSettings = {
@@ -17,6 +18,7 @@ const defaultSettings: UserSettings = {
     theme: 'system',
     displayUiExtensionPoints: false,
     mainNavExpanded: true,
+    activeChannelId: '',
 };
 
 interface UserSettingsContextType {
@@ -27,6 +29,7 @@ interface UserSettingsContextType {
     setTheme: (theme: Theme) => void;
     setDisplayUiExtensionPoints: (display: boolean) => void;
     setMainNavExpanded: (expanded: boolean) => void;
+    setActiveChannelId: (channelId: string) => void;
 }
 
 export const UserSettingsContext = createContext<UserSettingsContextType | undefined>(undefined);
@@ -71,6 +74,7 @@ export const UserSettingsProvider: React.FC<React.PropsWithChildren<{}>> = ({ ch
         setTheme: theme => updateSetting('theme', theme),
         setDisplayUiExtensionPoints: display => updateSetting('displayUiExtensionPoints', display),
         setMainNavExpanded: expanded => updateSetting('mainNavExpanded', expanded),
+        setActiveChannelId: channelId => updateSetting('activeChannelId', channelId),
     };
 
     return <UserSettingsContext.Provider value={contextValue}>{children}</UserSettingsContext.Provider>;

+ 6 - 3
packages/dashboard/src/routes/_authenticated/_products/products.tsx

@@ -4,6 +4,7 @@ import { createFileRoute, Link } from '@tanstack/react-router';
 import { productListDocument } from './products.graphql.js';
 import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
 import { PlusIcon } from 'lucide-react';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
 
 export const Route = createFileRoute('/_authenticated/_products/products')({
     component: ProductListPage,
@@ -36,12 +37,14 @@ export function ProductListPage() {
         >
             <PageActionBar>
                 <div></div>
-                <Button asChild>
-                    <Link to="./new">
-                        <PlusIcon className="mr-2 h-4 w-4" />
+                <PermissionGuard requires={['CreateProduct', 'CreateCatalog']}>
+                    <Button asChild>
+                        <Link to="./new">
+                            <PlusIcon className="mr-2 h-4 w-4" />
                         New Product
                     </Link>
                 </Button>
+                </PermissionGuard>
             </PageActionBar>
         </ListPage>
     );

+ 9 - 7
packages/dashboard/src/routes/_authenticated/_products/products_.$id.tsx

@@ -33,6 +33,7 @@ import { notFound } from '@tanstack/react-router';
 import { ErrorPage } from '@/components/shared/error-page.js';
 import { CreateProductVariants } from './components/create-product-variants.js';
 import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
 export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
     component: ProductDetailPage,
     loader: async ({ context, params }) => {
@@ -89,7 +90,6 @@ export function ProductDetailPage() {
             });
             form.reset();
             if (creatingNewEntity) {
-                console.log(`navigating to:`, `${data.id}`);
                 navigate({ to: `../${data.id}`, from: Route.id });
             }
         },
@@ -108,12 +108,14 @@ export function ProductDetailPage() {
                 <form onSubmit={submitHandler} className="space-y-8">
                     <PageActionBar>
                         <ContentLanguageSelector />
-                        <Button
-                            type="submit"
-                            disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
-                        >
-                            Submit
-                        </Button>
+                        <PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
+                            <Button
+                                type="submit"
+                                disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
+                            >
+                                <Trans>Update</Trans>
+                            </Button>
+                        </PermissionGuard>
                     </PageActionBar>
                     <PageLayout>
                         <PageBlock column="side">