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"],
|
||||
|
||||
@@ -239,7 +239,6 @@ ENV PORT=1349 \
|
||||
U2NET_HOME=/data/ai/models/rembg \
|
||||
DEFAULT_THEME=light \
|
||||
DEFAULT_LOCALE=en \
|
||||
APP_NAME="snapotter" \
|
||||
FILE_MAX_AGE_HOURS=72 \
|
||||
CLEANUP_INTERVAL_MINUTES=60 \
|
||||
MAX_UPLOAD_SIZE_MB=0 \
|
||||
@@ -253,7 +252,6 @@ ENV PORT=1349 \
|
||||
MAX_PIPELINE_STEPS=0 \
|
||||
MAX_CANVAS_PIXELS=0 \
|
||||
MAX_SVG_SIZE_MB=0 \
|
||||
MAX_LOGO_SIZE_KB=2048 \
|
||||
MAX_SPLIT_GRID=100 \
|
||||
MAX_PDF_PAGES=0 \
|
||||
SESSION_DURATION_HOURS=168 \
|
||||
|
||||
@@ -193,7 +193,6 @@ export const en = {
|
||||
sidebarView: "Sidebar",
|
||||
fullscreenView: "Fullscreen Grid",
|
||||
version: "Version",
|
||||
appName: "App Name",
|
||||
uploadLimit: "File Upload Limit",
|
||||
defaultTheme: "Default Theme",
|
||||
defaultLocale: "Language",
|
||||
@@ -247,14 +246,6 @@ export const en = {
|
||||
startupCleanup: "Startup Cleanup",
|
||||
startupCleanupDescription: "Clean up old temporary files when the server starts",
|
||||
},
|
||||
branding: {
|
||||
logo: "Custom Logo",
|
||||
logoDescription: "Upload a custom logo for the sidebar and navbar",
|
||||
uploadLogo: "Upload Logo",
|
||||
removeLogo: "Remove Logo",
|
||||
logoRequirements: "PNG, SVG, or JPEG. Max 500KB.",
|
||||
dragDrop: "Drag and drop or click to upload",
|
||||
},
|
||||
limitsAndResources: "Limits & Resources",
|
||||
maxFileSize: "Max File Size",
|
||||
maxBatchSize: "Max Batch Size",
|
||||
@@ -269,7 +260,6 @@ export const en = {
|
||||
envOverride: "Set by environment variable",
|
||||
maxCanvasPixels: "Max Canvas Pixels",
|
||||
maxSvgSize: "Max SVG Size",
|
||||
maxLogoSize: "Max Logo Size",
|
||||
maxSplitGrid: "Max Split Grid",
|
||||
maxPdfPages: "Max PDF Pages",
|
||||
sessionDuration: "Session Duration",
|
||||
|
||||
@@ -10,7 +10,6 @@ export type Permission =
|
||||
| "settings:write"
|
||||
| "users:manage"
|
||||
| "teams:manage"
|
||||
| "branding:manage"
|
||||
| "features:manage"
|
||||
| "system:health"
|
||||
| "audit:read";
|
||||
|
||||
@@ -122,15 +122,6 @@ test.describe("GUI Settings - General Tab", () => {
|
||||
});
|
||||
|
||||
test.describe("GUI Settings - System Settings Tab", () => {
|
||||
test("shows App Name input", async ({ loggedInPage: page }) => {
|
||||
await openSettings(page);
|
||||
await page.getByRole("button", { name: /system settings/i }).click();
|
||||
|
||||
await expect(page.getByText("App Name")).toBeVisible();
|
||||
const appNameInput = page.locator("input[type='text']").first();
|
||||
await expect(appNameInput).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows File Upload Limit input", async ({ loggedInPage: page }) => {
|
||||
await openSettings(page);
|
||||
await page.getByRole("button", { name: /system settings/i }).click();
|
||||
@@ -167,7 +158,7 @@ test.describe("GUI Settings - System Settings Tab", () => {
|
||||
await page.getByRole("button", { name: /system settings/i }).click();
|
||||
|
||||
// Wait for section to load
|
||||
await expect(page.getByText("App Name")).toBeVisible();
|
||||
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
||||
|
||||
// Click save
|
||||
await page.getByRole("button", { name: /save settings/i }).click();
|
||||
@@ -372,45 +363,3 @@ test.describe("GUI Settings - Product Analytics Tab (deep)", () => {
|
||||
expect(toggleVisible || disabledVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("GUI Settings - Save and Persistence", () => {
|
||||
test("System Settings save persists after dialog close and reopen", async ({
|
||||
loggedInPage: page,
|
||||
}) => {
|
||||
// Navigate to System Settings tab
|
||||
await openSettings(page);
|
||||
await page.getByRole("button", { name: /system settings/i }).click();
|
||||
await expect(page.getByText("App Name")).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Wait for the input to load
|
||||
const appNameInput = page.locator("input[type='text']").first();
|
||||
await expect(appNameInput).toBeVisible();
|
||||
const originalName = await appNameInput.inputValue();
|
||||
const testName = originalName === "SnapOtter" ? "TestApp" : "SnapOtter";
|
||||
|
||||
// Change the App Name
|
||||
await appNameInput.fill(testName);
|
||||
|
||||
// Save
|
||||
await page.getByRole("button", { name: /save settings/i }).click();
|
||||
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Close the dialog
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("h2").filter({ hasText: "Settings" })).not.toBeVisible();
|
||||
|
||||
// Reopen the dialog and go to System Settings
|
||||
await openSettings(page);
|
||||
await page.getByRole("button", { name: /system settings/i }).click();
|
||||
await expect(page.getByText("App Name")).toBeVisible();
|
||||
|
||||
// Verify the setting persisted
|
||||
const persistedName = await page.locator("input[type='text']").first().inputValue();
|
||||
expect(persistedName).toBe(testName);
|
||||
|
||||
// Restore original name
|
||||
await page.locator("input[type='text']").first().fill(originalName);
|
||||
await page.getByRole("button", { name: /save settings/i }).click();
|
||||
await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,7 +331,7 @@ base.describe("RBAC Full — Custom Role User", () => {
|
||||
const writeRes = await fetch(`${API}/api/v1/settings`, {
|
||||
method: "PUT",
|
||||
headers: authJson(bearerToken),
|
||||
body: JSON.stringify({ appName: "hacked" }),
|
||||
body: JSON.stringify({ testSetting: "hacked" }),
|
||||
});
|
||||
expect(writeRes.status).toBe(403);
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ base.describe("RBAC - User sees restricted tabs", () => {
|
||||
const settingsRes = await fetch(`${API}/api/v1/settings`, {
|
||||
method: "PUT",
|
||||
headers: authJson(bearerToken),
|
||||
body: JSON.stringify({ appName: "hacked" }),
|
||||
body: JSON.stringify({ testSetting: "hacked" }),
|
||||
});
|
||||
expect(settingsRes.status).toBe(403);
|
||||
});
|
||||
@@ -280,7 +280,7 @@ base.describe("RBAC - Editor sees collaborative tabs", () => {
|
||||
const settingsRes = await fetch(`${API}/api/v1/settings`, {
|
||||
method: "PUT",
|
||||
headers: authJson(token as string),
|
||||
body: JSON.stringify({ appName: "hacked" }),
|
||||
body: JSON.stringify({ testSetting: "hacked" }),
|
||||
});
|
||||
expect(settingsRes.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("API key permission scoping", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${apiKey}` },
|
||||
payload: { appName: "hacked" },
|
||||
payload: { testSetting: "hacked" },
|
||||
});
|
||||
expect(writeRes.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
@@ -1452,7 +1452,7 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { app_name: "<script>alert('xss')</script>" },
|
||||
payload: { test_setting: "<script>alert('xss')</script>" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
@@ -1500,7 +1500,7 @@ describe("Settings", () => {
|
||||
method: "PUT",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { app_name: "My App (v2.0) - Production" },
|
||||
payload: { test_setting: "My App (v2.0) - Production" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
|
||||
@@ -1,533 +0,0 @@
|
||||
/**
|
||||
* Integration tests for the Logo Branding API.
|
||||
*
|
||||
* 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 sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
// Helper: create a small test PNG
|
||||
async function makeTestPng(width = 10, height = 10): Promise<Buffer> {
|
||||
return sharp({
|
||||
create: { width, height, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// Helper: create a test JPEG
|
||||
async function makeTestJpeg(width = 10, height = 10): Promise<Buffer> {
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: { r: 0, g: 255, b: 0 } },
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// Helper: create a simple SVG buffer
|
||||
function makeTestSvg(): Buffer {
|
||||
return Buffer.from(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect fill="blue" width="64" height="64"/></svg>',
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// POST /api/v1/settings/logo
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe("POST /api/v1/settings/logo", () => {
|
||||
it("uploads a PNG logo and stores it", async () => {
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("uploads a JPEG and converts to PNG", async () => {
|
||||
const jpeg = await makeTestJpeg();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.jpg", contentType: "image/jpeg", content: jpeg },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// Verify it was converted — GET should return PNG
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(getRes.headers["content-type"]).toBe("image/png");
|
||||
});
|
||||
|
||||
it("uploads an SVG and converts to PNG", async () => {
|
||||
const svg = makeTestSvg();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.svg", contentType: "image/svg+xml", content: svg },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects files over 500KB", async () => {
|
||||
// Create an image that's just over 500KB but under the multipart limit (10MB)
|
||||
// A 500x500 uncompressed PNG with 4 channels is ~1MB
|
||||
const largePng = await sharp({
|
||||
create: {
|
||||
width: 500,
|
||||
height: 500,
|
||||
channels: 4,
|
||||
background: { r: 128, g: 64, b: 32, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer();
|
||||
|
||||
// Ensure it's actually over 500KB
|
||||
expect(largePng.length).toBeGreaterThan(500 * 1024);
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "big.png", contentType: "image/png", content: largePng },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/500KB/i);
|
||||
});
|
||||
|
||||
it("rejects non-image files", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "data.txt",
|
||||
contentType: "text/plain",
|
||||
content: Buffer.from("not an image"),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/image/i);
|
||||
});
|
||||
|
||||
it("requires admin role", async () => {
|
||||
// Register a non-admin user
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { username: "branduser", password: "Testpass1", role: "user" },
|
||||
});
|
||||
|
||||
// Login as non-admin
|
||||
const loginRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username: "branduser", password: "Testpass1" },
|
||||
});
|
||||
|
||||
// Clear mustChangePassword
|
||||
const { token } = JSON.parse(loginRes.body);
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/change-password",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { currentPassword: "Testpass1", newPassword: "Newpass123" },
|
||||
});
|
||||
|
||||
// Login again with new password
|
||||
const loginRes2 = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username: "branduser", password: "Newpass123" },
|
||||
});
|
||||
const userToken = JSON.parse(loginRes2.body).token;
|
||||
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${userToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("resizes large images to max 128x128", async () => {
|
||||
const png = await makeTestPng(256, 256);
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "big-logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
// Fetch and check dimensions
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const metadata = await sharp(getRes.rawPayload).metadata();
|
||||
expect(metadata.width).toBeLessThanOrEqual(128);
|
||||
expect(metadata.height).toBeLessThanOrEqual(128);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Settings state verification (customLogo flag)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe("customLogo setting reflects actual state", () => {
|
||||
it("sets customLogo to true after successful upload", async () => {
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
const uploadRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
expect(uploadRes.statusCode).toBe(200);
|
||||
|
||||
const settingsRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const { settings } = JSON.parse(settingsRes.body);
|
||||
expect(settings.customLogo).toBe("true");
|
||||
});
|
||||
|
||||
it("does not set customLogo to true when upload is rejected (oversized)", async () => {
|
||||
// First ensure no logo exists
|
||||
await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const largePng = await sharp({
|
||||
create: { width: 500, height: 500, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer();
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "big.png", contentType: "image/png", content: largePng },
|
||||
]);
|
||||
|
||||
const uploadRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
expect(uploadRes.statusCode).toBe(400);
|
||||
|
||||
const settingsRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const { settings } = JSON.parse(settingsRes.body);
|
||||
expect(settings.customLogo).not.toBe("true");
|
||||
});
|
||||
|
||||
it("sets customLogo to false after deletion", async () => {
|
||||
// Upload first
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
// Delete
|
||||
await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const settingsRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const { settings } = JSON.parse(settingsRes.body);
|
||||
expect(settings.customLogo).toBe("false");
|
||||
});
|
||||
|
||||
it("returns 400 with clear error when no file is attached", async () => {
|
||||
const { body, contentType } = createMultipartPayload([]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/no file/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/v1/settings/logo
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe("GET /api/v1/settings/logo", () => {
|
||||
it("returns 404 when no custom logo is set", async () => {
|
||||
// First delete any existing logo
|
||||
await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the logo with image/png content-type after upload", async () => {
|
||||
// Upload a logo first
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("image/png");
|
||||
// Verify it's valid PNG data
|
||||
const metadata = await sharp(res.rawPayload).metadata();
|
||||
expect(metadata.format).toBe("png");
|
||||
});
|
||||
|
||||
it("works without authentication (public path)", async () => {
|
||||
// Upload a logo first (as admin)
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
// GET without any auth header
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["content-type"]).toBe("image/png");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DELETE /api/v1/settings/logo
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe("DELETE /api/v1/settings/logo", () => {
|
||||
it("removes the custom logo", async () => {
|
||||
// Upload first
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
// Delete
|
||||
const delRes = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(delRes.statusCode).toBe(200);
|
||||
expect(JSON.parse(delRes.body)).toEqual({ ok: true });
|
||||
|
||||
// Verify logo is gone
|
||||
const getRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings/logo",
|
||||
});
|
||||
|
||||
expect(getRes.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("requires admin role", async () => {
|
||||
// Login as non-admin (created in earlier test)
|
||||
const loginRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username: "branduser", password: "Newpass123" },
|
||||
});
|
||||
const userToken = JSON.parse(loginRes.body).token;
|
||||
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${userToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("returns ok even if no logo exists (idempotent)", async () => {
|
||||
// Ensure no logo exists
|
||||
await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
// Delete again
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body)).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -243,15 +243,6 @@ const routes: RouteTest[] = [
|
||||
unauth: 401,
|
||||
label: "system:health",
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
admin: 200,
|
||||
editor: 403,
|
||||
user: 403,
|
||||
unauth: 401,
|
||||
label: "branding:manage (delete logo)",
|
||||
},
|
||||
];
|
||||
|
||||
describe("RBAC route permission matrix (full)", () => {
|
||||
@@ -320,7 +311,6 @@ describe("Cross-role isolation", () => {
|
||||
expect(body.user.permissions).not.toContain("settings:write");
|
||||
expect(body.user.permissions).not.toContain("users:manage");
|
||||
expect(body.user.permissions).not.toContain("teams:manage");
|
||||
expect(body.user.permissions).not.toContain("branding:manage");
|
||||
expect(body.user.permissions).not.toContain("features:manage");
|
||||
expect(body.user.permissions).not.toContain("system:health");
|
||||
expect(body.user.permissions).not.toContain("audit:read");
|
||||
|
||||
@@ -36,7 +36,6 @@ import { analyticsRoutes } from "../../apps/api/src/routes/analytics.js";
|
||||
import { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js";
|
||||
import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js";
|
||||
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.js";
|
||||
import { brandingRoutes } from "../../apps/api/src/routes/branding.js";
|
||||
import { docsRoutes } from "../../apps/api/src/routes/docs.js";
|
||||
import { fileRoutes } from "../../apps/api/src/routes/files.js";
|
||||
import { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js";
|
||||
@@ -109,9 +108,6 @@ export async function buildTestApp(): Promise<TestApp> {
|
||||
// Settings routes
|
||||
await settingsRoutes(app);
|
||||
|
||||
// Branding routes
|
||||
await brandingRoutes(app);
|
||||
|
||||
// Teams routes
|
||||
await teamsRoutes(app);
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ describe("hasEffectivePermission", () => {
|
||||
expect(hasEffectivePermission(editor, "users:manage")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "settings:write")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "teams:manage")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "branding:manage")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "features:manage")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "system:health")).toBe(false);
|
||||
expect(hasEffectivePermission(editor, "audit:read")).toBe(false);
|
||||
@@ -185,8 +184,8 @@ describe("hasEffectivePermission", () => {
|
||||
|
||||
describe("getPermissions", () => {
|
||||
describe("exact counts for built-in roles", () => {
|
||||
it("admin has exactly 15 permissions", () => {
|
||||
expect(getPermissions("admin")).toHaveLength(15);
|
||||
it("admin has exactly 14 permissions", () => {
|
||||
expect(getPermissions("admin")).toHaveLength(14);
|
||||
});
|
||||
|
||||
it("editor has exactly 7 permissions", () => {
|
||||
|
||||
@@ -19,9 +19,9 @@ import { getPermissions, hasPermission } from "../../../apps/api/src/permissions
|
||||
|
||||
describe("permissions", () => {
|
||||
describe("getPermissions", () => {
|
||||
it("returns all 15 permissions for admin", () => {
|
||||
it("returns all 14 permissions for admin", () => {
|
||||
const perms = getPermissions("admin");
|
||||
expect(perms).toHaveLength(15);
|
||||
expect(perms).toHaveLength(14);
|
||||
expect(perms).toContain("tools:use");
|
||||
expect(perms).toContain("files:own");
|
||||
expect(perms).toContain("files:all");
|
||||
@@ -33,7 +33,6 @@ describe("permissions", () => {
|
||||
expect(perms).toContain("settings:write");
|
||||
expect(perms).toContain("users:manage");
|
||||
expect(perms).toContain("teams:manage");
|
||||
expect(perms).toContain("branding:manage");
|
||||
expect(perms).toContain("features:manage");
|
||||
expect(perms).toContain("system:health");
|
||||
expect(perms).toContain("audit:read");
|
||||
@@ -58,7 +57,6 @@ describe("permissions", () => {
|
||||
expect(perms).not.toContain("settings:write");
|
||||
expect(perms).not.toContain("users:manage");
|
||||
expect(perms).not.toContain("teams:manage");
|
||||
expect(perms).not.toContain("branding:manage");
|
||||
});
|
||||
|
||||
it("returns empty array for unknown role", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { getPermissions, hasPermission } from "../../../apps/api/src/permissions.js";
|
||||
|
||||
describe("role permissions", () => {
|
||||
it("admin has all 15 permissions", () => {
|
||||
it("admin has all 14 permissions", () => {
|
||||
const perms = getPermissions("admin");
|
||||
expect(perms).toContain("tools:use");
|
||||
expect(perms).toContain("files:all");
|
||||
@@ -10,7 +10,7 @@ describe("role permissions", () => {
|
||||
expect(perms).toContain("features:manage");
|
||||
expect(perms).toContain("system:health");
|
||||
expect(perms).toContain("audit:read");
|
||||
expect(perms.length).toBe(15);
|
||||
expect(perms.length).toBe(14);
|
||||
});
|
||||
|
||||
it("editor has collaborative but not admin permissions", () => {
|
||||
|
||||
@@ -752,7 +752,6 @@ describe("loadEnv", () => {
|
||||
"WORKSPACE_PATH",
|
||||
"DEFAULT_THEME",
|
||||
"DEFAULT_LOCALE",
|
||||
"APP_NAME",
|
||||
];
|
||||
for (const key of keysToClean) {
|
||||
delete process.env[key];
|
||||
@@ -794,7 +793,6 @@ describe("loadEnv", () => {
|
||||
expect(typeof env.WORKSPACE_PATH).toBe("string");
|
||||
expect(["light", "dark"]).toContain(env.DEFAULT_THEME);
|
||||
expect(typeof env.DEFAULT_LOCALE).toBe("string");
|
||||
expect(typeof env.APP_NAME).toBe("string");
|
||||
});
|
||||
|
||||
it("parses custom PORT as a number via coercion", async () => {
|
||||
@@ -862,12 +860,10 @@ describe("loadEnv", () => {
|
||||
});
|
||||
|
||||
it("accepts string values for string fields", async () => {
|
||||
process.env.APP_NAME = "My Custom App";
|
||||
process.env.DB_PATH = "/var/data/mydb.sqlite";
|
||||
process.env.DEFAULT_LOCALE = "fr";
|
||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||
const env = loadEnv();
|
||||
expect(env.APP_NAME).toBe("My Custom App");
|
||||
expect(env.DB_PATH).toBe("/var/data/mydb.sqlite");
|
||||
expect(env.DEFAULT_LOCALE).toBe("fr");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user