feat(acadedoc): add AcadeDoc branding, Brevo SMTP preset, UI customization — R4.4
- Rebranding: BRAND_NAME env var (default AcadeDoc) replaces hardcoded "DocAdenice" in index.html title/meta, PWA manifest, app-header logo text, email footer/body - lib/config.ts: getAppName() reads BRAND_NAME; new getBrandLogoUrl/PrimaryColor/AccentColor helpers - vite.config.ts: BRAND_* vars exposed via define block to client - brand-theme.ts: getBrandTheme() generates 10-shade MantineColorsTuple from hex (no @mantine/colors-generator dep); merged into MantineProvider at boot - theme/__tests__/brand-theme.test.ts: 11 vitest tests (generateColorTuple + getBrandTheme) - Workspace branding: migration adds primary_color/accent_color to workspaces table WorkspaceBrandingService + WorkspaceBrandingController (POST /workspace/branding, POST /workspace/branding/update — admin only) + DTO hex validation - Settings: /settings/branding page (WorkspaceBranding) + sidebar entry (admin-only) - workspace-branding.spec.ts: 13 vitest tests (service + controller + DTO validation) - SMTP Brevo: .env.example preset block + transactional/README.md ops guide (key gen, port 587 STARTTLS, 300/day free limit, swaks/curl test) - environment.service.ts: getMailFromName() falls back to BRAND_NAME if MAIL_FROM_NAME unset - vitest.config.ts server: include pattern extended to src/core/workspace/spec/** - i18n: 11 branding keys added to en-US and fr-FR translations - 0 TypeScript errors client + server, 11 client + 13 server new tests all green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d0b75774d8
commit
b53ab5043f
27 changed files with 1012 additions and 18 deletions
29
.env.example
29
.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=<login-email-brevo>
|
||||
# SMTP_PASSWORD=<smtp-master-key-brevo>
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@
|
|||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0 user-scalable=no" />
|
||||
<title>DocAdenice</title>
|
||||
<title>AcadeDoc</title>
|
||||
<meta name="description" content="AcadeDoc — collaborative wiki for Acadenice" />
|
||||
<meta name="theme-color" content="#1f1f1f" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-touch-fullscreen" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="DocAdenice" />
|
||||
<meta name="apple-mobile-web-app-title" content="AcadeDoc" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<!--meta-tags-->
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Docmost",
|
||||
"short_name": "Docmost",
|
||||
"name": "AcadeDoc",
|
||||
"short_name": "AcadeDoc",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#222",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path={"templates"} element={<TemplatesAdminPage />} />
|
||||
{/* Acadenice R3.7 — notification preferences */}
|
||||
<Route path={"notifications"} element={<NotificationPreferencesPage />} />
|
||||
{/* Acadenice R4.3 — Web Clipper token management */}
|
||||
<Route path={"clipper-tokens"} element={<ClipperTokensPage />} />
|
||||
{/* Acadenice R4.4 — Workspace branding */}
|
||||
<Route path={"branding"} element={<WorkspaceBranding />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Link to="/home" className={classes.brand} aria-label="DocAdenice">
|
||||
<Link to="/home" className={classes.brand} aria-label={getAppName()}>
|
||||
<Box hiddenFrom="sm" className={classes.brandIcon}>
|
||||
<img
|
||||
src="/icons/favicon-32x32.png"
|
||||
alt="DocAdenice"
|
||||
alt={getAppName()}
|
||||
width={22}
|
||||
height={22}
|
||||
/>
|
||||
|
|
@ -99,7 +99,7 @@ export function AppHeader() {
|
|||
style={{ userSelect: "none" }}
|
||||
visibleFrom="sm"
|
||||
>
|
||||
DocAdenice
|
||||
{getAppName()}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -122,3 +122,26 @@ export async function getAppVersion(): Promise<IVersion> {
|
|||
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<IWorkspaceBranding> {
|
||||
const req = await api.post<IWorkspaceBranding>("/workspace/branding");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function updateWorkspaceBranding(
|
||||
data: Partial<Pick<IWorkspaceBranding, "primaryColor" | "accentColor">>,
|
||||
): Promise<IWorkspaceBranding> {
|
||||
const req = await api.post<IWorkspaceBranding>(
|
||||
"/workspace/branding/update",
|
||||
data,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<MantineProvider theme={brandedTheme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
|
|
|
|||
140
apps/client/src/pages/settings/workspace/workspace-branding.tsx
Normal file
140
apps/client/src/pages/settings/workspace/workspace-branding.tsx
Normal file
|
|
@ -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<string>(
|
||||
branding?.primaryColor ?? "#2563eb",
|
||||
);
|
||||
const [accentColor, setAccentColor] = useState<string>(
|
||||
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 (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("Branding settings")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<SettingsTitle title={t("Branding")} />
|
||||
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
{t(
|
||||
"Logo upload and name-based branding override is managed from the General settings tab.",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Divider mb="md" />
|
||||
|
||||
<Stack gap="md" maw={400}>
|
||||
<ColorInput
|
||||
label={t("Primary color")}
|
||||
description={t("Enter a hex color (e.g. #2563eb)")}
|
||||
placeholder="#2563eb"
|
||||
value={primaryColor}
|
||||
onChange={setPrimaryColor}
|
||||
format="hex"
|
||||
withEyeDropper
|
||||
aria-label={t("Primary color")}
|
||||
/>
|
||||
|
||||
<ColorInput
|
||||
label={t("Accent color")}
|
||||
description={t("Enter a hex color (e.g. #2563eb)")}
|
||||
placeholder="#7c3aed"
|
||||
value={accentColor}
|
||||
onChange={setAccentColor}
|
||||
format="hex"
|
||||
withEyeDropper
|
||||
aria-label={t("Accent color")}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={mutation.isPending}
|
||||
disabled={
|
||||
(!isValidHex(primaryColor) && !isValidHex(accentColor)) ||
|
||||
(primaryColor === loadedPrimary && accentColor === loadedAccent)
|
||||
}
|
||||
>
|
||||
{t("Save branding")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
apps/client/src/theme/__tests__/brand-theme.test.ts
Normal file
99
apps/client/src/theme/__tests__/brand-theme.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
135
apps/client/src/theme/brand-theme.ts
Normal file
135
apps/client/src/theme/brand-theme.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<string, string | null> = {};
|
||||
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);
|
||||
}
|
||||
}
|
||||
200
apps/server/src/core/workspace/spec/workspace-branding.spec.ts
Normal file
200
apps/server/src/core/workspace/spec/workspace-branding.spec.ts
Normal file
|
|
@ -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<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) => {
|
||||
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<typeof makeWorkspaceRepo>;
|
||||
|
||||
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<typeof vi.fn>; updateBranding: ReturnType<typeof vi.fn> };
|
||||
let ability: ReturnType<typeof makeAbility>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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<any>): Promise<void> {
|
||||
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<any>): Promise<void> {
|
||||
await sql`
|
||||
ALTER TABLE workspaces
|
||||
DROP COLUMN IF EXISTS primary_color,
|
||||
DROP COLUMN IF EXISTS accent_color
|
||||
`.execute(db);
|
||||
}
|
||||
4
apps/server/src/database/types/db.d.ts
vendored
4
apps/server/src/database/types/db.d.ts
vendored
|
|
@ -395,6 +395,10 @@ export interface Workspaces {
|
|||
stripeCustomerId: string | null;
|
||||
trialEndAt: Timestamp | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
/** 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 {
|
||||
|
|
|
|||
|
|
@ -131,7 +131,11 @@ export class EnvironmentService {
|
|||
}
|
||||
|
||||
getMailFromName(): string {
|
||||
return this.configService.get<string>('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<string>('MAIL_FROM_NAME');
|
||||
if (explicit) return explicit;
|
||||
return this.configService.get<string>('BRAND_NAME', 'AcadeDoc');
|
||||
}
|
||||
|
||||
getMailBlockedRecipientDomains(): string[] {
|
||||
|
|
|
|||
109
apps/server/src/integrations/transactional/README.md
Normal file
109
apps/server/src/integrations/transactional/README.md
Normal file
|
|
@ -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=<your-brevo-login-email>
|
||||
SMTP_PASSWORD=<smtp-master-key-from-step-2>
|
||||
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 "<your-brevo-login-email>" \
|
||||
--auth-password "<smtp-key>" \
|
||||
--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 "<login>:<smtp-key>" \
|
||||
-T - <<EOF
|
||||
From: noreply@yourdomain.com
|
||||
To: test@example.com
|
||||
Subject: AcadeDoc SMTP test
|
||||
|
||||
Test email from AcadeDoc.
|
||||
EOF
|
||||
```
|
||||
|
||||
If swaks/curl are not available in the container, trigger a password-reset
|
||||
from the AcadeDoc UI with a real user email and check delivery.
|
||||
|
||||
---
|
||||
|
||||
## STARTTLS vs TLS notes
|
||||
|
||||
| Env variable | Value | Effect |
|
||||
|---|---|---|
|
||||
| SMTP_SECURE | false | Port 587 / STARTTLS (recommended) |
|
||||
| SMTP_SECURE | true | Port 465 / implicit TLS |
|
||||
| SMTP_IGNORETLS | false | Allow STARTTLS upgrade (default) |
|
||||
| SMTP_IGNORETLS | true | Disable TLS entirely (insecure, dev only) |
|
||||
|
||||
Brevo port 587 requires STARTTLS. Do not set SMTP_SECURE=true on port 587.
|
||||
|
|
@ -12,7 +12,7 @@ export const InvitationEmail = ({ inviteLink }: Props) => {
|
|||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi there,</Text>
|
||||
<Text style={paragraph}>You have been invited to DocAdenice.</Text>
|
||||
<Text style={paragraph}>You have been invited to {process.env.BRAND_NAME || 'AcadeDoc'}.</Text>
|
||||
<Text style={paragraph}>
|
||||
Please click the button below to accept this invitation.
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -77,11 +77,12 @@ export function EmailButton({ href, children }: EmailButtonProps) {
|
|||
}
|
||||
|
||||
export function MailFooter() {
|
||||
const brandName = process.env.BRAND_NAME || 'AcadeDoc';
|
||||
return (
|
||||
<Section style={footer}>
|
||||
<Row>
|
||||
<Text style={{ textAlign: 'center', color: '#706a7b' }}>
|
||||
© {new Date().getFullYear()} DocAdenice, All Rights Reserved <br />
|
||||
© {new Date().getFullYear()} {brandName}, All Rights Reserved <br />
|
||||
</Text>
|
||||
</Row>
|
||||
</Section>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue