feat: Auth and User System (#205)

* server-side JWT auth implementation

* move oauth routes and strategies, fix bugs

* backend modifications for wiring up the frontend login and reg forms

* Add frontend data services for login and registration

* Add login and registration forms

* Implment auth context, functional client side auth

* protect routes with jwt auth

* finish local strategy (using local storage)

* Start setting up google auth

* disable token refresh, remove old auth middleware

* refactor client, add ApiErrorBoundary context

* disable google and facebook strategies

* fix: fix presets not displaying specific to user

* fix: fix issue with browser refresh

* fix: casing issue with User.js (#11)

* delete user.js to be renamed

* fix: fix casing issue with User.js

* comment out api error watcher temporarily

* fix: issue with api error watcher (#12)

* delete user.js to be renamed

* fix: fix casing issue with User.js

* comment out api error watcher temporarily

* feat: add google auth social login

* fix: make google login url dynamic based on dev/prod

* fix: bug where UI is briefly displayed before redirecting to login

* fix: fix cookie expires value for local auth

* Update README.md

* Update LOCAL_INSTALL structure

* Add local testing instructions

* Only load google strategy if client id and secret are provided

* Update .env.example files with new params

* fix issue with not redirecting to register form

* only show google login button if value is set in .env

* cleanup log messages

* Add label to button for google login on login form

* doc: fix client/server url values in .env.example

* feat: add error message details to registration failure

* Restore preventing paste on confirm password

* auto-login user after registering

* feat: forgot password (#24)

* make login/reg pages look like openai's

* add password reset data services

* new form designs similar to openai, add password reset pages

* add api's for password reset

* email utils for password reset

* remove bcrypt salt rounds from process.env

* refactor: restructure api auth code, consolidate routes (#25)

* add api's for password reset

* remove bcrypt salt rounds from process.env

* refactor: consolidate auth routes, use controller pattern

* refactor: code cleanup

* feat: migrate data to first user (#26)

* refactor: use /api for auth routes

* fix: use user id instead of username

* feat: migrate data to first user on register

* fix: fix social login routes after refactor (#27)

* refactor: use /api for auth routes

* fix: use user id instead of username

* feat: migrate data to first user on register

* fix: fix social login routes

* fix: issue with auto-login when logging out then logging in with new browser window (#28)

* refactor: use /api for auth routes

* fix: use user id instead of username

* feat: migrate data to first user on register

* fix: fix social login routes

* fix: fix issue with auto-login in new tab

* doc: Update README and .env.example files with user system information (#29)

* refactor: use /api for auth routes

* fix: use user id instead of username

* feat: migrate data to first user on register

* fix: fix social login routes

* fix: fix issue with auto-login in new tab

* doc: update README and .env.example files

* Fixup: LOCAL_INSTALL.md PS instructions (#200) (#30)

Co-authored-by: alfredo-f <alfredo.fomitchenko@mail.polimi.it>

* feat: send user with completion to protect against abuse (#31)

* Fixup: LOCAL_INSTALL.md PS instructions (#200)

* server-side JWT auth implementation

* move oauth routes and strategies, fix bugs

* backend modifications for wiring up the frontend login and reg forms

* Add frontend data services for login and registration

* Add login and registration forms

* Implment auth context, functional client side auth

* protect routes with jwt auth

* finish local strategy (using local storage)

* Start setting up google auth

* disable token refresh, remove old auth middleware

* refactor client, add ApiErrorBoundary context

* disable google and facebook strategies

* fix: fix presets not displaying specific to user

* fix: fix issue with browser refresh

* fix: casing issue with User.js (#11)

* delete user.js to be renamed

* fix: fix casing issue with User.js

* comment out api error watcher temporarily

* feat: add google auth social login

* fix: make google login url dynamic based on dev/prod

* fix: bug where UI is briefly displayed before redirecting to login

* fix: fix cookie expires value for local auth

* Only load google strategy if client id and secret are provided

* Update .env.example files with new params

* fix issue with not redirecting to register form

* only show google login button if value is set in .env

* cleanup log messages

* Add label to button for google login on login form

* doc: fix client/server url values in .env.example

* feat: add error message details to registration failure

* Restore preventing paste on confirm password

* auto-login user after registering

* feat: forgot password (#24)

* make login/reg pages look like openai's

* add password reset data services

* new form designs similar to openai, add password reset pages

* add api's for password reset

* email utils for password reset

* remove bcrypt salt rounds from process.env

* refactor: restructure api auth code, consolidate routes (#25)

* add api's for password reset

* remove bcrypt salt rounds from process.env

* refactor: consolidate auth routes, use controller pattern

* refactor: code cleanup

* feat: migrate data to first user (#26)

* refactor: use /api for auth routes

* fix: use user id instead of username

* feat: migrate data to first user on register

* fix: fix social login routes after refactor (#27)

* refactor: use /api for auth routes

* fix: use user id instead of username

* feat: migrate data to first user on register

* fix: fix social login routes

* fix: issue with auto-login when logging out then logging in with new browser window (#28)

* refactor: use /api for auth routes

* fix: use user id instead of username

* feat: migrate data to first user on register

* fix: fix social login routes

* fix: fix issue with auto-login in new tab

* doc: Update README and .env.example files with user system information (#29)

* refactor: use /api for auth routes

* fix: use user id instead of username

* feat: migrate data to first user on register

* fix: fix social login routes

* fix: fix issue with auto-login in new tab

* doc: update README and .env.example files

* Send user id to openai to protect against abuse

* add meilisearch to gitignore

* Remove webpack

---------

Co-authored-by: alfredo-f <alfredo.fomitchenko@mail.polimi.it>

---------

Co-authored-by: Danny Avila <110412045+danny-avila@users.noreply.github.com>
Co-authored-by: Alfredo Fomitchenko <alfredo.fomitchenko@mail.polimi.it>
This commit is contained in:
Dan Orlando 2023-05-07 10:04:51 -07:00 committed by GitHub
parent 65543eb084
commit dac19038a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 3968 additions and 3394 deletions

View file

@ -1,94 +1,93 @@
import { useEffect } from 'react';
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import { createBrowserRouter, RouterProvider, Navigate, Outlet } from 'react-router-dom';
import Root from './routes/Root';
import Chat from './routes/Chat';
import Search from './routes/Search';
import store from './store';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { ScreenshotProvider } from './utils/screenshotContext.jsx';
import { useGetSearchEnabledQuery, useGetUserQuery, useGetEndpointsQuery, useGetPresetsQuery} from '~/data-provider';
import {ReactQueryDevtools} from '@tanstack/react-query-devtools';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Login, Registration, RequestPasswordReset, ResetPassword } from './components/Auth';
import { AuthContextProvider } from './hooks/AuthContext';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
import { ThemeProvider } from './hooks/ThemeContext';
import { useApiErrorBoundary } from './hooks/ApiErrorBoundaryContext';
import ApiErrorWatcher from './components/Auth/ApiErrorWatcher';
const AuthLayout = () => (
<AuthContextProvider>
<Outlet />
<ApiErrorWatcher />
</AuthContextProvider>
);
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
path: 'register',
element: <Registration />
},
{
path: 'forgot-password',
element: <RequestPasswordReset />
},
{
path: 'reset-password',
element: <ResetPassword />
},
{
element: <AuthLayout />,
children: [
{
index: true,
element: (
<Navigate
to="/chat/new"
replace={true}
/>
)
path: 'login',
element: <Login />
},
{
path: 'chat/:conversationId?',
element: <Chat />
},
{
path: 'search/:query?',
element: <Search />
path: '/',
element: <Root />,
children: [
{
index: true,
element: (
<Navigate
to="/chat/new"
replace={true}
/>
)
},
{
path: 'chat/:conversationId?',
element: <Chat />
},
{
path: 'search/:query?',
element: <Search />
}
]
}
]
}
]);
const App = () => {
const [user, setUser] = useRecoilState(store.user);
const setIsSearchEnabled = useSetRecoilState(store.isSearchEnabled);
const setEndpointsConfig = useSetRecoilState(store.endpointsConfig);
const setPresets = useSetRecoilState(store.presets);
const { setError } = useApiErrorBoundary();
const searchEnabledQuery = useGetSearchEnabledQuery();
const userQuery = useGetUserQuery();
const endpointsQuery = useGetEndpointsQuery();
const presetsQuery = useGetPresetsQuery();
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: error => {
if (error?.response?.status === 401) {
setError(error);
}
}
})
});
useEffect(() => {
if(endpointsQuery.data) {
setEndpointsConfig(endpointsQuery.data);
} else if(endpointsQuery.isError) {
console.error("Failed to get endpoints", endpointsQuery.error);
window.location.href = '/auth/login';
}
}, [endpointsQuery.data, endpointsQuery.isError]);
useEffect(() => {
if(presetsQuery.data) {
setPresets(presetsQuery.data);
} else if(presetsQuery.isError) {
console.error("Failed to get presets", presetsQuery.error);
window.location.href = '/auth/login';
}
}, [presetsQuery.data, presetsQuery.isError]);
useEffect(() => {
if (searchEnabledQuery.data) {
setIsSearchEnabled(searchEnabledQuery.data);
} else if(searchEnabledQuery.isError) {
console.error("Failed to get search enabled", searchEnabledQuery.error);
}
}, [searchEnabledQuery.data, searchEnabledQuery.isError]);
useEffect(() => {
if (userQuery.data) {
setUser(userQuery.data);
} else if(userQuery.isError) {
console.error("Failed to get user", userQuery.error);
window.location.href = '/auth/login';
}
}, [userQuery.data, userQuery.isError]);
if (user)
return (
<>
<RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={false} />
</>
);
else return <div className="flex h-screen"></div>;
return (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<ThemeProvider>
<RouterProvider router={router} />
<ReactQueryDevtools initialIsOpen={false} />
</ThemeProvider>
</RecoilRoot>
</QueryClientProvider>
);
};
export default () => (

View file

@ -0,0 +1,18 @@
import React from 'react';
import { useApiErrorBoundary } from '~/hooks/ApiErrorBoundaryContext';
import { useNavigate } from 'react-router-dom';
const ApiErrorWatcher = () => {
const { error } = useApiErrorBoundary();
const navigate = useNavigate();
React.useEffect(() => {
if (error?.response?.status === 500) {
// do something with error
// navigate('/login');
}
}, [error, navigate]);
return null;
};
export default ApiErrorWatcher;

View file

@ -0,0 +1,184 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { TLoginUser } from "~/data-provider";
import { useAuthContext } from "~/hooks/AuthContext";
import { useNavigate } from "react-router-dom";
function Login() {
const { login, error, isAuthenticated } = useAuthContext();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<TLoginUser>();
const navigate = useNavigate();
useEffect(() => {
if (isAuthenticated) {
navigate("/chat/new");
}
}, [isAuthenticated, navigate])
const SERVER_URL = import.meta.env.DEV
? import.meta.env.VITE_SERVER_URL_DEV
: import.meta.env.VITE_SERVER_URL_PROD;
const showGoogleLogin =
import.meta.env.VITE_SHOW_GOOGLE_LOGIN_OPTION === "true";
return (
<div className="flex min-h-screen flex-col items-center pt-6 justify-center sm:pt-0 bg-white">
<div className="mt-6 overflow-hidden bg-white px-6 py-4 sm:max-w-md sm:rounded-lg w-96">
<h1 className="text-center text-3xl font-semibold mb-4">Welcome back</h1>
{error && (
<div
className="mt-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
Unable to login with the information provided. Please check your
credentials and try again.
</div>
)}
<form
className="mt-6"
aria-label="Login form"
method="POST"
onSubmit={handleSubmit((data) => login(data))}
>
<div className="mb-2">
<div className="relative">
<input
type="email"
id="email"
autoComplete="email"
aria-label="Email"
{...register("email", {
required: "Email is required",
minLength: {
value: 3,
message: "Email must be at least 6 characters",
},
maxLength: {
value: 120,
message: "Email should not be longer than 120 characters",
},
pattern: {
value: /\S+@\S+\.\S+/,
message: "You must enter a valid email address",
},
})}
aria-invalid={!!errors.email}
className="block rounded-t-md px-2.5 pb-2.5 pt-5 w-full text-sm text-gray-900 bg-gray-50 border-0 border-b-2 border-gray-300 appearance-none focus:outline-none focus:ring-0 focus:border-green-500 peer"
placeholder=" "
></input>
<label
htmlFor="email"
className="absolute text-gray-500 duration-300 transform -translate-y-4 scale-75 top-4 z-10 origin-[0] left-2.5 peer-focus:text-green-500 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-4"
>
Email address
</label>
</div>
{errors.email && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.email.message}
</span>
)}
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
id="password"
autoComplete="current-password"
aria-label="Password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
maxLength: {
value: 40,
message: "Password must be less than 40 characters",
},
})}
aria-invalid={!!errors.password}
className="block rounded-t-md px-2.5 pb-2.5 pt-5 w-full text-sm text-gray-900 bg-gray-50 border-0 border-b-2 border-gray-300 appearance-none focus:outline-none focus:ring-0 focus:border-green-500 peer"
placeholder=" "
></input>
<label
htmlFor="password"
className="absolute text-gray-500 duration-300 transform -translate-y-4 scale-75 top-4 z-10 origin-[0] left-2.5 peer-focus:text-green-500 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-4"
>
Password
</label>
</div>
{errors.password && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.password.message}
</span>
)}
</div>
<a
href="/forgot-password"
className="text-sm text-green-500 hover:underline"
>
Forgot Password?
</a>
<div className="mt-6">
<button
aria-label="Sign in"
type="submit"
className="w-full transform rounded-sm bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-600 focus:bg-green-600 focus:outline-none"
>
Continue
</button>
</div>
</form>
<p className="my-4 text-center text-sm font-light text-gray-700">
{" "}
Don't have an account?{" "}
<a
href="/register"
className="p-1 text-green-500 hover:underline"
>
Sign up
</a>
</p>
{showGoogleLogin && (
<>
<div className="relative mt-6 flex w-full items-center justify-center border border-t uppercase">
<div className="absolute text-xs bg-white px-3">Or</div>
</div>
<div className="mt-4 flex gap-x-2">
<a
aria-label="Login with Google"
className="flex w-full items-center justify-left space-x-3 rounded-md border border-gray-300 py-3 px-5 focus:ring-2 focus:ring-violet-600 focus:ring-offset-1 hover:bg-gray-50"
href={`${SERVER_URL}/oauth/google`}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="google" className="w-5 h-5"><path fill="#fbbb00" d="M113.47 309.408 95.648 375.94l-65.139 1.378C11.042 341.211 0 299.9 0 256c0-42.451 10.324-82.483 28.624-117.732h.014L86.63 148.9l25.404 57.644c-5.317 15.501-8.215 32.141-8.215 49.456.002 18.792 3.406 36.797 9.651 53.408z"></path><path fill="#518ef8" d="M507.527 208.176C510.467 223.662 512 239.655 512 256c0 18.328-1.927 36.206-5.598 53.451-12.462 58.683-45.025 109.925-90.134 146.187l-.014-.014-73.044-3.727-10.338-64.535c29.932-17.554 53.324-45.025 65.646-77.911h-136.89V208.176h245.899z"></path><path fill="#28b446" d="m416.253 455.624.014.014C372.396 490.901 316.666 512 256 512c-97.491 0-182.252-54.491-225.491-134.681l82.961-67.91c21.619 57.698 77.278 98.771 142.53 98.771 28.047 0 54.323-7.582 76.87-20.818l83.383 68.262z"></path><path fill="#f14336" d="m419.404 58.936-82.933 67.896C313.136 112.246 285.552 103.82 256 103.82c-66.729 0-123.429 42.957-143.965 102.724l-83.397-68.276h-.014C71.23 56.123 157.06 0 256 0c62.115 0 119.068 22.126 163.404 58.936z"></path></svg>
<p>Login with Google</p>
</a>
{/* <a
aria-label="Login with Facebook"
className="flex w-full items-center justify-center rounded-md border border-gray-600 p-2 focus:ring-2 focus:ring-violet-600 focus:ring-offset-1"
href="http://localhost:3080/auth/facebook">
<FontAwesomeIcon
icon={faFacebook}
size={'lg'}
/>
</a> */}
</div>
</>
)}
</div>
</div>
);
}
export default Login;

View file

@ -0,0 +1,315 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm } from "react-hook-form";
import { useRegisterUserMutation, TRegisterUser } from "~/data-provider";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFacebook } from "@fortawesome/free-brands-svg-icons";
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
function Registration() {
const SERVER_URL = import.meta.env.DEV
? import.meta.env.VITE_SERVER_URL_DEV
: import.meta.env.VITE_SERVER_URL_PROD;
const showGoogleLogin =
import.meta.env.VITE_SHOW_GOOGLE_LOGIN_OPTION === "true";
const navigate = useNavigate();
const {
register,
watch,
handleSubmit,
formState: { errors },
} = useForm<TRegisterUser>({ mode: "onChange" });
const [error, setError] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>("");
const registerUser = useRegisterUserMutation();
const password = watch("password");
const onRegisterUserFormSubmit = (data: TRegisterUser) => {
registerUser.mutate(data, {
onSuccess: () => {
navigate("/chat/new");
},
onError: (error) => {
setError(true);
if (error.response?.data?.message) {
setErrorMessage(error.response?.data?.message);
}
},
});
};
return (
<div className="flex min-h-screen flex-col items-center pt-6 justify-center sm:pt-0 bg-white">
<div className="mt-6 overflow-hidden bg-white px-6 py-4 sm:max-w-md sm:rounded-lg w-96">
<h1 className="text-center text-3xl font-semibold mb-4">
Create your account
</h1>
{error && (
<div
className="mt-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
There was an error attempting to register your account. Please try
again. {errorMessage}
</div>
)}
<form
className="mt-6"
aria-label="Registration form"
method="POST"
onSubmit={handleSubmit((data) => onRegisterUserFormSubmit(data))}
>
<div className="mb-2">
<div className="relative">
<input
id="name"
type="text"
autoComplete="name"
aria-label="Name"
// uncomment to prevent pasting in confirm field
onPaste={(e) => {
e.preventDefault();
return false;
}}
{...register("name", {
required: "Name is required",
minLength: {
value: 3,
message: "Name must be at least 3 characters",
},
maxLength: {
value: 80,
message: "Name must be less than 80 characters",
},
})}
aria-invalid={!!errors.name}
className="block rounded-t-md px-2.5 pb-2.5 pt-5 w-full text-sm text-gray-900 bg-gray-50 border-0 border-b-2 border-gray-300 appearance-none focus:outline-none focus:ring-0 focus:border-green-500 peer"
placeholder=" "
></input>
<label
htmlFor="name"
className="absolute text-sm text-gray-500 duration-300 transform -translate-y-4 scale-75 top-4 z-10 origin-[0] left-2.5 peer-focus:text-green-500 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-4"
>
Full Name
</label>
</div>
{errors.name && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.name.message}
</span>
)}
</div>
<div className="mb-2">
<div className="relative">
<input
type="text"
id="username"
aria-label="Username"
{...register("username", {
required: "Username is required",
minLength: {
value: 3,
message: "Username must be at least 3 characters",
},
maxLength: {
value: 20,
message: "Username must be less than 20 characters",
},
})}
aria-invalid={!!errors.username}
className="block rounded-t-md px-2.5 pb-2.5 pt-5 w-full text-sm text-gray-900 bg-gray-50 border-0 border-b-2 border-gray-300 appearance-none focus:outline-none focus:ring-0 focus:border-green-500 peer"
placeholder=" "
autoComplete="off"
></input>
<label
htmlFor="username"
className="absolute text-sm text-gray-500 duration-300 transform -translate-y-4 scale-75 top-4 z-10 origin-[0] left-2.5 peer-focus:text-green-500 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-4"
>
Username
</label>
</div>
{errors.username && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.username.message}
</span>
)}
</div>
<div className="mb-2">
<div className="relative">
<input
type="email"
id="email"
autoComplete="email"
aria-label="Email"
{...register("email", {
required: "Email is required",
minLength: {
value: 3,
message: "Email must be at least 6 characters",
},
maxLength: {
value: 120,
message: "Email should not be longer than 120 characters",
},
pattern: {
value: /\S+@\S+\.\S+/,
message: "You must enter a valid email address",
},
})}
aria-invalid={!!errors.email}
className="block rounded-t-md px-2.5 pb-2.5 pt-5 w-full text-sm text-gray-900 bg-gray-50 border-0 border-b-2 border-gray-300 appearance-none focus:outline-none focus:ring-0 focus:border-green-500 peer"
placeholder=" "
></input>
<label
htmlFor="email"
className="absolute text-sm text-gray-500 duration-300 transform -translate-y-4 scale-75 top-4 z-10 origin-[0] left-2.5 peer-focus:text-green-500 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-4"
>
Email
</label>
</div>
{errors.email && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.email.message}
</span>
)}
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
id="password"
autoComplete="current-password"
aria-label="Password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
maxLength: {
value: 40,
message: "Password must be less than 40 characters",
},
})}
aria-invalid={!!errors.password}
className="block rounded-t-md px-2.5 pb-2.5 pt-5 w-full text-sm text-gray-900 bg-gray-50 border-0 border-b-2 border-gray-300 appearance-none focus:outline-none focus:ring-0 focus:border-green-500 peer"
placeholder=" "
></input>
<label
htmlFor="password"
className="absolute text-sm text-gray-500 duration-300 transform -translate-y-4 scale-75 top-4 z-10 origin-[0] left-2.5 peer-focus:text-green-500 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-4"
>
Password
</label>
</div>
{errors.password && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.password.message}
</span>
)}
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
id="confirm_password"
aria-label="Confirm Password"
// uncomment to prevent pasting in confirm field
onPaste={(e) => {
e.preventDefault();
return false;
}}
{...register("confirm_password", {
validate: (value) =>
value === password || "Passwords do not match",
})}
aria-invalid={!!errors.confirm_password}
className="block rounded-t-md px-2.5 pb-2.5 pt-5 w-full text-sm text-gray-900 bg-gray-50 border-0 border-b-2 border-gray-300 appearance-none focus:outline-none focus:ring-0 focus:border-green-500 peer"
placeholder=" "
></input>
<label
htmlFor="confirm_password"
className="absolute text-sm text-gray-500 duration-300 transform -translate-y-4 scale-75 top-4 z-10 origin-[0] left-2.5 peer-focus:text-green-500 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-4"
>
Confirm Password
</label>
</div>
{errors.confirm_password && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.confirm_password.message}
</span>
)}
</div>
<div className="mt-6">
<button
disabled={
!!errors.email ||
!!errors.name ||
!!errors.password ||
!!errors.username ||
!!errors.confirm_password
}
type="submit"
aria-label="Submit registration"
className="w-full transform rounded-sm bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-600 focus:bg-green-600 focus:outline-none"
>
Continue
</button>
</div>
</form>
<p className="my-4 text-center text-sm font-light text-gray-700">
{" "}
Already have an account?{" "}
<a
href="/login"
className="font-medium text-green-500 p-1 hover:underline"
>
Login
</a>
</p>
{showGoogleLogin && (
<>
<div className="relative mt-6 flex w-full items-center justify-center border border-t uppercase">
<div className="absolute text-xs bg-white px-3">Or</div>
</div>
<div className="mt-4 flex gap-x-2">
<a
aria-label="Login with Google"
href={`${SERVER_URL}/oauth/google`}
className="flex w-full items-center justify-left space-x-3 rounded-md border border-gray-300 py-3 px-5 focus:ring-2 focus:ring-violet-600 focus:ring-offset-1 hover:bg-gray-50"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" id="google" className="w-5 h-5"><path fill="#fbbb00" d="M113.47 309.408 95.648 375.94l-65.139 1.378C11.042 341.211 0 299.9 0 256c0-42.451 10.324-82.483 28.624-117.732h.014L86.63 148.9l25.404 57.644c-5.317 15.501-8.215 32.141-8.215 49.456.002 18.792 3.406 36.797 9.651 53.408z"></path><path fill="#518ef8" d="M507.527 208.176C510.467 223.662 512 239.655 512 256c0 18.328-1.927 36.206-5.598 53.451-12.462 58.683-45.025 109.925-90.134 146.187l-.014-.014-73.044-3.727-10.338-64.535c29.932-17.554 53.324-45.025 65.646-77.911h-136.89V208.176h245.899z"></path><path fill="#28b446" d="m416.253 455.624.014.014C372.396 490.901 316.666 512 256 512c-97.491 0-182.252-54.491-225.491-134.681l82.961-67.91c21.619 57.698 77.278 98.771 142.53 98.771 28.047 0 54.323-7.582 76.87-20.818l83.383 68.262z"></path><path fill="#f14336" d="m419.404 58.936-82.933 67.896C313.136 112.246 285.552 103.82 256 103.82c-66.729 0-123.429 42.957-143.965 102.724l-83.397-68.276h-.014C71.23 56.123 157.06 0 256 0c62.115 0 119.068 22.126 163.404 58.936z"></path></svg>
<p>Login with Google</p>
</a>
{/* <button
aria-label="Login with Facebook"
role="button"
className="flex w-full items-center justify-center space-x-3 rounded-md border p-4 focus:ring-2 focus:ring-violet-400 focus:ring-offset-1 dark:border-gray-400"
>
<FontAwesomeIcon
icon={faFacebook}
size={'lg'}
/>
<p>Login with Facebook</p>
</button> */}
</div>
</>
)}
</div>
</div>
);
}
export default Registration;

