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
4 changes: 2 additions & 2 deletions docs/admin/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ Modgud is not just a login frontend — it's a full **OAuth 2.0 / OpenID Connect
Per-realm look and feel. SPA-shell branding plus a beta page-builder editor.

- [Branding](../platform/branding) — product name, primary color, logo, favicon
- [Asset Library](../platform/assets) — upload images for branding (and, later, page schemas); SVG sanitisation built in
- [Pages (Beta)](../platform/pages) — drag-and-drop editor for login / logout / forgot-password; gated behind a [feature flag](../operate/feature-flags) while the runtime renderer is still being built
- [Asset Library](../platform/assets) — upload images for branding and page schemas; SVG sanitisation built in
- [Pages (Beta)](../platform/pages) — Realm defaults plus per-Application overrides for login / signed-out / forgot-password; end-to-end gated by a [feature flag](../operate/feature-flags)

### Operations

Expand Down
5 changes: 3 additions & 2 deletions docs/operate/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Flags are read at startup; no hot-reload. A flip requires a restart.

| Flag | Default | Effect |
| --- | --- | --- |
| `PageBuilder` | `false` | Visibility of the [Customization → Pages](../platform/pages) editor. While off: sidebar tile hidden, `/platform/customization/pages` routes redirect to Branding, `/api/admin/customization/pages/*` returns 404, `RealmSettingsDto` omits the Pages section. While on: editor mounts and persists. **Runtime rendering of stored schemas on /login etc. is a separate future feature and is not gated by this flag.** |
| `PageBuilder` | `false` | End-to-end [custom authentication pages](../platform/pages). While off: editor routes are hidden/redirected, Realm/Application page endpoints return 404, DTO/app-info schemas are masked, and auth routes use fixed screens. While on: Realm defaults and Application overrides are editable and rendered on login, forgot-password, and signed-out routes. |

## Defense in depth

Expand All @@ -41,7 +41,8 @@ For each gated feature the flag fires at every layer the surface touches:
1. **SPA sidebar** — the navigation entry is hidden via `requireFeature` in `AdminView.vue`.
2. **Vue-router** — `beforeEnter` guards on the gated routes redirect to a visible sibling so deep-links don't dead-end on a blank screen.
3. **Backend endpoints** — return **404 Not Found** (not 403 / 401) so curl-callers see "no such endpoint", not "permission denied".
4. **DTO masking** — surfaces that aggregate multiple sub-documents (e.g. `GET /api/admin/realm-settings`) emit the gated section as empty so the SPA can't fingerprint stored data.
4. **DTO masking** — aggregate and anonymous surfaces (`GET /api/admin/realm-settings`, `GET /api/app-info`) emit the gated section as empty so the SPA can't fingerprint stored data.
5. **Runtime fallback** — auth routes render the fixed screen whenever the flag is off, a schema is absent/invalid, or `?safemode=1` is present.

The stored data itself persists across flips — turning a flag off doesn't delete anything from the tenant DB. Flipping it back on surfaces the existing data unchanged.

Expand Down
2 changes: 1 addition & 1 deletion docs/platform/assets.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Customization — Asset Library

Per-realm image library for branding (and, later, page-builder schemas). Upload once, reference by ID from any branding field. Each realm's library is fully isolated — assets live in the tenant DB and can never be referenced from another realm.
Per-realm image library for branding and page-builder schemas. Upload once, reference by ID from any branding field or custom page. Each realm's library is fully isolated — assets live in the tenant DB and can never be referenced from another realm.

::: warning Trust boundary: SVG and image MIME
The asset library accepts SVG, but every upload is sanitised on the server before the bytes touch storage. The MIME type is sniffed from the leading magic bytes — **the client's `Content-Type` header is never trusted**. Anything that doesn't match the allowlist is rejected outright.
Expand Down
40 changes: 19 additions & 21 deletions docs/platform/pages.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
# Customization — Pages

