From fcb591d364193b09cada5a53667c5c5d6919cff7 Mon Sep 17 00:00:00 2001
From: Sean Hatfield <seanhatfield5@gmail.com>
Date: Thu, 7 Dec 2023 14:11:51 -0800
Subject: [PATCH] Add user PFP support and context to logo (#408)

* fix sizing of onboarding modals & lint

* fix extra scrolling on mobile onboarding flow

* added message to use desktop for onboarding

* linting

* add arrow to scroll to bottom (debounced) and fix chat scrolling to always scroll to very bottom on message history change

* fix for empty chat

* change mobile alert copy

* WIP adding PFP upload support

* WIP pfp for users

* edit account menu complete with change username/password and upload profile picture

* add pfp context to update all instances of usePfp hook on update

* linting

* add context for logo change to immediately update logo

* fix div with bullet points to use list-disc instead

* fix: small changes

* update multer file storage locations

* fix: use STORAGE_DIR for filepathing

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
---
 frontend/src/App.jsx                          | 130 +++++------
 frontend/src/LogoContext.jsx                  |  28 +++
 frontend/src/PfpContext.jsx                   |  30 +++
 frontend/src/components/UserIcon/index.jsx    |  29 +--
 frontend/src/components/UserMenu/index.jsx    | 207 +++++++++++++++++-
 frontend/src/hooks/useLogo.js                 |  23 +-
 frontend/src/hooks/usePfp.js                  |   7 +
 frontend/src/models/system.js                 |  59 ++++-
 .../GeneralSettings/Appearance/index.jsx      |   8 +-
 .../Steps/AppearanceSetup/index.jsx           |   8 +-
 server/endpoints/system.js                    | 153 ++++++++++++-
 .../20231129012019_add/migration.sql          |   2 +
 server/prisma/schema.prisma                   |   1 +
 server/utils/files/logo.js                    |   1 +
 server/utils/files/multer.js                  |  27 ++-
 server/utils/files/pfp.js                     |  44 ++++
 16 files changed, 656 insertions(+), 101 deletions(-)
 create mode 100644 frontend/src/LogoContext.jsx
 create mode 100644 frontend/src/PfpContext.jsx
 create mode 100644 frontend/src/hooks/usePfp.js
 create mode 100644 server/prisma/migrations/20231129012019_add/migration.sql
 create mode 100644 server/utils/files/pfp.js

diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 558e8ae3f..2d1eeb7d1 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -8,6 +8,8 @@ import PrivateRoute, {
 import { ToastContainer } from "react-toastify";
 import "react-toastify/dist/ReactToastify.css";
 import Login from "@/pages/Login";
+import { PfpProvider } from "./PfpContext";
+import { LogoProvider } from "./LogoContext";
 
 const Main = lazy(() => import("@/pages/Main"));
 const InvitePage = lazy(() => import("@/pages/Invite"));
@@ -40,69 +42,73 @@ export default function App() {
   return (
     <Suspense fallback={<div />}>
       <ContextWrapper>
-        <Routes>
-          <Route path="/" element={<PrivateRoute Component={Main} />} />
-          <Route path="/login" element={<Login />} />
-          <Route
-            path="/workspace/:slug"
-            element={<PrivateRoute Component={WorkspaceChat} />}
-          />
-          <Route path="/accept-invite/:code" element={<InvitePage />} />
+        <LogoProvider>
+          <PfpProvider>
+            <Routes>
+              <Route path="/" element={<PrivateRoute Component={Main} />} />
+              <Route path="/login" element={<Login />} />
+              <Route
+                path="/workspace/:slug"
+                element={<PrivateRoute Component={WorkspaceChat} />}
+              />
+              <Route path="/accept-invite/:code" element={<InvitePage />} />
 
-          {/* Admin */}
-          <Route
-            path="/settings/llm-preference"
-            element={<AdminRoute Component={GeneralLLMPreference} />}
-          />
-          <Route
-            path="/settings/embedding-preference"
-            element={<AdminRoute Component={GeneralEmbeddingPreference} />}
-          />
-          <Route
-            path="/settings/vector-database"
-            element={<AdminRoute Component={GeneralVectorDatabase} />}
-          />
-          {/* Manager */}
-          <Route
-            path="/settings/export-import"
-            element={<ManagerRoute Component={GeneralExportImport} />}
-          />
-          <Route
-            path="/settings/security"
-            element={<ManagerRoute Component={GeneralSecurity} />}
-          />
-          <Route
-            path="/settings/appearance"
-            element={<ManagerRoute Component={GeneralAppearance} />}
-          />
-          <Route
-            path="/settings/api-keys"
-            element={<ManagerRoute Component={GeneralApiKeys} />}
-          />
-          <Route
-            path="/settings/workspace-chats"
-            element={<ManagerRoute Component={GeneralChats} />}
-          />
-          <Route
-            path="/settings/system-preferences"
-            element={<ManagerRoute Component={AdminSystem} />}
-          />
-          <Route
-            path="/settings/invites"
-            element={<ManagerRoute Component={AdminInvites} />}
-          />
-          <Route
-            path="/settings/users"
-            element={<ManagerRoute Component={AdminUsers} />}
-          />
-          <Route
-            path="/settings/workspaces"
-            element={<ManagerRoute Component={AdminWorkspaces} />}
-          />
-          {/* Onboarding Flow */}
-          <Route path="/onboarding" element={<OnboardingFlow />} />
-        </Routes>
-        <ToastContainer />
+              {/* Admin */}
+              <Route
+                path="/settings/llm-preference"
+                element={<AdminRoute Component={GeneralLLMPreference} />}
+              />
+              <Route
+                path="/settings/embedding-preference"
+                element={<AdminRoute Component={GeneralEmbeddingPreference} />}
+              />
+              <Route
+                path="/settings/vector-database"
+                element={<AdminRoute Component={GeneralVectorDatabase} />}
+              />
+              {/* Manager */}
+              <Route
+                path="/settings/export-import"
+                element={<ManagerRoute Component={GeneralExportImport} />}
+              />
+              <Route
+                path="/settings/security"
+                element={<ManagerRoute Component={GeneralSecurity} />}
+              />
+              <Route
+                path="/settings/appearance"
+                element={<ManagerRoute Component={GeneralAppearance} />}
+              />
+              <Route
+                path="/settings/api-keys"
+                element={<ManagerRoute Component={GeneralApiKeys} />}
+              />
+              <Route
+                path="/settings/workspace-chats"
+                element={<ManagerRoute Component={GeneralChats} />}
+              />
+              <Route
+                path="/settings/system-preferences"
+                element={<ManagerRoute Component={AdminSystem} />}
+              />
+              <Route
+                path="/settings/invites"
+                element={<ManagerRoute Component={AdminInvites} />}
+              />
+              <Route
+                path="/settings/users"
+                element={<ManagerRoute Component={AdminUsers} />}
+              />
+              <Route
+                path="/settings/workspaces"
+                element={<ManagerRoute Component={AdminWorkspaces} />}
+              />
+              {/* Onboarding Flow */}
+              <Route path="/onboarding" element={<OnboardingFlow />} />
+            </Routes>
+            <ToastContainer />
+          </PfpProvider>
+        </LogoProvider>
       </ContextWrapper>
     </Suspense>
   );
diff --git a/frontend/src/LogoContext.jsx b/frontend/src/LogoContext.jsx
new file mode 100644
index 000000000..6818967b8
--- /dev/null
+++ b/frontend/src/LogoContext.jsx
@@ -0,0 +1,28 @@
+import { createContext, useEffect, useState } from "react";
+import AnythingLLM from "./media/logo/anything-llm.png";
+import System from "./models/system";
+
+export const LogoContext = createContext();
+
+export function LogoProvider({ children }) {
+  const [logo, setLogo] = useState("");
+
+  useEffect(() => {
+    async function fetchInstanceLogo() {
+      try {
+        const logoURL = await System.fetchLogo();
+        logoURL ? setLogo(logoURL) : setLogo(AnythingLLM);
+      } catch (err) {
+        setLogo(AnythingLLM);
+        console.error("Failed to fetch logo:", err);
+      }
+    }
+    fetchInstanceLogo();
+  }, []);
+
+  return (
+    <LogoContext.Provider value={{ logo, setLogo }}>
+      {children}
+    </LogoContext.Provider>
+  );
+}
diff --git a/frontend/src/PfpContext.jsx b/frontend/src/PfpContext.jsx
new file mode 100644
index 000000000..3d60d559d
--- /dev/null
+++ b/frontend/src/PfpContext.jsx
@@ -0,0 +1,30 @@
+import React, { createContext, useState, useEffect } from "react";
+import useUser from "./hooks/useUser";
+import System from "./models/system";
+
+export const PfpContext = createContext();
+
+export function PfpProvider({ children }) {
+  const [pfp, setPfp] = useState(null);
+  const { user } = useUser();
+
+  useEffect(() => {
+    async function fetchPfp() {
+      if (!user?.id) return;
+      try {
+        const pfpUrl = await System.fetchPfp(user.id);
+        setPfp(pfpUrl);
+      } catch (err) {
+        setPfp(null);
+        console.error("Failed to fetch pfp:", err);
+      }
+    }
+    fetchPfp();
+  }, [user?.id]);
+
+  return (
+    <PfpContext.Provider value={{ pfp, setPfp }}>
+      {children}
+    </PfpContext.Provider>
+  );
+}
diff --git a/frontend/src/components/UserIcon/index.jsx b/frontend/src/components/UserIcon/index.jsx
index 40694606d..6cc9b57d0 100644
--- a/frontend/src/components/UserIcon/index.jsx
+++ b/frontend/src/components/UserIcon/index.jsx
@@ -1,32 +1,35 @@
 import React, { useRef, useEffect } from "react";
 import JAZZ from "@metamask/jazzicon";
+import usePfp from "../../hooks/usePfp";
 
 export default function Jazzicon({ size = 10, user, role }) {
+  const { pfp } = usePfp();
   const divRef = useRef(null);
   const seed = user?.uid
     ? toPseudoRandomInteger(user.uid)
     : Math.floor(100000 + Math.random() * 900000);
-  const result = JAZZ(size, seed);
 
   useEffect(() => {
-    if (!divRef || !divRef.current) return null;
+    if (!divRef.current || (role === "user" && pfp)) return;
 
+    const result = JAZZ(size, seed);
     divRef.current.appendChild(result);
-  }, []); // eslint-disable-line react-hooks/exhaustive-deps
+  }, [pfp, role, seed, size]);
 
   return (
-    <div
-      className={`flex ${role === "user" ? "user-reply" : ""}`}
-      ref={divRef}
-    />
+    <div className="relative w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden">
+      <div ref={divRef} />
+      {role === "user" && pfp && (
+        <img
+          src={pfp}
+          alt="User profile picture"
+          className="absolute top-0 left-0 w-full h-full object-cover rounded-full bg-white"
+        />
+      )}
+    </div>
   );
 }
 
 function toPseudoRandomInteger(uidString = "") {
-  var numberArray = [uidString.length];
-  for (var i = 0; i < uidString.length; i++) {
-    numberArray[i] = uidString.charCodeAt(i);
-  }
-
-  return numberArray.reduce((a, b) => a + b, 0);
+  return uidString.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0);
 }
