From fc7d6782bddc33c9b736a0730ddbbccbfeeca7c5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 25 Jun 2026 16:51:10 +0200 Subject: [PATCH 01/14] docs: branding module design spec --- .../2026-06-25-branding-module-design.md | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-branding-module-design.md diff --git a/docs/superpowers/specs/2026-06-25-branding-module-design.md b/docs/superpowers/specs/2026-06-25-branding-module-design.md new file mode 100644 index 00000000..23fb865d --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-branding-module-design.md @@ -0,0 +1,189 @@ +# Branding Module — Design Spec + +**Date:** 2026-06-25 +**Status:** Draft (awaiting review) +**Goal:** A Branding module that lets an admin customize the appearance of a SimpleModule application — app name, logo, favicon, brand colors, custom CSS, a configurable top bar, and a configurable footer. + +--- + +## 1. Decisions (locked) + +| Decision | Choice | Rationale | +|---|---|---| +| Storage | **Layer on the existing Settings module** (no new DbContext) | Settings already has `Color`/`Text`/`MultilineText`/`Json` types, FusionCache-backed storage, and `Application` scope. Reuse it; don't duplicate infra. | +| Scope | **Global** (one config for the whole app) | Single-org deployment. `Scope.Application` settings. Extensible to per-tenant later. | +| Fields | App name, logo, favicon, brand colors, custom CSS, **top bar**, **footer** | Per user request. | +| Logo/favicon storage | **Reuse FileStorage module** | It already handles upload, serve-by-id, and permissions. Branding stores the returned file id in a setting. | + +### Assumptions (confirm at review) + +1. **"Top-navbar management" = a configurable top utility/announcement bar.** The authenticated layout (`app-layout.tsx`) has only a left sidebar today — there is no top navbar for logged-in users. So "top bar" is interpreted as a thin, full-width bar above the content (announcement message + optional links + background color + show/hide + dismissible), rendered in both the authenticated and public layouts. It is **not** about editing module-contributed sidebar menu items. +2. **v1 color control = primary (light + dark) + one accent color.** The theme defines many CSS variables (OKLCH); v1 exposes the few that matter most and leaves deep theming to the custom-CSS box. (Easy to add more swatches later.) +3. **Favicon + brand-color application is server-side** (head injection) to avoid a flash of the default theme on first paint. This requires a small, generic framework addition (see §5). + +--- + +## 2. Verified codebase facts (grounding) + +- **Settings registration:** `IModule.ConfigureSettings(ISettingsBuilder settings)`; `settings.Add(new SettingDefinition { Key, DisplayName, Group, Scope, DefaultValue, Type, ... })`. `framework/SimpleModule.Core/Settings/`. +- **Setting types:** `Text, Number, Bool, Json, Select, Color, Url, Email, Password, MultilineText, DateTime`. Scopes: `System, Application, User`. +- **Read settings:** `ISettingsContracts` (`modules/Settings/.../Contracts`) — `GetSettingAsync(key, scope, userId?)`, `SetManyAsync(IReadOnlyList)`, `GetSettingValuesAsync(filter)`, etc. +- **Shared Inertia props:** `InertiaSharedData.Set(key, value)` (`framework/SimpleModule.Core/Inertia/InertiaSharedData.cs`), populated by `InertiaLayoutDataMiddleware` (`framework/SimpleModule.Hosting/Middleware/`). There is **no** interface for modules to contribute shared props — a module adds its own via middleware registered in `ConfigureMiddleware`. +- **Module middleware hook:** `IModule.ConfigureMiddleware(IApplicationBuilder app)` exists. +- **Layout shell:** `packages/SimpleModule.UI/components/layouts/` — `app-layout.tsx` (authenticated), `public-layout.tsx` (public), `layout-provider.tsx`, `types.ts` (`SharedProps`). App name `"SimpleModule"` and the `"S"` badge are **hardcoded** in the sidebar, mobile header, and public nav. No footer exists. `SharedProps` already has `menus.navbar` and `publicMenu`. +- **Renderer:** `HtmlFileInertiaPageRenderer` reads `wwwroot/index.html` **once at startup**, splits at ``, and per-request only replaces ``. Head placeholders present: ``, ``, ``. +- **Favicon:** `template/SimpleModule.Host/Program.cs` maps `GET /favicon.ico` → `wwwroot/favicon.svg`. `index.html` head has ``. +- **DbContext schema helpers** (not needed here, recorded for completeness): `AddModuleDbContext()`, `ModelBuilder.ApplyModuleSchema(name, dbOptions)`. + +--- + +## 3. Module layout (new files) + +``` +modules/Branding/ +├── src/ +│ ├── SimpleModule.Branding.Contracts/ +│ │ ├── SimpleModule.Branding.Contracts.csproj # references SimpleModule.Core only +│ │ ├── IBrandingContracts.cs # GetBrandingAsync() -> BrandingDto +│ │ ├── BrandingDto.cs # [Dto] — the resolved shared-prop shape +│ │ ├── TopBarConfig.cs # [Dto] nested +│ │ ├── FooterConfig.cs # [Dto] nested +│ │ └── BrandingLink.cs # [Dto] { Label, Url } +│ └── SimpleModule.Branding/ +│ ├── SimpleModule.Branding.csproj # SDK.StaticWebAssets; refs Core + Contracts + Settings.Contracts + FileStorage.Contracts; FrameworkReference AspNetCore.App +│ ├── BrandingModule.cs # [Module("Branding", ...)] IModule +│ ├── BrandingService.cs # IBrandingContracts — resolves settings -> BrandingDto (+ cached) +│ ├── BrandingPermissions.cs # Branding.Manage +│ ├── BrandingSettingKeys.cs # const string keys + defaults +│ ├── BrandingSharedDataMiddleware.cs # sets InertiaSharedData["branding"] +│ ├── BrandingHeadContributor.cs # IInertiaHeadContributor — emits ` (only the vars that differ from defaults). + - `` when a custom favicon is set (later in head → wins over the static one). + +**CSP:** verify the style-src policy allows nonce'd inline styles (the renderer already nonces scripts). If style-src lacks `'nonce-...'`/`'unsafe-inline'`, add the nonce to style-src in the CSP config. (Implementation must check the actual CSP setup and adjust, or fall back to client-side injection for custom CSS only.) + +--- + +## 6. Admin UI — `Branding/Manage` + +- `ManageEndpoint : IViewEndpoint` → `GET /branding`, `.RequirePermission(BrandingPermissions.Manage)`, `Inertia.Render("Branding/Manage", new { branding = , defaults })`. +- **Registered in `Pages/index.ts`** (`"Branding/Manage"`) — mandatory per project rule. +- `Manage.tsx` sections: **Identity** (app name text; logo upload + preview + clear; favicon upload + preview + clear), **Colors** (primary light, primary dark, accent — color pickers with swatches), **Top bar** (enable toggle, message, bg/text color, links editor, dismissible toggle), **Footer** (enable toggle, text, links editor, show-copyright toggle), **Advanced** (custom CSS textarea with a warning note). A **live preview** pane shows a mock sidebar header + top bar + footer reflecting current form state. +- Uploads: `UploadAssetEndpoint` (`POST /api/branding/asset?kind=logo|favicon`, `.DisableAntiforgery()`, admin-only) forwards the `IFormFile` to `IFileStorageContracts.UploadFileAsync(stream, name, contentType, folder: "branding", userId)` and returns the new file id + URL. The form stores the id; save persists it. +- Save: `PUT /api/branding` validates and calls `ISettingsContracts.SetManyAsync([...])`. `BrandingService` cache invalidates so the next page render reflects the change. + +--- + +## 7. Permissions & menu + +- `BrandingPermissions : IModulePermissions` with `Manage = "Branding.Manage"`. Endpoints use `.RequirePermission(BrandingPermissions.Manage)`. +- `ConfigureMenu`: add an **Admin sidebar** menu item "Branding" → `/branding`, `RequiredPermission = BrandingPermissions.Manage`, suitable icon/order. + +--- + +## 8. Testing + +**Backend (xUnit, `SimpleModuleWebApplicationFactory`):** +- Defaults: with no settings stored, `GetBrandingAsync` returns built-in defaults (`appName == "SimpleModule"`, top bar/footer disabled, null logo/favicon URLs). +- Persistence: `PUT /api/branding` then `GET /api/branding` round-trips all fields; settings actually written via `ISettingsContracts`. +- Authorization: `GET/PUT /api/branding` and `GET /branding` return 403 without `Branding.Manage`; 200 with it. +- Shared prop: an Inertia page response includes a `branding` prop with the resolved DTO. +- Head contributor: given non-default colors/custom CSS/favicon, `BrandingHeadContributor.GetHeadHtml` emits the expected `"); + } + + [Fact] + public async Task RenderedPage_IncludesContributorOutput_AndNoRawPlaceholder() + { + await using var factory = new SimpleModuleWebApplicationFactory(); + using var client = factory + .WithWebHostBuilder(b => + b.ConfigureServices(s => + s.AddScoped() + ) + ) + .CreateClient(); + + var html = await client.GetStringAsync("/"); + + html.Should().Contain("/*HEAD_MARKER*/"); + html.Should().NotContain(""); + } +} +``` + +> If `SimpleModuleWebApplicationFactory` requires a different construction (check an existing integration test like `modules/Admin/tests/.../Integration/AdminRolesEndpointTests.cs`), mirror that. The factory may be injected via `[Collection(TestCollections.Integration)]`; if a fresh instance can't take service overrides, instead add the fake contributor through the factory's existing override hook used by other tests, or assert the empty-placeholder behavior only and verify the contributor path in Task 6's branding integration test. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test --filter "FullyQualifiedName~HeadContributorTests"` +Expected: FAIL — `` not present yet (the marker isn't injected; the page also lacks the placeholder). + +- [ ] **Step 3: Add the interface** + +```csharp +// framework/SimpleModule.Core/Inertia/IInertiaHeadContributor.cs +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace SimpleModule.Core.Inertia; + +/// +/// Implement and register (scoped) to contribute raw HTML into the document <head> +/// of every full server-rendered Inertia page. Output replaces the +/// <!--HEAD_CONTRIBUTIONS--> placeholder (just before </head>) per request. +/// Contributors are responsible for their own escaping. +/// +public interface IInertiaHeadContributor +{ + ValueTask GetHeadHtmlAsync(HttpContext context); +} +``` + +- [ ] **Step 4: Add the placeholder to `index.html`** + +In `template/SimpleModule.Host/wwwroot/index.html`, insert the placeholder on its own line immediately before `` (after the `` block): + +```html + + +``` + +- [ ] **Step 5: Inject contributions in the renderer** + +In `HtmlFileInertiaPageRenderer.cs`: + +1. Add a constant next to the others: +```csharp +private const string HeadContributionsPlaceholder = ""; +``` +2. Change `RenderPageAsync` to `async Task`, build the head HTML, and replace the placeholder in `before` before writing. Replace the current method body with: + +```csharp +public async Task RenderPageAsync(HttpContext httpContext, string pageJson) +{ + var nonce = httpContext.RequestServices.GetRequiredService().Value; + var useViteDev = + _isDevelopment && httpContext.Items.ContainsKey(DevToolsConstants.ViteDevServerKey); + + var before = useViteDev ? _beforePlaceholderViteDev : _beforePlaceholder; + var after = useViteDev ? _afterPlaceholderViteDev : _afterPlaceholder; + var devScript = + _isDevelopment && !useViteDev + ? "" + : ""; + + var headHtml = await BuildHeadContributionsAsync(httpContext); + before = before.Replace(HeadContributionsPlaceholder, headHtml, StringComparison.Ordinal); + + httpContext.Response.ContentType = "text/html; charset=utf-8"; + await httpContext.Response.WriteAsync( + string.Concat( + before.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal), + $"", + devScript, + after.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal) + ) + ); +} + +private static async Task BuildHeadContributionsAsync(HttpContext httpContext) +{ + var contributors = httpContext.RequestServices.GetServices(); + StringBuilder? sb = null; + foreach (var contributor in contributors) + { + var html = await contributor.GetHeadHtmlAsync(httpContext); + if (string.IsNullOrEmpty(html)) + continue; + (sb ??= new StringBuilder()).Append(html); + } + return sb?.ToString() ?? string.Empty; +} +``` +3. Add `using SimpleModule.Core.Inertia;` if not already present (the file already uses `SimpleModule.Core.Inertia`). `System.Text`, `Microsoft.Extensions.DependencyInjection` are already imported. + +> Note: the constructor must NOT replace `HeadContributionsPlaceholder` (only `DEPLOY_VERSION` and `MODULE_CSS_LINKS` are replaced at startup). Leaving it untouched is correct — it survives into `_beforePlaceholder` and the ViteDev variant. + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `dotnet test --filter "FullyQualifiedName~HeadContributorTests"` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add framework/SimpleModule.Core/Inertia/IInertiaHeadContributor.cs \ + framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs \ + template/SimpleModule.Host/wwwroot/index.html \ + framework/SimpleModule.Hosting.Tests +git commit -m "feat(core): per-request IInertiaHeadContributor head injection" +``` + +--- + +## Task 3: Branding main project — module, service, permissions, settings, menu + +**Files:** +- Create: `modules/Branding/src/SimpleModule.Branding/SimpleModule.Branding.csproj` +- Create: `modules/Branding/src/SimpleModule.Branding/BrandingPermissions.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/BrandingService.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/BrandingModule.cs` +- Modify: `SimpleModule.slnx`; `template/SimpleModule.Host/SimpleModule.Host.csproj` +- Create test project: `modules/Branding/tests/SimpleModule.Branding.Tests/SimpleModule.Branding.Tests.csproj` +- Create: `modules/Branding/tests/SimpleModule.Branding.Tests/FakeSettings.cs` +- Test: `modules/Branding/tests/SimpleModule.Branding.Tests/BrandingServiceTests.cs` + +**Interfaces:** +- Consumes: `IBrandingContracts`, DTOs, keys, defaults (Task 1); `ISettingsContracts` (`GetSettingAsync`, `SetManyAsync`); `IInertiaHeadContributor` (Task 2 — referenced in Task 6). +- Produces: `BrandingService : IBrandingContracts`; `BrandingPermissions.Manage = "Branding.Manage"`; `BrandingModule` registering the service + settings + permission + admin menu item. + +- [ ] **Step 1: Create the main csproj** + +```xml + + + + net10.0 + Branding module for SimpleModule. Customize app name, logo, favicon, colors, custom CSS, top bar, and footer. + + + + + + + + + +``` + +- [ ] **Step 2: Add permissions** + +```csharp +// BrandingPermissions.cs +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Branding; + +public sealed class BrandingPermissions : IModulePermissions +{ + public const string Manage = "Branding.Manage"; +} +``` + +- [ ] **Step 3: Write the failing service unit test (+ fake settings)** + +```csharp +// FakeSettings.cs — dictionary-backed ISettingsContracts for unit tests +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using SimpleModule.Core.Settings; +using SimpleModule.Settings.Contracts; + +namespace SimpleModule.Branding.Tests; + +public sealed class FakeSettings : ISettingsContracts +{ + private readonly Dictionary _app = []; + + public Task GetSettingAsync(string key, SettingScope scope, string? userId = null) => + Task.FromResult(_app.TryGetValue(key, out var v) ? v.Deserialize() : default); + + public Task GetSettingAsync(string key, SettingScope scope, string? userId = null) => + Task.FromResult(_app.TryGetValue(key, out var v) ? v.GetString() : null); + + public Task SetSettingAsync(string key, JsonElement value, SettingScope scope, string? userId = null) + { + _app[key] = value; + return Task.CompletedTask; + } + + public Task SetManyAsync(IReadOnlyList updates) + { + foreach (var u in updates) + _app[u.Key] = u.Value; + return Task.CompletedTask; + } + + // Unused members — throw so accidental reliance is caught. + public Task ResolveUserSettingAsync(string key, string userId) => throw new System.NotSupportedException(); + public Task ResolveUserSettingElementAsync(string key, string userId) => throw new System.NotSupportedException(); + public Task DeleteSettingAsync(string key, SettingScope scope, string? userId = null) => throw new System.NotSupportedException(); + public Task ResetToDefaultAsync(string key, SettingScope scope, string? userId = null) => throw new System.NotSupportedException(); + public Task> GetSettingValuesAsync(SettingsFilter? filter = null) => throw new System.NotSupportedException(); + public Task GetSettingValueAsync(string key, SettingScope scope, string? userId = null) => throw new System.NotSupportedException(); +} +``` + +> Copy the exact `ISettingsContracts` member list from `modules/Settings/src/SimpleModule.Settings.Contracts/ISettingsContracts.cs` at implementation time and match every signature — the list above mirrors the researched interface but must compile against the real one. + +```csharp +// BrandingServiceTests.cs +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core.Settings; +using Xunit; + +public class BrandingServiceTests +{ + [Fact] + public async Task GetBrandingAsync_ReturnsDefaults_WhenNothingStored() + { + var svc = new BrandingService(new FakeSettings()); + + var dto = await svc.GetBrandingAsync(); + + dto.AppName.Should().Be(BrandingDefaults.AppName); + dto.ColorPrimary.Should().Be(BrandingDefaults.ColorPrimary); + dto.LogoUrl.Should().BeNull(); + dto.TopBar.Enabled.Should().BeFalse(); + dto.Footer.Enabled.Should().BeFalse(); + } + + [Fact] + public async Task Update_Then_Get_RoundTrips() + { + var settings = new FakeSettings(); + var svc = new BrandingService(settings); + + await svc.UpdateAsync(new BrandingEditModel + { + AppName = "Acme", + ColorPrimary = "#112233", + CustomCss = ".x{color:red}", + TopBar = new TopBarConfig { Enabled = true, Message = "Hi" }, + Footer = new FooterConfig { Enabled = true, Text = "© Acme" }, + }); + + var dto = await svc.GetBrandingAsync(); + dto.AppName.Should().Be("Acme"); + dto.ColorPrimary.Should().Be("#112233"); + dto.TopBar.Message.Should().Be("Hi"); + dto.Footer.Text.Should().Be("© Acme"); + (await svc.GetCustomCssAsync()).Should().Be(".x{color:red}"); + } + + [Fact] + public async Task GetBrandingAsync_BuildsLogoUrl_WhenFileIdSet() + { + var settings = new FakeSettings(); + await settings.SetSettingAsync(BrandingSettingKeys.LogoFileId, + JsonSerializer.SerializeToElement("abc-123"), SettingScope.Application); + var svc = new BrandingService(settings); + + var dto = await svc.GetBrandingAsync(); + + dto.LogoUrl.Should().Be("/api/branding/assets/logo?v=abc-123"); + } +} +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `dotnet test --filter "FullyQualifiedName~BrandingServiceTests"` +Expected: FAIL — `BrandingService` does not exist yet (won't compile until Step 5/6 wire the test project). + +- [ ] **Step 5: Create the test project csproj + FakeSettings** + +```xml + + + + net10.0 + false + Exe + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 6: Implement `BrandingService`** + +```csharp +// BrandingService.cs +using System.Text.Json; +using System.Threading.Tasks; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core.Settings; +using SimpleModule.Settings.Contracts; + +namespace SimpleModule.Branding; + +public sealed class BrandingService(ISettingsContracts settings) : IBrandingContracts +{ + public async Task GetBrandingAsync() + { + var logoId = await settings.GetSettingAsync(BrandingSettingKeys.LogoFileId, SettingScope.Application); + var faviconId = await settings.GetSettingAsync(BrandingSettingKeys.FaviconFileId, SettingScope.Application); + + return new BrandingDto + { + AppName = await Str(BrandingSettingKeys.AppName, BrandingDefaults.AppName), + ColorPrimary = await Str(BrandingSettingKeys.ColorPrimary, BrandingDefaults.ColorPrimary), + ColorPrimaryDark = await Str(BrandingSettingKeys.ColorPrimaryDark, BrandingDefaults.ColorPrimaryDark), + LogoUrl = AssetUrl("logo", logoId), + FaviconUrl = AssetUrl("favicon", faviconId), + TopBar = await Json(BrandingSettingKeys.TopBar) ?? new TopBarConfig(), + Footer = await Json(BrandingSettingKeys.Footer) ?? new FooterConfig(), + }; + } + + public async Task GetCustomCssAsync() => + await Str(BrandingSettingKeys.CustomCss, string.Empty); + + public async Task GetEditableAsync() + { + var logoId = await settings.GetSettingAsync(BrandingSettingKeys.LogoFileId, SettingScope.Application); + var faviconId = await settings.GetSettingAsync(BrandingSettingKeys.FaviconFileId, SettingScope.Application); + + return new BrandingEditModel + { + AppName = await Str(BrandingSettingKeys.AppName, BrandingDefaults.AppName), + ColorPrimary = await Str(BrandingSettingKeys.ColorPrimary, BrandingDefaults.ColorPrimary), + ColorPrimaryDark = await Str(BrandingSettingKeys.ColorPrimaryDark, BrandingDefaults.ColorPrimaryDark), + CustomCss = await Str(BrandingSettingKeys.CustomCss, string.Empty), + LogoFileId = logoId, + LogoUrl = AssetUrl("logo", logoId), + FaviconFileId = faviconId, + FaviconUrl = AssetUrl("favicon", faviconId), + TopBar = await Json(BrandingSettingKeys.TopBar) ?? new TopBarConfig(), + Footer = await Json(BrandingSettingKeys.Footer) ?? new FooterConfig(), + }; + } + + public async Task UpdateAsync(BrandingEditModel model) + { + var updates = new System.Collections.Generic.List + { + Str(BrandingSettingKeys.AppName, model.AppName ?? BrandingDefaults.AppName), + Str(BrandingSettingKeys.ColorPrimary, model.ColorPrimary ?? BrandingDefaults.ColorPrimary), + Str(BrandingSettingKeys.ColorPrimaryDark, model.ColorPrimaryDark ?? BrandingDefaults.ColorPrimaryDark), + Str(BrandingSettingKeys.CustomCss, model.CustomCss ?? string.Empty), + JsonUpdate(BrandingSettingKeys.TopBar, model.TopBar ?? new TopBarConfig()), + JsonUpdate(BrandingSettingKeys.Footer, model.Footer ?? new FooterConfig()), + }; + if (model.LogoFileId is not null) + updates.Add(Str(BrandingSettingKeys.LogoFileId, model.LogoFileId)); + if (model.FaviconFileId is not null) + updates.Add(Str(BrandingSettingKeys.FaviconFileId, model.FaviconFileId)); + + await settings.SetManyAsync(updates); + } + + private async Task Str(string key, string fallback) + { + var v = await settings.GetSettingAsync(key, SettingScope.Application); + return string.IsNullOrWhiteSpace(v) ? fallback : v; + } + + private Task Json(string key) => + settings.GetSettingAsync(key, SettingScope.Application); + + private static string? AssetUrl(string kind, string? fileId) => + string.IsNullOrWhiteSpace(fileId) ? null : $"/api/branding/assets/{kind}?v={fileId}"; + + private static BulkSettingUpdate Str(string key, string value) => new() + { + Key = key, + Scope = SettingScope.Application, + Value = JsonSerializer.SerializeToElement(value), + }; + + private static BulkSettingUpdate JsonUpdate(string key, T value) => new() + { + Key = key, + Scope = SettingScope.Application, + Value = JsonSerializer.SerializeToElement(value), + }; +} +``` + +- [ ] **Step 7: Implement `BrandingModule` (settings/permissions/menu; service registration)** + +```csharp +// BrandingModule.cs +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Menu; +using SimpleModule.Core.Settings; + +namespace SimpleModule.Branding; + +[Module("Branding", ViewPrefix = "/branding")] +public class BrandingModule : IModule +{ + private const string Icon = + """"""; + + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + // IInertiaHeadContributor + middleware are added in Task 6. + } + + public void ConfigurePermissions(PermissionRegistryBuilder builder) => + builder.AddPermissions(); + + public void ConfigureMenu(IMenuBuilder menus) => + menus.Add(new MenuItem + { + Label = "Branding", + Url = "/branding", + Icon = Icon, + Order = 86, + Section = MenuSection.AppSidebar, + Roles = ["Admin"], + RequiredPermission = BrandingPermissions.Manage, + }); + + public void ConfigureSettings(ISettingsBuilder settings) + { + settings + .Add(Def(BrandingSettingKeys.AppName, "Application name", SettingType.Text, JsonSerializer.Serialize(BrandingDefaults.AppName), order: 0)) + .Add(Def(BrandingSettingKeys.ColorPrimary, "Primary color (light)", SettingType.Color, JsonSerializer.Serialize(BrandingDefaults.ColorPrimary), order: 1)) + .Add(Def(BrandingSettingKeys.ColorPrimaryDark, "Primary color (dark)", SettingType.Color, JsonSerializer.Serialize(BrandingDefaults.ColorPrimaryDark), order: 2)) + .Add(Def(BrandingSettingKeys.CustomCss, "Custom CSS", SettingType.MultilineText, "\"\"", order: 3)) + .Add(Def(BrandingSettingKeys.LogoFileId, "Logo file id", SettingType.Text, "\"\"", order: 4)) + .Add(Def(BrandingSettingKeys.FaviconFileId, "Favicon file id", SettingType.Text, "\"\"", order: 5)) + .Add(Def(BrandingSettingKeys.TopBar, "Top bar", SettingType.Json, JsonSerializer.Serialize(new TopBarConfig()), order: 6)) + .Add(Def(BrandingSettingKeys.Footer, "Footer", SettingType.Json, JsonSerializer.Serialize(new FooterConfig()), order: 7)); + } + + private static SettingDefinition Def(string key, string name, SettingType type, string defaultValue, int order) => + new() + { + Key = key, + DisplayName = name, + Group = "Branding", + Scope = SettingScope.Application, + Type = type, + DefaultValue = defaultValue, + Order = order, + }; +} +``` + +- [ ] **Step 8: Register projects in `SimpleModule.slnx` and Host csproj** + +Add to `SimpleModule.slnx`: the main project and the test project. Add to `template/SimpleModule.Host/SimpleModule.Host.csproj` a `` alongside the other module references. + +- [ ] **Step 9: Run tests to verify they pass + build host** + +Run: `dotnet test --filter "FullyQualifiedName~BrandingServiceTests"` → PASS. +Run: `dotnet build template/SimpleModule.Host/SimpleModule.Host.csproj` → Build succeeded (module discovered by source generator). + +- [ ] **Step 10: Commit** + +```bash +git add modules/Branding SimpleModule.slnx template/SimpleModule.Host/SimpleModule.Host.csproj +git commit -m "feat(branding): module, settings, permission, menu, BrandingService" +``` + +--- + +## Task 4: Admin API + asset endpoints + view endpoint + +**Files:** +- Create: `modules/Branding/src/SimpleModule.Branding/Endpoints/Branding/GetBrandingEndpoint.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/Endpoints/Branding/UpdateBrandingEndpoint.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/Endpoints/Branding/UploadAssetEndpoint.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/Endpoints/Branding/AssetEndpoint.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/Pages/ManageEndpoint.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/Pages/index.ts` +- Test: `modules/Branding/tests/SimpleModule.Branding.Tests/Integration/BrandingEndpointsTests.cs` + +**Interfaces:** +- Consumes: `IBrandingContracts`, `ISettingsContracts`, `IFileStorageContracts`, `BrandingPermissions`, settings keys. +- Produces: routes `GET/PUT /api/branding`, `POST /api/branding/assets/{kind}`, `GET /api/branding/assets/{kind}`, `GET /branding`. + +- [ ] **Step 1: Write the failing integration tests** + +```csharp +// Integration/BrandingEndpointsTests.cs +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Tests.Shared; +using Xunit; + +[Collection(TestCollections.Integration)] +public class BrandingEndpointsTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + public BrandingEndpointsTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; + + private HttpClient AdminClient() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var claims = $"{ClaimTypes.Role}=Admin;{ClaimTypes.NameIdentifier}=admin-branding-test"; + client.DefaultRequestHeaders.Add("X-Test-Claims", claims); + client.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); + return client; + } + + [Fact] + public async Task Get_Requires_Permission() + { + var anon = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var res = await anon.GetAsync("/api/branding"); + res.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden, HttpStatusCode.Redirect); + } + + [Fact] + public async Task Put_Then_Get_RoundTrips() + { + var client = AdminClient(); + var model = new BrandingEditModel { AppName = "Acme Co", ColorPrimary = "#123456" }; + + var put = await client.PutAsJsonAsync("/api/branding", model); + put.StatusCode.Should().Be(HttpStatusCode.OK); + + var got = await client.GetFromJsonAsync("/api/branding"); + got!.AppName.Should().Be("Acme Co"); + got.ColorPrimary.Should().Be("#123456"); + } + + [Fact] + public async Task Asset_Serve_Returns404_WhenUnset() + { + var anon = _factory.CreateClient(); + var res = await anon.GetAsync("/api/branding/assets/logo"); + res.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task ManageView_Requires_Permission_And_RendersForAdmin() + { + var admin = AdminClient(); + var res = await admin.GetAsync("/branding"); + res.StatusCode.Should().Be(HttpStatusCode.OK); + } +} +``` + +> The exact unauthorized status depends on the app's auth fallback (cookie redirect vs 401/403). `BeOneOf(...)` keeps the test robust; tighten after observing actual behavior. If the integration collection requires a token-seeded user with the `Branding.Manage` permission rather than the `Admin` role, follow the seeding pattern used by `modules/Settings` or `modules/FileStorage` integration tests. Admin role bypasses permission checks (per `MenuItem`/permission semantics), so `Role=Admin` should satisfy `RequirePermission` — verify against an existing admin-gated endpoint test. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test --filter "FullyQualifiedName~BrandingEndpointsTests"` +Expected: FAIL — endpoints/routes don't exist (404). + +- [ ] **Step 3: Implement the admin GET/PUT endpoints** + +```csharp +// Endpoints/Branding/GetBrandingEndpoint.cs +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Branding.Endpoints.Branding; + +public class GetBrandingEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/api/branding", async (IBrandingContracts branding) => + TypedResults.Ok(await branding.GetEditableAsync())) + .RequirePermission(BrandingPermissions.Manage); +} +``` + +```csharp +// Endpoints/Branding/UpdateBrandingEndpoint.cs +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Branding.Endpoints.Branding; + +public class UpdateBrandingEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPut("/api/branding", async (BrandingEditModel model, IBrandingContracts branding) => + { + await branding.UpdateAsync(model); + return TypedResults.Ok(); + }) + .RequirePermission(BrandingPermissions.Manage) + .DisableAntiforgery(); +} +``` + +- [ ] **Step 4: Implement the upload endpoint** + +```csharp +// Endpoints/Branding/UploadAssetEndpoint.cs +using System; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Security; // GetUserId() — verify namespace in FileStorage UploadEndpoint +using SimpleModule.Core.Settings; +using SimpleModule.FileStorage.Contracts; +using SimpleModule.Settings.Contracts; + +namespace SimpleModule.Branding.Endpoints.Branding; + +public class UploadAssetEndpoint : IEndpoint +{ + private const long MaxBytes = 2 * 1024 * 1024; + + public void Map(IEndpointRouteBuilder app) => + app.MapPost("/api/branding/assets/{kind}", async Task ( + string kind, + IFormFile? file, + HttpContext context, + IFileStorageContracts files, + ISettingsContracts settings) => + { + if (kind is not ("logo" or "favicon")) + return TypedResults.BadRequest("Invalid asset kind."); + if (file is null || file.Length == 0) + return TypedResults.BadRequest("A file is required."); + if (!file.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + return TypedResults.BadRequest("Only image files are allowed."); + if (file.Length > MaxBytes) + return TypedResults.BadRequest("File too large (max 2 MB)."); + + var userId = context.User.GetUserId(); + await using var stream = file.OpenReadStream(); + var stored = await files.UploadFileAsync(stream, file.FileName, file.ContentType, "branding", userId); + + var key = kind == "logo" ? BrandingSettingKeys.LogoFileId : BrandingSettingKeys.FaviconFileId; + var fileId = stored.Id.ToString(); + await settings.SetSettingAsync(key, JsonSerializer.SerializeToElement(fileId), SettingScope.Application); + + return TypedResults.Ok(new { fileId, url = $"/api/branding/assets/{kind}?v={fileId}" }); + }) + .RequirePermission(BrandingPermissions.Manage) + .DisableAntiforgery(); +} +``` + +> Verify `context.User.GetUserId()`'s namespace by reading `modules/FileStorage/.../Endpoints/Files/UploadEndpoint.cs` (it uses the same call) and copy its `using`. `stored.Id.ToString()` must yield a value `Guid.TryParse` can round-trip in the serve endpoint — confirm `FileStorageId.ToString()` returns the bare GUID (read `FileStorageId`); if it returns a wrapped form, store `stored.Id.Value.ToString()` instead and adjust `AssetUrl`/serve parsing to match. + +- [ ] **Step 5: Implement the anonymous serve endpoint** + +```csharp +// Endpoints/Branding/AssetEndpoint.cs +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Settings; +using SimpleModule.FileStorage.Contracts; +using SimpleModule.Settings.Contracts; + +namespace SimpleModule.Branding.Endpoints.Branding; + +public class AssetEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/api/branding/assets/{kind}", async Task ( + string kind, + HttpContext context, + ISettingsContracts settings, + IFileStorageContracts files) => + { + if (kind is not ("logo" or "favicon")) + return Results.NotFound(); + + var key = kind == "logo" ? BrandingSettingKeys.LogoFileId : BrandingSettingKeys.FaviconFileId; + var idStr = await settings.GetSettingAsync(key, SettingScope.Application); + if (string.IsNullOrWhiteSpace(idStr) || !Guid.TryParse(idStr, out var guid)) + return Results.NotFound(); + + var file = await files.GetFileByIdAsync(FileStorageId.From(guid)); + if (file is null) + return Results.NotFound(); + + var stream = await files.DownloadFileAsync(file); + if (stream is null) + return Results.NotFound(); + + context.Response.Headers.CacheControl = "public, max-age=3600"; + return Results.File(stream, file.ContentType); + }) + .AllowAnonymous(); +} +``` + +> `FileStorageId.From(Guid)` — confirm the exact factory on `FileStorageId` (strongly-typed id; the repo uses value converters for it). If the factory differs (e.g. `FileStorageId.FromGuid`, or it wraps a `long`), adjust both this parse and the stored representation in Task 4 Step 4 so they round-trip. + +- [ ] **Step 6: Implement the view endpoint + Pages/index.ts** + +```csharp +// Pages/ManageEndpoint.cs +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Branding.Contracts; +using SimpleModule.Core; +using SimpleModule.Core.Authorization; +using SimpleModule.Core.Inertia; + +namespace SimpleModule.Branding.Pages; + +public class ManageEndpoint : IViewEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapGet("/branding", async (IBrandingContracts branding) => + Inertia.Render("Branding/Manage", new { branding = await branding.GetEditableAsync() })) + .RequirePermission(BrandingPermissions.Manage); +} +``` + +```ts +// Pages/index.ts +export const pages: Record = { + 'Branding/Manage': () => import('./Manage'), +}; +``` + +> `Manage.tsx` is created in Task 6; `validate-pages` is only run after that. To keep the build green now, you may create a minimal placeholder `Pages/Manage.tsx` (`export default function Manage(){return null}`) and flesh it out in Task 6, or defer creating `Pages/index.ts` until Task 6. Prefer the placeholder so the C# endpoint and registry stay in sync. + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `dotnet test --filter "FullyQualifiedName~BrandingEndpointsTests"` +Expected: PASS (all four). + +- [ ] **Step 8: Commit** + +```bash +git add modules/Branding/src/SimpleModule.Branding/Endpoints modules/Branding/src/SimpleModule.Branding/Pages modules/Branding/tests +git commit -m "feat(branding): admin API, asset upload/serve, manage view endpoint" +``` + +--- + +## Task 5: Shared-prop middleware + head contributor + +**Files:** +- Create: `modules/Branding/src/SimpleModule.Branding/BrandingSharedDataMiddleware.cs` +- Create: `modules/Branding/src/SimpleModule.Branding/BrandingHeadContributor.cs` +- Modify: `modules/Branding/src/SimpleModule.Branding/BrandingModule.cs` (register contributor in `ConfigureServices`; add `ConfigureMiddleware`) +- Test: `modules/Branding/tests/SimpleModule.Branding.Tests/Integration/BrandingRenderingTests.cs` + +**Interfaces:** +- Consumes: `IBrandingContracts`, `InertiaSharedData`, `IInertiaHeadContributor` (Task 2). +- Produces: `branding` shared Inertia prop on every page; `` `"); + } + + if (!string.IsNullOrWhiteSpace(b.FaviconUrl)) + sb.Append(""); + + return sb.Length == 0 ? null : sb.ToString(); + } + + private static bool ColorEquals(string a, string b) => + string.Equals(a, b, System.StringComparison.OrdinalIgnoreCase); + + private static string SanitizeColor(string color) => + HexColor().IsMatch(color) ? color : BrandingDefaults.ColorPrimary; + + private static string StripClosingStyle(string css) => + ClosingStyle().Replace(css, string.Empty); + + private static string EncodeAttr(string value) => + value.Replace("\"", """, System.StringComparison.Ordinal); + + [GeneratedRegex("^#[0-9a-fA-F]{3,8}$")] + private static partial Regex HexColor(); + + [GeneratedRegex("", RegexOptions.IgnoreCase)] + private static partial Regex ClosingStyle(); +} +``` + +- [ ] **Step 5: Wire registration in `BrandingModule`** + +In `BrandingModule.ConfigureServices`, add the contributor registration; add `ConfigureMiddleware`: + +```csharp +public void ConfigureServices(IServiceCollection services, IConfiguration configuration) +{ + services.AddScoped(); + services.AddScoped(); +} + +public void ConfigureMiddleware(Microsoft.AspNetCore.Builder.IApplicationBuilder app) +{ + app.UseMiddleware(); +} +``` + +(Add `using Microsoft.AspNetCore.Builder;` and `using SimpleModule.Core.Inertia;` to the top instead of inlining the fully-qualified names if you prefer — match file style.) + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `dotnet test --filter "FullyQualifiedName~BrandingRenderingTests"` +Expected: PASS. Also re-run `HeadContributorTests` to confirm no regression. + +> If `FullPage_Injects_PrimaryColor_WhenChanged` fails because module `ConfigureMiddleware` runs too late to set shared data / contributors aren't resolved on the `/` request, confirm the branding middleware is in the pipeline before endpoint execution (compare with `SimpleModule.Localization`'s middleware). The head contributor path is independent of the middleware (resolved directly by the renderer), so color injection should work even if the shared-prop timing needs adjustment. + +- [ ] **Step 7: Commit** + +```bash +git add modules/Branding/src/SimpleModule.Branding modules/Branding/tests +git commit -m "feat(branding): shared-prop middleware + head contributor (colors/css/favicon)" +``` + +--- + +## Task 6: Frontend — shared types, chrome (brand mark, top bar, footer), layout edits + +**Files:** +- Modify: `packages/SimpleModule.UI/components/layouts/types.ts` +- Create: `packages/SimpleModule.UI/components/layouts/brand-mark.tsx` +- Create: `packages/SimpleModule.UI/components/layouts/top-bar.tsx` +- Create: `packages/SimpleModule.UI/components/layouts/footer.tsx` +- Modify: `packages/SimpleModule.UI/components/layouts/app-layout.tsx` +- Modify: `packages/SimpleModule.UI/components/layouts/public-layout.tsx` +- Modify: `packages/SimpleModule.UI/components/layouts/index.ts` (export new pieces if needed) + +**Interfaces:** +- Consumes: `branding` shared prop (Task 5). +- Produces: `BrandingProps`/`TopBarConfig`/`FooterConfig`/`BrandingLink` TS types on `SharedProps.branding`; ``, ``, `