Per-realm drag-and-drop editor for the SPA's login / logout / forgot-password screens. Editor stores a JSON schema per page-slot in the tenant DB; a future runtime sprint hooks `<CoarPageRenderer>` into those routes so the schemas actually render at login time.
Drag-and-drop editor for the SPA's login, signed-out, and forgot-password screens. Schemas can be defined as Realm defaults and overridden per Application. The SPA renders them with `<CoarPageRenderer>` at runtime.

::: warning Beta — gated behind an operator feature flag
This surface is **disabled by default**. The Pages tile in the sidebar is hidden, the routes redirect away, and the underlying API returns 404 until the operator flips `AppSettings.Features.PageBuilder = true`. See [Feature Flags](../operate/feature-flags) for how to turn it on in your environment.

The editor itself is functional and persists schemas. **Runtime rendering of stored schemas on `/login` / `/logout` / `/forgot-password` is not yet wired** — saving a schema in the editor does not currently change what end-users see. That second half is a separate sprint, planned but unscheduled.
The flag gates the editor, persistence endpoints, anonymous schema delivery, and runtime rendering together. Stored schemas remain in the database while the flag is off.
:::

## When it's safe to enable

Today, **never in production** — the editor can produce schemas that the runtime can't yet render. Enable it in dev to:
The end-to-end path is wired, but remains opt-in while the PageBuilder integration is beta. Enable it first in a test realm and verify each saved page at desktop and mobile widths before enabling it in production.

- preview the editor flow and the per-slot palette
- pin down which page-slots you'd want to customise once the runtime ships
- give early feedback into `@cocoar/vue-page-builder` (Modgud is the first beta integration)
- Realm pages require `realm-settings:read` / `realm-settings:write`.
- Application overrides require `app:read` / `app:write` and are opened from the Application's Settings → Pages tab.
- `?safemode=1` on `/login`, `/forgot-password`, or `/logged-out` bypasses a stored schema for UX recovery.

Permissions when the flag is on: `realm-settings:read` / `realm-settings:write`. The `realm:admin` bypass grants both. The flag itself is operator-level — realm admins cannot turn it on.
The flag itself is operator-level — realm admins cannot turn it on.

## Page slots (today)

