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=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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'", () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue