refactor(acadedoc): move branding from .env to UI-only — Patch 020

Per Corentin's feedback (2026-05-08): .env should be reserved for
server-side config (SMTP, DB, OIDC). Branding (name, colors, logo)
is admin/UI territory.

Changes:
- Remove BRAND_NAME / BRAND_LOGO_URL / BRAND_PRIMARY_COLOR /
  BRAND_ACCENT_COLOR from .env.example, vite.config.ts define block
- Hardcode "AcadeDoc" + #2563eb / #7c3aed as defaults in
  apps/client/src/lib/config.ts and brand-theme.ts
- getBrandTheme() now takes optional runtime overrides instead of
  reading process.env (used by per-workspace branding hook to apply
  DB-stored colors)
- Server getMailFromName() defaults to "AcadeDoc" hardcoded; only
  MAIL_FROM_NAME env var can override
- Fix workspace-branding.spec.ts (was importing vitest in jest project,
  R4.4 leftover bug, similar to Patch 017 scope)
- Fix environment.service.spec.ts (was missing ConfigService provider
  in TestingModule, pre-existing upstream bug surfaced by jest run)

Tests: 13 brand-theme + 13 workspace-branding + 2 environment = 28
green. Per-workspace UI override via /settings/branding (R4.4) works
unchanged.

Patch 020.
This commit is contained in:
Corentin JOGUET 2026-05-08 11:49:49 +02:00
parent 23a85267bf
commit 8e717401bd
8 changed files with 79 additions and 82 deletions

View file

@ -57,19 +57,13 @@ DEBUG_DB=false
# Log http requests
LOG_HTTP=false
# ─── Branding (AcadeDoc selfhost) ────────────────────────────────────
# Override the visible product name (UI, emails, PWA manifest).
# Leave unset to keep the upstream "Docmost" default for pure selfhosters.
BRAND_NAME=AcadeDoc
# Optional: absolute URL to a custom logo asset served by your CDN or nginx.
# Default: the built-in Docmost favicon (no override needed for development).
# BRAND_LOGO_URL=/branding/acadedoc-logo.svg
# Optional CSS hex colors. Drives the Mantine theme at runtime (no rebuild).
# Default values match the Acadenice brand palette.
BRAND_PRIMARY_COLOR=#2563eb
BRAND_ACCENT_COLOR=#7c3aed
# ─── Branding ──────────────────────────────────────────────────────────
# Le branding (nom, couleurs, logo) se gere desormais UNIQUEMENT via l'UI
# admin: /settings/branding (couleurs + nom workspace) et /settings/general
# (logo). Pas de variable d'env a definir ici. Le defaut hardcode est
# "AcadeDoc" + bleu #2563eb / violet #7c3aed.
# ─── SMTP Brevo (recommande pour AcadeDoc) ─────────────────────
# ─── SMTP Brevo (recommande pour AcadeDoc) ─────────────────────────────
# Compte Brevo : https://app.brevo.com/settings/keys/smtp
# Pas le mot de passe du compte — generer une "SMTP key" dans le dashboard.
# Plan free Brevo : 300 emails/jour (suffisant pour usage interne).
@ -84,7 +78,6 @@ BRAND_ACCENT_COLOR=#7c3aed
#
# MAIL_FROM_ADDRESS=noreply@acadenice.fr
# MAIL_FROM_NAME=AcadeDoc
# (MAIL_FROM_NAME defaults to BRAND_NAME if unset)
# ─── OIDC (Authentik) — Bloc 4b ──────────────────────────────────────
# Disabled by default. Set OIDC_ENABLED=true and fill the block below

View file