View file

@ -0,0 +1,115 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useRequestPasswordResetMutation, TRequestPasswordReset } from "~/data-provider";
function RequestPasswordReset() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<TRequestPasswordReset>();
const requestPasswordReset = useRequestPasswordResetMutation();
const [success, setSuccess] = useState<boolean>(false);
const [requestError, setRequestError] = useState<boolean>(false);
const [resetLink, setResetLink] = useState<string>("");
const onSubmit = (data: TRequestPasswordReset) => {
requestPasswordReset.mutate(data, {
onSuccess: (data) => {
setSuccess(true);
setResetLink(data.link);
},
onError: () => {
setRequestError(true);
setTimeout(() => {
setRequestError(false);
}, 5000);
}
});
};
return (
<div className="flex min-h-screen flex-col items-center pt-6 justify-center sm:pt-0 bg-white">
<div className="mt-6 overflow-hidden bg-white px-6 py-4 sm:max-w-md sm:rounded-lg w-96">
<h1 className="text-center text-3xl font-semibold mb-4">
Reset your password
</h1>
{success && (
<div
className="mt-4 bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative"
role="alert"
>
Click <a className="text-green-600 hover:underline" href={resetLink}>HERE</a> to reset your password.
{/* An email has been sent with instructions on how to reset your password. */}
</div>
)}
{requestError && (
<div
className="mt-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
There was a problem resetting your password. There was no user found with the email address provided. Please try again.
</div>
)}
<form
className="mt-6"
aria-label="Password reset form"
method="POST"
onSubmit={handleSubmit(onSubmit)}
>
<div className="mb-2">
<div className="relative">
<input
type="email"
id="email"
autoComplete="off"
aria-label="Email"
{...register("email", {
required: "Email is required",
minLength: {
value: 3,
message: "Email must be at least 6 characters",
},
maxLength: {
value: 120,
message: "Email should not be longer than 120 characters",
},
pattern: {
value: /\S+@\S+\.\S+/,
message: "You must enter a valid email address",
},
})}
aria-invalid={!!errors.email}
className="block rounded-t-md px-2.5 pb-2.5 pt-5 w-full text-sm text-gray-900 bg-gray-50 border-0 border-b-2 border-gray-300 appearance-none focus:outline-none focus:ring-0 focus:border-green-500 peer"
placeholder=" "
></input>
<label
htmlFor="email"
className="absolute text-gray-500 duration-300 transform -translate-y-4 scale-75 top-4 z-10 origin-[0] left-2.5 peer-focus:text-green-500 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-4"
>
Email address
</label>
</div>
{errors.email && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.email.message}
</span>
)}
</div>
<div className="mt-6">
<button
type="submit"
disabled={ !!errors.email }
className="w-full py-2 px-4 border border-transparent rounded-sm shadow-sm text-sm font-medium text-white bg-green-500 hover:bg-green-600 focus:outline-none active:bg-green-500"
>
Continue
</button>
</div>
</form>
</div>
</div>
);
}
export default RequestPasswordReset;

