Compare commits

..

1 Commits

Author SHA1 Message Date
Jamison Lahman
b89936de39 feat(whitelabeling): allow cropping and positioning logos
nit

review

Update web/src/app/ee/admin/theme/AppearanceThemeSettings.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

Update web/src/app/ee/admin/theme/LogoCropModal.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-12 16:54:47 -07:00
7 changed files with 345 additions and 73 deletions

View File

@@ -1042,8 +1042,6 @@ POD_NAMESPACE = os.environ.get("POD_NAMESPACE")
DEV_MODE = os.environ.get("DEV_MODE", "").lower() == "true"
HOOK_ENABLED = os.environ.get("HOOK_ENABLED", "").lower() == "true"
INTEGRATION_TESTS_MODE = os.environ.get("INTEGRATION_TESTS_MODE", "").lower() == "true"
#####

View File

@@ -1,26 +0,0 @@
from onyx.configs.app_configs import HOOK_ENABLED
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from shared_configs.configs import MULTI_TENANT
def require_hook_enabled() -> None:
"""FastAPI dependency that gates all hook management endpoints.
Hooks are only available in single-tenant / self-hosted deployments with
HOOK_ENABLED=true explicitly set. Two layers of protection:
1. MULTI_TENANT check — rejects even if HOOK_ENABLED is accidentally set true
2. HOOK_ENABLED flag — explicit opt-in by the operator
Use as: Depends(require_hook_enabled)
"""
if MULTI_TENANT:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"Custom code hooks are not available in multi-tenant deployments",
)
if not HOOK_ENABLED:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
"Custom code hooks are not enabled. Set HOOK_ENABLED=true to enable.",
)

View File

@@ -1,40 +0,0 @@
"""Unit tests for the hooks feature gate."""
from unittest.mock import patch
import pytest
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.api_dependencies import require_hook_enabled
class TestRequireHookEnabled:
def test_raises_when_multi_tenant(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", True),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
):
with pytest.raises(OnyxError) as exc_info:
require_hook_enabled()
assert exc_info.value.error_code is OnyxErrorCode.NOT_FOUND
assert exc_info.value.status_code == 404
assert "multi-tenant" in exc_info.value.detail
def test_raises_when_flag_disabled(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", False),
):
with pytest.raises(OnyxError) as exc_info:
require_hook_enabled()
assert exc_info.value.error_code is OnyxErrorCode.NOT_FOUND
assert exc_info.value.status_code == 404
assert "HOOK_ENABLED" in exc_info.value.detail
def test_passes_when_enabled_single_tenant(self) -> None:
with (
patch("onyx.hooks.api_dependencies.MULTI_TENANT", False),
patch("onyx.hooks.api_dependencies.HOOK_ENABLED", True),
):
require_hook_enabled() # must not raise

View File

