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

16
client/.env.example Normal file
View file

@ -0,0 +1,16 @@
###########################
# Server URL configuration:
###########################
# The social login domain uses this to redirect to localhost:3080 when you run the app in dev mode with Vite.
# Use your domain name as the Prod URL when you deploy the app to a live domain.
# Please note that:
# Social login features will not work if you run the build version on port 3080 locally after modifying the Prod URL
VITE_SERVER_URL_DEV=http://localhost:3080
VITE_SERVER_URL_PROD=http://localhost:3080
# Enable Social Login
# This enables/disables the Login with Google button on the login page.
# Set to true if you have registered the app with google cloud services
# and have set the GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the /api/.env file
VITE_SHOW_GOOGLE_LOGIN_OPTION=false

3053
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,7 @@
"scripts": {
"build": "vite build",
"dev": "vite",
"preview-prod": "vite preview",
"build-dev": "Webpack . --watch"
"preview-prod": "vite preview"
},
"repository": {
"type": "git",
@ -21,6 +20,11 @@
},
"homepage": "https://github.com/danny-avila/chatgpt-clone#readme",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-brands-svg-icons": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.13",
"@radix-ui/react-alert-dialog": "^1.0.2",
"@radix-ui/react-checkbox": "^1.0.3",
@ -30,6 +34,7 @@
"@radix-ui/react-label": "^2.0.0",
"@radix-ui/react-slider": "^1.1.1",
"@radix-ui/react-tabs": "^1.0.3",
"@tailwindcss/forms": "^0.5.3",
"@tanstack/react-query": "^4.28.0",
"@types/jest": "^29.5.0",
"@types/node": "^18.15.10",
@ -51,6 +56,7 @@
"rc-input-number": "^7.4.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.43.9",
"react-lazy-load": "^4.0.1",
"react-markdown": "^8.0.6",
"react-router-dom": "^6.9.0",
@ -108,9 +114,6 @@
"ts-loader": "^9.4.2",
"typescript": "^4.9.5",
"vite": "^4.2.1",
"vite-plugin-html": "^3.2.0",
"webpack": "^5.77.0",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.11.1"
"vite-plugin-html": "^3.2.0"
}
}

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';
}
}

View file

@ -39,7 +39,19 @@ module.exports = {
'700': '#40414f', // Replacing .dark .dark:bg-gray-700 and .dark .dark:hover:bg-gray-700:hover
'800': '#343541', // Replacing .dark .dark:bg-gray-800, .bg-gray-800, and .dark .dark:hover:bg-gray-800\/90
'900': '#202123' // Replacing .dark .dark:bg-gray-900, .bg-gray-900, and .dark .dark:hover:bg-gray-900:hover
}
},
green: {
50: "#f1f9f7",
100: "#def2ed",
200: "#a6e5d6",
300: "#6dc8b9",
400: "#41a79d",
500: "#10a37f",
600: "#126e6b",
700: "#0a4f53",
800: "#06373e",
900: "#031f29",
},
}
}
},

View file

@ -17,23 +17,27 @@ export default defineConfig({
'/auth': {
target: 'http://localhost:3080',
changeOrigin: true
},
'/oauth': {
target: 'http://localhost:3080',
changeOrigin: true
}
}
},
plugins: [react(), sourcemapExclude({ excludeNodeModules: true }),],
plugins: [react(), sourcemapExclude({excludeNodeModules: true})],
publicDir: './public',
build: {
sourcemap: true,
outDir: './dist',
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes("node_modules")) {
return "vendor";
manualChunks: id => {
if (id.includes('node_modules')) {
return 'vendor';
}
},
},
},
}
}
}
},
resolve: {
alias: {

View file

@ -1,111 +0,0 @@
const path = require('path');
// const HtmlWebpackPlugin = require('html-webpack-plugin');
/*We are basically telling webpack to take index.js from entry. Then check for all file extensions in resolve.
After that apply all the rules in module.rules and produce the output and place it in main.js in the public folder.*/
module.exports = {
/** "mode"
* the environment - development, production, none. tells webpack
* to use its built-in optimizations accordingly. default is production
*/
mode: 'development',
// cache: false,
/** "entry"
* the entry point
*/
entry: './index.js',
output: {
/** "path"
* the folder path of the output file
*/
path: path.resolve(__dirname, 'public'),
/** "filename"
* the name of the output file
*/
filename: 'main.js',
sourceMapFilename: '[name].js.map'
},
devtool: 'source-map',
/** "target"
* setting "node" as target app (server side), and setting it as "web" is
* for browser (client side). Default is "web"
*/
target: 'web',
devServer: {
/** "port"
* port of dev server
*/
port: '9500',
/** "static"
* This property tells Webpack what static file it should serve
*/
static: ['./public'],
/** "open"
* opens the browser after server is successfully started
*/
open: true,
/** "hot"
* enabling and disabling HMR. takes "true", "false" and "only".
* "only" is used if enable Hot Module Replacement without page
* refresh as a fallback in case of build failures
*/
hot: true,
/** "liveReload"
* disable live reload on the browser. "hot" must be set to false for this to work
*/
liveReload: true
},
resolve: {
/** "extensions"
* If multiple files share the same name but have different extensions, webpack will
* resolve the one with the extension listed first in the array and skip the rest.
* This is what enables users to leave off the extension when importing
*/
extensions: ['.js', '.jsx', '.json'],
fallback: {
url: require.resolve('url/'),
fs: false,
tls: false,
net: false,
path: false,
zlib: false,
http: false,
https: false,
stream: false,
crypto: false,
'crypto-browserify': require.resolve('crypto-browserify') //if you want to use this module also don't forget npm i crypto-browserify
}
},
module: {
/** "rules"
* This says - "Hey webpack compiler, when you come across a path that resolves to a '.js or .jsx'
* file inside of a require()/import statement, use the babel-loader to transform it before you
* add it to the bundle. And in this process, kindly make sure to exclude node_modules folder from
* being searched"
*/
rules: [
{
test: /\.(js|jsx)$/, //kind of file extension this rule should look for and apply in test
exclude: /node_modules/, //folder to be excluded
use: 'babel-loader' //loader which we are going to use
},
{
test: /\.css$/i,
include: path.resolve(__dirname, 'src'),
use: ['style-loader', 'css-loader', 'postcss-loader']
},
{ // source: https://stackoverflow.com/questions/61767538/devtools-failed-to-load-sourcemap-for-webpack-node-modules-js-map-http-e
test: /\.js$/,
enforce: 'pre',
use: ['source-map-loader'],
},
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
]
}
// plugins: [new HtmlWebpackPlugin()],
};