diff --git a/frontend/src/components/Modals/DisplayRecoveryCodeModal/index.jsx b/frontend/src/components/Modals/DisplayRecoveryCodeModal/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a75a353820ad37160a9ee2f82e6de7dee0997d48 --- /dev/null +++ b/frontend/src/components/Modals/DisplayRecoveryCodeModal/index.jsx @@ -0,0 +1,86 @@ +import showToast from "@/utils/toast"; +import { DownloadSimple, Key } from "@phosphor-icons/react"; +import { saveAs } from "file-saver"; +import { useState } from "react"; + +export default function RecoveryCodeModal({ + recoveryCodes, + onDownloadComplete, + onClose, +}) { + const [downloadClicked, setDownloadClicked] = useState(false); + + const downloadRecoveryCodes = () => { + const blob = new Blob([recoveryCodes.join("\n")], { type: "text/plain" }); + saveAs(blob, "recovery_codes.txt"); + setDownloadClicked(true); + }; + + const handleClose = () => { + if (downloadClicked) { + onDownloadComplete(); + onClose(); + } + }; + + const handleCopyToClipboard = () => { + navigator.clipboard.writeText(recoveryCodes.join(",\n")).then(() => { + showToast("Recovery codes copied to clipboard", "success", { + clear: true, + }); + }); + }; + + return ( + <div className="inline-block bg-[#2C2F36] rounded-lg text-left overflow-hidden shadow-xl transform transition-all border-2 border-[#BCC9DB]/10 w-[600px] mx-4"> + <div className="md:py-[35px] md:px-[50px] py-[28px] px-[20px]"> + <div className="flex gap-x-2"> + <Key size={24} className="text-white" weight="bold" /> + <h3 + className="text-lg leading-6 font-medium text-white" + id="modal-headline" + > + Recovery Codes + </h3> + </div> + <div className="mt-4"> + <p className="text-sm text-white flex flex-col"> + In order to reset your password in the future, you will need these + recovery codes. Download or copy your recovery codes to save them.{" "} + <br /> + <b className="mt-4">These recovery codes are only shown once!</b> + </p> + <div + className="bg-[#1C1E21] text-white hover:text-[#46C8FF] + flex items-center justify-center rounded-md mt-6 cursor-pointer" + onClick={handleCopyToClipboard} + > + <ul className="space-y-2 md:p-6 p-4"> + {recoveryCodes.map((code, index) => ( + <li key={index} className="md:text-sm text-xs"> + {code} + </li> + ))} + </ul> + </div> + </div> + </div> + <div className="flex w-full justify-center items-center p-3 space-x-2 rounded-b border-gray-500/50 -mt-4 mb-4"> + <button + type="button" + className="transition-all duration-300 text-xs md:w-[500px] md:h-[34px] h-[48px] w-full m-2 font-semibold rounded-lg bg-[#46C8FF] hover:bg-[#2C2F36] border-2 border-transparent hover:border-[#46C8FF] hover:text-white whitespace-nowrap shadow-[0_4px_14px_rgba(0,0,0,0.25)] flex justify-center items-center gap-x-2" + onClick={downloadClicked ? handleClose : downloadRecoveryCodes} + > + {downloadClicked ? ( + "Close" + ) : ( + <> + <DownloadSimple weight="bold" size={18} /> + <p>Download</p> + </> + )} + </button> + </div> + </div> + ); +} diff --git a/frontend/src/components/Modals/Password/MultiUserAuth.jsx b/frontend/src/components/Modals/Password/MultiUserAuth.jsx index de086fc08eed3a1f2ddac03a8e9348d49aad435d..a44e040c44c3282f95e5e5bfff83eb3192434b1a 100644 --- a/frontend/src/components/Modals/Password/MultiUserAuth.jsx +++ b/frontend/src/components/Modals/Password/MultiUserAuth.jsx @@ -1,26 +1,203 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import System from "../../../models/system"; import { AUTH_TOKEN, AUTH_USER } from "../../../utils/constants"; import useLogo from "../../../hooks/useLogo"; import paths from "../../../utils/paths"; +import showToast from "@/utils/toast"; +import ModalWrapper from "@/components/ModalWrapper"; +import { useModal } from "@/hooks/useModal"; +import RecoveryCodeModal from "@/components/Modals/DisplayRecoveryCodeModal"; + +const RecoveryForm = ({ onSubmit, setShowRecoveryForm }) => { + const [username, setUsername] = useState(""); + const [recoveryCodeInputs, setRecoveryCodeInputs] = useState( + Array(2).fill("") + ); + + const handleRecoveryCodeChange = (index, value) => { + const updatedCodes = [...recoveryCodeInputs]; + updatedCodes[index] = value; + setRecoveryCodeInputs(updatedCodes); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + const recoveryCodes = recoveryCodeInputs.filter( + (code) => code.trim() !== "" + ); + onSubmit(username, recoveryCodes); + }; + + return ( + <form + onSubmit={handleSubmit} + className="flex flex-col justify-center items-center relative rounded-2xl md:bg-login-gradient md:shadow-[0_4px_14px_rgba(0,0,0,0.25)] md:px-8 px-0 py-4 w-full md:w-fit mt-10 md:mt-0" + > + <div className="flex items-start justify-between pt-11 pb-9 w-screen md:w-full md:px-12 px-6 "> + <div className="flex flex-col gap-y-4 w-full"> + <h3 className="text-4xl md:text-lg font-bold text-white text-center md:text-left"> + Password Reset + </h3> + <p className="text-sm text-white/90 md:text-left md:max-w-[300px] px-4 md:px-0 text-center"> + Provide the necessary information below to reset your password. + </p> + </div> + </div> + <div className="md:px-12 px-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + <div className="flex flex-col gap-y-2"> + <label className="text-white text-sm font-bold">Username</label> + <input + name="username" + type="text" + placeholder="Username" + value={username} + onChange={(e) => setUsername(e.target.value)} + className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-full h-[48px] md:w-[300px] md:h-[34px]" + required + /> + </div> + <div className="flex flex-col gap-y-2"> + <label className="text-white text-sm font-bold"> + Recovery Codes + </label> + {recoveryCodeInputs.map((code, index) => ( + <div key={index}> + <input + type="text" + name={`recoveryCode${index + 1}`} + placeholder={`Recovery Code ${index + 1}`} + value={code} + onChange={(e) => + handleRecoveryCodeChange(index, e.target.value) + } + className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-full h-[48px] md:w-[300px] md:h-[34px]" + required + /> + </div> + ))} + </div> + </div> + </div> + <div className="flex items-center md:p-12 md:px-0 px-6 mt-12 md:mt-0 space-x-2 border-gray-600 w-full flex-col gap-y-8"> + <button + type="submit" + className="md:text-[#46C8FF] md:bg-transparent md:w-[300px] text-[#222628] text-sm font-bold focus:ring-4 focus:outline-none rounded-md border-[1.5px] border-[#46C8FF] md:h-[34px] h-[48px] md:hover:text-white md:hover:bg-[#46C8FF] bg-[#46C8FF] focus:z-10 w-full" + > + Reset Password + </button> + <button + type="button" + className="text-white text-sm flex gap-x-1 hover:text-[#46C8FF] hover:underline -mb-8" + onClick={() => setShowRecoveryForm(false)} + > + Back to Login + </button> + </div> + </form> + ); +}; + +const ResetPasswordForm = ({ onSubmit }) => { + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const handleSubmit = (e) => { + e.preventDefault(); + onSubmit(newPassword, confirmPassword); + }; + + return ( + <form + onSubmit={handleSubmit} + className="flex flex-col justify-center items-center relative rounded-2xl md:bg-login-gradient md:shadow-[0_4px_14px_rgba(0,0,0,0.25)] md:px-12 px-0 py-4 w-full md:w-fit -mt-24 md:-mt-28" + > + <div className="flex items-start justify-between pt-11 pb-9 w-screen md:w-full md:px-12 px-6"> + <div className="flex flex-col gap-y-4 w-full"> + <h3 className="text-4xl md:text-2xl font-bold text-white text-center md:text-left"> + Reset Password + </h3> + <p className="text-sm text-white/90 md:text-left md:max-w-[300px] px-4 md:px-0 text-center"> + Enter your new password. + </p> + </div> + </div> + <div className="md:px-12 px-6 space-y-6 flex h-full w-full"> + <div className="w-full flex flex-col gap-y-4"> + <div> + <input + type="password" + name="newPassword" + placeholder="New Password" + value={newPassword} + onChange={(e) => setNewPassword(e.target.value)} + className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-full h-[48px] md:w-[300px] md:h-[34px]" + required + /> + </div> + <div> + <input + type="password" + name="confirmPassword" + placeholder="Confirm Password" + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} + className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-full h-[48px] md:w-[300px] md:h-[34px]" + required + /> + </div> + </div> + </div> + <div className="flex items-center md:p-12 md:px-0 px-6 mt-12 md:mt-0 space-x-2 border-gray-600 w-full flex-col gap-y-8"> + <button + type="submit" + className="md:text-[#46C8FF] md:bg-transparent md:w-[300px] text-[#222628] text-sm font-bold focus:ring-4 focus:outline-none rounded-md border-[1.5px] border-[#46C8FF] md:h-[34px] h-[48px] md:hover:text-white md:hover:bg-[#46C8FF] bg-[#46C8FF] focus:z-10 w-full" + > + Reset Password + </button> + </div> + </form> + ); +}; export default function MultiUserAuth() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { logo: _initLogo } = useLogo(); + const [recoveryCodes, setRecoveryCodes] = useState([]); + const [downloadComplete, setDownloadComplete] = useState(false); + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [showRecoveryForm, setShowRecoveryForm] = useState(false); + const [showResetPasswordForm, setShowResetPasswordForm] = useState(false); + + const { + isOpen: isRecoveryCodeModalOpen, + openModal: openRecoveryCodeModal, + closeModal: closeRecoveryCodeModal, + } = useModal(); + const handleLogin = async (e) => { setError(null); setLoading(true); e.preventDefault(); const data = {}; - const form = new FormData(e.target); for (var [key, value] of form.entries()) data[key] = value; - const { valid, user, token, message } = await System.requestToken(data); + const { valid, user, token, message, recoveryCodes } = + await System.requestToken(data); if (valid && !!token && !!user) { - window.localStorage.setItem(AUTH_USER, JSON.stringify(user)); - window.localStorage.setItem(AUTH_TOKEN, token); - window.location = paths.home(); + setUser(user); + setToken(token); + + if (recoveryCodes) { + setRecoveryCodes(recoveryCodes); + openRecoveryCodeModal(); + } else { + window.localStorage.setItem(AUTH_USER, JSON.stringify(user)); + window.localStorage.setItem(AUTH_TOKEN, token); + window.location = paths.home(); + } } else { setError(message); setLoading(false); @@ -28,57 +205,134 @@ export default function MultiUserAuth() { setLoading(false); }; - return ( - <form onSubmit={handleLogin}> - <div className="flex flex-col justify-center items-center relative rounded-2xl shadow border-2 border-slate-300 border-opacity-20 w-[400px] login-input-gradient"> - <div className="flex items-start justify-between pt-11 pb-9 rounded-t"> - <div className="flex items-center flex-col"> - <h3 className="text-md md:text-2xl font-bold text-gray-900 dark:text-white text-center"> - Sign In - </h3> - </div> - </div> - <div className="px-12 space-y-6 flex h-full w-full"> - <div className="w-full flex flex-col gap-y-4"> - <div> - <input - name="username" - type="text" - placeholder="Username" - className="bg-opacity-40 border-gray-300 text-sm rounded-lg block w-full p-2.5 bg-[#222628] placeholder-[#FFFFFF99] text-white focus:ring-blue-500 focus:border-blue-500" - required={true} - autoComplete="off" - /> - </div> + const handleDownloadComplete = () => setDownloadComplete(true); + const handleResetPassword = () => setShowRecoveryForm(true); + const handleRecoverySubmit = async (username, recoveryCodes) => { + const { success, resetToken, error } = await System.recoverAccount( + username, + recoveryCodes + ); - <div> - <input - name="password" - type="password" - placeholder="Password" - className="bg-opacity-40 border-gray-300 text-sm rounded-lg block w-full p-2.5 bg-[#222628] placeholder-[#FFFFFF99] text-white focus:ring-blue-500 focus:border-blue-500" - required={true} - autoComplete="off" - /> - </div> + if (success && resetToken) { + window.localStorage.setItem("resetToken", resetToken); + setShowRecoveryForm(false); + setShowResetPasswordForm(true); + } else { + showToast(error, "error", { clear: true }); + } + }; + + const handleResetSubmit = async (newPassword, confirmPassword) => { + const resetToken = window.localStorage.getItem("resetToken"); + + if (resetToken) { + const { success, error } = await System.resetPassword( + resetToken, + newPassword, + confirmPassword + ); + + if (success) { + window.localStorage.removeItem("resetToken"); + setShowResetPasswordForm(false); + showToast("Password reset successful", "success", { clear: true }); + } else { + showToast(error, "error", { clear: true }); + } + } else { + showToast("Invalid reset token", "error", { clear: true }); + } + }; - {error && ( - <p className="text-red-600 dark:text-red-400 text-sm"> - Error: {error} + useEffect(() => { + if (downloadComplete && user && token) { + window.localStorage.setItem(AUTH_USER, JSON.stringify(user)); + window.localStorage.setItem(AUTH_TOKEN, token); + window.location = paths.home(); + } + }, [downloadComplete, user, token]); + + if (showRecoveryForm) { + return ( + <RecoveryForm + onSubmit={handleRecoverySubmit} + setShowRecoveryForm={setShowRecoveryForm} + /> + ); + } + + if (showResetPasswordForm) + return <ResetPasswordForm onSubmit={handleResetSubmit} />; + return ( + <> + <form onSubmit={handleLogin}> + <div className="flex flex-col justify-center items-center relative rounded-2xl md:bg-login-gradient md:shadow-[0_4px_14px_rgba(0,0,0,0.25)] md:px-12 py-12 -mt-4 md:mt-0"> + <div className="flex items-start justify-between pt-11 pb-9 rounded-t"> + <div className="flex items-center flex-col gap-y-4"> + <div className="flex gap-x-1"> + <h3 className="text-md md:text-2xl font-bold text-white text-center white-space-nowrap hidden md:block"> + Welcome to + </h3> + <p className="text-4xl md:text-2xl font-bold bg-gradient-to-r from-[#75D6FF] via-[#FFFFFF] to-[#FFFFFF] bg-clip-text text-transparent"> + AnythingLLM + </p> + </div> + <p className="text-sm text-white/90 text-center"> + Sign in to your AnythingLLM account. </p> - )} + </div> + </div> + <div className="w-full px-4 md:px-12"> + <div className="w-full flex flex-col gap-y-4"> + <div className="w-screen md:w-full md:px-0 px-6"> + <input + name="username" + type="text" + placeholder="Username" + className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-full h-[48px] md:w-[300px] md:h-[34px]" + required={true} + autoComplete="off" + /> + </div> + <div className="w-screen md:w-full md:px-0 px-6"> + <input + name="password" + type="password" + placeholder="Password" + className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-full h-[48px] md:w-[300px] md:h-[34px]" + required={true} + autoComplete="off" + /> + </div> + {error && <p className="text-red-400 text-sm">Error: {error}</p>} + </div> + </div> + <div className="flex items-center md:p-12 px-10 mt-12 md:mt-0 space-x-2 border-gray-600 w-full flex-col gap-y-8"> + <button + disabled={loading} + type="submit" + className="md:text-[#46C8FF] md:bg-transparent text-[#222628] text-sm font-bold focus:ring-4 focus:outline-none rounded-md border-[1.5px] border-[#46C8FF] md:h-[34px] h-[48px] md:hover:text-white md:hover:bg-[#46C8FF] bg-[#46C8FF] focus:z-10 w-full" + > + {loading ? "Validating..." : "Login"} + </button> + <button + type="button" + className="text-white text-sm flex gap-x-1 hover:text-[#46C8FF] hover:underline" + onClick={handleResetPassword} + > + Forgot password?<b>Reset</b> + </button> </div> </div> - <div className="flex items-center p-12 space-x-2 border-gray-200 rounded-b dark:border-gray-600 w-full"> - <button - disabled={loading} - type="submit" - className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-white text-sm font-bold px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-white dark:text-neutral-700 dark:border-white dark:hover:text-white dark:hover:bg-slate-600 dark:focus:ring-gray-600 w-full" - > - {loading ? "Validating..." : "Login"} - </button> - </div> - </div> - </form> + </form> + + <ModalWrapper isOpen={isRecoveryCodeModalOpen}> + <RecoveryCodeModal + recoveryCodes={recoveryCodes} + onDownloadComplete={handleDownloadComplete} + onClose={closeRecoveryCodeModal} + /> + </ModalWrapper> + </> ); } diff --git a/frontend/src/components/Modals/Password/SingleUserAuth.jsx b/frontend/src/components/Modals/Password/SingleUserAuth.jsx index 8135f8f941e4e93e0d6c7f4ebb808ded65f54b44..c1f328ba2ca3b438f742a71dcc12f3203d2fed4f 100644 --- a/frontend/src/components/Modals/Password/SingleUserAuth.jsx +++ b/frontend/src/components/Modals/Password/SingleUserAuth.jsx @@ -1,25 +1,44 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import System from "../../../models/system"; import { AUTH_TOKEN } from "../../../utils/constants"; import useLogo from "../../../hooks/useLogo"; import paths from "../../../utils/paths"; +import ModalWrapper from "@/components/ModalWrapper"; +import { useModal } from "@/hooks/useModal"; +import RecoveryCodeModal from "@/components/Modals/DisplayRecoveryCodeModal"; export default function SingleUserAuth() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { logo: _initLogo } = useLogo(); + const [recoveryCodes, setRecoveryCodes] = useState([]); + const [downloadComplete, setDownloadComplete] = useState(false); + const [token, setToken] = useState(null); + + const { + isOpen: isRecoveryCodeModalOpen, + openModal: openRecoveryCodeModal, + closeModal: closeRecoveryCodeModal, + } = useModal(); + const handleLogin = async (e) => { setError(null); setLoading(true); e.preventDefault(); const data = {}; - const form = new FormData(e.target); for (var [key, value] of form.entries()) data[key] = value; - const { valid, token, message } = await System.requestToken(data); + const { valid, token, message, recoveryCodes } = + await System.requestToken(data); if (valid && !!token) { - window.localStorage.setItem(AUTH_TOKEN, token); - window.location = paths.home(); + setToken(token); + if (recoveryCodes) { + setRecoveryCodes(recoveryCodes); + openRecoveryCodeModal(); + } else { + window.localStorage.setItem(AUTH_TOKEN, token); + window.location = paths.home(); + } } else { setError(message); setLoading(false); @@ -27,45 +46,71 @@ export default function SingleUserAuth() { setLoading(false); }; + const handleDownloadComplete = () => { + setDownloadComplete(true); + }; + + useEffect(() => { + if (downloadComplete && token) { + window.localStorage.setItem(AUTH_TOKEN, token); + window.location = paths.home(); + } + }, [downloadComplete, token]); + return ( - <form onSubmit={handleLogin}> - <div className="flex flex-col justify-center items-center relative bg-white rounded-2xl shadow dark:bg-stone-700 border-2 border-slate-300 border-opacity-20 w-[400px] login-input-gradient"> - <div className="flex items-start justify-between pt-11 pb-9 rounded-t dark:border-gray-600"> - <div className="flex items-center flex-col"> - <h3 className="text-md md:text-2xl font-bold text-gray-900 dark:text-white text-center"> - Sign In - </h3> + <> + <form onSubmit={handleLogin}> + <div className="flex flex-col justify-center items-center relative rounded-2xl md:bg-login-gradient md:shadow-[0_4px_14px_rgba(0,0,0,0.25)] md:px-12 py-12 -mt-36 md:-mt-10"> + <div className="flex items-start justify-between pt-11 pb-9 rounded-t"> + <div className="flex items-center flex-col gap-y-4"> + <div className="flex gap-x-1"> + <h3 className="text-md md:text-2xl font-bold text-white text-center white-space-nowrap hidden md:block"> + Welcome to + </h3> + <p className="text-4xl md:text-2xl font-bold bg-gradient-to-r from-[#75D6FF] via-[#FFFFFF] to-[#FFFFFF] bg-clip-text text-transparent"> + AnythingLLM + </p> + </div> + <p className="text-sm text-white/90 text-center"> + Sign in to your AnythingLLM instance. + </p> + </div> </div> - </div> - <div className="px-12 space-y-6 flex h-full w-full"> - <div className="w-full flex flex-col gap-y-4"> - <div> - <input - name="password" - type="password" - placeholder="Password" - className="bg-neutral-800 bg-opacity-40 border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-[#222628] dark:bg-opacity-40 dark:placeholder-[#FFFFFF99] dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" - required={true} - autoComplete="off" - /> + <div className="w-full px-4 md:px-12"> + <div className="w-full flex flex-col gap-y-4"> + <div className="w-screen md:w-full md:px-0 px-6"> + <input + name="password" + type="password" + placeholder="Password" + className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-full h-[48px] md:w-[300px] md:h-[34px]" + required={true} + autoComplete="off" + /> + </div> + + {error && <p className="text-red-400 text-sm">Error: {error}</p>} </div> - {error && ( - <p className="text-red-600 dark:text-red-400 text-sm"> - Error: {error} - </p> - )} + </div> + <div className="flex items-center md:p-12 px-10 mt-12 md:mt-0 space-x-2 border-gray-600 w-full flex-col gap-y-8"> + <button + disabled={loading} + type="submit" + className="md:text-[#46C8FF] md:bg-transparent text-[#222628] text-sm font-bold focus:ring-4 focus:outline-none rounded-md border-[1.5px] border-[#46C8FF] md:h-[34px] h-[48px] md:hover:text-white md:hover:bg-[#46C8FF] bg-[#46C8FF] focus:z-10 w-full" + > + {loading ? "Validating..." : "Login"} + </button> </div> </div> - <div className="flex items-center p-12 space-x-2 border-gray-200 rounded-b dark:border-gray-600 w-full"> - <button - disabled={loading} - type="submit" - className="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-white text-sm font-bold px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-white dark:text-neutral-700 dark:border-white dark:hover:text-white dark:hover:bg-slate-600 dark:focus:ring-gray-600 w-full" - > - {loading ? "Validating..." : "Login"} - </button> - </div> - </div> - </form> + </form> + + <ModalWrapper isOpen={isRecoveryCodeModalOpen}> + <RecoveryCodeModal + recoveryCodes={recoveryCodes} + onDownloadComplete={handleDownloadComplete} + onClose={closeRecoveryCodeModal} + /> + </ModalWrapper> + </> ); } diff --git a/frontend/src/components/Modals/Password/index.jsx b/frontend/src/components/Modals/Password/index.jsx index d31b80fdf727d33bd20e8421c8c667af0214a4a3..9305d032e8d8f6c9ef4bc34b1b5afa13c41ddeac 100644 --- a/frontend/src/components/Modals/Password/index.jsx +++ b/frontend/src/components/Modals/Password/index.jsx @@ -8,26 +8,40 @@ import { AUTH_TIMESTAMP, } from "../../../utils/constants"; import useLogo from "../../../hooks/useLogo"; +import illustration from "@/media/illustrations/login-illustration.svg"; +import loginLogo from "@/media/illustrations/login-logo.svg"; export default function PasswordModal({ mode = "single" }) { const { logo: _initLogo } = useLogo(); return ( - <div className="fixed top-0 left-0 right-0 z-50 w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] h-full bg-zinc-800 flex items-center justify-center"> + <div className="fixed top-0 left-0 right-0 z-50 w-full overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] h-full bg-[#25272C] flex flex-col md:flex-row items-center justify-center"> <div - className="fixed top-0 left-0 right-0 bottom-0 z-40 animate-slow-pulse" style={{ background: ` - radial-gradient(circle at center, transparent 40%, black 100%), - linear-gradient(180deg, #FF8585 0%, #D4A447 100%) - `, + radial-gradient(circle at center, transparent 40%, black 100%), + linear-gradient(180deg, #85F8FF 0%, #65A6F2 100%) + `, width: "575px", - filter: "blur(200px)", - margin: "auto", + filter: "blur(150px)", + opacity: "0.4", }} + className="absolute left-0 top-0 z-0 h-full w-full" /> - - <div className="flex flex-col items-center justify-center h-full w-full z-50"> - <img src={_initLogo} className="mb-20 w-80 opacity-80" alt="logo" /> + <div className="hidden md:flex md:w-1/2 md:h-full md:items-center md:justify-center"> + <img + className="w-full h-full object-contain z-50" + src={illustration} + alt="login illustration" + /> + </div> + <div className="flex flex-col items-center justify-center h-full w-full md:w-1/2 z-50 relative"> + <img + src={loginLogo} + className={`mb-8 w-[84px] h-[84px] absolute ${ + mode === "single" ? "md:top-50" : "md:top-36" + } top-44 z-30`} + alt="logo" + /> {mode === "single" ? <SingleUserAuth /> : <MultiUserAuth />} </div> </div> diff --git a/frontend/src/media/illustrations/login-illustration.svg b/frontend/src/media/illustrations/login-illustration.svg new file mode 100644 index 0000000000000000000000000000000000000000..8b8b92cebfbdbdff0fd1b334966962362934dfd2 --- /dev/null +++ b/frontend/src/media/illustrations/login-illustration.svg @@ -0,0 +1,174 @@ +<svg width="500" height="656" viewBox="0 0 500 656" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_1_4)"> +<g filter="url(#filter1_ii_1_4)"> +<path d="M126.778 581.68V225.373L177.937 256.068V611.774L126.778 581.68Z" fill="url(#paint0_linear_1_4)"/> +</g> +<path d="M127.929 577.98L192.097 616.48L177.693 625.145L112.619 588.534L112.619 220.107L127.817 208.962L127.929 577.98Z" fill="url(#paint1_linear_1_4)"/> +<path d="M176.786 258.588L112.619 220.088L128.154 208.851L192.096 248.034V616.461L177.596 625.326L176.786 258.588Z" fill="url(#paint2_linear_1_4)"/> +<g filter="url(#filter2_ii_1_4)"> +<path d="M265.61 514.411V158.104L316.769 188.799V544.505L265.61 514.411Z" fill="url(#paint3_linear_1_4)"/> +</g> +<path d="M266.761 510.711L330.928 549.211L316.525 557.876L251.451 521.266L251.451 152.839L266.648 141.694L266.761 510.711Z" fill="url(#paint4_linear_1_4)"/> +<path d="M315.618 191.32L251.451 152.82L266.986 141.583L330.928 180.765V549.192L316.428 558.057L315.618 191.32Z" fill="url(#paint5_linear_1_4)"/> +<g filter="url(#filter3_ii_1_4)"> +<path d="M404.442 418.683V62.3754L455.602 93.071V448.776L404.442 418.683Z" fill="url(#paint6_linear_1_4)"/> +</g> +<path d="M405.594 414.982L469.761 453.483L455.357 462.147L390.283 425.537L390.283 57.11L405.481 45.9652L405.594 414.982Z" fill="url(#paint7_linear_1_4)"/> +<path d="M454.45 95.5913L390.283 57.0911L405.818 45.8542L469.761 85.0366V453.464L455.261 462.328L454.45 95.5913Z" fill="url(#paint8_linear_1_4)"/> +</g> +<rect x="88.956" y="351.304" width="68.0244" height="40.4539" rx="15" fill="url(#paint9_linear_1_4)"/> +<rect x="104.57" y="359.68" width="36.797" height="5.23376" rx="2.61688" fill="white" fill-opacity="0.8"/> +<rect x="104.57" y="378.148" width="36.797" height="5.23376" rx="2.61688" fill="white" fill-opacity="0.8"/> +<rect x="104.57" y="368.914" width="36.797" height="5.23376" rx="2.61688" fill="white" fill-opacity="0.8"/> +<mask id="mask0_1_4" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="211" width="178" height="436"> +<rect x="0.787216" y="211.982" width="177.152" height="434.649" fill="#D9D9D9"/> +</mask> +<g mask="url(#mask0_1_4)"> +<rect x="51.503" y="479.103" width="183.106" height="78.9537" rx="39.4769" fill="url(#paint10_linear_1_4)"/> +<circle cx="99.9761" cy="509.549" r="13.9262" fill="white"/> +<circle cx="143.056" cy="519.287" r="13.9262" fill="white"/> +<circle cx="186.136" cy="519.287" r="13.9262" fill="white"/> +</g> +<mask id="mask1_1_4" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="148" y="178" width="169" height="340"> +<rect x="148.819" y="178.725" width="167.95" height="338.735" fill="#D9D9D9"/> +</mask> +<g mask="url(#mask1_1_4)"> +<rect x="187.512" y="233.079" width="183.106" height="78.9537" rx="39.4769" fill="url(#paint11_linear_1_4)"/> +<path d="M310.535 287.977L305.269 284.227L311.812 275.529L301.997 272.178L303.992 266.034L313.886 269.305V258.613H320.35V269.305L330.244 266.034L332.239 272.178L322.424 275.529L328.888 284.227L323.701 287.977L317.078 279.28L310.535 287.977Z" fill="white"/> +<path d="M270.716 287.977L265.449 284.227L271.992 275.529L262.178 272.178L264.173 266.034L274.067 269.305V258.613H280.53V269.305L290.425 266.034L292.42 272.178L282.605 275.529L289.068 284.227L283.882 287.977L277.259 279.28L270.716 287.977Z" fill="white"/> +<path d="M230.897 287.977L225.63 284.227L232.173 275.529L222.359 272.178L224.354 266.034L234.248 269.305V258.613H240.711V269.305L250.606 266.034L252.601 272.178L242.786 275.529L249.249 284.227L244.063 287.977L237.44 279.28L230.897 287.977Z" fill="white"/> +<rect x="252.529" y="387.811" width="100.24" height="43.2226" rx="21.6113" fill="url(#paint12_linear_1_4)"/> +<circle cx="279.065" cy="404.479" r="7.62378" fill="white"/> +<circle cx="302.649" cy="409.81" r="7.62378" fill="white"/> +</g> +<mask id="mask2_1_4" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="199" y="0" width="257" height="309"> +<rect x="199.166" y="0.894867" width="256.435" height="307.227" fill="#D9D9D9"/> +</mask> +<g mask="url(#mask2_1_4)"> +<rect x="317.531" y="103.658" width="183.106" height="108.893" rx="40" fill="url(#paint13_linear_1_4)"/> +<rect x="343.093" y="123.945" width="131.983" height="18.7724" rx="8" fill="white" fill-opacity="0.8"/> +<rect x="343.093" y="173.49" width="131.983" height="18.7724" rx="8" fill="white" fill-opacity="0.8"/> +<rect x="343.093" y="148.718" width="131.983" height="18.7724" rx="8" fill="white" fill-opacity="0.8"/> +</g> +<defs> +<filter id="filter0_d_1_4" x="102.619" y="35.8542" width="397.142" height="619.471" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dx="10" dy="10"/> +<feGaussianBlur stdDeviation="10"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_4"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_4" result="shape"/> +</filter> +<filter id="filter1_ii_1_4" x="122.778" y="221.373" width="59.1591" height="394.401" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dx="-4" dy="4"/> +<feGaussianBlur stdDeviation="3"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="shape" result="effect1_innerShadow_1_4"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dx="4" dy="-4"/> +<feGaussianBlur stdDeviation="3"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.471302 0 0 0 0 0.547141 0 0 0 0 0.651872 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="effect1_innerShadow_1_4" result="effect2_innerShadow_1_4"/> +</filter> +<filter id="filter2_ii_1_4" x="261.61" y="154.104" width="59.159" height="394.401" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dx="-4" dy="4"/> +<feGaussianBlur stdDeviation="3"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="shape" result="effect1_innerShadow_1_4"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dx="4" dy="-4"/> +<feGaussianBlur stdDeviation="3"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.471302 0 0 0 0 0.547141 0 0 0 0 0.651872 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="effect1_innerShadow_1_4" result="effect2_innerShadow_1_4"/> +</filter> +<filter id="filter3_ii_1_4" x="400.442" y="58.3754" width="59.159" height="394.401" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dx="-4" dy="4"/> +<feGaussianBlur stdDeviation="3"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="shape" result="effect1_innerShadow_1_4"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dx="4" dy="-4"/> +<feGaussianBlur stdDeviation="3"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.471302 0 0 0 0 0.547141 0 0 0 0 0.651872 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="effect1_innerShadow_1_4" result="effect2_innerShadow_1_4"/> +</filter> +<linearGradient id="paint0_linear_1_4" x1="152.358" y1="225.373" x2="152.358" y2="611.774" gradientUnits="userSpaceOnUse"> +<stop stop-color="#41495D"/> +<stop offset="1" stop-color="#293240"/> +</linearGradient> +<linearGradient id="paint1_linear_1_4" x1="152.358" y1="208.962" x2="152.358" y2="625.145" gradientUnits="userSpaceOnUse"> +<stop stop-color="#151B23"/> +<stop offset="1" stop-color="#526A89"/> +</linearGradient> +<linearGradient id="paint2_linear_1_4" x1="152.358" y1="211.423" x2="152.358" y2="627.606" gradientUnits="userSpaceOnUse"> +<stop stop-color="#697784"/> +<stop offset="1" stop-color="#181B1E"/> +</linearGradient> +<linearGradient id="paint3_linear_1_4" x1="291.189" y1="158.104" x2="291.189" y2="544.505" gradientUnits="userSpaceOnUse"> +<stop stop-color="#41495D"/> +<stop offset="1" stop-color="#293240"/> +</linearGradient> +<linearGradient id="paint4_linear_1_4" x1="291.189" y1="141.694" x2="291.189" y2="557.876" gradientUnits="userSpaceOnUse"> +<stop stop-color="#151B23"/> +<stop offset="1" stop-color="#526A89"/> +</linearGradient> +<linearGradient id="paint5_linear_1_4" x1="291.19" y1="144.155" x2="291.19" y2="560.337" gradientUnits="userSpaceOnUse"> +<stop stop-color="#697784"/> +<stop offset="1" stop-color="#181B1E"/> +</linearGradient> +<linearGradient id="paint6_linear_1_4" x1="430.022" y1="62.3754" x2="430.022" y2="448.776" gradientUnits="userSpaceOnUse"> +<stop stop-color="#41495D"/> +<stop offset="1" stop-color="#293240"/> +</linearGradient> +<linearGradient id="paint7_linear_1_4" x1="430.022" y1="45.9652" x2="430.022" y2="462.147" gradientUnits="userSpaceOnUse"> +<stop stop-color="#151B23"/> +<stop offset="1" stop-color="#526A89"/> +</linearGradient> +<linearGradient id="paint8_linear_1_4" x1="430.022" y1="48.4262" x2="430.022" y2="464.608" gradientUnits="userSpaceOnUse"> +<stop stop-color="#697784"/> +<stop offset="1" stop-color="#181B1E"/> +</linearGradient> +<linearGradient id="paint9_linear_1_4" x1="122.968" y1="351.304" x2="122.968" y2="391.758" gradientUnits="userSpaceOnUse"> +<stop stop-color="#46C8FF"/> +<stop offset="0.438941" stop-color="#3AA5D2"/> +<stop offset="1" stop-color="#2A7899"/> +</linearGradient> +<linearGradient id="paint10_linear_1_4" x1="143.056" y1="479.103" x2="143.056" y2="558.057" gradientUnits="userSpaceOnUse"> +<stop stop-color="#46C8FF"/> +<stop offset="0.438941" stop-color="#3AA5D2"/> +<stop offset="1" stop-color="#2A7899"/> +</linearGradient> +<linearGradient id="paint11_linear_1_4" x1="279.065" y1="233.079" x2="279.065" y2="312.033" gradientUnits="userSpaceOnUse"> +<stop stop-color="#46C8FF"/> +<stop offset="0.438941" stop-color="#3AA5D2"/> +<stop offset="1" stop-color="#2A7899"/> +</linearGradient> +<linearGradient id="paint12_linear_1_4" x1="302.649" y1="387.811" x2="302.649" y2="431.034" gradientUnits="userSpaceOnUse"> +<stop stop-color="#46C8FF"/> +<stop offset="0.438941" stop-color="#3AA5D2"/> +<stop offset="1" stop-color="#2A7899"/> +</linearGradient> +<linearGradient id="paint13_linear_1_4" x1="409.084" y1="103.658" x2="409.084" y2="212.55" gradientUnits="userSpaceOnUse"> +<stop stop-color="#46C8FF"/> +<stop offset="0.438941" stop-color="#3AA5D2"/> +<stop offset="1" stop-color="#2A7899"/> +</linearGradient> +</defs> +</svg> diff --git a/frontend/src/media/illustrations/login-logo.svg b/frontend/src/media/illustrations/login-logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..729f847c00976319d40eda6c417a68a8f829ebaa --- /dev/null +++ b/frontend/src/media/illustrations/login-logo.svg @@ -0,0 +1,37 @@ +<svg width="86" height="85" viewBox="0 0 86 85" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="0.90625" y="0.40625" width="84.1875" height="84.1875" rx="20" fill="url(#paint0_linear_188_1378)"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M47.7351 50.8157C47.2046 51.501 47.1916 52.4626 47.725 53.157L52.1617 59.3215L52.1765 59.3402C53.1539 60.5704 54.6277 61.2761 56.198 61.2761H66.1112C68.922 61.2761 71.2192 59.0169 71.2192 56.215V30.1908C71.2192 27.3928 68.9225 25.1296 66.1112 25.1296H56.198C54.6235 25.1296 53.1525 25.8363 52.1753 27.0706L43.1661 38.4562L29.5453 55.667H20.434V30.7422H29.5279L34.4861 37.4414L34.4959 37.4537C35.2687 38.432 36.7515 38.4261 37.5241 37.4573L38.6696 36.0092L38.676 36.0009C39.2075 35.3144 39.2206 34.3483 38.6771 33.6523L33.8378 27.0839L33.827 27.0703C32.85 25.8364 31.3829 25.1296 29.8044 25.1296H19.8912C17.0914 25.1296 14.7832 27.3951 14.7832 30.1908V56.2184C14.7832 59.0135 17.0874 61.2795 19.8912 61.2795H29.8044C31.3826 61.2795 32.8491 60.5731 33.8259 59.3436L56.4599 30.7388H65.5684V55.6635H56.4728L51.9311 49.375L51.9165 49.3567C51.1455 48.388 49.6643 48.388 48.8934 49.3567L47.7426 50.8061L47.7351 50.8157ZM30.1646 56.4958L37.0263 47.8256L43.9503 39.0767L52.9594 27.6911L30.1646 56.4958Z" fill="url(#paint1_linear_188_1378)"/> +<g filter="url(#filter0_bi_188_1378)"> +<mask id="path-3-outside-1_188_1378" maskUnits="userSpaceOnUse" x="11.7832" y="22.1296" width="63" height="43" fill="black"> +<rect fill="white" x="11.7832" y="22.1296" width="63" height="43"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M47.7351 50.8157C47.2046 51.501 47.1916 52.4626 47.725 53.157L52.1617 59.3215L52.1765 59.3402C53.1539 60.5704 54.6277 61.2761 56.198 61.2761H66.1112C68.922 61.2761 71.2192 59.0169 71.2192 56.215V30.1908C71.2192 27.3928 68.9225 25.1296 66.1112 25.1296H56.198C54.6235 25.1296 53.1525 25.8363 52.1753 27.0706L43.1661 38.4562L29.5453 55.667H20.434V30.7422H29.5279L34.4861 37.4414L34.4959 37.4537C35.2687 38.432 36.7515 38.4261 37.5241 37.4573L38.6696 36.0092L38.676 36.0009C39.2075 35.3144 39.2206 34.3483 38.6771 33.6523L33.8378 27.0839L33.827 27.0703C32.85 25.8364 31.3829 25.1296 29.8044 25.1296H19.8912C17.0914 25.1296 14.7832 27.3951 14.7832 30.1908V56.2184C14.7832 59.0135 17.0874 61.2795 19.8912 61.2795H29.8044C31.3826 61.2795 32.8491 60.5731 33.8259 59.3436L56.4599 30.7388H65.5684V55.6635H56.4728L51.9311 49.375L51.9165 49.3567C51.1455 48.388 49.6643 48.388 48.8934 49.3567L47.7426 50.8061L47.7351 50.8157ZM30.1646 56.4958L37.0263 47.8256L43.9503 39.0767L52.9594 27.6911L30.1646 56.4958Z"/> +</mask> +<path d="M47.725 53.157L50.1599 51.4045L50.1326 51.3665L50.1041 51.3294L47.725 53.157ZM47.7351 50.8157L50.1074 52.6522L50.1077 52.6518L47.7351 50.8157ZM52.1617 59.3215L49.7268 61.074L49.7685 61.1321L49.813 61.1881L52.1617 59.3215ZM52.1765 59.3402L54.5254 57.4739L54.5251 57.4737L52.1765 59.3402ZM52.1753 27.0706L49.8233 25.2083L49.8227 25.209L52.1753 27.0706ZM43.1661 38.4562L45.5185 40.3179L45.5187 40.3177L43.1661 38.4562ZM29.5453 55.667V58.667H30.9969L31.8977 57.5287L29.5453 55.667ZM20.434 55.667H17.434V58.667H20.434V55.667ZM20.434 30.7422V27.7422H17.434V30.7422H20.434ZM29.5279 30.7422L31.9393 28.9575L31.0399 27.7422H29.5279V30.7422ZM34.4861 37.4414L32.0747 39.2261L32.1028 39.264L32.1321 39.301L34.4861 37.4414ZM34.4959 37.4537L32.1418 39.3134L32.1419 39.3135L34.4959 37.4537ZM37.5241 37.4573L39.8697 39.3277L39.8769 39.3185L37.5241 37.4573ZM38.6696 36.0092L41.0225 37.8704L41.0321 37.8582L41.0417 37.8458L38.6696 36.0092ZM38.676 36.0009L41.0481 37.8376L41.0483 37.8373L38.676 36.0009ZM38.6771 33.6523L36.2619 35.4318L36.2868 35.4656L36.3126 35.4987L38.6771 33.6523ZM33.8378 27.0839L36.253 25.3045L36.2221 25.2624L36.1896 25.2215L33.8378 27.0839ZM33.827 27.0703L31.475 28.9326L31.4751 28.9328L33.827 27.0703ZM33.8259 59.3436L36.1748 61.2098L36.1785 61.2051L33.8259 59.3436ZM56.4599 30.7388V27.7388H55.0081L54.1073 28.8773L56.4599 30.7388ZM65.5684 30.7388H68.5684V27.7388H65.5684V30.7388ZM65.5684 55.6635V58.6636H68.5684V55.6635H65.5684ZM56.4728 55.6635L54.0407 57.42L54.9388 58.6636H56.4728V55.6635ZM51.9311 49.375L54.3631 47.6185L54.322 47.5617L54.2783 47.5068L51.9311 49.375ZM51.9165 49.3567L49.5692 51.2249L49.5692 51.225L51.9165 49.3567ZM48.8934 49.3567L46.546 47.4886L46.5439 47.4913L48.8934 49.3567ZM47.7426 50.8061L45.3931 48.9406L45.3815 48.9552L45.3701 48.97L47.7426 50.8061ZM30.1646 56.4958L27.8121 54.6341L32.5171 58.3575L30.1646 56.4958ZM37.0263 47.8256L34.6738 45.9639L34.6738 45.9639L37.0263 47.8256ZM43.9503 39.0767L46.3027 40.9384L46.3029 40.9382L43.9503 39.0767ZM52.9594 27.6911L55.312 29.5526L50.607 25.8294L52.9594 27.6911ZM50.1041 51.3294C50.4169 51.7367 50.3953 52.2802 50.1074 52.6522L45.3629 48.9793C44.0139 50.7219 43.9663 53.1886 45.3459 54.9845L50.1041 51.3294ZM54.5966 57.5691L50.1599 51.4045L45.29 54.9094L49.7268 61.074L54.5966 57.5691ZM54.5251 57.4737L54.5103 57.455L49.813 61.1881L49.8278 61.2067L54.5251 57.4737ZM56.198 58.2761C55.5355 58.2761 54.9292 57.9823 54.5254 57.4739L49.8276 61.2064C51.3786 63.1584 53.7199 64.2761 56.198 64.2761V58.2761ZM66.1112 58.2761H56.198V64.2761H66.1112V58.2761ZM68.2192 56.215C68.2192 57.3276 67.2978 58.2761 66.1112 58.2761V64.2761C70.5461 64.2761 74.2192 60.7063 74.2192 56.215H68.2192ZM68.2192 30.1908V56.215H74.2192V30.1908H68.2192ZM66.1112 28.1296C67.2966 28.1296 68.2192 29.0804 68.2192 30.1908H74.2192C74.2192 25.7051 70.5484 22.1296 66.1112 22.1296V28.1296ZM56.198 28.1296H66.1112V22.1296H56.198V28.1296ZM54.5272 28.9329C54.9315 28.4223 55.5337 28.1296 56.198 28.1296V22.1296C53.7133 22.1296 51.3736 23.2503 49.8233 25.2083L54.5272 28.9329ZM45.5187 40.3177L54.5278 28.9321L49.8227 25.209L40.8135 36.5946L45.5187 40.3177ZM31.8977 57.5287L45.5185 40.3179L40.8137 36.5944L27.1928 53.8052L31.8977 57.5287ZM20.434 58.667H29.5453V52.667H20.434V58.667ZM17.434 30.7422V55.667H23.434V30.7422H17.434ZM29.5279 27.7422H20.434V33.7422H29.5279V27.7422ZM36.8975 35.6566L31.9393 28.9575L27.1165 32.5269L32.0747 39.2261L36.8975 35.6566ZM36.8499 35.594L36.8402 35.5817L32.1321 39.301L32.1418 39.3134L36.8499 35.594ZM35.1786 35.5869C35.5993 35.0592 36.414 35.0422 36.8499 35.594L32.1419 39.3135C34.1235 41.8217 37.9038 41.793 39.8697 39.3277L35.1786 35.5869ZM36.3168 34.148L35.1713 35.596L39.8769 39.3185L41.0225 37.8704L36.3168 34.148ZM36.304 34.1643L36.2976 34.1725L41.0417 37.8458L41.0481 37.8376L36.304 34.1643ZM36.3126 35.4987C35.9918 35.0879 36.0152 34.5373 36.3038 34.1645L41.0483 37.8373C42.3998 36.0915 42.4493 33.6087 41.0416 31.806L36.3126 35.4987ZM31.4225 28.8634L36.2619 35.4318L41.0924 31.8728L36.253 25.3045L31.4225 28.8634ZM31.4751 28.9328L31.4859 28.9464L36.1896 25.2215L36.1789 25.2079L31.4751 28.9328ZM29.8044 28.1296C30.4715 28.1296 31.0703 28.4215 31.475 28.9326L36.179 25.208C34.6297 23.2513 32.2942 22.1296 29.8044 22.1296V28.1296ZM19.8912 28.1296H29.8044V22.1296H19.8912V28.1296ZM17.7832 30.1908C17.7832 29.0862 18.7138 28.1296 19.8912 28.1296V22.1296C15.469 22.1296 11.7832 25.704 11.7832 30.1908H17.7832ZM17.7832 56.2184V30.1908H11.7832V56.2184H17.7832ZM19.8912 58.2795C18.7115 58.2795 17.7832 57.3241 17.7832 56.2184H11.7832C11.7832 60.7029 15.4632 64.2795 19.8912 64.2795V58.2795ZM29.8044 58.2795H19.8912V64.2795H29.8044V58.2795ZM31.4771 57.4774C31.072 57.9872 30.472 58.2795 29.8044 58.2795V64.2795C32.2931 64.2795 34.6261 63.159 36.1748 61.2098L31.4771 57.4774ZM54.1073 28.8773L31.4733 57.4821L36.1785 61.2051L58.8125 32.6003L54.1073 28.8773ZM65.5684 27.7388H56.4599V33.7388H65.5684V27.7388ZM68.5684 55.6635V30.7388H62.5684V55.6635H68.5684ZM56.4728 58.6636H65.5684V52.6635H56.4728V58.6636ZM49.499 51.1315L54.0407 57.42L58.9048 53.9071L54.3631 47.6185L49.499 51.1315ZM49.5692 51.225L49.5838 51.2432L54.2783 47.5068L54.2638 47.4885L49.5692 51.225ZM51.2407 51.2249C50.8106 51.7653 49.9992 51.7653 49.5692 51.2249L54.2639 47.4886C52.2918 45.0107 48.518 45.0107 46.546 47.4886L51.2407 51.2249ZM50.0921 52.6715L51.2429 51.2222L46.5439 47.4913L45.3931 48.9406L50.0921 52.6715ZM50.1077 52.6518L50.1152 52.6421L45.3701 48.97L45.3626 48.9797L50.1077 52.6518ZM32.5171 58.3575L39.3787 49.6873L34.6738 45.9639L27.8122 54.634L32.5171 58.3575ZM39.3787 49.6873L46.3027 40.9384L41.5978 37.2149L34.6738 45.9639L39.3787 49.6873ZM46.3029 40.9382L55.312 29.5526L50.6069 25.8295L41.5977 37.2151L46.3029 40.9382ZM50.607 25.8294L27.8121 54.6341L32.5171 58.3574L55.3119 29.5528L50.607 25.8294Z" fill="url(#paint2_linear_188_1378)" fill-opacity="0.1" mask="url(#path-3-outside-1_188_1378)"/> +</g> +<defs> +<filter id="filter0_bi_188_1378" x="9.1337" y="19.4801" width="67.735" height="47.4489" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feGaussianBlur in="BackgroundImageFix" stdDeviation="1.32475"/> +<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_188_1378"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_188_1378" result="shape"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="0.441584"/> +<feGaussianBlur stdDeviation="0.662376"/> +<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="shape" result="effect2_innerShadow_188_1378"/> +</filter> +<linearGradient id="paint0_linear_188_1378" x1="43" y1="0.40625" x2="43" y2="84.5938" gradientUnits="userSpaceOnUse"> +<stop stop-color="#3D4147"/> +<stop offset="1" stop-color="#2C2F35"/> +</linearGradient> +<linearGradient id="paint1_linear_188_1378" x1="15.7832" y1="26.1296" x2="74.4332" y2="49.8684" gradientUnits="userSpaceOnUse"> +<stop stop-color="#75A5FF"/> +<stop offset="0.703125" stop-color="#23E5FF"/> +</linearGradient> +<linearGradient id="paint2_linear_188_1378" x1="16.5468" y1="26.7728" x2="71.2192" y2="26.7728" gradientUnits="userSpaceOnUse"> +<stop stop-color="#3CDEB6"/> +<stop offset="0.65625" stop-color="#364AFF"/> +</linearGradient> +</defs> +</svg> diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 89deda75dd1b8d871dac413654561f993abf64a7..af532a0474d6c3ddca8ad4157946ec8178ea5983 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -77,6 +77,43 @@ const System = { return { valid: false, message: e.message }; }); }, + recoverAccount: async function (username, recoveryCodes) { + return await fetch(`${API_BASE}/system/recover-account`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ username, recoveryCodes }), + }) + .then(async (res) => { + const data = await res.json(); + if (!res.ok) { + throw new Error(data.message || "Error recovering account."); + } + return data; + }) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, + resetPassword: async function (token, newPassword, confirmPassword) { + return await fetch(`${API_BASE}/system/reset-password`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ token, newPassword, confirmPassword }), + }) + .then(async (res) => { + const data = await res.json(); + if (!res.ok) { + throw new Error(data.message || "Error resetting password."); + } + return data; + }) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, + checkDocumentProcessorOnline: async () => { return await fetch(`${API_BASE}/system/document-processing-status`, { headers: baseHeaders(), diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 8add5a6924cd4ea3f8912c4807bfaa6b5e47b6f2..b0ac87c900c47351d6d272dcb586bc8ac729c98e 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -37,6 +37,7 @@ export default { "main-gradient": "linear-gradient(180deg, #3D4147 0%, #2C2F35 100%)", "modal-gradient": "linear-gradient(180deg, #3D4147 0%, #2C2F35 100%)", "sidebar-gradient": "linear-gradient(90deg, #5B616A 0%, #3F434B 100%)", + "login-gradient": "linear-gradient(180deg, #3D4147 0%, #2C2F35 100%)", "menu-item-gradient": "linear-gradient(90deg, #3D4147 0%, #2C2F35 100%)", "menu-item-selected-gradient": diff --git a/server/endpoints/system.js b/server/endpoints/system.js index 761f892fed89438cb178859e05fec8285860daea..3ea7fb24c8629cd9e7720a35899ae00291a87ad7 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -36,6 +36,7 @@ const { WorkspaceChats } = require("../models/workspaceChats"); const { flexUserRoleValid, ROLES, + isMultiUserSetup, } = require("../utils/middleware/multiUserProtected"); const { fetchPfp, determinePfpFilepath } = require("../utils/files/pfp"); const { @@ -44,6 +45,11 @@ const { } = require("../utils/helpers/chat/convertTo"); const { EventLogs } = require("../models/eventLogs"); const { CollectorApi } = require("../utils/collectorApi"); +const { + recoverAccount, + resetPassword, + generateRecoveryCodes, +} = require("../utils/PasswordRecovery"); function systemEndpoints(app) { if (!app) return; @@ -174,6 +180,24 @@ function systemEndpoints(app) { existingUser?.id ); + // Check if the user has seen the recovery codes + if (!existingUser.seen_recovery_codes) { + const plainTextCodes = await generateRecoveryCodes(existingUser.id); + + // Return recovery codes to frontend + response.status(200).json({ + valid: true, + user: existingUser, + token: makeJWT( + { id: existingUser.id, username: existingUser.username }, + "30d" + ), + message: null, + recoveryCodes: plainTextCodes, + }); + return; + } + response.status(200).json({ valid: true, user: existingUser, @@ -221,6 +245,55 @@ function systemEndpoints(app) { } }); + app.post( + "/system/recover-account", + [isMultiUserSetup], + async (request, response) => { + try { + const { username, recoveryCodes } = reqBody(request); + const { success, resetToken, error } = await recoverAccount( + username, + recoveryCodes + ); + + if (success) { + response.status(200).json({ success, resetToken }); + } else { + response.status(400).json({ success, message: error }); + } + } catch (error) { + console.error("Error recovering account:", error); + response + .status(500) + .json({ success: false, message: "Internal server error" }); + } + } + ); + + app.post( + "/system/reset-password", + [isMultiUserSetup], + async (request, response) => { + try { + const { token, newPassword, confirmPassword } = reqBody(request); + const { success, message, error } = await resetPassword( + token, + newPassword, + confirmPassword + ); + + if (success) { + response.status(200).json({ success, message }); + } else { + response.status(400).json({ success, error }); + } + } catch (error) { + console.error("Error resetting password:", error); + response.status(500).json({ success: false, message: error.message }); + } + } + ); + app.get( "/system/system-vectors", [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], diff --git a/server/models/passwordRecovery.js b/server/models/passwordRecovery.js new file mode 100644 index 0000000000000000000000000000000000000000..1d09d08b306aff9b77be4f6a1f7178a29fef305e --- /dev/null +++ b/server/models/passwordRecovery.js @@ -0,0 +1,115 @@ +const { v4 } = require("uuid"); +const prisma = require("../utils/prisma"); +const bcrypt = require("bcrypt"); + +const RecoveryCode = { + tablename: "recovery_codes", + writable: [], + create: async function (userId, code) { + try { + const codeHash = await bcrypt.hash(code, 10); + const recoveryCode = await prisma.recovery_codes.create({ + data: { user_id: userId, code_hash: codeHash }, + }); + return { recoveryCode, error: null }; + } catch (error) { + console.error("FAILED TO CREATE RECOVERY CODE.", error.message); + return { recoveryCode: null, error: error.message }; + } + }, + createMany: async function (data) { + try { + const recoveryCodes = await prisma.$transaction( + data.map((recoveryCode) => + prisma.recovery_codes.create({ data: recoveryCode }) + ) + ); + return { recoveryCodes, error: null }; + } catch (error) { + console.error("FAILED TO CREATE RECOVERY CODES.", error.message); + return { recoveryCodes: null, error: error.message }; + } + }, + findFirst: async function (clause = {}) { + try { + const recoveryCode = await prisma.recovery_codes.findFirst({ + where: clause, + }); + return recoveryCode; + } catch (error) { + console.error("FAILED TO FIND RECOVERY CODE.", error.message); + return null; + } + }, + findMany: async function (clause = {}) { + try { + const recoveryCodes = await prisma.recovery_codes.findMany({ + where: clause, + }); + return recoveryCodes; + } catch (error) { + console.error("FAILED TO FIND RECOVERY CODES.", error.message); + return null; + } + }, + deleteMany: async function (clause = {}) { + try { + await prisma.recovery_codes.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error("FAILED TO DELETE RECOVERY CODES.", error.message); + return false; + } + }, + hashesForUser: async function (userId = null) { + if (!userId) return []; + return (await this.findMany({ user_id: userId })).map( + (recovery) => recovery.code_hash + ); + }, +}; + +const PasswordResetToken = { + tablename: "password_reset_tokens", + resetExpiryMs: 600_000, // 10 minutes in ms; + writable: [], + calcExpiry: function () { + return new Date(Date.now() + this.resetExpiryMs); + }, + create: async function (userId) { + try { + const passwordResetToken = await prisma.password_reset_tokens.create({ + data: { user_id: userId, token: v4(), expiresAt: this.calcExpiry() }, + }); + return { passwordResetToken, error: null }; + } catch (error) { + console.error("FAILED TO CREATE PASSWORD RESET TOKEN.", error.message); + return { passwordResetToken: null, error: error.message }; + } + }, + findUnique: async function (clause = {}) { + try { + const passwordResetToken = await prisma.password_reset_tokens.findUnique({ + where: clause, + }); + return passwordResetToken; + } catch (error) { + console.error("FAILED TO FIND PASSWORD RESET TOKEN.", error.message); + return null; + } + }, + deleteMany: async function (clause = {}) { + try { + await prisma.password_reset_tokens.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error("FAILED TO DELETE PASSWORD RESET TOKEN.", error.message); + return false; + } + }, +}; + +module.exports = { + RecoveryCode, + PasswordResetToken, +}; diff --git a/server/prisma/migrations/20240425004220_init/migration.sql b/server/prisma/migrations/20240425004220_init/migration.sql new file mode 100644 index 0000000000000000000000000000000000000000..14ec7643f9734c82f5d421f0fe8a3afb55056613 --- /dev/null +++ b/server/prisma/migrations/20240425004220_init/migration.sql @@ -0,0 +1,30 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "seen_recovery_codes" BOOLEAN DEFAULT false; + +-- CreateTable +CREATE TABLE "recovery_codes" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "user_id" INTEGER NOT NULL, + "code_hash" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "recovery_codes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "password_reset_tokens" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "user_id" INTEGER NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "recovery_codes_user_id_idx" ON "recovery_codes"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "password_reset_tokens_token_key" ON "password_reset_tokens"("token"); + +-- CreateIndex +CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens"("user_id"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index a07eb005852c17c1292c9e7b8f63d7daa60815d3..6c8689b9ef557d86edf7fea464b327ca3f77553e 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -62,6 +62,7 @@ model users { pfpFilename String? role String @default("default") suspended Int @default(0) + seen_recovery_codes Boolean? @default(false) createdAt DateTime @default(now()) lastUpdatedAt DateTime @default(now()) workspace_chats workspace_chats[] @@ -69,9 +70,32 @@ model users { embed_configs embed_configs[] embed_chats embed_chats[] threads workspace_threads[] + recovery_codes recovery_codes[] + password_reset_tokens password_reset_tokens[] workspace_agent_invocations workspace_agent_invocations[] } +model recovery_codes { + id Int @id @default(autoincrement()) + user_id Int + code_hash String + createdAt DateTime @default(now()) + user users @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@index([user_id]) +} + +model password_reset_tokens { + id Int @id @default(autoincrement()) + user_id Int + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + user users @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@index([user_id]) +} + model document_vectors { id Int @id @default(autoincrement()) docId String diff --git a/server/utils/PasswordRecovery/index.js b/server/utils/PasswordRecovery/index.js new file mode 100644 index 0000000000000000000000000000000000000000..fbcbe579971ecb888a322e426a5416e2c94a5f27 --- /dev/null +++ b/server/utils/PasswordRecovery/index.js @@ -0,0 +1,98 @@ +const bcrypt = require("bcrypt"); +const { v4, validate } = require("uuid"); +const { User } = require("../../models/user"); +const { + RecoveryCode, + PasswordResetToken, +} = require("../../models/passwordRecovery"); + +async function generateRecoveryCodes(userId) { + const newRecoveryCodes = []; + const plainTextCodes = []; + for (let i = 0; i < 4; i++) { + const code = v4(); + const hashedCode = bcrypt.hashSync(code, 10); + newRecoveryCodes.push({ + user_id: userId, + code_hash: hashedCode, + }); + plainTextCodes.push(code); + } + + const { error } = await RecoveryCode.createMany(newRecoveryCodes); + if (!!error) throw new Error(error); + + const { success } = await User.update(userId, { + seen_recovery_codes: true, + }); + if (!success) throw new Error("Failed to generate user recovery codes!"); + + return plainTextCodes; +} + +async function recoverAccount(username = "", recoveryCodes = []) { + const user = await User.get({ username: String(username) }); + if (!user) return { success: false, error: "Invalid recovery codes." }; + + // If hashes do not exist for a user + // because this is a user who has not logged out and back in since upgrade. + const allUserHashes = await RecoveryCode.hashesForUser(user.id); + if (allUserHashes.length < 4) + return { success: false, error: "Invalid recovery codes" }; + + // If they tried to send more than two unique codes, we only take the first two + const uniqueRecoveryCodes = [...new Set(recoveryCodes)] + .map((code) => code.trim()) + .filter((code) => validate(code)) // we know that any provided code must be a uuid v4. + .slice(0, 2); + if (uniqueRecoveryCodes.length !== 2) + return { success: false, error: "Invalid recovery codes." }; + + const validCodes = uniqueRecoveryCodes.every((code) => { + let valid = false; + allUserHashes.forEach((hash) => { + if (bcrypt.compareSync(code, hash)) valid = true; + }); + return valid; + }); + if (!validCodes) return { success: false, error: "Invalid recovery codes" }; + + const { passwordResetToken, error } = await PasswordResetToken.create( + user.id + ); + if (!!error) return { success: false, error }; + return { success: true, resetToken: passwordResetToken.token }; +} + +async function resetPassword(token, _newPassword = "", confirmPassword = "") { + const newPassword = String(_newPassword).trim(); // No spaces in passwords + if (!newPassword) throw new Error("Invalid password."); + if (newPassword !== String(confirmPassword)) + throw new Error("Passwords do not match"); + + const resetToken = await PasswordResetToken.findUnique({ + token: String(token), + }); + if (!resetToken || resetToken.expiresAt < new Date()) { + return { success: false, message: "Invalid reset token" }; + } + + // JOI password rules will be enforced inside .update. + const { error } = await User.update(resetToken.user_id, { + password: newPassword, + seen_recovery_codes: false, + }); + + if (error) return { success: false, message: error }; + await PasswordResetToken.deleteMany({ user_id: resetToken.user_id }); + await RecoveryCode.deleteMany({ user_id: resetToken.user_id }); + + // New codes are provided on first new login. + return { success: true, message: "Password reset successful" }; +} + +module.exports = { + recoverAccount, + resetPassword, + generateRecoveryCodes, +}; diff --git a/server/utils/middleware/multiUserProtected.js b/server/utils/middleware/multiUserProtected.js index f8a28c962b7708742f7a1b993d82e4df0372e07f..4f128ace14135a161040631c86093c6924a837b1 100644 --- a/server/utils/middleware/multiUserProtected.js +++ b/server/utils/middleware/multiUserProtected.js @@ -64,8 +64,24 @@ function flexUserRoleValid(allowedRoles = DEFAULT_ROLES) { }; } +// Middleware check on a public route if the instance is in a valid +// multi-user set up. +async function isMultiUserSetup(_request, response, next) { + const multiUserMode = await SystemSettings.isMultiUserMode(); + if (!multiUserMode) { + response.status(403).json({ + error: "Invalid request", + }); + return; + } + + next(); + return; +} + module.exports = { ROLES, strictMultiUserRoleValid, flexUserRoleValid, + isMultiUserSetup, };