mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: remove app name and logo customization feature
Users can no longer customize the app name or logo. The branding API endpoints, permission, frontend UI, env vars (APP_NAME, MAX_LOGO_SIZE_KB), and all related tests are removed. Includes a migration to clean up branding data from existing databases.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
-- Remove branding:manage from built-in admin role permissions
|
||||
UPDATE `roles`
|
||||
SET `permissions` = REPLACE(`permissions`, '"branding:manage",', ''),
|
||||
`updated_at` = unixepoch()
|
||||
WHERE `id` = 'builtin-admin';
|
||||
--> statement-breakpoint
|
||||
-- Remove branding:manage from any custom roles that have it
|
||||
UPDATE `roles`
|
||||
SET `permissions` = REPLACE(REPLACE(`permissions`, ',"branding:manage"', ''), '"branding:manage",', ''),
|
||||
`updated_at` = unixepoch()
|
||||
WHERE `id` != 'builtin-admin' AND `permissions` LIKE '%branding:manage%';
|
||||
--> statement-breakpoint
|
||||
-- Clean up branding-related settings
|
||||
DELETE FROM `settings` WHERE `key` IN ('customLogo', 'appName');
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1776855591000,
|
||||
"tag": "0009_analytics_consent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1778300000000,
|
||||
"tag": "0010_remove_branding",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { analyticsRoutes } from "./routes/analytics.js";
|
||||
import { apiKeyRoutes } from "./routes/api-keys.js";
|
||||
import { auditLogRoutes } from "./routes/audit-log.js";
|
||||
import { registerBatchRoutes } from "./routes/batch.js";
|
||||
import { brandingRoutes } from "./routes/branding.js";
|
||||
import { docsRoutes } from "./routes/docs.js";
|
||||
import { registerFeatureRoutes } from "./routes/features.js";
|
||||
import { fileRoutes } from "./routes/files.js";
|
||||
@@ -180,9 +179,6 @@ await analyticsRoutes(app);
|
||||
// Feature management routes (AI feature bundle install/uninstall)
|
||||
await registerFeatureRoutes(app);
|
||||
|
||||
// Branding routes (logo upload/serve/delete)
|
||||
await brandingRoutes(app);
|
||||
|
||||
// Teams routes
|
||||
await teamsRoutes(app);
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ const envSchema = z.object({
|
||||
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
|
||||
DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"),
|
||||
DEFAULT_LOCALE: z.string().default("en"),
|
||||
APP_NAME: z.string().default("snapotter"),
|
||||
CORS_ORIGIN: z.string().default(""),
|
||||
MAX_USERS: z.coerce.number().default(0),
|
||||
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
|
||||
@@ -35,7 +34,6 @@ const envSchema = z.object({
|
||||
MAX_PIPELINE_STEPS: z.coerce.number().default(0),
|
||||
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
|
||||
MAX_SVG_SIZE_MB: z.coerce.number().default(0),
|
||||
MAX_LOGO_SIZE_KB: z.coerce.number().default(500),
|
||||
MAX_SPLIT_GRID: z.coerce.number().default(100),
|
||||
MAX_PDF_PAGES: z.coerce.number().default(0),
|
||||
SESSION_DURATION_HOURS: z.coerce.number().default(168),
|
||||
|
||||
@@ -39,8 +39,6 @@ tags:
|
||||
description: System-wide configuration (admin only for writes).
|
||||
- name: Teams
|
||||
description: Organize users into teams.
|
||||
- name: Branding
|
||||
description: Custom logo management.
|
||||
- name: Roles
|
||||
description: Custom role management with fine-grained permissions.
|
||||
- name: Audit
|
||||
@@ -5764,96 +5762,6 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
# ─── Branding ─────────────────────────────────────────────────────────────
|
||||
|
||||
/api/v1/settings/logo:
|
||||
get:
|
||||
tags: [Branding]
|
||||
summary: Get custom logo
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: Logo image
|
||||
content:
|
||||
image/*:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
"404":
|
||||
description: No custom logo set
|
||||
post:
|
||||
tags: [Branding]
|
||||
summary: Upload custom logo (admin)
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [logo]
|
||||
properties:
|
||||
logo:
|
||||
type: string
|
||||
format: binary
|
||||
description: Logo image file
|
||||
responses:
|
||||
"200":
|
||||
description: Logo uploaded
|
||||
"400":
|
||||
description: Invalid input
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
description: Authentication required
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UnauthorizedError"
|
||||
"403":
|
||||
description: Admin access required
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ForbiddenError"
|
||||
delete:
|
||||
tags: [Branding]
|
||||
summary: Remove custom logo
|
||||
description: Requires branding:manage permission.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Logo removed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
"400":
|
||||
description: Invalid input
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
description: Authentication required
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UnauthorizedError"
|
||||
"403":
|
||||
description: Admin access required
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ForbiddenError"
|
||||
|
||||
# ─── System (additional) ──────────────────────────────────────────────────
|
||||
|
||||
/api/v1/config/auth:
|
||||
@@ -6417,7 +6325,7 @@ paths:
|
||||
Permission strings. Valid values: tools:use, files:own, files:all,
|
||||
apikeys:own, apikeys:all, pipelines:own, pipelines:all,
|
||||
settings:read, settings:write, users:manage, teams:manage,
|
||||
branding:manage, features:manage, system:health, audit:read
|
||||
features:manage, system:health, audit:read
|
||||
responses:
|
||||
"201":
|
||||
description: Role created
|
||||
|
||||
@@ -17,7 +17,6 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
"settings:write",
|
||||
"users:manage",
|
||||
"teams:manage",
|
||||
"branding:manage",
|
||||
"features:manage",
|
||||
"system:health",
|
||||
"audit:read",
|
||||
|
||||
@@ -763,7 +763,6 @@ const PUBLIC_PATHS = [
|
||||
"/api/auth/",
|
||||
"/api/v1/download/",
|
||||
"/api/v1/jobs/",
|
||||
"/api/v1/settings/logo",
|
||||
"/api/docs",
|
||||
"/api/v1/openapi.yaml",
|
||||
];
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Branding routes — custom logo upload, serving, and deletion.
|
||||
*
|
||||
* POST /api/v1/settings/logo — Upload logo (admin only)
|
||||
* GET /api/v1/settings/logo — Serve custom logo as PNG (public)
|
||||
* DELETE /api/v1/settings/logo — Remove custom logo (admin only)
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { ensureSharpCompat } from "../lib/heic-converter.js";
|
||||
import { requirePermission } from "../permissions.js";
|
||||
|
||||
const BRANDING_DIR = join(env.FILES_STORAGE_PATH, "branding");
|
||||
const LOGO_PATH = join(BRANDING_DIR, "logo.png");
|
||||
const maxLogoSize = env.MAX_LOGO_SIZE_KB * 1024;
|
||||
|
||||
function upsertSetting(key: string, value: string): void {
|
||||
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
|
||||
if (existing) {
|
||||
db.update(schema.settings)
|
||||
.set({ value, updatedAt: new Date() })
|
||||
.where(eq(schema.settings.key, key))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(schema.settings).values({ key, value }).run();
|
||||
}
|
||||
}
|
||||
|
||||
export async function brandingRoutes(app: FastifyInstance): Promise<void> {
|
||||
// POST /api/v1/settings/logo — Upload logo (admin only)
|
||||
app.post("/api/v1/settings/logo", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = requirePermission("branding:manage")(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
const file = await request.file();
|
||||
if (!file) {
|
||||
return reply.status(400).send({ error: "No file uploaded", code: "VALIDATION_ERROR" });
|
||||
}
|
||||
|
||||
// Validate mimetype
|
||||
if (!file.mimetype.startsWith("image/")) {
|
||||
return reply.status(400).send({ error: "File must be an image", code: "VALIDATION_ERROR" });
|
||||
}
|
||||
|
||||
// Read file buffer
|
||||
const buffer = await file.toBuffer();
|
||||
|
||||
// Validate size
|
||||
if (buffer.length > maxLogoSize) {
|
||||
return reply.status(400).send({
|
||||
error: `Logo must be ${env.MAX_LOGO_SIZE_KB}KB or smaller`,
|
||||
code: "VALIDATION_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
// Decode HEIC/HEIF if needed, then convert to PNG, resize to max 128x128
|
||||
const compatBuffer = await ensureSharpCompat(buffer);
|
||||
const pngBuffer = await sharp(compatBuffer)
|
||||
.resize(128, 128, { fit: "inside", withoutEnlargement: true })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// Ensure branding directory exists
|
||||
mkdirSync(BRANDING_DIR, { recursive: true });
|
||||
|
||||
// Write file
|
||||
writeFileSync(LOGO_PATH, pngBuffer);
|
||||
|
||||
// Upsert setting
|
||||
upsertSetting("customLogo", "true");
|
||||
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/v1/settings/logo — Serve logo (public, no auth required)
|
||||
app.get("/api/v1/settings/logo", async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!existsSync(LOGO_PATH)) {
|
||||
return reply.status(404).send({ error: "No custom logo set", code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
const logoBuffer = readFileSync(LOGO_PATH);
|
||||
return reply.type("image/png").send(logoBuffer);
|
||||
});
|
||||
|
||||
// DELETE /api/v1/settings/logo — Remove logo (admin only)
|
||||
app.delete("/api/v1/settings/logo", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const admin = requirePermission("branding:manage")(request, reply);
|
||||
if (!admin) return;
|
||||
|
||||
if (existsSync(LOGO_PATH)) {
|
||||
unlinkSync(LOGO_PATH);
|
||||
}
|
||||
|
||||
upsertSetting("customLogo", "false");
|
||||
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
app.log.info("Branding routes registered");
|
||||
}
|
||||
@@ -19,7 +19,6 @@ const ALL_PERMISSIONS: Permission[] = [
|
||||
"settings:write",
|
||||
"users:manage",
|
||||
"teams:manage",
|
||||
"branding:manage",
|
||||
"features:manage",
|
||||
"system:health",
|
||||
"audit:read",
|
||||
|
||||
+2
-10
@@ -312,14 +312,6 @@ To auto-save a tool result to the library, include `fileId` in the settings payl
|
||||
| `PUT` | `/api/v1/teams/:id` | Admin (`teams:manage`) | Rename team |
|
||||
| `DELETE` | `/api/v1/teams/:id` | Admin (`teams:manage`) | Delete team (cannot delete default team or teams with members) |
|
||||
|
||||
## Branding
|
||||
|
||||
| Method | Path | Access | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `POST` | `/api/v1/settings/logo` | Admin | Upload custom logo (max 500 KB, converted to 128×128 PNG) |
|
||||
| `GET` | `/api/v1/settings/logo` | Public | Serve current logo |
|
||||
| `DELETE` | `/api/v1/settings/logo` | Admin | Remove custom logo |
|
||||
|
||||
## Settings
|
||||
|
||||
Runtime key-value configuration (read by any authenticated user, write by admin only).
|
||||
@@ -330,7 +322,7 @@ Runtime key-value configuration (read by any authenticated user, write by admin
|
||||
| `PUT` | `/api/v1/settings` | Bulk update settings (JSON body with key-value pairs) |
|
||||
| `GET` | `/api/v1/settings/:key` | Get a specific setting by key |
|
||||
|
||||
Known keys: `disabledTools` (JSON array of tool IDs), `enableExperimentalTools` (bool string), `loginAttemptLimit` (number), `customLogo` (managed via branding endpoint).
|
||||
Known keys: `disabledTools` (JSON array of tool IDs), `enableExperimentalTools` (bool string), `loginAttemptLimit` (number).
|
||||
|
||||
## Roles
|
||||
|
||||
@@ -343,7 +335,7 @@ Custom role management with granular permissions.
|
||||
| `PUT` | `/api/v1/roles/:id` | Admin (`users:manage`) | Update a custom role (cannot modify built-in roles) |
|
||||
| `DELETE` | `/api/v1/roles/:id` | Admin (`users:manage`) | Delete a custom role (cannot delete built-in roles; affected users revert to `user` role) |
|
||||
|
||||
Available permissions: `tools:use`, `files:own`, `files:all`, `apikeys:own`, `apikeys:all`, `pipelines:own`, `pipelines:all`, `settings:read`, `settings:write`, `users:manage`, `teams:manage`, `branding:manage`, `features:manage`, `system:health`, `audit:read`.
|
||||
Available permissions: `tools:use`, `files:own`, `files:all`, `apikeys:own`, `apikeys:all`, `pipelines:own`, `pipelines:all`, `settings:read`, `settings:write`, `users:manage`, `teams:manage`, `features:manage`, `system:health`, `audit:read`.
|
||||
|
||||
## Audit Log
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ All configuration is done through environment variables. Every variable has a se
|
||||
| `MAX_PIPELINE_STEPS` | `0` (no limit) | Maximum number of steps in a pipeline. Set to 0 for no limit. |
|
||||
| `MAX_CANVAS_PIXELS` | `0` (no limit) | Maximum canvas size in pixels for output images. Set to 0 for no limit. |
|
||||
| `MAX_SVG_SIZE_MB` | `0` (unlimited) | Maximum SVG file size in megabytes. Set to 0 for unlimited. |
|
||||
| `MAX_LOGO_SIZE_KB` | `500` | Maximum custom branding logo size in kilobytes. |
|
||||
| `MAX_SPLIT_GRID` | `100` | Maximum grid dimension for the image split tool. |
|
||||
| `MAX_PDF_PAGES` | `0` (unlimited) | Maximum number of PDF pages for PDF-to-image conversion. Set to 0 for unlimited. |
|
||||
|
||||
@@ -62,7 +61,6 @@ All configuration is done through environment variables. Every variable has a se
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `APP_NAME` | `SnapOtter` | Display name shown in the UI. |
|
||||
| `DEFAULT_THEME` | `light` | Default theme for new sessions. `light` or `dark`. |
|
||||
| `DEFAULT_LOCALE` | `en` | Default interface language. |
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { FolderOpen, LayoutGrid, Menu, Settings as SettingsIcon, Workflow, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectionStore } from "@/stores/connection-store";
|
||||
import { Dropzone } from "../common/dropzone";
|
||||
@@ -25,16 +24,9 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const isMobile = useMobile();
|
||||
const [customLogo, setCustomLogo] = useState(false);
|
||||
const connectionStatus = useConnectionStore((s) => s.status);
|
||||
const bannerVisible = connectionStatus !== "connected";
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
||||
.then((data) => setCustomLogo(data.settings.customLogo === "true"))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -47,7 +39,6 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
<Sidebar
|
||||
onSettingsClick={() => setSettingsOpen(true)}
|
||||
onHelpClick={() => setHelpOpen(true)}
|
||||
customLogo={customLogo}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -61,20 +52,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
/>
|
||||
<div className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left">
|
||||
<div className="flex items-center justify-between p-3 border-b border-border">
|
||||
{customLogo ? (
|
||||
<img
|
||||
src="/api/v1/settings/logo"
|
||||
className="h-6 w-6 rounded object-contain"
|
||||
alt="Logo"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<OtterLogo className="h-5 w-5 text-primary" />
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
<span className="text-primary">SnapOtter</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<OtterLogo className="h-5 w-5 text-primary" />
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
<span className="text-primary">SnapOtter</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
@@ -114,20 +97,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
{customLogo ? (
|
||||
<img
|
||||
src="/api/v1/settings/logo"
|
||||
className="h-6 w-6 rounded object-contain"
|
||||
alt="Logo"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<OtterLogo className="h-5 w-5 text-primary" />
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
<span className="text-primary">SnapOtter</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<OtterLogo className="h-5 w-5 text-primary" />
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
<span className="text-primary">SnapOtter</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -29,8 +29,6 @@ interface SidebarProps {
|
||||
onNavClick?: () => void;
|
||||
/** When true, renders in expanded mode (for mobile overlay). */
|
||||
expanded?: boolean;
|
||||
/** Whether a custom logo is set. */
|
||||
customLogo?: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -38,7 +36,6 @@ export function Sidebar({
|
||||
onHelpClick,
|
||||
onNavClick,
|
||||
expanded = false,
|
||||
customLogo = false,
|
||||
}: SidebarProps) {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -103,11 +100,7 @@ export function Sidebar({
|
||||
return (
|
||||
<aside className="flex flex-col items-center w-16 bg-sidebar border-r border-border py-3 gap-1 shrink-0">
|
||||
<div className="mb-2 flex items-center justify-center">
|
||||
{customLogo ? (
|
||||
<img src="/api/v1/settings/logo" className="h-8 w-8 rounded object-contain" alt="Logo" />
|
||||
) : (
|
||||
<OtterLogo className="h-7 w-7 text-primary" />
|
||||
)}
|
||||
<OtterLogo className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<div className="border-t border-border w-10 mb-2" />
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api";
|
||||
import { apiDelete, apiGet, apiPost, apiPut, clearToken } from "@/lib/api";
|
||||
import { cn, copyToClipboard } from "@/lib/utils";
|
||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -349,7 +349,6 @@ function SystemSection() {
|
||||
.catch(() => {
|
||||
// Fallback defaults if endpoint not ready
|
||||
setSettings({
|
||||
appName: "SnapOtter",
|
||||
fileUploadLimitMb: "100",
|
||||
defaultTheme: "system",
|
||||
defaultLocale: "en",
|
||||
@@ -381,37 +380,6 @@ function SystemSection() {
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
const res = await fetch("/api/v1/settings/logo", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
setSaveMsg(body?.error || "Failed to upload logo.");
|
||||
return;
|
||||
}
|
||||
setSettings((prev) => ({ ...prev, customLogo: "true" }));
|
||||
} catch {
|
||||
setSaveMsg("Failed to upload logo.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoDelete = async () => {
|
||||
try {
|
||||
await apiDelete("/v1/settings/logo");
|
||||
setSettings((prev) => ({ ...prev, customLogo: "false" }));
|
||||
} catch {
|
||||
setSaveMsg("Failed to delete logo.");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -427,52 +395,6 @@ function SystemSection() {
|
||||
<p className="text-sm text-muted-foreground mt-1">Server-side configuration and limits.</p>
|
||||
</div>
|
||||
|
||||
<SettingRow label="App Name" description="Display name for the application">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.appName || ""}
|
||||
onChange={(e) => updateSetting("appName", e.target.value)}
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Custom Logo"
|
||||
description="Upload a custom logo for the sidebar. PNG, SVG, or JPEG. Max 500KB."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{settings.customLogo === "true" && (
|
||||
<img
|
||||
src="/api/v1/settings/logo"
|
||||
className="w-10 h-10 rounded object-contain"
|
||||
alt="Logo"
|
||||
/>
|
||||
)}
|
||||
<label
|
||||
htmlFor="system-logo-upload"
|
||||
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm cursor-pointer hover:bg-muted transition-colors"
|
||||
>
|
||||
Upload
|
||||
<input
|
||||
id="system-logo-upload"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={handleLogoUpload}
|
||||
/>
|
||||
</label>
|
||||
{settings.customLogo === "true" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogoDelete}
|
||||
className="text-sm text-destructive hover:underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow label="File Upload Limit (MB)" description="Maximum file size per upload">
|
||||
<input
|
||||
type="number"
|
||||
@@ -1756,7 +1678,6 @@ const PERMISSION_GROUPS = [
|
||||
{ label: "Settings", permissions: ["settings:read", "settings:write"] },
|
||||
{ label: "Users", permissions: ["users:manage"] },
|
||||
{ label: "Teams", permissions: ["teams:manage"] },
|
||||
{ label: "Branding", permissions: ["branding:manage"] },
|
||||
{
|
||||
label: "System",
|
||||
permissions: ["features:manage", "system:health", "audit:read"],
|
||||
|
||||
Reference in New Issue
Block a user