From b2a4678c7394d11e1621c519befa43d824f6d99e Mon Sep 17 00:00:00 2001 From: "Embruch, Gerd" <gerd.embruch@uni-hamburg.de> Date: Sat, 10 Aug 2024 14:04:11 +0200 Subject: [PATCH] added user profile --- src/assets/css/tailwind.presets.min.css | 2 +- src/assets/css/tailwind.presets.min.css.map | 2 +- src/assets/sass/tailwind.presets.scss | 3 + src/components/chat/Chats.jsx | 6 +- src/components/chat/Messages.jsx | 6 +- src/contexts/Auth/AuthState.jsx | 13 ++- src/pages/Config/AI/NewModel.jsx | 6 +- src/pages/Config/AIModels.jsx | 22 ++--- src/pages/Config/Embeddings.jsx | 23 ++--- src/pages/Config/Embeddings/Update.jsx | 4 +- src/pages/User/Profile.jsx | 102 ++++++++++++++++++++ src/routes/Sitemap.jsx | 20 ++-- 12 files changed, 163 insertions(+), 46 deletions(-) create mode 100644 src/pages/User/Profile.jsx diff --git a/src/assets/css/tailwind.presets.min.css b/src/assets/css/tailwind.presets.min.css index c647438..5f59425 100644 --- a/src/assets/css/tailwind.presets.min.css +++ b/src/assets/css/tailwind.presets.min.css @@ -1 +1 @@ -@tailwind base;@tailwind components;@tailwind utilities;@layer base{.conceal{@apply opacity-0 h-0 w-0 p-0 m-0 overflow-hidden}label,details{@apply block w-full pb-1 relative cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-60 lg:min-w-xs lg:w-[calc(1/2*100%-(1*1rem/2))] xl:w-[calc((1/4*100%)-(3*1rem/4))]}img,svg,video,canvas,audio,iframe,embed,object{display:inline;vertical-align:middle}:root{--background: 0 0% 100%;--foreground: 0 0% 3.9%;--card: 0 0% 100%;--card-foreground: 0 0% 3.9%;--popover: 0 0% 100%;--popover-foreground: 0 0% 3.9%;--primary: 204 98% 37%;--primary-foreground: 0 0% 98%;--secondary: 0 0% 96.1%;--secondary-foreground: 0 0% 9%;--muted: 0 0% 96.1%;--muted-foreground: 0 0% 45.1%;--accent: 0 0% 96.1%;--accent-foreground: 0 0% 9%;--destructive: 353 100% 44%;--destructive-foreground: 0 0% 98%;--border: 0 0% 89.8%;--input: 0 0% 89.8%;--ring: 0 0% 3.9%;--radius: 0.5rem}.dark{--background: 0 0% 3.9%;--foreground: 0 0% 98%;--card: 0 0% 3.9%;--card-foreground: 0 0% 98%;--popover: 0 0% 3.9%;--popover-foreground: 0 0% 98%;--primary: 0 0% 98%;--primary-foreground: 0 0% 9%;--secondary: 0 0% 14.9%;--secondary-foreground: 0 0% 98%;--muted: 0 0% 14.9%;--muted-foreground: 0 0% 63.9%;--accent: 0 0% 14.9%;--accent-foreground: 0 0% 98%;--destructive: 0 62.8% 30.6%;--destructive-foreground: 0 0% 98%;--border: 0 0% 14.9%;--input: 0 0% 14.9%;--ring: 0 0% 83.1%}*{@apply border-border}body{@apply bg-background text-foreground}}/*# sourceMappingURL=tailwind.presets.min.css.map */ \ No newline at end of file +@tailwind base;@tailwind components;@tailwind utilities;@layer base{.conceal{@apply opacity-0 h-0 w-0 p-0 m-0 overflow-hidden}label,details{@apply block w-full pb-1 relative cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-60 lg:min-w-xs lg:w-[calc(1/2*100%-(1*1rem/2))] xl:w-[calc((1/4*100%)-(3*1rem/4))]}fieldset{@apply pb-4 flex flex-wrap lg:gap-x-8 gap-x-4}img,svg,video,canvas,audio,iframe,embed,object{display:inline;vertical-align:middle}:root{--background: 0 0% 100%;--foreground: 0 0% 3.9%;--card: 0 0% 100%;--card-foreground: 0 0% 3.9%;--popover: 0 0% 100%;--popover-foreground: 0 0% 3.9%;--primary: 204 98% 37%;--primary-foreground: 0 0% 98%;--secondary: 0 0% 96.1%;--secondary-foreground: 0 0% 9%;--muted: 0 0% 96.1%;--muted-foreground: 0 0% 45.1%;--accent: 0 0% 96.1%;--accent-foreground: 0 0% 9%;--destructive: 353 100% 44%;--destructive-foreground: 0 0% 98%;--border: 0 0% 89.8%;--input: 0 0% 89.8%;--ring: 0 0% 3.9%;--radius: 0.5rem}.dark{--background: 0 0% 3.9%;--foreground: 0 0% 98%;--card: 0 0% 3.9%;--card-foreground: 0 0% 98%;--popover: 0 0% 3.9%;--popover-foreground: 0 0% 98%;--primary: 0 0% 98%;--primary-foreground: 0 0% 9%;--secondary: 0 0% 14.9%;--secondary-foreground: 0 0% 98%;--muted: 0 0% 14.9%;--muted-foreground: 0 0% 63.9%;--accent: 0 0% 14.9%;--accent-foreground: 0 0% 98%;--destructive: 0 62.8% 30.6%;--destructive-foreground: 0 0% 98%;--border: 0 0% 14.9%;--input: 0 0% 14.9%;--ring: 0 0% 83.1%}*{@apply border-border}body{@apply bg-background text-foreground}}/*# sourceMappingURL=tailwind.presets.min.css.map */ \ No newline at end of file diff --git a/src/assets/css/tailwind.presets.min.css.map b/src/assets/css/tailwind.presets.min.css.map index a24d0a1..eba59b3 100644 --- a/src/assets/css/tailwind.presets.min.css.map +++ b/src/assets/css/tailwind.presets.min.css.map @@ -1 +1 @@ -{"version":3,"sources":["../sass/tailwind.presets.scss"],"names":[],"mappings":"AAAA,cAAA,CACA,oBAAA,CACA,mBAAA,CACA,YACE,SACE,gDAAA,CAEF,cAEE,6MAAA,CAEF,+CAQE,cAAA,CACA,qBAAA,CAEF,MACE,uBAAA,CACA,uBAAA,CAEA,iBAAA,CACA,4BAAA,CAEA,oBAAA,CACA,+BAAA,CAGA,sBAAA,CACA,8BAAA,CAEA,uBAAA,CACA,+BAAA,CAEA,mBAAA,CACA,8BAAA,CAEA,oBAAA,CACA,4BAAA,CAEA,2BAAA,CACA,kCAAA,CAEA,oBAAA,CACA,mBAAA,CACA,iBAAA,CAEA,gBAAA,CAGF,MACE,uBAAA,CACA,sBAAA,CAEA,iBAAA,CACA,2BAAA,CAEA,oBAAA,CACA,8BAAA,CAEA,mBAAA,CACA,6BAAA,CAEA,uBAAA,CACA,gCAAA,CAEA,mBAAA,CACA,8BAAA,CAEA,oBAAA,CACA,6BAAA,CAEA,4BAAA,CACA,kCAAA,CAEA,oBAAA,CACA,mBAAA,CACA,kBAAA,CAGF,EACE,oBAAA,CAEF,KACE,oCAAA,CAAA","file":"tailwind.presets.min.css"} \ No newline at end of file +{"version":3,"sources":["../sass/tailwind.presets.scss"],"names":[],"mappings":"AAAA,cAAA,CACA,oBAAA,CACA,mBAAA,CACA,YACE,SACE,gDAAA,CAEF,cAEE,6MAAA,CAEF,SACE,6CAAA,CAEF,+CAQE,cAAA,CACA,qBAAA,CAEF,MACE,uBAAA,CACA,uBAAA,CAEA,iBAAA,CACA,4BAAA,CAEA,oBAAA,CACA,+BAAA,CAGA,sBAAA,CACA,8BAAA,CAEA,uBAAA,CACA,+BAAA,CAEA,mBAAA,CACA,8BAAA,CAEA,oBAAA,CACA,4BAAA,CAEA,2BAAA,CACA,kCAAA,CAEA,oBAAA,CACA,mBAAA,CACA,iBAAA,CAEA,gBAAA,CAGF,MACE,uBAAA,CACA,sBAAA,CAEA,iBAAA,CACA,2BAAA,CAEA,oBAAA,CACA,8BAAA,CAEA,mBAAA,CACA,6BAAA,CAEA,uBAAA,CACA,gCAAA,CAEA,mBAAA,CACA,8BAAA,CAEA,oBAAA,CACA,6BAAA,CAEA,4BAAA,CACA,kCAAA,CAEA,oBAAA,CACA,mBAAA,CACA,kBAAA,CAGF,EACE,oBAAA,CAEF,KACE,oCAAA,CAAA","file":"tailwind.presets.min.css"} \ No newline at end of file diff --git a/src/assets/sass/tailwind.presets.scss b/src/assets/sass/tailwind.presets.scss index 2e5d3a4..f257403 100644 --- a/src/assets/sass/tailwind.presets.scss +++ b/src/assets/sass/tailwind.presets.scss @@ -9,6 +9,9 @@ details { @apply block w-full pb-1 relative cursor-pointer disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-60 lg:min-w-xs lg:w-[calc(1/2*100%-(1*1rem/2))] xl:w-[calc((1/4*100%)-(3*1rem/4))]; } + fieldset { + @apply pb-4 flex flex-wrap lg:gap-x-8 gap-x-4; + } img, svg, video, diff --git a/src/components/chat/Chats.jsx b/src/components/chat/Chats.jsx index d3529f5..b45420c 100644 --- a/src/components/chat/Chats.jsx +++ b/src/components/chat/Chats.jsx @@ -1,10 +1,10 @@ import React, { useEffect, useState } from 'react'; -import Chat from './Chat'; -import Heading from '../font/Heading'; import { RiAddCircleLine, RiArrowLeftCircleLine, RiArrowRightCircleLine } from 'react-icons/ri'; -import { useChat } from '../../contexts/Chat/ChatState'; import { useNavigate, useParams } from 'react-router-dom'; +import { useChat } from '../../contexts/Chat/ChatState'; import { mergeBackendValidation } from '../../utils/ErrorHandling'; +import Heading from '../font/Heading'; +import Chat from './Chat'; const Chats = () => { // ################################# diff --git a/src/components/chat/Messages.jsx b/src/components/chat/Messages.jsx index 49d3a6e..771c4eb 100644 --- a/src/components/chat/Messages.jsx +++ b/src/components/chat/Messages.jsx @@ -1,8 +1,8 @@ import React, { useEffect, useRef } from 'react'; -import Message from './Message'; -import { useChat } from '../../contexts/Chat/ChatState'; -import { Link } from 'react-router-dom'; import { RxBookmark } from 'react-icons/rx'; +import { Link } from 'react-router-dom'; +import { useChat } from '../../contexts/Chat/ChatState'; +import Message from './Message'; const Messages = () => { diff --git a/src/contexts/Auth/AuthState.jsx b/src/contexts/Auth/AuthState.jsx index 64a32e5..f937e24 100755 --- a/src/contexts/Auth/AuthState.jsx +++ b/src/contexts/Auth/AuthState.jsx @@ -1,8 +1,8 @@ import React, { useContext, useReducer, useState } from 'react'; +import api from '../../utils/AxiosConfig'; import AuthContext from './AuthContext'; import authReducer from './AuthReducer'; import { USER_ACTIONS } from './AuthTypes'; -import api from '../../utils/AxiosConfig'; // ### EXPORT useContext TO REDUCE NEEDED CODE IN CLIENT FILES export function useAuth() { @@ -59,6 +59,16 @@ function AuthState({ children }) { return api.post('/auth/password-reset', { email }); } + // ### UPDATE USER_ACTIONS + async function update(id, user) { + // remove password keys if not set to avoid emtying password field + if (user && !user.password) delete user.password; + if (user && !user.confirmPassword) delete user.confirmPassword; + // send data to backend + return api.patch(`/users/${id}`, + user + ); + } // ### RETURN return ( @@ -66,6 +76,7 @@ function AuthState({ children }) { value={{ login, currentUser, + update, logout, requestVerificationToken, requestPasswordReset, diff --git a/src/pages/Config/AI/NewModel.jsx b/src/pages/Config/AI/NewModel.jsx index d103bc6..b298476 100644 --- a/src/pages/Config/AI/NewModel.jsx +++ b/src/pages/Config/AI/NewModel.jsx @@ -49,18 +49,18 @@ function NewModel({ data, setData }) { // OUTPUT // ################################# return ( - <> + <div> {(currentUser?.role >= 2) ? <FormProvider {...methods} > <Heading level="4">install new model</Heading> - <form onSubmit={methods.handleSubmit(handleInstall)} className='md:w-1/3'> + <form onSubmit={methods.handleSubmit(handleInstall)} className=''> <Input name='model' type='text' title='Model Name' className='h-16' required={true} tooltip={<Link to='https://ollama.com/library' target='_blank' rel='noopener noreferrer'>Ollama Library</Link>} /> <Submit size='sm' value={methods.formState.isSubmitting ? 'installing...' : 'install model'} /> </form> </FormProvider> : null} - </> + </div> ); } diff --git a/src/pages/Config/AIModels.jsx b/src/pages/Config/AIModels.jsx index 7eb9bf1..8772cd8 100644 --- a/src/pages/Config/AIModels.jsx +++ b/src/pages/Config/AIModels.jsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from 'react'; import api from '../../utils/AxiosConfig'; import { mergeBackendValidation } from '../../utils/ErrorHandling'; -import NewModel from './AI/NewModel'; import Models from './AI/Models'; +import NewModel from './AI/NewModel'; import AIStatus from './AI/Status'; function AIModels() { @@ -62,17 +62,15 @@ function AIModels() { <div className="absolute w-[50vw] right-[50%] h-1 bg-UhhRed"></div> </div> - <div className='max-h-full flex flex-col'> - <div> - <AIStatus /> - </div> - <div> - <NewModel data={data} setData={setData} /> - </div> - <div className='overflow-y-auto'> - <Models data={data} setData={setData} /> - </div> - </div> + <fieldset> + {/* ai status */} + <AIStatus /> + {/* new model */} + <NewModel data={data} setData={setData} /> + {/* model list */} + <Models data={data} setData={setData} /> + + </fieldset> diff --git a/src/pages/Config/Embeddings.jsx b/src/pages/Config/Embeddings.jsx index 2d57c9e..ae6b13c 100644 --- a/src/pages/Config/Embeddings.jsx +++ b/src/pages/Config/Embeddings.jsx @@ -55,20 +55,15 @@ function Embeddings() { <div className="absolute w-[50vw] right-[50%] h-1 bg-UhhRed"></div> </div> - <div className='max-h-full flex flex-col'> - <div> - {/* rag status */} - <Status status={status} /> - </div> - <div> - {/* update embeddings */} - <Update setStatus={setStatus} /> - </div> - <div> - {/* delete embeddings */} - <Delete setStatus={setStatus} /> - </div> - </div> + <fieldset> + {/* rag status */} + <Status status={status} /> + {/* update embeddings */} + <Update setStatus={setStatus} /> + {/* delete embeddings */} + <Delete setStatus={setStatus} /> + + </fieldset> </section> ); } diff --git a/src/pages/Config/Embeddings/Update.jsx b/src/pages/Config/Embeddings/Update.jsx index 0a306d5..0f4cd09 100644 --- a/src/pages/Config/Embeddings/Update.jsx +++ b/src/pages/Config/Embeddings/Update.jsx @@ -46,7 +46,7 @@ function Update({ setStatus }) { // OUTPUT // ################################# return ( - <> + <div> {(currentUser?.role >= 2) ? <FormProvider {...methods}> <Heading level="4">Update Embeddings @@ -61,7 +61,7 @@ function Update({ setStatus }) { </details> </FormProvider> : null} - </> + </div> ); } diff --git a/src/pages/User/Profile.jsx b/src/pages/User/Profile.jsx new file mode 100644 index 0000000..7a2a509 --- /dev/null +++ b/src/pages/User/Profile.jsx @@ -0,0 +1,102 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import React, { useEffect } from 'react'; +import { Helmet } from 'react-helmet-async'; +import { FormProvider, useForm } from 'react-hook-form'; +import { isStrongPassword } from 'validator'; +import { z } from "zod"; +import Input from '../..//components/form/Input'; +import Heading from '../../components/font/Heading'; +import Submit from '../../components/form/Submit'; +import { useAuth } from '../../contexts/Auth/AuthState'; +import { mergeBackendValidation, setFlashMsg } from '../../utils/ErrorHandling'; + +function Profile() { + // ################################# + // VALIDATION SCHEMA + // ################################# + // TODO limit file size via .env + // TODO check for file types + const schema = z.object({ + name: z.string().min(1), + username: z.string().min(1), + email: z.string().email(), + password: z.string().refine((val) => val && isStrongPassword(val), { + message: 'This field must be min 6 characters long and contain uppercase, lowercase, number, specialchar.', + }).nullish().or(z.literal('')), + confirmPassword: z.string().nullish().or(z.literal('')), + }).refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); + + // ################################# + // HOOKS + // ################################# + // ### CONNECT AUTH CONTEXT + const { currentUser, dispatchCurrentUser, USER_ACTIONS, update } = useAuth(); + // ### PREPARE FORM + const methods = useForm({ + resolver: zodResolver(schema), + mode: 'onSubmit', + defaultValues: currentUser + }); + + // ### RESET FORM AFTER SUCCESSFUL SUBMIT + useEffect(() => { + if (!methods.formState.isSubmitSuccessful) return; + methods.reset(currentUser); + methods.setValue('confirmPassword', ''); + methods.setValue('password', ''); + }, [methods.formState]); + + // ################################# + // FUNCTIONS + // ################################# + // ### HANDLE SUBMITTING FORM + async function handleSendForm(inputs) { + // TRY UPDATE + try { + // send data to update function + const result = await update( + currentUser._id, + inputs + ); + // update currentUser + await dispatchCurrentUser({ type: USER_ACTIONS.SET, payload: result.data.document }); + setFlashMsg(result.data?.message); + } catch (error) { + // catch the error + mergeBackendValidation(error.response.status, error.response.data, methods.setError); + } + } + + // ################################# + // OUTPUT + // ################################# + return ( + <> + {/* render page title */} + <Helmet><title>[{import.meta.env.VITE_APP_NAME}] Profile</title></Helmet> + + <Heading level="1" className="col-span-2">Profile</Heading> + <FormProvider {...methods} > + <form onSubmit={methods.handleSubmit(handleSendForm)}> + <fieldset> + <Input name='name' type='text' title='Name' required={true} autoFocus={true} /> + <Input name='username' type='text' title='Username' required={true} /> + <Input name='email' type='email' title='eMail' required={true} /> + </fieldset> + + <fieldset> + <Input name='password' type='password' title='password' placeholder='Leave blank to keep the same' /> + <Input name='confirmPassword' type='password' title='confirm password' placeholder='Leave blank to keep the same' /> + </fieldset> + + <Submit value='update' /> + </form> + </FormProvider> + </> + ); +} + +export default React.memo(Profile);; \ No newline at end of file diff --git a/src/routes/Sitemap.jsx b/src/routes/Sitemap.jsx index a27a28f..b8da28f 100644 --- a/src/routes/Sitemap.jsx +++ b/src/routes/Sitemap.jsx @@ -38,13 +38,21 @@ export const sitemap = [{ ] }, - // LOGOUT + // PROFILE { - title: 'Logout', - path: '/logout', - element: loadComponent('User/Logout', true, true), - handle: { crumb: () => <Link to="/profile/logout">Logout</Link> } - } + title: 'Profile', path: '/profile', handle: { crumb: () => <Link to="/profile">Profile</Link> }, + children: [ + { index: true, element: loadComponent('User/Profile', true, true) }, + { title: 'Logout', path: 'logout', element: loadComponent('User/Logout', true, true), handle: { crumb: () => <Link to="/profile/logout">Logout</Link> } } + ] + }, + // // LOGOUT + // { + // title: 'Logout', + // path: '/logout', + // element: loadComponent('User/Logout', true, true), + // handle: { crumb: () => <Link to="/profile/logout">Logout</Link> } + // } ] }] }, { -- GitLab