diff --git a/frontend/src/LogoContext.jsx b/frontend/src/LogoContext.jsx index 6818967b8c02c709149abea6a1b757fe3cdc8f2c..014c6a6be0f737e4a59375e483b5fa9cd88db2a2 100644 --- a/frontend/src/LogoContext.jsx +++ b/frontend/src/LogoContext.jsx @@ -1,27 +1,41 @@ import { createContext, useEffect, useState } from "react"; import AnythingLLM from "./media/logo/anything-llm.png"; +import DefaultLoginLogo from "./media/illustrations/login-logo.svg"; import System from "./models/system"; export const LogoContext = createContext(); export function LogoProvider({ children }) { const [logo, setLogo] = useState(""); + const [loginLogo, setLoginLogo] = useState(""); + const [isCustomLogo, setIsCustomLogo] = useState(false); useEffect(() => { async function fetchInstanceLogo() { try { - const logoURL = await System.fetchLogo(); - logoURL ? setLogo(logoURL) : setLogo(AnythingLLM); + const { isCustomLogo, logoURL } = await System.fetchLogo(); + if (logoURL) { + setLogo(logoURL); + setLoginLogo(isCustomLogo ? logoURL : DefaultLoginLogo); + setIsCustomLogo(isCustomLogo); + } else { + setLogo(AnythingLLM); + setLoginLogo(DefaultLoginLogo); + setIsCustomLogo(false); + } } catch (err) { setLogo(AnythingLLM); + setLoginLogo(DefaultLoginLogo); + setIsCustomLogo(false); console.error("Failed to fetch logo:", err); } } + fetchInstanceLogo(); }, []); return ( - <LogoContext.Provider value={{ logo, setLogo }}> + <LogoContext.Provider value={{ logo, setLogo, loginLogo, isCustomLogo }}> {children} </LogoContext.Provider> ); diff --git a/frontend/src/components/Modals/Password/MultiUserAuth.jsx b/frontend/src/components/Modals/Password/MultiUserAuth.jsx index e4de5e67e28bfb48d99bef771d0069287b38dee8..04625b950721cd761e7a67fa342de0dca43f6e96 100644 --- a/frontend/src/components/Modals/Password/MultiUserAuth.jsx +++ b/frontend/src/components/Modals/Password/MultiUserAuth.jsx @@ -168,6 +168,7 @@ export default function MultiUserAuth() { const [token, setToken] = useState(null); const [showRecoveryForm, setShowRecoveryForm] = useState(false); const [showResetPasswordForm, setShowResetPasswordForm] = useState(false); + const [customAppName, setCustomAppName] = useState(null); const { isOpen: isRecoveryCodeModalOpen, @@ -250,6 +251,15 @@ export default function MultiUserAuth() { } }, [downloadComplete, user, token]); + useEffect(() => { + const fetchCustomAppName = async () => { + const { appName } = await System.fetchCustomAppName(); + setCustomAppName(appName || ""); + setLoading(false); + }; + fetchCustomAppName(); + }, []); + if (showRecoveryForm) { return ( <RecoveryForm @@ -272,11 +282,11 @@ export default function MultiUserAuth() { Welcome to </h3> <p className="text-4xl md:text-2xl font-bold bg-gradient-to-r from-[#75D6FF] via-[#FFFFFF] to-[#FFFFFF] bg-clip-text text-transparent"> - AnythingLLM + {customAppName || "AnythingLLM"} </p> </div> <p className="text-sm text-white/90 text-center"> - Sign in to your AnythingLLM account. + Sign in to your {customAppName || "AnythingLLM"} account. </p> </div> </div> diff --git a/frontend/src/components/Modals/Password/SingleUserAuth.jsx b/frontend/src/components/Modals/Password/SingleUserAuth.jsx index c1f328ba2ca3b438f742a71dcc12f3203d2fed4f..541d2db5295e24a74d460b4ae48312117933ffe0 100644 --- a/frontend/src/components/Modals/Password/SingleUserAuth.jsx +++ b/frontend/src/components/Modals/Password/SingleUserAuth.jsx @@ -1,7 +1,6 @@ import React, { useEffect, useState } from "react"; import System from "../../../models/system"; import { AUTH_TOKEN } from "../../../utils/constants"; -import useLogo from "../../../hooks/useLogo"; import paths from "../../../utils/paths"; import ModalWrapper from "@/components/ModalWrapper"; import { useModal } from "@/hooks/useModal"; @@ -10,10 +9,10 @@ import RecoveryCodeModal from "@/components/Modals/DisplayRecoveryCodeModal"; export default function SingleUserAuth() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const { logo: _initLogo } = useLogo(); const [recoveryCodes, setRecoveryCodes] = useState([]); const [downloadComplete, setDownloadComplete] = useState(false); const [token, setToken] = useState(null); + const [customAppName, setCustomAppName] = useState(null); const { isOpen: isRecoveryCodeModalOpen, @@ -57,6 +56,15 @@ export default function SingleUserAuth() { } }, [downloadComplete, token]); + useEffect(() => { + const fetchCustomAppName = async () => { + const { appName } = await System.fetchCustomAppName(); + setCustomAppName(appName || ""); + setLoading(false); + }; + fetchCustomAppName(); + }, []); + return ( <> <form onSubmit={handleLogin}> @@ -68,11 +76,11 @@ export default function SingleUserAuth() { Welcome to </h3> <p className="text-4xl md:text-2xl font-bold bg-gradient-to-r from-[#75D6FF] via-[#FFFFFF] to-[#FFFFFF] bg-clip-text text-transparent"> - AnythingLLM + {customAppName || "AnythingLLM"} </p> </div> <p className="text-sm text-white/90 text-center"> - Sign in to your AnythingLLM instance. + Sign in to your {customAppName || "AnythingLLM"} instance. </p> </div> </div> diff --git a/frontend/src/components/Modals/Password/index.jsx b/frontend/src/components/Modals/Password/index.jsx index 9305d032e8d8f6c9ef4bc34b1b5afa13c41ddeac..8f86b611daee3353599094a98f8f4bc558790473 100644 --- a/frontend/src/components/Modals/Password/index.jsx +++ b/frontend/src/components/Modals/Password/index.jsx @@ -9,10 +9,9 @@ import { } from "../../../utils/constants"; import useLogo from "../../../hooks/useLogo"; import illustration from "@/media/illustrations/login-illustration.svg"; -import loginLogo from "@/media/illustrations/login-logo.svg"; export default function PasswordModal({ mode = "single" }) { - const { logo: _initLogo } = useLogo(); + const { loginLogo } = useLogo(); return ( <div className="fixed top-0 left-0 right-0 z-50 w-full overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] h-full bg-[#25272C] flex flex-col md:flex-row items-center justify-center"> <div @@ -37,10 +36,11 @@ export default function PasswordModal({ mode = "single" }) { <div className="flex flex-col items-center justify-center h-full w-full md:w-1/2 z-50 relative"> <img src={loginLogo} - className={`mb-8 w-[84px] h-[84px] absolute ${ - mode === "single" ? "md:top-50" : "md:top-36" - } top-44 z-30`} - alt="logo" + alt="Logo" + className={`hidden md:flex rounded-2xl w-fit m-4 z-30 ${ + mode === "single" ? "md:top-[170px]" : "md:top-36" + } absolute max-h-[65px] md:bg-login-gradient md:shadow-[0_4px_14px_rgba(0,0,0,0.25)]`} + style={{ objectFit: "contain" }} /> {mode === "single" ? <SingleUserAuth /> : <MultiUserAuth />} </div> diff --git a/frontend/src/hooks/useLogo.js b/frontend/src/hooks/useLogo.js index 4834b7a8e1f067be9382752a277810b0924d12f0..9ae741f7175c47ef2e7d003a5a2feeadf216a7c1 100644 --- a/frontend/src/hooks/useLogo.js +++ b/frontend/src/hooks/useLogo.js @@ -2,6 +2,6 @@ import { useContext } from "react"; import { LogoContext } from "../LogoContext"; export default function useLogo() { - const { logo, setLogo } = useContext(LogoContext); - return { logo, setLogo }; + const { logo, setLogo, loginLogo, isCustomLogo } = useContext(LogoContext); + return { logo, setLogo, loginLogo, isCustomLogo }; } diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index f8f123448494c49d8170679440299d27cda99353..d2252be16ff628147f3c53758bf6a17aedb6aae6 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -6,6 +6,7 @@ const System = { cacheKeys: { footerIcons: "anythingllm_footer_links", supportEmail: "anythingllm_support_email", + customAppName: "anythingllm_custom_app_name", }, ping: async function () { return await fetch(`${API_BASE}/ping`) @@ -305,19 +306,58 @@ const System = { ); return { email: supportEmail, error: null }; }, + + fetchCustomAppName: async function () { + const cache = window.localStorage.getItem(this.cacheKeys.customAppName); + const { appName, lastFetched } = cache + ? safeJsonParse(cache, { appName: "", lastFetched: 0 }) + : { appName: "", lastFetched: 0 }; + + if (!!appName && Date.now() - lastFetched < 3_600_000) + return { appName: appName, error: null }; + + const { customAppName, error } = await fetch( + `${API_BASE}/system/custom-app-name`, + { + method: "GET", + cache: "no-cache", + headers: baseHeaders(), + } + ) + .then((res) => res.json()) + .catch((e) => { + console.log(e); + return { customAppName: "", error: e.message }; + }); + + if (!customAppName || !!error) { + window.localStorage.removeItem(this.cacheKeys.customAppName); + return { appName: "", error: null }; + } + + window.localStorage.setItem( + this.cacheKeys.customAppName, + JSON.stringify({ appName: customAppName, lastFetched: Date.now() }) + ); + return { appName: customAppName, error: null }; + }, fetchLogo: async function () { return await fetch(`${API_BASE}/system/logo`, { method: "GET", cache: "no-cache", }) - .then((res) => { - if (res.ok && res.status !== 204) return res.blob(); + .then(async (res) => { + if (res.ok && res.status !== 204) { + const isCustomLogo = res.headers.get("X-Is-Custom-Logo") === "true"; + const blob = await res.blob(); + const logoURL = URL.createObjectURL(blob); + return { isCustomLogo, logoURL }; + } throw new Error("Failed to fetch logo!"); }) - .then((blob) => URL.createObjectURL(blob)) .catch((e) => { console.log(e); - return null; + return { isCustomLogo: false, logoURL: null }; }); }, fetchPfp: async function (id) { diff --git a/frontend/src/pages/GeneralSettings/Appearance/CustomAppName/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/CustomAppName/index.jsx new file mode 100644 index 0000000000000000000000000000000000000000..48efa5087e09acdb3b2c48833a22cc57a12119fc --- /dev/null +++ b/frontend/src/pages/GeneralSettings/Appearance/CustomAppName/index.jsx @@ -0,0 +1,100 @@ +import Admin from "@/models/admin"; +import System from "@/models/system"; +import showToast from "@/utils/toast"; +import { useEffect, useState } from "react"; + +export default function CustomAppName() { + const [loading, setLoading] = useState(true); + const [hasChanges, setHasChanges] = useState(false); + const [customAppName, setCustomAppName] = useState(""); + const [originalAppName, setOriginalAppName] = useState(""); + const [canCustomize, setCanCustomize] = useState(false); + + useEffect(() => { + const fetchInitialParams = async () => { + const settings = await System.keys(); + if (!settings?.MultiUserMode && !settings?.RequiresAuth) { + setCanCustomize(false); + return false; + } + + const { appName } = await System.fetchCustomAppName(); + setCustomAppName(appName || ""); + setOriginalAppName(appName || ""); + setCanCustomize(true); + setLoading(false); + }; + fetchInitialParams(); + }, []); + + const updateCustomAppName = async (e, newValue = null) => { + e.preventDefault(); + let custom_app_name = newValue; + if (newValue === null) { + const form = new FormData(e.target); + custom_app_name = form.get("customAppName"); + } + const { success, error } = await Admin.updateSystemPreferences({ + custom_app_name, + }); + if (!success) { + showToast(`Failed to update custom app name: ${error}`, "error"); + return; + } else { + showToast("Successfully updated custom app name.", "success"); + window.localStorage.removeItem(System.cacheKeys.customAppName); + setCustomAppName(custom_app_name); + setOriginalAppName(custom_app_name); + setHasChanges(false); + } + }; + + const handleChange = (e) => { + setCustomAppName(e.target.value); + setHasChanges(true); + }; + + if (!canCustomize || loading) return null; + + return ( + <form className="mb-6" onSubmit={updateCustomAppName}> + <div className="flex flex-col gap-y-1"> + <h2 className="text-base leading-6 font-bold text-white"> + Custom App Name + </h2> + <p className="text-xs leading-[18px] font-base text-white/60"> + Set a custom app name that is displayed on the login page. + </p> + </div> + <div className="flex items-center gap-x-4"> + <input + name="customAppName" + type="text" + className="bg-zinc-900 mt-3 text-white text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 max-w-[275px] placeholder:text-white/20" + placeholder="AnythingLLM" + required={true} + autoComplete="off" + onChange={handleChange} + value={customAppName} + /> + {originalAppName !== "" && ( + <button + type="button" + onClick={(e) => updateCustomAppName(e, "")} + className="mt-4 text-white text-base font-medium hover:text-opacity-60" + > + Clear + </button> + )} + </div> + {hasChanges && ( + <button + type="submit" + className="transition-all mt-6 w-fit duration-300 border border-slate-200 px-5 py-2.5 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> + )} + </form> + ); +} diff --git a/frontend/src/pages/GeneralSettings/Appearance/CustomLogo/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/CustomLogo/index.jsx index 8b2b5cab608f86785ee4ecc256f83be13535fead..5de37e3fef8b8c73a7eb21b42ae3cfd10da28f00 100644 --- a/frontend/src/pages/GeneralSettings/Appearance/CustomLogo/index.jsx +++ b/frontend/src/pages/GeneralSettings/Appearance/CustomLogo/index.jsx @@ -2,7 +2,6 @@ import useLogo from "@/hooks/useLogo"; import System from "@/models/system"; import showToast from "@/utils/toast"; import { useEffect, useRef, useState } from "react"; -import AnythingLLM from "@/media/logo/anything-llm.png"; import { Plus } from "@phosphor-icons/react"; export default function CustomLogo() { @@ -36,7 +35,7 @@ export default function CustomLogo() { return; } - const logoURL = await System.fetchLogo(); + const { logoURL } = await System.fetchLogo(); _setLogo(logoURL); showToast("Image uploaded successfully.", "success"); @@ -51,13 +50,13 @@ export default function CustomLogo() { if (!success) { console.error("Failed to remove logo:", error); showToast(`Failed to remove logo: ${error}`, "error"); - const logoURL = await System.fetchLogo(); + const { logoURL } = await System.fetchLogo(); setLogo(logoURL); setIsDefaultLogo(false); return; } - const logoURL = await System.fetchLogo(); + const { logoURL } = await System.fetchLogo(); _setLogo(logoURL); showToast("Image successfully removed.", "success"); diff --git a/frontend/src/pages/GeneralSettings/Appearance/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/index.jsx index bb2c79896c4f45f75f02111277de489a906ffa46..d735299874537501c1fc9f7f193d0283f8a7acb0 100644 --- a/frontend/src/pages/GeneralSettings/Appearance/index.jsx +++ b/frontend/src/pages/GeneralSettings/Appearance/index.jsx @@ -4,6 +4,7 @@ import FooterCustomization from "./FooterCustomization"; import SupportEmail from "./SupportEmail"; import CustomLogo from "./CustomLogo"; import CustomMessages from "./CustomMessages"; +import CustomAppName from "./CustomAppName"; export default function Appearance() { return ( @@ -25,6 +26,7 @@ export default function Appearance() { </p> </div> <CustomLogo /> + <CustomAppName /> <CustomMessages /> <FooterCustomization /> <SupportEmail /> diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index 9b836b19a2429f65aedfac69282e27d245cdb0cb..59d645447e4081785b80edeb4fc8836800925233 100644 --- a/server/endpoints/admin.js +++ b/server/endpoints/admin.js @@ -355,6 +355,9 @@ function adminEndpoints(app) { ?.value, [] ) || [], + custom_app_name: + (await SystemSettings.get({ label: "custom_app_name" }))?.value || + null, }; response.status(200).json({ settings }); } catch (e) { diff --git a/server/endpoints/system.js b/server/endpoints/system.js index 472e3aa74fe06e0b1bad00806fb5cf2d71f5897f..6ab30c5c10a38dc2ae3bdb4dabffde8ad230c250 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -526,17 +526,24 @@ function systemEndpoints(app) { const defaultFilename = getDefaultFilename(); const logoPath = await determineLogoFilepath(defaultFilename); const { found, buffer, size, mime } = fetchLogo(logoPath); + if (!found) { response.sendStatus(204).end(); return; } + const currentLogoFilename = await SystemSettings.currentLogoFilename(); response.writeHead(200, { + "Access-Control-Expose-Headers": + "Content-Disposition,X-Is-Custom-Logo,Content-Type,Content-Length", "Content-Type": mime || "image/png", "Content-Disposition": `attachment; filename=${path.basename( logoPath )}`, "Content-Length": size, + "X-Is-Custom-Logo": + currentLogoFilename !== null && + currentLogoFilename !== defaultFilename, }); response.end(Buffer.from(buffer, "base64")); return; @@ -573,6 +580,22 @@ function systemEndpoints(app) { } }); + // No middleware protection in order to get this on the login page + app.get("/system/custom-app-name", async (_, response) => { + try { + const customAppName = + ( + await SystemSettings.get({ + label: "custom_app_name", + }) + )?.value ?? null; + response.status(200).json({ customAppName: customAppName }); + } catch (error) { + console.error("Error fetching custom app name:", 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 70913fd9d4b16a1ab210487db1c81b6fc7f6ffbb..52393a02faa1a10c4570843191edc5ae05df25ff 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -27,6 +27,7 @@ const SystemSettings = { "agent_search_provider", "default_agent_skills", "agent_sql_connections", + "custom_app_name", ], validations: { footer_data: (updates) => {