From 8dc40edbc6603fdc8ec2103411ba6eeff1334b3a Mon Sep 17 00:00:00 2001 From: aldbr Date: Wed, 22 Nov 2023 17:58:01 +0100 Subject: [PATCH] feat: add /auth page --- public/DIRAC-logo-minimal.png | Bin 0 -> 5209 bytes src/app/auth/layout.tsx | 9 + src/app/auth/page.tsx | 5 + src/app/page.tsx | 2 +- .../{layout => applications}/Showcase.tsx | 0 src/components/applications/UserDashboard.tsx | 4 +- src/components/auth/OIDCUtils.tsx | 27 +-- src/components/ui/DiracLogo.tsx | 2 +- src/components/ui/LoginButton.tsx | 55 ++--- src/components/ui/LoginForm.tsx | 201 ++++++++++++++++++ src/contexts/ThemeProvider.tsx | 15 +- src/hooks/jobs.tsx | 28 +-- src/hooks/metadata.tsx | 40 ++++ src/hooks/theme.tsx | 14 +- src/hooks/utils.tsx | 32 ++- test/unit-tests/LoginForm.test.tsx | 62 ++++++ 16 files changed, 424 insertions(+), 72 deletions(-) create mode 100644 public/DIRAC-logo-minimal.png create mode 100644 src/app/auth/layout.tsx create mode 100644 src/app/auth/page.tsx rename src/components/{layout => applications}/Showcase.tsx (100%) create mode 100644 src/components/ui/LoginForm.tsx create mode 100644 src/hooks/metadata.tsx create mode 100644 test/unit-tests/LoginForm.test.tsx diff --git a/public/DIRAC-logo-minimal.png b/public/DIRAC-logo-minimal.png new file mode 100644 index 0000000000000000000000000000000000000000..50212ff775f8db7017c3775065ab70a28d24e37c GIT binary patch literal 5209 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 +} diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx new file mode 100644 index 00000000..1dd80041 --- /dev/null +++ b/src/app/auth/page.tsx @@ -0,0 +1,5 @@ +import { LoginForm } from "@/components/ui/LoginForm"; + +export default function Page() { + return ; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 038948cc..b3d72bd7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,4 @@ -import Showcase from "@/components/layout/Showcase"; +import Showcase from "@/components/applications/Showcase"; export default function Page() { return ; diff --git a/src/components/layout/Showcase.tsx b/src/components/applications/Showcase.tsx similarity index 100% rename from src/components/layout/Showcase.tsx rename to src/components/applications/Showcase.tsx diff --git a/src/components/applications/UserDashboard.tsx b/src/components/applications/UserDashboard.tsx index 4cc07f79..5d10a658 100644 --- a/src/components/applications/UserDashboard.tsx +++ b/src/components/applications/UserDashboard.tsx @@ -24,7 +24,9 @@ export default function UserDashboard() { mr: "5%", }} > - Hello {accessTokenPayload["preferred_username"]} +

Hello {accessTokenPayload["preferred_username"]}

+ +

To start with, select an application in the side bar

diff --git a/src/components/auth/OIDCUtils.tsx b/src/components/auth/OIDCUtils.tsx index 1cf977fe..0f371c27 100644 --- a/src/components/auth/OIDCUtils.tsx +++ b/src/components/auth/OIDCUtils.tsx @@ -1,11 +1,8 @@ "use client"; -import { - OidcConfiguration, - OidcProvider, - OidcSecure, -} from "@axa-fr/react-oidc"; +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; @@ -65,15 +62,19 @@ export function OIDCProvider(props: OIDCProviderProps) { } /** - * Wrapper around the react-oidc OidcSecure component - * Needed because OidcProvider cannot be directly called from a server file + * Check whether the user is authenticated, and redirect to the login page if not * @param props - configuration of the OIDC provider - * @returns the wrapper around OidcProvider + * @returns The children if the user is authenticated, null otherwise */ export function OIDCSecure({ children }: OIDCProps) { - return ( - <> - {children} - - ); + 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/ui/DiracLogo.tsx b/src/components/ui/DiracLogo.tsx index 5444f7c0..cff07b6f 100644 --- a/src/components/ui/DiracLogo.tsx +++ b/src/components/ui/DiracLogo.tsx @@ -9,7 +9,7 @@ export function DiracLogo() { return ( <> - DIRAC logo + DIRAC logo ); diff --git a/src/components/ui/LoginButton.tsx b/src/components/ui/LoginButton.tsx index 4b6b29a0..4714354e 100644 --- a/src/components/ui/LoginButton.tsx +++ b/src/components/ui/LoginButton.tsx @@ -6,6 +6,7 @@ import { Button, Divider, IconButton, + Link, ListItemIcon, Menu, MenuItem, @@ -20,10 +21,11 @@ import React from "react"; */ export function LoginButton() { const { accessTokenPayload } = useOidcAccessToken(); - const { login, logout, isAuthenticated } = useOidc(); + const { logout, isAuthenticated } = useOidc(); const [anchorEl, setAnchorEl] = React.useState(null); const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; @@ -42,7 +44,8 @@ export function LoginButton() { "&:hover": { bgcolor: deepOrange[500] }, }} variant="contained" - onClick={() => login()} + component={Link} + href="/auth" > Login @@ -69,29 +72,31 @@ export function LoginButton() { open={open} onClose={handleClose} onClick={handleClose} - PaperProps={{ - elevation: 0, - sx: { - overflow: "visible", - filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))", - mt: 1.5, - "& .MuiAvatar-root": { - width: 32, - height: 32, - ml: -0.5, - mr: 1, - }, - "&:before": { - content: '""', - display: "block", - position: "absolute", - top: 0, - right: 14, - width: 10, - height: 10, - bgcolor: "background.paper", - transform: "translateY(-50%) rotate(45deg)", - zIndex: 0, + slotProps={{ + paper: { + elevation: 0, + sx: { + overflow: "visible", + filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))", + mt: 1.5, + "& .MuiAvatar-root": { + width: 32, + height: 32, + ml: -0.5, + mr: 1, + }, + "&:before": { + content: '""', + display: "block", + position: "absolute", + top: 0, + right: 14, + width: 10, + height: 10, + bgcolor: "background.paper", + transform: "translateY(-50%) rotate(45deg)", + zIndex: 0, + }, }, }, }} diff --git a/src/components/ui/LoginForm.tsx b/src/components/ui/LoginForm.tsx new file mode 100644 index 00000000..7cf3a4c0 --- /dev/null +++ b/src/components/ui/LoginForm.tsx @@ -0,0 +1,201 @@ +"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/contexts/ThemeProvider.tsx b/src/contexts/ThemeProvider.tsx index f0cf2918..15ce516b 100644 --- a/src/contexts/ThemeProvider.tsx +++ b/src/contexts/ThemeProvider.tsx @@ -2,20 +2,33 @@ import { useMediaQuery } from "@mui/material"; import { createContext, useState } from "react"; +/** + * Theme context type + * @property theme - the current theme mode + * @property toggleTheme - function to toggle the theme mode + */ type ThemeContextType = { theme: "light" | "dark"; toggleTheme: () => void; }; +/** + * ThemeProvider props + */ export type ThemeProviderProps = { children: React.ReactNode; }; +/** + * Theme context + */ export const ThemeContext = createContext( undefined, ); -// ThemeProvider component to provide the theme context to its children +/** + * ThemeProvider component to provide the theme context to its children + */ export const ThemeProvider = ({ children }: ThemeProviderProps) => { // State to manage the current theme mode const [theme, setTheme] = useState<"light" | "dark">( diff --git a/src/hooks/jobs.tsx b/src/hooks/jobs.tsx index 37f06dd0..c8377d0c 100644 --- a/src/hooks/jobs.tsx +++ b/src/hooks/jobs.tsx @@ -1,31 +1,17 @@ import { useOidcAccessToken } from "@axa-fr/react-oidc"; import useSWR from "swr"; -import { useDiracxUrl } from "./utils"; - -const fetcher = (args: any[]) => { - const [url, accessToken] = args; - - return fetch(url, { - method: "POST", - headers: { Authorization: "Bearer " + accessToken }, - }).then((res) => { - if (!res.ok) throw new Error("Failed to fetch jobs"); - return res.json(); - }); -}; +import { useDiracxUrl, fetcher } from "./utils"; +/** + * Fetches the jobs from the /api/jobs/search endpoint + * @returns the jobs + */ export function useJobs() { const diracxUrl = useDiracxUrl(); const { accessToken } = useOidcAccessToken(); - const url = diracxUrl - ? `${diracxUrl}/api/jobs/search?page=0&per_page=100` - : null; - - const { data, error } = useSWR(url ? [url, accessToken] : null, fetcher); - if (diracxUrl === null) { - return { data: null, error: "diracxUrl is null", isLoading: false }; - } + const url = `${diracxUrl}/api/jobs/search?page=0&per_page=100`; + const { data, error } = useSWR([url, accessToken, "POST"], fetcher); return { data, diff --git a/src/hooks/metadata.tsx b/src/hooks/metadata.tsx new file mode 100644 index 00000000..50c757b1 --- /dev/null +++ b/src/hooks/metadata.tsx @@ -0,0 +1,40 @@ +import useSWR, { SWRResponse } from "swr"; +import { useDiracxUrl, fetcher } from "./utils"; + +/** + * Metadata returned by the /.well-known/dirac-metadata endpoint + */ +interface Metadata { + virtual_organizations: { + [key: string]: { + groups: { + [key: string]: { + properties: string[]; + }; + }; + support: { + message: string; + webpage: string | null; + email: string | null; + }; + default_group: string; + }; + }; +} + +/** + * Fetches the metadata from the /.well-known/dirac-metadata endpoint + * @returns the metadata + */ +export function useMetadata() { + const diracxUrl = useDiracxUrl(); + + const url = `${diracxUrl}/.well-known/dirac-metadata`; + const { data, error }: SWRResponse = useSWR([url], fetcher); + + return { + data, + error, + isLoading: !data && !error, + }; +} diff --git a/src/hooks/theme.tsx b/src/hooks/theme.tsx index 9831b2e5..dc854096 100644 --- a/src/hooks/theme.tsx +++ b/src/hooks/theme.tsx @@ -2,7 +2,11 @@ import { ThemeContext } from "@/contexts/ThemeProvider"; import { createTheme } from "@mui/material/styles"; import { useContext } from "react"; -// Custom hook to access the theme context +/** + * Custom hook to access the theme context + * @returns the theme context + * @throws an error if the hook is not used within a ThemeProvider + */ export const useTheme = () => { const context = useContext(ThemeContext); if (!context) { @@ -11,11 +15,15 @@ export const useTheme = () => { return context; }; -// Custom hook to generate and return the Material-UI theme based on the current mode +/** + * Custom hook to generate and return the Material-UI theme based on the current mode + * @returns the Material-UI theme + * @throws an error if the hook is not used within a ThemeProvider + */ export const useMUITheme = () => { const { theme } = useTheme(); - // Creating a Material-UI theme based on the current mode + // Create a Material-UI theme based on the current mode const muiTheme = createTheme({ palette: { mode: theme, diff --git a/src/hooks/utils.tsx b/src/hooks/utils.tsx index 12683886..085f36f0 100644 --- a/src/hooks/utils.tsx +++ b/src/hooks/utils.tsx @@ -1,15 +1,35 @@ +"use client"; import { useEffect, useState } from "react"; +/** + * Fetcher function for useSWR + * @param args - URL, access token, and method + * @returns a promise + */ +export const fetcher = (args: [string, string?, string?]): Promise => { + const [url, accessToken, method = "GET"] = args; + const headers = accessToken + ? { Authorization: "Bearer " + accessToken } + : undefined; + + return fetch(url, { + method, + ...(headers && { headers }), + }).then((res) => { + if (!res.ok) throw new Error("Failed to fetch jobs"); + return res.json(); + }); +}; + +/** + * Custom hook to get the diracx installation URL + * @returns the diracx installation URL + */ export function useDiracxUrl() { const [diracxUrl, setDiracxUrl] = useState(null); useEffect(() => { - // Ensure this runs on client side - if (typeof window !== "undefined") { - setDiracxUrl( - (prevConfig) => `${window.location.protocol}//${window.location.host}`, - ); - } + setDiracxUrl(`${window.location.protocol}//${window.location.host}`); }, []); return diracxUrl; diff --git a/test/unit-tests/LoginForm.test.tsx b/test/unit-tests/LoginForm.test.tsx new file mode 100644 index 00000000..eca7ad3b --- /dev/null +++ b/test/unit-tests/LoginForm.test.tsx @@ -0,0 +1,62 @@ +const data = { + virtual_organizations: { + diracAdmin: { + groups: { + admin: { + properties: ["NormalUser"], + }, + }, + support: { + message: "Please contact system administrator", + webpage: null, + email: null, + }, + default_group: "admin", + }, + LHCb: { + groups: { + admin: { + properties: ["NormalUser"], + }, + "lhcb-user": { + properties: ["NormalUser"], + }, + "lhcb-admin": { + properties: ["NormalUser"], + }, + }, + support: { + message: "Please contact system administrator", + webpage: null, + email: null, + }, + default_group: "admin", + }, + GridPP: { + groups: { + admin: { + properties: ["NormalUser"], + }, + }, + support: { + message: "Please contact system administrator", + webpage: null, + email: null, + }, + default_group: "admin", + }, + BelleII: { + groups: { + admin: { + properties: ["NormalUser"], + }, + }, + support: { + message: "Please contact system administrator", + webpage: null, + email: null, + }, + default_group: "admin", + }, + }, +};