mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2024-11-16 11:20:10 +01:00
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>
This commit is contained in:
parent
f48e6b1a3e
commit
fcb591d364
@ -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>
|
||||
);
|
||||
|
28
frontend/src/LogoContext.jsx
Normal file
28
frontend/src/LogoContext.jsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
30
frontend/src/PfpContext.jsx
Normal file
30
frontend/src/PfpContext.jsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 };
|
||||
}
|
||||
|
7
frontend/src/hooks/usePfp.js
Normal file
7
frontend/src/hooks/usePfp.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { useContext } from "react";
|
||||
import { PfpContext } from "../PfpContext";
|
||||
|
||||
export default function usePfp() {
|
||||
const { pfp, setPfp } = useContext(PfpContext);
|
||||
return { pfp, setPfp };
|
||||
}
|
@ -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;
|
||||
|
@ -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");
|
||||
};
|
||||
|
||||
|
@ -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");
|
||||
};
|
||||
|
||||
|
@ -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 };
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "pfpFilename" TEXT;
|
@ -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())
|
||||
|
@ -41,6 +41,7 @@ function fetchLogo(logoPath) {
|
||||
const mime = getType(logoPath);
|
||||
const buffer = fs.readFileSync(logoPath);
|
||||
return {
|
||||
found: true,
|
||||
buffer,
|
||||
size: buffer.length,
|
||||
mime,
|
||||
|
@ -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,
|
||||
};
|
||||
|
44
server/utils/files/pfp.js
Normal file
44
server/utils/files/pfp.js
Normal file
@ -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,
|
||||
};
|
Loading…
Reference in New Issue
Block a user