Whitelableing for Logo / Name via Admin panel

This commit is contained in:
Weves 2024-04-07 14:02:38 -07:00 committed by Chris Weaver
parent fa8cc10063
commit 153007c57c
20 changed files with 470 additions and 41 deletions

View File

@ -19,6 +19,12 @@ from danswer.utils.variable_functionality import global_version
from ee.danswer.configs.app_configs import OPENID_CONFIG_URL
from ee.danswer.server.analytics.api import router as analytics_router
from ee.danswer.server.api_key.api import router as api_key_router
from ee.danswer.server.enterprise_settings.api import (
admin_router as enterprise_settings_admin_router,
)
from ee.danswer.server.enterprise_settings.api import (
basic_router as enterprise_settings_router,
)
from ee.danswer.server.query_and_chat.chat_backend import (
router as chat_router,
)
@ -74,6 +80,11 @@ def get_ee_application() -> FastAPI:
# EE only backend APIs
include_router_with_global_prefix_prepended(application, query_router)
include_router_with_global_prefix_prepended(application, chat_router)
# Enterprise-only global settings
include_router_with_global_prefix_prepended(
application, enterprise_settings_admin_router
)
include_router_with_global_prefix_prepended(application, enterprise_settings_router)
return application

View File

@ -0,0 +1,65 @@
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from danswer.auth.users import current_admin_user
from danswer.db.engine import get_session
from danswer.db.file_store import get_default_file_store
from danswer.db.models import User
from ee.danswer.server.enterprise_settings.models import EnterpriseSettings
from ee.danswer.server.enterprise_settings.store import load_settings
from ee.danswer.server.enterprise_settings.store import store_settings
admin_router = APIRouter(prefix="/admin/enterprise-settings")
basic_router = APIRouter(prefix="/enterprise-settings")
@admin_router.put("")
def put_settings(
settings: EnterpriseSettings, _: User | None = Depends(current_admin_user)
) -> None:
try:
settings.check_validity()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
store_settings(settings)
@basic_router.get("")
def fetch_settings() -> EnterpriseSettings:
return load_settings()
_LOGO_FILENAME = "__logo__"
@admin_router.put("/logo")
def upload_logo(
file: UploadFile,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_admin_user),
) -> None:
if (
not file.filename.endswith(".png")
and not file.filename.endswith(".jpg")
and not file.filename.endswith(".jpeg")
):
raise HTTPException(
status_code=400,
detail="Invalid file type - only .png, .jpg, and .jpeg files are allowed",
)
# Save the file to the server
file_store = get_default_file_store(db_session)
file_store.save_file(_LOGO_FILENAME, file.file)
@basic_router.get("/logo")
def fetch_logo(db_session: Session = Depends(get_session)) -> StreamingResponse:
file_store = get_default_file_store(db_session)
file_io = file_store.read_file(_LOGO_FILENAME, mode="b")
return StreamingResponse(content=file_io, media_type="image/jpeg")

View File

@ -0,0 +1,13 @@
from pydantic import BaseModel
class EnterpriseSettings(BaseModel):
"""General settings that only apply to the Enterprise Edition of Danswer
NOTE: don't put anything sensitive in here, as this is accessible without auth."""
application_name: str | None = None
use_custom_logo: bool = False
def check_validity(self) -> None:
return

View File

@ -0,0 +1,25 @@
from typing import cast
from danswer.dynamic_configs.factory import get_dynamic_config_store
from danswer.dynamic_configs.interface import ConfigNotFoundError
from ee.danswer.server.enterprise_settings.models import EnterpriseSettings
_ENTERPRISE_SETTINGS_KEY = "danswer_enterprise_settings"
def load_settings() -> EnterpriseSettings:
dynamic_config_store = get_dynamic_config_store()
try:
settings = EnterpriseSettings(
**cast(dict, dynamic_config_store.load(_ENTERPRISE_SETTINGS_KEY))
)
except ConfigNotFoundError:
settings = EnterpriseSettings()
dynamic_config_store.store(_ENTERPRISE_SETTINGS_KEY, settings.dict())
return settings
def store_settings(settings: EnterpriseSettings) -> None:
get_dynamic_config_store().store(_ENTERPRISE_SETTINGS_KEY, settings.dict())

