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

feat - impl usePermissions hook and securize authorized users only #86

Merged
merged 15 commits into from
Apr 30, 2024
15 changes: 8 additions & 7 deletions .env
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
VITE_API_ENDPOINT=https://api-pilotage-enquetes.developpement.insee.fr
VITE_AUTH_TYPE=oidc
VITE_OIDC_CLIENT_ID=coleman-pilotage
VITE_OIDC_ISSUER=https://auth.insee.test/auth/realms/questionnaire-particuliers
VITE_IDENTITY_PROVIDER=insee-ssp
VITE_ADMIN_LDAP_ROLE=administrateur_Platine
VITE_USER_LDAP_ROLE=utilisateur_Platine
VITE_API_ENDPOINT=http://localhost:8000
VITE_AUTH_TYPE=anonymous
VITE_OIDC_CLIENT_ID=
VITE_OIDC_ISSUER=https://localhost:8000
VITE_IDENTITY_PROVIDER=
VITE_ADMIN_LDAP_ROLE=admin
VITE_USER_LDAP_ROLE=user
VITE_APP_URL=http://localhost:5173
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "platine-management",
"private": true,
"version": "1.0.17",
"version": "1.0.18",
"type": "module",
"scripts": {
"dev": "vite",
Expand All @@ -23,7 +23,7 @@
"@mui/material": "^5.14.10",
"@tanstack/react-query": "^5.17.15",
"date-fns": "^3.3.1",
"oidc-spa": "^4.0.0",
"oidc-spa": "^4.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.50.0",
Expand Down
26 changes: 4 additions & 22 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,16 @@
import { CircularProgress } from "@mui/material";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { routes } from "./routes";
import { useIsAuthenticated } from "./hooks/useAuth";
import { routes, unauthorizedRoutes } from "./routes";
import { useHasPermission } from "./hooks/usePermissions";
import "./App.css";
import { Row } from "./ui/Row";
import { UnauthorizedPage } from "./pages/UnauthorizedPage";

const router = createBrowserRouter(routes);
const unauthorizedRouter = createBrowserRouter(unauthorizedRoutes);

export function App() {
const { isAuthenticated } = useIsAuthenticated();

if (!isAuthenticated) {
return (
<Row justifyContent="center" py={10}>
<CircularProgress />
</Row>
);
}

return <AuthenticatedApp />;
}

function AuthenticatedApp() {
const canAccessSite = useHasPermission("ACCESS_SITE");
const canAccessSite = useHasPermission("ACCESS_APP");

if (!canAccessSite) {
// TODO : Mettre un composant Unauthorized
return <UnauthorizedPage />;
return <RouterProvider router={unauthorizedRouter} />;
}

return <RouterProvider router={router} />;
Expand Down
65 changes: 65 additions & 0 deletions src/functions/autoLogoutCountdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Typography } from "@mui/material";
import { useState, useEffect } from "react";
import { useOidc } from "../hooks/useAuth";

export function AutoLogoutCountdown() {
const { isUserLoggedIn, subscribeToAutoLogoutCountdown } = useOidc();
const [secondsLeft, setSecondsLeft] = useState<number | undefined>(undefined);

useEffect(
() => {
if (!isUserLoggedIn) {
return;
}

const { unsubscribeFromAutoLogoutCountdown } = subscribeToAutoLogoutCountdown(
({ secondsLeft }) => {
setSecondsLeft(secondsLeft === undefined || secondsLeft > 60 ? undefined : secondsLeft),
console.log(`seconds Left: ${secondsLeft}`);
},
);

return () => {
console.log("unsuscribing");
unsubscribeFromAutoLogoutCountdown();
};
},
// NOTE: These dependency array could very well be empty
// we're just making react-hooks/exhaustive-deps happy.
// Unless you're hot swapping the oidc context isUserLoggedIn
// and subscribeToAutoLogoutCountdown never change for the
// lifetime of the app.
[isUserLoggedIn, subscribeToAutoLogoutCountdown],
);

if (secondsLeft === undefined) {
return null;
}

return (
<div
// Full screen overlay, blurred background
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.5)",
backdropFilter: "blur(10px)",
display: "flex",
justifyContent: "center",
alignItems: "center",
textAlign: "center",
color: "white",
fontWeight: "bold",
zIndex: 1200,
}}
>
<div>
<Typography variant="h5">{"Vous êtes toujours là?"}</Typography>
<Typography>{`Vous allez être déconnecté(e) dans ${secondsLeft} sec.`}</Typography>
</div>
</div>
);
}
24 changes: 10 additions & 14 deletions src/functions/oidc.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { createMockReactOidc } from "oidc-spa/mock/react";
import { createReactOidc } from "oidc-spa/react";
import { Fragment } from "react";

