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
27 changes: 22 additions & 5 deletions docs/platform/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<json>"}`. 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: "<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/{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: "<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.

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

Expand Down
325 changes: 197 additions & 128 deletions src/dotnet/Modgud.Api.Tests/Authorization/PageBuilderFeatureFlagTests.cs

Large diffs are not rendered by default.

471 changes: 373 additions & 98 deletions src/dotnet/Modgud.Api/Features/Admin/CustomizationPagesEndpoints.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,9 @@ public record RealmSettingsDto
public DeletionSettingsDto Deletion { get; init; } = new();
public AuditSettingsDto Audit { get; init; } = new();

/// <summary>Page-builder schemas keyed by slug. Read-only via the bulk
/// GET; writes go through the dedicated <c>/api/admin/customization/pages/{slug}</c>
/// endpoints. Empty dict = no slot customised yet.</summary>
public IReadOnlyDictionary<string, string> Pages { get; init; }
= new Dictionary<string, string>();
// 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.
}

/// <summary>Patch payload for <c>PATCH /api/admin/realm-settings</c>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>() };
}
return Results.Ok(dto);
})
.WithName("RealmSettings_Get")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>()
: new Dictionary<string, string>(doc.Pages),
};

private static ErrorOr<BrandingSettings> ApplyBrandingPatch(
Expand Down
51 changes: 43 additions & 8 deletions src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,50 @@ public class ApplicationSettings
/// while an Enterprise App in the same tenant requires given/family name.</summary>
public ApplicationRegistrationFieldsOverrides? RegistrationFields { get; set; }

/// <summary>
/// Per-page PageBuilder overrides keyed by SPA page slot (<c>login</c>,
/// <c>password-forgot</c>, …). 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.
/// </summary>
/// <summary>LEGACY (pre-ADR-0001): single per-Application PageBuilder
/// schema per slot. Retained only for <see cref="MigratePagesToSlots"/> to
/// convert on load; cleared on the next save. New reads/writes use
/// <see cref="PageSlots"/>.</summary>
public Dictionary<string, string>? Pages { get; set; }

/// <summary>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
/// <see cref="AppPageSlot.InheritActive"/> 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.</summary>
public Dictionary<string, AppPageSlot>? PageSlots { get; set; }

/// <summary>Lazily migrate the legacy single-schema <see cref="Pages"/>
/// dictionary into <see cref="PageSlots"/> (ADR-0001). Each legacy
/// <c>Pages[slug] = schema</c> becomes one active Application variant named
/// "Custom" with <see cref="AppPageSlot.InheritActive"/> false (the legacy
/// entry was an explicit App override). Returns <c>true</c> when it changed
/// the document.</summary>
public bool MigratePagesToSlots()
{
if (Pages is null || Pages.Count == 0)
{
if (Pages is not null) { Pages = null; return true; }
return false;
}

PageSlots ??= new Dictionary<string, AppPageSlot>(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;
}
}

/// <summary>An Application's own origin. Phase-1 resolution maps a host to an
Expand Down
81 changes: 69 additions & 12 deletions src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -180,16 +182,71 @@ public sealed record EffectiveSettings
};
}

