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:
SnapOtter
2026-05-07 19:41:30 +08:00
parent 25a1dd7aba
commit 16af9c573a
29 changed files with 52 additions and 977 deletions
+14
View File
@@ -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');
+7
View File
@@ -71,6 +71,13 @@
"when": 1776855591000,
"tag": "0009_analytics_consent",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1778300000000,
"tag": "0010_remove_branding",
"breakpoints": true
}
]
}
-4
View File
@@ -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);
-2
View File
@@ -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),
+1 -93
View File
@@ -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
-1
View File
@@ -17,7 +17,6 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"settings:write",
"users:manage",
"teams:manage",
"branding:manage",
"features:manage",
"system:health",
"audit:read",
-1
View File
@@ -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",
];
-106
View File
@@ -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");
}
-1
View File
@@ -19,7 +19,6 @@ const ALL_PERMISSIONS: Permission[] = [
"settings:write",
"users:manage",
"teams:manage",
"branding:manage",
"features:manage",
"system:health",
"audit:read",
+2 -10
View File
@@ -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
-2
View File
@@ -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. |
+13 -38
View File
@@ -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>
)}
+1 -8
View File
@@ -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"],
-2
View File
@@ -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 \
-10
View File
@@ -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",
-1
View File
@@ -10,7 +10,6 @@ export type Permission =
| "settings:write"
| "users:manage"
| "teams:manage"
| "branding:manage"
| "features:manage"
| "system:health"
| "audit:read";
+1 -52
View File
@@ -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 });
});
});
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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);
});
+1 -1
View File
@@ -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);
});
+2 -2
View File
@@ -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);
-533
View File
@@ -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");
-4
View File
@@ -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);
+2 -3
View File
@@ -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", () => {
+2 -4
View File
@@ -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 -2
View File
@@ -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", () => {
-4
View File
@@ -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");
});