feat(t-paliad-065): firm-agnostic branding via single FIRM_NAME constant

Paliad ships firm-agnostic per CLAUDE.md ("survives firm renames") but
landing copy, email templates, page titles, and form placeholders still
hard-coded "Hogan Lovells" / "HL Patents". Replaces every user-facing
firm reference with a single source of truth: internal/branding.Name on
the server and frontend/src/branding.ts in the bundle, both reading
FIRM_NAME at startup/build time and defaulting to "HLC".

Server: branding package + boot log; auth, invite, admin_users error
strings; courts/offices/models comments; mail templates thread
{{.Firm}} via injected payload default. Files handler keeps the
upstream "HL Patents Style.dotm" path (must match mWorkRepo's blob
name) but renders the user-visible DownloadName from branding.Name.

Frontend: branding.ts read via Bun.build define so process.env.FIRM_NAME
is statically substituted into client bundles (no runtime process
reference); index/login/downloads/kostenrechner/Sidebar/ProjectFormFields
and every i18n.ts string templated against ${FIRM}.

ALLOWED_EMAIL_DOMAINS whitelist intentionally untouched — email
domains and display name rotate independently.

Verified: go build/vet/test clean; bun run build clean; FIRM_NAME=Acme
override produces "Acme" in HTML and JS bundles end-to-end.
This commit is contained in:
m
2026-04-28 22:44:06 +02:00
parent 4a84814b1d
commit 495e519475
25 changed files with 229 additions and 70 deletions

View File

