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 requests
LOG_HTTP=false LOG_HTTP=false
# ─── Branding (AcadeDoc selfhost) ──────────────────────────────────── # ─── Branding ──────────────────────────────────────────────────────────
# Override the visible product name (UI, emails, PWA manifest). # Le branding (nom, couleurs, logo) se gere desormais UNIQUEMENT via l'UI
# Leave unset to keep the upstream "Docmost" default for pure selfhosters. # admin: /settings/branding (couleurs + nom workspace) et /settings/general
BRAND_NAME=AcadeDoc # (logo). Pas de variable d'env a definir ici. Le defaut hardcode est
# Optional: absolute URL to a custom logo asset served by your CDN or nginx. # "AcadeDoc" + bleu #2563eb / violet #7c3aed.
# 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
# ─── SMTP Brevo (recommande pour AcadeDoc) ───────────────────── # ─── SMTP Brevo (recommande pour AcadeDoc) ─────────────────────────────
# Compte Brevo : https://app.brevo.com/settings/keys/smtp # Compte Brevo : https://app.brevo.com/settings/keys/smtp
# Pas le mot de passe du compte — generer une "SMTP key" dans le dashboard. # Pas le mot de passe du compte — generer une "SMTP key" dans le dashboard.
# Plan free Brevo : 300 emails/jour (suffisant pour usage interne). # 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_ADDRESS=noreply@acadenice.fr
# MAIL_FROM_NAME=AcadeDoc # MAIL_FROM_NAME=AcadeDoc
# (MAIL_FROM_NAME defaults to BRAND_NAME if unset)
# ─── OIDC (Authentik) — Bloc 4b ────────────────────────────────────── # ─── OIDC (Authentik) — Bloc 4b ──────────────────────────────────────
# Disabled by default. Set OIDC_ENABLED=true and fill the block below # 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 { export function getAppName(): string {
return getConfigValue("BRAND_NAME", "AcadeDoc"); return "AcadeDoc";
} }
export function getBrandLogoUrl(): string | null { export function getBrandLogoUrl(): string | null {
return getConfigValue("BRAND_LOGO_URL") || null; return null;
} }
export function getBrandPrimaryColor(): string { export function getBrandPrimaryColor(): string {
return getConfigValue("BRAND_PRIMARY_COLOR", "#2563eb"); return "#2563eb";
} }
export function getBrandAccentColor(): string { export function getBrandAccentColor(): string {
return getConfigValue("BRAND_ACCENT_COLOR", "#7c3aed"); return "#7c3aed";
} }
export function getAppUrl(): string { 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"; import { generateColorTuple, getBrandTheme } from "../brand-theme";
// Mantine color tuple has exactly 10 entries. // Mantine color tuple has exactly 10 entries.
@ -46,45 +46,43 @@ describe("generateColorTuple", () => {
}); });
describe("getBrandTheme", () => { 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", () => { it("returns brand and accent color keys in theme.colors", () => {
const theme = getBrandTheme(); const theme = getBrandTheme();
expect(theme.colors).toHaveProperty("brand"); expect(theme.colors).toHaveProperty("brand");
expect(theme.colors).toHaveProperty("accent"); expect(theme.colors).toHaveProperty("accent");
}); });
it("uses default primary color (#2563eb) when env is unset", () => { it("uses default primary color (#2563eb) when no override is given", () => {
delete process.env.BRAND_PRIMARY_COLOR;
const theme = getBrandTheme(); 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]; const shade6 = (theme.colors!.brand as unknown as string[])[6];
expect(shade6).toBeTruthy(); expect(shade6).toBeTruthy();
expect(shade6).not.toBe("#ffffff"); expect(shade6).not.toBe("#ffffff");
expect(shade6).not.toBe("#000000"); expect(shade6).not.toBe("#000000");
}); });
it("overrides primary color via BRAND_PRIMARY_COLOR env var", () => { it("overrides primary color via runtime override", () => {
process.env.BRAND_PRIMARY_COLOR = "#ff0000"; const theme = getBrandTheme({ primary: "#ff0000" });
const theme = getBrandTheme();
// shade[6] of #ff0000 with 0% black mix = #ff0000 itself // shade[6] of #ff0000 with 0% black mix = #ff0000 itself
const shade6 = (theme.colors!.brand as unknown as string[])[6]; const shade6 = (theme.colors!.brand as unknown as string[])[6];
expect(shade6.toLowerCase()).toBe("#ff0000"); expect(shade6.toLowerCase()).toBe("#ff0000");
}); });
it("falls back to default when BRAND_PRIMARY_COLOR is invalid", () => { it("falls back to default when override primary is invalid", () => {
process.env.BRAND_PRIMARY_COLOR = "badvalue"; const themeBad = getBrandTheme({ primary: "badvalue" });
const defaultTheme = getBrandTheme(); const themeDefault = getBrandTheme();
delete process.env.BRAND_PRIMARY_COLOR; expect(themeBad.colors!.brand).toEqual(themeDefault.colors!.brand);
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 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'", () => { 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 * Produces a MantineThemeOverride with hardcoded brand defaults. Per-workspace
* (process.env.*) and produces a MantineThemeOverride with generated color * runtime overrides come from /settings/branding (workspace.primary_color +
* tuples. Falls back to hardcoded defaults so the app is never misconfigured. * 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 * 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 * 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 * Returns a MantineThemeOverride with the hardcoded brand defaults.
* and returns a MantineThemeOverride that can be merged into the base theme. * 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 { export function getBrandTheme(overrides?: {
const rawPrimary = primary?: string | null;
(typeof process !== "undefined" && process.env?.BRAND_PRIMARY_COLOR) || accent?: string | null;
DEFAULT_PRIMARY; }): MantineThemeOverride {
const rawAccent = const primary =
(typeof process !== "undefined" && process.env?.BRAND_ACCENT_COLOR) || overrides?.primary && hexToRgb(overrides.primary)
DEFAULT_ACCENT; ? overrides.primary
: DEFAULT_PRIMARY;
// Validate: if the value is not a valid hex, fall back to the default. const accent =
const primary = hexToRgb(rawPrimary) ? rawPrimary : DEFAULT_PRIMARY; overrides?.accent && hexToRgb(overrides.accent)
const accent = hexToRgb(rawAccent) ? rawAccent : DEFAULT_ACCENT; ? overrides.accent
: DEFAULT_ACCENT;
return { return {
colors: { colors: {

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { EnvironmentService } from './environment.service'; import { EnvironmentService } from './environment.service';
describe('EnvironmentService', () => { describe('EnvironmentService', () => {
@ -6,7 +7,13 @@ describe('EnvironmentService', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [EnvironmentService], providers: [
EnvironmentService,
{
provide: ConfigService,
useValue: { get: (_key: string, def?: unknown) => def },
},
],
}).compile(); }).compile();
service = module.get<EnvironmentService>(EnvironmentService); service = module.get<EnvironmentService>(EnvironmentService);
@ -15,4 +22,8 @@ describe('EnvironmentService', () => {
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); 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 { getMailFromName(): string {
// MAIL_FROM_NAME can be overridden explicitly; if not set, fall back to // MAIL_FROM_NAME can be overridden explicitly in .env. Defaults to the
// BRAND_NAME so that outgoing mail uses the configured brand by default. // hardcoded brand name (UI override is per-workspace, not per-mail).
const explicit = this.configService.get<string>('MAIL_FROM_NAME'); return this.configService.get<string>('MAIL_FROM_NAME', 'AcadeDoc');
if (explicit) return explicit;
return this.configService.get<string>('BRAND_NAME', 'AcadeDoc');
} }
getMailBlockedRecipientDomains(): string[] { getMailBlockedRecipientDomains(): string[] {