private static Dictionary<string, string>? MergePages(
Dictionary<string, string>? realm,
Dictionary<string, string>? app)
/// <summary>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 <see cref="RealmSettings.Pages"/>
/// entries (pre-ADR-0001, not yet migrated) are honoured for any slug the
/// new <c>PageSlots</c> does not cover.</summary>
private static Dictionary<string, string>? ResolveRealmActivePages(RealmSettingsDoc realm)
{
if (app is null || app.Count == 0) return realm;
var result = new Dictionary<string, string>(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;
}

/// <summary>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).</summary>
private static Dictionary<string, string>? ResolveEffectivePages(
RealmSettingsDoc realm,
ApplicationSettings app)
{
var result = ResolveRealmActivePages(realm) is { } r
? new Dictionary<string, string>(r, StringComparer.Ordinal)
: new Dictionary<string, string>(StringComparer.Ordinal);

var merged = realm is null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(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<PageVariant> 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;
}
}
48 changes: 42 additions & 6 deletions src/dotnet/Modgud.Domain/RealmSettings/RealmSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,47 @@ public class RealmSettings
/// erase).</summary>
public AuditSettings? Audit { get; set; }

/// <summary>Page-builder schemas keyed by SPA-page-slug
/// (<c>login</c>, <c>logout</c>, <c>password-forgot</c>, …). Each
/// value is the serialised <c>PageNode</c> 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.</summary>
/// <summary>LEGACY (pre-ADR-0001): single page-builder schema per
/// SPA-page-slug. Retained only so <see cref="MigratePagesToSlots"/> can
/// convert existing data into <see cref="PageSlots"/> on load; cleared on
/// the next save. New reads/writes use <see cref="PageSlots"/>.</summary>
public Dictionary<string, string>? Pages { get; set; }

/// <summary>Page-builder configuration keyed by SPA-page-slug
/// (<c>login</c>, <c>logout</c>, <c>password-forgot</c>, …). Each entry is
/// a library of named variants plus which one is active (ADR-0001). A
/// missing slot, or a slot whose <see cref="RealmPageSlot.ActiveVariantId"/>
/// is null, renders the SPA's built-in hardcoded view.</summary>
public Dictionary<string, RealmPageSlot>? PageSlots { get; set; }

/// <summary>Lazily migrate the legacy single-schema <see cref="Pages"/>
/// dictionary into <see cref="PageSlots"/> (ADR-0001). Idempotent and
/// side-effect-only-when-needed: does nothing once migrated. Each legacy
/// <c>Pages[slug] = schema</c> becomes one active variant named "Custom".
/// Returns <c>true</c> when it changed the document (caller should persist).</summary>
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<string, RealmPageSlot>(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;
}
}
62 changes: 62 additions & 0 deletions src/dotnet/Modgud.Domain/Realms/PageVariants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
namespace Modgud.Domain.Realms;

/// <summary>
/// A single named PageBuilder variant for a page slot (ADR-0001). The
/// <see cref="Schema"/> is opaque JSON — the SPA's <c>@cocoar/vue-page-builder</c>
/// renderer is the schema-shape authority; the backend only stores + serves it.
/// </summary>
public class PageVariant
{
/// <summary>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.</summary>
public string Id { get; set; } = default!;

/// <summary>Operator-facing name, e.g. "Split screen", "Minimal".</summary>
public string Name { get; set; } = default!;

/// <summary>Serialized <c>PageNode</c> tree as JSON.</summary>
public string Schema { get; set; } = default!;

public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
}

/// <summary>
/// Realm-level configuration for one page slot: a library of named variants
/// plus which one is active (ADR-0001). <see cref="ActiveVariantId"/> is
/// <c>null</c> ⇒ the slot renders the SPA's built-in hardcoded view
/// (i.e. "deactivated" — variants may still exist, unused).
/// </summary>
public class RealmPageSlot
{
public List<PageVariant> Variants { get; set; } = new();

/// <summary>Id of the active variant, or <c>null</c> for the built-in
/// hardcoded view. An id that no longer matches a variant also resolves
/// to built-in (defensive).</summary>
public string? ActiveVariantId { get; set; }
}

/// <summary>
/// Application-level configuration for one page slot (ADR-0001). Mirrors
/// <see cref="RealmPageSlot"/> but adds an inherit switch: when
/// <see cref="InheritActive"/> is <c>true</c> (default) the effective active
/// page is resolved from the realm; when <c>false</c> the Application overrides
/// it — <see cref="ActiveVariantId"/> <c>null</c> ⇒ built-in, else an
/// Application variant.
/// </summary>
public class AppPageSlot
{
public List<PageVariant> Variants { get; set; } = new();

/// <summary>When <c>true</c> the Application defers to the realm's active
/// selection for this slot. When <c>false</c> the Application's own
/// selection (<see cref="ActiveVariantId"/>) wins.</summary>
public bool InheritActive { get; set; } = true;

/// <summary>Only meaningful when <see cref="InheritActive"/> is
/// <c>false</c>. <c>null</c> ⇒ built-in hardcoded view; else an
/// Application variant id.</summary>
public string? ActiveVariantId { get; set; }
}
Loading
Loading