@@ -29,7 +29,7 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
- **Backend:** Go API, `net/http`, `sqlx` for DB access
- **Migrations:** `golang-migrate/migrate/v4` with SQL files embedded via `embed.FS`; applied at server startup before the HTTP listener binds. Migration tracker is `paliad.paliad_schema_migrations` (avoids collision with other apps on the shared `public.schema_migrations`).
- **Database:** youpc Supabase Postgres (port 11833), `paliad` schema. Team-based RLS via `paliad.can_see_project(project_id)` — visibility determined by team membership (direct + inherited up the project tree). See `docs/design-data-model-v2.md`.
- **Auth:** Supabase (youpc instance) — password-based, `@hoganlovells.com` gate (TBD: update to `@hlc.*` post-merger)
- **Auth:** Supabase (youpc instance) — password-based, email-domain gate via `ALLOWED_EMAIL_DOMAINS` (default `hoganlovells.com,hlc.com,hlc.de`). The whitelist references real DNS domains and rotates independently from `FIRM_NAME` (display name).
- **Hosting:** Dokploy compose on mlake (72.62.52.189), compose ID `Zx147ycurfYagKRl_Zzyo`
- **Domains on Dokploy:** paliad.de (primary, Let's Encrypt), patholo.de (legacy), patholo.msbls.de (internal)
- **Deploy:** push to main → Gitea webhook → Dokploy auto-deploy
@@ -47,6 +47,7 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
| `ANTHROPIC_API_KEY` | not used today | Reserved for Phase H (AI Frist-Extraktion) which is deferred per m's 2026-04-16 decision. Do not set. |
| `FIRM_NAME` | optional (default `HLC`) | Display name of the firm Paliad is being branded for in this deployment. Read once at process start by `internal/branding.Name` (Go) and inlined into client bundles by `frontend/build.ts` (TypeScript). Powers every user-facing surface — landing hero, page titles, login hint, Downloads page, footer, invitation/reminder email bodies. The `ALLOWED_EMAIL_DOMAINS` whitelist is a separate concern (real DNS domains, not display name) and rotates independently. |
> *Note on `DATABASE_URL`:* "Work without DB" ≠ "ungated". All knowledge-platform routes (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) are still behind the auth gate (302 to `/login` for anon visitors); only `/`, `/login`, `/logout`, and `/assets/*` are public. The `gateOnboarded` middleware additionally blocks unonboarded users from app pages but does NOT gate the knowledge-platform pages.
@@ -54,7 +55,7 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
- **Gitea:** `mAi/paliad` on mgit.msbls.de (renamed from mAi/patholo — auto-redirects)
- **DNS:** paliad.de → 72.62.52.189 (via Hostinger)
- **Branding:** lime green accent (`#c6f41c`), sidebar layout, DE/EN i18n
- **Branding:** lime green accent (`#c6f41c`), sidebar layout, DE/EN i18n. Firm-agnostic: every user-facing firm reference is rendered from `internal/branding.Name` (Go) / `frontend/src/branding.ts` (TypeScript). Default "HLC", overridable via `FIRM_NAME`. See t-paliad-065.
## Phase status

View File

@@ -16,6 +16,7 @@ import (
_ "time/tzdata"
"mgit.msbls.de/m/patholo/internal/auth"
"mgit.msbls.de/m/patholo/internal/branding"
"mgit.msbls.de/m/patholo/internal/db"
"mgit.msbls.de/m/patholo/internal/handlers"
"mgit.msbls.de/m/patholo/internal/services"
@@ -27,6 +28,10 @@ func main() {
port = "8080"
}
// Surface the firm name in the boot log so a deployer can confirm
// FIRM_NAME took effect without curl-ing a rendered page.
log.Printf("branding: firm=%q (override with FIRM_NAME)", branding.Name)
supabaseURL := os.Getenv("SUPABASE_URL")
supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY")
if supabaseURL == "" || supabaseAnonKey == "" {

View File

@@ -55,11 +55,20 @@ const BUILD_FORMAT = "iife" as const;
// today with minify: true) or `(function(){...})()`. Match either prologue.
const IIFE_PROLOGUE = /^(\(\(\)\s*=>\s*\{|\(function\s*\(\s*\)\s*\{)/;
// Resolve FIRM_NAME once so both the client bundle's `define` substitution
// and the server-side TSX render see the same value. Mirrors the server's
// internal/branding/firm.go default — the two MUST stay in sync because
// users compare a rendered email body against a rendered HTML page and a
// drifted default would produce two different firm names per deploy.
const FIRM_NAME = (process.env.FIRM_NAME ?? "").trim() || "HLC";
async function build() {
// Clean dist/
await rm(DIST, { recursive: true, force: true });
await mkdir(join(DIST, "assets"), { recursive: true });
console.log(`branding: firm="${FIRM_NAME}" (override with FIRM_NAME env)`);
// Bundle client-side JS
const result = await Bun.build({
entrypoints: [
@@ -107,6 +116,13 @@ async function build() {
// depends on IIFE wrapping. Reuses the single-source-of-truth constant
// so the post-build guard below can detect a format swap.
format: BUILD_FORMAT,
// Inline the resolved firm name into every browser bundle. branding.ts
// reads `process.env.FIRM_NAME`, which Bun's bundler does NOT replace by
// default for browser targets — so without `define`, client code would
// see undefined and fall back to "HLC" regardless of FIRM_NAME.
define: {
"process.env.FIRM_NAME": JSON.stringify(FIRM_NAME),
},
});
if (!result.success) {

31
frontend/src/branding.ts Normal file
View File

@@ -0,0 +1,31 @@
// frontend/src/branding.ts — single source of truth for the firm name
// Paliad's UI renders. Mirrors internal/branding/firm.go on the server.
//
// At build time this resolves twice:
// 1. In the server-side render path (build.ts → renderXxx() returning HTML)
// Bun is running under Node, so process.env.FIRM_NAME is the real env
// var the deployer set; this file is loaded as a regular ESM module.
// 2. In the bundled client modules (e.g. client/i18n.ts) Bun.build replaces
// `process.env.FIRM_NAME` with a string literal via the `define` option
// configured in build.ts. Browsers never see process.env — every
// reference is statically substituted before the bundle is emitted.
//
// Both paths default to "HLC" when FIRM_NAME is unset.
//
// IMPORTANT: do NOT guard the read with `typeof process !== "undefined"` or
// any check on `process` itself. The minifier rewrites that guard into a
// short-string lexical comparison (`typeof process < "u"`) which evaluates
// false in the browser and would short-circuit the value back to "HLC" even
// when define has substituted the env var. The bare `process.env.FIRM_NAME`
// reference is only safe because build.ts's `define` rewrites it away
// completely for browser bundles.
//
// Why a runtime constant rather than i18n placeholder substitution: every
// Paliad surface (HTML title, hero headline, email body, PDF footer) has the
// firm name baked in literally; threading {{firm}} placeholders + a
// formatter through every t() call would be a far larger churn for the same
// firm-agnostic outcome. Re-deploying with FIRM_NAME=Acme rebuilds every
// asset with the new name in one step.
const RAW: string = (process.env.FIRM_NAME ?? "").trim();
export const FIRM: string = RAW !== "" ? RAW : "HLC";

View File

@@ -1,6 +1,8 @@
// i18n — Client-side internationalization for paliad
// Supports DE (German) and EN (English)
import { FIRM } from "../branding";
export type Lang = "de" | "en";
const STORAGE_KEY = "paliad-lang";
@@ -62,13 +64,13 @@ const translations: Record<Lang, Record<string, string>> = {
"footer.text": "\u00a9 2026 Paliad \u2014 ein Werkzeug von",
// Landing page
"index.title": "Paliad \u2014 Patentwissen f\u00fcr Hogan Lovells",
"index.hero.accent": "f\u00fcr Hogan Lovells",
"index.hero.sub": "Leitf\u00e4den, Vorlagen und Dokumente f\u00fcr das HL Patent-Team.",
"index.title": `Paliad \u2014 Patentwissen f\u00fcr ${FIRM}`,
"index.hero.accent": `f\u00fcr ${FIRM}`,
"index.hero.sub": `Leitf\u00e4den, Vorlagen und Dokumente f\u00fcr das ${FIRM} Patent-Team.`,
"index.guides.title": "Leitf\u00e4den",
"index.guides.desc": "Praxisleitf\u00e4den zu Verfahren vor dem EPA, BPatG und UPC. Schritt-f\u00fcr-Schritt-Anleitungen f\u00fcr typische Workflows.",
"index.templates.title": "Vorlagen",
"index.templates.desc": "Standardisierte Vorlagen f\u00fcr Schrifts\u00e4tze, Korrespondenz und interne Dokumente. HL Patents Style Guide.",
"index.templates.desc": `Standardisierte Vorlagen f\u00fcr Schrifts\u00e4tze, Korrespondenz und interne Dokumente. ${FIRM} Patents Style Guide.`,
"index.documents.title": "Dokumente",
"index.documents.desc": "Referenzmaterialien, Checklisten und Arbeitshilfen f\u00fcr den Praxisalltag im Patentrecht.",
"index.tools": "Werkzeuge",
@@ -79,8 +81,8 @@ const translations: Record<Lang, Record<string, string>> = {
"index.glossar.title": "Patentglossar",
"index.glossar.desc": "Zweisprachiges DE/EN-Glossar der wichtigsten Begriffe im Patentrecht. Durchsuchbar nach Kategorien.",
"index.downloads": "Downloads",
"index.style.title": "HL Patents Style",
"index.style.desc": "Word-Vorlage im HL Patents Style. Formatierung, Schriftarten und Makros f\u00fcr standardisierte Schrifts\u00e4tze.",
"index.style.title": `${FIRM} Patents Style`,
"index.style.desc": `Word-Vorlage im ${FIRM} Patents Style. Formatierung, Schriftarten und Makros f\u00fcr standardisierte Schrifts\u00e4tze.`,
"index.offices": "Standorte",
"index.office.munich": "M\u00fcnchen",
"index.office.duesseldorf": "D\u00fcsseldorf",
@@ -102,7 +104,7 @@ const translations: Record<Lang, Record<string, string>> = {
"login.confirm.placeholder": "Passwort wiederholen",
"login.minchars": "Mind. 8 Zeichen",
"login.register.submit": "Registrieren",
"login.hint": "Nur f\u00fcr autorisierte HLC-E-Mail-Adressen.",
"login.hint": `Nur f\u00fcr autorisierte ${FIRM}-E-Mail-Adressen.`,
"login.error.connection": "Verbindungsfehler. Bitte versuchen Sie es erneut.",
"login.error.mismatch": "Passw\u00f6rter stimmen nicht \u00fcberein.",
"login.error.minlength": "Passwort muss mindestens 8 Zeichen lang sein.",
@@ -211,9 +213,9 @@ const translations: Record<Lang, Record<string, string>> = {
// Downloads
"downloads.title": "Downloads \u2014 Paliad",
"downloads.heading": "Downloads",
"downloads.subtitle": "Dateien und Vorlagen f\u00fcr das HL Patent-Team.",
"downloads.style.title": "HL Patents Style",
"downloads.style.desc": "Word-Vorlage im HL Patents Style. Formatierung, Schriftarten und Makros f\u00fcr standardisierte Schrifts\u00e4tze.",
"downloads.subtitle": `Dateien und Vorlagen f\u00fcr das ${FIRM} Patent-Team.`,
"downloads.style.title": `${FIRM} Patents Style`,
"downloads.style.desc": `Word-Vorlage im ${FIRM} Patents Style. Formatierung, Schriftarten und Makros f\u00fcr standardisierte Schrifts\u00e4tze.`,
"downloads.btn": "Herunterladen",
// Links
@@ -396,7 +398,7 @@ const translations: Record<Lang, Record<string, string>> = {
"gerichte.field.fax": "Fax",
"gerichte.field.filing": "Einreichung",
"gerichte.field.notes": "Praktische Hinweise",
"gerichte.field.hlContact": "HL-Ansprechpartner",
"gerichte.field.hlContact": `${FIRM}-Ansprechpartner`,
"gerichte.feedback.btn": "Korrektur vorschlagen",
"gerichte.feedback.title": "Korrektur vorschlagen",
"gerichte.feedback.court": "Gericht",
@@ -451,7 +453,7 @@ const translations: Record<Lang, Record<string, string>> = {
"akten.field.title": "Titel",
"akten.field.title.placeholder": "Kurzbezeichnung des Mandats",
"akten.field.ref": "Aktenzeichen",
"akten.field.ref.placeholder": "z.\u202fB. HL-2026-0042",
"akten.field.ref.placeholder": `z.\u202fB. ${FIRM}-2026-0042`,
"akten.field.office": "Federf\u00fchrendes B\u00fcro",
"akten.field.status": "Status",
"akten.field.court": "Gericht (optional)",
@@ -874,10 +876,10 @@ const translations: Record<Lang, Record<string, string>> = {
"projekte.field.title": "Titel",
"projekte.field.title.placeholder": "z.B. Siemens AG | Siemens v. Huawei | EP 1 234 567",
"projekte.field.reference": "Interne Referenz (optional)",
"projekte.field.reference.placeholder": "z.B. HL-2026-0042",
"projekte.field.reference.placeholder": `z.B. ${FIRM}-2026-0042`,
"projekte.field.client_number": "Client-Nr. (7 Ziffern)",
"projekte.field.matter_number": "Matter-Nr. (7 Ziffern)",
"projekte.field.clientmatter.hint": "HLC-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt (\u00fcberschreibbar).",
"projekte.field.clientmatter.hint": `${FIRM}-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt (\u00fcberschreibbar).`,
"projekte.field.billing_reference": "Billing-Referenz (optional)",
"projekte.field.netdocuments_url": "netDocuments-URL (optional)",
"projekte.field.industry": "Branche",
@@ -1014,7 +1016,7 @@ const translations: Record<Lang, Record<string, string>> = {
// Invitation modal (sidebar)
"invite.button": "Kolleg:in einladen",
"invite.modal.title": "Kolleg:in zu Paliad einladen",
"invite.modal.body": "Senden Sie eine Einladung an eine HLC-E-Mail-Adresse. Die Empf\u00e4nger:in erh\u00e4lt einen Registrierungslink.",
"invite.modal.body": `Senden Sie eine Einladung an eine ${FIRM}-E-Mail-Adresse. Die Empf\u00e4nger:in erh\u00e4lt einen Registrierungslink.`,
"invite.modal.email": "E-Mail-Adresse",
"invite.modal.message": "Pers\u00f6nliche Nachricht (optional)",
"invite.modal.message.placeholder": "Hi, ich nutze Paliad f\u00fcr die Aktenverwaltung \u2014 schau es dir mal an.",
@@ -1307,13 +1309,13 @@ const translations: Record<Lang, Record<string, string>> = {
"footer.text": "\u00a9 2026 Paliad \u2014 a tool by",
// Landing page
"index.title": "Paliad \u2014 Patent Knowledge for Hogan Lovells",
"index.hero.accent": "for Hogan Lovells",
"index.hero.sub": "Guides, templates, and documents for the HL patent team.",
"index.title": `Paliad \u2014 Patent Knowledge for ${FIRM}`,
"index.hero.accent": `for ${FIRM}`,
"index.hero.sub": `Guides, templates, and documents for the ${FIRM} patent team.`,
"index.guides.title": "Guides",
"index.guides.desc": "Practical guides for proceedings before the EPO, Federal Patent Court, and UPC. Step-by-step instructions for typical workflows.",
"index.templates.title": "Templates",
"index.templates.desc": "Standardised templates for briefs, correspondence, and internal documents. HL Patents Style Guide.",
"index.templates.desc": `Standardised templates for briefs, correspondence, and internal documents. ${FIRM} Patents Style Guide.`,
"index.documents.title": "Documents",
"index.documents.desc": "Reference materials, checklists, and practical aids for day-to-day patent practice.",
"index.tools": "Tools",
@@ -1324,8 +1326,8 @@ const translations: Record<Lang, Record<string, string>> = {
"index.glossar.title": "Patent Glossary",
"index.glossar.desc": "Bilingual DE/EN glossary of key patent law terminology. Searchable by category.",
"index.downloads": "Downloads",
"index.style.title": "HL Patents Style",
"index.style.desc": "Word template in HL Patents style. Formatting, fonts, and macros for standardised briefs.",
"index.style.title": `${FIRM} Patents Style`,
"index.style.desc": `Word template in ${FIRM} Patents style. Formatting, fonts, and macros for standardised briefs.`,
"index.offices": "Offices",
"index.office.munich": "Munich",
"index.office.duesseldorf": "D\u00fcsseldorf",
@@ -1347,7 +1349,7 @@ const translations: Record<Lang, Record<string, string>> = {
"login.confirm.placeholder": "Repeat password",
"login.minchars": "Min. 8 characters",
"login.register.submit": "Register",
"login.hint": "Only for authorised HLC email addresses.",
"login.hint": `Only for authorised ${FIRM} email addresses.`,
"login.error.connection": "Connection error. Please try again.",
"login.error.mismatch": "Passwords do not match.",
"login.error.minlength": "Password must be at least 8 characters.",
@@ -1456,9 +1458,9 @@ const translations: Record<Lang, Record<string, string>> = {
// Downloads
"downloads.title": "Downloads \u2014 Paliad",
"downloads.heading": "Downloads",
"downloads.subtitle": "Files and templates for the HL patent team.",
"downloads.style.title": "HL Patents Style",
"downloads.style.desc": "Word template in HL Patents style. Formatting, fonts, and macros for standardised briefs.",
"downloads.subtitle": `Files and templates for the ${FIRM} patent team.`,
"downloads.style.title": `${FIRM} Patents Style`,
"downloads.style.desc": `Word template in ${FIRM} Patents style. Formatting, fonts, and macros for standardised briefs.`,
"downloads.btn": "Download",
// Links
@@ -1641,7 +1643,7 @@ const translations: Record<Lang, Record<string, string>> = {
"gerichte.field.fax": "Fax",
"gerichte.field.filing": "Filing",
"gerichte.field.notes": "Practical notes",
"gerichte.field.hlContact": "HL contact",
"gerichte.field.hlContact": `${FIRM} contact`,
"gerichte.feedback.btn": "Suggest a correction",
"gerichte.feedback.title": "Suggest a correction",
"gerichte.feedback.court": "Court",
@@ -1696,7 +1698,7 @@ const translations: Record<Lang, Record<string, string>> = {
"akten.field.title": "Title",
"akten.field.title.placeholder": "Short name for the matter",
"akten.field.ref": "Reference number",
"akten.field.ref.placeholder": "e.g. HL-2026-0042",
"akten.field.ref.placeholder": `e.g. ${FIRM}-2026-0042`,
"akten.field.office": "Owning office",
"akten.field.status": "Status",
"akten.field.court": "Court (optional)",
@@ -2115,10 +2117,10 @@ const translations: Record<Lang, Record<string, string>> = {
"projekte.field.title": "Title",
"projekte.field.title.placeholder": "e.g. Siemens AG | Siemens v. Huawei | EP 1 234 567",
"projekte.field.reference": "Internal reference (optional)",
"projekte.field.reference.placeholder": "e.g. HL-2026-0042",
"projekte.field.reference.placeholder": `e.g. ${FIRM}-2026-0042`,
"projekte.field.client_number": "Client no. (7 digits)",
"projekte.field.matter_number": "Matter no. (7 digits)",
"projekte.field.clientmatter.hint": "HLC billing numbers. Format CCCCCCC.MMMMMMM. Client no. is inherited by sub-projects (overridable).",
"projekte.field.clientmatter.hint": `${FIRM} billing numbers. Format CCCCCCC.MMMMMMM. Client no. is inherited by sub-projects (overridable).`,
"projekte.field.billing_reference": "Billing reference (optional)",
"projekte.field.netdocuments_url": "netDocuments URL (optional)",
"projekte.field.industry": "Industry",
@@ -2255,7 +2257,7 @@ const translations: Record<Lang, Record<string, string>> = {
// Invitation modal (sidebar)
"invite.button": "Invite a colleague",
"invite.modal.title": "Invite a colleague to Paliad",
"invite.modal.body": "Send an invitation to an HLC email address. The recipient will receive a registration link.",
"invite.modal.body": `Send an invitation to an ${FIRM} email address. The recipient will receive a registration link.`,
"invite.modal.email": "Email address",
"invite.modal.message": "Personal message (optional)",
"invite.modal.message.placeholder": "Hi, I'm using Paliad for matter management \u2014 take a look.",

View File

@@ -1,4 +1,5 @@
import { h } from "../jsx";
import { FIRM } from "../branding";
// Reusable Project form body. Renders the field grid only — the surrounding
// <form>, submit/cancel buttons and the form-msg paragraph belong to the
@@ -55,7 +56,7 @@ export function ProjectFormFields(): string {
<input
type="text"
id="project-ref"
placeholder="z.B. HL-2026-0042"
placeholder={`z.B. ${FIRM}-2026-0042`}
data-i18n-placeholder="projekte.field.reference.placeholder"
/>
</div>
@@ -83,8 +84,8 @@ export function ProjectFormFields(): string {
</div>
</div>
<p className="form-hint" data-i18n="projekte.field.clientmatter.hint">
HLC-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt
(&uuml;berschreibbar).
{`${FIRM}-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt
berschreibbar).`}
</p>
<div className="form-field">
@@ -101,7 +102,7 @@ export function ProjectFormFields(): string {
<input
type="url"
id="project-netdocs"
placeholder="https://netdocs.hoganlovells.com/..."
placeholder="https://netdocs.example.com/..."
/>
</div>

View File

@@ -1,4 +1,5 @@
import { h, Fragment } from "../jsx";
import { FIRM } from "../branding";
const ICON_HOME = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>';
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
@@ -196,7 +197,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
<button className="modal-close" id="invite-modal-close" type="button" aria-label="Close">&times;</button>
</div>
<p data-i18n="invite.modal.body" className="invite-modal-body">
Senden Sie eine Einladung an eine HLC-E-Mail-Adresse. Die Empf&auml;nger:in erh&auml;lt einen Registrierungslink.
{`Senden Sie eine Einladung an eine ${FIRM}-E-Mail-Adresse. Die Empfänger:in erhält einen Registrierungslink.`}
</p>
<form id="invite-form" className="akten-form" autocomplete="off">
<div className="form-field">

View File

@@ -3,6 +3,7 @@ import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
import { FIRM } from "./branding";
const ICON_WORD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13l1.5 5 1.5-4 1.5 4 1.5-5"/></svg>';
@@ -15,14 +16,16 @@ interface DownloadFile {
descDE: string;
}
// URL slug stays "hl-patents-style.dotm" — it's a stable public identifier
// that bookmarks point at; the user-facing title/description are firm-agnostic.
const files: DownloadFile[] = [
{
href: "/files/hl-patents-style.dotm",
icon: ICON_WORD,
titleKey: "downloads.style.title",
titleDE: "HL Patents Style",
titleDE: `${FIRM} Patents Style`,
descKey: "downloads.style.desc",
descDE: "Word-Vorlage im HL Patents Style. Formatierung, Schriftarten und Makros f&uuml;r standardisierte Schrifts&auml;tze.",
descDE: `Word-Vorlage im ${FIRM} Patents Style. Formatierung, Schriftarten und Makros für standardisierte Schriftsätze.`,
},
];
@@ -49,7 +52,7 @@ export function renderDownloads(): string {
<div className="tool-header">
<h1 data-i18n="downloads.heading">Downloads</h1>
<p className="tool-subtitle" data-i18n="downloads.subtitle">
Dateien und Vorlagen f&uuml;r das HL Patent-Team.
{`Dateien und Vorlagen für das ${FIRM} Patent-Team.`}
</p>
</div>

View File

@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
import { FIRM } from "./branding";
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/><path d="M8 7h6"/><path d="M8 11h4"/></svg>';
const ICON_FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
@@ -24,7 +25,7 @@ export function renderIndex(): string {
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="index.title">Paliad &mdash; Patentwissen f&uuml;r Hogan Lovells</title>
<title data-i18n="index.title">{`Paliad — Patentwissen für ${FIRM}`}</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
@@ -33,9 +34,9 @@ export function renderIndex(): string {
<main>
<section className="hero">
<div className="container">
<h1>Patent Knowledge<br /><span className="hero-accent" data-i18n="index.hero.accent">f&uuml;r Hogan Lovells</span></h1>
<h1>Patent Knowledge<br /><span className="hero-accent" data-i18n="index.hero.accent">{`für ${FIRM}`}</span></h1>
<p className="hero-sub" data-i18n="index.hero.sub">
Leitf&auml;den, Vorlagen und Dokumente f&uuml;r das HL Patent-Team.
{`Leitfäden, Vorlagen und Dokumente für das ${FIRM} Patent-Team.`}
</p>
</div>
</section>
@@ -52,7 +53,7 @@ export function renderIndex(): string {
<div className="card">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_FILE }} />
<h2 data-i18n="index.templates.title">Vorlagen</h2>
<p data-i18n="index.templates.desc">Standardisierte Vorlagen f&uuml;r Schrifts&auml;tze, Korrespondenz und interne Dokumente. HL Patents Style Guide.</p>
<p data-i18n="index.templates.desc">{`Standardisierte Vorlagen für Schriftsätze, Korrespondenz und interne Dokumente. ${FIRM} Patents Style Guide.`}</p>
</div>
<div className="card">
@@ -113,8 +114,8 @@ export function renderIndex(): string {
<div className="grid grid-2">
<a href="/files/hl-patents-style.dotm" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_DOWNLOAD }} />
<h2 data-i18n="index.style.title">HL Patents Style</h2>
<p data-i18n="index.style.desc">Word-Vorlage im HL Patents Style. Formatierung, Schriftarten und Makros f&uuml;r standardisierte Schrifts&auml;tze.</p>
<h2 data-i18n="index.style.title">{`${FIRM} Patents Style`}</h2>
<p data-i18n="index.style.desc">{`Word-Vorlage im ${FIRM} Patents Style. Formatierung, Schriftarten und Makros für standardisierte Schriftsätze.`}</p>
</a>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
import { FIRM } from "./branding";
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="10" x2="8" y2="10.01"/><line x1="12" y1="10" x2="12" y2="10.01"/><line x1="16" y1="10" x2="16" y2="10.01"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
@@ -233,7 +234,7 @@ export function renderKostenrechner(): string {
<div className="print-footer" id="print-footer">
<span data-i18n="kosten.print.disclaimer">Dieses Dokument dient ausschlie&szlig;lich der internen Verwendung und stellt keine Rechtsberatung dar. Alle Angaben ohne Gew&auml;hr.</span>
<span>&copy; 2026 Paliad &mdash; Hogan Lovells</span>
<span>{`© 2026 Paliad${FIRM}`}</span>
</div>
<Footer />

View File

@@ -2,6 +2,7 @@ import { h } from "./jsx";
import { Header } from "./components/Header";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
import { FIRM } from "./branding";
export function renderLogin(loginJs: string): string {
return "<!DOCTYPE html>" + (
@@ -44,7 +45,7 @@ export function renderLogin(loginJs: string): string {
<button type="submit" className="login-button" data-i18n="login.register.submit">Registrieren</button>
</form>
<p className="login-hint" data-i18n="login.hint">{"Nur f\u00FCr autorisierte HLC-E-Mail-Adressen."}</p>
<p className="login-hint" data-i18n="login.hint">{`Nur f\u00FCr autorisierte ${FIRM}-E-Mail-Adressen.`}</p>
</div>
</main>

View File

@@ -1,7 +1,11 @@
/* Paliad — Patent Knowledge for HLC */
/* Paliad — Patent Knowledge platform.
Firm name is rendered at runtime from FIRM_NAME (see internal/branding +
frontend/src/branding.ts). Default: "HLC". */
:root {
/* HLC brand palette (4 colors).
/* Brand palette (4 colors). Token names use the --hlc- prefix as a
stable internal identifier — not a firm-specific reference; renaming
the prefix would touch every CSS rule for no user-visible benefit.
Lime + midnight are the primary pair. Cyan + cream are supporting. */
--hlc-lime: #BFF355;
--hlc-midnight: #002236;

32
internal/branding/firm.go Normal file
View File

@@ -0,0 +1,32 @@
// Package branding is the single source of truth for the firm name that
// Paliad's UI, emails, and download metadata render. Paliad is firm-agnostic
// (per the project CLAUDE.md — "survives firm renames"); reading the name
// through this package keeps every surface in sync and lets a redeploy with
// a different FIRM_NAME repoint the whole product without code changes.
//
// Default is "HLC" (current firm). Override with the FIRM_NAME env var.
//
// History: until 2026-04-16 this codebase shipped "Hogan Lovells" / "HL"
// hard-coded across server templates and the frontend. The merger announced
// that month made those references stale and the rebrand to Paliad-the-name
// + branding.Name-as-runtime-value followed (t-paliad-065).
package branding
import (
"os"
"strings"
)
// Name is the firm Paliad is being branded for in this deployment. Read once
// at process start so handler hot paths don't pay the env-lookup cost.
//
// Consumers must treat it as a constant for the lifetime of the process — if
// FIRM_NAME changes on disk, that's a redeploy, not a hot-reload.
var Name = resolveName()
func resolveName() string {
if v := strings.TrimSpace(os.Getenv("FIRM_NAME")); v != "" {
return v
}
return "HLC"
}

View File

@@ -0,0 +1,32 @@
package branding
import (
"testing"
)
func TestResolveName(t *testing.T) {
cases := []struct {
name string
env string
envSet bool
expected string
}{
{"unset → default HLC", "", false, "HLC"},
{"empty string → default HLC", "", true, "HLC"},
{"whitespace only → default HLC", " ", true, "HLC"},
{"override applied", "Acme Patents", true, "Acme Patents"},
{"override trimmed", " Acme ", true, "Acme"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.envSet {
t.Setenv("FIRM_NAME", tc.env)
} else {
t.Setenv("FIRM_NAME", "")
}
if got := resolveName(); got != tc.expected {
t.Errorf("resolveName() = %q, want %q", got, tc.expected)
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"mgit.msbls.de/m/patholo/internal/branding"
"mgit.msbls.de/m/patholo/internal/services"
)
@@ -59,7 +60,7 @@ func handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
}
if !isAllowedEmailDomain(input.Email) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "email domain not on the HLC allow-list",
"error": "email domain not on the " + branding.Name + " allow-list",
})
return
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"mgit.msbls.de/m/patholo/internal/auth"
"mgit.msbls.de/m/patholo/internal/branding"
)
func handleLoginPage(w http.ResponseWriter, r *http.Request) {
@@ -37,7 +38,7 @@ func handleAPILogin(w http.ResponseWriter, r *http.Request) {
}
if !isAllowedEmailDomain(req.Email) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Zugang nur für autorisierte HLC-E-Mail-Adressen."})
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Zugang nur für autorisierte " + branding.Name + "-E-Mail-Adressen."})
return
}
@@ -78,7 +79,7 @@ func handleAPIRegister(w http.ResponseWriter, r *http.Request) {
}
if !isAllowedEmailDomain(req.Email) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Registrierung nur für autorisierte HLC-E-Mail-Adressen."})
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Registrierung nur für autorisierte " + branding.Name + "-E-Mail-Adressen."})
return
}
@@ -110,10 +111,13 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusFound)
}
// isAllowedEmailDomain gates sign-in/register to the HLC email domains.
// isAllowedEmailDomain gates sign-in/register to the firm's email domains.
// Whitelist is configurable via ALLOWED_EMAIL_DOMAINS (comma-separated),
// defaulting to hoganlovells.com,hlc.com,hlc.de so existing Hogan Lovells
// addresses keep working during the post-merger transition.
// defaulting to hoganlovells.com,hlc.com,hlc.de so legacy and post-merger
// addresses keep working until IT finishes the domain consolidation.
// Note: this whitelist intentionally references real DNS domains, not
// branding.Name — the firm's email domains and the firm's display name are
// separate concerns and rotate on different cadences.
func isAllowedEmailDomain(email string) bool {
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 {

View File

@@ -13,8 +13,9 @@ import (
"mgit.msbls.de/m/patholo/internal/auth"
)
// Court represents a court, division, or registry relevant to HL's patent practice.
// Fields left empty where details could not be reliably verified at build time.
// Court represents a court, division, or registry relevant to the firm's
// patent practice. Fields left empty where details could not be reliably
// verified at build time.
type Court struct {
ID string `json:"id"`
NameDE string `json:"nameDE"`
@@ -32,7 +33,7 @@ type Court struct {
Filing string `json:"filing,omitempty"` // e-filing system / accepted formats
NotesDE string `json:"notesDE,omitempty"`
NotesEN string `json:"notesEN,omitempty"`
HLContact string `json:"hlContact,omitempty"` // placeholder, populated later
HLContact string `json:"hlContact,omitempty"` // firm-internal contact at this court; field name kept for API stability post-rebrand
Source string `json:"source,omitempty"` // internal reference URL, not rendered
}

View File

@@ -9,6 +9,8 @@ import (
"net/url"
"sync"
"time"
"mgit.msbls.de/m/patholo/internal/branding"
)
const (
@@ -25,10 +27,19 @@ type fileEntry struct {
FilePath string
}
// fileRegistry maps the public download slug to the upstream Gitea object.
//
// RawURL / FilePath reference the actual file in mWorkRepo and must match the
// blob's name there exactly; renaming would 404 the proxy. DownloadName is
// what the browser saves the file as — that's a branding surface, so it
// renders branding.Name instead of the upstream filename.
//
// The URL slug ("hl-patents-style.dotm") is preserved as a stable public
// identifier so existing bookmarks keep working post-rebrand.
var fileRegistry = map[string]fileEntry{
"hl-patents-style.dotm": {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/HL%20Patents%20Style.dotm",
DownloadName: "HL Patents Style.dotm",
DownloadName: branding.Name + " Patents Style.dotm",
ContentType: "application/vnd.ms-word.template.macroEnabled.12",
RepoOwner: "m",
RepoName: "mWorkRepo",

View File

@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"mgit.msbls.de/m/patholo/internal/branding"
"mgit.msbls.de/m/patholo/internal/services"
)
@@ -24,7 +25,7 @@ type inviteResponse struct {
// POST /api/invite — send a branded invitation email to a colleague.
//
// Auth: any onboarded user may invite. The HLC email-domain whitelist
// Auth: any onboarded user may invite. The firm email-domain whitelist
// (ALLOWED_EMAIL_DOMAINS) is enforced in-service so callers can't bypass it
// by bypassing the handler.
//
@@ -82,7 +83,7 @@ func handleInvite(w http.ResponseWriter, r *http.Request) {
})
case errors.Is(err, services.ErrInviteDomainBlocked):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "recipient domain not on the HLC allow-list",
"error": "recipient domain not on the " + branding.Name + " allow-list",
})
case errors.Is(err, services.ErrInviteRateLimited):
w.Header().Set("Retry-After", "3600")

View File

@@ -6,7 +6,7 @@ import (
"mgit.msbls.de/m/patholo/internal/offices"
)
// GET /api/offices — returns the canonical HLC office list with DE + EN
// GET /api/offices — returns the canonical firm office list with DE + EN
// labels. Backed by internal/offices (single source of truth, also used by
// the Akte create / edit validation).
func handleListOffices(w http.ResponseWriter, r *http.Request) {

View File

@@ -75,7 +75,7 @@ type Project struct {
Country *string `db:"country" json:"country,omitempty"`
BillingReference *string `db:"billing_reference" json:"billing_reference,omitempty"`
// ClientMatter numbers — external HLC billing/DMS identifiers.
// ClientMatter numbers — external billing/DMS identifiers used by the firm.
// Child rows inherit client_number from the root by default (resolved at
// read time by the service); a child with its own client_number overrides.
// matter_number is assigned independently at any level.

View File

@@ -1,10 +1,10 @@
// Package offices is the single source of truth for the HLC office list.
// Package offices is the single source of truth for the firm's office list.
//
// The keys here must stay in sync with the CHECK constraint on
// paliad.users.office and paliad.akten.owning_office (migration 001).
package offices
// Office is a single HLC office with its i18n-ready labels.
// Office is a single firm office with its i18n-ready labels.
type Office struct {
Key string `json:"key"`
LabelDE string `json:"label_de"`

View File

@@ -29,6 +29,7 @@ import (
"strings"
"time"
"mgit.msbls.de/m/patholo/internal/branding"
"mgit.msbls.de/m/patholo/internal/templates"
)
@@ -184,9 +185,14 @@ func (s *MailService) RenderTemplate(in TemplateData) (string, error) {
return "", fmt.Errorf("parse template %s: %w", contentFile, err)
}
// Firm is injected from branding.Name so every email template can render
// the current firm name via {{.Firm}} without each caller threading it in.
// Caller-provided Data still wins (in.Data is copied last) — useful in
// tests that want to assert a specific firm string.
payload := map[string]any{
"Lang": lang,
"Subject": in.Subject,
"Firm": branding.Name,
}
maps.Copy(payload, in.Data)

View File

@@ -126,6 +126,10 @@ func TestRenderTemplateInvitation(t *testing.T) {
for _, want := range []string{
"Anna Schmidt", "invites you", "Have a look at Paliad.",
"https://paliad.de/login", "colleague@hlc.com",
// Branding placeholder: {{.Firm}} should resolve to the configured
// firm name (defaults to "HLC"). Catches accidental deletion of the
// template placeholder when nobody set FIRM_NAME in the test env.
"platform for HLC",
} {
if !strings.Contains(html, want) {
t.Errorf("rendered html missing %q", want)

View File

@@ -1,11 +1,11 @@
{{define "content"}}
{{if eq .Lang "en"}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">{{.InviterName}} invites you to Paliad</h1>
<p style="margin:0 0 12px 0;">Paliad is the patent practice platform for HLC &mdash; matter management, deadline calculations, knowledge tools, and more.</p>
<p style="margin:0 0 12px 0;">Paliad is the patent practice platform for {{.Firm}} &mdash; matter management, deadline calculations, knowledge tools, and more.</p>
{{if .Message}}
<div style="background:#f5f5f4;border-left:3px solid #BFF355;padding:14px 16px;margin:20px 0;border-radius:0 6px 6px 0;font-size:14px;line-height:1.55;color:#44403c;white-space:pre-wrap;">{{.Message}}</div>
{{end}}
<p style="margin:20px 0 24px 0;">Sign up with your HLC email to get started:</p>
<p style="margin:20px 0 24px 0;">Sign up with your {{.Firm}} email to get started:</p>
<p style="margin:0;">
<a href="{{.RegisterURL}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Join Paliad
@@ -14,11 +14,11 @@
<p style="margin:24px 0 0 0;font-size:12px;color:#78716c;">Sent to {{.ToEmail}} by {{.InviterEmail}}. If you didn't expect this invitation, you can ignore it.</p>
{{else}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">{{.InviterName}} l&auml;dt Sie zu Paliad ein</h1>
<p style="margin:0 0 12px 0;">Paliad ist die Patent-Praxis-Plattform f&uuml;r HLC &mdash; Aktenverwaltung, Fristenberechnung, Wissenswerkzeuge und mehr.</p>
<p style="margin:0 0 12px 0;">Paliad ist die Patent-Praxis-Plattform f&uuml;r {{.Firm}} &mdash; Aktenverwaltung, Fristenberechnung, Wissenswerkzeuge und mehr.</p>
{{if .Message}}
<div style="background:#f5f5f4;border-left:3px solid #BFF355;padding:14px 16px;margin:20px 0;border-radius:0 6px 6px 0;font-size:14px;line-height:1.55;color:#44403c;white-space:pre-wrap;">{{.Message}}</div>
{{end}}
<p style="margin:20px 0 24px 0;">Registrieren Sie sich mit Ihrer HLC-E-Mail-Adresse:</p>
<p style="margin:20px 0 24px 0;">Registrieren Sie sich mit Ihrer {{.Firm}}-E-Mail-Adresse:</p>
<p style="margin:0;">
<a href="{{.RegisterURL}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Zu Paliad anmelden