feat: allow file uploads on the wizard branding step
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { readFile, stat } from 'node:fs/promises';
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { getConfigDir } from '@/lib/admin/paths';
|
||||||
|
|
||||||
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
function getBrandingDir(): string {
|
||||||
|
return path.join(getConfigDir(), 'branding');
|
||||||
|
}
|
||||||
|
|
||||||
const MIME_TYPES: Record<string, string> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
'.svg': 'image/svg+xml',
|
'.svg': 'image/svg+xml',
|
||||||
@@ -38,11 +41,11 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
|
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join(BRANDING_DIR, safe);
|
const filePath = path.join(getBrandingDir(), safe);
|
||||||
|
|
||||||
// Ensure resolved path is still within BRANDING_DIR
|
// Ensure resolved path is still within getBrandingDir()
|
||||||
const resolved = path.resolve(filePath);
|
const resolved = path.resolve(filePath);
|
||||||
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
|
if (!resolved.startsWith(path.resolve(getBrandingDir()))) {
|
||||||
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
import { auditLog } from '@/lib/admin/audit';
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { getConfigDir } from '@/lib/admin/paths';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
const BRANDING_DIR = path.join(process.cwd(), 'data', 'admin', 'branding');
|
function getBrandingDir(): string {
|
||||||
|
return path.join(getConfigDir(), 'branding');
|
||||||
|
}
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||||
const ALLOWED_MIME_TYPES = new Set([
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
'image/svg+xml',
|
'image/svg+xml',
|
||||||
@@ -79,11 +82,11 @@ export async function POST(request: NextRequest) {
|
|||||||
};
|
};
|
||||||
const ext = extMap[file.type] || '.png';
|
const ext = extMap[file.type] || '.png';
|
||||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||||
const filePath = path.join(BRANDING_DIR, safeName);
|
const filePath = path.join(getBrandingDir(), safeName);
|
||||||
|
|
||||||
// Ensure branding directory exists
|
// Ensure branding directory exists
|
||||||
if (!existsSync(BRANDING_DIR)) {
|
if (!existsSync(getBrandingDir())) {
|
||||||
await mkdir(BRANDING_DIR, { recursive: true });
|
await mkdir(getBrandingDir(), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write file to disk
|
// Write file to disk
|
||||||
@@ -125,7 +128,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||||
let removed = false;
|
let removed = false;
|
||||||
for (const ext of possibleExts) {
|
for (const ext of possibleExts) {
|
||||||
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
|
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
||||||
if (existsSync(filePath)) {
|
if (existsSync(filePath)) {
|
||||||
await unlink(filePath);
|
await unlink(filePath);
|
||||||
removed = true;
|
removed = true;
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { detectSetupState } from '@/lib/setup/state';
|
||||||
|
import { authenticateWizardRequest } from '@/lib/setup/session';
|
||||||
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
import { getConfigDir, assertWritable } from '@/lib/admin/paths';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
|
||||||
|
|
||||||
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/webp',
|
||||||
|
'image/x-icon',
|
||||||
|
'image/vnd.microsoft.icon',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const VALID_SLOTS = new Set([
|
||||||
|
'faviconUrl',
|
||||||
|
'appLogoLightUrl',
|
||||||
|
'appLogoDarkUrl',
|
||||||
|
'loginLogoLightUrl',
|
||||||
|
'loginLogoDarkUrl',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const EXT_BY_MIME: Record<string, string> = {
|
||||||
|
'image/svg+xml': '.svg',
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
'image/x-icon': '.ico',
|
||||||
|
'image/vnd.microsoft.icon': '.ico',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBrandingDir(): string {
|
||||||
|
return path.join(getConfigDir(), 'branding');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/setup/branding — wizard branding upload.
|
||||||
|
*
|
||||||
|
* Multipart form fields:
|
||||||
|
* file — the image (SVG/PNG/JPEG/WebP/ICO, max 2 MB)
|
||||||
|
* slot — which branding key (faviconUrl, loginLogoLightUrl, etc.)
|
||||||
|
*
|
||||||
|
* Mirrors /api/admin/branding but authenticates via the wizard cookie
|
||||||
|
* instead of admin session — admin auth doesn't exist yet during bootstrap.
|
||||||
|
* Files land in the same directory; the public read endpoint at
|
||||||
|
* /api/admin/branding/<filename> serves both wizard- and admin-uploaded
|
||||||
|
* assets after setup.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (detectSetupState() !== 'bootstrap') {
|
||||||
|
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!(await authenticateWizardRequest())) {
|
||||||
|
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertWritable('upload branding asset');
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file');
|
||||||
|
const slot = formData.get('slot');
|
||||||
|
|
||||||
|
if (!(file instanceof File) || typeof slot !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!VALID_SLOTS.has(slot)) {
|
||||||
|
return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unsupported file type: ${file.type}. Allowed: SVG, PNG, JPEG, WebP, ICO` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = EXT_BY_MIME[file.type] ?? '.png';
|
||||||
|
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||||
|
|
||||||
|
const dir = getBrandingDir();
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing file for this slot with a different extension so
|
||||||
|
// the wizard doesn't leave orphan files behind on re-upload.
|
||||||
|
for (const otherExt of Object.values(EXT_BY_MIME)) {
|
||||||
|
if (otherExt === ext) continue;
|
||||||
|
const oldPath = path.join(dir, `${slot}${otherExt}`);
|
||||||
|
if (existsSync(oldPath)) {
|
||||||
|
try { await unlink(oldPath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const filePath = path.join(dir, safeName);
|
||||||
|
await writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
const servedUrl = `/api/admin/branding/${safeName}`;
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
await configManager.setAdminConfig({ [slot]: servedUrl });
|
||||||
|
|
||||||
|
return NextResponse.json({ url: servedUrl, filename: safeName });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Wizard branding upload failed', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/setup/branding — remove an uploaded asset and clear the
|
||||||
|
* config override so the slot falls back to the system default.
|
||||||
|
*
|
||||||
|
* Body: { slot: string }
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
if (detectSetupState() !== 'bootstrap') {
|
||||||
|
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!(await authenticateWizardRequest())) {
|
||||||
|
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertWritable('remove branding asset');
|
||||||
|
const { slot } = (await request.json()) as { slot?: string };
|
||||||
|
if (!slot || !VALID_SLOTS.has(slot)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = getBrandingDir();
|
||||||
|
for (const ext of Object.values(EXT_BY_MIME)) {
|
||||||
|
const filePath = path.join(dir, `${slot}${ext}`);
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
try { await unlink(filePath); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await configManager.ensureLoaded();
|
||||||
|
await configManager.removeAdminOverride(slot);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Wizard branding delete failed', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Delete failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+207
-22
@@ -1010,17 +1010,23 @@ function LoggingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'con
|
|||||||
|
|
||||||
// ─── Branding step ───────────────────────────────────────────────────────
|
// ─── Branding step ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type BrandingSlot =
|
||||||
|
| 'faviconUrl'
|
||||||
|
| 'appLogoLightUrl'
|
||||||
|
| 'appLogoDarkUrl'
|
||||||
|
| 'loginLogoLightUrl'
|
||||||
|
| 'loginLogoDarkUrl';
|
||||||
|
|
||||||
function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'config' | 'setConfig' | 'onNext' | 'onBack'>) {
|
function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'config' | 'setConfig' | 'onNext' | 'onBack'>) {
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
async function handle(e: FormEvent) {
|
async function handle(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
// Only send fields the operator actually filled in. Saving an empty
|
// Only send fields with a value. Empty strings would create an admin
|
||||||
// string would create an admin override that shadows the system
|
// override that shadows the system default and suppress the bundled
|
||||||
// default — a blank "Login logo" field would suppress the default
|
// Bulwark logo on the login page.
|
||||||
// Bulwark logo on the login page, which is never what we want from
|
|
||||||
// the wizard.
|
|
||||||
const allFields = {
|
const allFields = {
|
||||||
faviconUrl: config.faviconUrl,
|
faviconUrl: config.faviconUrl,
|
||||||
appLogoLightUrl: config.appLogoLightUrl,
|
appLogoLightUrl: config.appLogoLightUrl,
|
||||||
@@ -1041,29 +1047,57 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
|||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handle} className="space-y-4">
|
<form onSubmit={handle} className="space-y-4">
|
||||||
<StepHeader title="Branding" subtitle="All fields optional. Skip any field to use defaults." />
|
<StepHeader
|
||||||
|
title="Branding"
|
||||||
|
subtitle="All fields optional. Upload a file or paste a URL — defaults are used for anything you skip."
|
||||||
|
/>
|
||||||
<Field label="Company / organization name">
|
<Field label="Company / organization name">
|
||||||
<Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} />
|
<Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Favicon URL" hint="SVG recommended. Absolute URL or path under /public.">
|
|
||||||
<Input value={config.faviconUrl} onChange={(v) => setConfig({ ...config, faviconUrl: v })} />
|
<div className="space-y-2">
|
||||||
</Field>
|
<BrandingAsset
|
||||||
<div className="grid grid-cols-2 gap-3">
|
label="Favicon"
|
||||||
<Field label="Login logo (light)">
|
hint="Browser tab icon. SVG recommended."
|
||||||
<Input value={config.loginLogoLightUrl} onChange={(v) => setConfig({ ...config, loginLogoLightUrl: v })} />
|
slot="faviconUrl"
|
||||||
</Field>
|
value={config.faviconUrl}
|
||||||
<Field label="Login logo (dark)">
|
onChange={(v) => setConfig({ ...config, faviconUrl: v })}
|
||||||
<Input value={config.loginLogoDarkUrl} onChange={(v) => setConfig({ ...config, loginLogoDarkUrl: v })} />
|
/>
|
||||||
</Field>
|
<BrandingAsset
|
||||||
<Field label="Sidebar logo (light)">
|
label="Login logo (light mode)"
|
||||||
<Input value={config.appLogoLightUrl} onChange={(v) => setConfig({ ...config, appLogoLightUrl: v })} />
|
hint="Shown on the sign-in page, light backgrounds."
|
||||||
</Field>
|
slot="loginLogoLightUrl"
|
||||||
<Field label="Sidebar logo (dark)">
|
value={config.loginLogoLightUrl}
|
||||||
<Input value={config.appLogoDarkUrl} onChange={(v) => setConfig({ ...config, appLogoDarkUrl: v })} />
|
onChange={(v) => setConfig({ ...config, loginLogoLightUrl: v })}
|
||||||
</Field>
|
/>
|
||||||
|
<BrandingAsset
|
||||||
|
label="Login logo (dark mode)"
|
||||||
|
hint="Shown on the sign-in page, dark backgrounds."
|
||||||
|
slot="loginLogoDarkUrl"
|
||||||
|
value={config.loginLogoDarkUrl}
|
||||||
|
onChange={(v) => setConfig({ ...config, loginLogoDarkUrl: v })}
|
||||||
|
previewBg="dark"
|
||||||
|
/>
|
||||||
|
<BrandingAsset
|
||||||
|
label="Sidebar logo (light mode)"
|
||||||
|
hint="Shown after sign-in. Leave blank for none."
|
||||||
|
slot="appLogoLightUrl"
|
||||||
|
value={config.appLogoLightUrl}
|
||||||
|
onChange={(v) => setConfig({ ...config, appLogoLightUrl: v })}
|
||||||
|
/>
|
||||||
|
<BrandingAsset
|
||||||
|
label="Sidebar logo (dark mode)"
|
||||||
|
hint="Dark mode variant of the sidebar logo."
|
||||||
|
slot="appLogoDarkUrl"
|
||||||
|
value={config.appLogoDarkUrl}
|
||||||
|
onChange={(v) => setConfig({ ...config, appLogoDarkUrl: v })}
|
||||||
|
previewBg="dark"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Website URL">
|
<Field label="Website URL">
|
||||||
<Input value={config.loginWebsiteUrl} onChange={(v) => setConfig({ ...config, loginWebsiteUrl: v })} type="url" />
|
<Input value={config.loginWebsiteUrl} onChange={(v) => setConfig({ ...config, loginWebsiteUrl: v })} type="url" />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -1083,6 +1117,157 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One branding asset slot: shows a thumbnail preview if a value is set,
|
||||||
|
* a file picker (uploads to /api/setup/branding), and a URL field for
|
||||||
|
* operators who'd rather paste a link. Upload and URL are mutually
|
||||||
|
* compatible — the URL field always reflects the persisted value.
|
||||||
|
*/
|
||||||
|
function BrandingAsset({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
slot,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
previewBg = 'light',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
slot: BrandingSlot;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
previewBg?: 'light' | 'dark';
|
||||||
|
}) {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
const [showUrlField, setShowUrlField] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
async function handleFile(file: File) {
|
||||||
|
setUploadError(null);
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
fd.append('slot', slot);
|
||||||
|
const res = await apiFetch('/api/setup/branding', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setUploadError(data?.error ?? `Upload failed (HTTP ${res.status})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(data.url);
|
||||||
|
} catch (e) {
|
||||||
|
setUploadError(humanError(e));
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAsset() {
|
||||||
|
setUploadError(null);
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/setup/branding', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slot }),
|
||||||
|
}).catch(() => null);
|
||||||
|
} finally {
|
||||||
|
onChange('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewClasses =
|
||||||
|
'shrink-0 w-16 h-16 rounded-md border border-border flex items-center justify-center overflow-hidden transition-colors ' +
|
||||||
|
(previewBg === 'dark' ? 'bg-zinc-900' : 'bg-muted/40') +
|
||||||
|
(dragOver ? ' ring-2 ring-primary border-primary' : '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
className={previewClasses + (uploading ? ' opacity-50' : ' cursor-pointer hover:border-foreground/30')}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const f = e.dataTransfer.files?.[0];
|
||||||
|
if (f) void handleFile(f);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/svg+xml,image/png,image/jpeg,image/webp,image/x-icon,image/vnd.microsoft.icon"
|
||||||
|
disabled={uploading}
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleFile(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{value ? (
|
||||||
|
<img src={value} alt="" className="max-w-full max-h-full object-contain" />
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<div className="text-sm font-medium truncate">{label}</div>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearAsset}
|
||||||
|
className="text-xs text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hint && <p className="text-xs text-muted-foreground mt-0.5">{hint}</p>}
|
||||||
|
<div className="mt-1.5 flex items-center gap-2 text-xs">
|
||||||
|
{uploading ? (
|
||||||
|
<span className="text-muted-foreground">Uploading…</span>
|
||||||
|
) : value ? (
|
||||||
|
<span className="text-muted-foreground truncate">
|
||||||
|
{value.startsWith('/api/') ? 'Uploaded file' : value}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">SVG, PNG, JPEG, WebP or ICO · max 2 MB</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUrlField((v) => !v)}
|
||||||
|
className="text-muted-foreground hover:text-foreground underline shrink-0"
|
||||||
|
>
|
||||||
|
{showUrlField ? 'Hide URL' : 'Use URL'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showUrlField && (
|
||||||
|
<div className="mt-3 pl-[4.75rem]">
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder="https://… or /branding/file.svg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadError && (
|
||||||
|
<p className="mt-2 pl-[4.75rem] text-xs text-destructive">{uploadError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Review / finish step ─────────────────────────────────────────────────
|
// ─── Review / finish step ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack: () => void; onFinish: () => void }) {
|
function ReviewStep({ config, onBack, onFinish }: { config: WizardConfig; onBack: () => void; onFinish: () => void }) {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export async function proxy(request: NextRequest) {
|
|||||||
pathname === "/api/health" ||
|
pathname === "/api/health" ||
|
||||||
pathname.startsWith("/_next/") ||
|
pathname.startsWith("/_next/") ||
|
||||||
pathname.startsWith("/branding/") ||
|
pathname.startsWith("/branding/") ||
|
||||||
|
// Public read endpoint — serves wizard-uploaded branding assets so
|
||||||
|
// image previews work during the wizard. No auth on the GET route.
|
||||||
|
pathname.startsWith("/api/admin/branding/") ||
|
||||||
/\.[^/]+$/.test(pathname);
|
/\.[^/]+$/.test(pathname);
|
||||||
|
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
|
|||||||
Reference in New Issue
Block a user