From 6e59e177b31244ec9f7a44df197dcf9183ff45bd Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 16:02:09 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(apps):=20an=20App=20is=20one=20resourc?= =?UTF-8?q?e=20=E2=80=94=20fold=20settings=20into=20the=20App,=20atomic=20?= =?UTF-8?q?create/update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An App was split across two surfaces: the App (RBAC catalog) and the ADR-0011 per-App settings override (branding / origin / login posture / native grants / DCR / CIMD), each with its own modal and its own endpoint. Discovering where a setting lived was confusing, and "create/update an app" took two calls. Make the App ONE resource (API-first, like Users): the per-App settings override is carried inline on POST/PUT/GET /api/app and written in the SAME tenant transaction as the App aggregate (atomic). The granular /api/app/{id}/settings endpoint is removed — there is one write path. Backend: - CreateAppDto/UpdateAppDto gain an optional nested Settings; the read includes it. - AppAdminService (the canonical path the applier also uses) stages the settings override onto the same session and commits once. The only piece that can't be in the tenant transaction — the Origin subdomain, which drives the GLOBAL host→App routing map — is validated up-front (an invalid subdomain rejects the whole create/update before any commit) and its routing applied right after the atomic commit. The applier passes no settings, so its behaviour is unchanged. - ApplicationSettingsService gains StageNonOriginAsync (no-commit) + ValidateOriginAsync. - Removed ApplicationSettingsEndpoints + its registration. Frontend: - Folded ApplicationSettingsModal's tabs into AppDetails as one "Einstellungen" tab (new AppSettingsSections component); removed the separate modal, its route and the "Einstellungen" context-menu entry. Settings load/save ride the App create/update. Tests: migrated ApplicationSettingsAdminTests + the invite-code posture test to the unified endpoint and added atomic-create coverage (valid settings persist with the App; invalid settings reject the whole create — no orphan App). 15 + 26 + 11 backend tests green (incl. the provisioning applier and effective-settings resolution unchanged); frontend type-check + build + full solution build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ApplicationSettingsAdminTests.cs | 115 ++++-- .../InviteCodeRegistrationFlowTests.cs | 27 +- .../Features/Admin/Apps/AppAdminService.cs | 64 ++- .../Apps/ApplicationSettingsEndpoints.cs | 62 --- .../Features/Admin/Apps/AppsEndpoints.cs | 39 +- src/dotnet/Modgud.Api/Program.cs | 3 +- .../ApplicationSettingsService.cs | 145 +++++-- src/frontend-vue/public/i18n/de.json | 3 +- src/frontend-vue/src/models/application.ts | 6 + src/frontend-vue/src/router/index.ts | 13 +- .../src/stores/applications.store.ts | 14 +- .../src/views/admin/apps/AppDetails.vue | 22 +- .../src/views/admin/apps/AppList.vue | 3 - .../views/admin/apps/AppSettingsSections.vue | 362 +++++++++++++++++ .../admin/apps/ApplicationSettingsModal.vue | 379 ------------------ 15 files changed, 706 insertions(+), 551 deletions(-) delete mode 100644 src/dotnet/Modgud.Api/Features/Admin/Apps/ApplicationSettingsEndpoints.cs create mode 100644 src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue delete mode 100644 src/frontend-vue/src/views/admin/apps/ApplicationSettingsModal.vue diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs index 2f6849f5..6f39c624 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/ApplicationSettingsAdminTests.cs @@ -3,6 +3,7 @@ using BuildingBlocks.Helper; using Marten; using Microsoft.Extensions.DependencyInjection; +using Modgud.Api.Features.Admin.Apps; using Modgud.Api.Tests.Infrastructure; using Modgud.Application.DTOs.Applications; using Modgud.Authorization.Apps; @@ -13,25 +14,68 @@ namespace Modgud.Api.Tests.Authorization; /// -/// ADR-0011 — admin API for per-Application settings overrides -/// (GET/PATCH /api/app/{id}/settings): patch→get roundtrip across -/// sections, the Origin write also populating the global host→App routing map, -/// validation, and cross-app subdomain uniqueness. +/// ADR-0011 — an App is ONE resource: the per-App settings override is carried on the +/// unified App endpoint (POST/PUT/GET /api/app), there is no separate +/// /settings endpoint. Covers the atomic create (App + settings in one transaction, +/// and an invalid settings section rejecting the whole create), the update roundtrip across +/// sections, the Origin write also populating the global host→App routing map, validation, +/// and cross-app subdomain uniqueness. /// [Collection(IntegrationTestCollection.Name)] public class ApplicationSettingsAdminTests : IntegrationTestBase { public ApplicationSettingsAdminTests(SharedPostgresFixture fixture) : base(fixture) { } + private sealed record AppRead(string Id, string Slug, ApplicationSettingsDto? Settings); + + [Fact] + public async Task Create_With_Settings_PersistsBoth_Atomically() + { + var ct = TestContext.Current.CancellationToken; + var settings = new ApplicationSettingsDto + { + NativeGrants = new ApplicationNativeGrantsDto { Enabled = true, AccessTokenLifetimeMinutes = 10 }, + Branding = new ApplicationBrandingDto { ProductName = "Created Together" }, + }; + + var resp = await Client.PostAsJsonAsync("/api/app", + new CreateAppDto("as-create-app", "As Create App", null, [], settings), JsonOptions, ct); + Assert.True(resp.IsSuccessStatusCode, $"POST failed ({(int)resp.StatusCode}): {await resp.Content.ReadAsStringAsync(ct)}"); + + var created = await resp.Content.ReadFromJsonAsync(JsonOptions, ct); + Assert.True(created!.Settings!.NativeGrants!.Enabled); + Assert.Equal("Created Together", created.Settings.Branding!.ProductName); + + // Re-read confirms the override was committed alongside the App. + var got = await GetAppAsync(created.Id, ct); + Assert.Equal(10, got.Settings!.NativeGrants!.AccessTokenLifetimeMinutes); + } + + [Fact] + public async Task Create_With_Invalid_Settings_RejectsWholeCreate() + { + var ct = TestContext.Current.CancellationToken; + + // The App itself is valid, but the settings section is not → the whole create is + // rejected and NO App is left behind (one atomic transaction). + var resp = await Client.PostAsJsonAsync("/api/app", + new CreateAppDto("as-atomic-app", "As Atomic App", null, [], + new ApplicationSettingsDto { SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Nope" } }), + JsonOptions, ct); + + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + Assert.False(await AppExistsAsync("as-atomic-app"), "the App must not be persisted when its settings are invalid"); + } + [Fact] - public async Task Patch_Then_Get_Roundtrips_And_Writes_The_Routing_Map() + public async Task Update_Settings_Roundtrips_And_Writes_The_Routing_Map() { var ct = TestContext.Current.CancellationToken; - var app = await CreateAppAsync("as-admin-app"); + var app = await SeedAppAsync("as-admin-app"); var appShort = new ShortGuid(app.Id).ToString(); var host = $"as-admin.{await SystemPrimaryDomainAsync()}"; - var patch = new ApplicationSettingsDto + var settings = new ApplicationSettingsDto { SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Off", Enabled = true }, NativeGrants = new ApplicationNativeGrantsDto { Enabled = true, AccessTokenLifetimeMinutes = 10 }, @@ -41,12 +85,12 @@ public async Task Patch_Then_Get_Roundtrips_And_Writes_The_Routing_Map() RegistrationFields = new ApplicationRegistrationFieldsDto { Firstname = "Required", Lastname = "Off" }, Origin = new ApplicationOriginDto { Subdomain = host }, }; - var patchResp = await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings", patch, JsonOptions, ct); - Assert.True(patchResp.IsSuccessStatusCode, - $"PATCH failed ({(int)patchResp.StatusCode}): {await patchResp.Content.ReadAsStringAsync(ct)}"); + var putResp = await Client.PutAsJsonAsync($"/api/app/{appShort}", + new UpdateAppDto("As Admin App", null, [], settings), JsonOptions, ct); + Assert.True(putResp.IsSuccessStatusCode, + $"PUT failed ({(int)putResp.StatusCode}): {await putResp.Content.ReadAsStringAsync(ct)}"); - var got = await (await Client.GetAsync($"/api/app/{appShort}/settings", ct)) - .Content.ReadFromJsonAsync(JsonOptions, ct); + var got = (await GetAppAsync(appShort, ct)).Settings; Assert.Equal("Off", got!.SelfRegistration!.Posture); Assert.True(got.SelfRegistration.Enabled); Assert.True(got.NativeGrants!.Enabled); @@ -69,48 +113,51 @@ public async Task Patch_Then_Get_Roundtrips_And_Writes_The_Routing_Map() } [Fact] - public async Task Patch_Rejects_Invalid_Values() + public async Task Update_Rejects_Invalid_Values() { var ct = TestContext.Current.CancellationToken; - var app = await CreateAppAsync("as-invalid-app"); + var app = await SeedAppAsync("as-invalid-app"); var appShort = new ShortGuid(app.Id).ToString(); // Bad posture. - Assert.Equal(HttpStatusCode.BadRequest, (await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings", - new ApplicationSettingsDto { SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Nope" } }, - JsonOptions, ct)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await PutSettingsAsync(appShort, + new ApplicationSettingsDto { SelfRegistration = new ApplicationSelfRegistrationDto { Posture = "Nope" } }, ct)).StatusCode); // Subdomain not under the realm's primary domain. - Assert.Equal(HttpStatusCode.BadRequest, (await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings", - new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = "evil.example.com" } }, - JsonOptions, ct)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await PutSettingsAsync(appShort, + new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = "evil.example.com" } }, ct)).StatusCode); // Out-of-bounds token lifetime. - Assert.Equal(HttpStatusCode.BadRequest, (await Client.PatchAsJsonAsync($"/api/app/{appShort}/settings", - new ApplicationSettingsDto { NativeGrants = new ApplicationNativeGrantsDto { AccessTokenLifetimeMinutes = 9999 } }, - JsonOptions, ct)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await PutSettingsAsync(appShort, + new ApplicationSettingsDto { NativeGrants = new ApplicationNativeGrantsDto { AccessTokenLifetimeMinutes = 9999 } }, ct)).StatusCode); } [Fact] public async Task Subdomain_Is_Unique_Across_Apps() { var ct = TestContext.Current.CancellationToken; - var app1 = await CreateAppAsync("as-uniq-1"); - var app2 = await CreateAppAsync("as-uniq-2"); + var app1 = await SeedAppAsync("as-uniq-1"); + var app2 = await SeedAppAsync("as-uniq-2"); var host = $"as-uniq.{await SystemPrimaryDomainAsync()}"; - var first = await Client.PatchAsJsonAsync($"/api/app/{new ShortGuid(app1.Id)}/settings", - new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, JsonOptions, ct); + var first = await PutSettingsAsync(new ShortGuid(app1.Id).ToString(), + new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, ct); Assert.True(first.IsSuccessStatusCode); - var second = await Client.PatchAsJsonAsync($"/api/app/{new ShortGuid(app2.Id)}/settings", - new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, JsonOptions, ct); + var second = await PutSettingsAsync(new ShortGuid(app2.Id).ToString(), + new ApplicationSettingsDto { Origin = new ApplicationOriginDto { Subdomain = host } }, ct); Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); } // ── Helpers ────────────────────────────────────────────────────────────── - private async Task CreateAppAsync(string slug) + private Task PutSettingsAsync(string appShort, ApplicationSettingsDto settings, CancellationToken ct) => + Client.PutAsJsonAsync($"/api/app/{appShort}", new UpdateAppDto(appShort, null, [], settings), JsonOptions, ct); + + private async Task GetAppAsync(string appShort, CancellationToken ct) => + (await (await Client.GetAsync($"/api/app/{appShort}", ct)).Content.ReadFromJsonAsync(JsonOptions, ct))!; + + private async Task SeedAppAsync(string slug) { using var scope = Factory.Services.CreateScope(); var session = scope.ServiceProvider.GetRequiredService(); @@ -121,6 +168,14 @@ private async Task CreateAppAsync(string slug) return (await session.LoadAsync(id, TestContext.Current.CancellationToken))!; } + private async Task AppExistsAsync(string slug) + { + using var scope = Factory.Services.CreateScope(); + var session = scope.ServiceProvider.GetRequiredService(); + return await session.Query().Where(a => a.Slug == slug && !a.IsDeleted) + .AnyAsync(TestContext.Current.CancellationToken); + } + private async Task SystemPrimaryDomainAsync() { var globalStore = Factory.Services.GetRequiredService(); diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/InviteCodeRegistrationFlowTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/InviteCodeRegistrationFlowTests.cs index 83a350ba..e1155079 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/InviteCodeRegistrationFlowTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/InviteCodeRegistrationFlowTests.cs @@ -166,21 +166,28 @@ public async Task InviteCode_ConfirmedUser_With_Code_Is_Plain_Login_Code_Untouch } [Fact] - public async Task InviteCode_Posture_RoundTrips_Through_Settings_Endpoint() + public async Task InviteCode_Posture_RoundTrips_Through_App_Resource() { - // Gate 1 — the new posture value survives PATCH → GET on the per-App - // settings surface (sparse, zero-migration). + // Gate 1 — the new posture value survives a unified App update → GET on the + // single App resource (settings carried inline; sparse, zero-migration). var ct = TestContext.Current.CancellationToken; var app = await CreateAppAsync("adr12-posture"); var appShort = new ShortGuid(app.Id).ToString(); - var patch = await Client.PatchAsJsonAsync( - $"/api/app/{appShort}/settings", - new { SelfRegistration = new { Posture = "InviteCode" } }, ct); - Assert.Equal(HttpStatusCode.OK, patch.StatusCode); - - var got = await Client.GetFromJsonAsync($"/api/app/{appShort}/settings", JsonOptions, ct); - Assert.Equal("InviteCode", got.GetProperty("SelfRegistration").GetProperty("Posture").GetString()); + var put = await Client.PutAsJsonAsync( + $"/api/app/{appShort}", + new + { + DisplayName = "adr12-posture", + Description = (string?)null, + Permissions = Array.Empty(), + Settings = new { SelfRegistration = new { Posture = "InviteCode" } }, + }, ct); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + + var got = await Client.GetFromJsonAsync($"/api/app/{appShort}", JsonOptions, ct); + Assert.Equal("InviteCode", + got.GetProperty("Settings").GetProperty("SelfRegistration").GetProperty("Posture").GetString()); } [Fact] diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs index 9885df16..1353c089 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppAdminService.cs @@ -1,5 +1,7 @@ using ErrorOr; using Marten; +using Modgud.Application.DTOs.Applications; +using Modgud.Authentication.Applications; using Modgud.Authorization.Apps; using Modgud.Authorization.Events; using Modgud.Domain.OAuth.Apis; @@ -14,8 +16,16 @@ namespace Modgud.Api.Features.Admin.Apps; /// endpoint maps it to HTTP while the applier consumes it directly. The injected /// is tenant-scoped, so a call lands in whatever realm /// the ambient TenantContext selects. +/// +/// An App is ONE resource: when / +/// is supplied, the per-App ADR-0011 settings override +/// is written in the SAME tenant transaction as the App aggregate (atomic). The only piece +/// that cannot be — the Origin subdomain, which drives the GLOBAL host→App routing +/// map in a different database — is validated up-front (so an invalid subdomain rejects the +/// whole call before any commit) and its routing applied right after the atomic commit. The +/// applier passes no settings, so its behaviour is unchanged. /// -public sealed class AppAdminService(IDocumentSession session) +public sealed class AppAdminService(IDocumentSession session, IApplicationSettingsService settingsSvc) { public async Task> CreateAppAsync(CreateAppDto dto, CancellationToken ct = default) { @@ -46,8 +56,19 @@ public async Task> CreateAppAsync(CreateAppDto dto, CancellationTok Description: dto.Description, Permissions: permissions.Value, IsSystem: false)); + + // App + its settings override commit together (atomic) — see class summary. + if (dto.Settings is not null) + { + var staged = await StageSettingsAsync(id, dto.Settings, ct); + if (staged.IsError) return staged.Errors; + } + await session.SaveChangesAsync(ct); + var originApplied = await ApplyOriginIfAnyAsync(id, dto.Settings, ct); + if (originApplied.IsError) return originApplied.Errors; + return (await session.LoadAsync(id, ct))!; } @@ -97,11 +118,52 @@ public async Task> UpdateAppAsync(Guid id, UpdateAppDto dto, Cancel session.Events.Append(id, new AppUpdatedEvent( id, dto.DisplayName, dto.Description, permissions.Value)); + + // App + its settings override commit together (atomic) — see class summary. + if (dto.Settings is not null) + { + var staged = await StageSettingsAsync(id, dto.Settings, ct); + if (staged.IsError) return staged.Errors; + } + await session.SaveChangesAsync(ct); + var originApplied = await ApplyOriginIfAnyAsync(id, dto.Settings, ct); + if (originApplied.IsError) return originApplied.Errors; + return (await session.LoadAsync(id, ct))!; } + /// + /// Validates the Origin subdomain up-front (so an invalid one rejects the whole + /// create/update before any commit) and stages every other settings section onto the + /// shared session — committed atomically with the App by the caller's SaveChangesAsync. + /// + private async Task> StageSettingsAsync(Guid appId, ApplicationSettingsDto settings, CancellationToken ct) + { + if (settings.Origin is not null) + { + var validOrigin = await settingsSvc.ValidateOriginAsync(appId, settings.Origin.Subdomain, ct); + if (validOrigin.IsError) return validOrigin.Errors; + } + var staged = await settingsSvc.StageNonOriginAsync(appId, settings, ct); + return staged.IsError ? staged.Errors : Result.Success; + } + + /// + /// Applies the Origin subdomain → GLOBAL host routing AFTER the atomic tenant commit + /// (the routing map lives in a different database, so it can't be in the tenant + /// transaction). Already validated in , so this only + /// fails on a race or infrastructure error — and then the App + its other settings are + /// already persisted (a valid state; only the subdomain route is missing). + /// + private async Task> ApplyOriginIfAnyAsync(Guid appId, ApplicationSettingsDto? settings, CancellationToken ct) + { + if (settings?.Origin is null) return Result.Success; + var r = await settingsSvc.PatchAsync(appId, new ApplicationSettingsDto { Origin = settings.Origin }, ct); + return r.IsError ? r.Errors : Result.Success; + } + /// /// The single canonical delete path for an , shared by /// and the realm-provisioning applier's prune. Refuses the diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/ApplicationSettingsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/ApplicationSettingsEndpoints.cs deleted file mode 100644 index 71821d06..00000000 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/ApplicationSettingsEndpoints.cs +++ /dev/null @@ -1,62 +0,0 @@ -using BuildingBlocks.Helper; -using Modgud.Application.DTOs.Applications; -using Modgud.Authentication.Applications; -using Modgud.Authorization.AspNetCore; - -namespace Modgud.Api.Features.Admin.Apps; - -/// -/// ADR-0011 — admin surface for a per-Application settings override (the -/// tenant-scoped ApplicationSettings doc keyed by App.Id). Sparse: -/// GET returns only what the App overrides (null section = inherits the realm); -/// PATCH replaces provided sections (a null section = no change). Setting -/// Origin.Subdomain also writes the global host→App routing map. Gated by -/// the same app:read/app:write permissions as the rest of App admin. -/// -public static class ApplicationSettingsEndpoints -{ - public static WebApplication MapApplicationSettingsEndpoints(this WebApplication application, string path) - { - var group = application.MapGroup($"{path}/app") - .WithTags("Application Settings") - .RequireAuthorization(); - - group.MapGet("{id}/settings", async ( - ShortGuid id, - IApplicationSettingsService svc, - CancellationToken ct) => - { - var result = await svc.GetAsync(id.Guid, ct); - return result.Match(Results.Ok, Problem); - }) - .WithName("V2_App_Settings_Get") - .RequiresPermission("app:read"); - - group.MapPatch("{id}/settings", async ( - ShortGuid id, - ApplicationSettingsDto dto, - IApplicationSettingsService svc, - CancellationToken ct) => - { - var result = await svc.PatchAsync(id.Guid, dto, ct); - return result.Match(Results.Ok, Problem); - }) - .WithName("V2_App_Settings_Patch") - .RequiresPermission("app:write"); - - return application; - } - - private static IResult Problem(List errors) - { - var first = errors[0]; - var status = first.Type switch - { - ErrorOr.ErrorType.NotFound => StatusCodes.Status404NotFound, - ErrorOr.ErrorType.Validation => StatusCodes.Status400BadRequest, - ErrorOr.ErrorType.Conflict => StatusCodes.Status409Conflict, - _ => StatusCodes.Status500InternalServerError, - }; - return Results.Problem(statusCode: status, title: first.Code, detail: first.Description); - } -} diff --git a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs index c6fb6c63..d04d4a6f 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/Apps/AppsEndpoints.cs @@ -1,7 +1,9 @@ using BuildingBlocks.Helper; using ErrorOr; +using Modgud.Application.DTOs.Applications; using Modgud.Application.DTOs.OAuth; using Modgud.Application.Services; +using Modgud.Authentication.Applications; using Modgud.Authorization.Apps; using Modgud.Authorization.AspNetCore; using Marten; @@ -25,12 +27,17 @@ public record CreateAppDto( string Slug, string DisplayName, string? Description, - List Permissions); + List Permissions, + // ADR-0011 — an App is ONE resource: the optional per-App settings override is created + // in the SAME tenant transaction as the App (see AppAdminService). Null = inherit the + // realm everywhere (the zero-config default). The applier never sends it. + ApplicationSettingsDto? Settings = null); public record UpdateAppDto( string DisplayName, string? Description, - List Permissions); + List Permissions, + ApplicationSettingsDto? Settings = null); /// /// Admin surface for managing records — the per-realm list @@ -74,34 +81,41 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s .OrderBy(a => a.Slug) .ToListAsync(); - return Results.Ok(apps.Select(MapToResponse)); + return Results.Ok(apps.Select(a => MapToResponse(a))); }) .WithName("V2_App_GetAll") .RequiresPermission("app:read"); - appGroup.MapGet("{id}", async (ShortGuid id, IDocumentSession session) => + appGroup.MapGet("{id}", async (ShortGuid id, IDocumentSession session, IApplicationSettingsService settingsSvc, CancellationToken ct) => { - var app = await session.LoadAsync(id.Guid); + var app = await session.LoadAsync(id.Guid, ct); if (app is null || app.IsDeleted) return Results.NotFound(); - return Results.Ok(MapToResponse(app)); + var settings = await settingsSvc.GetAsync(id.Guid, ct); + return Results.Ok(MapToResponse(app, settings.IsError ? null : settings.Value)); }) .WithName("V2_App_GetById") .RequiresPermission("app:read"); // Create / Update both delegate to the shared AppAdminService — the single // canonical write path the realm-provisioning applier also calls (no divergence). - appGroup.MapPost("", async (CreateAppDto dto, AppAdminService appAdmin, CancellationToken ct) => + appGroup.MapPost("", async (CreateAppDto dto, AppAdminService appAdmin, IApplicationSettingsService settingsSvc, CancellationToken ct) => { var result = await appAdmin.CreateAppAsync(dto, ct); - return result.IsError ? ToErrorResult(result.FirstError) : Results.Ok(MapToResponse(result.Value)); + if (result.IsError) return ToErrorResult(result.FirstError); + var settings = await settingsSvc.GetAsync(result.Value.Id, ct); + return Results.Ok(MapToResponse(result.Value, settings.IsError ? null : settings.Value)); }) .WithName("V2_App_Create") .RequiresPermission("app:write"); - appGroup.MapPut("{id}", async (ShortGuid id, UpdateAppDto dto, AppAdminService appAdmin, CancellationToken ct) => + appGroup.MapPut("{id}", async (ShortGuid id, UpdateAppDto dto, AppAdminService appAdmin, IApplicationSettingsService settingsSvc, CancellationToken ct) => { var result = await appAdmin.UpdateAppAsync(id.Guid, dto, ct); - if (!result.IsError) return Results.Ok(MapToResponse(result.Value)); + if (!result.IsError) + { + var settings = await settingsSvc.GetAsync(id.Guid, ct); + return Results.Ok(MapToResponse(result.Value, settings.IsError ? null : settings.Value)); + } var error = result.FirstError; // The catalog-delete block carries its rich blocker list through the error @@ -155,7 +169,9 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s return application; } - private static object MapToResponse(App a) => new + // The list endpoint passes no settings (the grid doesn't show them); the detail / create / + // update responses pass the per-App settings override so the single App modal can render it. + private static object MapToResponse(App a, ApplicationSettingsDto? settings = null) => new { Id = new ShortGuid(a.Id).ToString(), a.Slug, @@ -171,6 +187,7 @@ public static WebApplication MapAppsEndpoints(this WebApplication application, s }) .ToList(), a.IsSystem, + Settings = settings, }; // Renders an AppAdminService ErrorOr error with the error code in the body. The shared diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index c7d04bc8..1e7936c2 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -1268,8 +1268,9 @@ app.MapPrincipalEndpoints("api"); app.MapRolesEndpoints("api"); app.MapGroupEndpoints("api"); + // An App is one resource: AppsEndpoints carries the per-App ADR-0011 settings override + // inline (POST/PUT/GET /api/app), so there is no separate /settings endpoint. Modgud.Api.Features.Admin.Apps.AppsEndpoints.MapAppsEndpoints(app, "api"); - Modgud.Api.Features.Admin.Apps.ApplicationSettingsEndpoints.MapApplicationSettingsEndpoints(app, "api"); // ADR-0012 — app-scoped invite codes (dual-auth: invite:write scope or invite-code:write permission). Modgud.Api.Features.InviteCodes.InviteCodeEndpoints.MapInviteCodeEndpoints(app, "api"); diff --git a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs index 7498edde..516fe985 100644 --- a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs @@ -23,6 +23,24 @@ public interface IApplicationSettingsService { Task> GetAsync(Guid applicationId, CancellationToken ct = default); Task> PatchAsync(Guid applicationId, ApplicationSettingsDto dto, CancellationToken ct = default); + + /// + /// Stages the per-App settings override (every section EXCEPT Origin) onto the + /// caller's shared WITHOUT committing, so it lands in the + /// same tenant transaction as the App aggregate — the unified, atomic App create/update. + /// Origin is excluded because it also drives the GLOBAL host→App routing map (a + /// different database, so inherently a separate write): validate it up-front via + /// and apply it via AFTER the + /// atomic commit. No app-existence check — the caller (AppAdminService) guarantees it. + /// + Task> StageNonOriginAsync(Guid applicationId, ApplicationSettingsDto dto, CancellationToken ct = default); + + /// + /// Read-only validation of an Origin subdomain (format, child-of-the-realm-primary, + /// cross-realm uniqueness) so a unified create/update can reject an invalid subdomain + /// up-front — before committing anything. An empty/null subdomain (a clear) is always valid. + /// + Task> ValidateOriginAsync(Guid applicationId, string? subdomain, CancellationToken ct = default); } public sealed class ApplicationSettingsService( @@ -135,14 +153,114 @@ public async Task> PatchAsync( return app is { IsDeleted: false } ? app : null; } + // ── Atomic-create staging (every section except Origin, no commit) ──────── + + public async Task> StageNonOriginAsync( + Guid applicationId, ApplicationSettingsDto dto, CancellationToken ct = default) + { + var doc = await session.LoadAsync(applicationId, ct) + ?? new ApplicationSettings { Id = applicationId, CreatedAt = DateTimeOffset.UtcNow }; + if (doc.CreatedAt == default) doc.CreatedAt = DateTimeOffset.UtcNow; + + if (dto.SelfRegistration is not null) + { + var r = MapSelfRegistration(dto.SelfRegistration); + if (r.IsError) return r.FirstError; + doc.SelfRegistration = r.Value; + } + if (dto.NativeGrants is not null) + { + var r = MapNativeGrants(dto.NativeGrants); + if (r.IsError) return r.FirstError; + doc.NativeGrants = r.Value; + } + if (dto.Dcr is not null) + { + var r = MapDcr(dto.Dcr); + if (r.IsError) return r.FirstError; + doc.Dcr = r.Value; + } + if (dto.Cimd is not null) + { + var r = MapCimd(dto.Cimd); + if (r.IsError) return r.FirstError; + doc.Cimd = r.Value; + } + if (dto.Branding is not null) + { + var r = MapBranding(dto.Branding); + if (r.IsError) return r.FirstError; + doc.Branding = r.Value; + } + if (dto.EmailBranding is not null) + { + doc.EmailBranding = string.IsNullOrWhiteSpace(dto.EmailBranding.ProductName) + ? null + : new ApplicationEmailBranding { ProductName = dto.EmailBranding.ProductName!.Trim() }; + } + if (dto.RegistrationFields is not null) + { + var r = MapRegistrationFields(dto.RegistrationFields); + if (r.IsError) return r.FirstError; + doc.RegistrationFields = r.Value; + } + + doc.UpdatedAt = DateTimeOffset.UtcNow; + session.Store(doc); // enrolled on the shared session; the caller commits. + return ErrorOr.Result.Success; + } + // ── Origin / global routing map ────────────────────────────────────────── + public async Task> ValidateOriginAsync( + Guid applicationId, string? subdomainRaw, CancellationToken ct = default) + { + var subdomain = subdomainRaw?.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(subdomain)) return ErrorOr.Result.Success; // clearing is always valid + + var slug = TenantContext.Current; + await using var gsession = globalStore.LightweightSession(); + var realm = await gsession.Query().FirstOrDefaultAsync(r => r.Slug == slug, ct); + if (realm is null) + return Error.Failure("Application.RealmNotFound", "The current realm could not be resolved."); + + if (!HostRegex.IsMatch(subdomain)) + return Error.Validation("Application.InvalidSubdomain", "Subdomain must be a valid hostname."); + + // Must be a child of the realm's primary domain (the cookie + routing + // model: apps live under the tenant's primary domain). + var primary = realm.PrimaryDomain.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(primary) || !subdomain.EndsWith("." + primary, StringComparison.Ordinal)) + return Error.Validation("Application.SubdomainNotUnderPrimary", + $"Subdomain must be a child of the realm's primary domain ('{realm.PrimaryDomain}')."); + + // Cross-realm uniqueness: the host must not be claimed by any realm's + // plain domains or another App's route. + var allRealms = await gsession.Query().ToListAsync(ct); + foreach (var r in allRealms) + { + if (r.Domains.Any(d => string.Equals(d, subdomain, StringComparison.OrdinalIgnoreCase))) + return Error.Conflict("Application.SubdomainTaken", "That host is already a realm domain."); + foreach (var kv in r.ApplicationDomains) + { + if (string.Equals(kv.Key, subdomain, StringComparison.OrdinalIgnoreCase) + && !(r.Id == realm.Id && kv.Value == applicationId)) + return Error.Conflict("Application.SubdomainTaken", "That host is already mapped to an application."); + } + } + + return ErrorOr.Result.Success; + } + private async Task> ApplyOriginAsync( Guid applicationId, string? subdomainRaw, CancellationToken ct) { - var slug = TenantContext.Current; var subdomain = subdomainRaw?.Trim().ToLowerInvariant(); + var valid = await ValidateOriginAsync(applicationId, subdomain, ct); + if (valid.IsError) return valid.FirstError; + + var slug = TenantContext.Current; await using var gsession = globalStore.LightweightSession(); var realm = await gsession.Query().FirstOrDefaultAsync(r => r.Slug == slug, ct); if (realm is null) @@ -159,31 +277,6 @@ public async Task> PatchAsync( ApplicationOrigin? origin = null; if (!string.IsNullOrEmpty(subdomain)) { - if (!HostRegex.IsMatch(subdomain)) - return Error.Validation("Application.InvalidSubdomain", "Subdomain must be a valid hostname."); - - // Must be a child of the realm's primary domain (the cookie + routing - // model: apps live under the tenant's primary domain). - var primary = realm.PrimaryDomain.Trim().ToLowerInvariant(); - if (string.IsNullOrEmpty(primary) || !subdomain.EndsWith("." + primary, StringComparison.Ordinal)) - return Error.Validation("Application.SubdomainNotUnderPrimary", - $"Subdomain must be a child of the realm's primary domain ('{realm.PrimaryDomain}')."); - - // Cross-realm uniqueness: the host must not be claimed by any realm's - // plain domains or another App's route. - var allRealms = await gsession.Query().ToListAsync(ct); - foreach (var r in allRealms) - { - if (r.Domains.Any(d => string.Equals(d, subdomain, StringComparison.OrdinalIgnoreCase))) - return Error.Conflict("Application.SubdomainTaken", "That host is already a realm domain."); - foreach (var kv in r.ApplicationDomains) - { - if (string.Equals(kv.Key, subdomain, StringComparison.OrdinalIgnoreCase) - && !(r.Id == realm.Id && kv.Value == applicationId)) - return Error.Conflict("Application.SubdomainTaken", "That host is already mapped to an application."); - } - } - realm.ApplicationDomains[subdomain] = applicationId; origin = new ApplicationOrigin { Subdomain = subdomain }; } diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index dae9d141..217336f6 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -1296,7 +1296,8 @@ "systemHint": "Dies ist eine System-App des IdP. Slug, Display Name und Permission-Catalog sind im Backend fest hinterlegt — der Catalog hier ist schreibgeschützt und dient nur zur Einsicht. Änderungen an den Strings würden die RequiresPermission-Aufrufe im Backend brechen.", "tabs": { "catalog": "Permission-Catalog", - "general": "Allgemein" + "general": "Allgemein", + "settings": "Einstellungen" } }, "realms": { diff --git a/src/frontend-vue/src/models/application.ts b/src/frontend-vue/src/models/application.ts index 95e58e42..5fee18b9 100644 --- a/src/frontend-vue/src/models/application.ts +++ b/src/frontend-vue/src/models/application.ts @@ -20,6 +20,9 @@ export interface ApplicationDto { Description?: string | null Permissions: ApplicationPermissionDto[] IsSystem: boolean + // An App is one resource: the per-App ADR-0011 settings override is carried inline on + // GET {id} / create / update (null on the list endpoint, which doesn't render it). + Settings?: ApplicationSettingsDto | null } export interface ApplicationLookupDto { @@ -45,12 +48,15 @@ export interface CreateApplicationDto { DisplayName: string Description?: string | null Permissions: ApplicationPermissionInputDto[] + // Optional per-App settings override, written atomically with the App. + Settings?: ApplicationSettingsDto | null } export interface UpdateApplicationDto { DisplayName: string Description?: string | null Permissions: ApplicationPermissionInputDto[] + Settings?: ApplicationSettingsDto | null } // ── ADR-0011: per-Application settings overrides ──────────────────────────── diff --git a/src/frontend-vue/src/router/index.ts b/src/frontend-vue/src/router/index.ts index b340054b..fb18b084 100644 --- a/src/frontend-vue/src/router/index.ts +++ b/src/frontend-vue/src/router/index.ts @@ -133,7 +133,6 @@ const GROUP_MODAL_SIZE = { // Heaviest builders (wide AG-Grid catalog / 6-tab client builder) → full. const APP_MODAL_SIZE = MODAL_FULL -const APP_SETTINGS_MODAL_SIZE = MODAL_MD const CLIENT_MODAL_SIZE = MODAL_FULL const routes = [ @@ -406,21 +405,13 @@ const routes = [ meta: { routedFragments: [ { - // The :id slot is the App's Id (or "create"). Slug is - // immutable post-creation but stored on the dto. + // The :id slot is the App's Id (or "create"). One modal for the + // whole App: identity + permission catalog + ADR-0011 settings. type: 'modal', path: ':id', component: () => import('@/views/admin/apps/AppDetails.vue'), overlayOptions: { size: APP_MODAL_SIZE }, }, - { - // ADR-0011 — per-App settings overrides. Two-segment path so - // it never collides with the single-segment `:id` App modal. - type: 'modal', - path: 'settings/:id', - component: () => import('@/views/admin/apps/ApplicationSettingsModal.vue'), - overlayOptions: { size: APP_SETTINGS_MODAL_SIZE }, - }, ], }, }, diff --git a/src/frontend-vue/src/stores/applications.store.ts b/src/frontend-vue/src/stores/applications.store.ts index 2c8463d3..b558e72b 100644 --- a/src/frontend-vue/src/stores/applications.store.ts +++ b/src/frontend-vue/src/stores/applications.store.ts @@ -3,7 +3,6 @@ import { ref } from 'vue' import { useHttpClient } from '@/composables/useHttpClient' import type { ApplicationDto, - ApplicationSettingsDto, CreateApplicationDto, UpdateApplicationDto, } from '@/models/application' @@ -60,15 +59,8 @@ export const useApplicationsStore = defineStore('applications', () => { apps.value = apps.value.filter((a) => a.Id !== id) } - // ADR-0011 — per-Application settings overrides (separate from the App CRUD - // above; tenant ApplicationSettings doc + global subdomain routing map). - async function loadSettings(id: string): Promise { - return await http.addPath(id, 'settings').get() - } - - async function saveSettings(id: string, dto: ApplicationSettingsDto): Promise { - return await http.addPath(id, 'settings').patch(dto) - } + // The per-App ADR-0011 settings override rides inline on loadOne/create/update + // (an App is one resource) — there is no separate settings endpoint. return { apps, @@ -79,8 +71,6 @@ export const useApplicationsStore = defineStore('applications', () => { create, update, remove, - loadSettings, - saveSettings, } }) diff --git a/src/frontend-vue/src/views/admin/apps/AppDetails.vue b/src/frontend-vue/src/views/admin/apps/AppDetails.vue index 6b77c44c..e4c1bfad 100644 --- a/src/frontend-vue/src/views/admin/apps/AppDetails.vue +++ b/src/frontend-vue/src/views/admin/apps/AppDetails.vue @@ -4,6 +4,7 @@ import { CoarTextInput, CoarFormField, CoarNote, CoarButton, CoarTabGroup, CoarT import { CoarDataGrid, CoarGridBuilder } from '@cocoar/vue-data-grid' import { useI18n } from '@cocoar/vue-localization' import ModalLayout from '@/components/ModalLayout.vue' +import AppSettingsSections from './AppSettingsSections.vue' import { useApplicationsStore } from '@/stores/applications.store' import type { ApplicationDto, @@ -153,7 +154,8 @@ const hasIncompleteRows = computed(() => catalog.value.some((r) => )) const isSystem = computed(() => dto.value?.IsSystem === true) -const activeTab = ref<'general' | 'catalog'>('general') +const activeTab = ref<'general' | 'catalog' | 'settings'>('general') +const settingsRef = ref | null>(null) /** * Per-cell visual cue for the resource/action validation surface. The @@ -279,18 +281,24 @@ async function save() { error.value = null catalogBlockers.value = [] try { + // An App is one resource: its ADR-0011 settings override is part of the same + // create/update payload (the backend writes it in one tenant transaction). System + // apps carry no per-App settings, so omit it for them. + const settings = isSystem.value ? undefined : settingsRef.value?.build() if (isCreate.value) { await store.create({ Slug: form.value.Slug.trim(), DisplayName: form.value.DisplayName.trim(), Description: form.value.Description.trim() || null, Permissions: buildPermissionsPayload(), + Settings: settings, }) } else { await store.update(id.value, { DisplayName: form.value.DisplayName.trim(), Description: form.value.Description.trim() || null, Permissions: buildPermissionsPayload(), + Settings: settings, }) } props.close() @@ -318,9 +326,10 @@ async function save() { {{ t('common.loading', {}, 'Laden...') }}
- + {{ t('admin.apps.tabs.general', {}, 'Allgemein') }} {{ t('admin.apps.tabs.catalog', {}, 'Permission-Catalog') }} + {{ t('admin.apps.tabs.settings', {}, 'Einstellungen') }} @@ -331,7 +340,7 @@ async function save() { -
+
-
+

{{ isSystem ? t('admin.apps.permissionsHintSystem', {}, 'Permission-Catalog der System-App — read-only. Diese Einträge entsprechen 1:1 den RequiresPermission-Aufrufen im Backend-Code.') @@ -404,6 +413,11 @@ async function save() {

+ +
+ +
+

{{ error }}

diff --git a/src/frontend-vue/src/views/admin/apps/AppList.vue b/src/frontend-vue/src/views/admin/apps/AppList.vue index f16565f1..7148a778 100644 --- a/src/frontend-vue/src/views/admin/apps/AppList.vue +++ b/src/frontend-vue/src/views/admin/apps/AppList.vue @@ -120,9 +120,6 @@ onMounted(() => store.initialize()) - diff --git a/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue new file mode 100644 index 00000000..120f872d --- /dev/null +++ b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue @@ -0,0 +1,362 @@ + + + + + diff --git a/src/frontend-vue/src/views/admin/apps/ApplicationSettingsModal.vue b/src/frontend-vue/src/views/admin/apps/ApplicationSettingsModal.vue deleted file mode 100644 index 4321bcf8..00000000 --- a/src/frontend-vue/src/views/admin/apps/ApplicationSettingsModal.vue +++ /dev/null @@ -1,379 +0,0 @@ - - - - - From 132b63f0424648c6904d2f68e0deaf00343578ac Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 17:22:52 +0200 Subject: [PATCH 2/2] fix(apps): settings override round-trips faithfully (replace, not empty-fill) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings form built `{}` for unchecked sections, the backend stored those as empty-but-present overrides, and on read they came back non-null — so every section showed as "overridden" after a save (caught by the visual smoke test: creating an app with only Native Grants reopened with Branding also ticked). Fix: the unified App create/update is a REPLACE of the complete override state. build() sends `null` for unchecked sections; StageNonOriginAsync clears a null section (and sets a present one). An unchecked section now round-trips back unchecked. PatchAsync stays sparse for the Origin-only follow-on. (Origin remains sparse here — toggling a subdomain off doesn't clear an existing route in this view.) Pre-existing empty-override docs from the old modal self-heal on next save. Verified in the browser: create app with only Native Grants → reopen shows only Native Grants ticked, Origin/Branding/etc. clean; single POST/GET /api/app, no /settings call. 16 settings tests green; frontend type-check clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ApplicationSettingsService.cs | 68 +++++++------------ .../views/admin/apps/AppSettingsSections.vue | 22 +++--- 2 files changed, 38 insertions(+), 52 deletions(-) diff --git a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs index 516fe985..040f8ba5 100644 --- a/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/Applications/ApplicationSettingsService.cs @@ -154,6 +154,11 @@ public async Task> PatchAsync( } // ── Atomic-create staging (every section except Origin, no commit) ──────── + // REPLACE semantics: the DTO is the COMPLETE desired override state (the unified App + // create/update is a replace, not a sparse patch). A provided section sets the override; + // a NULL section CLEARS it (→ inherit the realm). This is what keeps the modal honest — + // an unchecked section round-trips back unchecked instead of as an empty-but-present + // override. (PatchAsync stays sparse for the Origin-only follow-on.) public async Task> StageNonOriginAsync( Guid applicationId, ApplicationSettingsDto dto, CancellationToken ct = default) @@ -162,48 +167,27 @@ public async Task> StageNonOriginAsync( ?? new ApplicationSettings { Id = applicationId, CreatedAt = DateTimeOffset.UtcNow }; if (doc.CreatedAt == default) doc.CreatedAt = DateTimeOffset.UtcNow; - if (dto.SelfRegistration is not null) - { - var r = MapSelfRegistration(dto.SelfRegistration); - if (r.IsError) return r.FirstError; - doc.SelfRegistration = r.Value; - } - if (dto.NativeGrants is not null) - { - var r = MapNativeGrants(dto.NativeGrants); - if (r.IsError) return r.FirstError; - doc.NativeGrants = r.Value; - } - if (dto.Dcr is not null) - { - var r = MapDcr(dto.Dcr); - if (r.IsError) return r.FirstError; - doc.Dcr = r.Value; - } - if (dto.Cimd is not null) - { - var r = MapCimd(dto.Cimd); - if (r.IsError) return r.FirstError; - doc.Cimd = r.Value; - } - if (dto.Branding is not null) - { - var r = MapBranding(dto.Branding); - if (r.IsError) return r.FirstError; - doc.Branding = r.Value; - } - if (dto.EmailBranding is not null) - { - doc.EmailBranding = string.IsNullOrWhiteSpace(dto.EmailBranding.ProductName) - ? null - : new ApplicationEmailBranding { ProductName = dto.EmailBranding.ProductName!.Trim() }; - } - if (dto.RegistrationFields is not null) - { - var r = MapRegistrationFields(dto.RegistrationFields); - if (r.IsError) return r.FirstError; - doc.RegistrationFields = r.Value; - } + if (dto.SelfRegistration is null) doc.SelfRegistration = null; + else { var r = MapSelfRegistration(dto.SelfRegistration); if (r.IsError) return r.FirstError; doc.SelfRegistration = r.Value; } + + if (dto.NativeGrants is null) doc.NativeGrants = null; + else { var r = MapNativeGrants(dto.NativeGrants); if (r.IsError) return r.FirstError; doc.NativeGrants = r.Value; } + + if (dto.Dcr is null) doc.Dcr = null; + else { var r = MapDcr(dto.Dcr); if (r.IsError) return r.FirstError; doc.Dcr = r.Value; } + + if (dto.Cimd is null) doc.Cimd = null; + else { var r = MapCimd(dto.Cimd); if (r.IsError) return r.FirstError; doc.Cimd = r.Value; } + + if (dto.Branding is null) doc.Branding = null; + else { var r = MapBranding(dto.Branding); if (r.IsError) return r.FirstError; doc.Branding = r.Value; } + + doc.EmailBranding = string.IsNullOrWhiteSpace(dto.EmailBranding?.ProductName) + ? null + : new ApplicationEmailBranding { ProductName = dto.EmailBranding.ProductName!.Trim() }; + + if (dto.RegistrationFields is null) doc.RegistrationFields = null; + else { var r = MapRegistrationFields(dto.RegistrationFields); if (r.IsError) return r.FirstError; doc.RegistrationFields = r.Value; } doc.UpdatedAt = DateTimeOffset.UtcNow; session.Store(doc); // enrolled on the shared session; the caller commits. diff --git a/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue index 120f872d..a054a285 100644 --- a/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue +++ b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue @@ -150,17 +150,19 @@ function populate(s?: ApplicationSettingsDto | null) { } } -/** Build the override DTO — an overridden section sends its values; a non-overridden - * section sends {} (clear → inherit the realm), exactly as the old modal did. */ +/** Build the override DTO as the COMPLETE desired state (the App PUT is a replace): + * an overridden section sends its values, a non-overridden section sends `null` so the + * backend clears that override (→ inherit the realm). Origin sends null when off + * (sparse — toggling a subdomain off doesn't clear an existing route in this view). */ function build(): ApplicationSettingsDto { return { - Origin: f.origin.override ? { Subdomain: f.origin.subdomain.trim() || null } : {}, + Origin: f.origin.override ? { Subdomain: f.origin.subdomain.trim() || null } : null, Branding: f.branding.override ? { ProductName: f.branding.productName.trim() || null, PrimaryColor: f.branding.primaryColor.trim() || null } - : {}, + : null, EmailBranding: f.emailBranding.override ? { ProductName: f.emailBranding.productName.trim() || null } - : {}, + : null, SelfRegistration: f.selfReg.override ? { Posture: f.selfReg.posture || null, @@ -172,17 +174,17 @@ function build(): ApplicationSettingsDto { TermsOfServiceUrl: f.selfReg.termsOfServiceUrl.trim() || null, PrivacyPolicyUrl: f.selfReg.privacyPolicyUrl.trim() || null, } - : {}, + : null, RegistrationFields: f.registrationFields.override ? { Username: f.registrationFields.username || null, Firstname: f.registrationFields.firstname || null, Lastname: f.registrationFields.lastname || null, } - : {}, + : null, NativeGrants: f.nativeGrants.override ? { Enabled: f.nativeGrants.enabled, AccessTokenLifetimeMinutes: parseNum(f.nativeGrants.access), RefreshTokenLifetimeDays: parseNum(f.nativeGrants.refresh) } - : {}, + : null, Dcr: f.dcr.override ? { Enabled: f.dcr.enabled, @@ -192,10 +194,10 @@ function build(): ApplicationSettingsDto { PerIpRateLimitPerHour: parseNum(f.dcr.perIp), PerRealmRateLimitPerDay: parseNum(f.dcr.perRealm), } - : {}, + : null, Cimd: f.cimd.override ? { Enabled: f.cimd.enabled, AccessTokenLifetimeMinutes: parseNum(f.cimd.access), RefreshTokenLifetimeDays: parseNum(f.cimd.refresh) } - : {}, + : null, } }