diff --git a/frontend/src/components/UserMenu/index.jsx b/frontend/src/components/UserMenu/index.jsx
index 8bfc1b934..61e111da4 100644
--- a/frontend/src/components/UserMenu/index.jsx
+++ b/frontend/src/components/UserMenu/index.jsx
@@ -2,8 +2,12 @@ import React, { useState, useEffect, useRef } from "react";
 import { isMobile } from "react-device-detect";
 import paths from "@/utils/paths";
 import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants";
-import { Person, SignOut } from "@phosphor-icons/react";
+import { Person, Plus, X } from "@phosphor-icons/react";
 import { userFromStorage } from "@/utils/request";
+import useUser from "@/hooks/useUser";
+import System from "@/models/system";
+import showToast from "@/utils/toast";
+import usePfp from "@/hooks/usePfp";
 
 export default function UserMenu({ children }) {
   if (isMobile) return <>{children}</>;
@@ -26,12 +30,28 @@ function useLoginMode() {
 }
 
 function userDisplay() {
+  const { pfp } = usePfp();
   const user = userFromStorage();
+
+  if (pfp) {
+    return (
+      <div className="w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden transition-all duration-300 bg-gray-100 hover:border-slate-100 hover:border-opacity-50 border-transparent border hover:opacity-60">
+        <img
+          src={pfp}
+          alt="User profile picture"
+          className="w-full h-full object-cover"
+        />
+      </div>
+    );
+  }
+
   return user?.username?.slice(0, 2) || "AA";
 }
 
 function UserButton() {
+  const { user } = useUser();
   const [showMenu, setShowMenu] = useState(false);
+  const [showAccountSettings, setShowAccountSettings] = useState(false);
   const mode = useLoginMode();
   const menuRef = useRef();
   const buttonRef = useRef();
@@ -45,6 +65,11 @@ function UserButton() {
     }
   };
 
+  const handleOpenAccountModal = () => {
+    setShowAccountSettings(true);
+    setShowMenu(false);
+  };
+
   useEffect(() => {
     if (showMenu) {
       document.addEventListener("mousedown", handleClose);
@@ -71,6 +96,14 @@ function UserButton() {
           className="w-fit rounded-lg absolute top-12 right-0 bg-sidebar p-4 flex items-center-justify-center"
         >
           <div className="flex flex-col gap-y-2">
+            {mode === "multi" && !!user && (
+              <button
+                onClick={handleOpenAccountModal}
+                className="text-white hover:bg-slate-200/20 w-full text-left px-4 py-1.5 rounded-md"
+              >
+                Account
+              </button>
+            )}
             <a
               href={paths.mailToMintplex()}
               className="text-white hover:bg-slate-200/20 w-full text-left px-4 py-1.5 rounded-md"
@@ -92,6 +125,178 @@ function UserButton() {
           </div>
         </div>
       )}
+      {user && showAccountSettings && (
+        <AccountModal
+          user={user}
+          hideModal={() => setShowAccountSettings(false)}
+        />
+      )}
+    </div>
+  );
+}
+
+function AccountModal({ user, hideModal }) {
+  const { pfp, setPfp } = usePfp();
+  const handleFileUpload = async (event) => {
+    const file = event.target.files[0];
+    if (!file) return false;
+
+    const formData = new FormData();
+    formData.append("file", file);
+    const { success, error } = await System.uploadPfp(formData);
+    if (!success) {
+      showToast(`Failed to upload profile picture: ${error}`, "error");
+      return;
+    }
+
+    const pfpUrl = await System.fetchPfp(user.id);
+    setPfp(pfpUrl);
+
+    showToast("Profile picture uploaded successfully.", "success");
+  };
+
+  const handleRemovePfp = async () => {
+    const { success, error } = await System.removePfp();
+    if (!success) {
+      showToast(`Failed to remove profile picture: ${error}`, "error");
+      return;
+    }
+
+    setPfp(null);
+    showToast("Profile picture removed successfully.", "success");
+  };
+
+  const handleUpdate = async (e) => {
+    e.preventDefault();
+
+    const data = {};
+    const form = new FormData(e.target);
+    for (var [key, value] of form.entries()) {
+      if (!value || value === null) continue;
+      data[key] = value;
+    }
+
+    const { success, error } = await System.updateUser(data);
+    if (success) {
+      let storedUser = JSON.parse(localStorage.getItem(AUTH_USER));
+
+      if (storedUser) {
+        storedUser.username = data.username;
+        localStorage.setItem(AUTH_USER, JSON.stringify(storedUser));
+      }
+      window.location.reload();
+    } else {
+      showToast(`Failed to update user: ${error}`, "error");
+    }
+  };
+
+  return (
+    <div
+      id="account-modal"
+      className="bg-black/20 fixed top-0 left-0 outline-none w-screen h-screen flex items-center justify-center"
+    >
+      <div className="relative w-[500px] max-w-2xl max-h-full bg-main-gradient rounded-lg shadow">
+        <div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50">
+          <h3 className="text-xl font-semibold text-white">Edit Account</h3>
+          <button
+            onClick={hideModal}
+            type="button"
+            className="text-gray-400 bg-transparent hover:border-white/60 rounded-lg p-1.5 ml-auto inline-flex items-center hover:bg-menu-item-selected-gradient hover:border-slate-100 border-transparent"
+          >
+            <X className="text-lg" />
+          </button>
+        </div>
+        <form onSubmit={handleUpdate} className="space-y-6">
+          <div className="flex flex-col md:flex-row items-center justify-center gap-8">
+            <div className="flex flex-col items-center">
+              <label className="w-48 h-48 flex flex-col items-center justify-center bg-zinc-900/50 transition-all duration-300 rounded-full mt-8 border-2 border-dashed border-white border-opacity-60 cursor-pointer hover:opacity-60">
+                <input
+                  id="logo-upload"
+                  type="file"
+                  accept="image/*"
+                  className="hidden"
+                  onChange={handleFileUpload}
+                />
+                {pfp ? (
+                  <img
+                    src={pfp}
+                    alt="User profile picture"
+                    className="w-48 h-48 rounded-full object-cover bg-white"
+                  />
+                ) : (
+                  <div className="flex flex-col items-center justify-center p-3">
+                    <Plus className="w-8 h-8 text-white/80 m-2" />
+                    <span className="text-white text-opacity-80 text-sm font-semibold">
+                      Profile Picture
+                    </span>
+                    <span className="text-white text-opacity-60 text-xs">
+                      800 x 800
+                    </span>
+                  </div>
+                )}
+              </label>
+              {pfp && (
+                <button
+                  type="button"
+                  onClick={handleRemovePfp}
+                  className="mt-3 text-white text-opacity-60 text-sm font-medium hover:underline"
+                >
+                  Remove Profile Picture
+                </button>
+              )}
+            </div>
+          </div>
+          <div className="flex flex-col gap-y-4 px-6">
+            <div>
+              <label
+                htmlFor="username"
+                className="block mb-2 text-sm font-medium text-white"
+              >
+                Username
+              </label>
+              <input
+                name="username"
+                type="text"
+                className="bg-zinc-900 border border-gray-500 text-white text-sm rounded-lg block w-full p-2.5"
+                placeholder="User's username"
+                minLength={2}
+                defaultValue={user.username}
+                required
+                autoComplete="off"
+              />
+            </div>
+            <div>
+              <label
+                htmlFor="password"
+                className="block mb-2 text-sm font-medium text-white"
+              >
+                New Password
+              </label>
+              <input
+                name="password"
+                type="password"
+                className="bg-zinc-900 border border-gray-500 text-white text-sm rounded-lg block w-full p-2.5"
+                placeholder={`${user.username}'s new password`}
+              />
+            </div>
+          </div>
+          <div className="flex justify-between items-center border-t border-gray-500/50 pt-4 p-6">
+            <button
+              onClick={hideModal}
+              type="button"
+              className="px-4 py-2 rounded-lg text-white bg-transparent hover:bg-stone-900"
+            >
+              Cancel
+            </button>
+            <button
+              type="submit"
+              className="px-4 py-2 rounded-lg text-white bg-transparent border border-slate-200 hover:bg-slate-200 hover:text-slate-800"
+            >
+              Update Account
+            </button>
+          </div>
+        </form>
+      </div>
     </div>
   );
 }
diff --git a/frontend/src/hooks/useLogo.js b/frontend/src/hooks/useLogo.js
index f03d20981..4834b7a8e 100644
--- a/frontend/src/hooks/useLogo.js
+++ b/frontend/src/hooks/useLogo.js
@@ -1,22 +1,7 @@
-import { useEffect, useState } from "react";
-import System from "@/models/system";
-import AnythingLLM from "@/media/logo/anything-llm.png";
+import { useContext } from "react";
+import { LogoContext } from "../LogoContext";
 
 export default function useLogo() {
-  const [logo, setLogo] = useState("");
-
-  useEffect(() => {
-    async function fetchInstanceLogo() {
-      try {
-        const logoURL = await System.fetchLogo();
-        logoURL ? setLogo(logoURL) : setLogo(AnythingLLM);
-      } catch (err) {
-        setLogo(AnythingLLM);
-        console.error("Failed to fetch logo:", err);
-      }
-    }
-    fetchInstanceLogo();
-  }, []);
-
-  return { logo };
+  const { logo, setLogo } = useContext(LogoContext);
+  return { logo, setLogo };
 }
diff --git a/frontend/src/hooks/usePfp.js b/frontend/src/hooks/usePfp.js
new file mode 100644
index 000000000..36c54497f
--- /dev/null
+++ b/frontend/src/hooks/usePfp.js
@@ -0,0 +1,7 @@
+import { useContext } from "react";
+import { PfpContext } from "../PfpContext";
+
+export default function usePfp() {
+  const { pfp, setPfp } = useContext(PfpContext);
+  return { pfp, setPfp };
+}
diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js
index 2ef4eebbf..79c203d94 100644
--- a/frontend/src/models/system.js
+++ b/frontend/src/models/system.js
@@ -170,6 +170,21 @@ const System = {
         return { success: false, error: e.message };
       });
   },
