[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 <rambat1010@gmail.com>
This commit is contained in:
Sean Hatfield 2024-05-23 14:14:53 -07:00 committed by GitHub
parent a89812703b
commit 6a2d7aca28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 225 additions and 25 deletions

View File

@ -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>
);

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 };
}

View File

@ -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) {

View File

@ -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>
);
}

View File

@ -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");

View File

@ -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 />

View File

@ -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) {

View File

@ -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])],

View File

@ -27,6 +27,7 @@ const SystemSettings = {
"agent_search_provider",
"default_agent_skills",
"agent_sql_connections",
"custom_app_name",
],
validations: {
footer_data: (updates) => {