Expand All @@ -25,23 +25,24 @@ Three hardcoded slugs. Adding more is a code change (slug allowlist + per-slot a
| Slug | Purpose |
| --- | --- |
| `login` | Username + password + provider buttons. Hosts MFA-prompt actions too. |
| `logout` | Post-sign-out screen. |
| `logout` | `/logged-out` confirmation after local or federated sign-out. |
| `password-forgot` | Email-address entry for the password-reset flow. |

## What the editor lets you compose

The page-builder is **headless** — you choose elements from a palette and arrange them in a stack/card/section layout. Per-slot whitelists keep the surface tight:
The page-builder is **headless** — you choose elements from a palette and arrange them in a stack/card/section layout. The shared editor/runtime allowlist keeps the surface tight:

- **Containers**: stack, card, section, divider
- **Static content**: heading, paragraph
- **Inputs**: text-input, checkbox
- **Interactive**: button (with an action id), link, image (from the asset library)
- **Containers**: stack, card, section, divider, spacer
- **Static content**: heading, paragraph, note, Application-brand header
- **Inputs**: text, password, checkbox — bound only to the slot's declared fields
- **Interactive**: button/link with an allowlisted action, image from the asset library
- **Login only**: a live external-login-provider block

Each slot defines its own list of **available actions** for buttons. For `login` that's `auth:login`, `auth:passkey`, `auth:magic-link`, `auth:forgot-password`, `auth:register`, `auth:mfa-totp`, `auth:mfa-email-otp`. The renderer dispatches the action when the button is clicked; unknown action ids are ignored by design (forward compat).
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.

## Storage

Schemas live as a `Dictionary<string, string>` sub-document on the singleton `RealmSettings` record — keyed by slug, value is the serialised PageNode JSON. New slot additions don't need a schema migration; the dictionary grows implicitly.
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.

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

Expand All @@ -51,15 +52,12 @@ Endpoints (all admin-gated, all return 404 when the feature flag is off):
| `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. |

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.

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

## Customisation vs. security

**The page-builder schema describes UI, never security policy.** MFA enforcement, password policy, account-lockout, login-provider allowlist, rate limits, captcha — all of those live server-side in `RealmSettings` and `AppSettings`, completely independent of the schema. A customised login and the hardcoded default login enforce identical security; only the visual layout differs.

That property means a future safe-mode URL (`/login?safemode=1`) that bypasses the schema is a pure UX recovery, not a security bypass — the same backend policies apply whichever rendering path the page takes.

## What ships next

Runtime rendering of stored schemas is the next sprint and is
deferred — until it lands, leave the feature flag off in production.
Stored JSON is normalized to the current v2 schema before rendering. Unknown or disallowed elements are skipped, action IDs are matched against host-owned handlers, and invalid/unavailable schemas fall back to the fixed screen. Safe mode is therefore a UX recovery path, not a security bypass — the same backend policies apply whichever rendering path the page takes.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
using System.Text.Json;
using Modgud.Api.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Modgud.Api.Features.Admin.Apps;
using Modgud.Application.DTOs.Applications;
using Marten;
using Modgud.Domain.Applications;
using Modgud.Domain.OAuth.Applications;

namespace Modgud.Api.Tests.Authorization;

Expand Down Expand Up @@ -113,7 +118,11 @@ public async Task RealmSettings_DTO_emits_empty_Pages_when_feature_off()

Assert.True(doc.RootElement.TryGetProperty("Pages", out var pages));
Assert.Equal(JsonValueKind.Object, pages.ValueKind);
Assert.Equal(0, pages.EnumerateObject().Count());
Assert.Empty(pages.EnumerateObject());

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());
}

[Fact]
Expand All @@ -138,6 +147,124 @@ public async Task RealmSettings_DTO_includes_Pages_when_feature_on()
Assert.Equal(JsonValueKind.Object, pages.ValueKind);
Assert.True(pages.TryGetProperty("login", out _),
"Pages dictionary must surface stored slug when flag is on");

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
{
settings.Features.PageBuilder = false;
}
}

[Fact]
public async Task Application_page_override_inherits_overrides_survives_settings_save_and_can_reset()
{
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\":\"title\",\"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);

var slug = $"pb-{Guid.NewGuid():N}";
var createdResponse = await Client.PostAsJsonAsync("/api/app",
new CreateAppDto(slug, "PageBuilder App", null, [], null), JsonOptions, ct);
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";

using (var inherited = JsonDocument.Parse(await (await Client.GetAsync(endpoint, 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());
}

var appPut = await Client.PutAsJsonAsync(endpoint, new { Schema = appSchema }, ct);
Assert.Equal(HttpStatusCode.OK, appPut.StatusCode);

// A regular App settings replace must leave the separately-managed page tree intact.
var appUpdate = await Client.PutAsJsonAsync($"/api/app/{appId}",
new UpdateAppDto("PageBuilder App", null, [], new ApplicationSettingsDto
{
Branding = new ApplicationBrandingDto { ProductName = "Branded" },
}), JsonOptions, ct);
Assert.Equal(HttpStatusCode.OK, appUpdate.StatusCode);

using (var overridden = JsonDocument.Parse(await (await Client.GetAsync(endpoint, 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 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;
}
}

[Fact]
public async Task AppInfo_resolves_page_override_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\":\"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);

var appId = Guid.NewGuid();
var clientId = $"page-client-{Guid.NewGuid():N}";
using (var scope = Factory.Services.CreateScope())
{
var session = scope.ServiceProvider.GetRequiredService<IDocumentSession>();
session.Store(new ApplicationSettings
{
Id = appId,
CreatedAt = DateTimeOffset.UtcNow,
Pages = new Dictionary<string, string> { ["login"] = appSchema },
});
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());

// An absolute URL is not accepted as presentation context.
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
{
Expand Down
Loading
Loading