type TokenInfo = {
inseegroupedefaut: string[];
preferred_username: string;
};

const guestUser: TokenInfo = {
inseegroupedefaut: [],
inseegroupedefaut: [import.meta.env.VITE_USER_LDAP_ROLE],
preferred_username: "Guest",
};

Expand All @@ -19,20 +19,16 @@ export const createAppOidc = () => {
issuerUri: import.meta.env.VITE_OIDC_ISSUER,
clientId: import.meta.env.VITE_OIDC_CLIENT_ID,
publicUrl: "/",
autoLogoutParams: { redirectTo: "specific url", url: `${import.meta.env.VITE_APP_URL}/logout` },
extraQueryParams: { kc_idp_hint: import.meta.env.VITE_IDENTITY_PROVIDER },
});
}

return {
OidcProvider: Fragment,
useOidc: () => ({
login: () => null,
isUserLoggedIn: true,
oidcTokens: {
decodedIdToken: guestUser,
accessToken: "accessToken",
},
logout: () => (window.location.href = "/"),
}),
};
return createMockReactOidc<TokenInfo>({
isUserInitiallyLoggedIn: true,
mockedTokens: {
decodedIdToken: guestUser,
accessToken: "accessToken",
},
});
};
8 changes: 6 additions & 2 deletions src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { createAppOidc } from "../functions/oidc.ts";

const { OidcProvider, useOidc } = createAppOidc();
export const { OidcProvider, prOidc, useOidc } = createAppOidc();