View file

@ -0,0 +1,176 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import {useResetPasswordMutation, TResetPassword} from "~/data-provider";
import { useNavigate, useSearchParams } from "react-router-dom";
function ResetPassword() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<TResetPassword>();
const resetPassword = useResetPasswordMutation();
const [resetError, setResetError] = useState<boolean>(false);
const [params] = useSearchParams();
const navigate = useNavigate();
const password = watch("password");
const onSubmit = (data: TResetPassword) => {
resetPassword.mutate(data, {
onError: () => {
setResetError(true);
}
});
};
if (resetPassword.isSuccess) {
return (
<div className="flex min-h-screen flex-col items-center pt-6 justify-center sm:pt-0 bg-white">
<div className="mt-6 overflow-hidden bg-white px-6 py-4 sm:max-w-md sm:rounded-lg w-96">
<h1 className="text-center text-3xl font-semibold mb-4">
Password Reset Success
</h1>
<div
className="mt-4 bg-green-100 border border-green-400 text-center mb-8 text-green-700 px-4 py-3 rounded relative"
role="alert"
>
You may now login with your new password.
</div>
<button
onClick={() => navigate("/login")}
aria-label="Sign in"
className="w-full transform rounded-sm bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-600 focus:bg-green-600 focus:outline-none"
>
Continue
</button>
</div>
</div>
)
}
else {
return (
<div className="flex min-h-screen flex-col items-center pt-6 justify-center sm:pt-0 bg-white">
<div className="mt-6 overflow-hidden bg-white px-6 py-4 sm:max-w-md sm:rounded-lg w-96">
<h1 className="text-center text-3xl font-semibold mb-4">
Reset your password
</h1>
{resetError && (
<div
className="mt-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
This password reset token is no longer valid. <a className="font-semibold hover:underline text-green-600" href="/forgot-password">Click here</a> to try again.
</div>
)}
<form
className="mt-6"
aria-label="Password reset form"
method="POST"
onSubmit={handleSubmit(onSubmit)}
>
<div className="mb-2">
<div className="relative">
<input type="hidden" id="token" value={params.get("token")} {...register("token", { required: "Unable to process: No valid reset token" })} />
<input type="hidden" id="userId" value={params.get("userId")} {...register("userId", { required: "Unable to process: No valid user id" })} />
<input
type="password"
id="password"
autoComplete="current-password"
aria-label="Password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
maxLength: {
value: 40,
message: "Password must be less than 40 characters",
},
})}
aria-invalid={!!errors.password}
className="block rounded-t-md px-2.5 pb-2.5 pt-5 w-full text-sm text-gray-900 bg-gray-50 border-0 border-b-2 border-gray-300 appearance-none focus:outline-none focus:ring-0 focus:border-green-500 peer"
placeholder=" "
></input>
<label
htmlFor="password"
className="absolute text-sm text-gray-500 duration-300 transform -translate-y-4 scale-75 top-4 z-10 origin-[0] left-2.5 peer-focus:text-green-500 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-4"
>
Password
</label>
</div>
{errors.password && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.password.message}
</span>
)}
</div>
<div className="mb-2">
<div className="relative">
<input
type="password"
id="confirm_password"
aria-label="Confirm Password"
// uncomment to prevent pasting in confirm field
onPaste={(e) => {
e.preventDefault();
return false;
}}
{...register("confirm_password", {
validate: (value) =>
value === password || "Passwords do not match",
})}
aria-invalid={!!errors.confirm_password}
className="block rounded-t-md px-2.5 pb-2.5 pt-5 w-full text-sm text-gray-900 bg-gray-50 border-0 border-b-2 border-gray-300 appearance-none focus:outline-none focus:ring-0 focus:border-green-500 peer"
placeholder=" "
></input>
<label
htmlFor="confirm_password"
className="absolute text-sm text-gray-500 duration-300 transform -translate-y-4 scale-75 top-4 z-10 origin-[0] left-2.5 peer-focus:text-green-500 peer-placeholder-shown:scale-100 peer-placeholder-shown:translate-y-0 peer-focus:scale-75 peer-focus:-translate-y-4"
>
Confirm Password
</label>
</div>
{errors.confirm_password && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.confirm_password.message}
</span>
)}
{errors.token && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.token.message}
</span>
)}
{errors.userId && (
<span role="alert" className="mt-1 text-sm text-red-600">
{/* @ts-ignore */}
{errors.userId.message}
</span>
)}
</div>
<div className="mt-6">
<button
disabled={
!!errors.password ||
!!errors.confirm_password
}
type="submit"
aria-label="Submit registration"
className="w-full transform rounded-sm bg-green-500 px-4 py-3 tracking-wide text-white transition-colors duration-200 hover:bg-green-600 focus:bg-green-600 focus:outline-none"
>
Continue
</button>
</div>
</form>
</div>
</div>
)
}
};
export default ResetPassword;