+  uploadPfp: async function (formData) {
+    return await fetch(`${API_BASE}/system/upload-pfp`, {
+      method: "POST",
+      body: formData,
+      headers: baseHeaders(),
+    })
+      .then((res) => {
+        if (!res.ok) throw new Error("Error uploading pfp.");
+        return { success: true, error: null };
+      })
+      .catch((e) => {
+        console.log(e);
+        return { success: false, error: e.message };
+      });
+  },
   uploadLogo: async function (formData) {
     return await fetch(`${API_BASE}/system/upload-logo`, {
       method: "POST",
@@ -191,7 +206,7 @@ const System = {
       cache: "no-cache",
     })
       .then((res) => {
-        if (res.ok) return res.blob();
+        if (res.ok && res.status !== 204) return res.blob();
         throw new Error("Failed to fetch logo!");
       })
       .then((blob) => URL.createObjectURL(blob))
@@ -200,6 +215,36 @@ const System = {
         return null;
       });
   },
+  fetchPfp: async function (id) {
+    return await fetch(`${API_BASE}/system/pfp/${id}`, {
+      method: "GET",
+      cache: "no-cache",
+    })
+      .then((res) => {
+        if (res.ok && res.status !== 204) return res.blob();
+        throw new Error("Failed to fetch pfp.");
+      })
+      .then((blob) => (blob ? URL.createObjectURL(blob) : null))
+      .catch((e) => {
+        console.log(e);
+        return null;
+      });
+  },
+  removePfp: async function (id) {
+    return await fetch(`${API_BASE}/system/remove-pfp`, {
+      method: "DELETE",
+      headers: baseHeaders(),
+    })
+      .then((res) => {
+        if (res.ok) return { success: true, error: null };
+        throw new Error("Failed to remove pfp.");
+      })
+      .catch((e) => {
+        console.log(e);
+        return { success: false, error: e.message };
+      });
+  },
+
   isDefaultLogo: async function () {
     return await fetch(`${API_BASE}/system/is-default-logo`, {
       method: "GET",
@@ -374,6 +419,18 @@ const System = {
         return null;
       });
   },
+  updateUser: async (data) => {
+    return await fetch(`${API_BASE}/system/user`, {
+      method: "POST",
+      headers: baseHeaders(),
+      body: JSON.stringify(data),
+    })
+      .then((res) => res.json())
+      .catch((e) => {
+        console.error(e);
+        return { success: false, error: e.message };
+      });
+  },
 };
 
 export default System;
