diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 558e8ae3..2d1eeb7d 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 ( }> - - } /> - } /> - } - /> - } /> + + + + } /> + } /> + } + /> + } /> - {/* Admin */} - } - /> - } - /> - } - /> - {/* Manager */} - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - {/* Onboarding Flow */} - } /> - - + {/* Admin */} + } + /> + } + /> + } + /> + {/* Manager */} + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + {/* Onboarding Flow */} + } /> + + + + ); diff --git a/frontend/src/LogoContext.jsx b/frontend/src/LogoContext.jsx new file mode 100644 index 00000000..6818967b --- /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 ( + + {children} + + ); +} diff --git a/frontend/src/PfpContext.jsx b/frontend/src/PfpContext.jsx new file mode 100644 index 00000000..3d60d559 --- /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 ( + + {children} + + ); +} diff --git a/frontend/src/components/UserIcon/index.jsx b/frontend/src/components/UserIcon/index.jsx index 40694606..6cc9b57d 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 ( -
+
+
+ {role === "user" && pfp && ( + User profile picture + )} +
); } 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 8bfc1b93..61e111da 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 ( +
+ User profile picture +
+ ); + } + 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" >
+ {mode === "multi" && !!user && ( + + )}
)} + {user && showAccountSettings && ( + setShowAccountSettings(false)} + /> + )} +
+ ); +} + +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 ( +
+
+
+

Edit Account

+ +
+
+
+
+ + {pfp && ( + + )} +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
); } diff --git a/frontend/src/hooks/useLogo.js b/frontend/src/hooks/useLogo.js index f03d2098..4834b7a8 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 00000000..36c54497 --- /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 2ef4eebb..79c203d9 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 e823dd0b..7a992e91 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 496a4ff4..30e87b0a 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 22ce8ef1..024bdd99 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 00000000..7e37f7e8 --- /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 b2661e38..e9aa8a8a 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 14e8032f..eb4738b0 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 cc12ac9f..9c2967e0 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 00000000..30c42a51 --- /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, +};