diff --git a/package-lock.json b/package-lock.json index 3320690b..e899e558 100644 --- a/package-lock.json +++ b/package-lock.json @@ -102,11 +102,11 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz", + "integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==", "dependencies": { - "@babel/highlight": "^7.22.13", + "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" }, "engines": { @@ -232,12 +232,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.4.tgz", + "integrity": "sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==", "dev": true, "dependencies": { - "@babel/types": "^7.23.0", + "@babel/types": "^7.23.4", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -369,9 +369,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", "engines": { "node": ">=6.9.0" } @@ -408,9 +408,9 @@ } }, "node_modules/@babel/highlight": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", - "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -485,9 +485,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz", + "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -699,19 +699,19 @@ } }, "node_modules/@babel/traverse": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.0.tgz", - "integrity": "sha512-t/QaEvyIoIkwzpiZ7aoSKK8kObQYeF7T2v+dazAYCb8SXtp58zEVkWW7zAnju8FNKNdr4ScAOEDmMItbyOmEYw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.4.tgz", + "integrity": "sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", + "@babel/code-frame": "^7.23.4", + "@babel/generator": "^7.23.4", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", + "@babel/parser": "^7.23.4", + "@babel/types": "^7.23.4", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -729,11 +729,11 @@ } }, "node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz", + "integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==", "dependencies": { - "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx index 1dd80041..9ae44a30 100644 --- a/src/app/auth/page.tsx +++ b/src/app/auth/page.tsx @@ -1,4 +1,4 @@ -import { LoginForm } from "@/components/ui/LoginForm"; +import { LoginForm } from "@/components/applications/LoginForm"; export default function Page() { return ; diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 94e8f50b..dbab5b03 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,6 +1,6 @@ import React from "react"; import Dashboard from "@/components/layout/Dashboard"; -import { OIDCSecure } from "@/components/auth/OIDCUtils"; +import { OIDCSecure } from "@/components/layout/OIDCSecure"; export default function DashboardLayout({ children, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 657ccd2e..d6f170ac 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,5 @@ import { Inter } from "next/font/google"; -import { OIDCProvider } from "@/components/auth/OIDCUtils"; +import { OIDCConfigurationProvider } from "@/contexts/OIDCConfigurationProvider"; import { ThemeProvider } from "@/contexts/ThemeProvider"; const inter = Inter({ subsets: ["latin"] }); @@ -21,9 +21,9 @@ export default function RootLayout({ return ( - + {children} - + ); diff --git a/src/components/applications/LoginForm.tsx b/src/components/applications/LoginForm.tsx new file mode 100644 index 00000000..72068e9b --- /dev/null +++ b/src/components/applications/LoginForm.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import Select, { SelectChangeEvent } from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import Button from "@mui/material/Button"; +import Autocomplete from "@mui/material/Autocomplete"; +import TextField from "@mui/material/TextField"; +import { useMetadata, Metadata } from "@/hooks/metadata"; +import NextLink from "next/link"; +import Image from "next/image"; +import { CssBaseline, Stack } from "@mui/material"; +import { useMUITheme } from "@/hooks/theme"; +import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; +import { useRouter } from "next/navigation"; +import { useOIDCContext } from "@/hooks/oidcConfiguration"; +import * as React from "react"; +import { useOidc } from "@axa-fr/react-oidc"; +import { deepOrange, lightGreen } from "@mui/material/colors"; + +/** + * Login form + * @returns a form + */ +export function LoginForm() { + const theme = useMUITheme(); + const router = useRouter(); + const { data, error, isLoading } = useMetadata(); + const [selectedVO, setSelectedVO] = useState(null); + const [selectedGroup, setSelectedGroup] = useState(null); + const { + configuration, + setConfiguration, + configurationName, + setConfigurationName, + } = useOIDCContext(); + const { isAuthenticated, login } = useOidc(configurationName); + + // Login if not authenticated + useEffect(() => { + if (configurationName && isAuthenticated === false) { + login(); + } + }, [configurationName, isAuthenticated, login]); + + // Get default group + const getDefaultGroup = (data: Metadata | undefined, vo: string): string => { + if (!data) { + return ""; + } + + const defaultGroup = data.virtual_organizations[vo]?.default_group; + if (defaultGroup) { + return defaultGroup; + } else { + const groupKeys = Object.keys(data.virtual_organizations[vo].groups); + return groupKeys.length > 0 ? groupKeys[0] : ""; + } + }; + + // Set vo + const handleVOChange = ( + event: React.SyntheticEvent, + newValue: string | null, + ) => { + if (newValue) { + setSelectedVO(newValue); + setSelectedGroup(getDefaultGroup(data, newValue)); + } + }; + + // Set group + const handleGroupChange = (event: SelectChangeEvent) => { + const value = event.target.value; + setSelectedGroup(value); + }; + + // Update OIDC configuration + const handleConfigurationChanges = () => { + if (selectedVO && selectedGroup && configuration) { + const newScope = `vo:${selectedVO} group:${selectedGroup}`; + setConfiguration({ + ...configuration, + scope: newScope, + }); + setConfigurationName(newScope); + + sessionStorage.setItem("oidcConfigName", JSON.stringify(newScope)); + login(); + } + }; + // Redirect to dashboard if already authenticated + if (isAuthenticated) { + router.push("/dashboard"); + return null; + } + + if (isLoading) { + return
Loading...
; + } + if (error) { + return
An error occurred while fetching metadata.
; + } + if (!data) { + return
No metadata found.
; + } + + // Is there only one VO? + const singleVO = data && Object.keys(data.virtual_organizations).length === 1; + if (singleVO && !selectedVO) { + setSelectedVO(Object.keys(data.virtual_organizations)[0]); + setSelectedGroup( + getDefaultGroup(data, Object.keys(data.virtual_organizations)[0]), + ); + } + + return ( + + + + + + + + DIRAC logo + + + {singleVO ? ( + + {selectedVO} + + ) : ( + option} + renderInput={(params) => ( + + )} + value={selectedVO} + onChange={handleVOChange} + sx={{ + "& .MuiAutocomplete-root": { + // Style changes when an option is selected + opacity: selectedVO ? 0.5 : 1, + }, + }} + /> + )} + {selectedVO && ( + + + Select a Group + + + + + + + + Need help?{" "} + {data.virtual_organizations[selectedVO].support.message} + + + )} + + + + ); +} diff --git a/src/components/applications/Showcase.tsx b/src/components/applications/Showcase.tsx index c6090e82..91c48732 100644 --- a/src/components/applications/Showcase.tsx +++ b/src/components/applications/Showcase.tsx @@ -7,7 +7,7 @@ import Grid from "@mui/material/Grid"; import Paper from "@mui/material/Paper"; import { styled } from "@mui/material/styles"; import { DiracLogo } from "../ui/DiracLogo"; -import { LoginButton } from "../ui/LoginButton"; +import { ProfileButton } from "../ui/ProfileButton"; import { Box, Stack } from "@mui/material"; import { DashboardButton } from "../ui/DashboardButton"; import Image from "next/image"; @@ -47,7 +47,7 @@ export default function Showcase() { - + diff --git a/src/components/applications/UserDashboard.tsx b/src/components/applications/UserDashboard.tsx index 5d10a658..a3fd9c99 100644 --- a/src/components/applications/UserDashboard.tsx +++ b/src/components/applications/UserDashboard.tsx @@ -5,6 +5,7 @@ import { Box } from "@mui/material"; import { useMUITheme } from "@/hooks/theme"; import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; import { useOidcAccessToken } from "@axa-fr/react-oidc"; +import { useOIDCContext } from "@/hooks/oidcConfiguration"; /** * Build the User Dashboard page @@ -12,7 +13,8 @@ import { useOidcAccessToken } from "@axa-fr/react-oidc"; */ export default function UserDashboard() { const theme = useMUITheme(); - const { accessTokenPayload } = useOidcAccessToken(); + const { configurationName } = useOIDCContext(); + const { accessTokenPayload } = useOidcAccessToken(configurationName); return ( diff --git a/src/components/auth/OIDCUtils.tsx b/src/components/auth/OIDCUtils.tsx deleted file mode 100644 index 0f371c27..00000000 --- a/src/components/auth/OIDCUtils.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; -import { OidcConfiguration, OidcProvider, useOidc } from "@axa-fr/react-oidc"; -import React, { useState, useEffect } from "react"; -import { useDiracxUrl } from "@/hooks/utils"; -import { useRouter } from "next/navigation"; - -interface OIDCProviderProps { - children: React.ReactNode; -} - -interface OIDCProps { - children: React.ReactNode; -} - -/** - * Wrapper around the react-oidc OidcProvider component - * Needed because OidcProvider cannot be directly called from a server file - * @param props - configuration of the OIDC provider - * @returns the wrapper around OidcProvider - */ -export function OIDCProvider(props: OIDCProviderProps) { - const withCustomHistory = () => { - return { - replaceState: (url: string) => { - // Cannot use router.replace() because the code is not adapted to NextJS 13 yet - window.location.href = url; - }, - }; - }; - const diracxUrl = useDiracxUrl(); - const [configuration, setConfiguration] = useState( - null, - ); - - useEffect(() => { - if (diracxUrl !== null) { - setConfiguration((prevConfig) => ({ - authority: diracxUrl, - // TODO: Figure out how to get this. Hardcode? Get from a /.well-known/diracx-configuration endpoint? - client_id: "myDIRACClientID", - // TODO: Get this from the /.well-known/openid-configuration endpoint - scope: "vo:diracAdmin", - redirect_uri: `${diracxUrl}/#authentication-callback`, - })); - } - }, [diracxUrl]); - - if (configuration === null) { - return <>; - } - - return ( - <> - -
{props.children}
-
- - ); -} - -/** - * Check whether the user is authenticated, and redirect to the login page if not - * @param props - configuration of the OIDC provider - * @returns The children if the user is authenticated, null otherwise - */ -export function OIDCSecure({ children }: OIDCProps) { - const { isAuthenticated } = useOidc(); - const router = useRouter(); - - // Redirect to login page if not authenticated - if (!isAuthenticated) { - router.push("/auth"); - return null; - } - - return <>{children}; -} diff --git a/src/components/layout/Dashboard.tsx b/src/components/layout/Dashboard.tsx index 76e8efe3..3f85fd65 100644 --- a/src/components/layout/Dashboard.tsx +++ b/src/components/layout/Dashboard.tsx @@ -7,7 +7,7 @@ import IconButton from "@mui/material/IconButton"; import MenuIcon from "@mui/icons-material/Menu"; import Toolbar from "@mui/material/Toolbar"; import Stack from "@mui/material/Stack"; -import { LoginButton } from "../ui/LoginButton"; +import { ProfileButton } from "../ui/ProfileButton"; import DashboardDrawer from "../ui/DashboardDrawer"; import { useMUITheme } from "@/hooks/theme"; import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; @@ -68,7 +68,7 @@ export default function Dashboard(props: DashboardProps) {
- + diff --git a/src/components/layout/OIDCProvider.tsx b/src/components/layout/OIDCProvider.tsx new file mode 100644 index 00000000..e0b3959a --- /dev/null +++ b/src/components/layout/OIDCProvider.tsx @@ -0,0 +1,71 @@ +"use client"; +import { OidcProvider } from "@axa-fr/react-oidc"; +import React, { useEffect } from "react"; +import { useOIDCContext } from "@/hooks/oidcConfiguration"; +import { useDiracxUrl } from "@/hooks/utils"; + +interface OIDCProviderProps { + children: React.ReactNode; +} + +/** + * Wrapper around the react-oidc OidcProvider component + * Needed because OidcProvider cannot be directly called from a server file + * @param props - configuration of the OIDC provider + * @returns the wrapper around OidcProvider + */ +export function OIDCProvider(props: OIDCProviderProps) { + const { + configuration, + setConfiguration, + configurationName, + setConfigurationName, + } = useOIDCContext(); + const diracxUrl = useDiracxUrl(); + + useEffect(() => { + if (!configuration && !configurationName && diracxUrl) { + // Get the OIDC configuration name from the session storage if it exists + let scope = sessionStorage.getItem("oidcConfigName") || ``; + + if (scope) { + scope = scope.replace(/^"|"$/g, ""); + setConfigurationName(scope); + } + + // Set the OIDC configuration + setConfiguration({ + authority: diracxUrl, + client_id: "myDIRACClientID", + scope: scope, + redirect_uri: `${diracxUrl}/#authentication-callback`, + }); + } + }, [diracxUrl, configuration, setConfiguration]); + + const withCustomHistory = () => { + return { + replaceState: (url: string) => { + // Cannot use router.replace() because the code is not adapted to NextJS 13 yet + window.location.href = url; + }, + }; + }; + + if (!configuration) { + // Handle the case where configuration is still being determined + return
Loading OIDC Configuration...
; + } + // Configure the OidcProvider + return ( + <> + +
{props.children}
+
+ + ); +} diff --git a/src/components/layout/OIDCSecure.tsx b/src/components/layout/OIDCSecure.tsx new file mode 100644 index 00000000..5ef6c167 --- /dev/null +++ b/src/components/layout/OIDCSecure.tsx @@ -0,0 +1,28 @@ +"use client"; +import React from "react"; +import { useRouter } from "next/navigation"; +import { useOidc } from "@axa-fr/react-oidc"; +import { useOIDCContext } from "@/hooks/oidcConfiguration"; + +interface OIDCProps { + children: React.ReactNode; +} + +/** + * Check whether the user is authenticated, and redirect to the login page if not + * @param props - configuration of the OIDC provider + * @returns The children if the user is authenticated, null otherwise + */ +export function OIDCSecure({ children }: OIDCProps) { + const { configurationName } = useOIDCContext(); + const { isAuthenticated } = useOidc(configurationName); + const router = useRouter(); + + // Redirect to login page if not authenticated + if (!isAuthenticated) { + router.push("/auth"); + return null; + } + + return <>{children}; +} diff --git a/src/components/ui/DashboardButton.tsx b/src/components/ui/DashboardButton.tsx index a226b418..9cc05b2c 100644 --- a/src/components/ui/DashboardButton.tsx +++ b/src/components/ui/DashboardButton.tsx @@ -1,4 +1,5 @@ -import { useOidcAccessToken } from "@axa-fr/react-oidc"; +import { useOIDCContext } from "@/hooks/oidcConfiguration"; +import { useOidc } from "@axa-fr/react-oidc"; import { Button } from "@mui/material"; import { deepOrange, lightGreen } from "@mui/material/colors"; import Link from "next/link"; @@ -8,9 +9,11 @@ import Link from "next/link"; * @returns a Button */ export function DashboardButton() { - const { accessToken } = useOidcAccessToken(); + const { configurationName } = useOIDCContext(); + const { isAuthenticated } = useOidc(configurationName); - if (!accessToken) { + // Render null if the OIDC configuration is not ready or no access token is available + if (!isAuthenticated) { return null; } diff --git a/src/components/ui/LoginForm.tsx b/src/components/ui/LoginForm.tsx deleted file mode 100644 index 329eb3c6..00000000 --- a/src/components/ui/LoginForm.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import FormControl from "@mui/material/FormControl"; -import InputLabel from "@mui/material/InputLabel"; -import Select, { SelectChangeEvent } from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; -import Button from "@mui/material/Button"; -import Autocomplete from "@mui/material/Autocomplete"; -import TextField from "@mui/material/TextField"; -import { useMetadata } from "@/hooks/metadata"; -import { deepOrange, lightGreen } from "@mui/material/colors"; -import NextLink from "next/link"; -import Image from "next/image"; -import { CssBaseline, Stack } from "@mui/material"; -import { OidcConfiguration, useOidc } from "@axa-fr/react-oidc"; -import { useMUITheme } from "@/hooks/theme"; -import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; -import { useRouter } from "next/navigation"; -import { useDiracxUrl } from "@/hooks/utils"; - -/** - * Login form - * @returns a form - */ -export function LoginForm() { - const { login, isAuthenticated } = useOidc(); - const theme = useMUITheme(); - const router = useRouter(); - const { data, error, isLoading } = useMetadata(); - const [selectedVO, setSelectedVO] = useState(null); - const [selectedGroup, setSelectedGroup] = useState(null); - const [configuration, setConfiguration] = useState( - null, - ); - const diracxUrl = useDiracxUrl(); - - // Set OIDC configuration - useEffect(() => { - if (diracxUrl !== null && selectedVO !== null && selectedGroup !== null) { - setConfiguration(() => ({ - authority: diracxUrl, - // TODO: Figure out how to get this. Hardcode? Get from a /.well-known/diracx-configuration endpoint? - client_id: "myDIRACClientID", - scope: `vo:${selectedVO} group:${selectedGroup}`, - redirect_uri: `${diracxUrl}/#authentication-callback`, - })); - } - }, [diracxUrl]); - - // Set default VO if only one is available - useEffect(() => { - if (data) { - const vos = Object.keys(data.virtual_organizations); - if (vos.length === 1) { - setSelectedVO(vos[0]); - } - } - }, [data]); - - // Redirect to dashboard if already authenticated - if (isAuthenticated) { - router.push("/dashboard"); - return null; - } - - // Set group - const handleGroupChange = (event: SelectChangeEvent) => { - const value = event.target.value; - setSelectedGroup(value); - }; - - // Login - const handleLogin = () => { - login(); - }; - - if (isLoading) { - return
Loading...
; - } - if (error) { - return
An error occurred while fetching metadata.
; - } - if (!data) { - return
No metadata found.
; - } - - // Is there only one VO? - const singleVO = data && Object.keys(data.virtual_organizations).length === 1; - - return ( - - - - - - - DIRAC logo - - - {singleVO ? ( - - {selectedVO} - - ) : ( - option} - renderInput={(params) => ( - - )} - value={selectedVO} - onChange={(event: any, newValue: string | null) => { - setSelectedVO(newValue); - }} - sx={{ - "& .MuiAutocomplete-root": { - // Style changes when an option is selected - opacity: selectedVO ? 0.5 : 1, - }, - }} - /> - )} - {selectedVO && ( - - - Select a Group - - - - - - - - Need help?{" "} - {data.virtual_organizations[selectedVO].support.message} - - - )} - - - ); -} diff --git a/src/components/ui/LoginButton.tsx b/src/components/ui/ProfileButton.tsx similarity index 84% rename from src/components/ui/LoginButton.tsx rename to src/components/ui/ProfileButton.tsx index 4714354e..1fb5a3fb 100644 --- a/src/components/ui/LoginButton.tsx +++ b/src/components/ui/ProfileButton.tsx @@ -1,4 +1,5 @@ "use client"; +import { useOIDCContext } from "@/hooks/oidcConfiguration"; import { useOidc, useOidcAccessToken } from "@axa-fr/react-oidc"; import { Logout } from "@mui/icons-material"; import { @@ -16,12 +17,13 @@ import { deepOrange, lightGreen } from "@mui/material/colors"; import React from "react"; /** - * Login/Logout button, expected to vary whether the user is connected + * Profile button, expected to vary whether the user is connected * @returns a Button */ -export function LoginButton() { - const { accessTokenPayload } = useOidcAccessToken(); - const { logout, isAuthenticated } = useOidc(); +export function ProfileButton() { + const { configurationName, setConfigurationName } = useOIDCContext(); + const { accessTokenPayload } = useOidcAccessToken(configurationName); + const { logout, isAuthenticated } = useOidc(configurationName); const [anchorEl, setAnchorEl] = React.useState(null); const open = Boolean(anchorEl); @@ -33,6 +35,9 @@ export function LoginButton() { setAnchorEl(null); }; const handleLogout = () => { + // Remove the OIDC configuration name from the session storage + setConfigurationName(undefined); + sessionStorage.removeItem("oidcConfigName"); logout(); }; diff --git a/src/contexts/OIDCConfigurationProvider.tsx b/src/contexts/OIDCConfigurationProvider.tsx new file mode 100644 index 00000000..de3701eb --- /dev/null +++ b/src/contexts/OIDCConfigurationProvider.tsx @@ -0,0 +1,54 @@ +"use client"; + +import React, { useState, createContext } from "react"; +import { OidcConfiguration } from "@axa-fr/react-oidc"; +import { OIDCProvider } from "@/components/layout/OIDCProvider"; + +/** + * OIDC configuration context + * @property configuration - the OIDC configuration + * @property setConfiguration - function to set the OIDC configuration + * @returns the OIDC configuration context + */ +export const OIDCConfigurationContext = createContext<{ + configuration: OidcConfiguration | null; + setConfiguration: (config: OidcConfiguration | null) => void; + configurationName: string | undefined; + setConfigurationName: (name: string | undefined) => void; +}>({ + configuration: null, + setConfiguration: () => {}, + configurationName: undefined, + setConfigurationName: () => {}, +}); + +/** + * OIDC configuration provider + * @param children - the children of the provider + * @returns the OIDC configuration provider + */ +export const OIDCConfigurationProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [configuration, setConfiguration] = useState( + null, + ); + const [configurationName, setConfigurationName] = useState< + string | undefined + >(undefined); + + return ( + + {children} + + ); +}; diff --git a/src/hooks/jobs.tsx b/src/hooks/jobs.tsx index c8377d0c..07bcddca 100644 --- a/src/hooks/jobs.tsx +++ b/src/hooks/jobs.tsx @@ -1,14 +1,16 @@ import { useOidcAccessToken } from "@axa-fr/react-oidc"; import useSWR from "swr"; import { useDiracxUrl, fetcher } from "./utils"; +import { useOIDCContext } from "./oidcConfiguration"; /** * Fetches the jobs from the /api/jobs/search endpoint * @returns the jobs */ export function useJobs() { + const { configurationName } = useOIDCContext(); const diracxUrl = useDiracxUrl(); - const { accessToken } = useOidcAccessToken(); + const { accessToken } = useOidcAccessToken(configurationName); const url = `${diracxUrl}/api/jobs/search?page=0&per_page=100`; const { data, error } = useSWR([url, accessToken, "POST"], fetcher); diff --git a/src/hooks/metadata.tsx b/src/hooks/metadata.tsx index 50c757b1..8728ad34 100644 --- a/src/hooks/metadata.tsx +++ b/src/hooks/metadata.tsx @@ -1,10 +1,10 @@ -import useSWR, { SWRResponse } from "swr"; +import useSWRImmutable, { SWRResponse } from "swr"; import { useDiracxUrl, fetcher } from "./utils"; /** * Metadata returned by the /.well-known/dirac-metadata endpoint */ -interface Metadata { +export interface Metadata { virtual_organizations: { [key: string]: { groups: { @@ -30,7 +30,10 @@ export function useMetadata() { const diracxUrl = useDiracxUrl(); const url = `${diracxUrl}/.well-known/dirac-metadata`; - const { data, error }: SWRResponse = useSWR([url], fetcher); + const { data, error }: SWRResponse = useSWRImmutable( + [url], + fetcher, + ); return { data, diff --git a/src/hooks/oidcConfiguration.tsx b/src/hooks/oidcConfiguration.tsx new file mode 100644 index 00000000..41044bc5 --- /dev/null +++ b/src/hooks/oidcConfiguration.tsx @@ -0,0 +1,16 @@ +import { useContext } from "react"; +import { OIDCConfigurationContext } from "@/contexts/OIDCConfigurationProvider"; + +/** + * Hook to use the OIDC configuration context + * @returns the OIDC configuration context + */ +export const useOIDCContext = () => { + const context = useContext(OIDCConfigurationContext); + if (!context) { + throw new Error( + "useOIDCConfigurationContext must be used within an OIDCConfigurationProvider", + ); + } + return context; +}; diff --git a/test/unit-tests/DashboardButton.test.tsx b/test/unit-tests/DashboardButton.test.tsx index e16f40a5..0d371696 100644 --- a/test/unit-tests/DashboardButton.test.tsx +++ b/test/unit-tests/DashboardButton.test.tsx @@ -1,11 +1,11 @@ import React from "react"; import { render } from "@testing-library/react"; import { DashboardButton } from "@/components/ui/DashboardButton"; -import { useOidcAccessToken } from "@axa-fr/react-oidc"; +import { useOidc } from "@axa-fr/react-oidc"; // Mocking the useOidcAccessToken hook jest.mock("@axa-fr/react-oidc", () => ({ - useOidcAccessToken: jest.fn(), + useOidc: jest.fn(), })); describe("", () => { @@ -13,10 +13,10 @@ describe("", () => { jest.clearAllMocks(); }); - it("renders the button when user is connected (has accessToken)", () => { - // Mocking the return value of useOidcAccessToken to simulate a user with an accessToken - (useOidcAccessToken as jest.Mock).mockReturnValue({ - accessToken: "mocked_token", + it("renders the button when user is connected (isAuthenticated = true)", () => { + // Mocking the return value of useOidcAccessToken to simulate a non-connected user + (useOidc as jest.Mock).mockReturnValue({ + isAuthenticated: true, }); const { getByText } = render(); @@ -26,8 +26,8 @@ describe("", () => { }); it("does not render the button when user is not connected (no accessToken)", () => { - // Mocking the return value of useOidcAccessToken to simulate a user without an accessToken - (useOidcAccessToken as jest.Mock).mockReturnValue({ accessToken: null }); + // Mocking the return value of useOidc to simulate a connected user + (useOidc as jest.Mock).mockReturnValue({ isAuthenticated: false }); const { queryByText } = render(); const button = queryByText("Dashboard"); diff --git a/test/unit-tests/LoginButton.test.tsx b/test/unit-tests/LoginButton.test.tsx index 0efcdf38..dd8f2015 100644 --- a/test/unit-tests/LoginButton.test.tsx +++ b/test/unit-tests/LoginButton.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { render, fireEvent } from "@testing-library/react"; -import { LoginButton } from "@/components/ui/LoginButton"; +import { ProfileButton } from "@/components/ui/ProfileButton"; import { useOidcAccessToken, useOidc } from "@axa-fr/react-oidc"; // Mocking the hooks @@ -10,12 +10,12 @@ beforeEach(() => { jest.resetAllMocks(); }); -describe("", () => { +describe("", () => { it('displays the "Login" button when not authenticated', () => { (useOidc as jest.Mock).mockReturnValue({ isAuthenticated: false }); (useOidcAccessToken as jest.Mock).mockReturnValue({}); - const { getByText } = render(); + const { getByText } = render(); expect(getByText("Login")).toBeInTheDocument(); }); @@ -26,7 +26,7 @@ describe("", () => { accessTokenPayload: { preferred_username: "John" }, }); - const { getByText } = render(); + const { getByText } = render(); expect(getByText("J")).toBeInTheDocument(); // Assuming 'John' is the preferred username and 'J' is the first letter. }); @@ -37,7 +37,7 @@ describe("", () => { accessTokenPayload: { preferred_username: "John" }, }); - const { getByText, queryByText } = render(); + const { getByText, queryByText } = render(); fireEvent.click(getByText("J")); expect(queryByText("Profile")).toBeInTheDocument(); expect(queryByText("Logout")).toBeInTheDocument(); @@ -55,7 +55,7 @@ describe("", () => { accessTokenPayload: { preferred_username: "John" }, }); - const { getByText } = render(); + const { getByText } = render(); // Open the menu by clicking the avatar fireEvent.click(getByText("J")); diff --git a/test/unit-tests/LoginForm.test.tsx b/test/unit-tests/LoginForm.test.tsx index 41c5ba01..26a69ff0 100644 --- a/test/unit-tests/LoginForm.test.tsx +++ b/test/unit-tests/LoginForm.test.tsx @@ -1,5 +1,5 @@ import { render, fireEvent, screen, within } from "@testing-library/react"; -import { LoginForm } from "@/components/ui/LoginForm"; +import { LoginForm } from "@/components/applications/LoginForm"; import { ThemeProvider } from "@/contexts/ThemeProvider"; import { useMetadata } from "@/hooks/metadata";