diff --git a/.env.example b/.env.example index 806c6c49..b73bd827 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,35 @@ 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 + +# ─── 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). +# +# MAIL_DRIVER=smtp +# SMTP_HOST=smtp-relay.brevo.com +# SMTP_PORT=587 +# SMTP_USERNAME= +# SMTP_PASSWORD= +# SMTP_SECURE=false +# SMTP_IGNORETLS=false +# +# 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 # to expose /api/auth/oidc/login and the SSO button on the login page. diff --git a/apps/client/index.html b/apps/client/index.html index 762689fd..7f436eff 100644 --- a/apps/client/index.html +++ b/apps/client/index.html @@ -5,13 +5,14 @@ - DocAdenice + AcadeDoc + - + diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index ba60d067..aae2a5d8 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1,5 +1,15 @@ { "Account": "Account", + "Accent color": "Accent color", + "Branding": "Branding", + "Branding settings": "Branding settings", + "Primary color": "Primary color", + "Save branding": "Save branding", + "Branding saved": "Branding saved", + "Branding update failed": "Branding update failed", + "Enter a hex color (e.g. #2563eb)": "Enter a hex color (e.g. #2563eb)", + "Workspace logo": "Workspace logo", + "Logo upload and name-based branding override is managed from the General settings tab.": "Logo upload and name-based branding override is managed from the General settings tab.", "Active": "Active", "Add": "Add", "Add group members": "Add group members", diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json index 06cf87f4..3331d5f8 100644 --- a/apps/client/public/locales/fr-FR/translation.json +++ b/apps/client/public/locales/fr-FR/translation.json @@ -1,5 +1,15 @@ { "Account": "Compte", + "Accent color": "Couleur d'accentuation", + "Branding": "Personnalisation", + "Branding settings": "Paramètres de personnalisation", + "Primary color": "Couleur primaire", + "Save branding": "Enregistrer", + "Branding saved": "Personnalisation enregistrée", + "Branding update failed": "Echec de la mise à jour", + "Enter a hex color (e.g. #2563eb)": "Entrez une couleur hex (ex : #2563eb)", + "Workspace logo": "Logo de l'espace de travail", + "Logo upload and name-based branding override is managed from the General settings tab.": "Le logo et le nom se configurent depuis l'onglet Général.", "Active": "Actif", "Add": "Ajouter", "Add group members": "Ajouter des membres au groupe", diff --git a/apps/client/public/manifest.json b/apps/client/public/manifest.json index 3e4b35dd..7adbf89d 100644 --- a/apps/client/public/manifest.json +++ b/apps/client/public/manifest.json @@ -1,6 +1,6 @@ { - "name": "Docmost", - "short_name": "Docmost", + "name": "AcadeDoc", + "short_name": "AcadeDoc", "start_url": "/", "display": "standalone", "background_color": "#222", diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 2ff45aae..2c7da43a 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -6,6 +6,7 @@ import Page from "@/pages/page/page"; import AccountSettings from "@/pages/settings/account/account-settings"; import WorkspaceMembers from "@/pages/settings/workspace/workspace-members"; import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings"; +import WorkspaceBranding from "@/pages/settings/workspace/workspace-branding"; import Groups from "@/pages/settings/group/groups"; import GroupInfo from "./pages/settings/group/group-info"; import Spaces from "@/pages/settings/space/spaces.tsx"; @@ -57,6 +58,8 @@ import TemplatesAdminPage from "@/features/acadenice/templates-admin/pages/templ // Acadenice R3.7 — mention notifications import AcadeniceNotificationsPage from "@/features/acadenice/notifications/pages/notifications-page"; import NotificationPreferencesPage from "@/features/acadenice/notifications/pages/notification-preferences-page"; +// Acadenice R4.3 — Web Clipper token management +import ClipperTokensPage from "@/features/acadenice/clipper/pages/clipper-tokens-page"; export default function App() { const { t } = useTranslation(); @@ -152,6 +155,10 @@ export default function App() { } /> {/* Acadenice R3.7 — notification preferences */} } /> + {/* Acadenice R4.3 — Web Clipper token management */} + } /> + {/* Acadenice R4.4 — Workspace branding */} + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/layouts/global/app-header.tsx b/apps/client/src/components/layouts/global/app-header.tsx index 7b6371bd..25e4a589 100644 --- a/apps/client/src/components/layouts/global/app-header.tsx +++ b/apps/client/src/components/layouts/global/app-header.tsx @@ -23,7 +23,7 @@ import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-to import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx"; import { useTranslation } from "react-i18next"; import useTrial from "@/ee/hooks/use-trial.tsx"; -import { isCloud } from "@/lib/config.ts"; +import { isCloud, getAppName } from "@/lib/config.ts"; import { SearchControl, SearchMobileControl, @@ -84,11 +84,11 @@ export function AppHeader() { /> - + DocAdenice @@ -99,7 +99,7 @@ export function AppHeader() { style={{ userSelect: "none" }} visibleFrom="sm" > - DocAdenice + {getAppName()} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 8ae44f0d..1bb079d4 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -19,6 +19,7 @@ import { IconSlash, IconTemplate, IconBell, + IconScissors, } from "@tabler/icons-react"; import { useAcadenicePermissions } from "@/features/acadenice/rbac/hooks/use-acadenice-permissions"; import { Link, useLocation } from "react-router-dom"; @@ -89,12 +90,25 @@ const groupedData: DataGroup[] = [ path: "/settings/account/api-keys", feature: Feature.API_KEYS, }, + { + // Acadenice R4.3 — Web Clipper token management + label: "Clipper tokens", + icon: IconScissors, + path: "/settings/clipper-tokens", + }, ], }, { heading: "Workspace", items: [ { label: "General", icon: IconSettings, path: "/settings/workspace" }, + { + // Acadenice R4.4 — workspace brand colors + label: "Branding", + icon: IconBrush, + path: "/settings/branding", + role: "admin" as const, + }, { label: "Members", icon: IconUsers, path: "/settings/members" }, { label: "Billing", diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts index 0ffd6f23..a4988a13 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -122,3 +122,26 @@ export async function getAppVersion(): Promise { const req = await api.post("/version"); return req.data; } + +// R4.4 — branding endpoints + +export interface IWorkspaceBranding { + logo: string | null; + primaryColor: string | null; + accentColor: string | null; +} + +export async function getWorkspaceBranding(): Promise { + const req = await api.post("/workspace/branding"); + return req.data; +} + +export async function updateWorkspaceBranding( + data: Partial>, +): Promise { + const req = await api.post( + "/workspace/branding/update", + data, + ); + return req.data; +} diff --git a/apps/client/src/lib/config.ts b/apps/client/src/lib/config.ts index bccc762f..ea2a8f84 100644 --- a/apps/client/src/lib/config.ts +++ b/apps/client/src/lib/config.ts @@ -10,7 +10,19 @@ declare global { } export function getAppName(): string { - return "DocAdenice"; + return getConfigValue("BRAND_NAME", "AcadeDoc"); +} + +export function getBrandLogoUrl(): string | null { + return getConfigValue("BRAND_LOGO_URL") || null; +} + +export function getBrandPrimaryColor(): string { + return getConfigValue("BRAND_PRIMARY_COLOR", "#2563eb"); +} + +export function getBrandAccentColor(): string { + return getConfigValue("BRAND_ACCENT_COLOR", "#7c3aed"); } export function getAppUrl(): string { diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 0583dc34..a4827f42 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -7,6 +7,7 @@ import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import { mantineCssResolver, theme } from "@/theme"; import { MantineProvider } from "@mantine/core"; +import { getBrandTheme } from "@/theme/brand-theme"; import { BrowserRouter } from "react-router-dom"; import { ModalsProvider } from "@mantine/modals"; import { Notifications } from "@mantine/notifications"; @@ -42,12 +43,25 @@ if (isCloud() && isPostHogEnabled) { }); } +const brandOverride = getBrandTheme(); +// Deep-merge brand color overrides into the base theme by spreading. +const brandedTheme = { + ...theme, + colors: { ...theme.colors, ...brandOverride.colors }, + ...(brandOverride.primaryColor !== undefined + ? { primaryColor: brandOverride.primaryColor } + : {}), + ...(brandOverride.primaryShade !== undefined + ? { primaryShade: brandOverride.primaryShade } + : {}), +}; + const container = document.getElementById("root") as HTMLElement; const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container); root.render( - + diff --git a/apps/client/src/pages/settings/workspace/workspace-branding.tsx b/apps/client/src/pages/settings/workspace/workspace-branding.tsx new file mode 100644 index 00000000..d885d593 --- /dev/null +++ b/apps/client/src/pages/settings/workspace/workspace-branding.tsx @@ -0,0 +1,140 @@ +/** + * workspace-branding.tsx — R4.4 + * + * Settings page: /settings/branding + * Admin-only. Lets workspace admins set the primary and accent brand colors + * (hex). Logo upload is handled on the General tab via WorkspaceIcon component. + */ + +import { useState } from "react"; +import { + Button, + ColorInput, + Divider, + Group, + Stack, + Text, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Helmet } from "react-helmet-async"; +import { notifications } from "@mantine/notifications"; +import SettingsTitle from "@/components/settings/settings-title.tsx"; +import { getAppName } from "@/lib/config.ts"; +import { + getWorkspaceBranding, + updateWorkspaceBranding, +} from "@/features/workspace/services/workspace-service.ts"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + +const HEX_RE = /^#[0-9A-Fa-f]{6}$/; + +function isValidHex(v: string): boolean { + return HEX_RE.test(v); +} + +export default function WorkspaceBranding() { + const { t } = useTranslation(); + const qc = useQueryClient(); + + const { data: branding } = useQuery({ + queryKey: ["workspace-branding"], + queryFn: getWorkspaceBranding, + }); + + const [primaryColor, setPrimaryColor] = useState( + branding?.primaryColor ?? "#2563eb", + ); + const [accentColor, setAccentColor] = useState( + branding?.accentColor ?? "#7c3aed", + ); + + // Sync state when data loads + const loadedPrimary = branding?.primaryColor ?? "#2563eb"; + const loadedAccent = branding?.accentColor ?? "#7c3aed"; + + const mutation = useMutation({ + mutationFn: updateWorkspaceBranding, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["workspace-branding"] }); + notifications.show({ + message: t("Branding saved"), + color: "green", + }); + }, + onError: () => { + notifications.show({ + message: t("Branding update failed"), + color: "red", + }); + }, + }); + + function handleSave() { + const patch: { primaryColor?: string; accentColor?: string } = {}; + if (isValidHex(primaryColor) && primaryColor !== loadedPrimary) { + patch.primaryColor = primaryColor; + } + if (isValidHex(accentColor) && accentColor !== loadedAccent) { + patch.accentColor = accentColor; + } + if (Object.keys(patch).length === 0) return; + mutation.mutate(patch); + } + + return ( + <> + + + {t("Branding settings")} - {getAppName()} + + + + + + + {t( + "Logo upload and name-based branding override is managed from the General settings tab.", + )} + + + + + + + + + + + + + + + ); +} diff --git a/apps/client/src/theme/__tests__/brand-theme.test.ts b/apps/client/src/theme/__tests__/brand-theme.test.ts new file mode 100644 index 00000000..cce98f37 --- /dev/null +++ b/apps/client/src/theme/__tests__/brand-theme.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for brand-theme.ts — R4.4 + * + * Covers: tuple generation, env-var overrides, fallback on invalid input. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { generateColorTuple, getBrandTheme } from "../brand-theme"; + +// Mantine color tuple has exactly 10 entries. +const TUPLE_LENGTH = 10; + +describe("generateColorTuple", () => { + it("returns exactly 10 shades", () => { + const tuple = generateColorTuple("#2563eb"); + expect(tuple).toHaveLength(TUPLE_LENGTH); + }); + + it("all shades are valid hex strings", () => { + const tuple = generateColorTuple("#7c3aed"); + const hexRegex = /^#[0-9a-f]{6}$/i; + tuple.forEach((shade) => { + expect(shade).toMatch(hexRegex); + }); + }); + + it("shade[0] is lighter than shade[9] (R component higher)", () => { + const tuple = generateColorTuple("#2563eb"); + const rAt0 = parseInt(tuple[0].slice(1, 3), 16); + const rAt9 = parseInt(tuple[9].slice(1, 3), 16); + expect(rAt0).toBeGreaterThan(rAt9); + }); + + it("returns fallback gray scale for invalid hex", () => { + const tuple = generateColorTuple("notacolor"); + expect(tuple).toHaveLength(TUPLE_LENGTH); + // First shade of fallback is a light gray + expect(tuple[0]).toBe("#f8f9fa"); + }); + + it("handles uppercase hex without crashing", () => { + const tuple = generateColorTuple("#2563EB"); + expect(tuple).toHaveLength(TUPLE_LENGTH); + tuple.forEach((s) => expect(s).toMatch(/^#[0-9a-f]{6}$/i)); + }); +}); + +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; + 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(); + // 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("sets primaryColor to 'brand'", () => { + const theme = getBrandTheme(); + expect(theme.primaryColor).toBe("brand"); + }); + + it("sets primaryShade to 6", () => { + const theme = getBrandTheme(); + expect(theme.primaryShade).toBe(6); + }); +}); diff --git a/apps/client/src/theme/brand-theme.ts b/apps/client/src/theme/brand-theme.ts new file mode 100644 index 00000000..3778013b --- /dev/null +++ b/apps/client/src/theme/brand-theme.ts @@ -0,0 +1,135 @@ +/** + * brand-theme.ts — R4.4 + * + * 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. + * + * 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 + * shades) and toward black (dark shades) using a simple linear interpolation. + * This is good enough for UI tokens; we are not doing a perceptual-uniform + * lightness curve here. + */ + +import { MantineColorsTuple, MantineThemeOverride } from "@mantine/core"; + +const DEFAULT_PRIMARY = "#2563eb"; +const DEFAULT_ACCENT = "#7c3aed"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function hexToRgb(hex: string): [number, number, number] | null { + const clean = hex.replace(/^#/, ""); + if (clean.length !== 6) return null; + const r = parseInt(clean.slice(0, 2), 16); + const g = parseInt(clean.slice(2, 4), 16); + const b = parseInt(clean.slice(4, 6), 16); + if (isNaN(r) || isNaN(g) || isNaN(b)) return null; + return [r, g, b]; +} + +function rgbToHex(r: number, g: number, b: number): string { + return ( + "#" + + [r, g, b] + .map((v) => + Math.round(Math.max(0, Math.min(255, v))) + .toString(16) + .padStart(2, "0"), + ) + .join("") + ); +} + +/** + * Mix `color` toward `target` by `ratio` (0 = color, 1 = target). + */ +function mix( + color: [number, number, number], + target: [number, number, number], + ratio: number, +): string { + return rgbToHex( + color[0] + (target[0] - color[0]) * ratio, + color[1] + (target[1] - color[1]) * ratio, + color[2] + (target[2] - color[2]) * ratio, + ); +} + +const WHITE: [number, number, number] = [255, 255, 255]; +const BLACK: [number, number, number] = [0, 0, 0]; + +/** + * Generate a 10-shade MantineColorsTuple from a single hex color. + * + * Mantine uses shades[6] as the "primary" shade by convention. + * Shades 0-5 go from very light to the base color. + * Shades 6-9 go from the base color to very dark. + */ +export function generateColorTuple(hex: string): MantineColorsTuple { + const rgb = hexToRgb(hex); + if (!rgb) { + // Fallback: return a neutral gray scale — should never happen in practice. + return [ + "#f8f9fa", + "#f1f3f5", + "#e9ecef", + "#dee2e6", + "#ced4da", + "#adb5bd", + "#868e96", + "#495057", + "#343a40", + "#212529", + ] as unknown as MantineColorsTuple; + } + + // Shades 0-5: mix toward white (lightest = 0) + const lightSteps = [0.92, 0.80, 0.65, 0.50, 0.30, 0.10]; + // Shades 6-9: mix toward black (darkest = 9) + const darkSteps = [0.0, 0.15, 0.35, 0.55]; + + const shades: string[] = [ + ...lightSteps.map((ratio) => mix(rgb, WHITE, ratio)), + ...darkSteps.map((ratio) => mix(rgb, BLACK, ratio)), + ]; + + return shades as unknown as MantineColorsTuple; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * 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. + * + * Called once at startup in main.tsx. + */ +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; + + return { + colors: { + brand: generateColorTuple(primary), + accent: generateColorTuple(accent), + }, + // Map Mantine's default "blue" primary to our brand color so existing + // components that rely on color="blue" pick up the brand palette. + primaryColor: "brand", + primaryShade: 6, + }; +} diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index e6f9de48..e19191c3 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -16,6 +16,10 @@ 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 { @@ -31,6 +35,10 @@ 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), }, diff --git a/apps/server/src/core/workspace/controllers/workspace-branding.controller.ts b/apps/server/src/core/workspace/controllers/workspace-branding.controller.ts new file mode 100644 index 00000000..889ed659 --- /dev/null +++ b/apps/server/src/core/workspace/controllers/workspace-branding.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + ForbiddenException, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; +import { AuthUser } from '../../../common/decorators/auth-user.decorator'; +import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator'; +import { User, Workspace } from '@docmost/db/types/entity.types'; +import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory'; +import { + WorkspaceCaslAction, + WorkspaceCaslSubject, +} from '../../casl/interfaces/workspace-ability.type'; +import { WorkspaceBrandingService } from '../services/workspace-branding.service'; +import { UpdateWorkspaceBrandingDto } from '../dto/update-workspace-branding.dto'; + +/** + * R4.4 — Workspace branding REST endpoints. + * + * GET /workspace/branding — read current branding (any authenticated user) + * POST /workspace/branding/update — update branding (workspace admin only) + * + * Logo upload is handled separately by the existing attachment pipeline: + * POST /attachments/workspace-icon (multipart, no change in this patch) + */ +@UseGuards(JwtAuthGuard) +@Controller('workspace/branding') +export class WorkspaceBrandingController { + constructor( + private readonly brandingService: WorkspaceBrandingService, + private readonly workspaceAbility: WorkspaceAbilityFactory, + ) {} + + @HttpCode(HttpStatus.OK) + @Post() + async getBranding(@AuthWorkspace() workspace: Workspace) { + return this.brandingService.getBranding(workspace.id); + } + + @HttpCode(HttpStatus.OK) + @Post('update') + async updateBranding( + @Body() dto: UpdateWorkspaceBrandingDto, + @AuthUser() user: User, + @AuthWorkspace() workspace: Workspace, + ) { + const ability = this.workspaceAbility.createForUser(user, workspace); + if ( + ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Settings) + ) { + throw new ForbiddenException(); + } + + return this.brandingService.updateBranding(workspace.id, dto); + } +} diff --git a/apps/server/src/core/workspace/dto/update-workspace-branding.dto.ts b/apps/server/src/core/workspace/dto/update-workspace-branding.dto.ts new file mode 100644 index 00000000..27ca0902 --- /dev/null +++ b/apps/server/src/core/workspace/dto/update-workspace-branding.dto.ts @@ -0,0 +1,26 @@ +import { IsOptional, IsString, Matches, MaxLength } from 'class-validator'; + +const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/; +const HEX_MESSAGE = 'Must be a valid 6-digit CSS hex color (e.g. #2563eb)'; + +/** + * R4.4 — DTO for the workspace branding endpoint. + * + * All fields are optional: the caller may update only one at a time. + * Logo upload is handled by a separate multipart endpoint (existing attachment + * flow via uploadWorkspaceIcon on the client + POST /attachments/workspace-icon + * on the server — unchanged). + */ +export class UpdateWorkspaceBrandingDto { + @IsOptional() + @IsString() + @MaxLength(20) + @Matches(HEX_COLOR_RE, { message: HEX_MESSAGE }) + primaryColor?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + @Matches(HEX_COLOR_RE, { message: HEX_MESSAGE }) + accentColor?: string; +} diff --git a/apps/server/src/core/workspace/services/workspace-branding.service.ts b/apps/server/src/core/workspace/services/workspace-branding.service.ts new file mode 100644 index 00000000..0025181f --- /dev/null +++ b/apps/server/src/core/workspace/services/workspace-branding.service.ts @@ -0,0 +1,49 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; +import { UpdateWorkspaceBrandingDto } from '../dto/update-workspace-branding.dto'; + +/** + * R4.4 — WorkspaceBrandingService. + * + * Responsible for persisting and retrieving per-workspace brand overrides + * (primary_color, accent_color). Logo upload is already handled by the + * existing attachment pipeline (AttachmentController + uploadWorkspaceIcon). + */ +@Injectable() +export class WorkspaceBrandingService { + constructor(private readonly workspaceRepo: WorkspaceRepo) {} + + async getBranding(workspaceId: string) { + const workspace = await this.workspaceRepo.findById(workspaceId); + if (!workspace) { + throw new NotFoundException('Workspace not found'); + } + return { + logo: workspace.logo ?? null, + primaryColor: (workspace as any).primaryColor ?? null, + accentColor: (workspace as any).accentColor ?? null, + }; + } + + async updateBranding( + workspaceId: string, + dto: UpdateWorkspaceBrandingDto, + ) { + const workspace = await this.workspaceRepo.findById(workspaceId); + if (!workspace) { + throw new NotFoundException('Workspace not found'); + } + + const patch: Record = {}; + if (dto.primaryColor !== undefined) patch.primaryColor = dto.primaryColor; + if (dto.accentColor !== undefined) patch.accentColor = dto.accentColor; + + if (Object.keys(patch).length === 0) { + // Nothing to update — return current state. + return this.getBranding(workspaceId); + } + + await this.workspaceRepo.updateWorkspace(patch as any, workspaceId); + return this.getBranding(workspaceId); + } +} diff --git a/apps/server/src/core/workspace/spec/workspace-branding.spec.ts b/apps/server/src/core/workspace/spec/workspace-branding.spec.ts new file mode 100644 index 00000000..f37fa62d --- /dev/null +++ b/apps/server/src/core/workspace/spec/workspace-branding.spec.ts @@ -0,0 +1,200 @@ +/** + * workspace-branding.spec.ts — R4.4 + * + * Unit tests for WorkspaceBrandingService + WorkspaceBrandingController. + * Uses vitest + vi.fn() stubs — no real DB or HTTP server needed. + * + * Covered: + * 1. getBranding returns correct shape + * 2. getBranding throws NotFoundException when workspace missing + * 3. updateBranding patches primaryColor + * 4. updateBranding patches accentColor + * 5. updateBranding is a no-op when dto is empty (returns current state) + * 6. updateBranding throws NotFoundException when workspace missing + * 7. Controller: GET branding delegates to service + * 8. Controller: POST branding/update delegates to service when admin + * 9. Controller: POST branding/update returns 403 when not admin + * 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'; +import { UpdateWorkspaceBrandingDto } from '../dto/update-workspace-branding.dto'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +const fakeWorkspace = { + id: 'ws-uuid', + name: 'Acadenice', + logo: null, + primaryColor: null, + accentColor: null, +} as any; + +function makeWorkspaceRepo(overrides?: Partial) { + const ws = { ...fakeWorkspace, ...overrides }; + return { + findById: vi.fn(async (id: string) => (id === ws.id ? ws : null)), + updateWorkspace: vi.fn(async (patch: any, id: string) => { + Object.assign(ws, patch); + }), + }; +} + +function makeAbility(canManage = true) { + return { + createForUser: vi.fn(() => ({ + cannot: vi.fn(() => !canManage), + })), + }; +} + +// --------------------------------------------------------------------------- +// WorkspaceBrandingService +// --------------------------------------------------------------------------- + +describe('WorkspaceBrandingService', () => { + let service: WorkspaceBrandingService; + let repo: ReturnType; + + beforeEach(() => { + repo = makeWorkspaceRepo(); + service = new WorkspaceBrandingService(repo as any); + }); + + it('1. getBranding returns correct shape', async () => { + const result = await service.getBranding('ws-uuid'); + expect(result).toMatchObject({ + logo: null, + primaryColor: null, + accentColor: null, + }); + }); + + it('2. getBranding throws NotFoundException when workspace missing', async () => { + await expect(service.getBranding('bad-id')).rejects.toThrow( + NotFoundException, + ); + }); + + it('3. updateBranding patches primaryColor', async () => { + const dto = { primaryColor: '#2563eb' } as UpdateWorkspaceBrandingDto; + const result = await service.updateBranding('ws-uuid', dto); + expect(repo.updateWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ primaryColor: '#2563eb' }), + 'ws-uuid', + ); + expect(result.primaryColor).toBe('#2563eb'); + }); + + it('4. updateBranding patches accentColor', async () => { + const dto = { accentColor: '#7c3aed' } as UpdateWorkspaceBrandingDto; + const result = await service.updateBranding('ws-uuid', dto); + expect(repo.updateWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ accentColor: '#7c3aed' }), + 'ws-uuid', + ); + expect(result.accentColor).toBe('#7c3aed'); + }); + + it('5. updateBranding is a no-op when dto is empty (returns current state)', async () => { + const dto = {} as UpdateWorkspaceBrandingDto; + await service.updateBranding('ws-uuid', dto); + expect(repo.updateWorkspace).not.toHaveBeenCalled(); + }); + + it('6. updateBranding throws NotFoundException when workspace missing', async () => { + const dto = { primaryColor: '#123456' } as UpdateWorkspaceBrandingDto; + await expect(service.updateBranding('bad-id', dto)).rejects.toThrow( + NotFoundException, + ); + }); +}); + +// --------------------------------------------------------------------------- +// WorkspaceBrandingController +// --------------------------------------------------------------------------- + +describe('WorkspaceBrandingController', () => { + let controller: WorkspaceBrandingController; + let brandingService: { getBranding: ReturnType; updateBranding: ReturnType }; + let ability: ReturnType; + + const mockWorkspace = { id: 'ws-uuid' } as any; + const mockUser = { id: 'user-uuid', role: 'admin' } as any; + + beforeEach(() => { + brandingService = { + getBranding: vi.fn(async () => ({ logo: null, primaryColor: null, accentColor: null })), + updateBranding: vi.fn(async () => ({ logo: null, primaryColor: '#2563eb', accentColor: null })), + }; + ability = makeAbility(true); + controller = new WorkspaceBrandingController( + brandingService as any, + ability as any, + ); + }); + + it('7. GET branding delegates to service', async () => { + const result = await controller.getBranding(mockWorkspace); + expect(brandingService.getBranding).toHaveBeenCalledWith('ws-uuid'); + expect(result).toHaveProperty('logo'); + }); + + it('8. POST branding/update delegates to service when admin', async () => { + const dto = { primaryColor: '#2563eb' } as UpdateWorkspaceBrandingDto; + const result = await controller.updateBranding(dto, mockUser, mockWorkspace); + expect(brandingService.updateBranding).toHaveBeenCalledWith('ws-uuid', dto); + expect(result.primaryColor).toBe('#2563eb'); + }); + + it('9. POST branding/update returns ForbiddenException when not admin', async () => { + const noAdminAbility = makeAbility(false); + const restrictedController = new WorkspaceBrandingController( + brandingService as any, + noAdminAbility as any, + ); + const dto = { primaryColor: '#2563eb' } as UpdateWorkspaceBrandingDto; + await expect( + restrictedController.updateBranding(dto, mockUser, mockWorkspace), + ).rejects.toThrow(ForbiddenException); + }); +}); + +// --------------------------------------------------------------------------- +// DTO validation +// --------------------------------------------------------------------------- + +describe('UpdateWorkspaceBrandingDto validation', () => { + async function validate_(raw: object) { + const dto = plainToInstance(UpdateWorkspaceBrandingDto, raw); + return validate(dto); + } + + it('10. accepts valid hex colors', async () => { + const errors = await validate_({ primaryColor: '#2563eb', accentColor: '#7c3aed' }); + expect(errors).toHaveLength(0); + }); + + it('11. rejects non-hex string for primaryColor', async () => { + const errors = await validate_({ primaryColor: 'blue' }); + expect(errors.length).toBeGreaterThan(0); + expect(errors[0].property).toBe('primaryColor'); + }); + + it('12. rejects 3-digit short hex', async () => { + const errors = await validate_({ primaryColor: '#abc' }); + expect(errors.length).toBeGreaterThan(0); + }); + + it('13. accepts empty DTO (all fields optional)', async () => { + const errors = await validate_({}); + expect(errors).toHaveLength(0); + }); +}); diff --git a/apps/server/src/core/workspace/workspace.module.ts b/apps/server/src/core/workspace/workspace.module.ts index b4c3481a..a1927732 100644 --- a/apps/server/src/core/workspace/workspace.module.ts +++ b/apps/server/src/core/workspace/workspace.module.ts @@ -4,11 +4,13 @@ import { WorkspaceController } from './controllers/workspace.controller'; import { SpaceModule } from '../space/space.module'; import { WorkspaceInvitationService } from './services/workspace-invitation.service'; import { TokenModule } from '../auth/token.module'; +import { WorkspaceBrandingService } from './services/workspace-branding.service'; +import { WorkspaceBrandingController } from './controllers/workspace-branding.controller'; @Module({ imports: [SpaceModule, TokenModule], - controllers: [WorkspaceController], - providers: [WorkspaceService, WorkspaceInvitationService], - exports: [WorkspaceService], + controllers: [WorkspaceController, WorkspaceBrandingController], + providers: [WorkspaceService, WorkspaceInvitationService, WorkspaceBrandingService], + exports: [WorkspaceService, WorkspaceBrandingService], }) export class WorkspaceModule {} diff --git a/apps/server/src/database/migrations/20260509T140000-add-workspace-branding.ts b/apps/server/src/database/migrations/20260509T140000-add-workspace-branding.ts new file mode 100644 index 00000000..dce4785c --- /dev/null +++ b/apps/server/src/database/migrations/20260509T140000-add-workspace-branding.ts @@ -0,0 +1,31 @@ +import { Kysely, sql } from 'kysely'; + +/** + * R4.4 — Workspace branding columns. + * + * Adds two optional columns to `workspaces`: + * - primary_color : CSS hex string (e.g. "#2563eb") — overrides the default + * brand primary palette for this workspace. + * - accent_color : CSS hex string (e.g. "#7c3aed") — accent/secondary palette. + * + * The existing `logo` TEXT column already covers the workspace logo URL and is + * therefore NOT duplicated here. + * + * All DDL is idempotent (IF NOT EXISTS / DO NOTHING on constraint). + * Kysely camelCase mapping: primaryColor <-> primary_color, accentColor <-> accent_color. + */ +export async function up(db: Kysely): Promise { + await sql` + ALTER TABLE workspaces + ADD COLUMN IF NOT EXISTS primary_color VARCHAR(20), + ADD COLUMN IF NOT EXISTS accent_color VARCHAR(20) + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql` + ALTER TABLE workspaces + DROP COLUMN IF EXISTS primary_color, + DROP COLUMN IF EXISTS accent_color + `.execute(db); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index ef2c02a0..c736248a 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -395,6 +395,10 @@ export interface Workspaces { stripeCustomerId: string | null; trialEndAt: Timestamp | null; updatedAt: Generated; + /** R4.4 — optional CSS hex override for the workspace primary palette. */ + primaryColor: string | null; + /** R4.4 — optional CSS hex override for the workspace accent palette. */ + accentColor: string | null; } export interface Notifications { diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index 910222d2..62422a8c 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -131,7 +131,11 @@ export class EnvironmentService { } getMailFromName(): string { - return this.configService.get('MAIL_FROM_NAME', 'DocAdenice'); + // 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('MAIL_FROM_NAME'); + if (explicit) return explicit; + return this.configService.get('BRAND_NAME', 'AcadeDoc'); } getMailBlockedRecipientDomains(): string[] { diff --git a/apps/server/src/integrations/transactional/README.md b/apps/server/src/integrations/transactional/README.md new file mode 100644 index 00000000..bd0ad182 --- /dev/null +++ b/apps/server/src/integrations/transactional/README.md @@ -0,0 +1,109 @@ +# Transactional Mail — AcadeDoc + +## Architecture + +AcadeDoc uses NestJS + nodemailer for transactional email. Emails are rendered +with react-email and enqueued via BullMQ. The driver is selected at boot time +from `MAIL_DRIVER` (smtp | postmark | log). + +All email templates live in `emails/`. Partials (header, footer, button) are +in `partials/`. CSS helpers are in `css/`. + +--- + +## Configuring Brevo (SMTP relay) + +Brevo (ex-Sendinblue) is the recommended SMTP relay for AcadeDoc selfhost. + +### 1. Create a Brevo account + +Go to https://app.brevo.com and register. + +### 2. Generate an SMTP key + +1. Log in to Brevo dashboard. +2. Navigate to **Settings > SMTP & API > SMTP**. +3. Click **Generate a new SMTP key**. +4. Copy the key — it is shown only once. + This key is your `SMTP_PASSWORD`. It is NOT your account password. + +### 3. Set the environment variables + +```dotenv +MAIL_DRIVER=smtp +SMTP_HOST=smtp-relay.brevo.com +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_SECURE=false +SMTP_IGNORETLS=false + +MAIL_FROM_ADDRESS=noreply@yourdomain.com +MAIL_FROM_NAME=AcadeDoc +``` + +`SMTP_SECURE=false` + `SMTP_IGNORETLS=false` instructs nodemailer to upgrade +the connection to TLS via STARTTLS on port 587. This is the correct mode for +Brevo (port 587 is STARTTLS, port 465 is implicit TLS / SMTP_SECURE=true). + +### 4. Brevo free plan limits + +| Metric | Free plan | +|--------|-----------| +| Emails/day | 300 | +| Emails/month | 9 000 | +| Contacts | Unlimited | +| SMTP relay | Yes | + +For internal team usage (< 50 members), 300 emails/day is more than enough. +Upgrade if you need more capacity. + +### 5. Test from the server + +After deploying, send a test from the container/server shell: + +```bash +# Using swaks (SMTP Swiss Army Knife) +swaks \ + --to test@example.com \ + --from noreply@yourdomain.com \ + --server smtp-relay.brevo.com:587 \ + --auth LOGIN \ + --auth-user "" \ + --auth-password "" \ + --tls \ + --header "Subject: AcadeDoc SMTP test" +``` + +Or via curl (base64-encode credentials): + +```bash +curl --url "smtp://smtp-relay.brevo.com:587" \ + --ssl-reqd \ + --mail-from "noreply@yourdomain.com" \ + --mail-rcpt "test@example.com" \ + --user ":" \ + -T - < {
Hi there, - You have been invited to DocAdenice. + You have been invited to {process.env.BRAND_NAME || 'AcadeDoc'}. Please click the button below to accept this invitation. diff --git a/apps/server/src/integrations/transactional/partials/partials.tsx b/apps/server/src/integrations/transactional/partials/partials.tsx index 8bf8cbc9..0ba2c7c1 100644 --- a/apps/server/src/integrations/transactional/partials/partials.tsx +++ b/apps/server/src/integrations/transactional/partials/partials.tsx @@ -77,11 +77,12 @@ export function EmailButton({ href, children }: EmailButtonProps) { } export function MailFooter() { + const brandName = process.env.BRAND_NAME || 'AcadeDoc'; return (
- © {new Date().getFullYear()} DocAdenice, All Rights Reserved
+ © {new Date().getFullYear()} {brandName}, All Rights Reserved
diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 4d4e6629..d916ae72 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -24,8 +24,13 @@ export default defineConfig({ }, }, test: { - // Only cover acadenice specs — upstream Docmost tests use Jest - include: ["src/core/acadenice/**/*.spec.ts", "src/database/migrations/**/*.spec.ts"], + // Only cover acadenice specs — upstream Docmost tests use Jest. + // R4.4: also include workspace branding spec. + include: [ + "src/core/acadenice/**/*.spec.ts", + "src/database/migrations/**/*.spec.ts", + "src/core/workspace/spec/**/*.spec.ts", + ], globals: true, environment: "node", // Vitest needs to transform ESM-only packages