diff --git a/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx
index 2aedd4cb..3af67355 100644
--- a/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/branding/page.tsx
@@ -52,6 +52,10 @@ const FIELDS: SettingFieldDef[] = [
description:
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
type: 'image-upload',
+ // 16:9 — landscape. Without an explicit aspect, the cropper falls
+ // back to 1:1 and renders a circular mask (intended for avatars),
+ // which is the wrong UX for a viewport-cover background.
+ imageAspect: 16 / 9,
defaultValue: '',
},
{
diff --git a/src/app/api/v1/admin/branding/logo/route.ts b/src/app/api/v1/admin/branding/logo/route.ts
index 320fd4f1..5a56d0f6 100644
--- a/src/app/api/v1/admin/branding/logo/route.ts
+++ b/src/app/api/v1/admin/branding/logo/route.ts
@@ -51,10 +51,13 @@ export const GET = withAuth(
return NextResponse.json({ data: null });
}
const baseUrl = env.APP_URL.replace(/\/+$/, '');
+ // Stream from the public-by-id surface (gated on `category='branding'`)
+ // so the URL works as a direct `
` — the authenticated
+ // `/api/v1/files//preview` returns JSON, not image bytes.
return NextResponse.json({
data: {
fileId: file.id,
- previewUrl: `${baseUrl}/api/v1/files/${file.id}/preview`,
+ previewUrl: `${baseUrl}/api/public/files/${file.id}`,
sizeBytes: file.sizeBytes,
mimeType: file.mimeType,
},
@@ -96,7 +99,7 @@ export const POST = withAuth(
return NextResponse.json({
data: {
fileId: result.fileId,
- previewUrl: `${baseUrl}/api/v1/files/${result.fileId}/preview`,
+ previewUrl: `${baseUrl}/api/public/files/${result.fileId}`,
warnings: result.warnings,
finalDimensions: processed.finalDimensions,
finalBytes: processed.finalBytes,
diff --git a/src/app/api/v1/admin/settings/image/route.ts b/src/app/api/v1/admin/settings/image/route.ts
index 55fafe65..283dc94d 100644
--- a/src/app/api/v1/admin/settings/image/route.ts
+++ b/src/app/api/v1/admin/settings/image/route.ts
@@ -40,17 +40,31 @@ export const POST = withAuth(
if (!port) throw new ValidationError('No active port');
const buffer = Buffer.from(await fileEntry.arrayBuffer());
+ // Pick the storage filename's extension from the upload's MIME so
+ // PNG uploads aren't silently relabelled `.jpg` (the previous
+ // hardcoded extension flattened alpha-transparent logos).
+ const mimeType = fileEntry.type || 'image/jpeg';
+ const ext =
+ mimeType === 'image/png'
+ ? 'png'
+ : mimeType === 'image/webp'
+ ? 'webp'
+ : mimeType === 'image/gif'
+ ? 'gif'
+ : mimeType === 'image/avif'
+ ? 'avif'
+ : 'jpg';
const record = await uploadFile(
port.id,
port.slug,
{
buffer,
- originalName: fileEntry.name || 'branding.jpg',
- mimeType: fileEntry.type || 'image/jpeg',
+ originalName: fileEntry.name || `branding.${ext}`,
+ mimeType,
size: fileEntry.size,
},
{
- filename: `branding-${Date.now()}.jpg`,
+ filename: `branding-${Date.now()}.${ext}`,
category: 'branding',
entityType: 'port',
entityId: port.id,
diff --git a/src/app/api/v1/me/avatar/route.ts b/src/app/api/v1/me/avatar/route.ts
index 7fa8a513..a323e7a4 100644
--- a/src/app/api/v1/me/avatar/route.ts
+++ b/src/app/api/v1/me/avatar/route.ts
@@ -45,17 +45,31 @@ export const POST = withAuth(async (req, ctx) => {
if (!portId) throw new ValidationError('No active port');
const buffer = Buffer.from(await fileEntry.arrayBuffer());
+ // Pick the storage filename's extension from the upload's MIME so
+ // PNG uploads aren't silently relabelled `.jpg` (which would strip
+ // the alpha-channel signal from the storage layer).
+ const mimeType = fileEntry.type || 'image/jpeg';
+ const ext =
+ mimeType === 'image/png'
+ ? 'png'
+ : mimeType === 'image/webp'
+ ? 'webp'
+ : mimeType === 'image/gif'
+ ? 'gif'
+ : mimeType === 'image/avif'
+ ? 'avif'
+ : 'jpg';
const record = await uploadFile(
portId,
portSlug,
{
buffer,
- originalName: fileEntry.name || 'avatar.jpg',
- mimeType: fileEntry.type || 'image/jpeg',
+ originalName: fileEntry.name || `avatar.${ext}`,
+ mimeType,
size: fileEntry.size,
},
{
- filename: `avatar-${ctx.userId}.jpg`,
+ filename: `avatar-${ctx.userId}.${ext}`,
category: 'avatar',
entityType: 'user',
entityId: ctx.userId,
diff --git a/src/components/admin/branding/pdf-logo-uploader.tsx b/src/components/admin/branding/pdf-logo-uploader.tsx
index 99b4f7b3..57db78c4 100644
--- a/src/components/admin/branding/pdf-logo-uploader.tsx
+++ b/src/components/admin/branding/pdf-logo-uploader.tsx
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { toastError } from '@/lib/api/toast-error';
+import { resolvePortIdFromSlug } from '@/lib/api/client';
import { toast } from 'sonner';
import { useConfirmation } from '@/hooks/use-confirmation';
@@ -40,6 +41,26 @@ interface UploadResult {
originalFormat: string;
}
+/**
+ * Raw `fetch()` doesn't carry the `X-Port-Id` header that
+ * `apiFetch` injects, so the branding endpoints (which require port
+ * context) 400 with "No active port". We can't swap to `apiFetch`
+ * here because the upload payload is `FormData`, not JSON. So resolve
+ * the active port from the URL the same way `apiFetch` does and
+ * attach the header manually.
+ */
+async function brandingHeaders(): Promise {
+ const headers = new Headers();
+ if (typeof window === 'undefined') return headers;
+ const slug = window.location.pathname.split('/').filter(Boolean)[0];
+ if (!slug || slug === 'login' || slug === 'portal' || slug === 'api' || slug === 'dashboard') {
+ return headers;
+ }
+ const portId = await resolvePortIdFromSlug(slug);
+ if (portId) headers.set('X-Port-Id', portId);
+ return headers;
+}
+
const WARNING_LABELS: Record = {
trimmed: 'Auto-trimmed whitespace borders',
resized: 'Downscaled for size',
@@ -82,7 +103,8 @@ export function PdfLogoUploader() {
async function refresh() {
setLoading(true);
try {
- const res = await fetch('/api/v1/admin/branding/logo');
+ const headers = await brandingHeaders();
+ const res = await fetch('/api/v1/admin/branding/logo', { headers });
if (!res.ok) throw new Error(`GET ${res.status}`);
const body = (await res.json()) as { data: CurrentLogo | null };
setCurrent(body.data ?? null);
@@ -139,7 +161,12 @@ export function PdfLogoUploader() {
}),
);
}
- const res = await fetch('/api/v1/admin/branding/logo', { method: 'POST', body: fd });
+ const headers = await brandingHeaders();
+ const res = await fetch('/api/v1/admin/branding/logo', {
+ method: 'POST',
+ headers,
+ body: fd,
+ });
const body = (await res.json()) as { data: UploadResult } | { error?: { message?: string } };
if (!res.ok) {
const message = ('error' in body && body.error?.message) || `Upload failed (${res.status})`;
@@ -170,7 +197,8 @@ export function PdfLogoUploader() {
if (!ok) return;
setWorking(true);
try {
- const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE' });
+ const headers = await brandingHeaders();
+ const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE', headers });
if (!res.ok && res.status !== 204) throw new Error(`DELETE ${res.status}`);
toast.success('Logo removed');
setCurrent(null);
diff --git a/src/components/admin/shared/settings-form-card.tsx b/src/components/admin/shared/settings-form-card.tsx
index 14d5ca4a..b00fcb5c 100644
--- a/src/components/admin/shared/settings-form-card.tsx
+++ b/src/components/admin/shared/settings-form-card.tsx
@@ -19,7 +19,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
-import { apiFetch } from '@/lib/api/client';
+import { apiFetch, resolvePortIdFromSlug } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
export type SettingFieldType =
@@ -49,6 +49,11 @@ export interface SettingFieldDef {
defaultTemplate?: string;
/** For 'image-upload' fields: cropper aspect ratio. */
imageAspect?: number;
+ /** For 'image-upload' fields: output format. Default 'jpeg' (smaller
+ * files, good for photos / backgrounds). Use 'png' for logos with
+ * transparency — JPEG has no alpha channel, so transparent pixels
+ * composite against black on export. */
+ imageFormat?: 'jpeg' | 'png';
}
interface SettingsRowResponse {
@@ -399,8 +404,27 @@ function ImageUploadField({
async function uploadCropped(blob: Blob) {
const fd = new FormData();
- fd.append('file', new File([blob], 'image.jpg', { type: 'image/jpeg' }));
- const res = await fetch('/api/v1/admin/settings/image', { method: 'POST', body: fd });
+ // Trust the blob's own MIME — the cropper auto-picks PNG when the
+ // source had alpha, JPEG otherwise. Hardcoding to JPEG here threw
+ // away the alpha channel on transparent logos.
+ const mime = blob.type || 'image/jpeg';
+ const ext = mime === 'image/png' ? 'png' : 'jpg';
+ fd.append('file', new File([blob], `image.${ext}`, { type: mime }));
+ // Raw fetch (not apiFetch — FormData body) → manually attach the
+ // X-Port-Id header that the admin settings route requires.
+ const headers = new Headers();
+ if (typeof window !== 'undefined') {
+ const slug = window.location.pathname.split('/').filter(Boolean)[0];
+ if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api' && slug !== 'dashboard') {
+ const portId = await resolvePortIdFromSlug(slug);
+ if (portId) headers.set('X-Port-Id', portId);
+ }
+ }
+ const res = await fetch('/api/v1/admin/settings/image', {
+ method: 'POST',
+ headers,
+ body: fd,
+ });
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
throw new Error(err.error?.message ?? 'Image upload failed');
@@ -470,6 +494,7 @@ function ImageUploadField({
file={pendingFile}
aspect={field.imageAspect ?? 1}
outputWidth={field.imageAspect && field.imageAspect > 1 ? 1024 : 512}
+ outputFormat={field.imageFormat ?? 'auto'}
title={`Crop ${field.label.toLowerCase()}`}
onUpload={uploadCropped}
/>
diff --git a/src/components/settings/user-settings.tsx b/src/components/settings/user-settings.tsx
index b64d6496..8514be26 100644
--- a/src/components/settings/user-settings.tsx
+++ b/src/components/settings/user-settings.tsx
@@ -118,7 +118,11 @@ export function UserSettings() {
async function uploadAvatar(blob: Blob) {
const fd = new FormData();
- fd.append('file', new File([blob], 'avatar.jpg', { type: 'image/jpeg' }));
+ // Preserve the cropper's chosen format — a transparent PNG uploaded
+ // as JPEG composites the alpha against black on export.
+ const mime = blob.type || 'image/jpeg';
+ const ext = mime === 'image/png' ? 'png' : 'jpg';
+ fd.append('file', new File([blob], `avatar.${ext}`, { type: mime }));
const res = await fetch('/api/v1/me/avatar', { method: 'POST', body: fd });
if (!res.ok) throw new Error('Avatar upload failed');
const json = (await res.json()) as { data: { avatarFileId: string } };
diff --git a/src/components/shared/image-cropper-dialog.tsx b/src/components/shared/image-cropper-dialog.tsx
index 78eb9797..8d7fa10d 100644
--- a/src/components/shared/image-cropper-dialog.tsx
+++ b/src/components/shared/image-cropper-dialog.tsx
@@ -23,10 +23,14 @@ export interface ImageCropperDialogProps {
/** Aspect ratio for the crop frame. 1 = square (avatars), 4 = wide
* banners. Defaults to 1. */
aspect?: number;
- /** Output JPEG quality 0-1. Default 0.85. */
+ /** Output JPEG quality 0-1. Default 0.85. Ignored for PNG. */
outputQuality?: number;
/** Output width in pixels (height derived from aspect). Default 512. */
outputWidth?: number;
+ /** Output format. 'auto' (default) preserves alpha: PNG output when
+ * the source is PNG/GIF/WebP/AVIF, JPEG otherwise. Override to force
+ * a specific format. */
+ outputFormat?: 'auto' | 'jpeg' | 'png';
/** Async upload handler — receives the cropped Blob. Cropper closes on
* success; toasts on error. */
onUpload: (blob: Blob) => Promise;
@@ -34,6 +38,14 @@ export interface ImageCropperDialogProps {
title?: string;
}
+/**
+ * MIME types that carry an alpha channel. A PNG source dropped through a
+ * JPEG-output canvas composites transparent pixels against black on
+ * export — destroying the design intent of any logo with transparency.
+ * Default to PNG output whenever the source could have had alpha.
+ */
+const ALPHA_CAPABLE_MIME = new Set(['image/png', 'image/gif', 'image/webp', 'image/avif']);
+
/**
* Reusable crop-then-upload modal. Renders a draggable/zoomable crop
* frame over the picked file, then writes the cropped pixels to a
@@ -49,6 +61,7 @@ export function ImageCropperDialog({
aspect = 1,
outputQuality = 0.85,
outputWidth = 512,
+ outputFormat = 'auto',
onUpload,
title = 'Crop image',
}: ImageCropperDialogProps) {
@@ -64,10 +77,23 @@ export function ImageCropperDialog({
}, []);
async function handleUpload() {
- if (!objectUrl || !pixels) return;
+ if (!objectUrl || !pixels || !file) return;
setUploading(true);
try {
- const blob = await renderCrop(objectUrl, pixels, aspect, outputWidth, outputQuality);
+ const resolvedFormat: 'jpeg' | 'png' =
+ outputFormat === 'auto'
+ ? ALPHA_CAPABLE_MIME.has(file.type)
+ ? 'png'
+ : 'jpeg'
+ : outputFormat;
+ const blob = await renderCrop(
+ objectUrl,
+ pixels,
+ aspect,
+ outputWidth,
+ outputQuality,
+ resolvedFormat,
+ );
await onUpload(blob);
onOpenChange(false);
} catch (err) {
@@ -146,8 +172,9 @@ export function ImageCropperDialog({
/**
* Crop the source image at `pixels`, resize to `outputWidth`×
- * (outputWidth/aspect), and return as a JPEG Blob. Async because
- *
needs an onload event before we can drawImage.
+ * (outputWidth/aspect), and return as a JPEG or PNG Blob. Async because
+ *
needs an onload event before we can drawImage. PNG preserves
+ * alpha for logos; JPEG is smaller for photos / backgrounds.
*/
async function renderCrop(
src: string,
@@ -155,6 +182,7 @@ async function renderCrop(
aspect: number,
outputWidth: number,
quality: number,
+ format: 'jpeg' | 'png',
): Promise {
const image = await loadImage(src);
const canvas = document.createElement('canvas');
@@ -174,11 +202,13 @@ async function renderCrop(
outputWidth,
outputHeight,
);
+ const mime = format === 'png' ? 'image/png' : 'image/jpeg';
return await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null'))),
- 'image/jpeg',
- quality,
+ mime,
+ // toBlob's quality param applies to JPEG/WebP only; PNG ignores it.
+ format === 'jpeg' ? quality : undefined,
);
});
}
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts
index b67b8ae6..55894b9b 100644
--- a/src/lib/api/client.ts
+++ b/src/lib/api/client.ts
@@ -15,7 +15,7 @@ const slugToIdCache = new Map();
* trip instead of N. */
let inFlightPortsLookup: Promise | null> | null = null;
-async function resolvePortIdFromSlug(slug: string): Promise {
+export async function resolvePortIdFromSlug(slug: string): Promise {
const cached = slugToIdCache.get(slug);
if (cached) return cached;
if (!inFlightPortsLookup) {