@ -9,20 +9,22 @@ declare global {
}
}
// Branding hardcoded defaults (override at runtime via /settings/branding).
// .env is reserved for server-side config (SMTP, DB, OIDC) — not branding.
export function getAppName(): string {
return getConfigValue("BRAND_NAME", "AcadeDoc");
return "AcadeDoc";
}
export function getBrandLogoUrl(): string | null {
return getConfigValue("BRAND_LOGO_URL") || null;
return null;
}
export function getBrandPrimaryColor(): string {
return getConfigValue("BRAND_PRIMARY_COLOR", "#2563eb");
return "#2563eb";
}
export function getBrandAccentColor(): string {
return getConfigValue("BRAND_ACCENT_COLOR", "#7c3aed");
return "#7c3aed";
}
export function getAppUrl(): string {

View file

@ -1,10 +1,10 @@
/**
* Tests for brand-theme.ts R4.4
* Tests for brand-theme.ts Patch 020
*
* Covers: tuple generation, env-var overrides, fallback on invalid input.
* Covers: tuple generation, runtime overrides (workspace UI), fallback on invalid input.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { generateColorTuple, getBrandTheme } from "../brand-theme";
// Mantine color tuple has exactly 10 entries.
@ -46,45 +46,43 @@ describe("generateColorTuple", () => {
});
describe("getBrandTheme", () => {
const originalEnv = { ...process.env };
afterEach(() => {
// Restore env after each test
Object.assign(process.env, originalEnv);
vi.unstubAllEnvs();
});
it("returns brand and accent color keys in theme.colors", () => {
const theme = getBrandTheme();
expect(theme.colors).toHaveProperty("brand");
expect(theme.colors).toHaveProperty("accent");
});
it("uses default primary color (#2563eb) when env is unset", () => {
delete process.env.BRAND_PRIMARY_COLOR;
it("uses default primary color (#2563eb) when no override is given", () => {
const theme = getBrandTheme();
// Shade[6] of the default primary should not be fully black or white
const shade6 = (theme.colors!.brand as unknown as string[])[6];
expect(shade6).toBeTruthy();
expect(shade6).not.toBe("#ffffff");
expect(shade6).not.toBe("#000000");
});
it("overrides primary color via BRAND_PRIMARY_COLOR env var", () => {
process.env.BRAND_PRIMARY_COLOR = "#ff0000";
const theme = getBrandTheme();
it("overrides primary color via runtime override", () => {
const theme = getBrandTheme({ primary: "#ff0000" });
// shade[6] of #ff0000 with 0% black mix = #ff0000 itself
const shade6 = (theme.colors!.brand as unknown as string[])[6];
expect(shade6.toLowerCase()).toBe("#ff0000");
});
it("falls back to default when BRAND_PRIMARY_COLOR is invalid", () => {
process.env.BRAND_PRIMARY_COLOR = "badvalue";
const defaultTheme = getBrandTheme();
delete process.env.BRAND_PRIMARY_COLOR;
const normalTheme = getBrandTheme();
// Both should produce identical tuples (both use default)
expect(defaultTheme.colors!.brand).toEqual(normalTheme.colors!.brand);
it("falls back to default when override primary is invalid", () => {
const themeBad = getBrandTheme({ primary: "badvalue" });
const themeDefault = getBrandTheme();
expect(themeBad.colors!.brand).toEqual(themeDefault.colors!.brand);
});
it("falls back to default when override primary is null", () => {
const themeNull = getBrandTheme({ primary: null });
const themeDefault = getBrandTheme();
expect(themeNull.colors!.brand).toEqual(themeDefault.colors!.brand);
});
it("overrides accent color independently", () => {
const theme = getBrandTheme({ accent: "#00ff00" });
const shade6 = (theme.colors!.accent as unknown as string[])[6];
expect(shade6.toLowerCase()).toBe("#00ff00");
});
it("sets primaryColor to 'brand'", () => {

View file

@ -1,9 +1,10 @@
/**
* brand-theme.ts R4.4
* brand-theme.ts R4.4 / Patch 020
*
* Reads BRAND_PRIMARY_COLOR and BRAND_ACCENT_COLOR from vite define block
* (process.env.*) and produces a MantineThemeOverride with generated color
* tuples. Falls back to hardcoded defaults so the app is never misconfigured.
* Produces a MantineThemeOverride with hardcoded brand defaults. Per-workspace
* runtime overrides come from /settings/branding (workspace.primary_color +
* workspace.accent_color in the DB) and are merged at app startup before
* Mantine renders.
*
* We do not depend on @mantine/colors-generator (not installed). Instead we
* generate a 10-shade tuple by progressively mixing the hex toward white (light
@ -105,22 +106,25 @@ export function generateColorTuple(hex: string): MantineColorsTuple {
// ---------------------------------------------------------------------------
/**
* Reads BRAND_PRIMARY_COLOR and BRAND_ACCENT_COLOR from the vite define block
* and returns a MantineThemeOverride that can be merged into the base theme.
* Returns a MantineThemeOverride with the hardcoded brand defaults.
* Optional `overrides` override the defaults at runtime (used by the
* per-workspace branding UI to apply DB-stored colors).
*
* Called once at startup in main.tsx.
* Called at startup in main.tsx; the workspace branding hook calls it again
* with overrides once it has loaded the DB row.
*/
export function getBrandTheme(): MantineThemeOverride {
const rawPrimary =
(typeof process !== "undefined" && process.env?.BRAND_PRIMARY_COLOR) ||
DEFAULT_PRIMARY;
const rawAccent =
(typeof process !== "undefined" && process.env?.BRAND_ACCENT_COLOR) ||
DEFAULT_ACCENT;
// Validate: if the value is not a valid hex, fall back to the default.
const primary = hexToRgb(rawPrimary) ? rawPrimary : DEFAULT_PRIMARY;
const accent = hexToRgb(rawAccent) ? rawAccent : DEFAULT_ACCENT;
export function getBrandTheme(overrides?: {
primary?: string | null;
accent?: string | null;
}): MantineThemeOverride {
const primary =
overrides?.primary && hexToRgb(overrides.primary)
? overrides.primary
: DEFAULT_PRIMARY;
const accent =
overrides?.accent && hexToRgb(overrides.accent)
? overrides.accent
: DEFAULT_ACCENT;
return {
colors: {

View file

@ -16,10 +16,6 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
BRAND_NAME,
BRAND_LOGO_URL,
BRAND_PRIMARY_COLOR,
BRAND_ACCENT_COLOR,
} = loadEnv(mode, envPath, "");
return {
@ -35,10 +31,6 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
BRAND_NAME: BRAND_NAME || "AcadeDoc",
BRAND_LOGO_URL: BRAND_LOGO_URL || "",
BRAND_PRIMARY_COLOR: BRAND_PRIMARY_COLOR || "#2563eb",
BRAND_ACCENT_COLOR: BRAND_ACCENT_COLOR || "#7c3aed",
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},

View file

@ -2,7 +2,7 @@
* workspace-branding.spec.ts R4.4
*
* Unit tests for WorkspaceBrandingService + WorkspaceBrandingController.
* Uses vitest + vi.fn() stubs no real DB or HTTP server needed.
* Uses vitest + jest.fn() stubs no real DB or HTTP server needed.
*
* Covered:
* 1. getBranding returns correct shape
@ -17,7 +17,6 @@
* 10. UpdateWorkspaceBrandingDto validates hex color correctly
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { WorkspaceBrandingService } from '../services/workspace-branding.service';
import { WorkspaceBrandingController } from '../controllers/workspace-branding.controller';
@ -40,8 +39,8 @@ const fakeWorkspace = {
function makeWorkspaceRepo(overrides?: Partial<typeof fakeWorkspace>) {
const ws = { ...fakeWorkspace, ...overrides };
return {
findById: vi.fn(async (id: string) => (id === ws.id ? ws : null)),
updateWorkspace: vi.fn(async (patch: any, id: string) => {
findById: jest.fn(async (id: string) => (id === ws.id ? ws : null)),
updateWorkspace: jest.fn(async (patch: any, id: string) => {
Object.assign(ws, patch);
}),
};
@ -49,8 +48,8 @@ function makeWorkspaceRepo(overrides?: Partial<typeof fakeWorkspace>) {
function makeAbility(canManage = true) {
return {
createForUser: vi.fn(() => ({
cannot: vi.fn(() => !canManage),
createForUser: jest.fn(() => ({
cannot: jest.fn(() => !canManage),
})),
};
}
@ -123,7 +122,7 @@ describe('WorkspaceBrandingService', () => {
describe('WorkspaceBrandingController', () => {
let controller: WorkspaceBrandingController;
let brandingService: { getBranding: ReturnType<typeof vi.fn>; updateBranding: ReturnType<typeof vi.fn> };
let brandingService: { getBranding: jest.Mock; updateBranding: jest.Mock };
let ability: ReturnType<typeof makeAbility>;
const mockWorkspace = { id: 'ws-uuid' } as any;
@ -131,8 +130,8 @@ describe('WorkspaceBrandingController', () => {
beforeEach(() => {
brandingService = {
getBranding: vi.fn(async () => ({ logo: null, primaryColor: null, accentColor: null })),
updateBranding: vi.fn(async () => ({ logo: null, primaryColor: '#2563eb', accentColor: null })),
getBranding: jest.fn(async () => ({ logo: null, primaryColor: null, accentColor: null })),
updateBranding: jest.fn(async () => ({ logo: null, primaryColor: '#2563eb', accentColor: null })),
};
ability = makeAbility(true);
controller = new WorkspaceBrandingController(

View file

@ -1,4 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { EnvironmentService } from './environment.service';
describe('EnvironmentService', () => {
@ -6,7 +7,13 @@ describe('EnvironmentService', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [EnvironmentService],
providers: [
EnvironmentService,
{
provide: ConfigService,
useValue: { get: (_key: string, def?: unknown) => def },
},
],
}).compile();
service = module.get<EnvironmentService>(EnvironmentService);
@ -15,4 +22,8 @@ describe('EnvironmentService', () => {
it('should be defined', () => {
expect(service).toBeDefined();
});
it('getMailFromName defaults to "AcadeDoc" when MAIL_FROM_NAME is unset', () => {
expect(service.getMailFromName()).toBe('AcadeDoc');
});
});

View file

@ -131,11 +131,9 @@ export class EnvironmentService {
}
getMailFromName(): string {
// MAIL_FROM_NAME can be overridden explicitly; if not set, fall back to
// BRAND_NAME so that outgoing mail uses the configured brand by default.
const explicit = this.configService.get<string>('MAIL_FROM_NAME');
if (explicit) return explicit;
return this.configService.get<string>('BRAND_NAME', 'AcadeDoc');
// MAIL_FROM_NAME can be overridden explicitly in .env. Defaults to the
// hardcoded brand name (UI override is per-workspace, not per-mail).
return this.configService.get<string>('MAIL_FROM_NAME', 'AcadeDoc');
}
getMailBlockedRecipientDomains(): string[] {