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