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 { readFile, stat } from 'node:fs/promises';
|
||||
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> = {
|
||||
'.svg': 'image/svg+xml',
|
||||
@@ -38,11 +41,11 @@ export async function GET(
|
||||
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);
|
||||
if (!resolved.startsWith(path.resolve(BRANDING_DIR))) {
|
||||
if (!resolved.startsWith(path.resolve(getBrandingDir()))) {
|
||||
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 { auditLog } from '@/lib/admin/audit';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { getConfigDir } from '@/lib/admin/paths';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
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 ALLOWED_MIME_TYPES = new Set([
|
||||
'image/svg+xml',
|
||||
@@ -79,11 +82,11 @@ export async function POST(request: NextRequest) {
|
||||
};
|
||||
const ext = extMap[file.type] || '.png';
|
||||
const safeName = sanitizeFilename(`${slot}${ext}`);
|
||||
const filePath = path.join(BRANDING_DIR, safeName);
|
||||
const filePath = path.join(getBrandingDir(), safeName);
|
||||
|
||||
// Ensure branding directory exists
|
||||
if (!existsSync(BRANDING_DIR)) {
|
||||
await mkdir(BRANDING_DIR, { recursive: true });
|
||||
if (!existsSync(getBrandingDir())) {
|
||||
await mkdir(getBrandingDir(), { recursive: true });
|
||||
}
|
||||
|
||||
// Write file to disk
|
||||
@@ -125,7 +128,7 @@ export async function DELETE(request: NextRequest) {
|
||||
const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico'];
|
||||
let removed = false;
|
||||
for (const ext of possibleExts) {
|
||||
const filePath = path.join(BRANDING_DIR, `${slot}${ext}`);
|
||||
const filePath = path.join(getBrandingDir(), `${slot}${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
type BrandingSlot =
|
||||
| 'faviconUrl'
|
||||
| 'appLogoLightUrl'
|
||||
| 'appLogoDarkUrl'
|
||||
| 'loginLogoLightUrl'
|
||||
| 'loginLogoDarkUrl';
|
||||
|
||||
function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'config' | 'setConfig' | 'onNext' | 'onBack'>) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handle(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Only send fields the operator actually filled in. Saving an empty
|
||||
// string would create an admin override that shadows the system
|
||||
// default — a blank "Login logo" field would suppress the default
|
||||
// Bulwark logo on the login page, which is never what we want from
|
||||
// the wizard.
|
||||
// Only send fields with a value. Empty strings would create an admin
|
||||
// override that shadows the system default and suppress the bundled
|
||||
// Bulwark logo on the login page.
|
||||
const allFields = {
|
||||
faviconUrl: config.faviconUrl,
|
||||
appLogoLightUrl: config.appLogoLightUrl,
|
||||
@@ -1041,29 +1047,57 @@ function BrandingStep({ config, setConfig, onNext, onBack }: Pick<StepProps, 'co
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Input value={config.loginCompanyName} onChange={(v) => setConfig({ ...config, loginCompanyName: v })} />
|
||||
</Field>
|
||||
<Field label="Favicon URL" hint="SVG recommended. Absolute URL or path under /public.">
|
||||
<Input value={config.faviconUrl} onChange={(v) => setConfig({ ...config, faviconUrl: v })} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Login logo (light)">
|
||||
<Input value={config.loginLogoLightUrl} onChange={(v) => setConfig({ ...config, loginLogoLightUrl: v })} />
|
||||
</Field>
|
||||
<Field label="Login logo (dark)">
|
||||
<Input value={config.loginLogoDarkUrl} onChange={(v) => setConfig({ ...config, loginLogoDarkUrl: v })} />
|
||||
</Field>
|
||||
<Field label="Sidebar logo (light)">
|
||||
<Input value={config.appLogoLightUrl} onChange={(v) => setConfig({ ...config, appLogoLightUrl: v })} />
|
||||
</Field>
|
||||
<Field label="Sidebar logo (dark)">
|
||||
<Input value={config.appLogoDarkUrl} onChange={(v) => setConfig({ ...config, appLogoDarkUrl: v })} />
|
||||
</Field>
|
||||
|
||||
<div className="space-y-2">
|
||||
<BrandingAsset
|
||||
label="Favicon"
|
||||
hint="Browser tab icon. SVG recommended."
|
||||
slot="faviconUrl"
|
||||
value={config.faviconUrl}
|
||||
onChange={(v) => setConfig({ ...config, faviconUrl: v })}
|
||||
/>
|
||||
<BrandingAsset
|
||||
label="Login logo (light mode)"
|
||||
hint="Shown on the sign-in page, light backgrounds."
|
||||
slot="loginLogoLightUrl"
|
||||
value={config.loginLogoLightUrl}
|
||||
onChange={(v) => setConfig({ ...config, loginLogoLightUrl: v })}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<Field label="Website URL">
|
||||
<Input value={config.loginWebsiteUrl} onChange={(v) => setConfig({ ...config, loginWebsiteUrl: v })} type="url" />
|
||||
</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 ─────────────────────────────────────────────────
|
||||
|
||||
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.startsWith("/_next/") ||
|
||||
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);
|
||||
|
||||
if (!allowed) {
|
||||
|
||||
Reference in New Issue
Block a user