From 137eb563f524101a585b730778e2e61409ea4d4b Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Wed, 22 Jul 2026 07:54:23 +0200 Subject: [PATCH] feat(customization): render application-specific auth pages --- docs/admin/index.md | 4 +- docs/operate/feature-flags.md | 5 +- docs/platform/assets.md | 2 +- docs/platform/pages.md | 40 +++-- .../PageBuilderFeatureFlagTests.cs | 129 +++++++++++++- .../Features/Admin/AppSettingsEndpoints.cs | 36 +++- .../Admin/CustomizationPagesEndpoints.cs | 109 +++++++++++- .../Api/ExternalAuth/ExternalAuthEndpoints.cs | 2 +- .../Applications/ApplicationSettings.cs | 10 ++ .../Applications/EffectiveSettings.cs | 19 ++- .../Admin/AppSettingsEndpointsTests.cs | 42 +++++ .../Applications/EffectiveSettingsTests.cs | 33 ++++ src/frontend-vue/e2e/00-smoke.spec.ts | 4 +- src/frontend-vue/package.json | 12 +- src/frontend-vue/pnpm-lock.yaml | 81 ++++----- src/frontend-vue/src/assets/styles/main.css | 5 - .../page-builder/BrandHeaderElement.vue | 40 +++++ .../page-builder/BrandHeaderPreview.vue | 27 +++ .../page-builder/ExternalLoginsElement.vue | 51 ++++++ .../page-builder/ExternalLoginsPreview.vue | 28 +++ .../src/page-builder/authPageConfig.ts | 161 ++++++++++++++++++ .../src/page-builder/loginPageRuntime.ts | 28 +++ src/frontend-vue/src/router/index.ts | 14 +- .../src/stores/appconfig.store.ts | 24 ++- src/frontend-vue/src/stores/auth.store.ts | 2 +- .../src/views/admin/InboxSettingsView.vue | 26 +-- .../src/views/admin/apps/AppDetails.vue | 7 +- .../views/admin/apps/AppSettingsSections.vue | 88 +++++++++- .../admin/customization/PageEditorView.vue | 158 ++++++++--------- .../views/admin/customization/PagesView.vue | 2 +- .../login-providers/LoginProviderDetails.vue | 12 +- .../src/views/admin/oauth/ClientDetails.vue | 51 +++--- .../scheduledJobs/ScheduledJobDetails.vue | 20 ++- .../src/views/admin/user/UserDetails.vue | 14 +- .../src/views/auth/ForgotPasswordView.vue | 109 ++++++++++-- .../src/views/auth/LoggedOutView.vue | 98 +++++++++++ src/frontend-vue/src/views/auth/LoginView.vue | 160 ++++++++++++++--- .../src/views/profile/ProfileView.vue | 4 +- src/frontend-vue/vite.config.ts | 5 - 39 files changed, 1366 insertions(+), 296 deletions(-) create mode 100644 src/dotnet/Modgud.Tests.Unit/Api/Features/Admin/AppSettingsEndpointsTests.cs create mode 100644 src/frontend-vue/src/components/page-builder/BrandHeaderElement.vue create mode 100644 src/frontend-vue/src/components/page-builder/BrandHeaderPreview.vue create mode 100644 src/frontend-vue/src/components/page-builder/ExternalLoginsElement.vue create mode 100644 src/frontend-vue/src/components/page-builder/ExternalLoginsPreview.vue create mode 100644 src/frontend-vue/src/page-builder/authPageConfig.ts create mode 100644 src/frontend-vue/src/page-builder/loginPageRuntime.ts create mode 100644 src/frontend-vue/src/views/auth/LoggedOutView.vue diff --git a/docs/admin/index.md b/docs/admin/index.md index 67bfe8c7..9f202da0 100644 --- a/docs/admin/index.md +++ b/docs/admin/index.md @@ -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 diff --git a/docs/operate/feature-flags.md b/docs/operate/feature-flags.md index 756f0a6d..93ed6a49 100644 --- a/docs/operate/feature-flags.md +++ b/docs/operate/feature-flags.md @@ -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 @@ -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. diff --git a/docs/platform/assets.md b/docs/platform/assets.md index dd83ed56..b06ee919 100644 --- a/docs/platform/assets.md +++ b/docs/platform/assets.md @@ -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. diff --git a/docs/platform/pages.md b/docs/platform/pages.md index 244aedd2..f7df3495 100644 --- a/docs/platform/pages.md +++ b/docs/platform/pages.md @@ -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 `` 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 `` 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) @@ -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` 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): @@ -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: ""}`. 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. diff --git a/src/dotnet/Modgud.Api.Tests/Authorization/PageBuilderFeatureFlagTests.cs b/src/dotnet/Modgud.Api.Tests/Authorization/PageBuilderFeatureFlagTests.cs index 6329b15c..1edcfa25 100644 --- a/src/dotnet/Modgud.Api.Tests/Authorization/PageBuilderFeatureFlagTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Authorization/PageBuilderFeatureFlagTests.cs @@ -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; @@ -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] @@ -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(); + 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(); + 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(); + session.Store(new ApplicationSettings + { + Id = appId, + CreatedAt = DateTimeOffset.UtcNow, + Pages = new Dictionary { ["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 { diff --git a/src/dotnet/Modgud.Api/Features/Admin/AppSettingsEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/AppSettingsEndpoints.cs index d947e275..bc3dd91c 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/AppSettingsEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/AppSettingsEndpoints.cs @@ -1,4 +1,5 @@ using BuildingBlocks.Helper; +using Microsoft.AspNetCore.WebUtilities; using Modgud.Api; using Modgud.Authentication.Applications; using Modgud.Domain.Realms; @@ -24,14 +25,20 @@ public static WebApplication MapAppSettingsEndpoints(this WebApplication applica // is metadata, no secrets — same disclosure surface as the existing // public realm settings. application.MapGet($"{path}/app-info", - async (HttpContext http, AppSettings settings, IApplicationSettingsResolver settingsResolver) => + async (HttpContext http, AppSettings settings, IApplicationSettingsResolver settingsResolver, string? returnUrl) => { var tenant = http.Items[TenantConstants.HttpContextTenantInfoKey] as TenantInfo; // ADR-0011 — Host-time: on an Application subdomain the branding is // the App's overrides merged over the realm branding, so the login // page renders App-branded; on a plain tenant host this resolves to // the realm branding unchanged. - var effective = await settingsResolver.ResolveForRequestAsync(http, clientId: null, http.RequestAborted); + // On an App subdomain the Host already pins the Application. On the + // canonical Realm host, the login challenge keeps the original + // /connect/authorize URL in ?redirect=. Accept that local continuation + // as a second signal so App branding/pages also work without a custom + // subdomain. ResolveForRequestAsync still gives the Host pin precedence. + var clientId = ExtractAuthorizeClientId(returnUrl); + var effective = await settingsResolver.ResolveForRequestAsync(http, clientId, http.RequestAborted); var branding = effective.Branding; // ADR-0011 — publish the resolved (App⊕realm) registration-field // policy so native apps + the web register form render exactly the @@ -64,6 +71,9 @@ public static WebApplication MapAppSettingsEndpoints(this WebApplication applica { settings.Features.PageBuilder, }, + Pages = settings.Features.PageBuilder + ? effective.Pages ?? new Dictionary() + : new Dictionary(), RegistrationFields = new { Email = nameof(FieldRequirement.Required), // always required (the anchor) @@ -78,4 +88,26 @@ public static WebApplication MapAppSettingsEndpoints(this WebApplication applica return application; } + + /// + /// Extracts a client id only from a bounded, same-origin authorization + /// continuation. Arbitrary paths/URLs never influence anonymous branding. + /// Full OAuth request validation still happens at /connect/authorize; this + /// helper merely selects the effective App presentation before login. + /// + internal static string? ExtractAuthorizeClientId(string? returnUrl) + { + if (string.IsNullOrWhiteSpace(returnUrl) || returnUrl.Length > 8192) return null; + if (returnUrl.IndexOfAny(['\r', '\n', '\0', '\\']) >= 0) return null; + + var queryStart = returnUrl.IndexOf('?'); + var path = queryStart < 0 ? returnUrl : returnUrl[..queryStart]; + if (!string.Equals(path, "/connect/authorize", StringComparison.OrdinalIgnoreCase)) return null; + if (queryStart < 0 || queryStart == returnUrl.Length - 1) return null; + + var query = QueryHelpers.ParseQuery(returnUrl[queryStart..]); + if (!query.TryGetValue("client_id", out var clientIds) || clientIds.Count != 1) return null; + var clientId = clientIds[0]; + return string.IsNullOrWhiteSpace(clientId) ? null : clientId; + } } diff --git a/src/dotnet/Modgud.Api/Features/Admin/CustomizationPagesEndpoints.cs b/src/dotnet/Modgud.Api/Features/Admin/CustomizationPagesEndpoints.cs index 8a505c37..ae9753ae 100644 --- a/src/dotnet/Modgud.Api/Features/Admin/CustomizationPagesEndpoints.cs +++ b/src/dotnet/Modgud.Api/Features/Admin/CustomizationPagesEndpoints.cs @@ -1,4 +1,8 @@ +using System.Text; +using BuildingBlocks.Helper; using Modgud.Authorization.AspNetCore; +using Modgud.Authorization.Apps; +using Modgud.Domain.Applications; using Modgud.Domain.RealmSettings; using Marten; @@ -61,7 +65,7 @@ public static WebApplication MapCustomizationPagesEndpoints(this WebApplication 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 (body.Schema.Length > MaxSchemaBytes) + 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 @@ -110,6 +114,109 @@ public static WebApplication MapCustomizationPagesEndpoints(this WebApplication .RequiresPermission("realm-settings:write") .WithName("Admin_Customization_DeletePage"); + // 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") + .WithTags("Admin Application Customization Pages") + .RequireAuthorization(); + + appPages.MapGet("{slug}", async ( + ShortGuid applicationId, + string slug, + AppSettings settings, + IQuerySession 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 + { + Slug = slug, + Schema = schema, + EffectiveSchema = schema ?? inherited, + InheritsRealm = schema is null, + }); + }) + .RequiresPermission("app:read") + .WithName("Admin_ApplicationCustomization_GetPage"); + + appPages.MapPut("{slug}", async ( + ShortGuid applicationId, + string slug, + UpdatePageRequest 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 (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)." }); + + 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 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; + doc.UpdatedAt = DateTimeOffset.UtcNow; + session.Store(doc); + await session.SaveChangesAsync(ct); + + return Results.Ok(new { Slug = slug, Schema = body.Schema, InheritsRealm = false }); + }) + .RequiresPermission("app:write") + .WithName("Admin_ApplicationCustomization_PutPage"); + + appPages.MapDelete("{slug}", async ( + ShortGuid applicationId, + string slug, + 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) + { + if (doc.Pages.Count == 0) doc.Pages = null; + doc.UpdatedAt = DateTimeOffset.UtcNow; + session.Store(doc); + await session.SaveChangesAsync(ct); + } + + return Results.NoContent(); + }) + .RequiresPermission("app:write") + .WithName("Admin_ApplicationCustomization_DeletePage"); + return app; } diff --git a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs index 5b05751e..6162bf3f 100644 --- a/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs +++ b/src/dotnet/Modgud.Authentication/Api/ExternalAuth/ExternalAuthEndpoints.cs @@ -102,7 +102,7 @@ public static void MapExternalAuthEndpoints(this IEndpointRouteBuilder endpoints } var schemeName = DynamicOidcSchemeManager.SchemeNameFor(loginProviderId); - var props = new AuthenticationProperties { RedirectUri = "/login" }; + var props = new AuthenticationProperties { RedirectUri = "/logged-out" }; return Results.SignOut(props, [schemeName]); }).AllowAnonymous(); diff --git a/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs b/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs index cb3aec16..e2cc0e30 100644 --- a/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs +++ b/src/dotnet/Modgud.Domain/Applications/ApplicationSettings.cs @@ -72,6 +72,16 @@ public class ApplicationSettings /// Null = inherit the realm policy. Lets a Consumer App stay email-only /// 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. + /// + public Dictionary? Pages { get; set; } } /// 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 c0fc01c5..a8f111f2 100644 --- a/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs +++ b/src/dotnet/Modgud.Domain/Applications/EffectiveSettings.cs @@ -74,10 +74,12 @@ public sealed record EffectiveSettings Cimd = MergeCimd(realm.Cimd, app.Cimd), RegistrationFields = MergeRegistrationFields(realm.RegistrationFields, app.RegistrationFields), - // Realm-owned sections with no per-App override (operational / GDPR) — passthrough: + // Realm-owned operational / GDPR sections have no per-App override: Deletion = realm.Deletion, Audit = realm.Audit, - Pages = realm.Pages, + + // Page schemas overlay per slot; an absent App key inherits the Realm slot. + Pages = MergePages(realm.Pages, app.Pages), // New per-App facets: SelfRegPosture = app.SelfRegistration?.Posture ?? Applications.SelfRegPosture.JitOnOtp, @@ -177,4 +179,17 @@ public sealed record EffectiveSettings Lastname = app.Lastname ?? r.Lastname, }; } + + private static Dictionary? MergePages( + Dictionary? realm, + Dictionary? app) + { + if (app is null || app.Count == 0) return realm; + + 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; + } } diff --git a/src/dotnet/Modgud.Tests.Unit/Api/Features/Admin/AppSettingsEndpointsTests.cs b/src/dotnet/Modgud.Tests.Unit/Api/Features/Admin/AppSettingsEndpointsTests.cs new file mode 100644 index 00000000..9ae8d04c --- /dev/null +++ b/src/dotnet/Modgud.Tests.Unit/Api/Features/Admin/AppSettingsEndpointsTests.cs @@ -0,0 +1,42 @@ +using Modgud.Api.Features.Admin; + +namespace Modgud.Tests.Unit.Api.Features.Admin; + +public class AppSettingsEndpointsTests +{ + [Theory] + [InlineData("/connect/authorize?client_id=web-client", "web-client")] + [InlineData("/CONNECT/AUTHORIZE?scope=openid&client_id=my%20client", "my client")] + public void ExtractAuthorizeClientId_AcceptsOnlyLocalAuthorizeContinuation( + string returnUrl, + string expected) + { + Assert.Equal(expected, AppSettingsEndpoints.ExtractAuthorizeClientId(returnUrl)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("https://idp.example/connect/authorize?client_id=client")] + [InlineData("//evil.example/connect/authorize?client_id=client")] + [InlineData("/connect/authorize/extra?client_id=client")] + [InlineData("/connect/token?client_id=client")] + [InlineData("/connect/authorize")] + [InlineData("/connect/authorize?scope=openid")] + [InlineData("/connect/authorize?client_id=")] + [InlineData("/connect/authorize?client_id=one&client_id=two")] + [InlineData("/connect/authorize\\?client_id=client")] + [InlineData("/connect/authorize?client_id=client\r\nX-Test:true")] + public void ExtractAuthorizeClientId_RejectsUntrustedOrAmbiguousInput(string? returnUrl) + { + Assert.Null(AppSettingsEndpoints.ExtractAuthorizeClientId(returnUrl)); + } + + [Fact] + public void ExtractAuthorizeClientId_RejectsOversizedInput() + { + var returnUrl = "/connect/authorize?client_id=" + new string('a', 8192); + + Assert.Null(AppSettingsEndpoints.ExtractAuthorizeClientId(returnUrl)); + } +} diff --git a/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs b/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs index f8edf797..c0fdce27 100644 --- a/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs +++ b/src/dotnet/Modgud.Tests.Unit/Applications/EffectiveSettingsTests.cs @@ -256,6 +256,39 @@ public void Registration_fields_override_against_unset_realm_section_uses_record Assert.Equal(FieldRequirement.Required, eff.RegistrationFields.Lastname); // overridden } + [Fact] + public void Pages_are_overlaid_per_slot_and_missing_slots_inherit() + { + var realm = Realm(); + realm.Pages!["password-forgot"] = "realm-forgot"; + var app = new ApplicationSettings + { + Pages = new Dictionary + { + ["login"] = "app-login", + }, + }; + + var eff = EffectiveSettings.Merge(realm, app); + + Assert.Equal("app-login", eff.Pages!["login"]); + Assert.Equal("realm-forgot", eff.Pages["password-forgot"]); + Assert.NotSame(realm.Pages, eff.Pages); + } + + [Fact] + public void Pages_without_a_realm_default_can_be_application_only() + { + var app = new ApplicationSettings + { + Pages = new Dictionary { ["login"] = "app-login" }, + }; + + var eff = EffectiveSettings.Merge(new RealmSettingsDoc(), app); + + Assert.Equal("app-login", eff.Pages!["login"]); + } + [Fact] public void Origin_and_email_branding_pass_through_from_application() { diff --git a/src/frontend-vue/e2e/00-smoke.spec.ts b/src/frontend-vue/e2e/00-smoke.spec.ts index bf0b5157..25e1eb42 100644 --- a/src/frontend-vue/e2e/00-smoke.spec.ts +++ b/src/frontend-vue/e2e/00-smoke.spec.ts @@ -58,7 +58,7 @@ test.describe('§2 Login & sign-out', () => { expect((await me.json()).UserName).toBe(ADMIN_USER) }) - test('sign-out clears the cookie and lands on /login', async ({ page }) => { + test('sign-out clears the cookie and lands on the signed-out page', async ({ page }) => { await apiLogin(page, ADMIN_USER, ADMIN_PASSWORD) await page.goto('/dashboard') @@ -69,7 +69,7 @@ test.describe('§2 Login & sign-out', () => { // selector (/^AD$|admin/i) misfired onto an "Administration" nav element. await page.getByRole('banner').getByTitle(/admin/i).click() await page.getByRole('menuitem', { name: /Abmelden|Sign out|Logout/i }).click() - await page.waitForURL(/\/login/, { timeout: 10_000 }) + await page.waitForURL(/\/logged-out/, { timeout: 10_000 }) // Cookie should be gone. const me = await page.request.get('/api/account/me') diff --git a/src/frontend-vue/package.json b/src/frontend-vue/package.json index 431a7286..890b3691 100644 --- a/src/frontend-vue/package.json +++ b/src/frontend-vue/package.json @@ -15,12 +15,12 @@ }, "dependencies": { "@cocoar/signalarrr": "^4.3.2", - "@cocoar/vue-data-grid": "2.13.0", - "@cocoar/vue-fragment-parser": "2.13.0", - "@cocoar/vue-localization": "2.13.0", - "@cocoar/vue-page-builder": "2.13.0", - "@cocoar/vue-script-editor": "2.13.0", - "@cocoar/vue-ui": "2.13.0", + "@cocoar/vue-data-grid": "2.17.1", + "@cocoar/vue-fragment-parser": "2.17.1", + "@cocoar/vue-localization": "2.17.1", + "@cocoar/vue-page-builder": "2.17.1", + "@cocoar/vue-script-editor": "2.17.1", + "@cocoar/vue-ui": "2.17.1", "monaco-editor": "^0.55.1", "pinia": "^3.0.4", "vue": "^3.5.39", diff --git a/src/frontend-vue/pnpm-lock.yaml b/src/frontend-vue/pnpm-lock.yaml index b7d6a674..549faa98 100644 --- a/src/frontend-vue/pnpm-lock.yaml +++ b/src/frontend-vue/pnpm-lock.yaml @@ -17,23 +17,23 @@ importers: specifier: ^4.3.2 version: 4.3.2 '@cocoar/vue-data-grid': - specifier: 2.13.0 - version: 2.13.0(@cocoar/vue-localization@2.13.0(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.13.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(@js-temporal/polyfill@0.5.1)(vue@3.5.39(typescript@6.0.3)) + specifier: 2.17.1 + version: 2.17.1(@cocoar/vue-localization@2.17.1(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(@js-temporal/polyfill@0.5.1)(vue@3.5.39(typescript@6.0.3)) '@cocoar/vue-fragment-parser': - specifier: 2.13.0 - version: 2.13.0(@cocoar/vue-ui@2.13.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + specifier: 2.17.1 + version: 2.17.1(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) '@cocoar/vue-localization': - specifier: 2.13.0 - version: 2.13.0(vue@3.5.39(typescript@6.0.3)) + specifier: 2.17.1 + version: 2.17.1(vue@3.5.39(typescript@6.0.3)) '@cocoar/vue-page-builder': - specifier: 2.13.0 - version: 2.13.0(@cocoar/vue-ui@2.13.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + specifier: 2.17.1 + version: 2.17.1(@cocoar/vue-localization@2.17.1(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) '@cocoar/vue-script-editor': - specifier: 2.13.0 - version: 2.13.0(monaco-editor@0.55.1)(vue@3.5.39(typescript@6.0.3)) + specifier: 2.17.1 + version: 2.17.1(monaco-editor@0.55.1)(vue@3.5.39(typescript@6.0.3)) '@cocoar/vue-ui': - specifier: 2.13.0 - version: 2.13.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + specifier: 2.17.1 + version: 2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) monaco-editor: specifier: ^0.55.1 version: 0.55.1 @@ -115,18 +115,18 @@ packages: '@cocoar/signalarrr@4.3.2': resolution: {integrity: sha512-j9NpkWW0c0ZHqgAN6euJlOf0K+TJxGbqH0+/+/vIPQJ5PgMHSGmTshiATx2lyMKkWsgjNss9hAglRYUumQbDFw==} - '@cocoar/vue-data-grid@2.13.0': - resolution: {integrity: sha512-Rca7GyHv/QjlJ5vdX35paE3el1g4Uk2fg1G2wGQgGjRkfJURAgtOhSV60ofcIHWeki5x6HiB2E/gMAs+IXWOXQ==} + '@cocoar/vue-data-grid@2.17.1': + resolution: {integrity: sha512-FYznr/24P8Cce6/z4g8EgOW3cCGB4SCCGOpuTRXf0/eiVWb0S/tJgvhGD71Z6l+N8+Yd0b8jT8DX1CrnkZoE8Q==} peerDependencies: - '@cocoar/vue-localization': 2.13.0 - '@cocoar/vue-ui': 2.13.0 + '@cocoar/vue-localization': 2.17.1 + '@cocoar/vue-ui': 2.17.1 '@js-temporal/polyfill': ^0.5.1 vue: ^3.5.0 - '@cocoar/vue-fragment-parser@2.13.0': - resolution: {integrity: sha512-KLI2fgqspTWLWWGTqYhk4HiFAgKzLNXGmxRA52blv/cgEXardNSPjfCeqbUmYq7hc9BB7Gpngw02cuAMHbHFCQ==} + '@cocoar/vue-fragment-parser@2.17.1': + resolution: {integrity: sha512-er/2+5BZS4xSgyQ8p29YSQZMZ0CvhNn/2jAdfldEaBbHjyQ6suu/EciXIP4rAdO8OoBfSTZLP9mnyDOmwq/Hyg==} peerDependencies: - '@cocoar/vue-ui': 2.13.0 + '@cocoar/vue-ui': 2.17.1 vue: ^3.5.0 vue-router: ^4.5.0 || ^5.0.0 peerDependenciesMeta: @@ -135,25 +135,26 @@ packages: vue-router: optional: true - '@cocoar/vue-localization@2.13.0': - resolution: {integrity: sha512-aS/AUbQjdzxWbB6ZfhLTjt8lrk4ZK7OSiEFkseMQzi2a2tsVjDWdFUk7FGafSEFgd5yVGTXP1Bl/sElpTZgDrg==} + '@cocoar/vue-localization@2.17.1': + resolution: {integrity: sha512-uPY7V4Ij0QuXOMRucd2QV9QOUnFuCMGLaFE0R1imE4OM+GqKBTQKgsIGkuB2Xn7PkgN28GL9OS6RVGvIEWOCqg==} peerDependencies: vue: ^3.5.0 - '@cocoar/vue-page-builder@2.13.0': - resolution: {integrity: sha512-gMFCbufZuI4DURibSc2W94UT9O0qXt6mJuhepxDSuUTaGSQxxuGWF/xdo6iPjrr401eyjQYO8GY6Ke6wJpa7eQ==} + '@cocoar/vue-page-builder@2.17.1': + resolution: {integrity: sha512-sOuK1NAPLZeBiwEYTi0+bq6rcoCanv2C8LhHPCSrVMUJcF+XO246QpTZXkc2rmRuIb+pAfE9W3HOF+ugBV5pNw==} peerDependencies: - '@cocoar/vue-ui': 2.13.0 + '@cocoar/vue-localization': 2.17.1 + '@cocoar/vue-ui': 2.17.1 vue: ^3.5.0 - '@cocoar/vue-script-editor@2.13.0': - resolution: {integrity: sha512-fqPVsZr6AJcz+yihaXrsLxbHv9MXxlvUrxbQbWwTllthdebY32z5JNIV3T5G5C11FD7IxoP9OXbdlF5K7ig68A==} + '@cocoar/vue-script-editor@2.17.1': + resolution: {integrity: sha512-untRHzKtOnJpD57IObaE0NOk5cGtvXtjnyxKPflPS9dnfhpaVg2d1UEkj5TLbwaZgFh/Fzb/AkW1po8xIQHaPA==} peerDependencies: monaco-editor: ^0.55.1 vue: ^3.5.0 - '@cocoar/vue-ui@2.13.0': - resolution: {integrity: sha512-o9+LG1Df4R0+4peqkFZuS66HsoOcRhGKjU5A8K74T5qAQhei7SmgEkrTUtrlPCG7qjlWX0oWFCZvnq2/J2tKcA==} + '@cocoar/vue-ui@2.17.1': + resolution: {integrity: sha512-VYOoYWTKjDfvU0cS7GtvYbXXFSutFAZLswQq4SBIwsqwytKk7iTtCgCT5+XBbnmSIpxoiPTO7nMxpJuouMppMQ==} peerDependencies: vue: ^3.5.0 vue-router: ^4.5.0 || ^5.0.0 @@ -1097,40 +1098,42 @@ snapshots: - encoding - utf-8-validate - '@cocoar/vue-data-grid@2.13.0(@cocoar/vue-localization@2.13.0(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.13.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(@js-temporal/polyfill@0.5.1)(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-data-grid@2.17.1(@cocoar/vue-localization@2.17.1(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(@js-temporal/polyfill@0.5.1)(vue@3.5.39(typescript@6.0.3))': dependencies: - '@cocoar/vue-localization': 2.13.0(vue@3.5.39(typescript@6.0.3)) - '@cocoar/vue-ui': 2.13.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-localization': 2.17.1(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-ui': 2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) '@js-temporal/polyfill': 0.5.1 ag-grid-community: 35.0.0 ag-grid-vue3: 35.0.0(vue@3.5.39(typescript@6.0.3)) vue: 3.5.39(typescript@6.0.3) - '@cocoar/vue-fragment-parser@2.13.0(@cocoar/vue-ui@2.13.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-fragment-parser@2.17.1(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': dependencies: path-to-regexp: 8.4.2 vue: 3.5.39(typescript@6.0.3) optionalDependencies: - '@cocoar/vue-ui': 2.13.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-ui': 2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) vue-router: 5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) - '@cocoar/vue-localization@2.13.0(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-localization@2.17.1(vue@3.5.39(typescript@6.0.3))': dependencies: vue: 3.5.39(typescript@6.0.3) - '@cocoar/vue-page-builder@2.13.0(@cocoar/vue-ui@2.13.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-page-builder@2.17.1(@cocoar/vue-localization@2.17.1(vue@3.5.39(typescript@6.0.3)))(@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': dependencies: - '@cocoar/vue-ui': 2.13.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-localization': 2.17.1(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-ui': 2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3)) + '@js-temporal/polyfill': 0.5.1 vue: 3.5.39(typescript@6.0.3) - '@cocoar/vue-script-editor@2.13.0(monaco-editor@0.55.1)(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-script-editor@2.17.1(monaco-editor@0.55.1)(vue@3.5.39(typescript@6.0.3))': dependencies: monaco-editor: 0.55.1 vue: 3.5.39(typescript@6.0.3) - '@cocoar/vue-ui@2.13.0(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': + '@cocoar/vue-ui@2.17.1(vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)))(vite@8.1.2(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)))(vue@3.5.39(typescript@6.0.3))': dependencies: - '@cocoar/vue-localization': 2.13.0(vue@3.5.39(typescript@6.0.3)) + '@cocoar/vue-localization': 2.17.1(vue@3.5.39(typescript@6.0.3)) '@fontsource/cascadia-code': 5.2.3 '@fontsource/inter': 5.2.8 '@fontsource/poppins': 5.2.7 diff --git a/src/frontend-vue/src/assets/styles/main.css b/src/frontend-vue/src/assets/styles/main.css index 78a5459c..09f41427 100644 --- a/src/frontend-vue/src/assets/styles/main.css +++ b/src/frontend-vue/src/assets/styles/main.css @@ -1,11 +1,6 @@ @import "tailwindcss" layer(tailwind); @import "@cocoar/vue-ui/styles"; @import "@cocoar/vue-data-grid/styles"; -/* @cocoar/vue-page-builder 2.1.0 ships dist/index.css but its package.json - * exports map only exposes `.` (JS only) — no `./styles` subpath. We add - * an alias in vite.config.ts that resolves "/styles" to the dist file so - * this import looks canonical. Upstream fix captured in - * the maintainers' upstream-feature-requests note. */ @import "@cocoar/vue-page-builder/styles"; :root { diff --git a/src/frontend-vue/src/components/page-builder/BrandHeaderElement.vue b/src/frontend-vue/src/components/page-builder/BrandHeaderElement.vue new file mode 100644 index 00000000..41994e27 --- /dev/null +++ b/src/frontend-vue/src/components/page-builder/BrandHeaderElement.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/src/frontend-vue/src/components/page-builder/BrandHeaderPreview.vue b/src/frontend-vue/src/components/page-builder/BrandHeaderPreview.vue new file mode 100644 index 00000000..dcbae634 --- /dev/null +++ b/src/frontend-vue/src/components/page-builder/BrandHeaderPreview.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/frontend-vue/src/components/page-builder/ExternalLoginsElement.vue b/src/frontend-vue/src/components/page-builder/ExternalLoginsElement.vue new file mode 100644 index 00000000..05dc3f5f --- /dev/null +++ b/src/frontend-vue/src/components/page-builder/ExternalLoginsElement.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/frontend-vue/src/components/page-builder/ExternalLoginsPreview.vue b/src/frontend-vue/src/components/page-builder/ExternalLoginsPreview.vue new file mode 100644 index 00000000..8c10d781 --- /dev/null +++ b/src/frontend-vue/src/components/page-builder/ExternalLoginsPreview.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/frontend-vue/src/page-builder/authPageConfig.ts b/src/frontend-vue/src/page-builder/authPageConfig.ts new file mode 100644 index 00000000..a39d13a9 --- /dev/null +++ b/src/frontend-vue/src/page-builder/authPageConfig.ts @@ -0,0 +1,161 @@ +import { + definePageElement, + type EmptyProps, + type PageConfig, + type PageFieldSpec, + type PageNode, +} from '@cocoar/vue-page-builder' +import BrandHeaderElement from '@/components/page-builder/BrandHeaderElement.vue' +import BrandHeaderPreview from '@/components/page-builder/BrandHeaderPreview.vue' +import ExternalLoginsElement from '@/components/page-builder/ExternalLoginsElement.vue' +import ExternalLoginsPreview from '@/components/page-builder/ExternalLoginsPreview.vue' + +export type AuthPageSlot = 'login' | 'logout' | 'password-forgot' + +export const AUTH_PAGE_SLOTS: AuthPageSlot[] = ['login', 'logout', 'password-forgot'] + +const actionsBySlot: Record = { + login: [ + { id: 'auth:login', label: 'Sign in' }, + { id: 'auth:passkey', label: 'Sign in with passkey' }, + { id: 'auth:magic-link', label: 'Open magic-link login' }, + { id: 'auth:forgot-password', label: 'Forgot password' }, + { id: 'auth:register', label: 'Create account' }, + ], + logout: [ + { id: 'auth:back-to-login', label: 'Sign in again' }, + ], + 'password-forgot': [ + { id: 'auth:send-reset-link', label: 'Send reset link' }, + { id: 'auth:back-to-login', label: 'Back to login' }, + ], +} + +const fieldsBySlot: Record = { + login: [ + { name: 'username', valueType: 'string', label: 'Username', required: true, defaultElement: 'text-input' }, + { name: 'password', valueType: 'string', label: 'Password', required: true, defaultElement: 'password-input' }, + { name: 'rememberMe', valueType: 'boolean', label: 'Stay signed in', defaultElement: 'checkbox' }, + ], + logout: [], + 'password-forgot': [ + { name: 'username', valueType: 'string', label: 'Username or email', required: true, defaultElement: 'text-input' }, + ], +} + +const modgudElements = { + 'modgud-brand-header': definePageElement({ + renderer: BrandHeaderElement, + builder: { + label: { key: 'modgud.pageBuilder.brandHeader', fallback: 'Application branding' }, + icon: 'image', + defaults: () => ({}), + preview: BrandHeaderPreview, + }, + }), + 'modgud-external-logins': definePageElement({ + renderer: ExternalLoginsElement, + builder: { + label: { key: 'modgud.pageBuilder.externalLogins', fallback: 'External login providers' }, + icon: 'log-in', + defaults: () => ({}), + preview: ExternalLoginsPreview, + }, + }), +} + +export function createAuthPageConfig( + slot: AuthPageSlot, + pickAsset?: (currentId?: string) => Promise, +): PageConfig { + const authElements = slot === 'login' + ? ['modgud-brand-header', 'modgud-external-logins'] + : ['modgud-brand-header'] + return { + allowedElements: [ + 'stack', 'card', 'section', 'divider', 'spacer', + 'heading', 'paragraph', 'note', + 'text-input', 'password-input', 'checkbox', 'button', 'link', 'image', + ...authElements, + ], + elements: modgudElements, + fields: fieldsBySlot[slot], + allowCustomFields: false, + availableActions: actionsBySlot[slot], + assetResolver: (id: string) => `/api/assets/${encodeURIComponent(id)}`, + pickAsset, + } +} + +export function createDefaultAuthPageSchema(slot: AuthPageSlot): PageNode { + if (slot === 'password-forgot') return forgotPasswordSchema() + if (slot === 'logout') return logoutSchema() + return loginSchema() +} + +function loginSchema(): PageNode { + return { + id: 'login-page', + type: 'page', + schemaVersion: 2, + enterSubmits: true, + style: { minHeight: '100vh', justify: 'center', align: 'center', gap: '24px', padding: '24px' }, + children: [ + { id: 'login-brand', type: 'modgud-brand-header', props: {} }, + { + id: 'login-card', type: 'card', props: {}, + style: { size: 'fixed', width: 'min(400px, calc(100vw - 48px))', gap: '16px', padding: '24px' }, + children: [ + { id: 'login-title', type: 'heading', props: { text: 'Sign in to continue', level: 2 } }, + { id: 'login-username', type: 'text-input', name: 'username', props: { label: 'Username', placeholder: 'Username' }, validation: { required: true } }, + { id: 'login-password', type: 'password-input', name: 'password', props: { label: 'Password', placeholder: 'Password' }, validation: { required: true } }, + { id: 'login-remember', type: 'checkbox', name: 'rememberMe', defaultValue: false, props: { label: 'Stay signed in' } }, + { id: 'login-submit', type: 'button', props: { label: 'Sign in', action: 'auth:login', validates: true, default: true }, style: { size: 'fill' } }, + { id: 'login-passkey', type: 'button', props: { label: 'Sign in with passkey', action: 'auth:passkey', variant: 'secondary' }, style: { size: 'fill' } }, + { id: 'login-providers', type: 'modgud-external-logins', props: {} }, + { id: 'login-forgot', type: 'link', props: { label: 'Forgot password?', action: 'auth:forgot-password' }, style: { alignSelf: 'center' } }, + ], + }, + ], + } as PageNode +} + +function forgotPasswordSchema(): PageNode { + return { + id: 'forgot-page', type: 'page', schemaVersion: 2, enterSubmits: true, + style: { minHeight: '100vh', justify: 'center', align: 'center', gap: '24px', padding: '24px' }, + children: [ + { id: 'forgot-brand', type: 'modgud-brand-header', props: {} }, + { + id: 'forgot-card', type: 'card', props: {}, + style: { size: 'fixed', width: 'min(400px, calc(100vw - 48px))', gap: '16px', padding: '24px' }, + children: [ + { id: 'forgot-title', type: 'heading', props: { text: 'Reset password', level: 2 } }, + { id: 'forgot-copy', type: 'paragraph', props: { text: 'Enter your username or email address. We will send you a reset link.' } }, + { id: 'forgot-username', type: 'text-input', name: 'username', props: { label: 'Username or email' }, validation: { required: true } }, + { id: 'forgot-submit', type: 'button', props: { label: 'Send reset link', action: 'auth:send-reset-link', validates: true, default: true }, style: { size: 'fill' } }, + { id: 'forgot-back', type: 'link', props: { label: 'Back to login', action: 'auth:back-to-login' }, style: { alignSelf: 'center' } }, + ], + }, + ], + } as PageNode +} + +function logoutSchema(): PageNode { + return { + id: 'logout-page', type: 'page', schemaVersion: 2, + style: { minHeight: '100vh', justify: 'center', align: 'center', gap: '24px', padding: '24px' }, + children: [ + { id: 'logout-brand', type: 'modgud-brand-header', props: {} }, + { + id: 'logout-card', type: 'card', props: {}, + style: { size: 'fixed', width: 'min(400px, calc(100vw - 48px))', gap: '16px', padding: '24px' }, + children: [ + { id: 'logout-title', type: 'heading', props: { text: 'Signed out', level: 2 } }, + { id: 'logout-copy', type: 'paragraph', props: { text: 'Your session has ended safely.' } }, + { id: 'logout-login', type: 'button', props: { label: 'Sign in again', action: 'auth:back-to-login' }, style: { size: 'fill' } }, + ], + }, + ], + } as PageNode +} diff --git a/src/frontend-vue/src/page-builder/loginPageRuntime.ts b/src/frontend-vue/src/page-builder/loginPageRuntime.ts new file mode 100644 index 00000000..5fb532a3 --- /dev/null +++ b/src/frontend-vue/src/page-builder/loginPageRuntime.ts @@ -0,0 +1,28 @@ +import type { ComputedRef, InjectionKey, Ref } from 'vue' +import { inject } from 'vue' +import type { BrandingConfig } from '@/stores/appconfig.store' + +export interface ExternalLoginDto { + Id: string + Kind: string + Slug: string + DisplayName: string + Flavor: string + IconName?: string | null + ButtonColorHex?: string | null +} + +export interface LoginPageRuntimeContext { + branding: ComputedRef + externalLogins: Ref + startExternalLogin: (provider: ExternalLoginDto) => void +} + +export const LOGIN_PAGE_RUNTIME_KEY: InjectionKey + = Symbol.for('modgud.login-page-runtime') as InjectionKey + +export function useLoginPageRuntime(): LoginPageRuntimeContext { + const context = inject(LOGIN_PAGE_RUNTIME_KEY) + if (!context) throw new Error('Login PageBuilder element rendered outside LoginView.') + return context +} diff --git a/src/frontend-vue/src/router/index.ts b/src/frontend-vue/src/router/index.ts index ca7da43e..43b786ef 100644 --- a/src/frontend-vue/src/router/index.ts +++ b/src/frontend-vue/src/router/index.ts @@ -19,6 +19,13 @@ const pageBuilderFeatureGate: NavigationGuard = () => { return true } +const authPageSlotGate: NavigationGuard = (to) => { + const slug = typeof to.params.slug === 'string' ? to.params.slug : '' + return ['login', 'logout', 'password-forgot'].includes(slug) + ? true + : { path: '/platform/customization/pages', replace: true } +} + /** * Per-modal sizes for routedFragments. The Admin UI is desktop-only — * each modal gets a size tailored to its own content density (Scope @@ -151,6 +158,11 @@ const routes = [ component: () => import('@/views/auth/ForgotPasswordView.vue'), meta: { public: true }, }, + { + path: '/logged-out', + component: () => import('@/views/auth/LoggedOutView.vue'), + meta: { public: true }, + }, { path: '/register', component: () => import('@/views/auth/RegisterView.vue'), @@ -455,7 +467,7 @@ const routes = [ { path: 'customization/pages/:slug', component: () => import('@/views/admin/customization/PageEditorView.vue'), - beforeEnter: pageBuilderFeatureGate, + beforeEnter: [pageBuilderFeatureGate, authPageSlotGate], }, { path: 'customization/assets', diff --git a/src/frontend-vue/src/stores/appconfig.store.ts b/src/frontend-vue/src/stores/appconfig.store.ts index a0399e94..e26e327e 100644 --- a/src/frontend-vue/src/stores/appconfig.store.ts +++ b/src/frontend-vue/src/stores/appconfig.store.ts @@ -47,6 +47,8 @@ export interface AppConfig { Branding: BrandingConfig Features: FeatureFlags RegistrationFields: RegistrationFieldsConfig + /** Effective PageBuilder schemas for the current Host/OAuth client context. */ + Pages: Record } const defaults: AppConfig = { @@ -70,6 +72,7 @@ const defaults: AppConfig = { Firstname: 'Optional', Lastname: 'Optional', }, + Pages: {}, } /** @@ -106,10 +109,12 @@ export const useAppConfigStore = defineStore('appConfig', () => { const config = ref({ ...defaults }) const loaded = ref(false) - async function load() { - if (loaded.value) return + async function fetchConfig(returnUrl?: string) { try { - const result = await http.get() + const request = returnUrl + ? http.setQueryParameter('returnUrl', returnUrl) + : http + const result = await request.get() if (result) { config.value = { ...defaults, @@ -117,6 +122,7 @@ export const useAppConfigStore = defineStore('appConfig', () => { Branding: { ...defaults.Branding, ...(result.Branding ?? {}) }, Features: { ...defaults.Features, ...(result.Features ?? {}) }, RegistrationFields: { ...defaults.RegistrationFields, ...(result.RegistrationFields ?? {}) }, + Pages: { ...(result.Pages ?? {}) }, } applyBranding(config.value.Branding) } @@ -124,5 +130,15 @@ export const useAppConfigStore = defineStore('appConfig', () => { finally { loaded.value = true } } - return { config, loaded, load } + async function load() { + if (loaded.value) return + await fetchConfig() + } + + /** Refresh presentation for a local /connect/authorize continuation. */ + async function loadForLogin(returnUrl: string) { + await fetchConfig(returnUrl) + } + + return { config, loaded, load, loadForLogin } }) diff --git a/src/frontend-vue/src/stores/auth.store.ts b/src/frontend-vue/src/stores/auth.store.ts index f8d33005..fa318a06 100644 --- a/src/frontend-vue/src/stores/auth.store.ts +++ b/src/frontend-vue/src/stores/auth.store.ts @@ -189,7 +189,7 @@ export const useAuthStore = defineStore('auth', () => { async function logout(endIdpSession: boolean = true): Promise { const response = await http.addPath('logout').post<{ Message: string; ExternalLogoutUrl?: string | null }>({ EndIdpSession: endIdpSession }) user.value = null - const target = response?.ExternalLogoutUrl ?? '/login' + const target = response?.ExternalLogoutUrl ?? '/logged-out' window.location.assign(target) } diff --git a/src/frontend-vue/src/views/admin/InboxSettingsView.vue b/src/frontend-vue/src/views/admin/InboxSettingsView.vue index 11de6ebd..c6ba818a 100644 --- a/src/frontend-vue/src/views/admin/InboxSettingsView.vue +++ b/src/frontend-vue/src/views/admin/InboxSettingsView.vue @@ -2,7 +2,7 @@ import { onMounted, ref, watch } from 'vue' import { useI18n } from '@cocoar/vue-localization' import { - CoarCard, CoarTextInput, CoarFormField, CoarButton, CoarIcon, + CoarCard, CoarNumberInput, CoarFormField, CoarButton, CoarIcon, } from '@cocoar/vue-ui' import { useUI } from '@/composables/useUI' import { useInboxSettingsStore } from '@/stores/inboxSettings.store' @@ -35,13 +35,8 @@ onMounted(async () => { */ function numModel(getter: () => number | null, setter: (v: number | null) => void) { return { - get: () => (getter() == null ? '' : String(getter())), - set: (v: string) => { - const trimmed = v.trim() - if (trimmed === '') { setter(null); return } - const n = Number(trimmed) - setter(Number.isFinite(n) ? n : null) - }, + get: () => getter(), + set: (v: number | null) => setter(v), } } @@ -82,8 +77,7 @@ async function save() {

-
- -
- -
- +

{{ error }}

diff --git a/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue index ac9834b0..05982611 100644 --- a/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue +++ b/src/frontend-vue/src/views/admin/apps/AppSettingsSections.vue @@ -1,13 +1,14 @@
+ + +
+ + {{ t('admin.appSettings.pages.saveFirst', {}, 'Save the application first, then you can give its authentication pages their own layout.') }} + + +
diff --git a/src/frontend-vue/src/views/admin/customization/PageEditorView.vue b/src/frontend-vue/src/views/admin/customization/PageEditorView.vue index 8095ea5a..689e1cd6 100644 --- a/src/frontend-vue/src/views/admin/customization/PageEditorView.vue +++ b/src/frontend-vue/src/views/admin/customization/PageEditorView.vue @@ -1,16 +1,18 @@