export const useHasRole = (role: string): boolean => {
const { oidcTokens } = useOidc({ assertUserLoggedIn: true });
Expand All @@ -16,8 +16,12 @@ export const useUser = () => {
return useOidc({ assertUserLoggedIn: true }).oidcTokens.decodedIdToken;
};

export const useMaybeUser = () => {
return useOidc({ assertUserLoggedIn: false }).oidcTokens?.decodedIdToken;
};

export const useLogout = () => {
return useOidc({ assertUserLoggedIn: true }).logout;
return useOidc({ assertUserLoggedIn: false }).logout;
};

export function useIsAuthenticated() {
Expand Down
9 changes: 5 additions & 4 deletions src/hooks/usePermissions.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { useUser } from "./useAuth";
import { useMaybeUser, useUser } from "./useAuth";

type User = ReturnType<typeof useUser>;
type PermissionRequirement = string[] | ((user: User) => boolean);

const permissions = {
ACCESS_SITE: ["admin", "user"],
ACCESS_APP: [import.meta.env.VITE_ADMIN_LDAP_ROLE, import.meta.env.VITE_USER_LDAP_ROLE],
ACCESS_SETTINGS: [import.meta.env.VITE_ADMIN_LDAP_ROLE],
EDIT_PAGE: ["admin", "user"],
READ_PAGE: ["user"],
DELETE_SITE: (user: User) => user.preferred_username === "admin",
} satisfies Record<string, PermissionRequirement>;

export const useHasPermission = (permissionKey: keyof typeof permissions) => {
const user = useUser();
const user = useMaybeUser();
const permission = permissions[permissionKey];

// For unknown permission, refuse access by default
if (!permission) {
if (!permission || !user) {
return false;
}

Expand Down
2 changes: 2 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PlatineTheme } from "./theme.tsx";
import { StrictMode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "./hooks/useAuth.ts";
import { AutoLogoutCountdown } from "./functions/autoLogoutCountdown.tsx";

const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -18,6 +19,7 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById("root")!).render(
<StrictMode>
<AuthProvider>
<AutoLogoutCountdown />
<QueryClientProvider client={queryClient}>
<PlatineTheme>
<App />
Expand Down
44 changes: 44 additions & 0 deletions src/pages/Logout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Stack, Typography, Box, Link } from "@mui/material";
import { Row } from "../ui/Row";
import { PropsWithChildren } from "react";
import { Link as RouterLink } from "react-router-dom";

export function LogoutPage() {
return (
<>
<Stack
position="relative"
sx={{
background: "linear-gradient(270deg, #21005D 0%, #9A82DB 0%, #E12358 90%)",
}}
justifyContent="center"
alignItems="center"
minHeight={500}
height="calc(100vh - 230px)"
>
<Typography variant="displaySmall" fontWeight={400} color="white">
{"Vous avez été deconnecté,"}
</Typography>
<Row spacing={2}>
<Typography variant="displaySmall" fontWeight={400} color="white">
{"pour revenir sur "}
</Typography>
<Row typography="headlineMedium" gap={0.25} color="red.main" component="span">
<Box component="span" color="black.main" fontWeight={600}>
Platine
</Box>
Gestion
</Row>
,
</Row>
<Typography variant="displaySmall" fontWeight={400} color="white" component={HomeLink}>
{"cliquez ici"}
</Typography>
</Stack>
</>
);
}

const HomeLink = (props: PropsWithChildren) => {
return <Link component={RouterLink} underline="none" to="/" {...props} />;
};
14 changes: 12 additions & 2 deletions src/pages/Search/SearchSurveys.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box, CardActionArea, Stack } from "@mui/material";
import { Box, CardActionArea, CircularProgress, Stack } from "@mui/material";
import { FilterListBySelector } from "../../ui/Search/FilterListBySelector.tsx";
import { Row } from "../../ui/Row.tsx";
import { useInfiniteFetchQuery } from "../../hooks/useFetchQuery.ts";
Expand All @@ -23,6 +23,7 @@ export const SearchSurveys = () => {
results: surveys,
hasNextPage,
fetchNextPage,
isLoading,
} = useInfiniteFetchQuery(endpoint, {
query: useSearchFilterParams("surveys"),
});
Expand All @@ -40,7 +41,16 @@ export const SearchSurveys = () => {
</ToggleButtonGroup>
<FilterListBySelector />
</Row>

{isLoading && (
<Row justifyContent={"space-around"} height={"100%"}>
<CircularProgress />
</Row>
)}
{!isLoading && surveys.length === 0 && (
<Row justifyContent={"space-around"} height={"100%"}>
<Typography variant="titleMedium">Aucun résultat</Typography>
</Row>
)}
<CardGrid>
{surveys.map(s => (
<div key={s.id}>
Expand Down
17 changes: 13 additions & 4 deletions src/pages/UnauthorizedPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Stack, Typography } from "@mui/material";
import { Box, Stack, Typography } from "@mui/material";
import { Row } from "../ui/Row";

export function UnauthorizedPage() {
return (
Expand All @@ -13,9 +14,17 @@ export function UnauthorizedPage() {
minHeight={500}
height="calc(100vh - 230px)"
>
<Typography variant="displaySmall" fontWeight={400} color="white">
Vous n'avez pas les droits nécessaires pour accéder à Platine Gestion
</Typography>
<Row spacing={2}>
<Typography variant="displaySmall" fontWeight={400} color="white">
{"Vous n'êtes pas autorisé à accéder à "}
</Typography>
<Row typography="headlineMedium" gap={0.25} color="red.main" component="span">
<Box component="span" color="black.main" fontWeight={600}>
Platine
</Box>
Gestion
</Row>
</Row>
</Stack>
</>
);
Expand Down
Loading
Loading