diff --git a/README.md b/README.md index 7f6f5f79..6ce88149 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ framework/ SimpleModule.Storage(.Local/.Azure/.S3) # File storage abstraction + provider implementations SimpleModule.Testing # Shared testing utilities for module test projects modules/ - Admin, AuditLogs, BackgroundJobs, Dashboard, Email, FeatureFlags, + Admin, AuditLogs, BackgroundJobs, Branding, Dashboard, Email, FeatureFlags, FileStorage, Identity, Keycloak, Localization, Notifications, RateLimiting, Settings, Tenants, Users OpenIddict # OpenID Connect / OAuth 2.0 via OpenIddict diff --git a/docs/site/.vitepress/config.ts b/docs/site/.vitepress/config.ts index 815de16a..ca632f20 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -68,6 +68,7 @@ export default defineConfig({ { text: 'Rate Limiting', link: '/guide/rate-limiting' }, { text: 'Identity & Sessions', link: '/guide/identity' }, { text: 'Localization', link: '/guide/localization' }, + { text: 'Branding', link: '/guide/branding' }, { text: 'Error Pages', link: '/guide/error-pages' }, ], }, diff --git a/docs/site/getting-started/project-structure.md b/docs/site/getting-started/project-structure.md index f68b4ffd..f5cdf55f 100644 --- a/docs/site/getting-started/project-structure.md +++ b/docs/site/getting-started/project-structure.md @@ -60,11 +60,15 @@ SimpleModule/ │ ├── Admin/ │ ├── AuditLogs/ │ ├── BackgroundJobs/ +│ ├── Branding/ │ ├── Dashboard/ │ ├── Email/ │ ├── FeatureFlags/ │ ├── FileStorage/ +│ ├── Identity/ +│ ├── Keycloak/ │ ├── Localization/ +│ ├── Notifications/ │ ├── OpenIddict/ │ ├── Permissions/ │ ├── RateLimiting/ diff --git a/docs/site/guide/branding.md b/docs/site/guide/branding.md new file mode 100644 index 00000000..ca5d10f6 --- /dev/null +++ b/docs/site/guide/branding.md @@ -0,0 +1,93 @@ +--- +outline: deep +--- + +# Branding + +The Branding module lets an administrator customize the appearance of a SimpleModule application from a single global configuration page — app name, logo, favicon, primary colors, an announcement top bar, a footer, and advanced custom CSS. Colors, custom CSS, and the favicon are injected into the document `` server-side so they apply before first paint (no flash of default theme), while the app name, logo, top bar, and footer reach React as an Inertia shared prop. + +## Admin UI + +The module mounts an admin view at `/branding/manage` (mirroring Settings' `/settings/manage`) with a live preview panel. Everything on the page — and the API behind it — is protected by the single `Branding.Manage` permission. + +Configurable surface: + +| Section | What it controls | +|---|---| +| Identity | App name, logo image, favicon | +| Colors | Primary color for light and dark themes | +| Top bar | Announcement banner: message, colors, links, dismissible toggle | +| Footer | Footer text, links, copyright line | +| Advanced | Free-form custom CSS injected into every page | + +## How values are stored + +Branding values are stored through `ISettingsContracts` under `branding.*` keys (see `BrandingSettingKeys`), but they are **intentionally not registered as `SettingDefinition`s** — they are edited through the dedicated `/branding/manage` page, not the generic Settings admin UI. Defaults live in `BrandingDefaults` and `BrandingService`. + +## Server-side head injection + +The framework's `IInertiaHeadContributor` extension point lets a module contribute HTML to the `` of every Inertia page render. The Branding module registers `BrandingHeadContributor`, which injects: + +- **CSS variable overrides** when the primary color differs from the default. The full primary palette (hover, light, subtle, ring variants) is derived from the chosen color via `color-mix`, so button hovers and focus rings follow the brand color. +- **Custom CSS**, if any is configured. +- **A favicon ``** pointing at the uploaded favicon asset. + +Because injection happens server-side, branded colors apply before the first paint. + +## The `branding` shared prop + +`BrandingSharedDataMiddleware` publishes the resolved `BrandingDto` as the `branding` Inertia shared prop on every page render (API routes are skipped), so the React layout chrome can render the app name, logo, top bar, and footer without an extra request: + +```csharp +[Dto] +public class BrandingDto +{ + public string AppName { get; set; } + public string? LogoUrl { get; set; } + public string? FaviconUrl { get; set; } + public string ColorPrimary { get; set; } + public string ColorPrimaryDark { get; set; } + public TopBarConfig TopBar { get; set; } + public FooterConfig Footer { get; set; } +} +``` + +Custom CSS is deliberately excluded from this DTO so it never ships in the per-page payload — it only travels via head injection. + +## API endpoints + +| Method | Route | Auth | Purpose | +|---|---|---|---| +| `GET` | `/api/branding` | `Branding.Manage` | Full editable model (`BrandingEditModel`) | +| `PUT` | `/api/branding` | `Branding.Manage` | Save the branding configuration | +| `POST` | `/api/branding/assets/{kind}` | `Branding.Manage` | Upload a `logo` or `favicon` image | +| `GET` | `/api/branding/assets/{kind}` | Anonymous | Serve the logo/favicon | + +The asset-serve endpoint is intentionally anonymous: the logo and favicon must render on the login page, before any user is authenticated. FileStorage's own download endpoint is permissioned and ownership-checked, so branding assets get their own serve path with `Cache-Control: public, max-age=3600`. + +## Security hardening + +- **No SVG** — uploads and serving are restricted to raster/icon formats (PNG, JPEG, GIF, WebP, ICO). An SVG can carry inline script that executes on direct navigation, which would be stored XSS on an anonymously served asset. The content type is re-validated on the serve path, not just at upload. +- **`X-Content-Type-Options: nosniff`** on served assets. +- **Link URL sanitization** — top-bar and footer link URLs are sanitized to block the `javascript:` scheme. +- **Upload limits** — assets are capped at 2 MB. + +## Querying from other modules + +Depend on the contract if a module needs the resolved branding programmatically: + +```csharp +public interface IBrandingContracts +{ + Task GetBrandingAsync(); + Task GetCustomCssAsync(); + Task GetEditableAsync(); + Task UpdateAsync(BrandingEditModel model); +} +``` + +## Next Steps + +- [Settings](/guide/settings) — the storage layer branding values live in. +- [File Storage](/guide/file-storage) — where uploaded logo/favicon files are kept. +- [Permissions](/guide/permissions) — how `Branding.Manage` is enforced. diff --git a/docs/site/guide/inertia.md b/docs/site/guide/inertia.md index 8b715b5a..95aab398 100644 --- a/docs/site/guide/inertia.md +++ b/docs/site/guide/inertia.md @@ -137,6 +137,21 @@ app.Use(async (context, next) => Shared data has **lower priority** than endpoint props. If an endpoint sets a prop with the same key as shared data, the endpoint's value wins. +### Head Contributions + +To inject HTML into the document `` of every full server-rendered page (styles, meta tags, favicon links), implement `IInertiaHeadContributor` and register it as a **scoped** service: + +```csharp +public interface IInertiaHeadContributor +{ + ValueTask GetHeadHtmlAsync(HttpContext context); +} +``` + +The renderer resolves all registered contributors per request and substitutes their combined output for the `` placeholder just before ``. Because this happens server-side, the injected markup (e.g. CSS variable overrides) applies before first paint. Contributors are responsible for their own escaping, and a throwing contributor is skipped rather than failing the page. + +The [Branding module](/guide/branding) uses this to inject brand colors, custom CSS, and the favicon. + ### Version Detection The Inertia middleware handles asset versioning to prevent stale JavaScript from running after deployments: @@ -178,6 +193,7 @@ The host's `wwwroot/index.html` contains these placeholders: } } + @@ -199,7 +215,7 @@ At request time, `RenderPageAsync` writes: before + + after ``` -and swaps `` with the per-request nonce from `ICspNonce`. In development it also strips the import map and app.js script tag and injects Vite's `/@vite/client` and `/app.tsx` entries when the Vite dev server is active (via `DevToolsConstants.ViteDevServerKey`). +and swaps `` with the per-request nonce from `ICspNonce` and `` with the combined output of all registered `IInertiaHeadContributor` services (see [Head Contributions](#head-contributions)). In development it also strips the import map and app.js script tag and injects Vite's `/@vite/client` and `/app.tsx` entries when the Vite dev server is active (via `DevToolsConstants.ViteDevServerKey`). To swap the renderer, replace the `IInertiaPageRenderer` registration with your own implementation — there is no `InertiaOptions.ShellComponent` or `AddSimpleModuleBlazor` hook. diff --git a/website/index.html b/website/index.html index ddf892b7..926fcfed 100644 --- a/website/index.html +++ b/website/index.html @@ -372,7 +372,7 @@

Form Requests

Batteries included

- Ship faster with 16 built-in modules covering identity, multi-tenancy, background jobs, + Ship faster with 17 built-in modules covering identity, multi-tenancy, background jobs, and common application concerns.

@@ -538,6 +538,16 @@

Email

Notifications

Multi-channel notifications

+ +
+
+ +
+

Branding

+

App name, logo, colors & custom CSS

+