@@ -22,6 +22,25 @@ import {
} from "react";
import type { PreviewHighlightTarget } from "./Preview";
import { SvgEdit } from "@opal/icons";
import { toast } from "@/hooks/useToast";
import LogoCropModal from "./LogoCropModal";
const LOGO_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // 2 MB
const LOGO_MAX_FILE_SIZE_MB = LOGO_MAX_FILE_SIZE_BYTES / (1024 * 1024);
const ALLOWED_LOGO_FILE_TYPES = ["image/png", "image/jpeg"];
function validateLogoFile(file: File): string | null {
if (!ALLOWED_LOGO_FILE_TYPES.includes(file.type)) {
return `Unsupported file type (${
file.type || "unknown"
}). Please upload a PNG or JPEG image.`;
}
if (file.size > LOGO_MAX_FILE_SIZE_BYTES) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
return `File is too large (${sizeMB} MB). Maximum allowed size is ${LOGO_MAX_FILE_SIZE_MB} MB.`;
}
return null;
}
interface AppearanceThemeSettingsProps {
selectedLogo: File | null;
@@ -63,6 +82,7 @@ export const AppearanceThemeSettings = forwardRef<
const prevEnableConsentScreenRef = useRef<boolean>(
Boolean(values.enable_consent_screen)
);
const [cropFile, setCropFile] = useState<File | null>(null);
const [focusedPreviewTarget, setFocusedPreviewTarget] =
useState<PreviewHighlightTarget | null>(null);
const [hoveredPreviewTarget, setHoveredPreviewTarget] =
@@ -146,10 +166,17 @@ export const AppearanceThemeSettings = forwardRef<
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedLogo(file);
setFieldValue("use_custom_logo", true);
if (!file) return;
const error = validateLogoFile(file);
if (error) {
toast.error(error);
event.target.value = "";
return;
}
setCropFile(file);
event.target.value = "";
};
const handleLogoRemove = async () => {
@@ -305,8 +332,12 @@ export const AppearanceThemeSettings = forwardRef<
src={getLogoSrc()}
onEdit={handleLogoEdit}
onDrop={(file) => {
setSelectedLogo(file);
setFieldValue("use_custom_logo", true);
const error = validateLogoFile(file);
if (error) {
toast.error(error);
return;
}
setCropFile(file);
}}
onRemove={handleLogoRemove}
showEditOverlay={false}
@@ -582,6 +613,18 @@ export const AppearanceThemeSettings = forwardRef<
</>
)}
</div>
{cropFile && (
<LogoCropModal
file={cropFile}
onApply={(croppedFile) => {
setSelectedLogo(croppedFile);
setFieldValue("use_custom_logo", true);
setCropFile(null);
}}
onCancel={() => setCropFile(null)}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,297 @@
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { SvgZoomIn, SvgZoomOut } from "@opal/icons";
import { toast } from "@/hooks/useToast";
import { cn } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
const CANVAS_SIZE = 448;
const OUTPUT_SIZE = 192;
const MIN_ZOOM = 1;
const MAX_ZOOM = 5;
const ZOOM_STEP = 0.25;
interface LogoCropModalProps {
file: File;
onApply: (croppedFile: File) => void;
onCancel: () => void;
}
export default function LogoCropModal({
file,
onApply,
onCancel,
}: LogoCropModalProps) {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [zoom, setZoom] = useState(MIN_ZOOM);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const [naturalSize, setNaturalSize] = useState({ w: 0, h: 0 });
const maskId = useId();
const dragStartRef = useRef({ x: 0, y: 0, ox: 0, oy: 0 });
const imageRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const url = URL.createObjectURL(file);
setImageSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
const handleImageLoad = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight });
imageRef.current = img;
},
[]
);
const getBaseDisplayedSize = useCallback(() => {
if (naturalSize.w === 0 || naturalSize.h === 0) {
return { w: CANVAS_SIZE, h: CANVAS_SIZE };
}
const shortSide = Math.min(naturalSize.w, naturalSize.h);
const scale = CANVAS_SIZE / shortSide;
return {
w: naturalSize.w * scale,
h: naturalSize.h * scale,
};
}, [naturalSize]);
const getDisplayedSize = useCallback(() => {
const baseSize = getBaseDisplayedSize();
return {
w: baseSize.w * zoom,
h: baseSize.h * zoom,
};
}, [getBaseDisplayedSize, zoom]);
const clampOffset = useCallback(
(ox: number, oy: number) => {
const { w, h } = getDisplayedSize();
const maxX = Math.max(0, (w - CANVAS_SIZE) / 2);
const maxY = Math.max(0, (h - CANVAS_SIZE) / 2);
return {
x: Math.max(-maxX, Math.min(maxX, ox)),
y: Math.max(-maxY, Math.min(maxY, oy)),
};
},
[getDisplayedSize]
);
useEffect(() => {
setOffset((prev) => clampOffset(prev.x, prev.y));
}, [zoom, clampOffset]);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
setDragging(true);
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
ox: offset.x,
oy: offset.y,
};
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[offset]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!dragging) return;
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
setOffset(
clampOffset(dragStartRef.current.ox + dx, dragStartRef.current.oy + dy)
);
},
[dragging, clampOffset]
);
const handlePointerUp = useCallback(() => {
setDragging(false);
}, []);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = -e.deltaY * 0.003;
setZoom((z) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z + delta)));
}, []);
const handleApply = useCallback(() => {
if (!imageRef.current || naturalSize.w === 0) return;
const canvas = document.createElement("canvas");
canvas.width = OUTPUT_SIZE;
canvas.height = OUTPUT_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) {
toast.error("Failed to process image. Please try again.");
return;
}
const shortSide = Math.min(naturalSize.w, naturalSize.h);
const scale = (CANVAS_SIZE / shortSide) * zoom;
const srcCenterX = naturalSize.w / 2 - offset.x / scale;
const srcCenterY = naturalSize.h / 2 - offset.y / scale;
const srcSize = CANVAS_SIZE / scale;
ctx.drawImage(
imageRef.current,
srcCenterX - srcSize / 2,
srcCenterY - srcSize / 2,
srcSize,
srcSize,
0,
0,
OUTPUT_SIZE,
OUTPUT_SIZE
);
const isPng = file.type === "image/png";
canvas.toBlob(
(blob) => {
if (!blob) {
toast.error("Failed to process image. Please try again.");
return;
}
const cropped = new File(
[blob],
file.name.replace(/\.\w+$/, isPng ? ".png" : ".jpg"),
{ type: isPng ? "image/png" : "image/jpeg" }
);
onApply(cropped);
},
isPng ? "image/png" : "image/jpeg",
0.92
);
}, [file, naturalSize, zoom, offset, onApply]);
const baseDisplayed = getBaseDisplayedSize();
const isLandscapeOrSquare = naturalSize.w >= naturalSize.h;
return (
<Modal open onOpenChange={(open) => !open && onCancel()}>
<Modal.Content width="sm" height="fit" preventAccidentalClose={false}>
<Modal.Header title="Position Logo" onClose={onCancel} />
<Modal.Body twoTone>
<div className="flex flex-col items-center w-full">
<div
className="relative overflow-hidden select-none"
style={{
width: CANVAS_SIZE,
height: CANVAS_SIZE,
cursor: dragging ? "grabbing" : "grab",
touchAction: "none",
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onWheel={handleWheel}
>
{imageSrc && (
<img
src={imageSrc}
alt="Logo preview"
draggable={false}
onLoad={handleImageLoad}
className="absolute pointer-events-none"
style={{
width: isLandscapeOrSquare ? baseDisplayed.w : "auto",
height: isLandscapeOrSquare ? "auto" : baseDisplayed.h,
maxWidth: "none",
left: CANVAS_SIZE / 2 - baseDisplayed.w / 2,
top: CANVAS_SIZE / 2 - baseDisplayed.h / 2,
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
transformOrigin: "center center",
}}
/>
)}
<svg
className="absolute inset-0 pointer-events-none"
width={CANVAS_SIZE}
height={CANVAS_SIZE}
>
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white" />
<circle
cx={CANVAS_SIZE / 2}
cy={CANVAS_SIZE / 2}
r={CANVAS_SIZE / 2 - 1}
fill="black"
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
fill="rgba(0,0,0,0.55)"
mask={`url(#${maskId})`}
/>
<circle
cx={CANVAS_SIZE / 2}
cy={CANVAS_SIZE / 2}
r={CANVAS_SIZE / 2 - 1}
fill="none"
stroke="white"
strokeWidth="2"
/>
</svg>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<BasicModalFooter
left={
<div className="flex items-center gap-1">
<Disabled disabled={zoom <= MIN_ZOOM}>
<Button
prominence="tertiary"
size="md"
icon={SvgZoomOut}
onClick={() =>
setZoom((z) => Math.max(MIN_ZOOM, z - ZOOM_STEP))
}
/>
</Disabled>
<Text
text03
mainUiAction
className={cn("w-10 text-center select-none")}
>
{Math.round(zoom * 100)}%
</Text>
<Disabled disabled={zoom >= MAX_ZOOM}>
<Button
prominence="tertiary"
size="md"
icon={SvgZoomIn}
onClick={() =>
setZoom((z) => Math.min(MAX_ZOOM, z + ZOOM_STEP))
}
/>
</Disabled>
</div>
}
cancel={
<Button prominence="secondary" onClick={onCancel}>
Cancel
</Button>
}
submit={<Button onClick={handleApply}>Apply</Button>}
/>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}