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