Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions docs/platform/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,34 +42,38 @@ Each slot defines its own list of **available actions**. Login supports credenti

## 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:
The variant library is **realm-global** (ADR-0001): each slot owns a set of named variants, authored in **Platform → Pages**. Three concepts stay separate *a variant exists*, *a variant is live*, and *the built-in fixed view* — so 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**.
**Where you do what:**

- **Platform → Pages** is a grid of all variants (name, type, *Used By* count with a hover of the exact consumers, last-updated). Right-click (or the toolbar button) creates a new Login / Logout / Forgot-password page; double-click edits; the context menu deletes.
- **Realm settings → Pages** has three selectors (login / logout / forgot) choosing the realm's live variant per slot — **Built-in** or a variant.
- An **Application → Settings → Pages** has the same three selectors, each **Inherit realm** (default) / **Built-in** / one of the realm variants. An App never authors its own variant — it only *selects* from the realm library.

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 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.
The realm variant library + the realm's active selection live in `RealmSettings.PageSlots` (keyed by slot). Each Application's per-slot selection (inherit / built-in / a realm variant id) lives in `ApplicationSettings.PageSlots`. Legacy single-schema `Pages` data migrates on first touch — a realm entry becomes an active "Custom" variant; an App entry (which can no longer be represented) is dropped, so the App inherits.

Endpoints (all admin-gated, all return 404 when the feature flag is off):

