Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: layout and add access right management #642

Merged
merged 5 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/@types/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LanguageType } from "../utils";
import { LanguageType } from "@/utils";
import {
AccessCreateDto,
AccessDetailsResponseDto,
Expand Down
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
83 changes: 83 additions & 0 deletions assets/components/Layout/CommunityLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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";
import { canUserAccess } from "@/utils";

export interface CommunityLayoutProps extends Omit<DatastoreLayoutProps, "datastoreId"> {
accessRight?: CommunityMemberDtoRightsEnum | 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(() => {
if (!user?.id || user?.communities_member) {
return false;
}
const communityMember = user.communities_member.find((member) => member.community?._id === communityId);
if (!communityMember) {
return false; // is not part of the community
}
return canUserAccess(user.id, communityMember, 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);
59 changes: 45 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,62 @@ 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";
import { canUserAccess } from "@/utils";

type DatastoreLayoutProps = {
export interface DatastoreLayoutProps extends Omit<AppLayoutProps, "navItems"> {
accessRight?: CommunityMemberDtoRightsEnum | 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(() => {
if (!user?.id || user?.communities_member) {
return false;
}
const communityMember = user?.communities_member.find((member) => member.community?.datastore === datastoreId);
if (!communityMember) {
return false; // is not part of the community
}
return canUserAccess(user.id, communityMember, 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