diff --git a/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx b/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx
index fb1515ba2..c46985ef6 100644
--- a/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx
+++ b/services/platform/app/features/settings/branding/components/image-upload-field.stories.tsx
@@ -50,6 +50,22 @@ export const EmptyWithLabel: Story = {
},
};
+export const WithImage: Story = {
+ args: {
+ currentUrl:
+ 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Crect%20width%3D%2248%22%20height%3D%2248%22%20rx%3D%228%22%20fill%3D%22%236366f1%22/%3E%3C/svg%3E',
+ ariaLabel: 'Upload logo',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'With an uploaded image. Hover or focus the control to reveal the replace overlay; a remove button sits in the corner.',
+ },
+ },
+ },
+};
+
export const SmallSize: Story = {
args: {
size: 'sm',
diff --git a/services/platform/app/features/settings/branding/components/image-upload-field.test.tsx b/services/platform/app/features/settings/branding/components/image-upload-field.test.tsx
new file mode 100644
index 000000000..0a5703588
--- /dev/null
+++ b/services/platform/app/features/settings/branding/components/image-upload-field.test.tsx
@@ -0,0 +1,145 @@
+// @vitest-environment jsdom
+import '@testing-library/jest-dom/vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { checkAccessibility } from '@/tests/utils/a11y';
+
+// Mock the save-image mutation so the upload path resolves without a backend.
+// `mockSaveImage` is reassigned per-test (e.g. to a deferred promise) to drive
+// the in-flight `isUploading` state.
+let mockSaveImage = vi.fn().mockResolvedValue({ filename: 'logo.png' });
+vi.mock('../hooks/mutations', () => ({
+ useSaveImage: () => ({ mutateAsync: (args: unknown) => mockSaveImage(args) }),
+}));
+
+// The Next.js Image wrapper is only reached when an existing `currentUrl` is
+// shown; render a plain
so jsdom has nothing to resolve.
+vi.mock('@/app/components/ui/data-display/image', () => ({
+ Image: (props: Record) => (
+
+ ),
+}));
+
+import { ImageUploadField } from './image-upload-field';
+
+const baseProps = {
+ organizationId: 'org_demo',
+ imageType: 'logo' as const,
+ ariaLabel: 'Upload logo',
+};
+
+function makeFile(name: string, type: string) {
+ return new File(['fake-image-bytes'], name, { type });
+}
+
+function dropFile(element: Element, file: File) {
+ fireEvent.drop(element, { dataTransfer: { files: [file] } });
+}
+
+beforeEach(() => {
+ mockSaveImage = vi.fn().mockResolvedValue({ filename: 'logo.png' });
+ // jsdom does not implement object-URL APIs the component calls on upload.
+ global.URL.createObjectURL = vi.fn(() => 'blob:preview');
+ global.URL.revokeObjectURL = vi.fn();
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('ImageUploadField', () => {
+ it('renders an accessible upload control', () => {
+ render();
+ expect(
+ screen.getByRole('button', { name: 'Upload logo' }),
+ ).toBeInTheDocument();
+ });
+
+ // Happy path: a valid image dropped onto the control runs the upload and
+ // reports the saved filename back to the parent.
+ it('uploads a valid image dropped onto the control', async () => {
+ const onUpload = vi.fn();
+ render();
+
+ dropFile(
+ screen.getByRole('button', { name: 'Upload logo' }),
+ makeFile('logo.png', 'image/png'),
+ );
+
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
+ expect(mockSaveImage).toHaveBeenCalledTimes(1);
+ expect(mockSaveImage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ organizationId: 'org_demo',
+ type: 'logo',
+ mimeType: 'image/png',
+ }),
+ );
+ expect(onUpload).toHaveBeenCalledWith('logo.png', expect.any(File));
+ });
+
+ // Edge: a second drop while an upload is in flight must be ignored so the
+ // same control cannot fire two concurrent uploads.
+ it('ignores a drop while an upload is already in flight', async () => {
+ // Keep the first upload pending so `isUploading` stays true for the second.
+ let resolveUpload: (value: { filename: string }) => void = () => {};
+ mockSaveImage = vi.fn().mockImplementation(
+ () =>
+ new Promise<{ filename: string }>((resolve) => {
+ resolveUpload = resolve;
+ }),
+ );
+
+ render();
+ const button = screen.getByRole('button', { name: 'Upload logo' });
+
+ dropFile(button, makeFile('logo.png', 'image/png'));
+ // The control disables itself once the upload starts.
+ await waitFor(() => expect(button).toBeDisabled());
+
+ dropFile(button, makeFile('logo-2.png', 'image/png'));
+
+ expect(mockSaveImage).toHaveBeenCalledTimes(1);
+ resolveUpload({ filename: 'logo.png' });
+ });
+
+ // Edge: a valid file whose browser-reported MIME is blank/non-`image/*` is
+ // still accepted via its extension (e.g. a `.ico` reporting empty type),
+ // keeping the drop path in sync with the picker's `accept` list.
+ it('accepts a dropped file with a non-image MIME but an accepted extension', async () => {
+ const onUpload = vi.fn();
+ render();
+
+ dropFile(
+ screen.getByRole('button', { name: 'Upload logo' }),
+ makeFile('favicon.ico', ''),
+ );
+
+ await waitFor(() => expect(onUpload).toHaveBeenCalledTimes(1));
+ expect(mockSaveImage).toHaveBeenCalledTimes(1);
+ });
+
+ // Error: a non-image file is rejected before any upload is attempted.
+ it('ignores a dropped non-image file', () => {
+ const onUpload = vi.fn();
+ render();
+
+ dropFile(
+ screen.getByRole('button', { name: 'Upload logo' }),
+ makeFile('notes.txt', 'text/plain'),
+ );
+
+ expect(mockSaveImage).not.toHaveBeenCalled();
+ expect(onUpload).not.toHaveBeenCalled();
+ });
+
+ describe('accessibility', () => {
+ it('passes axe audit', async () => {
+ const { container } = render(
+ ,
+ );
+ await checkAccessibility(container);
+ });
+ });
+});
diff --git a/services/platform/app/features/settings/branding/components/image-upload-field.tsx b/services/platform/app/features/settings/branding/components/image-upload-field.tsx
index 24d1b09e9..4d2d11a71 100644
--- a/services/platform/app/features/settings/branding/components/image-upload-field.tsx
+++ b/services/platform/app/features/settings/branding/components/image-upload-field.tsx
@@ -3,7 +3,7 @@
import { VStack } from '@tale/ui/layout';
import { Spinner } from '@tale/ui/spinner';
import { Text } from '@tale/ui/text';
-import { Plus, X } from 'lucide-react';
+import { Plus, Upload, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Image } from '@/app/components/ui/data-display/image';
@@ -12,6 +12,17 @@ import { cn } from '@/lib/utils/cn';
import { useSaveImage } from '../hooks/mutations';
const ACCEPTED_IMAGE_TYPES = '.png,.svg,.jpg,.jpeg,.webp,.ico';
+const ACCEPTED_EXTENSIONS = ['.png', '.svg', '.jpg', '.jpeg', '.webp', '.ico'];
+
+// Mirror the `` list for the drag-and-drop path. Some valid
+// uploads report an empty or non-`image/*` MIME type (e.g. a `.ico` whose
+// browser-reported type is blank), so fall back to the file extension rather
+// than rejecting them — keeping picker and drop acceptance in sync.
+function isAcceptedImage(file: File): boolean {
+ if (file.type.startsWith('image/')) return true;
+ const name = file.name.toLowerCase();
+ return ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext));
+}
interface ImageUploadFieldProps {
organizationId: string;
@@ -37,6 +48,7 @@ export function ImageUploadField({
ariaLabel,
}: ImageUploadFieldProps) {
const [isUploading, setIsUploading] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
const [previewUrl, setPreviewUrl] = useState(null);
const [isRemoved, setIsRemoved] = useState(false);
const fileInputRef = useRef(null);
@@ -65,11 +77,8 @@ export function ImageUploadField({
fileInputRef.current?.click();
}, []);
- const handleFileChange = useCallback(
- async (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (!file) return;
-
+ const uploadFile = useCallback(
+ async (file: File) => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
}
@@ -79,6 +88,7 @@ export function ImageUploadField({
setPreviewUrl(objectUrl);
onPreviewUrlChange?.(objectUrl);
setIsRemoved(false);
+ setIsDragging(false);
setIsUploading(true);
try {
@@ -113,6 +123,38 @@ export function ImageUploadField({
[organizationId, saveImage, imageType, onUpload, onPreviewUrlChange],
);
+ const handleFileChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) void uploadFile(file);
+ },
+ [uploadFile],
+ );
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ if (!isUploading) setIsDragging(true);
+ },
+ [isUploading],
+ );
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ }, []);
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ if (isUploading) return;
+ const file = e.dataTransfer.files?.[0];
+ if (file && isAcceptedImage(file)) void uploadFile(file);
+ },
+ [isUploading, uploadFile],
+ );
+
const handleRemove = useCallback(() => {
setPreviewUrl(null);
onPreviewUrlChange?.(null);
@@ -132,10 +174,17 @@ export function ImageUploadField({
) : displayUrl ? (
- previewUrl ? (
-
- ) : (
-
- )
+ <>
+ {previewUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+ >
) : (
-
+
)}
{displayUrl && !isUploading && onRemove && (