diff --git a/frontend/src/pages/GeneralSettings/Appearance/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/index.jsx
index e823dd0b7..7a992e916 100644
--- a/frontend/src/pages/GeneralSettings/Appearance/index.jsx
+++ b/frontend/src/pages/GeneralSettings/Appearance/index.jsx
@@ -10,7 +10,7 @@ import showToast from "@/utils/toast";
 import { Plus } from "@phosphor-icons/react";
 
 export default function Appearance() {
-  const { logo: _initLogo } = useLogo();
+  const { logo: _initLogo, setLogo: _setLogo } = useLogo();
   const [logo, setLogo] = useState("");
   const [hasChanges, setHasChanges] = useState(false);
   const [messages, setMessages] = useState([]);
@@ -49,6 +49,9 @@ export default function Appearance() {
       return;
     }
 
+    const logoURL = await System.fetchLogo();
+    _setLogo(logoURL);
+
     showToast("Image uploaded successfully.", "success");
     setIsDefaultLogo(false);
   };
@@ -67,6 +70,9 @@ export default function Appearance() {
       return;
     }
 
+    const logoURL = await System.fetchLogo();
+    _setLogo(logoURL);
+
     showToast("Image successfully removed.", "success");
   };
 
diff --git a/frontend/src/pages/OnboardingFlow/OnboardingModal/Steps/AppearanceSetup/index.jsx b/frontend/src/pages/OnboardingFlow/OnboardingModal/Steps/AppearanceSetup/index.jsx
index 496a4ff4f..30e87b0ac 100644
--- a/frontend/src/pages/OnboardingFlow/OnboardingModal/Steps/AppearanceSetup/index.jsx
+++ b/frontend/src/pages/OnboardingFlow/OnboardingModal/Steps/AppearanceSetup/index.jsx
@@ -6,7 +6,7 @@ import { Plus } from "@phosphor-icons/react";
 import showToast from "@/utils/toast";
 
 function AppearanceSetup({ prevStep, nextStep }) {
-  const { logo: _initLogo } = useLogo();
+  const { logo: _initLogo, setLogo: _setLogo } = useLogo();
   const [logo, setLogo] = useState("");
   const [isDefaultLogo, setIsDefaultLogo] = useState(true);
 
@@ -35,6 +35,9 @@ function AppearanceSetup({ prevStep, nextStep }) {
       return;
     }
 
+    const logoURL = await System.fetchLogo();
+    _setLogo(logoURL);
+
     showToast("Image uploaded successfully.", "success");
     setIsDefaultLogo(false);
   };