View file

@ -0,0 +1,4 @@
export { default as Login } from './Login';
export { default as Registration } from './Registration';
export { default as RequestPasswordReset } from './RequestPasswordReset';
export { default as ResetPassword } from './ResetPassword';

View file

@ -4,6 +4,7 @@ import { SSE } from '~/data-provider/sse.mjs';
import createPayload from '~/data-provider/createPayload';
import { useAbortRequestWithMessage } from '~/data-provider';
import store from '~/store';
import { useAuthContext } from '~/hooks/AuthContext';
export default function MessageHandler() {
const submission = useRecoilValue(store.submission);
@ -11,6 +12,7 @@ export default function MessageHandler() {
const setMessages = useSetRecoilState(store.messages);
const setConversation = useSetRecoilState(store.conversation);
const resetLatestMessage = useResetRecoilState(store.latestMessage);
const { token } = useAuthContext();
const { refreshConversations } = store.useConversations();
@ -158,7 +160,8 @@ export default function MessageHandler() {
fetch(`/api/ask/${endpoint}/abort`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
abortKey: conversationId
@ -187,7 +190,7 @@ export default function MessageHandler() {
const events = new SSE(server, {
payload: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' }
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`}
});
events.onmessage = e => {

View file

@ -1,22 +1,22 @@
import React from 'react';
import LogOutIcon from '../svg/LogOutIcon';
import { useRecoilValue } from 'recoil';
import store from '~/store';
import { useAuthContext } from '~/hooks/AuthContext';
export default function Logout() {
const user = useRecoilValue(store.user);
const { user, logout } = useAuthContext();
const clickHandler = () => {
window.location.href = '/auth/logout';
const handleLogout = () => {
logout()
window.location.reload();
};
return (
<button
className="flex cursor-pointer items-center gap-3 rounded-md py-3 px-3 text-sm text-white transition-colors duration-200 hover:bg-gray-500/10"
onClick={clickHandler}
onClick={handleLogout}
>
<LogOutIcon />
{user?.display || user?.username || 'USER'}
{user?.username || 'USER'}
<small>Log out</small>
</button>
);

View file

@ -8,10 +8,11 @@ import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useGetConversationsQuery, useSearchQuery } from '~/data-provider';
import useDebounce from '~/hooks/useDebounce';
import store from '~/store';
import { useAuthContext } from '~/hooks/AuthContext';
export default function Nav({ navVisible, setNavVisible }) {
const [isHovering, setIsHovering] = useState(false);
const { isAuthenticated } = useAuthContext();
const containerRef = useRef(null);
const scrollPositionRef = useRef(null);
@ -22,7 +23,7 @@ export default function Nav({ navVisible, setNavVisible }) {
const [pages, setPages] = useState(1);
// data provider
const getConversationsQuery = useGetConversationsQuery(pageNumber);
const getConversationsQuery = useGetConversationsQuery(pageNumber, { enabled: isAuthenticated });
// search
const searchQuery = useRecoilValue(store.searchQuery);

View file

@ -1,5 +1,5 @@
export const user = () => {
return `/api/me`;
return `/api/auth/user`;
};
export const messages = (id: string) => {
@ -49,3 +49,35 @@ export const aiEndpoints = () => {
export const tokenizer = () => {
return `/api/tokenizer`;
}
export const login = () => {
return '/api/auth/login';
}
export const logout = () => {
return '/api/auth/logout';
}
export const register = () => {
return '/api/auth/register';
}
export const loginFacebook = () => {
return '/api/auth/facebook';
}
export const loginGoogle = () => {
return '/api/auth/google';
}
export const refreshToken = () => {
return '/api/auth/refresh';
}
export const requestPasswordReset = () => {
return '/api/auth/requestPasswordReset';
}
export const resetPassword = () => {
return '/api/auth/resetPassword';
}

View file

@ -67,4 +67,32 @@ export const getAIEndpoints = () => {
export const updateTokenCount = (text: string) => {
return request.post(endpoints.tokenizer(), {arg: text});
}
export const login = (payload: t.TLoginUser) => {
return request.post(endpoints.login(), payload);
}
export const logout = () => {
return request.post(endpoints.logout());
}
export const register = (payload: t.TRegisterUser) => {
return request.post(endpoints.register(), payload);
}
export const refreshToken = () => {
return request.post(endpoints.refreshToken());
}
export const getLoginGoogle = () => {
return request.get(endpoints.loginGoogle());
}
export const requestPasswordReset = (payload: t.TRequestPasswordReset) => {
return request.post(endpoints.requestPasswordReset(), payload);
}
export const resetPassword = (payload: t.TResetPassword) => {
return request.post(endpoints.resetPassword(), payload);
}

View file

@ -8,6 +8,7 @@ import {
} from "@tanstack/react-query";
import * as t from "./types";
import * as dataService from "./data-service";
import axios from 'axios';
export enum QueryKeys {
messages = "messsages",
@ -25,11 +26,13 @@ export const useAbortRequestWithMessage = (): UseMutationResult<void, Error, { e
return useMutation(({ endpoint, abortKey, message }) => dataService.abortRequestWithMessage(endpoint, abortKey, message));
};
export const useGetUserQuery = (): QueryObserverResult<t.TUser> => {
export const useGetUserQuery = (config?: UseQueryOptions<t.TUser>): QueryObserverResult<t.TUser> => {
return useQuery<t.TUser>([QueryKeys.user], () => dataService.getUser(), {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
...config,
});
};
@ -120,11 +123,13 @@ export const useClearConversationsMutation = (): UseMutationResult<unknown> => {
});
};
export const useGetConversationsQuery = (pageNumber: string): QueryObserverResult<t.TConversation[]> => {
return useQuery([QueryKeys.allConversations, pageNumber], () =>
export const useGetConversationsQuery = (pageNumber: string, config?: UseQueryOptions<t.TConversation[]>): QueryObserverResult<t.TConversation[]> => {
return useQuery<t.TConversation[]>([QueryKeys.allConversations, pageNumber], () =>
dataService.getConversations(pageNumber), {
refetchOnReconnect: false,
refetchOnMount: false,
retry: 1,
...config,
}
);
}
@ -176,11 +181,12 @@ export const useUpdatePresetMutation = (): UseMutationResult<t.TPreset[], unknow
);
};
export const useGetPresetsQuery = (): QueryObserverResult<t.TPreset[], unknown> => {
return useQuery([QueryKeys.presets], () => dataService.getPresets(), {
export const useGetPresetsQuery = (config?: UseQueryOptions<t.TPreset[]>): QueryObserverResult<t.TPreset[], unknown> => {
return useQuery<t.TPreset[]>([QueryKeys.presets], () => dataService.getPresets(), {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
...config,
});
};
@ -223,4 +229,52 @@ export const useUpdateTokenCountMutation = (): UseMutationResult<t.TUpdateTokenC
},
}
);
}
export const useLoginUserMutation = (): UseMutationResult<t.TLoginUserResponse, unknown, t.TLoginUserRequest, unknown> => {
const queryClient = useQueryClient();
return useMutation(
(payload: t.TLoginUserRequest) =>
dataService.login(payload),
{
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.user]);
},
}
);
}
export const useRegisterUserMutation = (): UseMutationResult<t.TRegisterUserResponse, unknown, t.TRegisterUser, unknown> => {
const queryClient = useQueryClient();
return useMutation(
(payload: t.TRegisterUser) =>
dataService.register(payload),
{
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.user]);
},
}
);
}
export const useLogoutUserMutation = (): UseMutationResult<unknown> => {
const queryClient = useQueryClient();
return useMutation(() => dataService.logout(), {
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.user]);
},
});
}
export const useRefreshTokenMutation = (): UseMutationResult<t.TRefreshTokenResponse, unknown, unknown, unknown> => {
return useMutation(() => dataService.refreshToken(), {
});
}
export const useRequestPasswordResetMutation = (): UseMutationResult<unknown> => {
return useMutation((payload: t.TRequestPasswordReset) => dataService.requestPasswordReset(payload));
}
export const useResetPasswordMutation = (): UseMutationResult<unknown> => {
return useMutation((payload: t.TResetPassword) => dataService.resetPassword(payload));
}

View file

@ -1,7 +1,7 @@
import axios, { AxiosRequestConfig } from "axios";
async function _get<T>(url: string, options?: AxiosRequestConfig): Promise<T> {
const response = await axios.get(url, { withCredentials: true, ...options});
const response = await axios.get(url, { ...options});
return response.data;
}

View file

@ -98,8 +98,14 @@ export type TPreset = {
}
export type TUser = {
id: string,
username: string,
display: string
email: string,
name: string,
avatar: string,
role: string,
createdAt: string,
updatedAt: string,
};
export type TGetConversationsResponse = {
@ -160,4 +166,31 @@ export type TMessageTreeNode = {}
export type TSearchMessage = {}
export type TSearchMessageTreeNode = {}
export type TSearchMessageTreeNode = {}
export type TRegisterUser = {
name: string,
email: string,
username: string,
password: string,
}
export type TLoginUser = {
email: string,
password: string,
}
export type TLoginResponse = {
token: string,
user: TUser
}
export type TRequestPasswordReset = {
email: string,
}
export type TResetPassword = {
userId: string,
token: string,
password: string,
}

View file

@ -0,0 +1,33 @@
import React, { useState } from 'react';
export type ApiError = {
error: any,
setError: (error: any) => void
};
const ApiErrorBoundaryContext = React.createContext<ApiError | undefined>(undefined);
export const ApiErrorBoundaryProvider = ({
value,
children
}: {
value?: ApiError,
children: React.ReactNode
}) => {
const [error, setError] = useState(false);
return (
<ApiErrorBoundaryContext.Provider value={value ? value : { error, setError }}>
{children}
</ApiErrorBoundaryContext.Provider>
);
};
export const useApiErrorBoundary = () => {
const context = React.useContext(ApiErrorBoundaryContext);
if (context === undefined) {
throw new Error('useApiErrorBoundary must be used inside ApiErrorBoundaryProvider');
}
return context;
};

View file

@ -0,0 +1,175 @@
import { useState, useCallback, useEffect, useMemo, ReactNode, createContext, useContext } from 'react';
import {
TUser,
TLoginResponse,
setTokenHeader,
useLoginUserMutation,
useLogoutUserMutation,
useGetUserQuery,
useRefreshTokenMutation,
TLoginUser
} from '~/data-provider';
import { useNavigate, useLocation } from 'react-router-dom';
import store from '~/store';
export type TAuthContext = {
user: TUser | undefined,
token: string | undefined,
isAuthenticated: boolean,
isLoading: boolean,
error: string | undefined,
login: (data: TLoginUser) => void,
logout: () => void
};
export type TUserContext = {
user?: TUser | undefined,
token: string | undefined,
isAuthenticated: boolean,
redirect?: string
};
const AuthContext = createContext <TAuthContext | undefined>(undefined);
const AuthContextProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<TUser | undefined>(undefined);
const [token, setToken] = useState <string | undefined>(undefined);
const [error, setError] = useState <string | undefined>(undefined);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const navigate = useNavigate();
const loginUser = useLoginUserMutation();
const logoutUser = useLogoutUserMutation();
const userQuery = useGetUserQuery({ enabled: !!token });
const refreshToken = useRefreshTokenMutation();
const location = useLocation();
const { newConversation } = store.useConversation();
const setUserContext = (userContext: TUserContext) => {
const { token, isAuthenticated, user, redirect } = userContext;
if(user) {
setUser(user);
}
setToken(token);
setTokenHeader(token);
setIsAuthenticated(isAuthenticated);
if (redirect) {
navigate(redirect);
}
};
const getCookieValue = key => {
let keyValue = document.cookie.match('(^|;) ?' + key + '=([^;]*)(;|$)');
return keyValue ? keyValue[2] : null;
};
const login = (data: TLoginUser) => {
loginUser.mutate(data, {
onSuccess: (data: TLoginResponse) => {
const { user, token } = data;
setUserContext({ token, isAuthenticated: true, user, redirect: '/chat/new' });
},
onError: error => {
setError(error.message);
},
});
};
const logout = () => {
document.cookie.split(';').forEach(c => {
document.cookie = c
.replace(/^ +/, '')
.replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/');
});
logoutUser.mutate(undefined, {
onSuccess: () => {
setUserContext({ token: undefined, isAuthenticated: false, user: undefined, redirect: '/login' });
},
onError: error => {
setError(error.message);
}
});
};
useEffect(() => {
if (userQuery.data) {
setUser(userQuery.data);
}
else if (userQuery.isError) {
setError(userQuery.error.message);
navigate('/login');
}
if (error && isAuthenticated) {
setError(undefined);
}
if (!token || !isAuthenticated) {
const tokenFromCookie = getCookieValue('token');
if (tokenFromCookie) {
// debugger;
setUserContext({ token: tokenFromCookie, isAuthenticated: true, user: userQuery.data, redirect: '/chat/new' })
}
else {
navigate('/login');
}
}
}, [token, isAuthenticated, userQuery.data, userQuery.isError]);
// const silentRefresh = useCallback(() => {
// refreshToken.mutate(undefined, {
// onSuccess: (data: TLoginResponse) => {
// const { user, token } = data;
// setUserContext({ token, isAuthenticated: true, user });
// },
// onError: error => {
// setError(error.message);
// }
// });
// setTimeout(silentRefresh, 5 * 60 * 1000);
// }, [setUserContext]);
useEffect(() => {
if (loginUser.isLoading || logoutUser.isLoading) {
setIsLoading(true);
} else {
setIsLoading(false);
}
}, [loginUser.isLoading, logoutUser.isLoading]);
// useEffect(() => {
// if (token)
// silentRefresh();
// }, [token, silentRefresh]);
// Make the provider update only when it should
const memoedValue = useMemo(
() => ({
user,
token,
isAuthenticated,
isLoading,
error,
login,
logout
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[user, isLoading, error, isAuthenticated, token]
);
return <AuthContext.Provider value={memoedValue}>{children}</AuthContext.Provider>;
};
const useAuthContext = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuthContext should be used inside AuthProvider');
}
return context;
};
export { AuthContextProvider, useAuthContext };

View file

@ -1,22 +1,15 @@
import { createRoot } from 'react-dom/client';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from './hooks/ThemeContext';
import App from './App';
import './style.css';
import './mobile.css';
import { ApiErrorBoundaryProvider } from './hooks/ApiErrorBoundaryContext';
const container = document.getElementById('root');
const root = createRoot(container);
const queryClient = new QueryClient();
root.render(
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<ThemeProvider>
<App />
</ThemeProvider>
</RecoilRoot>
</QueryClientProvider>
<ApiErrorBoundaryProvider>
<App />
</ApiErrorBoundaryProvider>
);

View file

@ -1,13 +1,49 @@
import React, { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom';
import MessageHandler from '../components/MessageHandler';
import Nav from '../components/Nav';
import MobileNav from '../components/Nav/MobileNav';
import { useGetSearchEnabledQuery, useGetEndpointsQuery, useGetPresetsQuery } from '~/data-provider';
import store from '~/store';
import { useSetRecoilState } from 'recoil';
import { useAuthContext } from '~/hooks/AuthContext';
export default function Root() {
const [navVisible, setNavVisible] = useState(false);
const setIsSearchEnabled = useSetRecoilState(store.isSearchEnabled);
const setEndpointsConfig = useSetRecoilState(store.endpointsConfig);
const setPresets = useSetRecoilState(store.presets);
const { user } = useAuthContext();
const searchEnabledQuery = useGetSearchEnabledQuery();
const endpointsQuery = useGetEndpointsQuery();
const presetsQuery = useGetPresetsQuery({ enabled: !!user });
useEffect(() => {
if (endpointsQuery.data) {
setEndpointsConfig(endpointsQuery.data);
} else if (endpointsQuery.isError) {
console.error('Failed to get endpoints', endpointsQuery.error);
}
}, [endpointsQuery.data, endpointsQuery.isError]);
useEffect(() => {
if (presetsQuery.data) {
setPresets(presetsQuery.data);
} else if (presetsQuery.isError) {
console.error('Failed to get presets', presetsQuery.error);
}
}, [presetsQuery.data, presetsQuery.isError]);
useEffect(() => {
if (searchEnabledQuery.data) {
setIsSearchEnabled(searchEnabledQuery.data);
} else if (searchEnabledQuery.isError) {
console.error('Failed to get search enabled', searchEnabledQuery.error);
}
}, [searchEnabledQuery.data, searchEnabledQuery.isError]);
return (
<>
<div className="flex h-screen">
@ -22,7 +58,6 @@ export default function Root() {
</div>
</div>
</div>
<MessageHandler />
</>
);

View file

@ -1,23 +0,0 @@
import axios from 'axios';
export default async function fetchData() {
try {
const response = await axios.get('/api/me', {
timeout: 1000,
withCredentials: true
});
const user = response.data;
if (user) {
// dispatch(setUser(user));
// callback(user);
return user;
} else {
console.log('Not login!');
window.location.href = '/auth/login';
}
} catch (error) {
console.error(error);
console.log('Not login!');
window.location.href = '/auth/login';
}
}