[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:
Sean Hatfield 2024-04-25 16:52:30 -07:00 committed by GitHub
parent dfaaf1680f
commit 11f6419c3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1109 additions and 105 deletions

View File

@ -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>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -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>

View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@ -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(),

View File

@ -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":

View File

@ -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])],

View 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,
};

View 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");

View File

@ -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

View 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,
};

View File

@ -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,
};