From 5abb7bcd3d6dbdab74e6f5102553e050ddbd1efe Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 16 May 2026 13:07:28 +0200 Subject: [PATCH 01/56] Make CLI alias-name handling generic so downstream renames need no source changes --- application/README.md | 2 +- developer-cli/Installation/ChangeDetection.cs | 6 +++--- developer-cli/Installation/Configuration.cs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/application/README.md b/application/README.md index 1557cb173..fcdacaa70 100644 --- a/application/README.md +++ b/application/README.md @@ -134,4 +134,4 @@ Please note that all IDs are strongly typed (like `TenantId` and `UserId`), even The architecture is designed according to [screaming architecture](https://blog.cleancoder.com/uncle-bob/2011/09/30/Screaming-Architecture.html). This means that there is one namespace (folder) per feature, using business-related names like `Tenants` and `Users`, making the concepts easily visible and expressive, rather than organizing the code by types like `models`, `services`, `repositories`, etc. -Tests for the Account system are conducted using xUnit, with SQLite for in-memory database testing. The tests can be run directly in JetBrains Rider, Visual Studio, or with the `pp test` command. The tests focus on the behavior of the system, not the implementation details. This is done by focusing on testing the API instead of Application and Domain classes when possible. +Tests for the Account system are conducted using xUnit, with SQLite for in-memory database testing. The tests can be run directly in JetBrains Rider, Visual Studio, or with the `test` cli command. The tests focus on the behavior of the system, not the implementation details. This is done by focusing on testing the API instead of Application and Domain classes when possible. diff --git a/developer-cli/Installation/ChangeDetection.cs b/developer-cli/Installation/ChangeDetection.cs index 90c11f6c5..00f78cc05 100644 --- a/developer-cli/Installation/ChangeDetection.cs +++ b/developer-cli/Installation/ChangeDetection.cs @@ -93,7 +93,7 @@ private static void TryDeletePreviousExe(string previousExePath) try { // Kill processes that have the file locked - ProcessHelper.StartProcess("""powershell -Command "Get-Process pp.previous -ErrorAction SilentlyContinue | Stop-Process -Force" """, redirectOutput: true, exitOnError: false); + ProcessHelper.StartProcess($$"""powershell -Command "Get-Process {{Configuration.AliasName}}.previous -ErrorAction SilentlyContinue | Stop-Process -Force" """, redirectOutput: true, exitOnError: false); ProcessHelper.StartProcess($$"""powershell -Command "Get-Process | Where-Object {$_.Path -eq '{{previousExePath}}'} | Stop-Process -Force" """, redirectOutput: true, exitOnError: false); Thread.Sleep(1000); @@ -266,13 +266,13 @@ private static void PublishDeveloperCli(string currentHash) // Save the hash before moving files into place: while the old single-file bundle is // still mapped at PublishFolder, the runtime can resolve any assembly JsonSerializer - // lazily pulls in (notably System.IO.Pipelines). Once the move replaces pp on disk, + // lazily pulls in (notably System.IO.Pipelines). Once the move replaces {Configuration.AliasName} on disk, // a fresh assembly load fails because the runtime tries to read it from the new // bundle layout. SaveCurrentHash(currentHash); // Skip the config file -- the publish folder is shared across multiple project CLIs - // and each one keeps its config (e.g. pp.json) here. Publish does not emit it, but + // and each one keeps its config (e.g. {Configuration.AliasName}.json) here. Publish does not emit it, but // we exclude it defensively so a future change cannot clobber the hash we just saved. var configFileName = $"{Configuration.AliasName}.json"; foreach (var publishedFile in Directory.EnumerateFiles(tempPublishFolder)) diff --git a/developer-cli/Installation/Configuration.cs b/developer-cli/Installation/Configuration.cs index b7f01e1b5..f52417f7b 100644 --- a/developer-cli/Installation/Configuration.cs +++ b/developer-cli/Installation/Configuration.cs @@ -25,7 +25,7 @@ public static class Configuration : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".PlatformPlatform"); public static readonly string SourceCodeFolder = IsDebugMode - // In debug mode, the ProcessPath is in /developer-cli/artifacts/bin/DeveloperCli/debug/pp.exe + // In debug mode, the ProcessPath is in /developer-cli/artifacts/bin/DeveloperCli/debug/{Configuration.AliasName}.exe ? new DirectoryInfo(Environment.ProcessPath!).Parent!.Parent!.Parent!.Parent!.Parent!.Parent!.FullName : new DirectoryInfo(GetConfigurationSetting().CliSourceCodeFolder!).Parent!.FullName; From 7374a3d4fb33e659f30f0cfa381128bd2e85071a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 16 May 2026 13:11:20 +0200 Subject: [PATCH 02/56] Extract Docker volume prefix into DockerVolumePrefix const for downstream isolation --- application/AppHost/Program.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/application/AppHost/Program.cs b/application/AppHost/Program.cs index a9c0d1e1f..403cb86a6 100644 --- a/application/AppHost/Program.cs +++ b/application/AppHost/Program.cs @@ -6,6 +6,9 @@ using SharedKernel.Authentication.MockEasyAuth; using SharedKernel.Configuration; +// Prefix for Docker volume names. Rename in downstream forks to isolate volumes from upstream. +const string DockerVolumePrefix = "platformplatform"; + // Read the port allocation before CreateBuilder so we can set Aspire's dashboard env vars // (ASPNETCORE_URLS, DOTNET_DASHBOARD_OTLP_ENDPOINT_URL, etc.) before Aspire reads them. var ports = PortAllocation.Load(); @@ -35,7 +38,7 @@ var postgresPassword = builder.CreateStablePassword("postgres-password"); var postgres = builder.AddPostgres("postgres", password: postgresPassword, port: ports.Postgres) - .WithDataVolume($"platform-platform{ports.VolumeNameInfix}-postgres-data") + .WithDataVolume($"{DockerVolumePrefix}{ports.VolumeNameInfix}-postgres-data") .WithLifetime(ContainerLifetime.Persistent) .WithArgs("-c", "wal_level=logical"); @@ -43,7 +46,7 @@ .AddAzureStorage("azure-storage") .RunAsEmulator(resourceBuilder => { - resourceBuilder.WithDataVolume($"platform-platform{ports.VolumeNameInfix}-azure-storage-data"); + resourceBuilder.WithDataVolume($"{DockerVolumePrefix}{ports.VolumeNameInfix}-azure-storage-data"); resourceBuilder.WithBlobPort(ports.Blob); resourceBuilder.WithLifetime(ContainerLifetime.Persistent); } From 5e3dcaac378a07b73f99e125fe4946c085df9744 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 16 May 2026 13:41:02 +0200 Subject: [PATCH 03/56] Move platform-settings.jsonc to application root for visibility --- .../SharedKernel/Platform => }/platform-settings.jsonc | 0 application/shared-kernel/SharedKernel/Platform/Settings.cs | 2 +- application/shared-kernel/SharedKernel/SharedKernel.csproj | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename application/{shared-kernel/SharedKernel/Platform => }/platform-settings.jsonc (100%) diff --git a/application/shared-kernel/SharedKernel/Platform/platform-settings.jsonc b/application/platform-settings.jsonc similarity index 100% rename from application/shared-kernel/SharedKernel/Platform/platform-settings.jsonc rename to application/platform-settings.jsonc diff --git a/application/shared-kernel/SharedKernel/Platform/Settings.cs b/application/shared-kernel/SharedKernel/Platform/Settings.cs index 16bb4bf97..ce4d6e11b 100644 --- a/application/shared-kernel/SharedKernel/Platform/Settings.cs +++ b/application/shared-kernel/SharedKernel/Platform/Settings.cs @@ -15,7 +15,7 @@ public sealed class Settings private static Settings LoadFromEmbeddedResource() { var assembly = Assembly.GetExecutingAssembly(); - var resourceName = "SharedKernel.Platform.platform-settings.jsonc"; + var resourceName = "platform-settings.jsonc"; using var stream = assembly.GetManifestResourceStream(resourceName) ?? throw new InvalidOperationException($"Could not find embedded resource: {resourceName}"); diff --git a/application/shared-kernel/SharedKernel/SharedKernel.csproj b/application/shared-kernel/SharedKernel/SharedKernel.csproj index 98785c0c1..a70614204 100644 --- a/application/shared-kernel/SharedKernel/SharedKernel.csproj +++ b/application/shared-kernel/SharedKernel/SharedKernel.csproj @@ -58,7 +58,7 @@ - + From 0ebe3589186cf6ac404ff8a54bd45d7ed46f9216 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 16 May 2026 14:14:16 +0200 Subject: [PATCH 04/56] Rename DockerVolumePrefix constant to camelCase to satisfy local-constant naming --- application/AppHost/Program.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/application/AppHost/Program.cs b/application/AppHost/Program.cs index 403cb86a6..2d7a04762 100644 --- a/application/AppHost/Program.cs +++ b/application/AppHost/Program.cs @@ -7,7 +7,7 @@ using SharedKernel.Configuration; // Prefix for Docker volume names. Rename in downstream forks to isolate volumes from upstream. -const string DockerVolumePrefix = "platformplatform"; +const string dockerVolumePrefix = "platformplatform"; // Read the port allocation before CreateBuilder so we can set Aspire's dashboard env vars // (ASPNETCORE_URLS, DOTNET_DASHBOARD_OTLP_ENDPOINT_URL, etc.) before Aspire reads them. @@ -38,7 +38,7 @@ var postgresPassword = builder.CreateStablePassword("postgres-password"); var postgres = builder.AddPostgres("postgres", password: postgresPassword, port: ports.Postgres) - .WithDataVolume($"{DockerVolumePrefix}{ports.VolumeNameInfix}-postgres-data") + .WithDataVolume($"{dockerVolumePrefix}{ports.VolumeNameInfix}-postgres-data") .WithLifetime(ContainerLifetime.Persistent) .WithArgs("-c", "wal_level=logical"); @@ -46,7 +46,7 @@ .AddAzureStorage("azure-storage") .RunAsEmulator(resourceBuilder => { - resourceBuilder.WithDataVolume($"{DockerVolumePrefix}{ports.VolumeNameInfix}-azure-storage-data"); + resourceBuilder.WithDataVolume($"{dockerVolumePrefix}{ports.VolumeNameInfix}-azure-storage-data"); resourceBuilder.WithBlobPort(ports.Blob); resourceBuilder.WithLifetime(ContainerLifetime.Persistent); } From 42ea60194cf0c6774c1056b4ff266e4e0070b6f2 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 16 May 2026 14:14:21 +0200 Subject: [PATCH 05/56] Expose platform-settings branding and social links to the frontend build --- .../account/BackOffice/rsbuild.config.ts | 3 +- application/account/WebApp/rsbuild.config.ts | 3 +- application/main/WebApp/rsbuild.config.ts | 3 +- application/platform-settings.jsonc | 32 +++++++++------- .../SharedKernel/Platform/Settings.cs | 11 ++++++ .../shared-webapp/build/environment.d.ts | 33 ++++++++++++++++- .../shared-webapp/build/platformSettings.ts | 37 +++++++++++++++++++ 7 files changed, 105 insertions(+), 17 deletions(-) create mode 100644 application/shared-webapp/build/platformSettings.ts diff --git a/application/account/BackOffice/rsbuild.config.ts b/application/account/BackOffice/rsbuild.config.ts index 1ca0fd2ba..59e058a48 100644 --- a/application/account/BackOffice/rsbuild.config.ts +++ b/application/account/BackOffice/rsbuild.config.ts @@ -1,3 +1,4 @@ +import { loadPlatformSettings } from "@repo/build/platformSettings"; import { DevelopmentServerPlugin } from "@repo/build/plugin/DevelopmentServerPlugin"; import { FileSystemRouterPlugin } from "@repo/build/plugin/FileSystemRouterPlugin"; import { LinguiPlugin } from "@repo/build/plugin/LinguiPlugin"; @@ -9,7 +10,7 @@ import { pluginSourceBuild } from "@rsbuild/plugin-source-build"; import { pluginSvgr } from "@rsbuild/plugin-svgr"; import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; -const customBuildEnv: CustomBuildEnv = {}; +const customBuildEnv: CustomBuildEnv = loadPlatformSettings(); function requirePort(name: string): number { // In production builds, port env vars aren't relevant (no dev server). Returning 0 keeps the diff --git a/application/account/WebApp/rsbuild.config.ts b/application/account/WebApp/rsbuild.config.ts index 9ae3cf85d..0f4e49569 100644 --- a/application/account/WebApp/rsbuild.config.ts +++ b/application/account/WebApp/rsbuild.config.ts @@ -1,3 +1,4 @@ +import { loadPlatformSettings } from "@repo/build/platformSettings"; import { DevelopmentServerPlugin } from "@repo/build/plugin/DevelopmentServerPlugin"; import { FileSystemRouterPlugin } from "@repo/build/plugin/FileSystemRouterPlugin"; import { LinguiPlugin } from "@repo/build/plugin/LinguiPlugin"; @@ -10,7 +11,7 @@ import { pluginSourceBuild } from "@rsbuild/plugin-source-build"; import { pluginSvgr } from "@rsbuild/plugin-svgr"; import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; -const customBuildEnv: CustomBuildEnv = {}; +const customBuildEnv: CustomBuildEnv = loadPlatformSettings(); function requirePort(name: string): number { // In production builds, port env vars aren't relevant (no dev server). Returning 0 keeps the diff --git a/application/main/WebApp/rsbuild.config.ts b/application/main/WebApp/rsbuild.config.ts index a33bc5b18..8c20ad395 100644 --- a/application/main/WebApp/rsbuild.config.ts +++ b/application/main/WebApp/rsbuild.config.ts @@ -1,3 +1,4 @@ +import { loadPlatformSettings } from "@repo/build/platformSettings"; import { DevelopmentServerPlugin } from "@repo/build/plugin/DevelopmentServerPlugin"; import { FileSystemRouterPlugin } from "@repo/build/plugin/FileSystemRouterPlugin"; import { LinguiPlugin } from "@repo/build/plugin/LinguiPlugin"; @@ -10,7 +11,7 @@ import { pluginSourceBuild } from "@rsbuild/plugin-source-build"; import { pluginSvgr } from "@rsbuild/plugin-svgr"; import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; -const customBuildEnv: CustomBuildEnv = {}; +const customBuildEnv: CustomBuildEnv = loadPlatformSettings(); function requirePort(name: string): number { // In production builds, port env vars aren't relevant (no dev server). Returning 0 keeps the diff --git a/application/platform-settings.jsonc b/application/platform-settings.jsonc index 63357a6ad..2483f5aa7 100644 --- a/application/platform-settings.jsonc +++ b/application/platform-settings.jsonc @@ -1,27 +1,33 @@ { - // Platform-wide configuration settings - // This file is prepared for sharing between backend (.NET), frontend (TypeScript), and tests + // Platform-wide brand configuration -- the single source of truth for rebranding. + // Shared by the backend (.NET, embedded resource) and the frontend (TypeScript, injected at build time). // - // IMPORTANT: This configuration is embedded in the backend and injected at build time in the frontend - // Not all values are currently used - some are placeholders for future functionality + // To rebrand a downstream fork, edit the values in this file. Comments must stay on their own + // lines: the frontend build strips whole-line // comments, so a trailing comment after a value + // would not be removed and could corrupt the JSON. // - // Security Note: Only include non-sensitive configuration here - // Sensitive values (like API keys) should be stored in environment variables or key vaults + // Security Note: Only include non-sensitive configuration here. + // Sensitive values (like API keys) should be stored in environment variables or key vaults. "identity": { - // Email domain suffix that identifies internal users - // Users with this domain get access to BackOffice and other internal features - // Currently used by backend only - frontend relies on isInternalUser flag from backend + // Email domain suffix that identifies internal users. + // Users with this domain get access to BackOffice and other internal features. + // Used by backend only - frontend relies on the isInternalUser flag from the backend. "internalEmailDomain": "@platformplatform.net" }, "branding": { - // Product/platform name used throughout the application - // Placeholder for future use - currently hardcoded in various places + // Product/platform name displayed throughout the application. "productName": "PlatformPlatform", - // Support email address for user inquiries - // Placeholder for future use - not currently referenced in the codebase + // Support email address shown in the landing page footer and the in-app support dialog. "supportEmail": "support@platformplatform.net" + }, + + // Public social media / community profile links shown in the landing page footer. + "socialLinks": { + "gitHub": "https://github.com/platformplatform/PlatformPlatform", + "linkedIn": "https://www.linkedin.com/company/platformplatform/", + "youTube": "https://www.youtube.com/@PlatformPlatform" } } diff --git a/application/shared-kernel/SharedKernel/Platform/Settings.cs b/application/shared-kernel/SharedKernel/Platform/Settings.cs index ce4d6e11b..796e5ed3d 100644 --- a/application/shared-kernel/SharedKernel/Platform/Settings.cs +++ b/application/shared-kernel/SharedKernel/Platform/Settings.cs @@ -12,6 +12,8 @@ public sealed class Settings public required BrandingConfig Branding { get; init; } + public required SocialLinksConfig SocialLinks { get; init; } + private static Settings LoadFromEmbeddedResource() { var assembly = Assembly.GetExecutingAssembly(); @@ -41,4 +43,13 @@ public sealed class BrandingConfig public required string SupportEmail { get; init; } } + + public sealed class SocialLinksConfig + { + public required string GitHub { get; init; } + + public required string LinkedIn { get; init; } + + public required string YouTube { get; init; } + } } diff --git a/application/shared-webapp/build/environment.d.ts b/application/shared-webapp/build/environment.d.ts index 5db908f4a..d8559ccc5 100644 --- a/application/shared-webapp/build/environment.d.ts +++ b/application/shared-webapp/build/environment.d.ts @@ -2,7 +2,38 @@ export declare global { /** * Custom build environment variables */ - interface CustomBuildEnv {} + interface CustomBuildEnv { + /** + * Brand configuration injected from platform-settings.jsonc at build time + */ + branding: { + /** + * Product/platform name displayed throughout the application + */ + productName: string; + /** + * Support email address shown in the landing page footer and the in-app support dialog + */ + supportEmail: string; + }; + /** + * Public social media / community profile links injected from platform-settings.jsonc + */ + socialLinks: { + /** + * Public GitHub profile / repository URL + */ + gitHub: string; + /** + * Public LinkedIn profile URL + */ + linkedIn: string; + /** + * Public YouTube channel URL + */ + youTube: string; + }; + } /** * Build Environment Variables */ diff --git a/application/shared-webapp/build/platformSettings.ts b/application/shared-webapp/build/platformSettings.ts new file mode 100644 index 000000000..81ab89501 --- /dev/null +++ b/application/shared-webapp/build/platformSettings.ts @@ -0,0 +1,37 @@ +import fs from "node:fs"; +import path from "node:path"; + +export interface PlatformBranding { + /** Product/platform name displayed throughout the application */ + productName: string; + /** Support email address shown in the landing page footer and the in-app support dialog */ + supportEmail: string; +} + +export interface PlatformSocialLinks { + /** Public GitHub profile / repository URL */ + gitHub: string; + /** Public LinkedIn profile URL */ + linkedIn: string; + /** Public YouTube channel URL */ + youTube: string; +} + +export interface PlatformSettings { + branding: PlatformBranding; + socialLinks: PlatformSocialLinks; +} + +// platform-settings.jsonc is the single source of truth for brand configuration, shared by the +// backend (embedded resource) and the frontend (injected here at build time). It lives at the +// application root; this file compiles to shared-webapp/build/dist/, three levels below it. +const settingsPath = path.join(__dirname, "..", "..", "..", "platform-settings.jsonc"); + +export function loadPlatformSettings(): PlatformSettings { + const raw = fs.readFileSync(settingsPath, "utf8"); + // Strip whole-line // comments. Comments in platform-settings.jsonc must stay on their own + // lines so this does not corrupt values such as URLs that contain "//". + const json = raw.replace(/^\s*\/\/.*$/gm, ""); + const settings = JSON.parse(json) as PlatformSettings; + return { branding: settings.branding, socialLinks: settings.socialLinks }; +} From ffae79994003288bf06778a9cc8c2e9b14754c18 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 16 May 2026 14:14:26 +0200 Subject: [PATCH 06/56] Wire SPA branding strings to platform-settings --- .../errorPages/AccessDeniedPage.tsx | 5 ++- .../errorPages/ErrorPage.tsx | 5 ++- .../errorPages/NotFoundPage.tsx | 5 ++- .../federated-modules/public/PublicFooter.tsx | 41 +++++-------------- .../public/PublicNavigation.tsx | 5 ++- .../federated-modules/sideMenu/MobileMenu.tsx | 3 +- .../sideMenu/TenantMenuSection.tsx | 3 +- .../subscription/SuspendedPage.tsx | 5 ++- .../federated-modules/userMenu/UserMenu.tsx | 3 +- .../account/WebApp/routes/signup/index.tsx | 3 +- .../shared/translations/locale/da-DK.po | 11 +++-- .../shared/translations/locale/en-US.po | 11 +++-- application/main/WebApp/routes/index.tsx | 7 ++-- .../shared/translations/locale/da-DK.po | 12 +++--- .../shared/translations/locale/en-US.po | 12 +++--- .../shared-webapp/infrastructure/branding.ts | 5 +++ 16 files changed, 69 insertions(+), 67 deletions(-) create mode 100644 application/shared-webapp/infrastructure/branding.ts diff --git a/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx b/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx index 6b9e1f86d..eec93206e 100644 --- a/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx +++ b/application/account/WebApp/federated-modules/errorPages/AccessDeniedPage.tsx @@ -3,6 +3,7 @@ import { Trans } from "@lingui/react/macro"; import { AuthenticationContext } from "@repo/infrastructure/auth/AuthenticationProvider"; import { loginPath } from "@repo/infrastructure/auth/constants"; import { useIsAuthenticated, useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { productName } from "@repo/infrastructure/branding"; import { Button } from "@repo/ui/components/Button"; import { Link } from "@repo/ui/components/Link"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; @@ -54,8 +55,8 @@ function AccessDeniedNavigation() { return (