From 6a2d7aca2835e0e215cc124328ffa7c6da3d91f0 Mon Sep 17 00:00:00 2001 From: Sean Hatfield Date: Thu, 23 May 2024 14:14:53 -0700 Subject: [PATCH] [FEAT] Custom login screen icon + custom app name (#1500) * implement custom icon on login screen for single & multi user + custom app name feature * hide field when not relevant * set customApp name * show original anythingllm login logo unless custom logo is set * nit-picks * remove console log --------- Co-authored-by: timothycarambat --- frontend/src/LogoContext.jsx | 20 +++- .../Modals/Password/MultiUserAuth.jsx | 14 ++- .../Modals/Password/SingleUserAuth.jsx | 16 ++- .../src/components/Modals/Password/index.jsx | 12 +-- frontend/src/hooks/useLogo.js | 4 +- frontend/src/models/system.js | 48 ++++++++- .../Appearance/CustomAppName/index.jsx | 100 ++++++++++++++++++ .../Appearance/CustomLogo/index.jsx | 7 +- .../GeneralSettings/Appearance/index.jsx | 2 + server/endpoints/admin.js | 3 + server/endpoints/system.js | 23 ++++ server/models/systemSettings.js | 1 + 12 files changed, 225 insertions(+), 25 deletions(-) create mode 100644 frontend/src/pages/GeneralSettings/Appearance/CustomAppName/index.jsx diff --git a/frontend/src/LogoContext.jsx b/frontend/src/LogoContext.jsx index 6818967b..014c6a6b 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 ( - + {children} ); diff --git a/frontend/src/components/Modals/Password/MultiUserAuth.jsx b/frontend/src/components/Modals/Password/MultiUserAuth.jsx index e4de5e67..04625b95 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 (

- AnythingLLM + {customAppName || "AnythingLLM"}

- Sign in to your AnythingLLM account. + Sign in to your {customAppName || "AnythingLLM"} account.

diff --git a/frontend/src/components/Modals/Password/SingleUserAuth.jsx b/frontend/src/components/Modals/Password/SingleUserAuth.jsx index c1f328ba..541d2db5 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 ( <>
@@ -68,11 +76,11 @@ export default function SingleUserAuth() { Welcome to

- AnythingLLM + {customAppName || "AnythingLLM"}

- Sign in to your AnythingLLM instance. + Sign in to your {customAppName || "AnythingLLM"} instance.

diff --git a/frontend/src/components/Modals/Password/index.jsx b/frontend/src/components/Modals/Password/index.jsx index 9305d032..8f86b611 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 (
logo {mode === "single" ? : }
diff --git a/frontend/src/hooks/useLogo.js b/frontend/src/hooks/useLogo.js index 4834b7a8..9ae741f7 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 f8f12344..d2252be1 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 00000000..48efa508 --- /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 ( + +
+

+ Custom App Name +

+

+ Set a custom app name that is displayed on the login page. +

+
+
+ + {originalAppName !== "" && ( + + )} +
+ {hasChanges && ( + + )} + + ); +} diff --git a/frontend/src/pages/GeneralSettings/Appearance/CustomLogo/index.jsx b/frontend/src/pages/GeneralSettings/Appearance/CustomLogo/index.jsx index 8b2b5cab..5de37e3f 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 bb2c7989..d7352998 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() {

+ diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index 9b836b19..59d64544 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 472e3aa7..6ab30c5c 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 70913fd9..52393a02 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) => {