Skip to content

Commit

Permalink
Merge pull request #86 from InseeFr/manage-unauthorized-users
Browse files Browse the repository at this point in the history
feat - impl usePermissions hook and securize authorized users only
  • Loading branch information
EricThuaud authored Apr 30, 2024
2 parents 1af5b82 + e5548ed commit 84a2c0d
Show file tree
Hide file tree
Showing 16 changed files with 349 additions and 91 deletions.
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

0 comments on commit 84a2c0d

Please sign in to comment.