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:
parent
23a85267bf
commit
8e717401bd
8 changed files with 79 additions and 82 deletions
19
.env.example
19
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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'", () => {
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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[] {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue