Skip to content

Commit 8631920

Browse files
authored
Add multi-tenant theme customization (API & UI) (#1153)
- Introduce TenantTheme entity, config, and migrations for per-tenant theme storage (colors, typography, layout, brand assets) - Implement ITenantThemeService for CRUD, reset, and S3 asset management - Add API endpoints for get/update/reset theme with validation and permissions - Add Blazor UI: theme customizer, color/brand/typography/layout pickers, live preview, and file upload - Integrate dynamic theme state and dark mode in Playground - Update FileUploadRequest, S3StorageService, navigation, and docs - Enables full white-labeling and theme management per tenant
1 parent 5a58ea6 commit 8631920

File tree

45 files changed

+3828
-10
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+3828
-10
lines changed

.claude/settings.local.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(dotnet build:*)",
5+
"Bash(dotnet ef migrations add:*)",
6+
"Bash(taskkill:*)",
7+
"Bash(docker start:*)"
8+
],
9+
"deny": [],
10+
"ask": []
11+
}
12+
}

.gitignore

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,8 @@ $RECYCLE.BIN/
488488
/.bmad
489489

490490
team/
491-
fshuser/
492491
docs/
493-
spec-os/
492+
spec-os/
493+
/PLAN.md
494+
/nul
495+
**/wwwroot/uploads/*

CLAUDE.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Build & Run Commands
6+
7+
```bash
8+
# Restore and build
9+
dotnet restore src/FSH.Framework.slnx
10+
dotnet build src/FSH.Framework.slnx
11+
12+
# Run with Aspire (spins up Postgres + Redis via Docker)
13+
dotnet run --project src/Playground/FSH.Playground.AppHost
14+
15+
# Run API standalone (requires DB/Redis/JWT config in appsettings)
16+
dotnet run --project src/Playground/Playground.Api
17+
18+
# Run all tests
19+
dotnet test src/FSH.Framework.slnx
20+
21+
# Run single test project
22+
dotnet test src/Tests/Architecture.Tests
23+
24+
# Run specific test
25+
dotnet test src/Tests/Architecture.Tests --filter "FullyQualifiedName~TestMethodName"
26+
27+
# Generate C# API client from OpenAPI spec (requires API running)
28+
./scripts/openapi/generate-api-clients.ps1 -SpecUrl "https://localhost:7030/openapi/v1.json"
29+
30+
# Check for OpenAPI drift (CI validation)
31+
./scripts/openapi/check-openapi-drift.ps1 -SpecUrl "<spec-url>"
32+
```
33+
34+
## Architecture
35+
36+
FullStackHero .NET 10 Starter Kit - multi-tenant SaaS framework using vertical slice architecture.
37+
38+
### Repository Structure
39+
40+
- **src/BuildingBlocks/** - Reusable framework components (packaged as NuGets): Core (DDD primitives), Persistence (EF Core + specifications), Caching (Redis), Mailing, Jobs (Hangfire), Storage, Web (host wiring), Eventing
41+
- **src/Modules/** - Feature modules (packaged as NuGets): Identity (JWT auth, users, roles), Multitenancy (Finbuckle), Auditing
42+
- **src/Playground/** - Reference implementation using direct project references for development; includes Aspire AppHost, API, Blazor UI, PostgreSQL migrations
43+
- **src/Tests/** - Architecture tests using NetArchTest.Rules, xUnit, Shouldly
44+
- **scripts/openapi/** - NSwag-based C# client generation from OpenAPI spec; outputs to `Playground.Blazor/ApiClient/Generated.cs`
45+
- **terraform/** - AWS infrastructure as code (modular)
46+
- `modules/` - Reusable: network, ecs_cluster, ecs_service, rds_postgres, elasticache_redis, alb, s3_bucket
47+
- `apps/playground/` - Playground deployment stack with `envs/{dev,staging,prod}/{region}/`
48+
- `bootstrap/` - Initial AWS setup (S3 backend, etc.)
49+
50+
### Module Pattern
51+
52+
Each module implements `IModule` with:
53+
- `ConfigureServices(IHostApplicationBuilder)` - DI registration
54+
- `MapEndpoints(IEndpointRouteBuilder)` - Minimal API endpoint mapping
55+
56+
Feature structure within modules:
57+
```
58+
Features/v1/{Feature}/
59+
├── {Feature}Command.cs (or Query)
60+
├── {Feature}Handler.cs
61+
├── {Feature}Validator.cs (FluentValidation)
62+
└── {Feature}Endpoint.cs (static extension method on IEndpointRouteBuilder)
63+
```
64+
65+
Contracts projects (`Modules.{Name}.Contracts/`) contain public DTOs shareable with clients.
66+
67+
### Endpoint Pattern
68+
69+
Endpoints are static extension methods returning `RouteHandlerBuilder`:
70+
```csharp
71+
public static RouteHandlerBuilder MapXxxEndpoint(this IEndpointRouteBuilder endpoint)
72+
{
73+
return endpoint.MapPost("/path", async (..., IMediator mediator, CancellationToken ct) =>
74+
{
75+
var result = await mediator.Send(command, ct);
76+
return TypedResults.Ok(result);
77+
});
78+
}
79+
```
80+
81+
### Platform Wiring
82+
83+
In `Program.cs`:
84+
1. Register Mediator with command/query assemblies
85+
2. Call `builder.AddHeroPlatform(...)` - enables auth, OpenAPI, caching, mailing, jobs, health, OTel
86+
3. Call `builder.AddModules(moduleAssemblies)` to load modules
87+
4. Call `app.UseHeroMultiTenantDatabases()` for tenant DB migrations
88+
5. Call `app.UseHeroPlatform(p => p.MapModules = true)` to wire endpoints
89+
90+
## Configuration
91+
92+
Key settings (appsettings or env vars):
93+
- `DatabaseOptions:Provider` - postgres or mssql
94+
- `DatabaseOptions:ConnectionString` - Primary database
95+
- `CachingOptions:Redis` - Redis connection
96+
- `JwtOptions:SigningKey` - Required in production
97+
98+
## Code Standards
99+
100+
- .NET 10, C# latest, nullable enabled
101+
- SonarAnalyzer.CSharp with code style enforced in build
102+
- API versioning in URL path (`/api/v1/...`)
103+
- Mediator library (not MediatR) for commands/queries
104+
- FluentValidation for request validation
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
@using FSH.Framework.Blazor.UI.Theme
2+
3+
<MudPaper Class="pa-4" Elevation="0" Outlined="true">
4+
<MudText Typo="Typo.h6" Class="mb-4">Brand Assets</MudText>
5+
6+
<MudGrid>
7+
@* Light Mode Logo *@
8+
<MudItem xs="12" sm="6" md="4">
9+
<MudPaper Class="pa-4" Elevation="0" Outlined="true">
10+
<MudText Typo="Typo.subtitle2" Class="mb-2">Logo (Light Mode)</MudText>
11+
@if (!string.IsNullOrEmpty(BrandAssets.LogoUrl))
12+
{
13+
<div class="d-flex flex-column align-center gap-2">
14+
<MudImage Src="@BrandAssets.LogoUrl"
15+
Height="64"
16+
Alt="Logo Light"
17+
ObjectFit="ObjectFit.Contain"
18+
Class="rounded" />
19+
<MudButton Variant="Variant.Text"
20+
Color="Color.Error"
21+
Size="Size.Small"
22+
StartIcon="@Icons.Material.Filled.Delete"
23+
OnClick="@(() => ClearLogo())">
24+
Remove
25+
</MudButton>
26+
</div>
27+
}
28+
else
29+
{
30+
<MudFileUpload T="IBrowserFile"
31+
Accept=".png,.jpg,.jpeg,.svg,.webp"
32+
FilesChanged="@OnLogoUpload"
33+
MaximumFileCount="1">
34+
<ActivatorContent>
35+
<MudPaper Class="pa-4 d-flex flex-column align-center justify-center cursor-pointer"
36+
Style="border: 2px dashed; border-color: var(--mud-palette-lines-default); min-height: 100px;"
37+
Elevation="0">
38+
<MudIcon Icon="@Icons.Material.Outlined.CloudUpload" Size="Size.Large" Color="Color.Default" />
39+
<MudText Typo="Typo.caption" Class="mt-2">Click to upload logo</MudText>
40+
<MudText Typo="Typo.caption" Color="Color.Secondary">PNG, JPG, SVG, WebP</MudText>
41+
</MudPaper>
42+
</ActivatorContent>
43+
</MudFileUpload>
44+
}
45+
</MudPaper>
46+
</MudItem>
47+
48+
@* Dark Mode Logo *@
49+
<MudItem xs="12" sm="6" md="4">
50+
<MudPaper Class="pa-4" Elevation="0" Outlined="true" Style="background-color: #1a1a2e;">
51+
<MudText Typo="Typo.subtitle2" Class="mb-2" Style="color: white;">Logo (Dark Mode)</MudText>
52+
@if (!string.IsNullOrEmpty(BrandAssets.LogoDarkUrl))
53+
{
54+
<div class="d-flex flex-column align-center gap-2">
55+
<MudImage Src="@BrandAssets.LogoDarkUrl"
56+
Height="64"
57+
Alt="Logo Dark"
58+
ObjectFit="ObjectFit.Contain"
59+
Class="rounded" />
60+
<MudButton Variant="Variant.Text"
61+
Color="Color.Error"
62+
Size="Size.Small"
63+
StartIcon="@Icons.Material.Filled.Delete"
64+
OnClick="@(() => ClearLogoDark())">
65+
Remove
66+
</MudButton>
67+
</div>
68+
}
69+
else
70+
{
71+
<MudFileUpload T="IBrowserFile"
72+
Accept=".png,.jpg,.jpeg,.svg,.webp"
73+
FilesChanged="@OnLogoDarkUpload"
74+
MaximumFileCount="1">
75+
<ActivatorContent>
76+
<MudPaper Class="pa-4 d-flex flex-column align-center justify-center cursor-pointer"
77+
Style="border: 2px dashed; border-color: rgba(255,255,255,0.3); min-height: 100px; background-color: transparent;"
78+
Elevation="0">
79+
<MudIcon Icon="@Icons.Material.Outlined.CloudUpload" Size="Size.Large" Style="color: rgba(255,255,255,0.7);" />
80+
<MudText Typo="Typo.caption" Class="mt-2" Style="color: rgba(255,255,255,0.7);">Click to upload logo</MudText>
81+
<MudText Typo="Typo.caption" Style="color: rgba(255,255,255,0.5);">PNG, JPG, SVG, WebP</MudText>
82+
</MudPaper>
83+
</ActivatorContent>
84+
</MudFileUpload>
85+
}
86+
</MudPaper>
87+
</MudItem>
88+
89+
@* Favicon *@
90+
<MudItem xs="12" sm="6" md="4">
91+
<MudPaper Class="pa-4" Elevation="0" Outlined="true">
92+
<MudText Typo="Typo.subtitle2" Class="mb-2">Favicon</MudText>
93+
@if (!string.IsNullOrEmpty(BrandAssets.FaviconUrl))
94+
{
95+
<div class="d-flex flex-column align-center gap-2">
96+
<MudImage Src="@BrandAssets.FaviconUrl"
97+
Height="32"
98+
Width="32"
99+
Alt="Favicon"
100+
ObjectFit="ObjectFit.Contain"
101+
Class="rounded" />
102+
<MudButton Variant="Variant.Text"
103+
Color="Color.Error"
104+
Size="Size.Small"
105+
StartIcon="@Icons.Material.Filled.Delete"
106+
OnClick="@(() => ClearFavicon())">
107+
Remove
108+
</MudButton>
109+
</div>
110+
}
111+
else
112+
{
113+
<MudFileUpload T="IBrowserFile"
114+
Accept=".png,.ico,.svg"
115+
FilesChanged="@OnFaviconUpload"
116+
MaximumFileCount="1">
117+
<ActivatorContent>
118+
<MudPaper Class="pa-4 d-flex flex-column align-center justify-center cursor-pointer"
119+
Style="border: 2px dashed; border-color: var(--mud-palette-lines-default); min-height: 100px;"
120+
Elevation="0">
121+
<MudIcon Icon="@Icons.Material.Outlined.Image" Size="Size.Large" Color="Color.Default" />
122+
<MudText Typo="Typo.caption" Class="mt-2">Click to upload favicon</MudText>
123+
<MudText Typo="Typo.caption" Color="Color.Secondary">16x16 or 32x32 PNG, ICO</MudText>
124+
</MudPaper>
125+
</ActivatorContent>
126+
</MudFileUpload>
127+
}
128+
</MudPaper>
129+
</MudItem>
130+
</MudGrid>
131+
132+
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-3">
133+
<MudIcon Icon="@Icons.Material.Outlined.Info" Size="Size.Small" Class="mr-1" Style="vertical-align: middle;" />
134+
Recommended logo size: 200x50px. Favicon: 32x32px. Maximum file size: 2MB.
135+
</MudText>
136+
</MudPaper>
137+
138+
@code {
139+
[Parameter] public BrandAssets BrandAssets { get; set; } = new();
140+
[Parameter] public EventCallback<BrandAssets> BrandAssetsChanged { get; set; }
141+
[Parameter] public EventCallback<(IBrowserFile File, string AssetType)> OnFileUpload { get; set; }
142+
143+
private async Task OnLogoUpload(IBrowserFile file)
144+
{
145+
if (OnFileUpload.HasDelegate)
146+
{
147+
await OnFileUpload.InvokeAsync((file, "logo"));
148+
}
149+
}
150+
151+
private async Task OnLogoDarkUpload(IBrowserFile file)
152+
{
153+
if (OnFileUpload.HasDelegate)
154+
{
155+
await OnFileUpload.InvokeAsync((file, "logo-dark"));
156+
}
157+
}
158+
159+
private async Task OnFaviconUpload(IBrowserFile file)
160+
{
161+
if (OnFileUpload.HasDelegate)
162+
{
163+
await OnFileUpload.InvokeAsync((file, "favicon"));
164+
}
165+
}
166+
167+
private void ClearLogo()
168+
{
169+
BrandAssets.LogoUrl = null;
170+
BrandAssetsChanged.InvokeAsync(BrandAssets);
171+
}
172+
173+
private void ClearLogoDark()
174+
{
175+
BrandAssets.LogoDarkUrl = null;
176+
BrandAssetsChanged.InvokeAsync(BrandAssets);
177+
}
178+
179+
private void ClearFavicon()
180+
{
181+
BrandAssets.FaviconUrl = null;
182+
BrandAssetsChanged.InvokeAsync(BrandAssets);
183+
}
184+
}

0 commit comments

Comments
 (0)