@@ -53,6 +56,9 @@ function AppearanceSetup({ prevStep, nextStep }) {
       return;
     }
 
+    const logoURL = await System.fetchLogo();
+    _setLogo(logoURL);
+
     showToast("Image successfully removed.", "success");
   };
 
diff --git a/server/endpoints/system.js b/server/endpoints/system.js
index 22ce8ef10..024bdd995 100644
--- a/server/endpoints/system.js
+++ b/server/endpoints/system.js
@@ -16,13 +16,18 @@ const {
   userFromSession,
   multiUserMode,
 } = require("../utils/http");
-const { setupDataImports, setupLogoUploads } = require("../utils/files/multer");
+const {
+  setupDataImports,
+  setupLogoUploads,
+  setupPfpUploads,
+} = require("../utils/files/multer");
 const { v4 } = require("uuid");
 const { SystemSettings } = require("../models/systemSettings");
 const { User } = require("../models/user");
 const { validatedRequest } = require("../utils/middleware/validatedRequest");
 const { handleImports } = setupDataImports();
 const { handleLogoUploads } = setupLogoUploads();
+const { handlePfpUploads } = setupPfpUploads();
 const fs = require("fs");
 const path = require("path");
 const {
@@ -41,6 +46,7 @@ const { getCustomModels } = require("../utils/helpers/customModels");
 const { WorkspaceChats } = require("../models/workspaceChats");
 const { Workspace } = require("../models/workspace");
 const { flexUserRoleValid } = require("../utils/middleware/multiUserProtected");
+const { fetchPfp, determinePfpFilepath } = require("../utils/files/pfp");
 
 function systemEndpoints(app) {
   if (!app) return;
@@ -399,7 +405,12 @@ function systemEndpoints(app) {
     try {
       const defaultFilename = getDefaultFilename();
       const logoPath = await determineLogoFilepath(defaultFilename);
-      const { buffer, size, mime } = fetchLogo(logoPath);
+      const { found, buffer, size, mime } = fetchLogo(logoPath);
+      if (!found) {
+        response.sendStatus(204).end();
+        return;
+      }
+
       response.writeHead(200, {
         "Content-Type": mime || "image/png",
         "Content-Disposition": `attachment; filename=${path.basename(
@@ -415,6 +426,110 @@ function systemEndpoints(app) {
     }
   });
 
+  app.get("/system/pfp/:id", async function (request, response) {
+    try {
+      const { id } = request.params;
+      const pfpPath = await determinePfpFilepath(id);
+
+      if (!pfpPath) {
+        response.sendStatus(204).end();
+        return;
+      }
+
+      const { found, buffer, size, mime } = fetchPfp(pfpPath);
+      if (!found) {
+        response.sendStatus(204).end();
+        return;
+      }
+
+      response.writeHead(200, {
+        "Content-Type": mime || "image/png",
+        "Content-Disposition": `attachment; filename=${path.basename(pfpPath)}`,
+        "Content-Length": size,
+      });
+      response.end(Buffer.from(buffer, "base64"));
+      return;
+    } catch (error) {
+      console.error("Error processing the logo request:", error);
+      response.status(500).json({ message: "Internal server error" });
+    }
+  });
+
+  app.post(
+    "/system/upload-pfp",
+    [validatedRequest, flexUserRoleValid],
+    handlePfpUploads.single("file"),
+    async function (request, response) {
+      try {
+        const user = await userFromSession(request, response);
+        const uploadedFileName = request.randomFileName;
+
+        if (!uploadedFileName) {
+          return response.status(400).json({ message: "File upload failed." });
+        }
+
+        const userRecord = await User.get({ id: user.id });
+        const oldPfpFilename = userRecord.pfpFilename;
+        console.log("oldPfpFilename", oldPfpFilename);
+        if (oldPfpFilename) {
+          const oldPfpPath = path.join(
+            __dirname,
+            `../storage/assets/pfp/${oldPfpFilename}`
+          );
+
+          if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath);
+        }
+
+        const { success, error } = await User.update(user.id, {
+          pfpFilename: uploadedFileName,
+        });
+
+        return response.status(success ? 200 : 500).json({
+          message: success
+            ? "Profile picture uploaded successfully."
+            : error || "Failed to update with new profile picture.",
+        });
+      } catch (error) {
+        console.error("Error processing the profile picture upload:", error);
+        response.status(500).json({ message: "Internal server error" });
+      }
+    }
+  );
+
+  app.delete(
+    "/system/remove-pfp",
+    [validatedRequest, flexUserRoleValid],
+    async function (request, response) {
+      try {
+        const user = await userFromSession(request, response);
+        const userRecord = await User.get({ id: user.id });
+        const oldPfpFilename = userRecord.pfpFilename;
+        console.log("oldPfpFilename", oldPfpFilename);
+        if (oldPfpFilename) {
+          const oldPfpPath = path.join(
+            __dirname,
+            `../storage/assets/pfp/${oldPfpFilename}`
+          );
+
+          if (fs.existsSync(oldPfpPath)) fs.unlinkSync(oldPfpPath);
+        }
+
+        const { success, error } = await User.update(user.id, {
+          pfpFilename: null,
+        });
+
+        return response.status(success ? 200 : 500).json({
+          message: success
+            ? "Profile picture removed successfully."
+            : error || "Failed to remove profile picture.",
+        });
+      } catch (error) {
+        console.error("Error processing the profile picture removal:", error);
+        response.status(500).json({ message: "Internal server error" });
+      }
+    }
+  );
+
   app.post(
     "/system/upload-logo",
     [validatedRequest, flexUserRoleValid],
@@ -738,6 +853,40 @@ function systemEndpoints(app) {
       }
     }
   );
+
+  app.post("/system/user", [validatedRequest], async (request, response) => {
+    try {
+      const sessionUser = await userFromSession(request, response);
+      const { username, password } = reqBody(request);
+      const id = Number(sessionUser.id);
+
+      if (!id) {
+        response.status(400).json({ success: false, error: "Invalid user ID" });
+        return;
+      }
+
+      const updates = {};
+      if (username) {
+        updates.username = username;
+      }
+      if (password) {
+        updates.password = password;
+      }
+
+      if (Object.keys(updates).length === 0) {
+        response
+          .status(400)
+          .json({ success: false, error: "No updates provided" });
+        return;
+      }
+
+      const { success, error } = await User.update(id, updates);
+      response.status(200).json({ success, error });
+    } catch (e) {
+      console.error(e);
+      response.sendStatus(500).end();
+    }
+  });
 }
 
 module.exports = { systemEndpoints };
diff --git a/server/prisma/migrations/20231129012019_add/migration.sql b/server/prisma/migrations/20231129012019_add/migration.sql
new file mode 100644
index 000000000..7e37f7e89
--- /dev/null
+++ b/server/prisma/migrations/20231129012019_add/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN "pfpFilename" TEXT;
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index b2661e384..e9aa8a8a5 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -57,6 +57,7 @@ model users {
   id              Int               @id @default(autoincrement())
   username        String?           @unique
   password        String
+  pfpFilename     String?
   role            String            @default("default")
   suspended       Int               @default(0)
   createdAt       DateTime          @default(now())
diff --git a/server/utils/files/logo.js b/server/utils/files/logo.js
index 14e8032f9..eb4738b09 100644
--- a/server/utils/files/logo.js
+++ b/server/utils/files/logo.js
@@ -41,6 +41,7 @@ function fetchLogo(logoPath) {
   const mime = getType(logoPath);
   const buffer = fs.readFileSync(logoPath);
   return {
+    found: true,
     buffer,
     size: buffer.length,
     mime,
diff --git a/server/utils/files/multer.js b/server/utils/files/multer.js
index cc12ac9fd..9c2967e01 100644
--- a/server/utils/files/multer.js
+++ b/server/utils/files/multer.js
@@ -1,6 +1,7 @@
 const multer = require("multer");
 const path = require("path");
 const fs = require("fs");
+const { v4 } = require("uuid");
 
 function setupMulter() {
   // Handle File uploads for auto-uploading.
@@ -40,7 +41,10 @@ function setupLogoUploads() {
   // Handle Logo uploads.
   const storage = multer.diskStorage({
     destination: function (_, _, cb) {
-      const uploadOutput = path.resolve(__dirname, `../../storage/assets`);
+      const uploadOutput =
+        process.env.NODE_ENV === "development"
+          ? path.resolve(__dirname, `../../storage/assets`)
+          : path.resolve(process.env.STORAGE_DIR, "assets");
       fs.mkdirSync(uploadOutput, { recursive: true });
       return cb(null, uploadOutput);
     },
@@ -52,8 +56,29 @@ function setupLogoUploads() {
   return { handleLogoUploads: multer({ storage }) };
 }
 
+function setupPfpUploads() {
+  const storage = multer.diskStorage({
+    destination: function (_, _, cb) {
+      const uploadOutput =
+        process.env.NODE_ENV === "development"
+          ? path.resolve(__dirname, `../../storage/assets/pfp`)
+          : path.resolve(process.env.STORAGE_DIR, "assets/pfp");
+      fs.mkdirSync(uploadOutput, { recursive: true });
+      return cb(null, uploadOutput);
+    },
+    filename: function (req, file, cb) {
+      const randomFileName = `${v4()}${path.extname(file.originalname)}`;
+      req.randomFileName = randomFileName;
+      cb(null, randomFileName);
+    },
+  });
+
+  return { handlePfpUploads: multer({ storage }) };
+}
+
 module.exports = {
   setupMulter,
   setupDataImports,
   setupLogoUploads,
+  setupPfpUploads,
 };
diff --git a/server/utils/files/pfp.js b/server/utils/files/pfp.js
new file mode 100644
index 000000000..30c42a519
--- /dev/null
+++ b/server/utils/files/pfp.js
@@ -0,0 +1,44 @@
+const path = require("path");
+const fs = require("fs");
+const { getType } = require("mime");
+const { User } = require("../../models/user");
+
+function fetchPfp(pfpPath) {
+  if (!fs.existsSync(pfpPath)) {
+    return {
+      found: false,
+      buffer: null,
+      size: 0,
+      mime: "none/none",
+    };
+  }
+
+  const mime = getType(pfpPath);
+  const buffer = fs.readFileSync(pfpPath);
+  return {
+    found: true,
+    buffer,
+    size: buffer.length,
+    mime,
+  };
+}
+
+async function determinePfpFilepath(id) {
+  const numberId = Number(id);
+  const user = await User.get({ id: numberId });
+  const pfpFilename = user.pfpFilename;
+  if (!pfpFilename) return null;
+
+  const basePath = process.env.STORAGE_DIR
+    ? path.join(process.env.STORAGE_DIR, "assets/pfp")
+    : path.join(__dirname, "../../storage/assets/pfp");
+  const pfpFilepath = path.join(basePath, pfpFilename);
+
+  if (!fs.existsSync(pfpFilepath)) return null;
+  return pfpFilepath;
+}
+
+module.exports = {
+  fetchPfp,
+  determinePfpFilepath,
+};
-- 
GitLab