From b9855249015c99a8ff6f86c382c954cfff91df80 Mon Sep 17 00:00:00 2001 From: Sean Hatfield <seanhatfield5@gmail.com> Date: Thu, 8 Feb 2024 12:17:01 -0800 Subject: [PATCH] [FEAT] Customizable footer icon links in Appearance Settings (#694) * WIP custom footer icons * UI for updating footer icons complete and backend to save/modify * add backend for unprotected footer fetch * break out footer into separate component and render footer items using a cache for 1 hour * wip review * refactor & cleanup * Optimize footer form component Optimize caching for footer icons Add validation on SystemSetting upserts Normalize fallback items for footer_data * Adjust max icons to 3 * fix success message on remove * fix success message on remove --------- Co-authored-by: timothycarambat <rambat1010@gmail.com> --- frontend/src/components/Footer/index.jsx | 97 ++++++++++++ .../src/components/SettingsSidebar/index.jsx | 36 +---- frontend/src/components/Sidebar/index.jsx | 71 +-------- frontend/src/models/system.js | 37 ++++- frontend/src/pages/Admin/System/index.jsx | 2 +- .../FooterCustomization/NewIconForm/index.jsx | 98 ++++++++++++ .../Appearance/FooterCustomization/index.jsx | 140 ++++++++++++++++++ .../GeneralSettings/Appearance/index.jsx | 2 + frontend/src/utils/request.js | 7 + server/endpoints/admin.js | 10 +- server/endpoints/system.js | 12 ++ server/models/systemSettings.js | 20 ++- 12 files changed, 423 insertions(+), 109 deletions(-) create mode 100644 frontend/src/components/Footer/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/NewIconForm/index.jsx create mode 100644 frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/index.jsx diff --git a/frontend/src/components/Footer/index.jsx b/frontend/src/components/Footer/index.jsx new file mode 100644 index 000000000..26b271a8c --- /dev/null +++ b/frontend/src/components/Footer/index.jsx @@ -0,0 +1,97 @@ +import System from "@/models/system"; +import paths from "@/utils/paths"; +import { safeJsonParse } from "@/utils/request"; +import { + BookOpen, + DiscordLogo, + GithubLogo, + Briefcase, + Envelope, + Globe, + HouseLine, + Info, + LinkSimple, +} from "@phosphor-icons/react"; +import React, { useEffect, useState } from "react"; + +export const MAX_ICONS = 3; +export const ICON_COMPONENTS = { + BookOpen: BookOpen, + DiscordLogo: DiscordLogo, + GithubLogo: GithubLogo, + Envelope: Envelope, + LinkSimple: LinkSimple, + HouseLine: HouseLine, + Globe: Globe, + Briefcase: Briefcase, + Info: Info, +}; + +export default function Footer() { + const [footerData, setFooterData] = useState(false); + + useEffect(() => { + async function fetchFooterData() { + const { footerData } = await System.fetchCustomFooterIcons(); + setFooterData(footerData); + } + fetchFooterData(); + }, []); + + // wait for some kind of non-false response from footer data first + // to prevent pop-in. + if (footerData === false) return null; + + if (!Array.isArray(footerData) || footerData.length === 0) { + return ( + <div className="flex justify-center mt-2"> + <div className="flex space-x-4"> + <a + href={paths.github()} + target="_blank" + className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" + > + <GithubLogo weight="fill" className="h-5 w-5 " /> + </a> + <a + href={paths.docs()} + target="_blank" + className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" + > + <BookOpen weight="fill" className="h-5 w-5 " /> + </a> + <a + href={paths.discord()} + target="_blank" + className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" + > + <DiscordLogo + weight="fill" + className="h-5 w-5 stroke-slate-200 group-hover:stroke-slate-200" + /> + </a> + </div> + </div> + ); + } + + return ( + <div className="flex justify-center mt-2"> + <div className="flex space-x-4"> + {footerData.map((item, index) => ( + <a + key={index} + href={item.url} + target="_blank" + className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" + > + {React.createElement(ICON_COMPONENTS[item.icon], { + weight: "fill", + className: "h-5 w-5", + })} + </a> + ))} + </div> + </div> + ); +} diff --git a/frontend/src/components/SettingsSidebar/index.jsx b/frontend/src/components/SettingsSidebar/index.jsx index 13c4abeab..63f2efd91 100644 --- a/frontend/src/components/SettingsSidebar/index.jsx +++ b/frontend/src/components/SettingsSidebar/index.jsx @@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState } from "react"; import paths from "@/utils/paths"; import useLogo from "@/hooks/useLogo"; import { - DiscordLogo, EnvelopeSimple, SquaresFour, Users, @@ -13,7 +12,6 @@ import { ChatText, Database, Lock, - GithubLogo, House, X, List, @@ -26,6 +24,7 @@ import { import useUser from "@/hooks/useUser"; import { USER_BACKGROUND_COLOR } from "@/utils/constants"; import { isMobile } from "react-device-detect"; +import Footer from "../Footer"; export default function SettingsSidebar() { const { logo } = useLogo(); @@ -172,39 +171,6 @@ export default function SettingsSidebar() { ); } -const Footer = () => { - return ( - <div className="flex justify-center mt-2"> - <div className="flex space-x-4"> - <a - href={paths.github()} - className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" - > - <GithubLogo weight="fill" className="h-5 w-5 " /> - </a> - <a - href={paths.docs()} - className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" - > - <BookOpen weight="fill" className="h-5 w-5 " /> - </a> - <a - href={paths.discord()} - className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" - > - <DiscordLogo - weight="fill" - className="h-5 w-5 stroke-slate-200 group-hover:stroke-slate-200" - /> - </a> - {/* <button className="invisible transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"> - <DotsThree className="h-5 w-5 group-hover:stroke-slate-200" /> - </button> */} - </div> - </div> - ); -}; - const Option = ({ btnText, icon, diff --git a/frontend/src/components/Sidebar/index.jsx b/frontend/src/components/Sidebar/index.jsx index 58af4048b..d14099b2b 100644 --- a/frontend/src/components/Sidebar/index.jsx +++ b/frontend/src/components/Sidebar/index.jsx @@ -1,13 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { - Wrench, - GithubLogo, - BookOpen, - DiscordLogo, - DotsThree, - Plus, - List, -} from "@phosphor-icons/react"; +import { Wrench, Plus, List } from "@phosphor-icons/react"; import NewWorkspaceModal, { useNewWorkspaceModal, } from "../Modals/NewWorkspace"; @@ -16,6 +8,7 @@ import paths from "@/utils/paths"; import { USER_BACKGROUND_COLOR } from "@/utils/constants"; import useLogo from "@/hooks/useLogo"; import useUser from "@/hooks/useUser"; +import Footer from "../Footer"; export default function Sidebar() { const { user } = useUser(); @@ -71,35 +64,7 @@ export default function Sidebar() { <ActiveWorkspaces /> </div> <div className="flex flex-col flex-grow justify-end mb-2"> - {/* Footer */} - <div className="flex justify-center mt-2"> - <div className="flex space-x-4"> - <a - href={paths.github()} - className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" - > - <GithubLogo weight="fill" className="h-5 w-5 " /> - </a> - <a - href={paths.docs()} - className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" - > - <BookOpen weight="fill" className="h-5 w-5 " /> - </a> - <a - href={paths.discord()} - className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" - > - <DiscordLogo - weight="fill" - className="h-5 w-5 stroke-slate-200 group-hover:stroke-slate-200" - /> - </a> - {/* <button className="invisible transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"> - <DotsThree className="h-5 w-5 group-hover:stroke-slate-200" /> - </button> */} - </div> - </div> + <Footer /> </div> </div> </div> @@ -215,35 +180,7 @@ export function SidebarMobileHeader() { </div> </div> <div> - {/* Footer */} - <div className="flex justify-center mt-2"> - <div className="flex space-x-4"> - <a - href={paths.github()} - className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" - > - <GithubLogo weight="fill" className="h-5 w-5 " /> - </a> - <a - href={paths.docs()} - className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" - > - <BookOpen weight="fill" className="h-5 w-5 " /> - </a> - <a - href={paths.discord()} - className="transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border" - > - <DiscordLogo - weight="fill" - className="h-5 w-5 stroke-slate-200 group-hover:stroke-slate-200" - /> - </a> - {/* <button className="invisible transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border-transparent border"> - <DotsThree className="h-5 w-5 group-hover:stroke-slate-200" /> - </button> */} - </div> - </div> + <Footer /> </div> </div> </div> diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 0ffbae690..9b6cc507c 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -1,8 +1,11 @@ import { API_BASE, AUTH_TIMESTAMP, fullApiUrl } from "@/utils/constants"; -import { baseHeaders } from "@/utils/request"; +import { baseHeaders, safeJsonParse } from "@/utils/request"; import DataConnector from "./dataConnector"; const System = { + cacheKeys: { + footerIcons: "anythingllm_footer_links", + }, ping: async function () { return await fetch(`${API_BASE}/ping`) .then((res) => res.json()) @@ -190,6 +193,38 @@ const System = { return { success: false, error: e.message }; }); }, + fetchCustomFooterIcons: async function () { + const cache = window.localStorage.getItem(this.cacheKeys.footerIcons); + const { data, lastFetched } = cache + ? safeJsonParse(cache, { data: [], lastFetched: 0 }) + : { data: [], lastFetched: 0 }; + + if (!!data && Date.now() - lastFetched < 3_600_000) + return { footerData: data, error: null }; + + const { footerData, error } = await fetch( + `${API_BASE}/system/footer-data`, + { + method: "GET", + cache: "no-cache", + headers: baseHeaders(), + } + ) + .then((res) => res.json()) + .catch((e) => { + console.log(e); + return { footerData: [], error: e.message }; + }); + + if (!footerData || !!error) return { footerData: [], error: null }; + + const newData = safeJsonParse(footerData, []); + window.localStorage.setItem( + this.cacheKeys.footerIcons, + JSON.stringify({ data: newData, lastFetched: Date.now() }) + ); + return { footerData: newData, error: null }; + }, fetchLogo: async function () { return await fetch(`${API_BASE}/system/logo`, { method: "GET", diff --git a/frontend/src/pages/Admin/System/index.jsx b/frontend/src/pages/Admin/System/index.jsx index 909408865..c561efd2e 100644 --- a/frontend/src/pages/Admin/System/index.jsx +++ b/frontend/src/pages/Admin/System/index.jsx @@ -27,7 +27,7 @@ export default function AdminSystem() { useEffect(() => { async function fetchSettings() { - const { settings } = await Admin.systemPreferences(); + const settings = (await Admin.systemPreferences())?.settings; if (!settings) return; setCanDelete(settings?.users_can_delete_workspaces); setMessageLimit({ diff --git a/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/NewIconForm/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/NewIconForm/index.jsx new file mode 100644 index 000000000..8828bbbaa --- /dev/null +++ b/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/NewIconForm/index.jsx @@ -0,0 +1,98 @@ +import { ICON_COMPONENTS } from "@/components/Footer"; +import React, { useEffect, useRef, useState } from "react"; + +export default function NewIconForm({ handleSubmit, showing }) { + const [selectedIcon, setSelectedIcon] = useState("Info"); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsDropdownOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [dropdownRef]); + + if (!showing) return null; + return ( + <form onSubmit={handleSubmit} className="flex justify-start"> + <div className="mt-6 mb-6 flex flex-col bg-zinc-900 rounded-lg px-6 py-4"> + <div className="flex gap-x-4 items-center"> + <div + className="relative flex flex-col items-center gap-y-4" + ref={dropdownRef} + > + <input type="hidden" name="icon" value={selectedIcon} /> + <label className="text-sm font-medium text-white">Icon</label> + <button + type="button" + className={`${ + isDropdownOpen + ? "bg-menu-item-selected-gradient border-slate-100/50" + : "" + }border-transparent transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border`} + onClick={(e) => { + e.preventDefault(); + setIsDropdownOpen(!isDropdownOpen); + }} + > + {React.createElement(ICON_COMPONENTS[selectedIcon], { + className: "h-5 w-5 text-white", + weight: "fill", + })} + </button> + {isDropdownOpen && ( + <div className="absolute z-10 grid grid-cols-4 gap-4 bg-zinc-800 -mt-20 ml-44 p-1 rounded-md w-56 h-28 overflow-y-auto border border-slate-100/10"> + {Object.keys(ICON_COMPONENTS).map((iconName) => ( + <button + key={iconName} + type="button" + className="flex justify-center items-center border border-transparent hover:bg-menu-item-selected-gradient hover:border-slate-100 rounded-full" + onClick={() => { + setSelectedIcon(iconName); + setIsDropdownOpen(false); + }} + > + {React.createElement(ICON_COMPONENTS[iconName], { + className: "h-5 w-5 text-white m-2.5", + weight: "fill", + })} + </button> + ))} + </div> + )} + </div> + <div className="flex flex-col gap-y-4"> + <label className="text-sm font-medium text-white">Link</label> + <input + type="url" + name="url" + required={true} + placeholder="https://example.com" + className="bg-sidebar text-white placeholder-white/60 rounded-md p-2" + /> + </div> + {selectedIcon !== "" && ( + <div className="flex flex-col gap-y-4"> + <label className="text-sm font-medium text-white invisible"> + Submit + </label> + <div className="flex justify-center"> + <button + type="submit" + className="transition-all duration-300 border border-slate-200 px-4 py-2 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800" + > + Save + </button> + </div> + </div> + )} + </div> + </div> + </form> + ); +} diff --git a/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/index.jsx new file mode 100644 index 000000000..ee94e7c34 --- /dev/null +++ b/frontend/src/pages/GeneralSettings/Appearance/FooterCustomization/index.jsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from "react"; +import showToast from "@/utils/toast"; +import { Plus, X } from "@phosphor-icons/react"; +import { ICON_COMPONENTS, MAX_ICONS } from "@/components/Footer"; +import { safeJsonParse } from "@/utils/request"; +import NewIconForm from "./NewIconForm"; +import Admin from "@/models/admin"; +import System from "@/models/system"; + +export default function FooterCustomization() { + const [loading, setLoading] = useState(true); + const [footerIcons, setFooterIcons] = useState([]); + const [showForm, setShowForm] = useState(false); + + useEffect(() => { + async function fetchFooterIcons() { + const settings = (await Admin.systemPreferences())?.settings; + if (settings && settings.footer_data) { + setFooterIcons(safeJsonParse(settings.footer_data, [])); + } + setLoading(false); + } + fetchFooterIcons(); + }, []); + + const removeFooterIcon = async (index) => { + const updatedIcons = footerIcons.filter((_, i) => i !== index); + const { success, error } = await Admin.updateSystemPreferences({ + footer_data: JSON.stringify(updatedIcons), + }); + + if (!success) { + showToast(`Failed to remove footer icon - ${error}`, "error", { + clear: true, + }); + return; + } + + window.localStorage.removeItem(System.cacheKeys.footerIcons); + setFooterIcons(updatedIcons); + showToast("Successfully removed footer icon.", "success", { clear: true }); + }; + + const onSubmit = async (e) => { + e.preventDefault(); + const form = new FormData(e.target); + const icon = form.get("icon"); + const url = form.get("url"); + + const newIcon = { icon, url }; + setFooterIcons([...footerIcons, newIcon]); + + const { success, error } = await Admin.updateSystemPreferences({ + footer_data: JSON.stringify([...footerIcons, newIcon]), + }); + + if (!success) { + showToast(`Failed to add footer icon - ${error}`, "error", { + clear: true, + }); + return; + } + window.localStorage.removeItem(System.cacheKeys.footerIcons); + + setShowForm(false); + showToast("Successfully added footer icon.", "success", { clear: true }); + }; + + return ( + <div className="mb-6"> + <div className="flex flex-col gap-y-2"> + <h2 className="leading-tight font-medium text-white"> + Custom Footer Icons + </h2> + <p className="text-sm font-base text-white/60"> + Customize the footer icons displayed on the bottom of the sidebar. + </p> + </div> + <CurrentIcons footerIcons={footerIcons} remove={removeFooterIcon} /> + <NewIconForm + handleSubmit={onSubmit} + showing={footerIcons.length < MAX_ICONS && showForm} + /> + <div hidden={!(!showForm && footerIcons.length < MAX_ICONS) || loading}> + <div className="flex gap-2 mt-6"> + <button + onClick={() => setShowForm(true)} + className="flex gap-x-2 items-center justify-center text-white text-sm hover:text-sky-400 transition-all duration-300" + > + Add new footer icon + <Plus className="" size={24} weight="fill" /> + </button> + </div> + </div> + </div> + ); +} + +function CurrentIcons({ footerIcons, remove }) { + if (footerIcons.length === 0) return null; + return ( + <div className="flex flex-col w-fit gap-y-2 mt-4"> + {footerIcons.map((icon, index) => ( + <div + key={index} + className="flex items-center justify-between bg-zinc-900 p-2 rounded-md gap-x-4" + > + <div className="flex items-center gap-x-2"> + <IconPreview symbol={icon.icon} disabled={true} /> + <span className="text-white/60">{icon.url}</span> + </div> + + <button + type="button" + className="transition-all duration-300 text-neutral-700 bg-transparent rounded-full hover:bg-zinc-600 hover:border-zinc-600 hover:text-white border-transparent border shadow-lg mr-2" + onClick={() => remove(index)} + > + <X className="m-[1px]" size={20} /> + </button> + </div> + ))} + </div> + ); +} + +const IconPreview = ({ symbol, disabled = false }) => { + const IconComponent = ICON_COMPONENTS.hasOwnProperty(symbol) + ? ICON_COMPONENTS[symbol] + : ICON_COMPONENTS.Info; + + return ( + <button + type="button" + disabled={disabled} + className="disabled:pointer-events-none border-transparent transition-all duration-300 p-2 rounded-full text-white bg-sidebar-button hover:bg-menu-item-selected-gradient hover:border-slate-100 hover:border-opacity-50 border mx-1" + > + <IconComponent className="h-5 w-5 text-white" weight="fill" /> + </button> + ); +}; diff --git a/frontend/src/pages/GeneralSettings/Appearance/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/index.jsx index a2a9ec001..99d413a4c 100644 --- a/frontend/src/pages/GeneralSettings/Appearance/index.jsx +++ b/frontend/src/pages/GeneralSettings/Appearance/index.jsx @@ -7,6 +7,7 @@ import System from "@/models/system"; import EditingChatBubble from "@/components/EditingChatBubble"; import showToast from "@/utils/toast"; import { Plus } from "@phosphor-icons/react"; +import FooterCustomization from "./FooterCustomization"; export default function Appearance() { const { logo: _initLogo, setLogo: _setLogo } = useLogo(); @@ -248,6 +249,7 @@ export default function Appearance() { </div> )} </div> + <FooterCustomization /> </div> </div> </div> diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js index 271c97b20..a568fb8e1 100644 --- a/frontend/src/utils/request.js +++ b/frontend/src/utils/request.js @@ -17,3 +17,10 @@ export function baseHeaders(providedToken = null) { Authorization: token ? `Bearer ${token}` : null, }; } + +export function safeJsonParse(jsonString, fallback = null) { + try { + return JSON.parse(jsonString); + } catch {} + return fallback; +} diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index d9e1f9a0b..885fa9980 100644 --- a/server/endpoints/admin.js +++ b/server/endpoints/admin.js @@ -17,6 +17,7 @@ const { const { reqBody, userFromSession } = require("../utils/http"); const { strictMultiUserRoleValid, + flexUserRoleValid, ROLES, } = require("../utils/middleware/multiUserProtected"); const { validatedRequest } = require("../utils/middleware/validatedRequest"); @@ -289,8 +290,8 @@ function adminEndpoints(app) { app.get( "/admin/system-preferences", - [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], - async (_request, response) => { + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], + async (_, response) => { try { const settings = { users_can_delete_workspaces: @@ -303,6 +304,9 @@ function adminEndpoints(app) { Number( (await SystemSettings.get({ label: "message_limit" }))?.value ) || 10, + footer_data: + (await SystemSettings.get({ label: "footer_data" }))?.value || + JSON.stringify([]), }; response.status(200).json({ settings }); } catch (e) { @@ -314,7 +318,7 @@ function adminEndpoints(app) { app.post( "/admin/system-preferences", - [validatedRequest, strictMultiUserRoleValid([ROLES.admin, ROLES.manager])], + [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], async (request, response) => { try { const updates = reqBody(request); diff --git a/server/endpoints/system.js b/server/endpoints/system.js index 823de7f1c..680545034 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -460,6 +460,18 @@ function systemEndpoints(app) { } }); + app.get("/system/footer-data", [validatedRequest], async (_, response) => { + try { + const footerData = + (await SystemSettings.get({ label: "footer_data" }))?.value ?? + JSON.stringify([]); + response.status(200).json({ footerData: footerData }); + } catch (error) { + console.error("Error fetching footer data:", error); + response.status(500).json({ message: "Internal server error" }); + } + }); + app.get( "/system/pfp/:id", [validatedRequest, flexUserRoleValid([ROLES.all])], diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js index abb930127..8a008d0fa 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -12,7 +12,19 @@ const SystemSettings = { "message_limit", "logo_filename", "telemetry_id", + "footer_data", ], + validations: { + footer_data: (updates) => { + try { + const array = JSON.parse(updates); + return JSON.stringify(array.slice(0, 3)); // max of 3 items in footer. + } catch (e) { + console.error(`Failed to run validation function on footer_data`); + return JSON.stringify([]); + } + }, + }, currentSettings: async function () { const llmProvider = process.env.LLM_PROVIDER; const vectorDB = process.env.VECTOR_DB; @@ -239,14 +251,18 @@ const SystemSettings = { const updatePromises = Object.keys(updates) .filter((key) => this.supportedFields.includes(key)) .map((key) => { + const validatedValue = this.validations.hasOwnProperty(key) + ? this.validations[key](updates[key]) + : updates[key]; + return prisma.system_settings.upsert({ where: { label: key }, update: { - value: updates[key] === null ? null : String(updates[key]), + value: validatedValue === null ? null : String(validatedValue), }, create: { label: key, - value: updates[key] === null ? null : String(updates[key]), + value: validatedValue === null ? null : String(validatedValue), }, }); }); -- GitLab