View File

@ -38,6 +38,11 @@ const nextConfig = {
source: "/admin/performance/query-history/:path*",
destination: "/ee/admin/performance/query-history/:path*",
},
// whitelabeling
{
source: "/admin/whitelabeling",
destination: "/ee/admin/whitelabeling",
},
]
: [];

View File

@ -5,6 +5,8 @@ import { Title } from "@tremor/react";
import { Settings } from "./interfaces";
import { useRouter } from "next/navigation";
import { DefaultDropdown, Option } from "@/components/Dropdown";
import { useContext } from "react";
import { SettingsContext } from "@/components/settings/SettingsProviderClientSideHelper";
function Checkbox({
label,
@ -62,9 +64,15 @@ function Selector({
);
}
export function SettingsForm({ settings }: { settings: Settings }) {
export function SettingsForm() {
const router = useRouter();
const combinedSettings = useContext(SettingsContext);
if (!combinedSettings) {
return null;
}
const settings = combinedSettings.settings;
async function updateSettingField(
updateRequests: { fieldName: keyof Settings; newValue: any }[]
) {

View File

@ -1,20 +1,9 @@
import { AdminPageTitle } from "@/components/admin/Title";
import { FiSettings } from "react-icons/fi";
import { Settings } from "./interfaces";
import { fetchSS } from "@/lib/utilsSS";
import { SettingsForm } from "./SettingsForm";
import { Callout, Text } from "@tremor/react";
import { Text } from "@tremor/react";
export default async function Page() {
const response = await fetchSS("/settings");
if (!response.ok) {
const errorMsg = await response.text();
return <Callout title="Failed to fetch settings">{errorMsg}</Callout>;
}
const settings = (await response.json()) as Settings;
return (
<div className="mx-auto container">
<AdminPageTitle
@ -27,7 +16,7 @@ export default async function Page() {
workspace.
</Text>
<SettingsForm settings={settings} />
<SettingsForm />
</div>
);
}

View File

@ -12,6 +12,7 @@ import { SignInButton } from "./SignInButton";
import { EmailPasswordForm } from "./EmailPasswordForm";
import { Card, Title, Text } from "@tremor/react";
import Link from "next/link";
import { Logo } from "@/components/Logo";
const Page = async ({
searchParams,
@ -69,9 +70,7 @@ const Page = async ({
</div>
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div>
<div className="h-16 w-16 mx-auto">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
</div>
<Logo height={64} width={64} className="mx-auto w-fit" />
{authUrl && authTypeMetadata && (
<>
<h2 className="text-center text-xl text-strong font-bold mt-6">

View File

@ -6,10 +6,10 @@ import {
AuthTypeMetadata,
} from "@/lib/userSS";
import { redirect } from "next/navigation";
import Image from "next/image";
import { EmailPasswordForm } from "../login/EmailPasswordForm";
import { Card, Title, Text } from "@tremor/react";
import Link from "next/link";
import { Logo } from "@/components/Logo";
const Page = async () => {
// catch cases where the backend is completely unreachable here
@ -51,9 +51,8 @@ const Page = async () => {
</div>
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div>
<div className="h-16 w-16 mx-auto">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
</div>
<Logo height={64} width={64} className="mx-auto w-fit" />
<Card className="mt-4 w-96">
<div className="flex">
<Title className="mb-2 mx-auto font-bold">

View File

@ -3,10 +3,10 @@
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import Image from "next/image";
import { Text } from "@tremor/react";
import { RequestNewVerificationEmail } from "../waiting-on-verification/RequestNewVerificationEmail";
import { User } from "@/lib/types";
import { Logo } from "@/components/Logo";
export function Verify({ user }: { user: User | null }) {
const searchParams = useSearchParams();
@ -52,9 +52,11 @@ export function Verify({ user }: { user: User | null }) {
</div>
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div>
<div className="h-16 w-16 mx-auto animate-pulse">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
</div>
<Logo
height={64}
width={64}
className="mx-auto w-fit animate-pulse"
/>
{!error ? (
<Text className="mt-2">Verifying your email...</Text>

View File

@ -4,11 +4,11 @@ import {
getCurrentUserSS,
} from "@/lib/userSS";
import { redirect } from "next/navigation";
import Image from "next/image";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { User } from "@/lib/types";
import { Text } from "@tremor/react";
import { RequestNewVerificationEmail } from "./RequestNewVerificationEmail";
import { Logo } from "@/components/Logo";
export default async function Page() {
// catch cases where the backend is completely unreachable here
@ -43,9 +43,7 @@ export default async function Page() {
</div>
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div>
<div className="h-16 w-16 mx-auto">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
</div>
<Logo height={64} width={64} className="mx-auto w-fit" />
<div className="flex">
<Text className="text-center font-medium text-lg mt-6 w-108">

View File

@ -7,8 +7,7 @@ import { FiBookmark, FiCpu, FiInfo, FiX, FiZoomIn } from "react-icons/fi";
import { HoverPopup } from "@/components/HoverPopup";
import { Modal } from "@/components/Modal";
import { useState } from "react";
import { FaRobot } from "react-icons/fa";
import { SourceMetadata } from "@/lib/search/interfaces";
import { Logo } from "@/components/Logo";
const MAX_PERSONAS_TO_DISPLAY = 4;

View File

@ -0,0 +1,74 @@
import { SubLabel } from "@/components/admin/connectors/Field";
import { usePopup } from "@/components/admin/connectors/Popup";
import { Text } from "@tremor/react";
import { useEffect, useState } from "react";
import Dropzone from "react-dropzone";
export function ImageUpload({
selectedFile,
setSelectedFile,
}: {
selectedFile: File | null;
setSelectedFile: (file: File) => void;
}) {
const [tmpImageUrl, setTmpImageUrl] = useState<string>("");
console.log(selectedFile);
useEffect(() => {
if (selectedFile) {
setTmpImageUrl(URL.createObjectURL(selectedFile));
} else {
setTmpImageUrl("");
}
}, [selectedFile]);
const [dragActive, setDragActive] = useState(false);
const { popup, setPopup } = usePopup();
return (
<>
{popup}
<Dropzone
onDrop={(acceptedFiles) => {
if (acceptedFiles.length !== 1) {
setPopup({
type: "error",
message: "Only one file can be uploaded at a time",
});
}
setTmpImageUrl(URL.createObjectURL(acceptedFiles[0]));
setSelectedFile(acceptedFiles[0]);
setDragActive(false);
}}
onDragLeave={() => setDragActive(false)}
onDragEnter={() => setDragActive(true)}
>
{({ getRootProps, getInputProps }) => (
<section>
<div
{...getRootProps()}
className={
"flex flex-col items-center w-full px-4 py-12 rounded " +
"shadow-lg tracking-wide border border-border cursor-pointer" +
(dragActive ? " border-accent" : "")
}
>
<input {...getInputProps()} />
<b className="text-emphasis">
Drag and drop a .png or .jpg file, or click to select a file!
</b>
</div>
{tmpImageUrl && (
<div className="mt-4 mb-8">
<SubLabel>Uploaded Image:</SubLabel>
<img src={tmpImageUrl} className="mt-4 max-w-xs max-h-64" />
</div>
)}
</section>
)}
</Dropzone>
</>
);
}

View File

@ -0,0 +1,148 @@
"use client";
import { useRouter } from "next/navigation";
import { EnterpriseSettings, Settings } from "@/app/admin/settings/interfaces";
import { useContext, useState } from "react";
import { SettingsContext } from "@/components/settings/SettingsProviderClientSideHelper";
import { Form, Formik } from "formik";
import * as Yup from "yup";
import {
Label,
SubLabel,
TextFormField,
} from "@/components/admin/connectors/Field";
import { Button } from "@tremor/react";
import { ImageUpload } from "./ImageUpload";
export function WhitelabelingForm() {
const router = useRouter();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const settings = useContext(SettingsContext);
if (!settings) {
return null;
}
const enterpriseSettings = settings.enterpriseSettings;
async function updateEnterpriseSettings(newValues: EnterpriseSettings) {
const response = await fetch("/api/admin/enterprise-settings", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...(enterpriseSettings || {}),
...newValues,
}),
});
if (response.ok) {
router.refresh();
} else {
const errorMsg = (await response.json()).detail;
alert(`Failed to update settings. ${errorMsg}`);
}
}
return (
<div>
<Formik
initialValues={{
application_name: enterpriseSettings?.application_name || null,
use_custom_logo: enterpriseSettings?.use_custom_logo || false,
}}
validationSchema={Yup.object().shape({
application_name: Yup.string(),
use_custom_logo: Yup.boolean().required(),
})}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
if (selectedFile) {
values.use_custom_logo = true;
const formData = new FormData();
formData.append("file", selectedFile);
const response = await fetch(
"/api/admin/enterprise-settings/logo",
{
method: "PUT",
body: formData,
}
);
if (!response.ok) {
const errorMsg = (await response.json()).detail;
alert(`Failed to upload logo. ${errorMsg}`);
formikHelpers.setSubmitting(false);
return;
}
setSelectedFile(null);
}
formikHelpers.setValues(values);
await updateEnterpriseSettings(values);
}}
>
{({ isSubmitting, values, setValues }) => (
<Form>
<TextFormField
label="Application Name"
name="application_name"
subtext={`The custom name you are giving Danswer for your organization. This will replace 'Danswer' everywhere in the UI.`}
placeholder="Custom name which will replace 'Danswer'"
disabled={isSubmitting}
/>
<Label>Custom Logo</Label>
{values.use_custom_logo ? (
<div className="mt-3">
<SubLabel>Current Custom Logo: </SubLabel>
<img
src="/api/enterprise-settings/logo"
alt="logo"
style={{ objectFit: "contain" }}
className="w-32 h-32 mb-10 mt-4"
/>
<Button
color="red"
size="xs"
type="button"
className="mb-8"
onClick={async () => {
const valuesWithoutLogo = {
...values,
use_custom_logo: false,
};
await updateEnterpriseSettings(valuesWithoutLogo);
setValues(valuesWithoutLogo);
}}
>
Delete
</Button>
<SubLabel>
Override the current custom logo by uploading a new image
below and clicking the Update button.
</SubLabel>
</div>
) : (
<SubLabel>
Specify your own logo to replace the standard Danswer logo.
</SubLabel>
)}
<ImageUpload
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
/>
<Button type="submit" className="mt-4">
Update
</Button>
</Form>
)}
</Formik>
</div>
);
}

View File

@ -0,0 +1,16 @@
import { WhitelabelingForm } from "./WhitelabelingForm";
import { AdminPageTitle } from "@/components/admin/Title";
import { FiImage } from "react-icons/fi";
export default async function Whitelabeling() {
return (
<div className="mx-auto container">
<AdminPageTitle
title="Whitelabeling"
icon={<FiImage size={32} className="my-auto" />}
/>
<WhitelabelingForm />
</div>
);
}

View File

@ -1,12 +1,11 @@
import { Bold } from "@tremor/react";
import Image from "next/image";
import { Logo } from "./Logo";
export function DanswerInitializingLoader() {
return (
<div className="mx-auto animate-pulse">
<div className="h-24 w-24 mx-auto mb-3">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
</div>
<Logo height={96} width={96} className="mx-auto mb-3" />
<Bold>Initializing Danswer</Bold>
</div>
);

View File

@ -0,0 +1,43 @@
"use client";
import { useContext } from "react";
import { SettingsContext } from "./settings/SettingsProviderClientSideHelper";
import Image from "next/image";
export function Logo({
height,
width,
className,
}: {
height?: number;
width?: number;
className?: string;
}) {
const settings = useContext(SettingsContext);
height = height || 32;
width = width || 30;
if (
!settings ||
!settings.enterpriseSettings ||
!settings.enterpriseSettings.use_custom_logo
) {
return (
<div style={{ height, width }} className={className}>
<Image src="/logo.png" alt="Logo" width={width} height={height} />
</div>
);
}
return (
<div style={{ height, width }} className={`relative ${className}`}>
<Image
src="/api/enterprise-settings/logo"
alt="Logo"
fill
style={{ objectFit: "contain" }}
/>
</div>
);
}

View File

@ -11,6 +11,7 @@ import {
GroupsIcon,
BarChartIcon,
DatabaseIcon,
KeyIcon,
} from "@/components/icons/icons";
import { User } from "@/lib/types";
import {
@ -20,7 +21,7 @@ import {
} from "@/lib/userSS";
import { EE_ENABLED } from "@/lib/constants";
import { redirect } from "next/navigation";
import { FiCpu, FiPackage, FiSettings, FiSlack, FiTool } from "react-icons/fi";
import { FiCpu, FiImage, FiPackage, FiSettings, FiSlack, FiTool } from "react-icons/fi";
export async function Layout({ children }: { children: React.ReactNode }) {
const tasks = [getAuthTypeMetadataSS(), getCurrentUserSS()];
@ -58,7 +59,7 @@ export async function Layout({ children }: { children: React.ReactNode }) {
<Header user={user} />
</div>
<div className="flex h-full pt-16">
<div className="w-80 pt-12 pb-8 h-full border-r border-border">
<div className="w-80 pt-12 pb-8 h-full border-r border-border overflow-auto">
<AdminSidebar
collections={[
{
@ -246,6 +247,19 @@ export async function Layout({ children }: { children: React.ReactNode }) {
),
link: "/admin/settings",
},
...(EE_ENABLED
? [
{
name: (
<div className="flex">
<FiImage size={18} />
<div className="ml-1">Whitelabeling</div>
</div>
),
link: "/admin/whitelabeling",
},
]
: []),
],
},
]}

View File

@ -2,7 +2,6 @@
import { User } from "@/lib/types";
import { logout } from "@/lib/user";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React, { useContext, useEffect, useRef, useState } from "react";
@ -11,6 +10,7 @@ import { FiMessageSquare, FiSearch } from "react-icons/fi";
import { HeaderWrapper } from "./HeaderWrapper";
import { SettingsContext } from "../settings/SettingsProvider";
import { UserDropdown } from "../UserDropdown";
import { Logo } from "../Logo";
interface HeaderProps {
user: User | null;
@ -22,6 +22,7 @@ export function Header({ user }: HeaderProps) {
return null;
}
const settings = combinedSettings.settings;
const enterpriseSettings = combinedSettings.enterpriseSettings;
return (
<HeaderWrapper>
@ -33,11 +34,12 @@ export function Header({ user }: HeaderProps) {
}
>
<div className="flex">
<div className="h-[32px] w-[30px]">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
<div className="mr-1">
<Logo />
</div>
<h1 className="flex text-2xl text-strong font-bold my-auto">
Danswer
{(enterpriseSettings && enterpriseSettings.application_name) ||
"Danswer"}
</h1>
</div>
</Link>

View File

@ -0,0 +1,20 @@
"use client";
import { CombinedSettings } from "@/app/admin/settings/interfaces";
import { createContext } from "react";
export const SettingsContext = createContext<CombinedSettings | null>(null);
export function SettingsProviderClientSideHelper({
children,
settings,
}: {
children: React.ReactNode | JSX.Element;
settings: CombinedSettings;
}) {
return (
<SettingsContext.Provider value={settings}>
{children}
</SettingsContext.Provider>
);
}