diff --git a/public/DIRAC-logo-minimal.png b/public/DIRAC-logo-minimal.png new file mode 100644 index 00000000..50212ff7 Binary files /dev/null and b/public/DIRAC-logo-minimal.png differ diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx new file mode 100644 index 00000000..b8a20694 --- /dev/null +++ b/src/app/auth/layout.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} 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", + }, + }, +};