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,
|
"when": 1776855591000,
|
||||||
"tag": "0009_analytics_consent",
|
"tag": "0009_analytics_consent",
|
||||||
"breakpoints": true
|
"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 { apiKeyRoutes } from "./routes/api-keys.js";
|
||||||
import { auditLogRoutes } from "./routes/audit-log.js";
|
import { auditLogRoutes } from "./routes/audit-log.js";
|
||||||
import { registerBatchRoutes } from "./routes/batch.js";
|
import { registerBatchRoutes } from "./routes/batch.js";
|
||||||
import { brandingRoutes } from "./routes/branding.js";
|
|
||||||
import { docsRoutes } from "./routes/docs.js";
|
import { docsRoutes } from "./routes/docs.js";
|
||||||
import { registerFeatureRoutes } from "./routes/features.js";
|
import { registerFeatureRoutes } from "./routes/features.js";
|
||||||
import { fileRoutes } from "./routes/files.js";
|
import { fileRoutes } from "./routes/files.js";
|
||||||
@@ -180,9 +179,6 @@ await analyticsRoutes(app);
|
|||||||
// Feature management routes (AI feature bundle install/uninstall)
|
// Feature management routes (AI feature bundle install/uninstall)
|
||||||
await registerFeatureRoutes(app);
|
await registerFeatureRoutes(app);
|
||||||
|
|
||||||
// Branding routes (logo upload/serve/delete)
|
|
||||||
await brandingRoutes(app);
|
|
||||||
|
|
||||||
// Teams routes
|
// Teams routes
|
||||||
await teamsRoutes(app);
|
await teamsRoutes(app);
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const envSchema = z.object({
|
|||||||
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
|
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
|
||||||
DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"),
|
DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"),
|
||||||
DEFAULT_LOCALE: z.string().default("en"),
|
DEFAULT_LOCALE: z.string().default("en"),
|
||||||
APP_NAME: z.string().default("snapotter"),
|
|
||||||
CORS_ORIGIN: z.string().default(""),
|
CORS_ORIGIN: z.string().default(""),
|
||||||
MAX_USERS: z.coerce.number().default(0),
|
MAX_USERS: z.coerce.number().default(0),
|
||||||
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
|
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_PIPELINE_STEPS: z.coerce.number().default(0),
|
||||||
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
|
MAX_CANVAS_PIXELS: z.coerce.number().default(0),
|
||||||
MAX_SVG_SIZE_MB: 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_SPLIT_GRID: z.coerce.number().default(100),
|
||||||
MAX_PDF_PAGES: z.coerce.number().default(0),
|
MAX_PDF_PAGES: z.coerce.number().default(0),
|
||||||
SESSION_DURATION_HOURS: z.coerce.number().default(168),
|
SESSION_DURATION_HOURS: z.coerce.number().default(168),
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ tags:
|
|||||||
description: System-wide configuration (admin only for writes).
|
description: System-wide configuration (admin only for writes).
|
||||||
- name: Teams
|
- name: Teams
|
||||||
description: Organize users into teams.
|
description: Organize users into teams.
|
||||||
- name: Branding
|
|
||||||
description: Custom logo management.
|
|
||||||
- name: Roles
|
- name: Roles
|
||||||
description: Custom role management with fine-grained permissions.
|
description: Custom role management with fine-grained permissions.
|
||||||
- name: Audit
|
- name: Audit
|
||||||
@@ -5764,96 +5762,6 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$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) ──────────────────────────────────────────────────
|
# ─── System (additional) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/api/v1/config/auth:
|
/api/v1/config/auth:
|
||||||
@@ -6417,7 +6325,7 @@ paths:
|
|||||||
Permission strings. Valid values: tools:use, files:own, files:all,
|
Permission strings. Valid values: tools:use, files:own, files:all,
|
||||||
apikeys:own, apikeys:all, pipelines:own, pipelines:all,
|
apikeys:own, apikeys:all, pipelines:own, pipelines:all,
|
||||||
settings:read, settings:write, users:manage, teams:manage,
|
settings:read, settings:write, users:manage, teams:manage,
|
||||||
branding:manage, features:manage, system:health, audit:read
|
features:manage, system:health, audit:read
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: Role created
|
description: Role created
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||||||
"settings:write",
|
"settings:write",
|
||||||
"users:manage",
|
"users:manage",
|
||||||
"teams:manage",
|
"teams:manage",
|
||||||
"branding:manage",
|
|
||||||
"features:manage",
|
"features:manage",
|
||||||
"system:health",
|
"system:health",
|
||||||
"audit:read",
|
"audit:read",
|
||||||
|
|||||||
@@ -763,7 +763,6 @@ const PUBLIC_PATHS = [
|
|||||||
"/api/auth/",
|
"/api/auth/",
|
||||||
"/api/v1/download/",
|
"/api/v1/download/",
|
||||||
"/api/v1/jobs/",
|
"/api/v1/jobs/",
|
||||||
"/api/v1/settings/logo",
|
|
||||||
"/api/docs",
|
"/api/docs",
|
||||||
"/api/v1/openapi.yaml",
|
"/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",
|
"settings:write",
|
||||||
"users:manage",
|
"users:manage",
|
||||||
"teams:manage",
|
"teams:manage",
|
||||||
"branding:manage",
|
|
||||||
"features:manage",
|
"features:manage",
|
||||||
"system:health",
|
"system:health",
|
||||||
"audit:read",
|
"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 |
|
| `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) |
|
| `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
|
## Settings
|
||||||
|
|
||||||
Runtime key-value configuration (read by any authenticated user, write by admin only).
|
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) |
|
| `PUT` | `/api/v1/settings` | Bulk update settings (JSON body with key-value pairs) |
|
||||||
| `GET` | `/api/v1/settings/:key` | Get a specific setting by key |
|
| `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
|
## 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) |
|
| `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) |
|
| `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
|
## 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_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_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_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_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. |
|
| `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 |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `APP_NAME` | `SnapOtter` | Display name shown in the UI. |
|
|
||||||
| `DEFAULT_THEME` | `light` | Default theme for new sessions. `light` or `dark`. |
|
| `DEFAULT_THEME` | `light` | Default theme for new sessions. `light` or `dark`. |
|
||||||
| `DEFAULT_LOCALE` | `en` | Default interface language. |
|
| `DEFAULT_LOCALE` | `en` | Default interface language. |
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { FolderOpen, LayoutGrid, Menu, Settings as SettingsIcon, Workflow, X } from "lucide-react";
|
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 { Link } from "react-router-dom";
|
||||||
import { useMobile } from "@/hooks/use-mobile";
|
import { useMobile } from "@/hooks/use-mobile";
|
||||||
import { apiGet } from "@/lib/api";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useConnectionStore } from "@/stores/connection-store";
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
import { Dropzone } from "../common/dropzone";
|
import { Dropzone } from "../common/dropzone";
|
||||||
@@ -25,16 +24,9 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
|||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
const [customLogo, setCustomLogo] = useState(false);
|
|
||||||
const connectionStatus = useConnectionStore((s) => s.status);
|
const connectionStatus = useConnectionStore((s) => s.status);
|
||||||
const bannerVisible = connectionStatus !== "connected";
|
const bannerVisible = connectionStatus !== "connected";
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
apiGet<{ settings: Record<string, string> }>("/v1/settings")
|
|
||||||
.then((data) => setCustomLogo(data.settings.customLogo === "true"))
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -47,7 +39,6 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
onSettingsClick={() => setSettingsOpen(true)}
|
onSettingsClick={() => setSettingsOpen(true)}
|
||||||
onHelpClick={() => setHelpOpen(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="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">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<OtterLogo className="h-5 w-5 text-primary" />
|
<OtterLogo className="h-5 w-5 text-primary" />
|
||||||
<span className="text-sm font-bold text-foreground">
|
<span className="text-sm font-bold text-foreground">
|
||||||
<span className="text-primary">SnapOtter</span>
|
<span className="text-primary">SnapOtter</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMobileSidebarOpen(false)}
|
onClick={() => setMobileSidebarOpen(false)}
|
||||||
@@ -114,20 +97,12 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
|||||||
>
|
>
|
||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
</button>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<OtterLogo className="h-5 w-5 text-primary" />
|
<OtterLogo className="h-5 w-5 text-primary" />
|
||||||
<span className="text-sm font-bold text-foreground">
|
<span className="text-sm font-bold text-foreground">
|
||||||
<span className="text-primary">SnapOtter</span>
|
<span className="text-primary">SnapOtter</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ interface SidebarProps {
|
|||||||
onNavClick?: () => void;
|
onNavClick?: () => void;
|
||||||
/** When true, renders in expanded mode (for mobile overlay). */
|
/** When true, renders in expanded mode (for mobile overlay). */
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
/** Whether a custom logo is set. */
|
|
||||||
customLogo?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
@@ -38,7 +36,6 @@ export function Sidebar({
|
|||||||
onHelpClick,
|
onHelpClick,
|
||||||
onNavClick,
|
onNavClick,
|
||||||
expanded = false,
|
expanded = false,
|
||||||
customLogo = false,
|
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -103,11 +100,7 @@ export function Sidebar({
|
|||||||
return (
|
return (
|
||||||
<aside className="flex flex-col items-center w-16 bg-sidebar border-r border-border py-3 gap-1 shrink-0">
|
<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">
|
<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>
|
||||||
<div className="border-t border-border w-10 mb-2" />
|
<div className="border-t border-border w-10 mb-2" />
|
||||||
<div className="flex flex-col gap-1 flex-1">
|
<div className="flex flex-col gap-1 flex-1">
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
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 { cn, copyToClipboard } from "@/lib/utils";
|
||||||
import { useAnalyticsStore } from "@/stores/analytics-store";
|
import { useAnalyticsStore } from "@/stores/analytics-store";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
@@ -349,7 +349,6 @@ function SystemSection() {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Fallback defaults if endpoint not ready
|
// Fallback defaults if endpoint not ready
|
||||||
setSettings({
|
setSettings({
|
||||||
appName: "SnapOtter",
|
|
||||||
fileUploadLimitMb: "100",
|
fileUploadLimitMb: "100",
|
||||||
defaultTheme: "system",
|
defaultTheme: "system",
|
||||||
defaultLocale: "en",
|
defaultLocale: "en",
|
||||||
@@ -381,37 +380,6 @@ function SystemSection() {
|
|||||||
}
|
}
|
||||||
}, [settings]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<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>
|
<p className="text-sm text-muted-foreground mt-1">Server-side configuration and limits.</p>
|
||||||
</div>
|
</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">
|
<SettingRow label="File Upload Limit (MB)" description="Maximum file size per upload">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -1756,7 +1678,6 @@ const PERMISSION_GROUPS = [
|
|||||||
{ label: "Settings", permissions: ["settings:read", "settings:write"] },
|
{ label: "Settings", permissions: ["settings:read", "settings:write"] },
|
||||||
{ label: "Users", permissions: ["users:manage"] },
|
{ label: "Users", permissions: ["users:manage"] },
|
||||||
{ label: "Teams", permissions: ["teams:manage"] },
|
{ label: "Teams", permissions: ["teams:manage"] },
|
||||||
{ label: "Branding", permissions: ["branding:manage"] },
|
|
||||||
{
|
{
|
||||||
label: "System",
|
label: "System",
|
||||||
permissions: ["features:manage", "system:health", "audit:read"],
|
permissions: ["features:manage", "system:health", "audit:read"],
|
||||||
|
|||||||
@@ -239,7 +239,6 @@ ENV PORT=1349 \
|
|||||||
U2NET_HOME=/data/ai/models/rembg \
|
U2NET_HOME=/data/ai/models/rembg \
|
||||||
DEFAULT_THEME=light \
|
DEFAULT_THEME=light \
|
||||||
DEFAULT_LOCALE=en \
|
DEFAULT_LOCALE=en \
|
||||||
APP_NAME="snapotter" \
|
|
||||||
FILE_MAX_AGE_HOURS=72 \
|
FILE_MAX_AGE_HOURS=72 \
|
||||||
CLEANUP_INTERVAL_MINUTES=60 \
|
CLEANUP_INTERVAL_MINUTES=60 \
|
||||||
MAX_UPLOAD_SIZE_MB=0 \
|
MAX_UPLOAD_SIZE_MB=0 \
|
||||||
@@ -253,7 +252,6 @@ ENV PORT=1349 \
|
|||||||
MAX_PIPELINE_STEPS=0 \
|
MAX_PIPELINE_STEPS=0 \
|
||||||
MAX_CANVAS_PIXELS=0 \
|
MAX_CANVAS_PIXELS=0 \
|
||||||
MAX_SVG_SIZE_MB=0 \
|
MAX_SVG_SIZE_MB=0 \
|
||||||
MAX_LOGO_SIZE_KB=2048 \
|
|
||||||
MAX_SPLIT_GRID=100 \
|
MAX_SPLIT_GRID=100 \
|
||||||
MAX_PDF_PAGES=0 \
|
MAX_PDF_PAGES=0 \
|
||||||
SESSION_DURATION_HOURS=168 \
|
SESSION_DURATION_HOURS=168 \
|
||||||
|
|||||||
@@ -193,7 +193,6 @@ export const en = {
|
|||||||
sidebarView: "Sidebar",
|
sidebarView: "Sidebar",
|
||||||
fullscreenView: "Fullscreen Grid",
|
fullscreenView: "Fullscreen Grid",
|
||||||
version: "Version",
|
version: "Version",
|
||||||
appName: "App Name",
|
|
||||||
uploadLimit: "File Upload Limit",
|
uploadLimit: "File Upload Limit",
|
||||||
defaultTheme: "Default Theme",
|
defaultTheme: "Default Theme",
|
||||||
defaultLocale: "Language",
|
defaultLocale: "Language",
|
||||||
@@ -247,14 +246,6 @@ export const en = {
|
|||||||
startupCleanup: "Startup Cleanup",
|
startupCleanup: "Startup Cleanup",
|
||||||
startupCleanupDescription: "Clean up old temporary files when the server starts",
|
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",
|
limitsAndResources: "Limits & Resources",
|
||||||
maxFileSize: "Max File Size",
|
maxFileSize: "Max File Size",
|
||||||
maxBatchSize: "Max Batch Size",
|
maxBatchSize: "Max Batch Size",
|
||||||
@@ -269,7 +260,6 @@ export const en = {
|
|||||||
envOverride: "Set by environment variable",
|
envOverride: "Set by environment variable",
|
||||||
maxCanvasPixels: "Max Canvas Pixels",
|
maxCanvasPixels: "Max Canvas Pixels",
|
||||||
maxSvgSize: "Max SVG Size",
|
maxSvgSize: "Max SVG Size",
|
||||||
maxLogoSize: "Max Logo Size",
|
|
||||||
maxSplitGrid: "Max Split Grid",
|
maxSplitGrid: "Max Split Grid",
|
||||||
maxPdfPages: "Max PDF Pages",
|
maxPdfPages: "Max PDF Pages",
|
||||||
sessionDuration: "Session Duration",
|
sessionDuration: "Session Duration",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export type Permission =
|
|||||||
| "settings:write"
|
| "settings:write"
|
||||||
| "users:manage"
|
| "users:manage"
|
||||||
| "teams:manage"
|
| "teams:manage"
|
||||||
| "branding:manage"
|
|
||||||
| "features:manage"
|
| "features:manage"
|
||||||
| "system:health"
|
| "system:health"
|
||||||
| "audit:read";
|
| "audit:read";
|
||||||
|
|||||||
@@ -122,15 +122,6 @@ test.describe("GUI Settings - General Tab", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe("GUI Settings - System Settings 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 }) => {
|
test("shows File Upload Limit input", async ({ loggedInPage: page }) => {
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await page.getByRole("button", { name: /system settings/i }).click();
|
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();
|
await page.getByRole("button", { name: /system settings/i }).click();
|
||||||
|
|
||||||
// Wait for section to load
|
// Wait for section to load
|
||||||
await expect(page.getByText("App Name")).toBeVisible();
|
await expect(page.getByText("File Upload Limit (MB)")).toBeVisible();
|
||||||
|
|
||||||
// Click save
|
// Click save
|
||||||
await page.getByRole("button", { name: /save settings/i }).click();
|
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);
|
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`, {
|
const writeRes = await fetch(`${API}/api/v1/settings`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: authJson(bearerToken),
|
headers: authJson(bearerToken),
|
||||||
body: JSON.stringify({ appName: "hacked" }),
|
body: JSON.stringify({ testSetting: "hacked" }),
|
||||||
});
|
});
|
||||||
expect(writeRes.status).toBe(403);
|
expect(writeRes.status).toBe(403);
|
||||||
|
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ base.describe("RBAC - User sees restricted tabs", () => {
|
|||||||
const settingsRes = await fetch(`${API}/api/v1/settings`, {
|
const settingsRes = await fetch(`${API}/api/v1/settings`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: authJson(bearerToken),
|
headers: authJson(bearerToken),
|
||||||
body: JSON.stringify({ appName: "hacked" }),
|
body: JSON.stringify({ testSetting: "hacked" }),
|
||||||
});
|
});
|
||||||
expect(settingsRes.status).toBe(403);
|
expect(settingsRes.status).toBe(403);
|
||||||
});
|
});
|
||||||
@@ -280,7 +280,7 @@ base.describe("RBAC - Editor sees collaborative tabs", () => {
|
|||||||
const settingsRes = await fetch(`${API}/api/v1/settings`, {
|
const settingsRes = await fetch(`${API}/api/v1/settings`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: authJson(token as string),
|
headers: authJson(token as string),
|
||||||
body: JSON.stringify({ appName: "hacked" }),
|
body: JSON.stringify({ testSetting: "hacked" }),
|
||||||
});
|
});
|
||||||
expect(settingsRes.status).toBe(403);
|
expect(settingsRes.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ describe("API key permission scoping", () => {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
url: "/api/v1/settings",
|
url: "/api/v1/settings",
|
||||||
headers: { authorization: `Bearer ${apiKey}` },
|
headers: { authorization: `Bearer ${apiKey}` },
|
||||||
payload: { appName: "hacked" },
|
payload: { testSetting: "hacked" },
|
||||||
});
|
});
|
||||||
expect(writeRes.statusCode).toBe(403);
|
expect(writeRes.statusCode).toBe(403);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1452,7 +1452,7 @@ describe("Settings", () => {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
url: "/api/v1/settings",
|
url: "/api/v1/settings",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
payload: { app_name: "<script>alert('xss')</script>" },
|
payload: { test_setting: "<script>alert('xss')</script>" },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
@@ -1500,7 +1500,7 @@ describe("Settings", () => {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
url: "/api/v1/settings",
|
url: "/api/v1/settings",
|
||||||
headers: { authorization: `Bearer ${adminToken}` },
|
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);
|
expect(res.statusCode).toBe(200);
|
||||||
const body = JSON.parse(res.body);
|
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,
|
unauth: 401,
|
||||||
label: "system:health",
|
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)", () => {
|
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("settings:write");
|
||||||
expect(body.user.permissions).not.toContain("users:manage");
|
expect(body.user.permissions).not.toContain("users:manage");
|
||||||
expect(body.user.permissions).not.toContain("teams: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("features:manage");
|
||||||
expect(body.user.permissions).not.toContain("system:health");
|
expect(body.user.permissions).not.toContain("system:health");
|
||||||
expect(body.user.permissions).not.toContain("audit:read");
|
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 { apiKeyRoutes } from "../../apps/api/src/routes/api-keys.js";
|
||||||
import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js";
|
import { auditLogRoutes } from "../../apps/api/src/routes/audit-log.js";
|
||||||
import { registerBatchRoutes } from "../../apps/api/src/routes/batch.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 { docsRoutes } from "../../apps/api/src/routes/docs.js";
|
||||||
import { fileRoutes } from "../../apps/api/src/routes/files.js";
|
import { fileRoutes } from "../../apps/api/src/routes/files.js";
|
||||||
import { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js";
|
import { registerPipelineRoutes } from "../../apps/api/src/routes/pipeline.js";
|
||||||
@@ -109,9 +108,6 @@ export async function buildTestApp(): Promise<TestApp> {
|
|||||||
// Settings routes
|
// Settings routes
|
||||||
await settingsRoutes(app);
|
await settingsRoutes(app);
|
||||||
|
|
||||||
// Branding routes
|
|
||||||
await brandingRoutes(app);
|
|
||||||
|
|
||||||
// Teams routes
|
// Teams routes
|
||||||
await teamsRoutes(app);
|
await teamsRoutes(app);
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ describe("hasEffectivePermission", () => {
|
|||||||
expect(hasEffectivePermission(editor, "users:manage")).toBe(false);
|
expect(hasEffectivePermission(editor, "users:manage")).toBe(false);
|
||||||
expect(hasEffectivePermission(editor, "settings:write")).toBe(false);
|
expect(hasEffectivePermission(editor, "settings:write")).toBe(false);
|
||||||
expect(hasEffectivePermission(editor, "teams:manage")).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, "features:manage")).toBe(false);
|
||||||
expect(hasEffectivePermission(editor, "system:health")).toBe(false);
|
expect(hasEffectivePermission(editor, "system:health")).toBe(false);
|
||||||
expect(hasEffectivePermission(editor, "audit:read")).toBe(false);
|
expect(hasEffectivePermission(editor, "audit:read")).toBe(false);
|
||||||
@@ -185,8 +184,8 @@ describe("hasEffectivePermission", () => {
|
|||||||
|
|
||||||
describe("getPermissions", () => {
|
describe("getPermissions", () => {
|
||||||
describe("exact counts for built-in roles", () => {
|
describe("exact counts for built-in roles", () => {
|
||||||
it("admin has exactly 15 permissions", () => {
|
it("admin has exactly 14 permissions", () => {
|
||||||
expect(getPermissions("admin")).toHaveLength(15);
|
expect(getPermissions("admin")).toHaveLength(14);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("editor has exactly 7 permissions", () => {
|
it("editor has exactly 7 permissions", () => {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import { getPermissions, hasPermission } from "../../../apps/api/src/permissions
|
|||||||
|
|
||||||
describe("permissions", () => {
|
describe("permissions", () => {
|
||||||
describe("getPermissions", () => {
|
describe("getPermissions", () => {
|
||||||
it("returns all 15 permissions for admin", () => {
|
it("returns all 14 permissions for admin", () => {
|
||||||
const perms = getPermissions("admin");
|
const perms = getPermissions("admin");
|
||||||
expect(perms).toHaveLength(15);
|
expect(perms).toHaveLength(14);
|
||||||
expect(perms).toContain("tools:use");
|
expect(perms).toContain("tools:use");
|
||||||
expect(perms).toContain("files:own");
|
expect(perms).toContain("files:own");
|
||||||
expect(perms).toContain("files:all");
|
expect(perms).toContain("files:all");
|
||||||
@@ -33,7 +33,6 @@ describe("permissions", () => {
|
|||||||
expect(perms).toContain("settings:write");
|
expect(perms).toContain("settings:write");
|
||||||
expect(perms).toContain("users:manage");
|
expect(perms).toContain("users:manage");
|
||||||
expect(perms).toContain("teams:manage");
|
expect(perms).toContain("teams:manage");
|
||||||
expect(perms).toContain("branding:manage");
|
|
||||||
expect(perms).toContain("features:manage");
|
expect(perms).toContain("features:manage");
|
||||||
expect(perms).toContain("system:health");
|
expect(perms).toContain("system:health");
|
||||||
expect(perms).toContain("audit:read");
|
expect(perms).toContain("audit:read");
|
||||||
@@ -58,7 +57,6 @@ describe("permissions", () => {
|
|||||||
expect(perms).not.toContain("settings:write");
|
expect(perms).not.toContain("settings:write");
|
||||||
expect(perms).not.toContain("users:manage");
|
expect(perms).not.toContain("users:manage");
|
||||||
expect(perms).not.toContain("teams:manage");
|
expect(perms).not.toContain("teams:manage");
|
||||||
expect(perms).not.toContain("branding:manage");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty array for unknown role", () => {
|
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";
|
import { getPermissions, hasPermission } from "../../../apps/api/src/permissions.js";
|
||||||
|
|
||||||
describe("role permissions", () => {
|
describe("role permissions", () => {
|
||||||
it("admin has all 15 permissions", () => {
|
it("admin has all 14 permissions", () => {
|
||||||
const perms = getPermissions("admin");
|
const perms = getPermissions("admin");
|
||||||
expect(perms).toContain("tools:use");
|
expect(perms).toContain("tools:use");
|
||||||
expect(perms).toContain("files:all");
|
expect(perms).toContain("files:all");
|
||||||
@@ -10,7 +10,7 @@ describe("role permissions", () => {
|
|||||||
expect(perms).toContain("features:manage");
|
expect(perms).toContain("features:manage");
|
||||||
expect(perms).toContain("system:health");
|
expect(perms).toContain("system:health");
|
||||||
expect(perms).toContain("audit:read");
|
expect(perms).toContain("audit:read");
|
||||||
expect(perms.length).toBe(15);
|
expect(perms.length).toBe(14);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("editor has collaborative but not admin permissions", () => {
|
it("editor has collaborative but not admin permissions", () => {
|
||||||
|
|||||||
@@ -752,7 +752,6 @@ describe("loadEnv", () => {
|
|||||||
"WORKSPACE_PATH",
|
"WORKSPACE_PATH",
|
||||||
"DEFAULT_THEME",
|
"DEFAULT_THEME",
|
||||||
"DEFAULT_LOCALE",
|
"DEFAULT_LOCALE",
|
||||||
"APP_NAME",
|
|
||||||
];
|
];
|
||||||
for (const key of keysToClean) {
|
for (const key of keysToClean) {
|
||||||
delete process.env[key];
|
delete process.env[key];
|
||||||
@@ -794,7 +793,6 @@ describe("loadEnv", () => {
|
|||||||
expect(typeof env.WORKSPACE_PATH).toBe("string");
|
expect(typeof env.WORKSPACE_PATH).toBe("string");
|
||||||
expect(["light", "dark"]).toContain(env.DEFAULT_THEME);
|
expect(["light", "dark"]).toContain(env.DEFAULT_THEME);
|
||||||
expect(typeof env.DEFAULT_LOCALE).toBe("string");
|
expect(typeof env.DEFAULT_LOCALE).toBe("string");
|
||||||
expect(typeof env.APP_NAME).toBe("string");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses custom PORT as a number via coercion", async () => {
|
it("parses custom PORT as a number via coercion", async () => {
|
||||||
@@ -862,12 +860,10 @@ describe("loadEnv", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("accepts string values for string fields", async () => {
|
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.DB_PATH = "/var/data/mydb.sqlite";
|
||||||
process.env.DEFAULT_LOCALE = "fr";
|
process.env.DEFAULT_LOCALE = "fr";
|
||||||
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
const { loadEnv } = await import("../../../apps/api/src/lib/env.js");
|
||||||
const env = loadEnv();
|
const env = loadEnv();
|
||||||
expect(env.APP_NAME).toBe("My Custom App");
|
|
||||||
expect(env.DB_PATH).toBe("/var/data/mydb.sqlite");
|
expect(env.DB_PATH).toBe("/var/data/mydb.sqlite");
|
||||||
expect(env.DEFAULT_LOCALE).toBe("fr");
|
expect(env.DEFAULT_LOCALE).toBe("fr");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user