diff --git a/docs/platform/pages.md b/docs/platform/pages.md index f7df3495..bf94f1ec 100644 --- a/docs/platform/pages.md +++ b/docs/platform/pages.md @@ -40,19 +40,36 @@ The page-builder is **headless** — you choose elements from a palette and arra Each slot defines its own list of **available actions**. Login supports credentials, passkey, magic-link, forgot-password, and register; forgot-password supports submit/back; logout supports back-to-login. The runtime only provides handlers for that fixed list. MFA choice, TOTP/email OTP, and secure-setup screens intentionally remain fixed UI: a schema cannot weaken or skip those transitions. +## Variants and activation + +Each slot owns a **library of named variants** plus an **active selection** — three separate concepts (ADR-0001): *a variant exists*, *a variant is live*, and *the built-in fixed view*. This means you can: + +- author several variants for one slot (e.g. two login layouts) and switch which is live, +- **deactivate** a variant (set the slot back to Built-in) without deleting it, and +- reset the editor to the built-in template as a purely local action — nothing is persisted until you Save, and Saving creates/updates a variant, it never deletes. + +**Activation is a settings decision, not an editor action.** In the Pages overview each slot has an *Active for realm* selector (Built-in / a variant). The overview also badges which variant is live so you can see the blast-radius before editing one. Applications set their own *Active for this app* selector (Inherit realm / Built-in / an app variant) in **Settings → Pages**. + +Effective resolution per slot: **app selection → realm selection → built-in**. A slot resolved to Built-in is simply absent from the schema the SPA receives, so the runtime renders the hardcoded view. + ## Storage -Realm schemas live in `RealmSettings.Pages`; Application overrides live in `ApplicationSettings.Pages`. Both are dictionaries keyed by slot. Effective settings overlay Application keys over Realm keys, so an Application can override only `login` and inherit the other slots. +Realm variants + activation live in `RealmSettings.PageSlots`; Application variants + activation live in `ApplicationSettings.PageSlots` (both keyed by slot). Legacy single-schema `Pages` dictionaries are migrated to a single active "Custom" variant on first touch. Endpoints (all admin-gated, all return 404 when the feature flag is off): | Method | Path | Behaviour | | --- | --- | --- | -| `GET` | `/api/admin/customization/pages/{slug}` | Returns `{Slug, Schema}` or `Schema: null` if never saved. | -| `PUT` | `/api/admin/customization/pages/{slug}` | Persists. Body: `{Schema: ""}`. Server validates as JSON (rejects malformed) and caps at 256 KB. | -| `DELETE` | `/api/admin/customization/pages/{slug}` | Clears the slot. Runtime falls back to the hardcoded default view. | +| `GET` | `/api/admin/customization/pages` | Lists every slot with its variants (summaries) + active variant id. | +| `GET` | `/api/admin/customization/pages/{slug}/variants/{id}` | Returns `{Id, Name, Schema}` for one variant. | +| `POST` | `/api/admin/customization/pages/{slug}/variants` | Creates a variant. Body: `{Name, Schema}`. Does **not** activate it. | +| `PUT` | `/api/admin/customization/pages/{slug}/variants/{id}` | Updates a variant's name/schema. | +| `DELETE` | `/api/admin/customization/pages/{slug}/variants/{id}` | Removes a variant; if it was active the slot reverts to Built-in. | +| `PUT` | `/api/admin/customization/pages/{slug}/active` | Sets the live variant. Body: `{ActiveVariantId: "" \| null}` (null = Built-in). | + +Schemas validate as JSON (malformed rejected) and cap at 256 KB; variant names cap at 80 chars; max 50 variants per slot. -Application endpoints use `/api/app/{applicationId}/pages/{slug}` with the same methods. Application `GET` also returns `EffectiveSchema` and `InheritsRealm`; `DELETE` removes the override and resumes Realm inheritance. Regular Application-settings saves do not replace page schemas. +Application endpoints use `/api/app/{applicationId}/pages/...` with the same variant CRUD, plus `PUT /{slug}/active` taking `{Inherit: bool, ActiveVariantId: "" \| null}` — `Inherit: true` defers to the realm; `false` + `null` forces Built-in; `false` + an id activates an app variant. Regular Application-settings saves do not touch page variants. Slug charset: `a-z0-9-`, length 1–32. Anything else is a 400. diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/PageBuilderFeatureFlagTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/PageBuilderFeatureFlagTests.cs index 1edcfa25..403d4f21 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/PageBuilderFeatureFlagTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/PageBuilderFeatureFlagTests.cs @@ -8,29 +8,59 @@ using Marten; using Modgud.Domain.Applications; using Modgud.Domain.OAuth.Applications; +using Modgud.Domain.Realms; +using RealmSettingsDoc = Modgud.Domain.RealmSettings.RealmSettings; namespace Modgud.Api.Tests.Authorization; /// -/// Pins the AppSettings.Features.PageBuilder gate on the -/// customization-pages surface. While the flag is off (default) the -/// endpoints look invisible (404), and the RealmSettings DTO emits an -/// empty Pages dictionary so the SPA never sees stored schemas. +/// Pins the AppSettings.Features.PageBuilder gate and the ADR-0001 +/// variant + activation model on the customization-pages surface. While the +/// flag is off (default) every endpoint is invisible (404). While on, a slot +/// owns a variant library plus an active selection; the effective active +/// schema is what /api/app-info publishes and the runtime renders. /// -/// Mutates the AppSettings singleton in-process. The fixture -/// uses [Collection] so tests run sequentially within the -/// collection — each test restores the flag in its finally block. +/// Mutates the AppSettings singleton in-process. The fixture uses +/// [Collection] so tests run sequentially — each test restores the flag +/// in its finally block. /// [Collection(IntegrationTestCollection.Name)] public class PageBuilderFeatureFlagTests : IntegrationTestBase { public PageBuilderFeatureFlagTests(SharedPostgresFixture fixture) : base(fixture) { } + // ── helpers ── + + /// Create a realm variant for the slot and activate it. Returns the + /// new variant id. + private async Task SeedRealmActive(string slug, string name, string schema, CancellationToken ct) + { + var post = await Client.PostAsJsonAsync($"/api/admin/customization/pages/{slug}/variants", + new { Name = name, Schema = schema }, ct); + Assert.Equal(HttpStatusCode.OK, post.StatusCode); + using var created = JsonDocument.Parse(await post.Content.ReadAsStringAsync(ct)); + var id = created.RootElement.GetProperty("Id").GetString()!; + + var active = await Client.PutAsJsonAsync($"/api/admin/customization/pages/{slug}/active", + new { ActiveVariantId = id }, ct); + Assert.Equal(HttpStatusCode.OK, active.StatusCode); + return id; + } + + private async Task AppInfoActiveSchema(string slug, CancellationToken ct, string? returnUrl = null) + { + var url = returnUrl is null ? "/api/app-info" : $"/api/app-info?returnUrl={returnUrl}"; + using var doc = JsonDocument.Parse(await (await Client.GetAsync(url, ct)).Content.ReadAsStringAsync(ct)); + var pages = doc.RootElement.GetProperty("Pages"); + return pages.TryGetProperty(slug, out var s) ? s.GetString() : null; + } + + // ── feature-flag gating ── + [Fact] - public async Task GetPage_returns_404_when_feature_off() + public async Task GetPageSlot_returns_404_when_feature_off() { - var settings = Factory.Services.GetRequiredService(); - settings.Features.PageBuilder = false; + Factory.Services.GetRequiredService().Features.PageBuilder = false; var resp = await Client.GetAsync("/api/admin/customization/pages/login", TestContext.Current.CancellationToken); @@ -39,20 +69,21 @@ public async Task GetPage_returns_404_when_feature_off() } [Fact] - public async Task PutPage_returns_404_when_feature_off() + public async Task CreateVariant_returns_404_when_feature_off() { - var settings = Factory.Services.GetRequiredService(); - settings.Features.PageBuilder = false; + Factory.Services.GetRequiredService().Features.PageBuilder = false; - var resp = await Client.PutAsJsonAsync("/api/admin/customization/pages/login", - new { Schema = "{\"type\":\"page\",\"children\":[]}" }, + var resp = await Client.PostAsJsonAsync("/api/admin/customization/pages/login/variants", + new { Name = "X", Schema = "{\"type\":\"page\",\"children\":[]}" }, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode); } + // ── variant CRUD + activation ── + [Fact] - public async Task GetPage_works_when_feature_on() + public async Task GetPageSlot_works_when_feature_on() { var settings = Factory.Services.GetRequiredService(); settings.Features.PageBuilder = true; @@ -62,104 +93,144 @@ public async Task GetPage_works_when_feature_on() TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, resp.StatusCode); - var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - using var doc = JsonDocument.Parse(body); + using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); Assert.Equal("login", doc.RootElement.GetProperty("Slug").GetString()); } - finally - { - settings.Features.PageBuilder = false; - } + finally { settings.Features.PageBuilder = false; } } [Fact] - public async Task PutPage_persists_when_feature_on() + public async Task Create_activate_persists_and_surfaces_effective_schema() { var settings = Factory.Services.GetRequiredService(); settings.Features.PageBuilder = true; + var ct = TestContext.Current.CancellationToken; try { var schema = "{\"type\":\"page\",\"children\":[{\"type\":\"heading\",\"level\":1}]}"; - var put = await Client.PutAsJsonAsync("/api/admin/customization/pages/login", - new { Schema = schema }, - TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, put.StatusCode); + var id = await SeedRealmActive("login", "Primary", schema, ct); - var get = await Client.GetAsync("/api/admin/customization/pages/login", - TestContext.Current.CancellationToken); - var body = await get.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - using var doc = JsonDocument.Parse(body); - Assert.Equal(schema, doc.RootElement.GetProperty("Schema").GetString()); - } - finally - { - settings.Features.PageBuilder = false; + // The variant round-trips with its schema. + using (var variant = JsonDocument.Parse(await (await Client.GetAsync( + $"/api/admin/customization/pages/login/variants/{id}", ct)).Content.ReadAsStringAsync(ct))) + { + Assert.Equal(schema, variant.RootElement.GetProperty("Schema").GetString()); + } + + // The slot reports it as active. + using (var slot = JsonDocument.Parse(await (await Client.GetAsync( + "/api/admin/customization/pages/login", ct)).Content.ReadAsStringAsync(ct))) + { + Assert.Equal(id, slot.RootElement.GetProperty("ActiveVariantId").GetString()); + Assert.Single(slot.RootElement.GetProperty("Variants").EnumerateArray()); + } + + Assert.Equal(schema, await AppInfoActiveSchema("login", ct)); } + finally { settings.Features.PageBuilder = false; } } [Fact] - public async Task RealmSettings_DTO_emits_empty_Pages_when_feature_off() + public async Task Variant_without_activation_stays_builtin() { var settings = Factory.Services.GetRequiredService(); - - // Seed a schema while the flag is on, then turn it off and read. settings.Features.PageBuilder = true; - var schema = "{\"type\":\"page\",\"children\":[]}"; - var put = await Client.PutAsJsonAsync("/api/admin/customization/pages/login", - new { Schema = schema }, - TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, put.StatusCode); - - settings.Features.PageBuilder = false; - var resp = await Client.GetAsync("/api/admin/realm-settings", - TestContext.Current.CancellationToken); - var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - using var doc = JsonDocument.Parse(body); - - Assert.True(doc.RootElement.TryGetProperty("Pages", out var pages)); - Assert.Equal(JsonValueKind.Object, pages.ValueKind); - Assert.Empty(pages.EnumerateObject()); + var ct = TestContext.Current.CancellationToken; + try + { + // Creating a variant does NOT activate it — the slot stays built-in + // until explicitly activated (ADR-0001: existence ≠ active). + var post = await Client.PostAsJsonAsync("/api/admin/customization/pages/logout/variants", + new { Name = "Draft", Schema = "{\"type\":\"page\",\"children\":[]}" }, ct); + Assert.Equal(HttpStatusCode.OK, post.StatusCode); - var anonymous = await Client.GetAsync("/api/app-info", TestContext.Current.CancellationToken); - using var appInfo = JsonDocument.Parse(await anonymous.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - Assert.Empty(appInfo.RootElement.GetProperty("Pages").EnumerateObject()); + Assert.Null(await AppInfoActiveSchema("logout", ct)); + } + finally { settings.Features.PageBuilder = false; } } [Fact] - public async Task RealmSettings_DTO_includes_Pages_when_feature_on() + public async Task Deactivating_reverts_to_builtin_without_deleting_the_variant() { var settings = Factory.Services.GetRequiredService(); settings.Features.PageBuilder = true; + var ct = TestContext.Current.CancellationToken; try { var schema = "{\"type\":\"page\",\"children\":[]}"; - var put = await Client.PutAsJsonAsync("/api/admin/customization/pages/login", - new { Schema = schema }, - TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, put.StatusCode); + var id = await SeedRealmActive("password-forgot", "V", schema, ct); + Assert.Equal(schema, await AppInfoActiveSchema("password-forgot", ct)); + + // Deactivate (built-in) — variant stays in the library. + var deactivate = await Client.PutAsJsonAsync("/api/admin/customization/pages/password-forgot/active", + new { ActiveVariantId = (string?)null }, ct); + Assert.Equal(HttpStatusCode.OK, deactivate.StatusCode); + + Assert.Null(await AppInfoActiveSchema("password-forgot", ct)); + using var slot = JsonDocument.Parse(await (await Client.GetAsync( + "/api/admin/customization/pages/password-forgot", ct)).Content.ReadAsStringAsync(ct)); + Assert.Single(slot.RootElement.GetProperty("Variants").EnumerateArray()); // not deleted + Assert.Equal(id, slot.RootElement.GetProperty("Variants")[0].GetProperty("Id").GetString()); + } + finally { settings.Features.PageBuilder = false; } + } - var resp = await Client.GetAsync("/api/admin/realm-settings", - TestContext.Current.CancellationToken); - var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - using var doc = JsonDocument.Parse(body); + [Fact] + public async Task Activating_unknown_variant_is_rejected() + { + var settings = Factory.Services.GetRequiredService(); + settings.Features.PageBuilder = true; + var ct = TestContext.Current.CancellationToken; + try + { + var resp = await Client.PutAsJsonAsync("/api/admin/customization/pages/login/active", + new { ActiveVariantId = "does-not-exist" }, ct); + Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); + } + finally { settings.Features.PageBuilder = false; } + } - Assert.True(doc.RootElement.TryGetProperty("Pages", out var pages)); - Assert.Equal(JsonValueKind.Object, pages.ValueKind); - Assert.True(pages.TryGetProperty("login", out _), - "Pages dictionary must surface stored slug when flag is on"); + // ── legacy migration ── - var anonymous = await Client.GetAsync("/api/app-info", TestContext.Current.CancellationToken); - using var appInfo = JsonDocument.Parse(await anonymous.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - Assert.Equal(schema, appInfo.RootElement.GetProperty("Pages").GetProperty("login").GetString()); - } - finally + [Fact] + public async Task Legacy_single_schema_migrates_to_an_active_variant() + { + var settings = Factory.Services.GetRequiredService(); + settings.Features.PageBuilder = true; + var ct = TestContext.Current.CancellationToken; + try { - settings.Features.PageBuilder = false; + const string legacy = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[{\"id\":\"x\",\"type\":\"heading\",\"props\":{\"text\":\"Legacy\"}}]}"; + using (var scope = Factory.Services.CreateScope()) + { + var session = scope.ServiceProvider.GetRequiredService(); + var existing = await session.LoadAsync(RealmSettingsDoc.SingletonId, ct) + ?? new RealmSettingsDoc { Id = RealmSettingsDoc.SingletonId, CreatedAt = DateTimeOffset.UtcNow }; + existing.Pages = new Dictionary { ["login"] = legacy }; + existing.PageSlots = null; + session.Store(existing); + await session.SaveChangesAsync(ct); + } + + // Listing the slot migrates it: one active "Custom" variant. + using (var slot = JsonDocument.Parse(await (await Client.GetAsync( + "/api/admin/customization/pages/login", ct)).Content.ReadAsStringAsync(ct))) + { + var variants = slot.RootElement.GetProperty("Variants").EnumerateArray().ToArray(); + Assert.Single(variants); + Assert.Equal(slot.RootElement.GetProperty("ActiveVariantId").GetString(), + variants[0].GetProperty("Id").GetString()); + } + + Assert.Equal(legacy, await AppInfoActiveSchema("login", ct)); } + finally { settings.Features.PageBuilder = false; } } + // ── application inheritance / override / deactivate ── + [Fact] - public async Task Application_page_override_inherits_overrides_survives_settings_save_and_can_reset() + public async Task Application_inherits_then_overrides_then_deactivates_and_survives_settings_save() { var settings = Factory.Services.GetRequiredService(); settings.Features.PageBuilder = true; @@ -167,11 +238,9 @@ public async Task Application_page_override_inherits_overrides_survives_settings try { const string realmSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[]}"; - const string appSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[{\"id\":\"title\",\"type\":\"heading\",\"props\":{\"text\":\"App\"}}]}"; + const string appSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[{\"id\":\"t\",\"type\":\"heading\",\"props\":{\"text\":\"App\"}}]}"; - var realmPut = await Client.PutAsJsonAsync("/api/admin/customization/pages/logout", - new { Schema = realmSchema }, ct); - Assert.Equal(HttpStatusCode.OK, realmPut.StatusCode); + await SeedRealmActive("logout", "Realm", realmSchema, ct); var slug = $"pb-{Guid.NewGuid():N}"; var createdResponse = await Client.PostAsJsonAsync("/api/app", @@ -179,20 +248,24 @@ public async Task Application_page_override_inherits_overrides_survives_settings Assert.Equal(HttpStatusCode.OK, createdResponse.StatusCode); using var created = JsonDocument.Parse(await createdResponse.Content.ReadAsStringAsync(ct)); var appId = created.RootElement.GetProperty("Id").GetString(); - Assert.False(string.IsNullOrWhiteSpace(appId)); - var endpoint = $"/api/app/{appId}/pages/logout"; + var basePath = $"/api/app/{appId}/pages"; - using (var inherited = JsonDocument.Parse(await (await Client.GetAsync(endpoint, ct)).Content.ReadAsStringAsync(ct))) + // Fresh app inherits: its slot list is empty. + using (var list = JsonDocument.Parse(await (await Client.GetAsync(basePath, ct)).Content.ReadAsStringAsync(ct))) { - Assert.True(inherited.RootElement.GetProperty("InheritsRealm").GetBoolean()); - Assert.False(inherited.RootElement.TryGetProperty("Schema", out _)); - Assert.Equal(realmSchema, inherited.RootElement.GetProperty("EffectiveSchema").GetString()); + Assert.Empty(list.RootElement.GetProperty("Slots").EnumerateArray()); } - var appPut = await Client.PutAsJsonAsync(endpoint, new { Schema = appSchema }, ct); - Assert.Equal(HttpStatusCode.OK, appPut.StatusCode); + // App authors its own variant and activates it (non-inheriting). + var post = await Client.PostAsJsonAsync($"{basePath}/logout/variants", + new { Name = "App", Schema = appSchema }, ct); + using var appVariant = JsonDocument.Parse(await post.Content.ReadAsStringAsync(ct)); + var appVariantId = appVariant.RootElement.GetProperty("Id").GetString(); + var setActive = await Client.PutAsJsonAsync($"{basePath}/logout/active", + new { Inherit = false, ActiveVariantId = appVariantId }, ct); + Assert.Equal(HttpStatusCode.OK, setActive.StatusCode); - // A regular App settings replace must leave the separately-managed page tree intact. + // A regular App settings replace must leave the page tree intact. var appUpdate = await Client.PutAsJsonAsync($"/api/app/{appId}", new UpdateAppDto("PageBuilder App", null, [], new ApplicationSettingsDto { @@ -200,28 +273,30 @@ public async Task Application_page_override_inherits_overrides_survives_settings }), JsonOptions, ct); Assert.Equal(HttpStatusCode.OK, appUpdate.StatusCode); - using (var overridden = JsonDocument.Parse(await (await Client.GetAsync(endpoint, ct)).Content.ReadAsStringAsync(ct))) + using (var list = JsonDocument.Parse(await (await Client.GetAsync(basePath, ct)).Content.ReadAsStringAsync(ct))) { - Assert.False(overridden.RootElement.GetProperty("InheritsRealm").GetBoolean()); - Assert.Equal(appSchema, overridden.RootElement.GetProperty("Schema").GetString()); - Assert.Equal(appSchema, overridden.RootElement.GetProperty("EffectiveSchema").GetString()); + var slot = list.RootElement.GetProperty("Slots").EnumerateArray().Single(); + Assert.False(slot.GetProperty("InheritActive").GetBoolean()); + Assert.Equal(appVariantId, slot.GetProperty("ActiveVariantId").GetString()); + Assert.Single(slot.GetProperty("Variants").EnumerateArray()); } - var delete = await Client.DeleteAsync(endpoint, ct); - Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode); - - using var reset = JsonDocument.Parse(await (await Client.GetAsync(endpoint, ct)).Content.ReadAsStringAsync(ct)); - Assert.True(reset.RootElement.GetProperty("InheritsRealm").GetBoolean()); - Assert.Equal(realmSchema, reset.RootElement.GetProperty("EffectiveSchema").GetString()); - } - finally - { - settings.Features.PageBuilder = false; + // Back to inherit — app variant is retained, realm selection stands. + var inherit = await Client.PutAsJsonAsync($"{basePath}/logout/active", + new { Inherit = true, ActiveVariantId = (string?)null }, ct); + Assert.Equal(HttpStatusCode.OK, inherit.StatusCode); + using (var list = JsonDocument.Parse(await (await Client.GetAsync(basePath, ct)).Content.ReadAsStringAsync(ct))) + { + var slot = list.RootElement.GetProperty("Slots").EnumerateArray().Single(); + Assert.True(slot.GetProperty("InheritActive").GetBoolean()); + Assert.Single(slot.GetProperty("Variants").EnumerateArray()); // retained + } } + finally { settings.Features.PageBuilder = false; } } [Fact] - public async Task AppInfo_resolves_page_override_from_local_authorize_client_context() + public async Task AppInfo_resolves_app_override_from_local_authorize_client_context() { var settings = Factory.Services.GetRequiredService(); settings.Features.PageBuilder = true; @@ -229,13 +304,12 @@ public async Task AppInfo_resolves_page_override_from_local_authorize_client_con try { const string realmSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[]}"; - const string appSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[{\"id\":\"app\",\"type\":\"paragraph\",\"props\":{\"text\":\"App login\"}}]}"; - var realmPut = await Client.PutAsJsonAsync("/api/admin/customization/pages/login", - new { Schema = realmSchema }, ct); - Assert.Equal(HttpStatusCode.OK, realmPut.StatusCode); + const string appSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[{\"id\":\"a\",\"type\":\"paragraph\",\"props\":{\"text\":\"App login\"}}]}"; + await SeedRealmActive("login", "Realm", realmSchema, ct); var appId = Guid.NewGuid(); var clientId = $"page-client-{Guid.NewGuid():N}"; + var appVariantId = Guid.NewGuid().ToString("N"); using (var scope = Factory.Services.CreateScope()) { var session = scope.ServiceProvider.GetRequiredService(); @@ -243,32 +317,27 @@ public async Task AppInfo_resolves_page_override_from_local_authorize_client_con { Id = appId, CreatedAt = DateTimeOffset.UtcNow, - Pages = new Dictionary { ["login"] = appSchema }, - }); - session.Store(new OAuthApplicationState - { - Id = Guid.NewGuid(), - ClientId = clientId, - AppIds = [appId], + PageSlots = new Dictionary + { + ["login"] = new AppPageSlot + { + InheritActive = false, + Variants = [new PageVariant { Id = appVariantId, Name = "App", Schema = appSchema }], + ActiveVariantId = appVariantId, + }, + }, }); + session.Store(new OAuthApplicationState { Id = Guid.NewGuid(), ClientId = clientId, AppIds = [appId] }); await session.SaveChangesAsync(ct); } var continuation = Uri.EscapeDataString($"/connect/authorize?client_id={clientId}&scope=openid"); - var appInfoResponse = await Client.GetAsync($"/api/app-info?returnUrl={continuation}", ct); - Assert.Equal(HttpStatusCode.OK, appInfoResponse.StatusCode); - using var appInfo = JsonDocument.Parse(await appInfoResponse.Content.ReadAsStringAsync(ct)); - Assert.Equal(appSchema, appInfo.RootElement.GetProperty("Pages").GetProperty("login").GetString()); + Assert.Equal(appSchema, await AppInfoActiveSchema("login", ct, continuation)); - // An absolute URL is not accepted as presentation context. + // An absolute URL is not accepted as presentation context → realm schema. var untrusted = Uri.EscapeDataString($"https://evil.example/connect/authorize?client_id={clientId}"); - using var realmInfo = JsonDocument.Parse(await (await Client.GetAsync($"/api/app-info?returnUrl={untrusted}", ct)) - .Content.ReadAsStringAsync(ct)); - Assert.Equal(realmSchema, realmInfo.RootElement.GetProperty("Pages").GetProperty("login").GetString()); - } - finally - { - settings.Features.PageBuilder = false; + Assert.Equal(realmSchema, await AppInfoActiveSchema("login", ct, untrusted)); } + finally { settings.Features.PageBuilder = false; } } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/CustomizationPagesEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/CustomizationPagesEndpoints.cs index ae9753ae..f9057a61 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/CustomizationPagesEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/CustomizationPagesEndpoints.cs @@ -3,98 +3,196 @@ using Modgud.Authorization.AspNetCore; using Modgud.Authorization.Apps; using Modgud.Domain.Applications; +using Modgud.Domain.Realms; using Modgud.Domain.RealmSettings; using Marten; namespace Modgud.Api.Features.Admin; /// -/// Per-realm page-builder schemas. One slot per SPA page-slug -/// (login, logout, password-forgot, …). The schema -/// is opaque to the backend — a JSON string that the -/// renderer interprets at runtime -/// in the SPA. +/// Per-realm and per-Application PageBuilder configuration (ADR-0001). Each SPA +/// page-slug (login, logout, password-forgot, …) owns a +/// library of named s plus an active selection. The +/// schema of a variant is opaque to the backend — a JSON string the +/// @cocoar/vue-page-builder renderer interprets in the SPA. /// -/// Stored as a dictionary on the tenant-DB RealmSettings -/// singleton (alongside Branding / SelfRegistration / Dcr). No schema -/// migration when new page-slots get added — the dictionary grows -/// implicitly. +/// Stored on the tenant-DB singleton +/// () and per-App +/// . Legacy single-schema data is +/// migrated in-place on first touch via MigratePagesToSlots. /// public static class CustomizationPagesEndpoints { - /// Max-length on the stored schema. JSON page-trees are tiny - /// (a handful of nodes); 256 KB is way more than needed and caps - /// abuse via the admin endpoint. + /// Max-length on a stored schema. JSON page-trees are tiny; 256 KB + /// is far more than needed and caps abuse via the admin endpoint. private const int MaxSchemaBytes = 256 * 1024; + private const int MaxVariantNameLength = 80; + + /// Guard against unbounded variant libraries per slot. + private const int MaxVariantsPerSlot = 50; + public static WebApplication MapCustomizationPagesEndpoints(this WebApplication app, string path) + { + MapRealmPageEndpoints(app, path); + MapApplicationPageEndpoints(app, path); + return app; + } + + // ─────────────────────────── Realm ─────────────────────────── + + private static void MapRealmPageEndpoints(WebApplication app, string path) { var group = app.MapGroup($"{path}/admin/customization/pages") .WithTags("Admin Customization Pages") .RequireAuthorization(); - // GET /{slug} — returns the schema (or empty when never saved). - // Gated by AppSettings.Features.PageBuilder: while off the entire - // surface returns 404 so the SPA + curl-callers see "no such - // endpoint" and the editor stays dark. + // GET / — every slot's variant library + active selection. The schema + // bodies are omitted (list view); fetch a single variant for editing. + group.MapGet("", async ( + AppSettings settings, + IDocumentSession session, + CancellationToken ct) => + { + if (!settings.Features.PageBuilder) return Results.NotFound(); + + var doc = await session.LoadAsync(RealmSettings.SingletonId, ct); + if (doc is not null && doc.MigratePagesToSlots()) + { + session.Store(doc); + await session.SaveChangesAsync(ct); + } + + var slots = (doc?.PageSlots ?? new()) + .Select(kv => new + { + Slug = kv.Key, + kv.Value.ActiveVariantId, + Variants = kv.Value.Variants.Select(SummariseVariant).ToArray(), + }) + .ToArray(); + return Results.Ok(new { Slots = slots }); + }) + .RequiresPermission("realm-settings:read") + .WithName("Admin_Customization_ListPages"); + + // GET /{slug} — one slot's variants + active selection. group.MapGet("{slug}", async ( string slug, AppSettings settings, - IQuerySession session, + IDocumentSession session, CancellationToken ct) => { if (!settings.Features.PageBuilder) return Results.NotFound(); if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); - var doc = await session.LoadAsync(RealmSettings.SingletonId, ct); - var schema = doc?.Pages?.TryGetValue(slug, out var s) == true ? s : null; - return Results.Ok(new { Slug = slug, Schema = schema }); + var (doc, changed) = await LoadRealmMigrated(session, ct); + if (changed) { session.Store(doc!); await session.SaveChangesAsync(ct); } + + var slot = doc?.PageSlots?.GetValueOrDefault(slug); + return Results.Ok(new + { + Slug = slug, + ActiveVariantId = slot?.ActiveVariantId, + Variants = (slot?.Variants ?? new()).Select(SummariseVariant).ToArray(), + }); }) .RequiresPermission("realm-settings:read") - .WithName("Admin_Customization_GetPage"); + .WithName("Admin_Customization_GetPageSlot"); - // PUT /{slug} — replaces the schema for that slug. Body is the - // raw JSON schema as a string (wrapped in { Schema: "..." }). - group.MapPut("{slug}", async ( + // GET /{slug}/variants/{variantId} — a single variant incl. its schema. + group.MapGet("{slug}/variants/{variantId}", async ( string slug, - UpdatePageRequest body, + string variantId, AppSettings settings, IDocumentSession session, CancellationToken ct) => { if (!settings.Features.PageBuilder) return Results.NotFound(); if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); - if (body.Schema is null) return Results.BadRequest(new { Message = "Schema required." }); - if (Encoding.UTF8.GetByteCount(body.Schema) > MaxSchemaBytes) - return Results.BadRequest(new { Message = $"Schema too large (max {MaxSchemaBytes} bytes)." }); - - // Validate it parses as JSON — anything else is a developer - // bug or someone bypassing the editor. We don't try to - // semantically validate the PageNode tree here; the SPA's - // builder + renderer are the schema-shape authority. - try { System.Text.Json.JsonDocument.Parse(body.Schema); } - catch (System.Text.Json.JsonException ex) - { - return Results.BadRequest(new { Message = $"Schema is not valid JSON: {ex.Message}" }); - } + + var (doc, changed) = await LoadRealmMigrated(session, ct); + if (changed) { session.Store(doc!); await session.SaveChangesAsync(ct); } + + var variant = doc?.PageSlots?.GetValueOrDefault(slug)?.Variants + .FirstOrDefault(v => v.Id == variantId); + if (variant is null) return Results.NotFound(); + return Results.Ok(new { variant.Id, variant.Name, variant.Schema }); + }) + .RequiresPermission("realm-settings:read") + .WithName("Admin_Customization_GetPageVariant"); + + // POST /{slug}/variants — create a named variant. Does NOT activate it; + // activation is a separate settings decision (ADR-0001). + group.MapPost("{slug}/variants", async ( + string slug, + SaveVariantRequest body, + AppSettings settings, + IDocumentSession session, + CancellationToken ct) => + { + if (!settings.Features.PageBuilder) return Results.NotFound(); + if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); + if (ValidateVariant(body, out var err) is false) return Results.BadRequest(new { Message = err }); var doc = await session.LoadAsync(RealmSettings.SingletonId, ct) ?? new RealmSettings { Id = RealmSettings.SingletonId, CreatedAt = DateTimeOffset.UtcNow }; - doc.Pages ??= new Dictionary(); - doc.Pages[slug] = body.Schema; + doc.MigratePagesToSlots(); + doc.PageSlots ??= new Dictionary(StringComparer.Ordinal); + var slot = doc.PageSlots.TryGetValue(slug, out var s) ? s : (doc.PageSlots[slug] = new RealmPageSlot()); + if (slot.Variants.Count >= MaxVariantsPerSlot) + return Results.BadRequest(new { Message = $"Too many variants (max {MaxVariantsPerSlot})." }); + + var variant = new PageVariant + { + Id = Guid.NewGuid().ToString("N"), + Name = body.Name!.Trim(), + Schema = body.Schema!, + CreatedAt = DateTimeOffset.UtcNow, + }; + slot.Variants.Add(variant); doc.UpdatedAt = DateTimeOffset.UtcNow; session.Store(doc); await session.SaveChangesAsync(ct); + return Results.Ok(new { variant.Id, variant.Name }); + }) + .RequiresPermission("realm-settings:write") + .WithName("Admin_Customization_CreatePageVariant"); - return Results.Ok(new { Slug = slug, Schema = body.Schema }); + // PUT /{slug}/variants/{variantId} — update a variant's name / schema. + group.MapPut("{slug}/variants/{variantId}", async ( + string slug, + string variantId, + SaveVariantRequest body, + AppSettings settings, + IDocumentSession session, + CancellationToken ct) => + { + if (!settings.Features.PageBuilder) return Results.NotFound(); + if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); + if (ValidateVariant(body, out var err) is false) return Results.BadRequest(new { Message = err }); + + var doc = await session.LoadAsync(RealmSettings.SingletonId, ct); + doc?.MigratePagesToSlots(); + var variant = doc?.PageSlots?.GetValueOrDefault(slug)?.Variants.FirstOrDefault(v => v.Id == variantId); + if (variant is null) return Results.NotFound(); + + variant.Name = body.Name!.Trim(); + variant.Schema = body.Schema!; + variant.UpdatedAt = DateTimeOffset.UtcNow; + doc!.UpdatedAt = DateTimeOffset.UtcNow; + session.Store(doc); + await session.SaveChangesAsync(ct); + return Results.Ok(new { variant.Id, variant.Name }); }) .RequiresPermission("realm-settings:write") - .WithName("Admin_Customization_PutPage"); + .WithName("Admin_Customization_UpdatePageVariant"); - // DELETE /{slug} — drops the slug, SPA reverts to its hardcoded - // view for that page. - group.MapDelete("{slug}", async ( + // DELETE /{slug}/variants/{variantId} — remove a variant. If it was the + // active one, the slot reverts to the built-in view. + group.MapDelete("{slug}/variants/{variantId}", async ( string slug, + string variantId, AppSettings settings, IDocumentSession session, CancellationToken ct) => @@ -103,125 +201,300 @@ public static WebApplication MapCustomizationPagesEndpoints(this WebApplication if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); var doc = await session.LoadAsync(RealmSettings.SingletonId, ct); - if (doc?.Pages?.Remove(slug) == true) + doc?.MigratePagesToSlots(); + var slot = doc?.PageSlots?.GetValueOrDefault(slug); + var removed = slot?.Variants.RemoveAll(v => v.Id == variantId) > 0; + if (removed) { - doc.UpdatedAt = DateTimeOffset.UtcNow; + if (slot!.ActiveVariantId == variantId) slot.ActiveVariantId = null; + doc!.UpdatedAt = DateTimeOffset.UtcNow; session.Store(doc); await session.SaveChangesAsync(ct); } return Results.NoContent(); }) .RequiresPermission("realm-settings:write") - .WithName("Admin_Customization_DeletePage"); + .WithName("Admin_Customization_DeletePageVariant"); + + // PUT /{slug}/active — set which variant is live (null = built-in). + group.MapPut("{slug}/active", async ( + string slug, + SetRealmActiveRequest body, + AppSettings settings, + IDocumentSession session, + CancellationToken ct) => + { + if (!settings.Features.PageBuilder) return Results.NotFound(); + if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); + + var doc = await session.LoadAsync(RealmSettings.SingletonId, ct) + ?? new RealmSettings { Id = RealmSettings.SingletonId, CreatedAt = DateTimeOffset.UtcNow }; + doc.MigratePagesToSlots(); + doc.PageSlots ??= new Dictionary(StringComparer.Ordinal); + var slot = doc.PageSlots.TryGetValue(slug, out var s) ? s : (doc.PageSlots[slug] = new RealmPageSlot()); + + if (body.ActiveVariantId is not null && + slot.Variants.All(v => v.Id != body.ActiveVariantId)) + return Results.BadRequest(new { Message = "No such variant for this slot." }); + + slot.ActiveVariantId = body.ActiveVariantId; + doc.UpdatedAt = DateTimeOffset.UtcNow; + session.Store(doc); + await session.SaveChangesAsync(ct); + return Results.Ok(new { Slug = slug, slot.ActiveVariantId }); + }) + .RequiresPermission("realm-settings:write") + .WithName("Admin_Customization_SetPageActive"); + } + + // ──────────────────────── Application ──────────────────────── - // Per-Application overrides. A missing App slot inherits the Realm slot; - // DELETE therefore restores inheritance rather than forcing the system - // hardcoded page. These routes deliberately use app:read/write because - // the page is part of the App resource, not the Realm settings resource. - var appPages = app.MapGroup($"{path}/app/{{applicationId}}/pages") + private static void MapApplicationPageEndpoints(WebApplication app, string path) + { + // App page config uses app:read/write — the pages are part of the App + // resource, not the Realm settings resource. + var group = app.MapGroup($"{path}/app/{{applicationId}}/pages") .WithTags("Admin Application Customization Pages") .RequireAuthorization(); - appPages.MapGet("{slug}", async ( + group.MapGet("", async ( ShortGuid applicationId, - string slug, AppSettings settings, - IQuerySession session, + IDocumentSession session, CancellationToken ct) => { if (!settings.Features.PageBuilder) return Results.NotFound(); - if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); - var owningApp = await session.LoadAsync(applicationId.Guid, ct); if (owningApp is null || owningApp.IsDeleted) return Results.NotFound(); var doc = await session.LoadAsync(applicationId.Guid, ct); - var schema = doc?.Pages?.TryGetValue(slug, out var value) == true ? value : null; - var realm = await session.LoadAsync(RealmSettings.SingletonId, ct); - var inherited = realm?.Pages?.TryGetValue(slug, out var realmValue) == true ? realmValue : null; - return Results.Ok(new + if (doc is not null && doc.MigratePagesToSlots()) { - Slug = slug, - Schema = schema, - EffectiveSchema = schema ?? inherited, - InheritsRealm = schema is null, - }); + session.Store(doc); + await session.SaveChangesAsync(ct); + } + + var slots = (doc?.PageSlots ?? new()) + .Select(kv => new + { + Slug = kv.Key, + kv.Value.InheritActive, + kv.Value.ActiveVariantId, + Variants = kv.Value.Variants.Select(SummariseVariant).ToArray(), + }) + .ToArray(); + return Results.Ok(new { Slots = slots }); }) .RequiresPermission("app:read") - .WithName("Admin_ApplicationCustomization_GetPage"); + .WithName("Admin_ApplicationCustomization_ListPages"); - appPages.MapPut("{slug}", async ( + group.MapGet("{slug}/variants/{variantId}", async ( ShortGuid applicationId, string slug, - UpdatePageRequest body, + string variantId, AppSettings settings, IDocumentSession session, CancellationToken ct) => { if (!settings.Features.PageBuilder) return Results.NotFound(); if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); - if (body.Schema is null) return Results.BadRequest(new { Message = "Schema required." }); - if (Encoding.UTF8.GetByteCount(body.Schema) > MaxSchemaBytes) - return Results.BadRequest(new { Message = $"Schema too large (max {MaxSchemaBytes} bytes)." }); + var owningApp = await session.LoadAsync(applicationId.Guid, ct); + if (owningApp is null || owningApp.IsDeleted) return Results.NotFound(); - try { System.Text.Json.JsonDocument.Parse(body.Schema); } - catch (System.Text.Json.JsonException ex) - { - return Results.BadRequest(new { Message = $"Schema is not valid JSON: {ex.Message}" }); - } + var doc = await session.LoadAsync(applicationId.Guid, ct); + doc?.MigratePagesToSlots(); + var variant = doc?.PageSlots?.GetValueOrDefault(slug)?.Variants.FirstOrDefault(v => v.Id == variantId); + if (variant is null) return Results.NotFound(); + return Results.Ok(new { variant.Id, variant.Name, variant.Schema }); + }) + .RequiresPermission("app:read") + .WithName("Admin_ApplicationCustomization_GetPageVariant"); + group.MapPost("{slug}/variants", async ( + ShortGuid applicationId, + string slug, + SaveVariantRequest body, + AppSettings settings, + IDocumentSession session, + CancellationToken ct) => + { + if (!settings.Features.PageBuilder) return Results.NotFound(); + if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); + if (ValidateVariant(body, out var err) is false) return Results.BadRequest(new { Message = err }); var owningApp = await session.LoadAsync(applicationId.Guid, ct); if (owningApp is null || owningApp.IsDeleted) return Results.NotFound(); var doc = await session.LoadAsync(applicationId.Guid, ct) - ?? new ApplicationSettings - { - Id = applicationId.Guid, - CreatedAt = DateTimeOffset.UtcNow, - }; - doc.Pages ??= new Dictionary(StringComparer.Ordinal); - doc.Pages[slug] = body.Schema; + ?? new ApplicationSettings { Id = applicationId.Guid, CreatedAt = DateTimeOffset.UtcNow }; + doc.MigratePagesToSlots(); + doc.PageSlots ??= new Dictionary(StringComparer.Ordinal); + var slot = doc.PageSlots.TryGetValue(slug, out var s) ? s : (doc.PageSlots[slug] = new AppPageSlot()); + if (slot.Variants.Count >= MaxVariantsPerSlot) + return Results.BadRequest(new { Message = $"Too many variants (max {MaxVariantsPerSlot})." }); + + var variant = new PageVariant + { + Id = Guid.NewGuid().ToString("N"), + Name = body.Name!.Trim(), + Schema = body.Schema!, + CreatedAt = DateTimeOffset.UtcNow, + }; + slot.Variants.Add(variant); doc.UpdatedAt = DateTimeOffset.UtcNow; session.Store(doc); await session.SaveChangesAsync(ct); - - return Results.Ok(new { Slug = slug, Schema = body.Schema, InheritsRealm = false }); + return Results.Ok(new { variant.Id, variant.Name }); }) .RequiresPermission("app:write") - .WithName("Admin_ApplicationCustomization_PutPage"); + .WithName("Admin_ApplicationCustomization_CreatePageVariant"); - appPages.MapDelete("{slug}", async ( + group.MapPut("{slug}/variants/{variantId}", async ( ShortGuid applicationId, string slug, + string variantId, + SaveVariantRequest body, AppSettings settings, IDocumentSession session, CancellationToken ct) => { if (!settings.Features.PageBuilder) return Results.NotFound(); if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); + if (ValidateVariant(body, out var err) is false) return Results.BadRequest(new { Message = err }); + var owningApp = await session.LoadAsync(applicationId.Guid, ct); + if (owningApp is null || owningApp.IsDeleted) return Results.NotFound(); + var doc = await session.LoadAsync(applicationId.Guid, ct); + doc?.MigratePagesToSlots(); + var variant = doc?.PageSlots?.GetValueOrDefault(slug)?.Variants.FirstOrDefault(v => v.Id == variantId); + if (variant is null) return Results.NotFound(); + + variant.Name = body.Name!.Trim(); + variant.Schema = body.Schema!; + variant.UpdatedAt = DateTimeOffset.UtcNow; + doc!.UpdatedAt = DateTimeOffset.UtcNow; + session.Store(doc); + await session.SaveChangesAsync(ct); + return Results.Ok(new { variant.Id, variant.Name }); + }) + .RequiresPermission("app:write") + .WithName("Admin_ApplicationCustomization_UpdatePageVariant"); + + group.MapDelete("{slug}/variants/{variantId}", async ( + ShortGuid applicationId, + string slug, + string variantId, + AppSettings settings, + IDocumentSession session, + CancellationToken ct) => + { + if (!settings.Features.PageBuilder) return Results.NotFound(); + if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); var owningApp = await session.LoadAsync(applicationId.Guid, ct); if (owningApp is null || owningApp.IsDeleted) return Results.NotFound(); var doc = await session.LoadAsync(applicationId.Guid, ct); - if (doc?.Pages?.Remove(slug) == true) + doc?.MigratePagesToSlots(); + var slot = doc?.PageSlots?.GetValueOrDefault(slug); + var removed = slot?.Variants.RemoveAll(v => v.Id == variantId) > 0; + if (removed) { - if (doc.Pages.Count == 0) doc.Pages = null; - doc.UpdatedAt = DateTimeOffset.UtcNow; + if (slot!.ActiveVariantId == variantId) slot.ActiveVariantId = null; + doc!.UpdatedAt = DateTimeOffset.UtcNow; session.Store(doc); await session.SaveChangesAsync(ct); } - return Results.NoContent(); }) .RequiresPermission("app:write") - .WithName("Admin_ApplicationCustomization_DeletePage"); + .WithName("Admin_ApplicationCustomization_DeletePageVariant"); - return app; + // PUT /{slug}/active — inherit the realm, or override (built-in / app variant). + group.MapPut("{slug}/active", async ( + ShortGuid applicationId, + string slug, + SetAppActiveRequest body, + AppSettings settings, + IDocumentSession session, + CancellationToken ct) => + { + if (!settings.Features.PageBuilder) return Results.NotFound(); + if (!IsValidSlug(slug)) return Results.BadRequest(new { Message = "Invalid slug." }); + var owningApp = await session.LoadAsync(applicationId.Guid, ct); + if (owningApp is null || owningApp.IsDeleted) return Results.NotFound(); + + var doc = await session.LoadAsync(applicationId.Guid, ct) + ?? new ApplicationSettings { Id = applicationId.Guid, CreatedAt = DateTimeOffset.UtcNow }; + doc.MigratePagesToSlots(); + doc.PageSlots ??= new Dictionary(StringComparer.Ordinal); + var slot = doc.PageSlots.TryGetValue(slug, out var s) ? s : (doc.PageSlots[slug] = new AppPageSlot()); + + if (!body.Inherit && body.ActiveVariantId is not null && + slot.Variants.All(v => v.Id != body.ActiveVariantId)) + return Results.BadRequest(new { Message = "No such variant for this slot." }); + + slot.InheritActive = body.Inherit; + slot.ActiveVariantId = body.Inherit ? null : body.ActiveVariantId; + doc.UpdatedAt = DateTimeOffset.UtcNow; + session.Store(doc); + await session.SaveChangesAsync(ct); + return Results.Ok(new { Slug = slug, slot.InheritActive, slot.ActiveVariantId }); + }) + .RequiresPermission("app:write") + .WithName("Admin_ApplicationCustomization_SetPageActive"); + } + + // ──────────────────────────── helpers ──────────────────────────── + + private static async Task<(RealmSettings? doc, bool changed)> LoadRealmMigrated( + IDocumentSession session, CancellationToken ct) + { + var doc = await session.LoadAsync(RealmSettings.SingletonId, ct); + var changed = doc?.MigratePagesToSlots() ?? false; + return (doc, changed); + } + + private static object SummariseVariant(PageVariant v) => new + { + v.Id, + v.Name, + v.CreatedAt, + v.UpdatedAt, + }; + + private static bool ValidateVariant(SaveVariantRequest body, out string error) + { + if (string.IsNullOrWhiteSpace(body.Name)) + { + error = "Name required."; + return false; + } + if (body.Name.Trim().Length > MaxVariantNameLength) + { + error = $"Name too long (max {MaxVariantNameLength})."; + return false; + } + if (body.Schema is null) + { + error = "Schema required."; + return false; + } + if (Encoding.UTF8.GetByteCount(body.Schema) > MaxSchemaBytes) + { + error = $"Schema too large (max {MaxSchemaBytes} bytes)."; + return false; + } + try { System.Text.Json.JsonDocument.Parse(body.Schema); } + catch (System.Text.Json.JsonException ex) + { + error = $"Schema is not valid JSON: {ex.Message}"; + return false; + } + error = string.Empty; + return true; } - /// Allow lowercase ASCII + hyphens, length 1-32. Keeps URL- - /// pretty and rejects anything that could route-injection. + /// Allow lowercase ASCII + hyphens, length 1-32. Keeps URL-pretty + /// and rejects anything that could route-inject. private static bool IsValidSlug(string slug) { if (string.IsNullOrEmpty(slug) || slug.Length > 32) return false; @@ -232,5 +505,7 @@ private static bool IsValidSlug(string slug) return true; } - public record UpdatePageRequest(string? Schema); + public record SaveVariantRequest(string? Name, string? Schema); + public record SetRealmActiveRequest(string? ActiveVariantId); + public record SetAppActiveRequest(bool Inherit, string? ActiveVariantId); } diff --git a/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs b/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs index b6e35270..eb8546b1 100644 --- a/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs +++ b/src/dotnet/Modgud.Application/DTOs/RealmSettings/RealmSettingsDtos.cs @@ -22,11 +22,9 @@ public record RealmSettingsDto public DeletionSettingsDto Deletion { get; init; } = new(); public AuditSettingsDto Audit { get; init; } = new(); - /// Page-builder schemas keyed by slug. Read-only via the bulk - /// GET; writes go through the dedicated /api/admin/customization/pages/{slug} - /// endpoints. Empty dict = no slot customised yet. - public IReadOnlyDictionary Pages { get; init; } - = new Dictionary(); + // PageBuilder page config (variants + active selection) is served by the + // dedicated /api/admin/customization/pages endpoints (ADR-0001), not this + // bulk settings DTO — the schemas are large and independently managed. } /// Patch payload for PATCH /api/admin/realm-settings. diff --git a/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs index 69c2933d..effb6a96 100644 --- a/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/Admin/RealmSettingsEndpoints.cs @@ -33,16 +33,10 @@ public static WebApplication MapRealmSettingsEndpoints(this WebApplication app, IFeatureFlags features, CancellationToken ct) => { + // PageBuilder config is no longer part of this DTO (ADR-0001) — it + // is served by the /api/admin/customization/pages endpoints, which + // already 404 while the feature flag is off, so nothing to mask here. var dto = await svc.GetDtoAsync(ct); - // Mask Pages when the page-builder feature is off so the SPA - // (and any direct curl-caller) can't fingerprint stored - // schemas — the editor is dark, the data is invisible. The - // schemas themselves persist in the tenant DB and resurface - // when the flag is flipped back on. - if (!features.PageBuilder) - { - dto = dto with { Pages = new Dictionary() }; - } return Results.Ok(dto); }) .WithName("RealmSettings_Get") diff --git a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs index b77609b7..e30ccb22 100644 --- a/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs +++ b/src/dotnet/Modgud.Authentication/RealmSettings/RealmSettingsService.cs @@ -158,9 +158,6 @@ private SelfRegistrationSettings ApplySelfRegistrationPatch( RegistrationFields = MapRegistrationFieldsToDto(doc.RegistrationFields), Deletion = MapDeletionToDto(doc.Deletion), Audit = MapAuditToDto(doc.Audit), - Pages = doc.Pages is null - ? new Dictionary() - : new Dictionary(doc.Pages), }; private static ErrorOr ApplyBrandingPatch( diff --git a/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs b/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs index e2cc0e30..d4f971c4 100644 --- a/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs +++ b/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs @@ -73,15 +73,50 @@ public class ApplicationSettings /// while an Enterprise App in the same tenant requires given/family name. public ApplicationRegistrationFieldsOverrides? RegistrationFields { get; set; } - /// - /// Per-page PageBuilder overrides keyed by SPA page slot (login, - /// password-forgot, …). Missing keys inherit the realm schema for - /// that slot; deleting an entry therefore restores inheritance without - /// touching the realm default. Values are opaque serialized PageNode JSON. - /// Managed through the dedicated Application page endpoints rather than - /// the ordinary App settings form so an App update cannot erase pages. - /// + /// LEGACY (pre-ADR-0001): single per-Application PageBuilder + /// schema per slot. Retained only for to + /// convert on load; cleared on the next save. New reads/writes use + /// . public Dictionary? Pages { get; set; } + + /// Per-Application PageBuilder configuration keyed by SPA page slot + /// (ADR-0001). Each entry is the Application's own variant library plus an + /// inherit switch + active selection. A missing slot, or one with + /// true, inherits the realm's active + /// selection for that slot. Managed through the dedicated Application page + /// endpoints so an ordinary App settings update cannot erase pages. + public Dictionary? PageSlots { get; set; } + + /// Lazily migrate the legacy single-schema + /// dictionary into (ADR-0001). Each legacy + /// Pages[slug] = schema becomes one active Application variant named + /// "Custom" with false (the legacy + /// entry was an explicit App override). Returns true when it changed + /// the document. + public bool MigratePagesToSlots() + { + if (Pages is null || Pages.Count == 0) + { + if (Pages is not null) { Pages = null; return true; } + return false; + } + + PageSlots ??= new Dictionary(StringComparer.Ordinal); + foreach (var (slug, schema) in Pages) + { + if (string.IsNullOrWhiteSpace(schema)) continue; + if (PageSlots.ContainsKey(slug)) continue; + var id = Guid.NewGuid().ToString("N"); + PageSlots[slug] = new AppPageSlot + { + Variants = [new PageVariant { Id = id, Name = "Custom", Schema = schema, CreatedAt = CreatedAt }], + InheritActive = false, + ActiveVariantId = id, + }; + } + Pages = null; + return true; + } } /// An Application's own origin. Phase-1 resolution maps a host to an diff --git a/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs b/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs index a8f111f2..4d9eff77 100644 --- a/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs +++ b/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs @@ -54,7 +54,7 @@ public sealed record EffectiveSettings RegistrationFields = realm.RegistrationFields, Deletion = realm.Deletion, Audit = realm.Audit, - Pages = realm.Pages, + Pages = ResolveRealmActivePages(realm), SelfRegPosture = null, Origin = null, EmailBranding = null, @@ -78,8 +78,10 @@ public sealed record EffectiveSettings Deletion = realm.Deletion, Audit = realm.Audit, - // Page schemas overlay per slot; an absent App key inherits the Realm slot. - Pages = MergePages(realm.Pages, app.Pages), + // Effective active page schema per slot: App selection (variant / + // built-in) overrides the Realm selection when the App does not inherit + // that slot; otherwise the Realm's active selection stands (ADR-0001). + Pages = ResolveEffectivePages(realm, app), // New per-App facets: SelfRegPosture = app.SelfRegistration?.Posture ?? Applications.SelfRegPosture.JitOnOtp, @@ -180,16 +182,71 @@ public sealed record EffectiveSettings }; } - private static Dictionary? MergePages( - Dictionary? realm, - Dictionary? app) + /// The realm's active schema per slot: the schema of each slot's + /// active variant. Slots with no active variant (built-in) are omitted, so + /// the SPA falls back to its hardcoded view. Legacy + /// entries (pre-ADR-0001, not yet migrated) are honoured for any slug the + /// new PageSlots does not cover. + private static Dictionary? ResolveRealmActivePages(RealmSettingsDoc realm) { - if (app is null || app.Count == 0) return realm; + var result = new Dictionary(StringComparer.Ordinal); + if (realm.PageSlots is not null) + { + foreach (var (slug, slot) in realm.PageSlots) + { + var schema = ActiveSchema(slot.Variants, slot.ActiveVariantId); + if (schema is not null) result[slug] = schema; + } + } + if (realm.Pages is not null) + { + foreach (var (slug, schema) in realm.Pages) + { + if (string.IsNullOrWhiteSpace(schema)) continue; + if (realm.PageSlots?.ContainsKey(slug) == true) continue; + result.TryAdd(slug, schema); + } + } + return result.Count == 0 ? null : result; + } + + /// Effective active schema per slot with an Application in context: + /// start from the realm's active pages, then apply the App's non-inherited + /// slot selections (a variant, or built-in which removes the slot). + private static Dictionary? ResolveEffectivePages( + RealmSettingsDoc realm, + ApplicationSettings app) + { + var result = ResolveRealmActivePages(realm) is { } r + ? new Dictionary(r, StringComparer.Ordinal) + : new Dictionary(StringComparer.Ordinal); - var merged = realm is null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(realm, StringComparer.Ordinal); - foreach (var (slug, schema) in app) merged[slug] = schema; - return merged; + if (app.PageSlots is not null) + { + foreach (var (slug, slot) in app.PageSlots) + { + if (slot.InheritActive) continue; // realm selection stands + var schema = ActiveSchema(slot.Variants, slot.ActiveVariantId); + if (schema is not null) result[slug] = schema; + else result.Remove(slug); // explicit built-in override + } + } + if (app.Pages is not null) + { + foreach (var (slug, schema) in app.Pages) + { + if (string.IsNullOrWhiteSpace(schema)) continue; + if (app.PageSlots?.ContainsKey(slug) == true) continue; + result[slug] = schema; // legacy App override + } + } + return result.Count == 0 ? null : result; + } + + private static string? ActiveSchema(List variants, string? activeId) + { + if (activeId is null) return null; // built-in + var v = variants.FirstOrDefault(x => x.Id == activeId); + return string.IsNullOrWhiteSpace(v?.Schema) ? null : v!.Schema; } } diff --git a/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs b/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs index d78aa106..c7c3a792 100644 --- a/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs +++ b/src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs @@ -94,11 +94,47 @@ public class RealmSettings /// erase). public AuditSettings? Audit { get; set; } - /// Page-builder schemas keyed by SPA-page-slug - /// (login, logout, password-forgot, …). Each - /// value is the serialised PageNode tree as JSON. Missing key - /// or empty value = render the SPA's hardcoded view for that page. - /// Dictionary keeps the slug-set extensible without a schema change - /// when more page-slots get adopted. + /// LEGACY (pre-ADR-0001): single page-builder schema per + /// SPA-page-slug. Retained only so can + /// convert existing data into on load; cleared on + /// the next save. New reads/writes use . public Dictionary? Pages { get; set; } + + /// Page-builder configuration keyed by SPA-page-slug + /// (login, logout, password-forgot, …). Each entry is + /// a library of named variants plus which one is active (ADR-0001). A + /// missing slot, or a slot whose + /// is null, renders the SPA's built-in hardcoded view. + public Dictionary? PageSlots { get; set; } + + /// Lazily migrate the legacy single-schema + /// dictionary into (ADR-0001). Idempotent and + /// side-effect-only-when-needed: does nothing once migrated. Each legacy + /// Pages[slug] = schema becomes one active variant named "Custom". + /// Returns true when it changed the document (caller should persist). + public bool MigratePagesToSlots() + { + if (Pages is null || Pages.Count == 0) + { + // Nothing legacy to migrate; drop the empty dictionary so it + // doesn't linger in storage. + if (Pages is not null) { Pages = null; return true; } + return false; + } + + PageSlots ??= new Dictionary(StringComparer.Ordinal); + foreach (var (slug, schema) in Pages) + { + if (string.IsNullOrWhiteSpace(schema)) continue; + if (PageSlots.ContainsKey(slug)) continue; // never clobber new data + var id = Guid.NewGuid().ToString("N"); + PageSlots[slug] = new RealmPageSlot + { + Variants = [new PageVariant { Id = id, Name = "Custom", Schema = schema, CreatedAt = CreatedAt }], + ActiveVariantId = id, + }; + } + Pages = null; + return true; + } } diff --git a/src/dotnet/Modgud.Domain/Realms/PageVariants.cs b/src/dotnet/Modgud.Domain/Realms/PageVariants.cs new file mode 100644 index 00000000..50c97b94 --- /dev/null +++ b/src/dotnet/Modgud.Domain/Realms/PageVariants.cs @@ -0,0 +1,62 @@ +namespace Modgud.Domain.Realms; + +/// +/// A single named PageBuilder variant for a page slot (ADR-0001). The +/// is opaque JSON — the SPA's @cocoar/vue-page-builder +/// renderer is the schema-shape authority; the backend only stores + serves it. +/// +public class PageVariant +{ + /// Stable id (a GUID string) assigned on create. Referenced by + /// the slot's active-selection pointer and by any Application that + /// activates a realm variant. + public string Id { get; set; } = default!; + + /// Operator-facing name, e.g. "Split screen", "Minimal". + public string Name { get; set; } = default!; + + /// Serialized PageNode tree as JSON. + public string Schema { get; set; } = default!; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } +} + +/// +/// Realm-level configuration for one page slot: a library of named variants +/// plus which one is active (ADR-0001). is +/// null ⇒ the slot renders the SPA's built-in hardcoded view +/// (i.e. "deactivated" — variants may still exist, unused). +/// +public class RealmPageSlot +{ + public List Variants { get; set; } = new(); + + /// Id of the active variant, or null for the built-in + /// hardcoded view. An id that no longer matches a variant also resolves + /// to built-in (defensive). + public string? ActiveVariantId { get; set; } +} + +/// +/// Application-level configuration for one page slot (ADR-0001). Mirrors +/// but adds an inherit switch: when +/// is true (default) the effective active +/// page is resolved from the realm; when false the Application overrides +/// it — null ⇒ built-in, else an +/// Application variant. +/// +public class AppPageSlot +{ + public List Variants { get; set; } = new(); + + /// When true the Application defers to the realm's active + /// selection for this slot. When false the Application's own + /// selection () wins. + public bool InheritActive { get; set; } = true; + + /// Only meaningful when is + /// false. null ⇒ built-in hardcoded view; else an + /// Application variant id. + public string? ActiveVariantId { get; set; } +} diff --git a/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs b/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs index c0fdce27..27ad7da2 100644 --- a/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs @@ -303,5 +303,144 @@ public void Origin_and_email_branding_pass_through_from_application() Assert.Equal("amzettel.cocoar.app", eff.Origin!.Subdomain); Assert.Equal("amZettel", eff.EmailBranding!.ProductName); } + + // ─────────────── ADR-0001: variants + activation ─────────────── + + private static RealmSettingsDoc RealmWithSlot(string slug, RealmPageSlot slot) + => new() { PageSlots = new Dictionary { [slug] = slot } }; + + [Fact] + public void Realm_active_variant_resolves_to_its_schema() + { + var realm = RealmWithSlot("login", new RealmPageSlot + { + Variants = + [ + new PageVariant { Id = "a", Name = "A", Schema = "schema-a" }, + new PageVariant { Id = "b", Name = "B", Schema = "schema-b" }, + ], + ActiveVariantId = "b", + }); + + var eff = EffectiveSettings.From(realm); + + Assert.Equal("schema-b", eff.Pages!["login"]); + } + + [Fact] + public void Realm_builtin_when_no_active_variant_omits_the_slot() + { + // Variants exist but none is active → the slot falls back to the + // built-in hardcoded view (absent from the effective Pages). + var realm = RealmWithSlot("login", new RealmPageSlot + { + Variants = [new PageVariant { Id = "a", Name = "A", Schema = "schema-a" }], + ActiveVariantId = null, + }); + + var eff = EffectiveSettings.From(realm); + + Assert.True(eff.Pages is null || !eff.Pages.ContainsKey("login")); + } + + [Fact] + public void App_inherits_realm_active_by_default() + { + var realm = RealmWithSlot("login", new RealmPageSlot + { + Variants = [new PageVariant { Id = "r", Name = "R", Schema = "realm-login" }], + ActiveVariantId = "r", + }); + var app = new ApplicationSettings(); // no PageSlots → inherit + + var eff = EffectiveSettings.Merge(realm, app); + + Assert.Equal("realm-login", eff.Pages!["login"]); + } + + [Fact] + public void App_override_variant_wins_over_realm() + { + var realm = RealmWithSlot("login", new RealmPageSlot + { + Variants = [new PageVariant { Id = "r", Name = "R", Schema = "realm-login" }], + ActiveVariantId = "r", + }); + var app = new ApplicationSettings + { + PageSlots = new Dictionary + { + ["login"] = new AppPageSlot + { + InheritActive = false, + Variants = [new PageVariant { Id = "x", Name = "X", Schema = "app-login" }], + ActiveVariantId = "x", + }, + }, + }; + + var eff = EffectiveSettings.Merge(realm, app); + + Assert.Equal("app-login", eff.Pages!["login"]); + } + + [Fact] + public void App_override_builtin_removes_the_inherited_slot() + { + var realm = RealmWithSlot("login", new RealmPageSlot + { + Variants = [new PageVariant { Id = "r", Name = "R", Schema = "realm-login" }], + ActiveVariantId = "r", + }); + var app = new ApplicationSettings + { + PageSlots = new Dictionary + { + // Override to built-in: not inheriting, no active variant. + ["login"] = new AppPageSlot { InheritActive = false, ActiveVariantId = null }, + }, + }; + + var eff = EffectiveSettings.Merge(realm, app); + + Assert.True(eff.Pages is null || !eff.Pages.ContainsKey("login")); + } + + [Fact] + public void Realm_migration_converts_legacy_pages_to_active_variant() + { + var realm = new RealmSettingsDoc + { + Pages = new Dictionary { ["login"] = "legacy-login" }, + }; + + var changed = realm.MigratePagesToSlots(); + + Assert.True(changed); + Assert.Null(realm.Pages); + var slot = realm.PageSlots!["login"]; + Assert.Single(slot.Variants); + Assert.Equal("legacy-login", slot.Variants[0].Schema); + Assert.Equal(slot.Variants[0].Id, slot.ActiveVariantId); // migrated as active + Assert.Equal("legacy-login", EffectiveSettings.From(realm).Pages!["login"]); + } + + [Fact] + public void App_migration_marks_legacy_override_as_non_inheriting_active() + { + var app = new ApplicationSettings + { + Pages = new Dictionary { ["login"] = "legacy-app-login" }, + }; + + var changed = app.MigratePagesToSlots(); + + Assert.True(changed); + Assert.Null(app.Pages); + var slot = app.PageSlots!["login"]; + Assert.False(slot.InheritActive); + Assert.Equal(slot.Variants[0].Id, slot.ActiveVariantId); + Assert.Equal("legacy-app-login", EffectiveSettings.Merge(new RealmSettingsDoc(), app).Pages!["login"]); + } } } diff --git a/src/frontend-vue/src/composables/usePagesApi.ts b/src/frontend-vue/src/composables/usePagesApi.ts new file mode 100644 index 00000000..fde8aea4 --- /dev/null +++ b/src/frontend-vue/src/composables/usePagesApi.ts @@ -0,0 +1,88 @@ +// PageBuilder variant + activation API (ADR-0001). Thin fetch wrappers over the +// realm (`/api/admin/customization/pages`) and application +// (`/api/app/{appId}/pages`) endpoints. Realm and app share the variant CRUD +// shape; only the active-selection payload differs (the app can inherit). + +export interface PageVariantSummary { + Id: string + Name: string + CreatedAt: string + UpdatedAt: string | null +} + +export interface PageVariantFull { + Id: string + Name: string + Schema: string +} + +export interface RealmSlotDto { + Slug: string + ActiveVariantId: string | null + Variants: PageVariantSummary[] +} + +export interface AppSlotDto extends RealmSlotDto { + InheritActive: boolean +} + +export interface VariantPayload { Name: string; Schema: string } + +async function ok(res: Response): Promise { + if (!res.ok) { + const body = await res.json().catch(() => null) as { Message?: string } | null + throw new Error(body?.Message ?? `HTTP ${res.status}`) + } + return res.json() as Promise +} + +async function okEmpty(res: Response): Promise { + if (!res.ok) throw new Error(`HTTP ${res.status}`) +} + +const acceptJson: RequestInit = { headers: { Accept: 'application/json' } } + +function jsonInit(method: string, body: unknown): RequestInit { + return { + method, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(body), + } +} + +const enc = encodeURIComponent + +export function useRealmPagesApi() { + const base = '/api/admin/customization/pages' + return { + listSlots: async () => ok<{ Slots: RealmSlotDto[] }>(await fetch(base, acceptJson)), + getSlot: async (slug: string) => ok(await fetch(`${base}/${enc(slug)}`, acceptJson)), + getVariant: async (slug: string, id: string) => + ok(await fetch(`${base}/${enc(slug)}/variants/${enc(id)}`, acceptJson)), + createVariant: async (slug: string, body: VariantPayload) => + ok<{ Id: string; Name: string }>(await fetch(`${base}/${enc(slug)}/variants`, jsonInit('POST', body))), + updateVariant: async (slug: string, id: string, body: VariantPayload) => + ok<{ Id: string; Name: string }>(await fetch(`${base}/${enc(slug)}/variants/${enc(id)}`, jsonInit('PUT', body))), + deleteVariant: async (slug: string, id: string) => + okEmpty(await fetch(`${base}/${enc(slug)}/variants/${enc(id)}`, { method: 'DELETE', ...acceptJson })), + setActive: async (slug: string, activeVariantId: string | null) => + okEmpty(await fetch(`${base}/${enc(slug)}/active`, jsonInit('PUT', { ActiveVariantId: activeVariantId }))), + } +} + +export function useAppPagesApi(applicationId: string) { + const base = `/api/app/${enc(applicationId)}/pages` + return { + listSlots: async () => ok<{ Slots: AppSlotDto[] }>(await fetch(base, acceptJson)), + getVariant: async (slug: string, id: string) => + ok(await fetch(`${base}/${enc(slug)}/variants/${enc(id)}`, acceptJson)), + createVariant: async (slug: string, body: VariantPayload) => + ok<{ Id: string; Name: string }>(await fetch(`${base}/${enc(slug)}/variants`, jsonInit('POST', body))), + updateVariant: async (slug: string, id: string, body: VariantPayload) => + ok<{ Id: string; Name: string }>(await fetch(`${base}/${enc(slug)}/variants/${enc(id)}`, jsonInit('PUT', body))), + deleteVariant: async (slug: string, id: string) => + okEmpty(await fetch(`${base}/${enc(slug)}/variants/${enc(id)}`, { method: 'DELETE', ...acceptJson })), + setActive: async (slug: string, inherit: boolean, activeVariantId: string | null) => + okEmpty(await fetch(`${base}/${enc(slug)}/active`, jsonInit('PUT', { Inherit: inherit, ActiveVariantId: activeVariantId }))), + } +} diff --git a/src/frontend-vue/src/router/index.ts b/src/frontend-vue/src/router/index.ts index 43b786ef..c87bd4fb 100644 --- a/src/frontend-vue/src/router/index.ts +++ b/src/frontend-vue/src/router/index.ts @@ -465,7 +465,8 @@ const routes = [ beforeEnter: pageBuilderFeatureGate, }, { - path: 'customization/pages/:slug', + // :variantId is a variant GUID, or the literal "new" to author one. + path: 'customization/pages/:slug/:variantId', component: () => import('@/views/admin/customization/PageEditorView.vue'), beforeEnter: [pageBuilderFeatureGate, authPageSlotGate], }, diff --git a/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue index 05982611..b48fbb10 100644 --- a/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue +++ b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue @@ -9,6 +9,7 @@ import { useI18n } from '@cocoar/vue-localization' import EditableStringList from '@/components/EditableStringList.vue' import { useGroupStore } from '@/stores/group.store' import { useAppConfigStore } from '@/stores/appconfig.store' +import { useAppPagesApi, type AppSlotDto } from '@/composables/usePagesApi' import type { ApplicationSettingsDto } from '@/models/application' // ADR-0011 per-App settings override sections, extracted from the old standalone @@ -216,16 +217,98 @@ onMounted(async () => { defineExpose({ build }) -function editPage(slug: string) { - if (!props.applicationId) return - router.push({ - path: `/platform/customization/pages/${slug}`, +// ── Application PageBuilder variants + activation (ADR-0001) ── +const PAGE_SLOT_META = [ + { slug: 'login', label: t('admin.customization.pages.login.title', {}, 'Login') }, + { slug: 'password-forgot', label: t('admin.customization.pages.passwordForgot.title', {}, 'Forgot password') }, + { slug: 'logout', label: t('admin.customization.pages.logout.title', {}, 'Logout') }, +] +const APP_BUILT_IN = '__builtin__' +const appSlots = reactive>({}) +const pagesError = ref(null) +const pagesBusy = ref(false) + +function appSlotOf(slug: string): AppSlotDto { + return appSlots[slug] ?? { Slug: slug, InheritActive: true, ActiveVariantId: null, Variants: [] } +} + +function appPagesApi() { + return props.applicationId ? useAppPagesApi(props.applicationId) : null +} + +async function loadAppPages() { + const client = appPagesApi() + if (!client || !appConfig.config.Features.PageBuilder) return + try { + const { Slots } = await client.listSlots() + const bySlug = new Map(Slots.map((s) => [s.Slug, s])) + for (const m of PAGE_SLOT_META) { + appSlots[m.slug] = bySlug.get(m.slug) + ?? { Slug: m.slug, InheritActive: true, ActiveVariantId: null, Variants: [] } + } + } catch (e: any) { pagesError.value = e?.message ?? String(e) } +} + +// Active dropdown value: 'inherit' | '__builtin__' | variantId. +function appActiveValue(slot: AppSlotDto): string { + if (slot.InheritActive) return 'inherit' + return slot.ActiveVariantId ?? APP_BUILT_IN +} + +function appActiveOptions(slot: AppSlotDto) { + return [ + { value: 'inherit', label: t('admin.appSettings.pages.inheritRealm', {}, 'Inherit realm') }, + { value: APP_BUILT_IN, label: t('admin.customization.pages.builtin', {}, 'Built-in (default)') }, + ...slot.Variants.map((v) => ({ value: v.Id, label: v.Name })), + ] +} + +async function setAppActive(slug: string, value: string | null) { + const client = appPagesApi() + if (!client) return + pagesBusy.value = true + pagesError.value = null + try { + if (value === 'inherit') await client.setActive(slug, true, null) + else if (value === APP_BUILT_IN) await client.setActive(slug, false, null) + else await client.setActive(slug, false, value) + await loadAppPages() + } catch (e: any) { pagesError.value = e?.message ?? String(e) } finally { pagesBusy.value = false } +} + +function pageEditorTarget(slug: string, variantId: string) { + return { + path: `/platform/customization/pages/${slug}/${variantId}`, query: { - appId: props.applicationId, + appId: props.applicationId!, ...(props.applicationName ? { appName: props.applicationName } : {}), }, - }) + } +} + +function newAppVariant(slug: string) { + if (!props.applicationId) return + router.push(pageEditorTarget(slug, 'new')) +} + +function editAppVariant(slug: string, variantId: string) { + if (!props.applicationId) return + router.push(pageEditorTarget(slug, variantId)) +} + +async function deleteAppVariant(slug: string, variantId: string) { + const client = appPagesApi() + if (!client) return + pagesBusy.value = true + try { + await client.deleteVariant(slug, variantId) + await loadAppPages() + } catch (e: any) { pagesError.value = e?.message ?? String(e) } finally { pagesBusy.value = false } } + +watch(() => [activeTab.value, props.applicationId] as const, ([tab]) => { + if (tab === 'pages') loadAppPages() +})