Skip to content

Commit

Permalink
refactor: layout and add access right management
Browse files Browse the repository at this point in the history
  • Loading branch information
tonai committed Feb 3, 2025
1 parent ab91714 commit 5095b5e
Show file tree
Hide file tree
Showing 64 changed files with 1,106 additions and 832 deletions.
4 changes: 2 additions & 2 deletions assets/components/Layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { FC, memo } from "react";

// import { useLang } from "../../i18n/i18n";
import SymfonyRouting from "../../modules/Routing";
import { catalogueUrl, publicRoutes, routes, useRoute } from "../../router/router";
import { catalogueUrl, groups, routes, useRoute } from "../../router/router";
import { useAuthStore } from "../../stores/AuthStore";
// import LanguageSelector from "../Utils/LanguageSelector";

Expand Down Expand Up @@ -58,7 +58,7 @@ const AppHeader: FC<AppHeaderProps> = ({ navItems = [] }) => {
});
} else {
// utilisateur est connecté
if (route.name === false || publicRoutes.includes(route.name)) {
if (route.name === false || groups.public.has(route)) {
// on garde le lien vers le géoportail sur les pages également accessibles publiquement
quickAccessItems.push(geoportailQuickAccessItem);
} else {
Expand Down
62 changes: 8 additions & 54 deletions assets/components/Layout/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import { fr } from "@codegouvfr/react-dsfr";
import { Breadcrumb, BreadcrumbProps } from "@codegouvfr/react-dsfr/Breadcrumb";
import { MainNavigationProps } from "@codegouvfr/react-dsfr/MainNavigation";
import { Notice, addNoticeTranslations } from "@codegouvfr/react-dsfr/Notice";
import { addNoticeTranslations } from "@codegouvfr/react-dsfr/Notice";
import { SkipLinks } from "@codegouvfr/react-dsfr/SkipLinks";
import { useQuery } from "@tanstack/react-query";
import { FC, PropsWithChildren, ReactNode, memo, useMemo } from "react";
import { FC, PropsWithChildren, memo, useMemo } from "react";

import { ConsentBannerAndConsentManagement } from "../../config/consentManagement";
import { defaultNavItems } from "../../config/navItems/navItems";
import api from "../../entrepot/api";
import useDocumentTitle from "../../hooks/useDocumentTitle";
import { useTranslation } from "../../i18n/i18n";
import RQKeys from "../../modules/entrepot/RQKeys";
import getBreadcrumb from "../../modules/entrepot/breadcrumbs/Breadcrumb";
import { useRoute } from "../../router/router";
import SessionExpiredAlert from "../Utils/SessionExpiredAlert";
import SnackbarMessage from "../Utils/SnackbarMessage";
import AppFooter from "./AppFooter";
import AppHeader from "./AppHeader";
Expand All @@ -41,56 +32,19 @@ const HiddenElements: FC = () => {

const HiddenElementsMemoized = memo(HiddenElements);

type AppLayoutProps = {
export interface AppLayoutProps {
navItems?: MainNavigationProps.Item[];
documentTitle?: string;
customBreadcrumbProps?: BreadcrumbProps;
infoBannerMsg?: ReactNode;
};
}

const AppLayout: FC<PropsWithChildren<AppLayoutProps>> = ({ children, navItems, documentTitle, customBreadcrumbProps, infoBannerMsg }) => {
useDocumentTitle(documentTitle);
const AppLayout: FC<PropsWithChildren<AppLayoutProps>> = ({ children, navItems }) => {
const { t } = useTranslation("navItems");

const route = useRoute();

const datastoreQuery = useQuery({
// @ts-expect-error fausse alerte
queryKey: RQKeys.datastore(route.params.datastoreId),
// @ts-expect-error fausse alerte
queryFn: ({ signal }) => api.datastore.get(route.params.datastoreId, { signal }),
staleTime: 3600000,
enabled: "datastoreId" in route.params,
});

const breadcrumbProps = useMemo(() => {
if (customBreadcrumbProps !== undefined) {
return customBreadcrumbProps;
}

return getBreadcrumb(route, datastoreQuery.data);
}, [route, datastoreQuery.data, customBreadcrumbProps]);

navItems = useMemo(() => navItems ?? defaultNavItems(t), [navItems, t]);
const nav = useMemo(() => navItems ?? defaultNavItems(t), [navItems, t]);

return (
<>
<HiddenElementsMemoized />
<AppHeader navItems={navItems} />
<main id="main" role="main">
{/* doit être le premier élément atteignable après le lien d'évitement (Accessibilité) : https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/bandeau-d-information-importante */}
{infoBannerMsg && <Notice title={infoBannerMsg} isClosable={true} />}

<div className={fr.cx("fr-container", "fr-my-2w")}>
{breadcrumbProps && <Breadcrumb {...breadcrumbProps} />}

<div className={fr.cx("fr-mb-4v")}>
<SessionExpiredAlert />
</div>

{children}
</div>
</main>
<AppHeader navItems={nav} />
{children}
<AppFooter />
<SnackbarMessage />
</>
Expand Down
81 changes: 81 additions & 0 deletions assets/components/Layout/CommunityLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useQuery } from "@tanstack/react-query";
import { FC, PropsWithChildren, memo, useMemo } from "react";

import api from "../../entrepot/api";
import RQKeys from "../../modules/entrepot/RQKeys";
import { CartesApiException } from "../../modules/jsonFetch";
import { DatastoreLayoutProps } from "./DatastoreLayout";
import { useAuthStore } from "../../stores/AuthStore";
import { CommunityDetailResponseDto, CommunityMemberDtoRightsEnum } from "../../@types/entrepot";
import Forbidden from "../../pages/error/Forbidden";
import { CommunityProvider } from "../../contexts/community";
import { Datastore } from "../../@types/app";
import { datastoreNavItems } from "../../config/navItems/datastoreNavItems";
import AppLayout from "./AppLayout";
import PageNotFoundWithLayout from "../../pages/error/PageNotFoundWithLayout";
import Main from "./Main";
import LoadingText from "../Utils/LoadingText";
import { DatastoreProvider } from "../../contexts/datastore";

export interface CommunityLayoutProps extends Omit<DatastoreLayoutProps, "datastoreId"> {
accessRight?: CommunityMemberDtoRightsEnum;
communityId: string;
}

const CommunityLayout: FC<PropsWithChildren<CommunityLayoutProps>> = (props) => {
const { accessRight, children, communityId, ...rest } = props;

const { user } = useAuthStore();
const { data, error, failureReason, isFetching, isLoading, status } = useQuery<[CommunityDetailResponseDto, Datastore | undefined], CartesApiException>({
queryKey: RQKeys.community(communityId),
queryFn: async ({ signal }) => {
const community = await api.community.get(communityId, { signal });
let datastore: Datastore | undefined;
if (community.datastore?._id) {
datastore = await api.datastore.get(community.datastore?._id, { signal });
}
return [community, datastore];
},
staleTime: 20000,
enabled: !!communityId,
});

const [community, datastore] = data ?? [];
const navItems = useMemo(() => datastoreNavItems(datastore), [datastore]);

const isAuthorized = useMemo(() => {
const communityMember = user?.communities_member.find((member) => member.community?._id === communityId);
if (!communityMember) {
return false; // is not part of the community
}
const { community, rights } = communityMember;
const isSupervisor = community?.supervisor === user?.id;
return isSupervisor || !accessRight || rights?.includes(accessRight);
}, [accessRight, user?.communities_member, communityId, user?.id]);

if (isLoading) {
return (
<AppLayout {...rest}>
<Main>
<LoadingText withSpinnerIcon />
</Main>
</AppLayout>
);
}

if (error?.code === 404 || failureReason?.code === 404 || !community) {
return <PageNotFoundWithLayout />;
}

return (
<AppLayout {...rest} navItems={navItems}>
<CommunityProvider community={community}>
<DatastoreProvider datastore={datastore} isFetching={isFetching} status={status}>
{isAuthorized ? children : <Forbidden />}
</DatastoreProvider>
</CommunityProvider>
</AppLayout>
);
};

export default memo(CommunityLayout);
57 changes: 43 additions & 14 deletions assets/components/Layout/DatastoreLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { BreadcrumbProps } from "@codegouvfr/react-dsfr/Breadcrumb";
import { useQuery } from "@tanstack/react-query";
import { FC, PropsWithChildren, memo, useMemo } from "react";

Expand All @@ -7,30 +6,60 @@ import { datastoreNavItems } from "../../config/navItems/datastoreNavItems";
import api from "../../entrepot/api";
import RQKeys from "../../modules/entrepot/RQKeys";
import { CartesApiException } from "../../modules/jsonFetch";
import PageNotFound from "../../pages/error/PageNotFound";
import AppLayout from "./AppLayout";
import AppLayout, { AppLayoutProps } from "./AppLayout";
import { DatastoreProvider } from "../../contexts/datastore";
import LoadingText from "../Utils/LoadingText";
import PageNotFoundWithLayout from "../../pages/error/PageNotFoundWithLayout";
import { useAuthStore } from "../../stores/AuthStore";
import Main from "./Main";
import { CommunityMemberDtoRightsEnum } from "../../@types/entrepot";
import Forbidden from "../../pages/error/Forbidden";

type DatastoreLayoutProps = {
export interface DatastoreLayoutProps extends Omit<AppLayoutProps, "navItems"> {
accessRight?: CommunityMemberDtoRightsEnum;
datastoreId: string;
documentTitle?: string;
customBreadcrumbProps?: BreadcrumbProps;
};
const DatastoreLayout: FC<PropsWithChildren<DatastoreLayoutProps>> = ({ datastoreId, documentTitle, customBreadcrumbProps, children }) => {
const datastoreQuery = useQuery<Datastore, CartesApiException>({
}
const DatastoreLayout: FC<PropsWithChildren<DatastoreLayoutProps>> = (props) => {
const { accessRight, datastoreId, children, ...rest } = props;

const { user } = useAuthStore();
const { data, error, failureReason, isFetching, isLoading, status } = useQuery<Datastore, CartesApiException>({
queryKey: RQKeys.datastore(datastoreId),
queryFn: ({ signal }) => api.datastore.get(datastoreId, { signal }),
staleTime: 3600000,
});

const navItems = useMemo(() => datastoreNavItems(datastoreQuery.data), [datastoreQuery.data]);
const navItems = useMemo(() => datastoreNavItems(data), [data]);

const isAuthorized = useMemo(() => {
const communityMember = user?.communities_member.find((member) => member.community?.datastore === datastoreId);
if (!communityMember) {
return false; // is not part of the community
}
const { community, rights } = communityMember;
const isSupervisor = community?.supervisor === user?.id;
return isSupervisor || !accessRight || rights?.includes(accessRight);
}, [accessRight, user?.communities_member, datastoreId, user?.id]);

if (isLoading) {
return (
<AppLayout {...rest}>
<Main>
<LoadingText withSpinnerIcon />
</Main>
</AppLayout>
);
}

if (datastoreQuery?.error?.code === 404 || datastoreQuery.failureReason?.code === 404) {
return <PageNotFound />;
if (error?.code === 404 || failureReason?.code === 404 || !data) {
return <PageNotFoundWithLayout />;
}

return (
<AppLayout navItems={navItems} documentTitle={documentTitle} customBreadcrumbProps={customBreadcrumbProps}>
{children}
<AppLayout {...rest} navItems={navItems}>
<DatastoreProvider datastore={data} isFetching={isFetching} status={status}>
{isAuthorized ? children : <Forbidden />}
</DatastoreProvider>
</AppLayout>
);
};
Expand Down
50 changes: 50 additions & 0 deletions assets/components/Layout/Main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { fr } from "@codegouvfr/react-dsfr";
import { Breadcrumb, BreadcrumbProps } from "@codegouvfr/react-dsfr/Breadcrumb";
import { Notice } from "@codegouvfr/react-dsfr/Notice";
import { PropsWithChildren, ReactNode, memo, useContext, useMemo } from "react";

import getBreadcrumb from "../../modules/entrepot/breadcrumbs/Breadcrumb";
import { useRoute } from "../../router/router";
import SessionExpiredAlert from "../Utils/SessionExpiredAlert";
import useDocumentTitle from "../../hooks/useDocumentTitle";
import { datastoreContext } from "../../contexts/datastore";

export interface MainProps {
customBreadcrumbProps?: BreadcrumbProps;
infoBannerMsg?: ReactNode;
title?: string;
}

function Main(props: PropsWithChildren<MainProps>) {
const { children, customBreadcrumbProps, infoBannerMsg, title } = props;
const route = useRoute();
useDocumentTitle(title);
const datastoreValue = useContext(datastoreContext);

const breadcrumbProps = useMemo(() => {
if (customBreadcrumbProps !== undefined) {
return customBreadcrumbProps;
}

return getBreadcrumb(route, datastoreValue.datastore);
}, [route, datastoreValue.datastore, customBreadcrumbProps]);

return (
<main id="main" role="main">
{/* doit être le premier élément atteignable après le lien d'évitement (Accessibilité) : https://www.systeme-de-design.gouv.fr/elements-d-interface/composants/bandeau-d-information-importante */}
{infoBannerMsg && <Notice title={infoBannerMsg} isClosable={true} />}

<div className={fr.cx("fr-container", "fr-my-2w")}>
{breadcrumbProps && <Breadcrumb {...breadcrumbProps} />}

<div className={fr.cx("fr-mb-4v")}>
<SessionExpiredAlert />
</div>

{children}
</div>
</main>
);
}

export default memo(Main);
23 changes: 13 additions & 10 deletions assets/components/Utils/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@ import { ErrorBoundary as BaseErrorBoundary, type FallbackProps } from "react-er

import { routes } from "../../router/router";
import AppLayout from "../Layout/AppLayout";
import Main from "../Layout/Main";

const Fallback: FC<FallbackProps> = ({ error, resetErrorBoundary }) => {
return (
<AppLayout documentTitle="Une erreur est survenue">
<Alert severity="error" title="Une erreur est survenue" description={error?.message} className={fr.cx("fr-my-3w")} />
<Button
onClick={() => {
resetErrorBoundary();
routes.home().push();
}}
>
{"Retour à l'accueil"}
</Button>
<AppLayout>
<Main title="Une erreur est survenue">
<Alert severity="error" title="Une erreur est survenue" description={error?.message} className={fr.cx("fr-my-3w")} />
<Button
onClick={() => {
resetErrorBoundary();
routes.home().push();
}}
>
{"Retour à l'accueil"}
</Button>
</Main>
</AppLayout>
);
};
Expand Down
16 changes: 16 additions & 0 deletions assets/contexts/community.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createContext, ReactNode, useContext } from "react";
import { CommunityDetailResponseDto } from "../@types/entrepot";

export const communityContext = createContext<CommunityDetailResponseDto | null>(null);

export function useCommunity() {
const community = useContext(communityContext);
if (!community) {
throw new Error("useCommunity must be used within a CommunityProvider");
}
return community;
}

export function CommunityProvider({ children, community }: { children: ReactNode; community: CommunityDetailResponseDto }) {
return <communityContext.Provider value={community}>{children}</communityContext.Provider>;
}
Loading

0 comments on commit 5095b5e

Please sign in to comment.