| Method | Path | Behaviour |
| --- | --- | --- |
| `GET` | `/api/admin/customization/pages` | Lists every slot with its variants (summaries) + active variant id. |
| `GET` | `/api/admin/customization/pages` | Lists every slot with its variant summaries (incl. `RealmActive` + `UsedByApps`) + active 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: "<id>" \| null}` (null = Built-in). |
| `DELETE` | `/api/admin/customization/pages/{slug}/variants/{id}` | Removes a variant; the realm active pointer clears if it targeted it. |
| `PUT` | `/api/admin/customization/pages/{slug}/active` | Sets the realm's live variant. Body: `{ActiveVariantId: "<id>" \| 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/...` with the same variant CRUD, plus `PUT /{slug}/active` taking `{Inherit: bool, ActiveVariantId: "<id>" \| 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.
Application endpoints under `/api/app/{applicationId}/pages`: `GET` returns each slot's selection (`InheritActive`, `ActiveVariantId`) plus the `AvailableVariants` (the realm library) to choose from; `PUT /{slug}/active` takes `{Inherit: bool, ActiveVariantId: "<id>" \| null}` where the id must be a **realm** variant — `Inherit: true` defers to the realm, `false` + `null` forces Built-in, `false` + an id selects that realm variant. Regular Application-settings saves do not touch the page selection.

Slug charset: `a-z0-9-`, length 1–32. Anything else is a 400.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,20 +227,25 @@ public async Task Legacy_single_schema_migrates_to_an_active_variant()
finally { settings.Features.PageBuilder = false; }
}

// ── application inheritance / override / deactivate ──
// ── application: select from realm variants ──

[Fact]
public async Task Application_inherits_then_overrides_then_deactivates_and_survives_settings_save()
public async Task Application_selects_a_realm_variant_and_survives_settings_save()
{
var settings = Factory.Services.GetRequiredService<AppSettings>();
settings.Features.PageBuilder = true;
var ct = TestContext.Current.CancellationToken;
try
{
const string realmSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[]}";
const string appSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[{\"id\":\"t\",\"type\":\"heading\",\"props\":{\"text\":\"App\"}}]}";
const string altSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[{\"id\":\"t\",\"type\":\"heading\",\"props\":{\"text\":\"Alt\"}}]}";

await SeedRealmActive("logout", "Realm", realmSchema, ct);
// A second, non-active realm variant the App can pick.
var altPost = await Client.PostAsJsonAsync("/api/admin/customization/pages/logout/variants",
new { Name = "Alt", Schema = altSchema }, ct);
using var altCreated = JsonDocument.Parse(await altPost.Content.ReadAsStringAsync(ct));
var altId = altCreated.RootElement.GetProperty("Id").GetString();

var slug = $"pb-{Guid.NewGuid():N}";
var createdResponse = await Client.PostAsJsonAsync("/api/app",
Expand All @@ -250,22 +255,21 @@ public async Task Application_inherits_then_overrides_then_deactivates_and_survi
var appId = created.RootElement.GetProperty("Id").GetString();
var basePath = $"/api/app/{appId}/pages";

// Fresh app inherits: its slot list is empty.
// Fresh app inherits, and can see the realm variants as options.
using (var list = JsonDocument.Parse(await (await Client.GetAsync(basePath, ct)).Content.ReadAsStringAsync(ct)))
{
Assert.Empty(list.RootElement.GetProperty("Slots").EnumerateArray());
var slot = list.RootElement.GetProperty("Slots").EnumerateArray()
.Single(s => s.GetProperty("Slug").GetString() == "logout");
Assert.True(slot.GetProperty("InheritActive").GetBoolean());
Assert.Equal(2, slot.GetProperty("AvailableVariants").EnumerateArray().Count());
}

// 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();
// App overrides to the Alt realm variant.
var setActive = await Client.PutAsJsonAsync($"{basePath}/logout/active",
new { Inherit = false, ActiveVariantId = appVariantId }, ct);
new { Inherit = false, ActiveVariantId = altId }, ct);
Assert.Equal(HttpStatusCode.OK, setActive.StatusCode);

// A regular App settings replace must leave the page tree intact.
// A regular App settings replace must leave the page selection intact.
var appUpdate = await Client.PutAsJsonAsync($"/api/app/{appId}",
new UpdateAppDto("PageBuilder App", null, [], new ApplicationSettingsDto
{
Expand All @@ -275,41 +279,75 @@ public async Task Application_inherits_then_overrides_then_deactivates_and_survi

using (var list = JsonDocument.Parse(await (await Client.GetAsync(basePath, ct)).Content.ReadAsStringAsync(ct)))
{
var slot = list.RootElement.GetProperty("Slots").EnumerateArray().Single();
var slot = list.RootElement.GetProperty("Slots").EnumerateArray()
.Single(s => s.GetProperty("Slug").GetString() == "logout");
Assert.False(slot.GetProperty("InheritActive").GetBoolean());
Assert.Equal(appVariantId, slot.GetProperty("ActiveVariantId").GetString());
Assert.Single(slot.GetProperty("Variants").EnumerateArray());
Assert.Equal(altId, slot.GetProperty("ActiveVariantId").GetString());
}

// The realm grid shows the Alt variant is used by this app.
using (var realmSlot = JsonDocument.Parse(await (await Client.GetAsync(
"/api/admin/customization/pages/logout", ct)).Content.ReadAsStringAsync(ct)))
{
var alt = realmSlot.RootElement.GetProperty("Variants").EnumerateArray()
.Single(v => v.GetProperty("Id").GetString() == altId);
Assert.Contains("PageBuilder App", alt.GetProperty("UsedByApps").EnumerateArray().Select(x => x.GetString()));
}

// Back to inherit — app variant is retained, realm selection stands.
// Back to inherit.
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();
var slot = list.RootElement.GetProperty("Slots").EnumerateArray()
.Single(s => s.GetProperty("Slug").GetString() == "logout");
Assert.True(slot.GetProperty("InheritActive").GetBoolean());
Assert.Single(slot.GetProperty("Variants").EnumerateArray()); // retained
}
}
finally { settings.Features.PageBuilder = false; }
}

[Fact]
public async Task AppInfo_resolves_app_override_from_local_authorize_client_context()
public async Task Application_activating_a_non_realm_variant_is_rejected()
{
var settings = Factory.Services.GetRequiredService<AppSettings>();
settings.Features.PageBuilder = true;
var ct = TestContext.Current.CancellationToken;
try
{
var slug = $"pb-{Guid.NewGuid():N}";
var createdResponse = await Client.PostAsJsonAsync("/api/app",
new CreateAppDto(slug, "PB App 2", null, [], null), JsonOptions, ct);
using var created = JsonDocument.Parse(await createdResponse.Content.ReadAsStringAsync(ct));
var appId = created.RootElement.GetProperty("Id").GetString();

var resp = await Client.PutAsJsonAsync($"/api/app/{appId}/pages/login/active",
new { Inherit = false, ActiveVariantId = "not-a-realm-variant" }, ct);
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
finally { settings.Features.PageBuilder = false; }
}

[Fact]
public async Task AppInfo_resolves_app_selected_realm_variant_from_local_authorize_client_context()
{
var settings = Factory.Services.GetRequiredService<AppSettings>();
settings.Features.PageBuilder = true;
var ct = TestContext.Current.CancellationToken;
try
{
const string realmSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[]}";
const string appSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[{\"id\":\"a\",\"type\":\"paragraph\",\"props\":{\"text\":\"App login\"}}]}";
const string altSchema = "{\"type\":\"page\",\"schemaVersion\":2,\"children\":[{\"id\":\"a\",\"type\":\"paragraph\",\"props\":{\"text\":\"Alt login\"}}]}";
await SeedRealmActive("login", "Realm", realmSchema, ct);
// A second realm variant the App will select.
var altPost = await Client.PostAsJsonAsync("/api/admin/customization/pages/login/variants",
new { Name = "Alt", Schema = altSchema }, ct);
using var altCreated = JsonDocument.Parse(await altPost.Content.ReadAsStringAsync(ct));
var altId = altCreated.RootElement.GetProperty("Id").GetString();

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<IDocumentSession>();
Expand All @@ -319,20 +357,16 @@ public async Task AppInfo_resolves_app_override_from_local_authorize_client_cont
CreatedAt = DateTimeOffset.UtcNow,
PageSlots = new Dictionary<string, AppPageSlot>
{
["login"] = new AppPageSlot
{
InheritActive = false,
Variants = [new PageVariant { Id = appVariantId, Name = "App", Schema = appSchema }],
ActiveVariantId = appVariantId,
},
// App selects the Alt *realm* variant (no app-owned variants).
["login"] = new AppPageSlot { InheritActive = false, ActiveVariantId = altId },
},
});
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");
Assert.Equal(appSchema, await AppInfoActiveSchema("login", ct, continuation));
Assert.Equal(altSchema, await AppInfoActiveSchema("login", ct, continuation));

// An absolute URL is not accepted as presentation context → realm schema.
var untrusted = Uri.EscapeDataString($"https://evil.example/connect/authorize?client_id={clientId}");
Expand Down
Loading
Loading