diff --git a/mock/mutator/customClient.ts b/mock/mutator/customClient.ts index 761805db..b09388ab 100644 --- a/mock/mutator/customClient.ts +++ b/mock/mutator/customClient.ts @@ -1,6 +1,6 @@ import Axios, { AxiosRequestConfig } from 'axios'; - -export const AXIOS_INSTANCE = Axios.create({ baseURL: '.' }); // use your own URL here or environment variable +import { API_CONFIG } from '../../src/config'; +export const AXIOS_INSTANCE = Axios.create({ baseURL: API_CONFIG.BASE_URL }); // add a second `options` argument here if you want to pass extra options to each generated query export const customInstance = ( @@ -12,7 +12,9 @@ export const customInstance = ( ...config, ...options, cancelToken: source.token, - }).then(({ data }) => data).catch(error => console.log("Error ", error)); + }).then(({ data }) => data).catch(error => { + throw error; + }); // @ts-ignore promise.cancel = () => { @@ -30,4 +32,4 @@ export type BodyType = BodyData; // Or, in case you want to wrap the body type (optional) // (if the custom instance is processing data before sending it, like changing the case for example) -export type BodyType = CamelCase; \ No newline at end of file +export type BodyType = CamelCase; diff --git a/package.json b/package.json index 3ed59959..9e91387d 100644 --- a/package.json +++ b/package.json @@ -17,17 +17,18 @@ "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.15", "@mui/x-tree-view": "^7.5.0", - "body-parser" : "^1.20.3", - "cors" : "^2.8.5", + "body-parser": "^1.20.3", + "cors": "^2.8.5", "d3": "^7.1.1", - "dotenv": "16.4.7", "date-fns": "^3.6.0", - "express" : "^4.21.2", + "dotenv": "16.4.7", + "express": "^4.21.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.23.1", "react-rte": "^0.16.5", - "react-syntax-highlighter": "^15.5.0" + "react-syntax-highlighter": "^15.5.0", + "yup": "^1.6.1" }, "devDependencies": { "@faker-js/faker": "^8.4.1", diff --git a/src/App.jsx b/src/App.jsx index a274b1be..1946418c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import CssBaseline from "@mui/material/CssBaseline"; import { Box, ThemeProvider } from "@mui/material"; import { @@ -24,6 +25,7 @@ import ForgotPassword from "./components/Auth/ForgotPassword"; import SingleOrganization from "./components/SingleOrganization"; import TermActivity from "./components/term_activity/TermActivity"; import OrganizationsCurieEditor from "./components/CurieEditor/OrganizationCurieEditor"; +import { handleOrcidLogin } from "./api/endpoints"; const PageContainer = ({ children }) => { return ( @@ -116,6 +118,20 @@ const Layout = ({ children }) => { const authPaths = ["/login", "/register", "/forgot", "/reset"]; const location = useLocation(); const isAuthPath = authPaths.includes(location.pathname); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const code = params.get("code"); + if (code) {(async () => { + try { + const response = await handleOrcidLogin(code); + localStorage.setItem("token", response.token); + } catch (error) { + console.log("error: ", error) + }})(); + } + }, [location]); + // Determine whether to show the footer based on the current route const showFooter = location.pathname !== "/"; return ( @@ -134,6 +150,7 @@ const Layout = ({ children }) => { }; function App() { + return ( diff --git a/src/api/custom-axios-instance.ts b/src/api/custom-axios-instance.ts index fa0487a8..664f5aab 100644 --- a/src/api/custom-axios-instance.ts +++ b/src/api/custom-axios-instance.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { API_CONFIG } from "../../config/config.js"; +import { API_CONFIG } from "../config.ts"; export const customAxiosInstance = axios.create({ baseURL: API_CONFIG.BASE_SCICRUNCH_URL, @@ -14,4 +14,4 @@ customAxiosInstance.interceptors.request.use((config) => { api_key: process.env.API_KEY, // Load API key from .env }; return config; -}); \ No newline at end of file +}); diff --git a/src/api/endpoints/apiActions.ts b/src/api/endpoints/apiActions.ts new file mode 100644 index 00000000..77fd1ec0 --- /dev/null +++ b/src/api/endpoints/apiActions.ts @@ -0,0 +1,42 @@ +import { AxiosRequestConfig } from 'axios'; +import { customInstance } from '../../../mock/mutator/customClient'; +import { API_CONFIG } from '../../config'; + + +type SecondParameter any> = Parameters[1]; + +export const createPostRequest = (endpoint: string, contentType = "application/json") => { + return (data?: D, options?: SecondParameter) => { + return customInstance( + { + url: API_CONFIG.BASE_URL + endpoint, + method: "POST", + data: data, + headers: { + "Content-Type": contentType, + }, + }, + options, + ) + } +} + +export const createGetRequest = (endpoint: string, contentType?: string) => { + return (params?: P, options?: SecondParameter, signal?: AbortSignal) => { + const config: AxiosRequestConfig = { + url: API_CONFIG.BASE_URL + endpoint, + method: "GET", + params, + signal, + } + + if (contentType) { + config.headers = { + ...config.headers, + "Content-Type": contentType, + } + } + + return customInstance(config, options) + } +} \ No newline at end of file diff --git a/src/api/endpoints/apiService.ts b/src/api/endpoints/apiService.ts new file mode 100644 index 00000000..7c51d35d --- /dev/null +++ b/src/api/endpoints/apiService.ts @@ -0,0 +1,19 @@ +import { createPostRequest } from "./apiActions"; +import { API_CONFIG } from "../../config"; + +export interface LoginRequest { + username: string + password: string +} + +export interface RegisterRequest { + username: string + password: string + firstName: string + lastName: string + email: string + organization: string +} + +export const login = createPostRequest(API_CONFIG.REAL_API.SIGNIN, "application/x-www-form-urlencoded") +export const register = createPostRequest(API_CONFIG.REAL_API.NEWUSER_ILX, "application/x-www-form-urlencoded") \ No newline at end of file diff --git a/src/api/endpoints/index.ts b/src/api/endpoints/index.ts index 1155e7f3..649881c9 100644 --- a/src/api/endpoints/index.ts +++ b/src/api/endpoints/index.ts @@ -344,21 +344,32 @@ export const addMessateToVariantDiscussion = async (group, variantID, message) = }); } -export const handleLogin = async (email: string, password: string) => { +export const handleLogin = async (username: string, password: string) => { try { const { postOpsUserLogin } = useApi() - const response = await postOpsUserLogin({ - email, - password, - }); + const response = await postOpsUserLogin({ username: username, password: password }); console.log("Login successful:", response); - return response.data; + return response; } catch (error) { console.error("Login failed:", error); throw error; } }; +export const handleOrcidLogin = async (code: string) => { + const response = await fetch("https://uri.olympiangods.org/u/ops/orcid-login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + // body: JSON.strijiuigngify({ code }), + }); + + if (!response.ok) { + throw new Error("ORCID authentication failed"); + } + + return response.json(); +}; + export const handleRegister = async ( firstName: string, lastName: string, diff --git a/src/api/endpoints/interLexURIStructureAPI.ts b/src/api/endpoints/interLexURIStructureAPI.ts index 2b79b428..10abf38c 100644 --- a/src/api/endpoints/interLexURIStructureAPI.ts +++ b/src/api/endpoints/interLexURIStructureAPI.ts @@ -20,6 +20,7 @@ import type { } from '@tanstack/react-query' import { customInstance } from '../../../mock/mutator/customClient'; import type { ErrorType } from '../../../mock/mutator/customClient'; +import { API_CONFIG } from '../../config'; type AwaitedInput = PromiseLike | T; @@ -483,16 +484,32 @@ export const useGetOpsUserLogin = ,) => { +// options?: SecondParameter,) => { - return customInstance( - {url: `https://uri.olympiangods.org/u/ops/user-login`, method: 'POST' - }, - options); - } +// return customInstance( +// {url: `https://uri.olympiangods.org/u/ops/user-login`, method: 'POST' +// }, +// options); +// } +export const postOpsUserLogin = ( + userData: { username: string; password: string }, + options?: SecondParameter +) => { + return customInstance( + { + url: `${API_CONFIG.BASE_URL}${API_CONFIG.REAL_API.SIGNIN}`, + method: 'POST', + data: userData, + headers: { + 'Content-Type': "application/x-www-form-urlencoded", + }, + }, + options + ); +}; diff --git a/src/components/Auth/Login.jsx b/src/components/Auth/Login.jsx index 5aeed8b9..fd1b43bc 100644 --- a/src/components/Auth/Login.jsx +++ b/src/components/Auth/Login.jsx @@ -7,122 +7,183 @@ import { Grid, Paper, Typography, + Alert, + CircularProgress } from "@mui/material"; import { ArrowBack } from "@mui/icons-material"; import Checkbox from "@mui/material/Checkbox"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { CheckedIcon, UncheckedIcon, OrcidIcon } from "../../Icons"; import FormField from "./UI/Formfield"; import PasswordField from "./UI/PasswordField"; -import { handleLogin } from "../../api/endpoints/index"; +import { login } from "../../api/endpoints/apiService"; +import { API_CONFIG } from "../../config"; +import { GlobalDataContext } from "../../contexts/DataContext"; +import * as yup from "yup"; + +const schema = yup.object().shape({ + username: yup.string().required().min(3), + password: yup.string().required().min(6), +}); const Login = () => { const [formData, setFormData] = React.useState({ - email: "", + username: "", password: "", }); + const [errors, setErrors] = React.useState({}); + const [isLoading, setIsLoading] = React.useState(false); + const { setUserData } = React.useContext(GlobalDataContext); + const navigate = useNavigate(); + + React.useEffect(() => { + let eventMethod = window.addEventListener ? "addEventListener" : "attachEvent"; + let eventer = window[eventMethod]; + let messageEvent = eventMethod === "attachEvent" ? "onmessage" : "message"; + eventer(messageEvent, function (e) { + if (!e.data || !e.data.orcid_meta) return; + const { code, orcid_meta } = e.data; + + if (code === 200 || code === 302) { + setUserData({ name: orcid_meta.name, id: orcid_meta.orcid }); + navigate("/") + } else if (code === 401) { + setErrors((prev) => ({ + ...prev, + auth: "Invalid username or password. Please try again", + })); + } else { + setErrors((prev) => ({ + ...prev, + auth: "An unknown error occurred. Please try again", + })); + } + }); + + setIsLoading(false) + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading]); const handleInputChange = (e) => { const { name, value } = e.target; - setFormData((prev) => ({ - ...prev, - [name]: value, - })); + setFormData((prev) => ({ ...prev, [name]: value })); }; const loginUser = async () => { try { - await handleLogin(formData.email, formData.password); - console.log("Login successful"); + setIsLoading(true) + await schema.validate(formData, { abortEarly: false }) + setErrors({}) + + const result = await login({ username: formData.username, password: formData.password }) + console.log("result: ", result) + navigate("/") } catch (error) { console.error("Login error:", error); + setErrors((prevErrors) => ({ + ...prevErrors, + auth: "An unknown error occurred. Please try again", + })); + } finally { + setIsLoading(false) } }; const handleOrcidSignIn = () => { - const orcidSignInUrl = `https://uri.olympiangods.org/u/ops/orcid-login`; - window.location.href = orcidSignInUrl; + setIsLoading(true) + const orcidSignInUrl = `${API_CONFIG.OLYMPIAN_GODS}${API_CONFIG.REAL_API.ORCID_SIGNIN}?aspopup=true`; + window.open(orcidSignInUrl, "Orcid Sign In", "width=600,height=800").focus(); }; return ( - - - - Return to page - - Log in to your account - Welcome! Please enter your details. -
- - - - - - } - checkedIcon={} - defaultChecked - color="primary" - /> - } - label="Remember for 30 days" - /> - - Forgot password - - - - - - - + {isLoading ? + + : ( + + + + Return to page + + Log in to your account + Welcome! Please enter your details. + {errors.auth && {errors.auth}} + + + + + + + } + checkedIcon={} + defaultChecked + color="primary" + /> + } + label="Remember for 30 days" + /> + + Forgot password + + + + + + + + + + + or + + - - or - - - - - - - - Don’t have an account? Register - - - -
+ + + + + + Don’t have an account? Register + + + + + )}
); }; diff --git a/src/components/Auth/Register.jsx b/src/components/Auth/Register.jsx index 848a79cf..9567b072 100644 --- a/src/components/Auth/Register.jsx +++ b/src/components/Auth/Register.jsx @@ -12,41 +12,65 @@ import { ArrowBack } from "@mui/icons-material"; import { Link, useNavigate } from "react-router-dom"; import FormField from "./UI/Formfield"; import PasswordField from "./UI/PasswordField"; -import { handleRegister } from "../../api/endpoints/index"; +import { register } from "../../api/endpoints/apiService"; +import * as yup from "yup"; + +const schema = yup.object().shape({ + firstName: yup.string().required("First name is a required field"), + lastName: yup.string().required("Last name is a required field"), + email: yup.string().email().required(), + username: yup.string().required().min(3), + password: yup.string().required().min(10), + organization: yup.string().required() +}); const Register = () => { const [formData, setFormData] = React.useState({ firstName: "", lastName: "", + username: "", email: "", password: "", organization: "", }); const [errors, setErrors] = React.useState({}); - const [errorMessage, setErrorMessage] = React.useState(""); const navigate = useNavigate(); const registerUser = async () => { try { - const response = await handleRegister( - formData.firstName, - formData.lastName, - formData.email, - formData.password, - formData.organization - ); + await schema.validate(formData, { abortEarly: false }) + setErrors({}) + + const response = await register({ + firstName: formData.firstName, + lastName: formData.lastName, + username: formData.username, + email: formData.email, + password: formData.password, + organization: formData.organization + }); if (response.status === 200) { navigate("/"); + } else if(response.status === 401) { + setErrors((prevErrors) => ({ + ...prevErrors, + auth: "Invalid data. Please try again", + })) } else { const errorData = await response.json(); - setErrors(errorData.errors || {}); - setErrorMessage(errorData.message || "Something went wrong."); + setErrors((prevErrors) => ({ + ...prevErrors, + auth: errorData.message || "An unknown error occurred. Please try again", + })); } } catch (error) { console.error("Registration error:", error); - setErrorMessage("An unexpected error occurred. Please try again."); + setErrors((prevErrors) => ({ + ...prevErrors, + auth: "An unknown error occurred. Please try again", + })); } }; @@ -60,7 +84,7 @@ const Register = () => { Register a new account and join - {errorMessage && {errorMessage}} + {errors.auth && {errors.auth}}
@@ -72,8 +96,8 @@ const Register = () => { onChange={(e) => setFormData({ ...formData, firstName: e.target.value }) } - error={!!errors.firstName} - helperText={errors.firstName} + errorMessage={errors.firstName} + helperText="Required" /> { onChange={(e) => setFormData({ ...formData, lastName: e.target.value }) } - error={!!errors.lastName} - helperText={errors.lastName} + errorMessage={errors.lastName} + helperText="Required" + /> + + setFormData({ ...formData, username: e.target.value }) + } + errorMessage={errors.username} + helperText="Required" /> { onChange={(e) => setFormData({ ...formData, email: e.target.value }) } - error={!!errors.email} - helperText={errors.email} + errorMessage={errors.email} + helperText="Required" /> { onChange={(e) => setFormData({ ...formData, password: e.target.value }) } - error={!!errors.password} - helperText={errors.password} + errorMessage={errors.password} + helperText="Required" /> { onChange={(e) => setFormData({ ...formData, organization: e.target.value }) } - error={!!errors.organization} - helperText={errors.organization} + errorMessage={errors.organization} + helperText="Required" /> diff --git a/src/components/Auth/UI/Formfield.jsx b/src/components/Auth/UI/Formfield.jsx index c729c06b..0b5c7ea0 100644 --- a/src/components/Auth/UI/Formfield.jsx +++ b/src/components/Auth/UI/Formfield.jsx @@ -1,7 +1,8 @@ import PropTypes from "prop-types"; -import { Grid, FormControl, Box, FormHelperText, OutlinedInput } from "@mui/material"; +import { Grid, FormControl, Box, FormHelperText, OutlinedInput, InputAdornment, Typography } from "@mui/material"; +import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined'; -const FormField = ({ xs = 12, label, helperText, placeholder, name, value, onChange }) => { +const FormField = ({ xs = 12, label, helperText, placeholder, name, value, onChange, errorMessage }) => { return ( @@ -14,7 +15,17 @@ const FormField = ({ xs = 12, label, helperText, placeholder, name, value, onCha name={name} // Pass the name prop value={value} onChange={onChange} + autoComplete="off" + error={errorMessage} + endAdornment={ + errorMessage && ( + + + + ) + } /> + {errorMessage && {`${errorMessage.charAt(0).toUpperCase() + errorMessage.slice(1)}`}} ); @@ -28,6 +39,7 @@ FormField.propTypes = { name: PropTypes.string.isRequired, value: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, + errorMessage: PropTypes.string.isRequired, }; FormField.defaultProps = { @@ -35,4 +47,4 @@ FormField.defaultProps = { placeholder: "", }; -export default FormField; +export default FormField; \ No newline at end of file diff --git a/src/components/Auth/UI/PasswordField.jsx b/src/components/Auth/UI/PasswordField.jsx index d6d02545..700ab0c5 100644 --- a/src/components/Auth/UI/PasswordField.jsx +++ b/src/components/Auth/UI/PasswordField.jsx @@ -8,11 +8,13 @@ import { InputAdornment, IconButton, Grid, + Typography } from "@mui/material"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined"; +import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined'; -const PasswordField = ({ xs = 12, label, placeholder, helperText, name, value, onChange }) => { +const PasswordField = ({ xs = 12, label, placeholder, helperText, name, value, onChange, errorMessage }) => { const [showPassword, setShowPassword] = useState(false); const handleClickShowPassword = () => setShowPassword((show) => !show); @@ -32,22 +34,29 @@ const PasswordField = ({ xs = 12, label, placeholder, helperText, name, value, o name={name} // Pass the name prop value={value} onChange={onChange} + error={errorMessage} + autoComplete="off" endAdornment={ - - - {showPassword ? ( - + + + {errorMessage ? : <> + {showPassword ? ( + ) : ( - - )} - - + + )} + + } + + } /> + {errorMessage && {`${errorMessage.charAt(0).toUpperCase() + errorMessage.slice(1)}`}} ); @@ -61,6 +70,7 @@ PasswordField.propTypes = { name: PropTypes.string.isRequired, value: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, + errorMessage: PropTypes.string.isRequired, }; PasswordField.defaultProps = { @@ -68,4 +78,4 @@ PasswordField.defaultProps = { helperText: "", }; -export default PasswordField; +export default PasswordField; \ No newline at end of file diff --git a/src/components/Header/index.jsx b/src/components/Header/index.jsx index 20adcfbf..f9526929 100644 --- a/src/components/Header/index.jsx +++ b/src/components/Header/index.jsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import { AddIcon, DocumentationIcon, @@ -34,6 +33,7 @@ import ListItemButton from '@mui/material/ListItemButton'; import { GlobalDataContext } from "../../contexts/DataContext"; import EditBulkTermsDialog from "../Dashboard/EditBulkTerms/EditBulkTermsDialog"; import ModeEditOutlineOutlinedIcon from "@mui/icons-material/ModeEditOutlineOutlined"; +import PersonOutlineIcon from '@mui/icons-material/PersonOutline'; import { vars } from "../../theme/variables"; const { gray200, white, gray100, gray600 } = vars; @@ -101,6 +101,17 @@ const styles = { keyBoardInfo: { borderRadius: '0.25rem', pointerEvents: 'none', background: gray100, color: gray600, fontSize: '0.875rem', lineHeight: '142.857%', p: '0.125rem 0.5rem' + }, + + avatar: { + border: '0.0469rem solid rgba(0,0,0,0.08)', + width: '2.5rem', + height: '2.5rem', + '& .MuiSvgIcon-root': { + width: '1.5rem', + height: '1.5rem', + fontSize: '1.5rem' + } } } @@ -139,11 +150,12 @@ const UserNavMenu = [ } ] -const Header = ({ isLoggedIn = true }) => { +const Header = () => { const [anchorEl, setAnchorEl] = React.useState(null); const [anchorElUser, setAnchorElUser] = React.useState(null); const [openEditBulkTerms, setOpenEditBulkTerms] = React.useState(false); const [activeStep, setActiveStep] = React.useState(0); + const [isLoggedIn, setIsLoggedIn] = React.useState(false); const { user, setUserData } = useContext(GlobalDataContext); const [openNewTermDialog, setOpenNewTermDialog] = React.useState(false); @@ -230,6 +242,9 @@ const Header = ({ isLoggedIn = true }) => { React.useEffect(() => { console.log("Stored user in context ", user) + if(user) { + setIsLoggedIn(true) + } }, [user]) return ( @@ -297,8 +312,8 @@ const Header = ({ isLoggedIn = true }) => { {!isLoggedIn ? ( - - + +