Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/site/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
Expand Down
4 changes: 4 additions & 0 deletions docs/site/getting-started/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,15 @@ SimpleModule/
│ ├── Admin/
│ ├── AuditLogs/
│ ├── BackgroundJobs/
│ ├── Branding/
│ ├── Dashboard/
│ ├── Email/
│ ├── FeatureFlags/
│ ├── FileStorage/
│ ├── Identity/
│ ├── Keycloak/
│ ├── Localization/
│ ├── Notifications/
│ ├── OpenIddict/
│ ├── Permissions/
│ ├── RateLimiting/
Expand Down
93 changes: 93 additions & 0 deletions docs/site/guide/branding.md
Original file line number Diff line number Diff line change
@@ -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 `<head>` 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 `<head>` 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 `<link>`** 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<BrandingDto> GetBrandingAsync();
Task<string> GetCustomCssAsync();
Task<BrandingEditModel> 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.
18 changes: 17 additions & 1 deletion docs/site/guide/inertia.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>` 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<string?> GetHeadHtmlAsync(HttpContext context);
}
```

The renderer resolves all registered contributors per request and substitutes their combined output for the `<!--HEAD_CONTRIBUTIONS-->` placeholder just before `</head>`. 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:
Expand Down Expand Up @@ -178,6 +193,7 @@ The host's `wwwroot/index.html` contains these placeholders:
}
}
</script>
<!--HEAD_CONTRIBUTIONS-->
</head>
<body>
<!--INERTIA_PAGE_DATA-->
Expand All @@ -199,7 +215,7 @@ At request time, `RenderPageAsync` writes:
before + <script data-page="app" type="application/json" nonce="…">{pageJson}</script> + after
```

and swaps `<!--CSP_NONCE-->` 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 `<!--CSP_NONCE-->` with the per-request nonce from `ICspNonce` and `<!--HEAD_CONTRIBUTIONS-->` 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.

Expand Down
12 changes: 11 additions & 1 deletion website/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ <h3 class="text-base font-bold mb-2">Form Requests</h3>
<span class="gradient-text">Batteries included</span>
</h2>
<p class="text-text-secondary text-lg max-w-2xl mx-auto">
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.
</p>
</div>
Expand Down Expand Up @@ -538,6 +538,16 @@ <h3 class="text-sm font-bold mb-1">Email</h3>
<h3 class="text-sm font-bold mb-1">Notifications</h3>
<p class="text-xs text-text-secondary">Multi-channel notifications</p>
</div>

<div class="module-card">
<div class="module-icon">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</div>
<h3 class="text-sm font-bold mb-1">Branding</h3>
<p class="text-xs text-text-secondary">App name, logo, colors &amp; custom CSS</p>
</div>
</div>

<div class="text-center mt-10">
Expand Down
Loading