diff --git a/.env b/.env index e7f70a2..31a4f9b 100644 --- a/.env +++ b/.env @@ -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 \ No newline at end of file +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 diff --git a/package.json b/package.json index f528466..7cf5876 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "platine-management", "private": true, - "version": "1.0.17", + "version": "1.0.18", "type": "module", "scripts": { "dev": "vite", @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 2676973..6e29e97 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( - - - - ); - } - - return ; -} - -function AuthenticatedApp() { - const canAccessSite = useHasPermission("ACCESS_SITE"); + const canAccessSite = useHasPermission("ACCESS_APP"); if (!canAccessSite) { - // TODO : Mettre un composant Unauthorized - return ; + return ; } return ; diff --git a/src/functions/autoLogoutCountdown.tsx b/src/functions/autoLogoutCountdown.tsx new file mode 100644 index 0000000..ffee7bb --- /dev/null +++ b/src/functions/autoLogoutCountdown.tsx @@ -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(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 ( +
+
+ {"Vous êtes toujours là?"} + {`Vous allez être déconnecté(e) dans ${secondsLeft} sec.`} +
+
+ ); +} diff --git a/src/functions/oidc.ts b/src/functions/oidc.ts index 0ff8112..ef143c2 100644 --- a/src/functions/oidc.ts +++ b/src/functions/oidc.ts @@ -1,5 +1,5 @@ +import { createMockReactOidc } from "oidc-spa/mock/react"; import { createReactOidc } from "oidc-spa/react"; -import { Fragment } from "react"; type TokenInfo = { inseegroupedefaut: string[]; @@ -7,7 +7,7 @@ type TokenInfo = { }; const guestUser: TokenInfo = { - inseegroupedefaut: [], + inseegroupedefaut: [import.meta.env.VITE_USER_LDAP_ROLE], preferred_username: "Guest", }; @@ -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({ + isUserInitiallyLoggedIn: true, + mockedTokens: { + decodedIdToken: guestUser, + accessToken: "accessToken", + }, + }); }; diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 6998d71..215c49b 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -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 }); @@ -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() { diff --git a/src/hooks/usePermissions.ts b/src/hooks/usePermissions.ts index e6a5059..9d06f82 100644 --- a/src/hooks/usePermissions.ts +++ b/src/hooks/usePermissions.ts @@ -1,21 +1,22 @@ -import { useUser } from "./useAuth"; +import { useMaybeUser, useUser } from "./useAuth"; type User = ReturnType; 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; 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; } diff --git a/src/main.tsx b/src/main.tsx index df58f51..6ff0ae0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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: { @@ -18,6 +19,7 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById("root")!).render( + diff --git a/src/pages/Logout.tsx b/src/pages/Logout.tsx new file mode 100644 index 0000000..ded8915 --- /dev/null +++ b/src/pages/Logout.tsx @@ -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 ( + <> + + + {"Vous avez été deconnecté,"} + + + + {"pour revenir sur "} + + + + Platine + + Gestion + + , + + + {"cliquez ici"} + + + + ); +} + +const HomeLink = (props: PropsWithChildren) => { + return ; +}; diff --git a/src/pages/Search/SearchSurveys.tsx b/src/pages/Search/SearchSurveys.tsx index 7f51f20..e35678a 100644 --- a/src/pages/Search/SearchSurveys.tsx +++ b/src/pages/Search/SearchSurveys.tsx @@ -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"; @@ -23,6 +23,7 @@ export const SearchSurveys = () => { results: surveys, hasNextPage, fetchNextPage, + isLoading, } = useInfiniteFetchQuery(endpoint, { query: useSearchFilterParams("surveys"), }); @@ -40,7 +41,16 @@ export const SearchSurveys = () => { - + {isLoading && ( + + + + )} + {!isLoading && surveys.length === 0 && ( + + Aucun résultat + + )} {surveys.map(s => (
diff --git a/src/pages/UnauthorizedPage.tsx b/src/pages/UnauthorizedPage.tsx index 50fdf52..c3a98b8 100644 --- a/src/pages/UnauthorizedPage.tsx +++ b/src/pages/UnauthorizedPage.tsx @@ -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 ( @@ -13,9 +14,17 @@ export function UnauthorizedPage() { minHeight={500} height="calc(100vh - 230px)" > - - Vous n'avez pas les droits nécessaires pour accéder à Platine Gestion - + + + {"Vous n'êtes pas autorisé à accéder à "} + + + + Platine + + Gestion + + ); diff --git a/src/routes.tsx b/src/routes.tsx index deae304..fbe5ecc 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,7 +1,7 @@ import { SearchPage } from "./pages/SearchPage"; import { Home } from "./pages/Home.tsx"; import { Outlet, redirect, RouteObject } from "react-router-dom"; -import { Layout } from "./ui/Layout"; +import { Layout, LayoutWithAuth } from "./ui/Layout"; import { PageError } from "./ui/PageError"; import { SurveyPage } from "./pages/SurveyPage"; import { ContactPage } from "./pages/ContactPage"; @@ -11,15 +11,25 @@ import { SearchSurveys } from "./pages/Search/SearchSurveys.tsx"; import { SearchSurveyUnits } from "./pages/Search/SearchSurveyUnits.tsx"; import { SurveyUnitPage } from "./pages/SurveyUnitPage.tsx"; import { CreateContactPage } from "./pages/CreateContactPage.tsx"; +import { UnauthorizedPage } from "./pages/UnauthorizedPage.tsx"; +import { LogoutPage } from "./pages/Logout.tsx"; export const routes: RouteObject[] = [ { - path: "/", + path: "logout", element: ( - + ), + }, + { + path: "/", + element: ( + + + + ), errorElement: , children: [ { @@ -41,3 +51,39 @@ export const routes: RouteObject[] = [ ], }, ]; + +export const unauthorizedRoutes: RouteObject[] = [ + { + path: "/logout", + element: ( + + + + ), + }, + { + path: "/*", + element: ( + + + + ), + errorElement: , + }, +]; + +/* export const unauthenticatedRoutes: RouteObject[] = [ + { + path: "/", + element: ( + + + + ), + errorElement: , + children: [ + { path: "logout", element: }, + { path: "", element: }, + ], + }, +]; */ diff --git a/src/ui/Header.tsx b/src/ui/Header.tsx index ace24c5..fed957b 100644 --- a/src/ui/Header.tsx +++ b/src/ui/Header.tsx @@ -4,12 +4,14 @@ import ExitToAppIcon from "@mui/icons-material/ExitToApp"; import { Link as RouterLink } from "react-router-dom"; import { Row } from "./Row.tsx"; import { PropsWithChildren } from "react"; -import { useUser, useLogout } from "../hooks/useAuth.ts"; +import { useMaybeUser, useLogout } from "../hooks/useAuth.ts"; import packageInfo from "../../package.json"; +import { useHasPermission } from "../hooks/usePermissions.ts"; export function Header() { - const { preferred_username } = useUser(); + const user = useMaybeUser(); const logout = useLogout(); + const activeSettings = useHasPermission("ACCESS_SETTINGS"); return ( @@ -28,20 +30,24 @@ export function Header() { - {preferred_username} - - - - - logout({ - redirectTo: "specific url", - url: "", - }) - } - > - - + {user?.preferred_username} + {activeSettings && ( + + + + )} + {logout && ( + + logout({ + redirectTo: "specific url", + url: `${import.meta.env.VITE_APP_URL}/logout`, + }) + } + > + + + )} ); diff --git a/src/ui/Layout.tsx b/src/ui/Layout.tsx index 99256bd..7ebe8b4 100644 --- a/src/ui/Layout.tsx +++ b/src/ui/Layout.tsx @@ -1,5 +1,8 @@ import { type PropsWithChildren } from "react"; import { Header } from "./Header"; +import { useIsAuthenticated } from "../hooks/useAuth"; +import { CircularProgress } from "@mui/material"; +import { Row } from "../ui/Row"; export function Layout({ children }: PropsWithChildren) { return ( @@ -9,3 +12,22 @@ export function Layout({ children }: PropsWithChildren) { ); } + +export function LayoutWithAuth({ children }: PropsWithChildren) { + const { isAuthenticated } = useIsAuthenticated(); + + if (!isAuthenticated) { + return ( + + + + ); + } + + return ( + <> +
+ {children} + + ); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 4c741ad..ee62647 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,14 +1,40 @@ -/// -interface ImportMetaEnv { - readonly VITE_API_ENDPOINT: string; - readonly VITE_AUTH_TYPE: string; - readonly VITE_OIDC_CLIENT_ID: string; - readonly VITE_OIDC_ISSUER: string; - readonly VITE_IDENTITY_PROVIDER: string; +/// +type ImportMetaEnv = { + // Auto-generated by `npx vite-envs update-types` and hot-reloaded by the `vite-env` plugin + VITE_API_ENDPOINT: string + VITE_AUTH_TYPE: string + VITE_OIDC_CLIENT_ID: string + VITE_OIDC_ISSUER: string + VITE_IDENTITY_PROVIDER: string + VITE_ADMIN_LDAP_ROLE: string + VITE_USER_LDAP_ROLE: string + VITE_APP_URL: string + BASE_URL: string + MODE: string + DEV: boolean + PROD: boolean + // @user-defined-start + /* + * Here you can define your own special variables + * that would be available on `import.meta.env` but + * that vite-envs does not know about. + * This section will be preserved thanks to the special comments. + * Example: + */ + SSR: boolean; + // @user-defined-end } interface ImportMeta { - readonly env: ImportMetaEnv; + // Auto-generated by `npx vite-envs update-types` + + url: string + + readonly hot?: import('vite-envs/types/hot').ViteHotContext + + readonly env: ImportMetaEnv + + glob: import('vite-envs/types/importGlob').ImportGlobFunction } type ObjectKeys = T extends object diff --git a/yarn.lock b/yarn.lock index de52dbd..1a2a210 100644 --- a/yarn.lock +++ b/yarn.lock @@ -227,6 +227,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.23.8", "@babel/runtime@^7.24.4": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.4.tgz#de795accd698007a66ba44add6cc86542aff1edd" + integrity sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.22.15": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38" @@ -1626,6 +1633,14 @@ fast-shallow-equal@^1.0.0: resolved "https://registry.yarnpkg.com/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz#d4dcaf6472440dcefa6f88b98e3251e27f25628b" integrity sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw== +fast-unique-numbers@^8.0.13: + version "8.0.13" + resolved "https://registry.yarnpkg.com/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz#3c87232061ff5f408a216e1f0121232f76f695d7" + integrity sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g== + dependencies: + "@babel/runtime" "^7.23.8" + tslib "^2.6.2" + fastest-stable-stringify@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz#3757a6774f6ec8de40c4e86ec28ea02417214c76" @@ -2376,7 +2391,7 @@ object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== -oidc-client-ts@^2.3.0: +oidc-client-ts@2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-2.4.0.tgz#764c8a33de542026e2798de9849ce8049047d7e5" integrity sha512-WijhkTrlXK2VvgGoakWJiBdfIsVGz6CFzgjNNqZU1hPKV2kyeEaJgLs7RwuiSp2WhLfWBQuLvr2SxVlZnk3N1w== @@ -2384,14 +2399,15 @@ oidc-client-ts@^2.3.0: crypto-js "^4.2.0" jwt-decode "^3.1.2" -oidc-spa@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/oidc-spa/-/oidc-spa-4.0.0.tgz#7cec1683686cd6f2b9c85547107b19b6b54ffd39" - integrity sha512-Zy386BfS/LbkUopNTA1xFCWYIMc7fFrQ1Iyc58vt+2dAezTAt15F6YTrYGXGCa4P0q8SpO6Avu8/OmHyO67d9w== +oidc-spa@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/oidc-spa/-/oidc-spa-4.6.0.tgz#3482e14c405b0ef9c7673ae7be4c1928d0b06a65" + integrity sha512-a1y+PSBtlhzyKJ7ucGx9Rp+aJhV2cL36rmO/imIHkfb4ig/5xOQtzl9Ee9hRbAIrwVAu/lpR9PfUXTq2bLrG2Q== dependencies: jwt-decode "^3.1.2" - oidc-client-ts "^2.3.0" + oidc-client-ts "2.4.0" tsafe "^1.6.5" + worker-timers "^7.1.7" once@^1.3.0: version "1.4.0" @@ -2926,7 +2942,7 @@ tsafe@^1.6.5, tsafe@^1.6.6: resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.6.6.tgz#fd93e64d6eb13ef83ed1650669cc24bad4f5df9f" integrity sha512-gzkapsdbMNwBnTIjgO758GujLCj031IgHK/PKr2mrmkCSJMhSOR5FeOuSxKLMUoYc0vAA4RGEYYbjt/v6afD3g== -tslib@^2.1.0: +tslib@^2.1.0, tslib@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== @@ -3065,6 +3081,34 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +worker-timers-broker@^6.1.7: + version "6.1.7" + resolved "https://registry.yarnpkg.com/worker-timers-broker/-/worker-timers-broker-6.1.7.tgz#c570552f1576f81226dbeb0dc14bb64144c9597c" + integrity sha512-8hb4lSMAijDY/Dp/MOw9Hc2x6uU59XWFYjcWQgC4bai+sxcLXjeexd9aYKdYMFZPiPoieGzMYIs9WGpv2Co3eA== + dependencies: + "@babel/runtime" "^7.24.4" + fast-unique-numbers "^8.0.13" + tslib "^2.6.2" + worker-timers-worker "^7.0.70" + +worker-timers-worker@^7.0.70: + version "7.0.70" + resolved "https://registry.yarnpkg.com/worker-timers-worker/-/worker-timers-worker-7.0.70.tgz#064758e1ca3bed908950d152e3e3006595dd9600" + integrity sha512-lemWEME0RHB78hzGkkQcKfF6L82gqVhV3T9iY14jHBhbLxLq9t1RRCLmPDBZV7sdnUoW6Khkfn6coqPjgEK6cw== + dependencies: + "@babel/runtime" "^7.24.4" + tslib "^2.6.2" + +worker-timers@^7.1.7: + version "7.1.7" + resolved "https://registry.yarnpkg.com/worker-timers/-/worker-timers-7.1.7.tgz#1119f6454cb7c8653097ffb66f592115fb6693ea" + integrity sha512-Dr4La61d94SjOA8P57h2LN8W3MXOVe/m1P7jER8cmuIy+JaDMqPttSwo6QRJFSK6YnG9cD6SU7J8m7CVlu8jlw== + dependencies: + "@babel/runtime" "^7.24.4" + tslib "^2.6.2" + worker-timers-broker "^6.1.7" + worker-timers-worker "^7.0.70" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"