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 (
<>
-
+
>
);
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 (
+
+
+
+
+
+
+
+
+
+ {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",
+ },
+ },
+};