mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2024-11-10 17:00:11 +01:00
[FEAT] Implement new login screen UI & multi-user password reset (#1074)
* WIP new login screen UI * update prisma schema/create new models for pw recovery * WIP password recovery backend * WIP reset password flow * WIP pw reset flow * password reset logic complete & functional UI * WIP login screen redesign for single and multi user * create placeholder modal to display recovery codes * implement UI for recovery code modals/download recovery codes * multiuser desktop password reset UI/functionality complete * support single user mode for pw reset * mobile styles for all password reset/login flows complete * lint * remove single user password recovery * create PasswordRecovery util file to make more readable * do not drop-replace users table in migration * review pr --------- Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
parent
dfaaf1680f
commit
11f6419c3c
@ -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>
|
||||
);
|
||||
}
|
@ -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);
|
||||
};
|
||||
|
||||
const handleDownloadComplete = () => setDownloadComplete(true);
|
||||
const handleResetPassword = () => setShowRecoveryForm(true);
|
||||
const handleRecoverySubmit = async (username, recoveryCodes) => {
|
||||
const { success, resetToken, error } = await System.recoverAccount(
|
||||
username,
|
||||
recoveryCodes
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
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 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>
|
||||
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">
|
||||
Error: {error}
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
</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>
|
||||
{error && (
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">
|
||||
Error: {error}
|
||||
<>
|
||||
<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 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>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
174
frontend/src/media/illustrations/login-illustration.svg
Normal file
174
frontend/src/media/illustrations/login-illustration.svg
Normal file
@ -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>
|
After Width: | Height: | Size: 12 KiB |
37
frontend/src/media/illustrations/login-logo.svg
Normal file
37
frontend/src/media/illustrations/login-logo.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 9.8 KiB |
@ -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(),
|
||||
|
@ -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":
|
||||
|
@ -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])],
|
||||
|
115
server/models/passwordRecovery.js
Normal file
115
server/models/passwordRecovery.js
Normal file
@ -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,
|
||||
};
|
30
server/prisma/migrations/20240425004220_init/migration.sql
Normal file
30
server/prisma/migrations/20240425004220_init/migration.sql
Normal file
@ -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");
|
@ -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
|
||||
|
98
server/utils/PasswordRecovery/index.js
Normal file
98
server/utils/PasswordRecovery/index.js
Normal file
@ -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,
|
||||
};
|
@ -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,
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user