diff --git a/back/src/__tests__/auth.integration.ts b/back/src/__tests__/auth.integration.ts index f3bc4844cf..78da1198c8 100644 --- a/back/src/__tests__/auth.integration.ts +++ b/back/src/__tests__/auth.integration.ts @@ -35,7 +35,7 @@ describe("POST /login", () => { // should redirect to / expect(login.status).toBe(302); - expect(login.header.location).toBe(`http://${UI_HOST}/`); + expect(login.header.location).toBe(`http://${UI_HOST}/dashboard`); const cookieValue = sessionCookie.match(cookieRegExp)[1]; diff --git a/back/src/__tests__/captcha.integration.ts b/back/src/__tests__/captcha.integration.ts index d8ec9737ee..636de291a9 100644 --- a/back/src/__tests__/captcha.integration.ts +++ b/back/src/__tests__/captcha.integration.ts @@ -187,7 +187,7 @@ describe("POST /login", () => { // should redirect to / expect(login.status).toBe(302); - expect(login.header.location).toBe(`http://${UI_HOST}/`); + expect(login.header.location).toBe(`http://${UI_HOST}/dashboard`); const cookieValue = sessionCookie.match(cookieRegExp)[1]; diff --git a/back/src/routers/auth-router.ts b/back/src/routers/auth-router.ts index a99ff2431f..2f9e15fa99 100644 --- a/back/src/routers/auth-router.ts +++ b/back/src/routers/auth-router.ts @@ -44,7 +44,7 @@ authRouter.post( } req.logIn(user, () => { storeUserSessionsId(user.id, req.session.id); - const returnTo = req.body.returnTo || "/"; + const returnTo = req.body.returnTo || "/dashboard"; return res.redirect(`${UI_BASE_URL}${returnTo}`); }); })(req, res, next); diff --git a/back/src/users/resolvers/Query.ts b/back/src/users/resolvers/Query.ts index bb6dba0abd..aa41b17665 100644 --- a/back/src/users/resolvers/Query.ts +++ b/back/src/users/resolvers/Query.ts @@ -11,9 +11,11 @@ import passwordResetRequest from "./queries/passwordResetRequest"; import warningMessage from "./queries/warningMessage"; import myCompaniesCsv from "./queries/myCompaniesCsv"; import myCompaniesXls from "./queries/myCompaniesXls"; +import isAuthenticated from "./queries/isAuthenticated"; const Query: QueryResolvers = { me, + isAuthenticated, apiKey, invitation, membershipRequest, diff --git a/back/src/users/resolvers/queries/__tests__/isAuthenticated.integration.ts b/back/src/users/resolvers/queries/__tests__/isAuthenticated.integration.ts new file mode 100644 index 0000000000..c110a2ce9f --- /dev/null +++ b/back/src/users/resolvers/queries/__tests__/isAuthenticated.integration.ts @@ -0,0 +1,59 @@ +import { resetDatabase } from "../../../../../integration-tests/helper"; +import makeClient from "../../../../__tests__/testClient"; +import { userFactory } from "../../../../__tests__/factories"; +import type { Query } from "@td/codegen-back"; +import { AuthType } from "../../../../auth"; + +const IS_AUTHENTICATED = ` + query IsAuthenticated { + isAuthenticated + } +`; + +describe("query isAuthenticated", () => { + afterAll(resetDatabase); + + it("should return true if user is authenticated", async () => { + // Given + const user = await userFactory(); + + // When + const { query } = makeClient(user); + const { data, errors } = await query>( + IS_AUTHENTICATED + ); + + // Then + expect(errors).toBeUndefined(); + expect(data.isAuthenticated).toEqual(true); + }); + + it("should return false if user is not authenticated", async () => { + // Given + + // When + const { query } = makeClient(); + const { data, errors } = await query>( + IS_AUTHENTICATED + ); + + // Then + expect(errors).toBeUndefined(); + expect(data.isAuthenticated).toEqual(false); + }); + + it("should return false if not SESSION", async () => { + // Given + const user = await userFactory(); + + // When + const { query } = makeClient({ ...user, auth: AuthType.Bearer }); + const { errors, data } = await query>( + IS_AUTHENTICATED + ); + + // Then + expect(errors).toBeUndefined(); + expect(data.isAuthenticated).toEqual(false); + }); +}); diff --git a/back/src/users/resolvers/queries/isAuthenticated.ts b/back/src/users/resolvers/queries/isAuthenticated.ts new file mode 100644 index 0000000000..0e718be231 --- /dev/null +++ b/back/src/users/resolvers/queries/isAuthenticated.ts @@ -0,0 +1,20 @@ +import { applyAuthStrategies, AuthType } from "../../../auth"; +import { checkIsAuthenticated } from "../../../common/permissions"; +import type { QueryResolvers } from "@td/codegen-back"; + +const isAuthenticatedResolver: QueryResolvers["isAuthenticated"] = async ( + _, + __, + context +) => { + applyAuthStrategies(context, [AuthType.Session]); + + try { + checkIsAuthenticated(context); + return true; + } catch (_) { + return false; + } +}; + +export default isAuthenticatedResolver; diff --git a/back/src/users/typeDefs/private/user.queries.graphql b/back/src/users/typeDefs/private/user.queries.graphql index 5502a1e939..ebd5b116f1 100644 --- a/back/src/users/typeDefs/private/user.queries.graphql +++ b/back/src/users/typeDefs/private/user.queries.graphql @@ -46,4 +46,10 @@ type Query { skip: Int first: Int ): MembershipRequestsConnection! + + """ + Permet de savoir si l'utilisateur est authentifié ou non. + Ne retourne pas d'erreur si l'utilisateur n'est pas authentifié + """ + isAuthenticated: Boolean! } diff --git a/front/src/Apps/Account/Account.tsx b/front/src/Apps/Account/Account.tsx index acc509fb7f..5625880abf 100644 --- a/front/src/Apps/Account/Account.tsx +++ b/front/src/Apps/Account/Account.tsx @@ -34,7 +34,7 @@ export default function Account() { const isMobile = useMedia(`(max-width: ${MEDIA_QUERIES.handHeld})`); - if (loading) return ; + if (loading || data?.me == null) return ; if (error) return ; diff --git a/front/src/Apps/Companies/CompaniesRoutes.tsx b/front/src/Apps/Companies/CompaniesRoutes.tsx index 3a5201ca35..d4ead3bd73 100644 --- a/front/src/Apps/Companies/CompaniesRoutes.tsx +++ b/front/src/Apps/Companies/CompaniesRoutes.tsx @@ -28,7 +28,7 @@ export default function CompaniesRoutes() { const isMobile = useMedia(`(max-width: ${MEDIA_QUERIES.handHeld})`); - if (loading) return ; + if (loading || data?.me == null) return ; if (error) return ; diff --git a/front/src/Apps/Dashboard/DashboardRoutes.tsx b/front/src/Apps/Dashboard/DashboardRoutes.tsx index db9c7eb08d..342cd2652d 100644 --- a/front/src/Apps/Dashboard/DashboardRoutes.tsx +++ b/front/src/Apps/Dashboard/DashboardRoutes.tsx @@ -65,7 +65,7 @@ const toRelative = route => { function DashboardRoutes() { const { siret } = useParams<{ siret: string }>(); - const { data } = useQuery>(GET_ME); + const { data, loading } = useQuery>(GET_ME); const { updatePermissions } = usePermissions(); const navigate = useNavigate(); @@ -108,7 +108,7 @@ function DashboardRoutes() { } }, [updatePermissions, data, siret]); - if (data?.me == null) { + if (loading || data?.me == null) { return ; } diff --git a/front/src/Apps/common/Components/layout/Header.tsx b/front/src/Apps/common/Components/layout/Header.tsx index 9b5fa7dd31..756c083434 100644 --- a/front/src/Apps/common/Components/layout/Header.tsx +++ b/front/src/Apps/common/Components/layout/Header.tsx @@ -33,12 +33,15 @@ import { import routes from "../../../routes"; import styles from "./Header.module.scss"; -import CompanySwitcher from "../CompanySwitcher/CompanySwitcher"; +import CompanySwitcher, { + getDefaultOrgId +} from "../CompanySwitcher/CompanySwitcher"; export const GET_ME = gql` { me { id + isAdmin companies { id name @@ -581,22 +584,12 @@ const getDesktopMenuEntries = ( return [...(isAuthenticated ? connected : []), ...(isAdmin ? admin : [])]; }; -type HeaderProps = { - isAuthenticated: boolean; - isAdmin: boolean; - defaultOrgId?: string; -}; - /** * Main nav * Contains External and internal links * On mobile appear as a sliding panel and includes other items */ -export default function Header({ - isAuthenticated, - isAdmin, - defaultOrgId -}: HeaderProps) { +export default function Header() { const { VITE_API_ENDPOINT } = import.meta.env; const location = useLocation(); const { updatePermissions, role, permissions } = usePermissions(); @@ -611,11 +604,16 @@ export default function Header({ location.pathname ); + const { data, loading } = useQuery>(GET_ME); + + const isAuthenticated = !loading && data != null; + const isAdmin = isAuthenticated && Boolean(data?.me?.isAdmin); + + const defaultOrgId = getDefaultOrgId(data?.me.companies ?? []); + // Catching siret from url when not available from props (just after login) const currentSiret = matchDashboard?.params["siret"] || defaultOrgId; - const { data } = useQuery>(GET_ME); - useEffect(() => { if (isAuthenticated && data && currentSiret) { const companies = data.me.companies; @@ -648,6 +646,8 @@ export default function Header({ [navigate, role] ); + if (loading) return null; + const showRegistry = permissions.includes(UserPermission.RegistryCanRead) && [UserRole.Admin, UserRole.Member].includes(role!); @@ -673,7 +673,153 @@ export default function Header({ canViewNewRegistry ); - return !isAuthenticated ? ( + return ( + <> + + + {/* Company switcher on top of the page */} + {!!matchDashboard && companies && currentCompany && ( +
+
+ +
+
+ )} + + ); +} + +/** + * Main nav when logged out + * Contains External and internal links + * On mobile appear as a sliding panel and includes other items + */ +export function UnauthenticatedHeader() { + return ( - ) : ( - <> - - - {/* Company switcher on top of the page */} - {!!matchDashboard && companies && currentCompany && ( -
-
- -
-
- )} - ); } diff --git a/front/src/Apps/common/Components/layout/Layout.tsx b/front/src/Apps/common/Components/layout/Layout.tsx index a75472ba4d..1577f25130 100644 --- a/front/src/Apps/common/Components/layout/Layout.tsx +++ b/front/src/Apps/common/Components/layout/Layout.tsx @@ -3,7 +3,7 @@ import { gql, useQuery } from "@apollo/client"; import Button from "@codegouvfr/react-dsfr/Button"; import { Query } from "@td/codegen-ui"; import { Outlet } from "react-router-dom"; -import Header from "./Header"; +import Header, { UnauthenticatedHeader } from "./Header"; import { Toaster } from "react-hot-toast"; import sandboxIcon from "./assets/code-sandbox.svg"; import downtimeIcon from "./assets/code-downtime.svg"; @@ -11,9 +11,8 @@ import PageTitle from "../PageTitle/PageTitle"; import A11ySkipLinks from "../A11ySkipLinks/A11ySkipLinks"; interface AuthProps { - isAuthenticated: boolean; - isAdmin: boolean; - defaultOrgId?: string; + v2banner?: JSX.Element; + unauthenticatedRoutes?: boolean; } const { VITE_WARNING_MESSAGE, VITE_DOWNTIME_MESSAGE, VITE_API_ENDPOINT } = import.meta.env; @@ -28,13 +27,9 @@ const GET_WARNING_MESSAGE = gql` * Layout with common elements to all routes */ export default function Layout({ - isAuthenticated, - isAdmin, v2banner, - defaultOrgId -}: AuthProps & { - v2banner?: JSX.Element; -}) { + unauthenticatedRoutes = false +}: AuthProps) { const { data } = useQuery>(GET_WARNING_MESSAGE); const isIE11 = !!navigator.userAgent.match(/Trident.*rv:11\./); @@ -104,11 +99,7 @@ export default function Layout({ )} -
+ {unauthenticatedRoutes ? :
} diff --git a/front/src/Apps/common/Components/layout/LayoutContainer.tsx b/front/src/Apps/common/Components/layout/LayoutContainer.tsx index 3422460e37..3e95cadd27 100644 --- a/front/src/Apps/common/Components/layout/LayoutContainer.tsx +++ b/front/src/Apps/common/Components/layout/LayoutContainer.tsx @@ -1,17 +1,12 @@ import React, { lazy, Suspense } from "react"; -import { Route, Routes, Navigate, generatePath } from "react-router-dom"; -import * as Sentry from "@sentry/browser"; +import { Route, Routes } from "react-router-dom"; import Loader from "../Loader/Loaders"; import Layout from "./Layout"; import routes from "../../../routes"; -import { useQuery, gql } from "@apollo/client"; -import { Query, UserRole } from "@td/codegen-ui"; import ResendActivationEmail from "../../../../login/ResendActivationEmail"; import Login from "../../../../login/Login"; import SurveyBanner from "../SurveyBanner/SurveyBanner"; import { RequireAuth, Redirect } from "../../../utils/routerUtils"; -import { getDefaultOrgId } from "../CompanySwitcher/CompanySwitcher"; -import { usePermissions } from "../../../../common/contexts/PermissionsContext"; import Exports from "../../../../dashboard/exports/Registry"; import { Oauth2Dialog, OidcDialog } from "../../../../oauth/AuthDialog"; import { MyImports } from "../../../../dashboard/registry/MyImports"; @@ -53,53 +48,18 @@ const Signup = lazy(() => import("../../../../login/Signup")); const Company = lazy(() => import("../../../../company/Company")); const WasteTree = lazy(() => import("../search/WasteTree")); -const GET_ME = gql` - query GetMe { - me { - id - email - isAdmin - companies { - orgId - siret - securityCode - } - featureFlags - } - } -`; - const BANNER_MESSAGES = [ `Abonnez-vous à notre lettre d'information mensuelle pour suivre les nouveautés de la plateforme, la programmation des formations, des conseils pratiques, ainsi que les évolutions réglementaires liées à la traçabilité des déchets.` ]; export default function LayoutContainer() { - const { orgId } = usePermissions(); - const { data, loading } = useQuery>(GET_ME, { - onCompleted: ({ me }) => { - if (import.meta.env.VITE_SENTRY_DSN && me.email) { - Sentry.setUser({ email: me.email }); - } - } - }); - - const isAuthenticated = !loading && data != null; - const isAdmin = isAuthenticated && Boolean(data?.me?.isAdmin); - - if (loading) { - return ; - } - - const defaultOrgId = - isAuthenticated && data && (orgId ?? getDefaultOrgId(data.me.companies)); - return ( }> + } @@ -108,16 +68,41 @@ export default function LayoutContainer() { + } /> + + }> + } /> + + } /> + + } /> + + } /> + + } /> + + } + /> + + } /> + + } /> + + } + /> + + } - defaultOrgId={defaultOrgId} /> } > - {isAdmin ? ( - - ) : ( -
Vous n'êtes pas autorisé à consulter cette page
- )} + + } /> - } /> - - } /> - - } /> - - } /> - } + path={routes.company} + element={ + + + + } /> - } /> - - } /> - } + path={routes.wasteTree} + element={ + + + + } /> - } /> - - } /> - } @@ -179,7 +151,7 @@ export default function LayoutContainer() { + } @@ -193,7 +165,7 @@ export default function LayoutContainer() { + } @@ -202,7 +174,7 @@ export default function LayoutContainer() { + } @@ -211,7 +183,7 @@ export default function LayoutContainer() { + } @@ -220,7 +192,7 @@ export default function LayoutContainer() { + } @@ -229,7 +201,7 @@ export default function LayoutContainer() { + } @@ -238,7 +210,7 @@ export default function LayoutContainer() { + } @@ -247,7 +219,7 @@ export default function LayoutContainer() { + } @@ -256,7 +228,7 @@ export default function LayoutContainer() { + } @@ -265,7 +237,7 @@ export default function LayoutContainer() { + } @@ -274,7 +246,7 @@ export default function LayoutContainer() { + } @@ -283,7 +255,7 @@ export default function LayoutContainer() { + } @@ -292,7 +264,7 @@ export default function LayoutContainer() { + } @@ -301,7 +273,7 @@ export default function LayoutContainer() { + } @@ -310,7 +282,7 @@ export default function LayoutContainer() { + } @@ -319,7 +291,7 @@ export default function LayoutContainer() { + } @@ -328,25 +300,9 @@ export default function LayoutContainer() { 0 - ? generatePath( - data?.me.companies[0].userRole?.includes( - UserRole.Driver - ) - ? routes.dashboard.transport.toCollect - : routes.dashboard.index, - { - siret: defaultOrgId - } - ) - : routes.companies.index - : routes.login - } - replace - /> + + + } /> diff --git a/front/src/Apps/utils/routerUtils.tsx b/front/src/Apps/utils/routerUtils.tsx index 69b0b678a4..4a28a202dc 100644 --- a/front/src/Apps/utils/routerUtils.tsx +++ b/front/src/Apps/utils/routerUtils.tsx @@ -5,16 +5,61 @@ import { useLocation, useParams } from "react-router-dom"; +import { useQuery, gql } from "@apollo/client"; +import * as Sentry from "@sentry/browser"; +import { Query } from "@td/codegen-ui"; +import Loader from "../common/Components/Loader/Loaders"; import routes from "../routes"; -export function RequireAuth({ children, isAuthenticated }) { +const GET_ME = gql` + query GetMe { + me { + id + email + isAdmin + companies { + orgId + siret + securityCode + } + featureFlags + } + } +`; + +export function RequireAuth({ + children, + needsAdminPrivilege = false, + replace = false +}) { const location = useLocation(); + + const { data, loading } = useQuery>(GET_ME, { + onCompleted: ({ me }) => { + if (import.meta.env.VITE_SENTRY_DSN && me.email) { + Sentry.setUser({ email: me.email }); + } + } + }); + + const isAuthenticated = !loading && data != null; + const isAdmin = isAuthenticated && Boolean(data?.me?.isAdmin); + + if (loading) { + return ; + } + + if (needsAdminPrivilege && !isAdmin) { + return
Vous n'êtes pas autorisé à consulter cette page
; + } + return isAuthenticated ? ( children ) : ( ); } diff --git a/front/src/graphql-client.ts b/front/src/graphql-client.ts index ad40326b1a..f633915730 100644 --- a/front/src/graphql-client.ts +++ b/front/src/graphql-client.ts @@ -7,6 +7,7 @@ import { import { onError } from "@apollo/client/link/error"; import { relayStylePagination } from "@apollo/client/utilities"; import { removeOrgId } from "./common/helper"; +import { localAuthService } from "./login/auth.service"; /** * Automatically erase `__typename` from variables @@ -48,6 +49,12 @@ const errorLink = onError(({ response, graphQLErrors }) => { // modify the response context to ignore the error // cf. https://www.apollographql.com/docs/react/data/error-handling/#ignoring-errors response!.errors = undefined; + + // If we catch an UNAUTHENTICATED exception at this point, + // the user session has probably expired. Redirect to login + // page with a hint in the URL to display a message + localAuthService.locallySignOut(); + window.location.href = "/login?session=expired"; } } } diff --git a/front/src/login/Login.tsx b/front/src/login/Login.tsx index f18fe9b45e..653e9830f0 100644 --- a/front/src/login/Login.tsx +++ b/front/src/login/Login.tsx @@ -76,7 +76,7 @@ export default function Login() { return ; } - const { returnTo, errorCode, username } = location.state || {}; + const { returnTo, errorCode, username = "" } = location.state || {}; const showCaptcha = displayCaptcha(errorCode); @@ -100,6 +100,17 @@ export default function Login() { ) : null; + const disconnectedAlert = + queries["session"] === "expired" ? ( +
+ +
+ ) : null; + return (
{createdAlert} + {disconnectedAlert} {alert}
diff --git a/front/src/login/auth.service.ts b/front/src/login/auth.service.ts index c5e565bc9a..672d0e1db2 100644 --- a/front/src/login/auth.service.ts +++ b/front/src/login/auth.service.ts @@ -3,7 +3,8 @@ import { SIRET_STORAGE_KEY } from "../Apps/common/Components/CompanySwitcher/Com export const localAuthService = { locallySignOut() { - client.resetStore(); + client.stop(); + client.clearStore(); window.localStorage.removeItem(SIRET_STORAGE_KEY); } };