diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b3bea0..12072f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,207 +2,12 @@
All notable changes to this service will be documented in this file.
-## [2.0.0]
-### Notes
-- FlexForms (Forms Engine SaaS)
-
----------------------------------------------
-## [1.0.0]
-### Notes
-- First formally versioned public beta release.
-
-## [1.0.1]
-### Notes
-- Added Client Side AppInsights SDK.
-
-## [1.1.0]
-### Notes
-- Added Support for Multi-Tenancy. This service is now a Tenant of EAT API.
-
-## [1.2.0]
-### Notes
-- As part of the Multi-Tenancy, we have now converted EAT Web to a single repository deployed to multiple services
-- Each service will have it's own set of appsettings.json files, and it is decided at the time of the deployment which one is deployed to the container.
-
-## [1.2.1]
-### Notes
-- Updated LSRP Test env appsettings with an update DSI auth details and Front-Door URL.
-
-## [1.2.2]
-### Notes
-- Improved Event-Mapping to support multi handlers when an application is submitted.
-
-## [1.2.3]
-### Notes
-- Added postcode to the Academies Auto-Complete confirmation page.
-- Fixed a bug in Auto-Complete where duplicate items couldn't be selected.
-
-## [1.2.4]
-### Notes
-- Enabled Test Auth in LSRP Test Environment.
-
-## [1.2.5]
-### Notes
-- Updated Collection Flows to save user changes on each click of Save and Continue
-
-## [1.2.6]
-### Notes
-- Fixed Collection Flow validation and conditional logic issues
-
-## [1.2.8]
-### Notes
-- Added a flag to the template's Task node to support moving of the Task Summary to the end of the Task journey.
-
-## [1.2.9]
-### Notes
-- Added support for checkboxes field
-
-## [1.2.10]
-### Notes
-- Added support custom application names per tenant.
-
-## [1.2.11]
-### Notes
-- Added max-words property to character count field.
-
-## [1.2.12]
-### Notes
-- Added root level template property "hideFieldLabelWhenOnlyOneField" to toggle labels hiding for single field pages.
-
-## [1.2.13]
-### Notes
-- Replaced hardcoded values with Layout:ServiceName
-
-## [1.3.0]
-### Notes
-- Upgraded to .NET10
-
-## [1.3.1]
-### Notes
-- Fixed issue with backlink not always returning to task summary
-
-## [1.3.2]
-### Notes
-- Fixed wrong message showing when a new item has been added to a collection
-
-## [1.3.3]
-### Notes
-- Reduced complexity of collection item added and updated messages
-
-## [1.3.4]
-### Notes
-- Updated appsettings to include LSRP Prod environment details
-
-## [1.3.5]
-### Notes
-- Fixed issue with CSS on flow descriptions
-
-## [1.3.6]
-### Notes
-- Fixed issue with derived collection not being saved when edited
-## [1.3.7]
-### Notes
-- Increased request size for Json template saving logic.
-
-## [1.3.8]
-### Notes
-- Added support for EntraSSO authentication scheme.
-
-## [1.3.9]
-### Notes
-- Fixed character count not counting characters properly on validation.
-
-## [1.3.10]
-### Notes
-- Set Entra SSO enabled to false
-
-## [1.3.11]
-### Notes
-- Added site-wide notification banner and feature flag
-
-## [1.3.12]
-### Notes
-- Make email address configurable on invite a contributor page
-
-## [1.3.13]
-### Notes
-- Fix Vision page executive summary word count bug
-
-## [1.3.14]
-### Notes
-- Remove the word "form" from the plan version label
-
-## [1.3.15]
-### Notes
-- Make the lead applicant label configurable in form header
-
-## [1.3.16]
-### Notes
-- Added SignedOutCallBackUri to the DSI config
-
-## [1.3.17]
-### Notes
-- Use a feature flag to disable submitting an application
-
-## [1.3.18]
-### Notes
-- Added pagination to the dashboard
-
-## [1.3.19]
-### Notes
-- Added Test env appsettings
-- Updated pagination default page size to show 50 applications per page
-
-## [1.3.20]
-### Notes
-- Added Test env appsettings
-
-## [1.3.21]
-### Notes
-- Improved Logout functionality
-- Improved caching and API error handling
-
-## [1.3.22]
-### Notes
-- Allow contributor pattern to be disabled from the Template
-
-## [1.3.23]
-### Notes
-- Added Prod env appsettings
-
-## [1.3.24]
-### Notes
-- Improved caching
-
-## [1.3.25]
-### Notes
-- Added LA and Diocese ComplexFields and details to the Auto-Complete search results and confirmation page.
-
-## [1.3.26]
-### Notes
-- Added Academy filtering feature flag on the endpoint
-
-## [1.3.27]
-### Notes
-- Added application search functionality
-
-## [1.3.28]
-### Notes
-- Updated RGVisits service name
-
-## [1.3.29]
-### Notes
-- Created read-only dashboard for listing all applications
-## [1.3.30]
+## [2.1.0]
### Notes
-- UCD changes for disabling the submit buuton
+- Refactored to follow Clean Architecture principles
-## [1.4.0]
-### Notes
-- Added feature for overriding the application statuses
-
-## [1.4.1]
+## [2.0.0]
### Notes
-- Accessibility issue with override custom application status form fixed
\ No newline at end of file
+- FlexForms (Forms Engine SaaS)
\ No newline at end of file
diff --git a/GovUK.Dfe.FlexForms.Web.sln b/GovUK.Dfe.FlexForms.Web.sln
index 086a5f3..89054c9 100644
--- a/GovUK.Dfe.FlexForms.Web.sln
+++ b/GovUK.Dfe.FlexForms.Web.sln
@@ -16,6 +16,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Web.Uni
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Infrastructure.UnitTests", "src\Tests\GovUK.Dfe.FlexForms.Infrastructure.UnitTests\GovUK.Dfe.FlexForms.Infrastructure.UnitTests.csproj", "{FB4D1E39-01AB-47D4-8394-270993A56B0D}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Application.Tests", "src\Tests\GovUK.Dfe.FlexForms.Application.Tests\GovUK.Dfe.FlexForms.Application.Tests.csproj", "{B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GovUK.Dfe.FlexForms.Domain.Tests", "src\Tests\GovUK.Dfe.FlexForms.Domain.Tests\GovUK.Dfe.FlexForms.Domain.Tests.csproj", "{E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -46,6 +50,14 @@ Global
{FB4D1E39-01AB-47D4-8394-270993A56B0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FB4D1E39-01AB-47D4-8394-270993A56B0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FB4D1E39-01AB-47D4-8394-270993A56B0D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -53,6 +65,8 @@ Global
GlobalSection(NestedProjects) = preSolution
{A5568E05-5568-49E3-BF8A-08EA7AB74960} = {F62DE500-90E9-431D-B84C-8DF4CB166F54}
{FB4D1E39-01AB-47D4-8394-270993A56B0D} = {F62DE500-90E9-431D-B84C-8DF4CB166F54}
+ {B3E91C47-8A2F-4D16-9C55-7E1A0F8D3B24} = {F62DE500-90E9-431D-B84C-8DF4CB166F54}
+ {E6C12A90-4B7D-4F18-9A33-2C8D1E5F0471} = {F62DE500-90E9-431D-B84C-8DF4CB166F54}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F20FD7F1-3208-45E6-B45D-AF2EBDF28903}
diff --git a/README.md b/README.md
index 370542c..90e35ff 100644
--- a/README.md
+++ b/README.md
@@ -24,12 +24,43 @@ Template authoring guide: [`docs/Form-Template-Designer-Manual.md`](docs/Form-Te
## Architecture overview
-| Layer | Project | Purpose |
-|-------|---------|---------|
-| Web | `GovUK.Dfe.FlexForms.Web` | Razor Pages, middleware, auth overlays, admin |
-| Application | `GovUK.Dfe.FlexForms.Application` | Interfaces; references `GovUK.Dfe.FlexForms.Api.Client` |
-| Domain | `GovUK.Dfe.FlexForms.Domain` | `FormTemplate` models, conditional logic, complex fields |
-| Infrastructure | `GovUK.Dfe.FlexForms.Infrastructure` | API template store, form services, MassTransit consumers |
+This repository follows **Clean Architecture**. Dependencies point inward: Domain has no external dependencies, Application depends only on Domain, and Infrastructure implements Application ports. The Web layer is a thin composition root and UI host.
+
+| Layer | Project | Depends on | Purpose |
+|-------|---------|------------|---------|
+| **Domain** | `GovUK.Dfe.FlexForms.Domain` | — | `FormTemplate` models, `FormRouteParser`, `FormStepPolicy`, `CheckboxValueNormalizer` |
+| **Application** | `GovUK.Dfe.FlexForms.Application` | Domain | Use-case services, port interfaces, work-state bags, outcome types, `AdminApiErrorMapper` |
+| **Infrastructure** | `GovUK.Dfe.FlexForms.Infrastructure` | Application | Adapter implementations (API clients, session stores, Redis, MassTransit consumers) |
+| **Web** | `GovUK.Dfe.FlexForms.Web` | Application, Infrastructure | Razor Pages (thin PageModels), middleware, auth, DI composition root |
+
+### Dependency rule
+
+```mermaid
+flowchart LR
+ Domain["Domain"]
+ Application["Application"]
+ Infrastructure["Infrastructure"]
+ Web["Web
(composition root)"]
+
+ Web --> Application
+ Web --> Infrastructure
+ Infrastructure --> Application
+ Application --> Domain
+
+ style Domain fill:#e8f5e9,stroke:#2e7d32
+ style Application fill:#e3f2fd,stroke:#1565c0
+ style Infrastructure fill:#fff3e0,stroke:#e65100
+ style Web fill:#fce4ec,stroke:#c62828
+```
+
+These boundaries are enforced at build time by **NetArchTest guard tests** (`Architecture/CleanArchitectureGuardTests.cs`):
+
+- PageModels must not reference `GovUK.Dfe.FlexForms.Infrastructure`
+- Application must not reference Infrastructure or Web
+- Application must not take `ISession`, `ModelStateDictionary`, or `HttpContext`
+- Domain must not reference any outer layer
+
+### System context
```mermaid
flowchart TB
@@ -219,6 +250,254 @@ See `Security/AdminAccessHelper.cs`.
---
+## Clean Architecture pattern (use cases)
+
+Every page in the application follows the same pattern. Business logic lives in the **Application** layer as a use-case service. The **PageModel** is a thin dispatcher that binds HTTP, calls the use case, and maps the result to `Page()` / `Redirect()` / `File()`.
+
+### Pattern overview (how a request flows through the layers)
+
+```mermaid
+sequenceDiagram
+ participant Browser
+ participant PM as PageModel
(Web)
+ participant UC as Use-Case Service
(Application)
+ participant API as API Client
(Infrastructure)
+
+ Browser->>PM: HTTP GET / POST
+ PM->>PM: CaptureWorkState()
+ PM->>UC: service.ExecuteAsync(workState, ...)
+ UC->>API: API client call
+ API-->>UC: DTO response
+ UC->>UC: Validate, map, set workState fields
+ UC-->>PM: AdminPageOutcome / FormEngineOutcome
+ PM->>PM: ApplyWorkState(state)
+ PM->>PM: MapOutcome → Page() / Redirect() / File()
+ PM-->>Browser: HTML / redirect
+```
+
+### What lives where
+
+```mermaid
+flowchart TB
+ subgraph Web ["Web Layer (Razor Pages)"]
+ PM["PageModel"]
+ TD["TempData / Session"]
+ Auth["Authorization attributes"]
+ Cache["Local cache invalidation"]
+ Bind["BindProperty / ModelState"]
+ end
+
+ subgraph App ["Application Layer"]
+ IF["Interface
(e.g. ITenantSettingsAdmin)"]
+ SVC["Service
(e.g. TenantSettingsAdminService)"]
+ WS["WorkState bag
(e.g. TenantSettingsWorkState)"]
+ OC["Outcome
(AdminPageOutcome /
FormEngineOutcome)"]
+ MSG["Messages class
(user-facing copy)"]
+ ERR["AdminApiErrorMapper"]
+ end
+
+ subgraph Infra ["Infrastructure Layer"]
+ IMPL["Adapter implementations
(API stores, Redis, session)"]
+ end
+
+ subgraph Dom ["Domain Layer"]
+ MOD["FormTemplate, Task, Page, Field"]
+ POL["FormRouteParser, FormStepPolicy"]
+ NORM["CheckboxValueNormalizer"]
+ end
+
+ PM --> IF
+ IF -.->|implemented by| SVC
+ SVC --> WS
+ SVC --> OC
+ SVC --> MSG
+ SVC --> ERR
+ SVC -.->|calls| IMPL
+ IMPL -.->|implements ports in| App
+ SVC --> MOD
+ SVC --> POL
+
+ style Web fill:#fce4ec,stroke:#c62828
+ style App fill:#e3f2fd,stroke:#1565c0
+ style Infra fill:#fff3e0,stroke:#e65100
+ style Dom fill:#e8f5e9,stroke:#2e7d32
+```
+
+### The four artefacts per feature
+
+Every feature (Admin page, form engine handler, dashboard) produces up to four files in `Application/`:
+
+| Artefact | Example | Purpose |
+|----------|---------|---------|
+| **Interface** | `ITenantSettingsAdmin` | Port the PageModel depends on |
+| **Service** | `TenantSettingsAdminService` | Implements the interface; calls API clients, applies business rules |
+| **WorkState** | `TenantSettingsWorkState` | Mutable bag of view-state. PageModel populates it before the call (`CaptureWorkState`), the service mutates it, PageModel reads it back (`ApplyWorkState`) |
+| **Messages** | `TenantSettingsMessages` | `const string` user-facing copy (error/success text). Keeps strings identical to the original PageModel for backward compatibility |
+
+Shared helpers:
+
+| Helper | Location | Purpose |
+|--------|----------|---------|
+| `AdminPageOutcome` | `Application/Admin/` | HTTP-agnostic result: `Stay`, `Redirect`, or `File` with optional success/error messages and cache-refresh flag |
+| `FormEngineOutcome` | `Application/FormEngine/` | Same idea for form engine: redirect URL, validation errors, file downloads, notification context |
+| `AdminApiErrorMapper` | `Application/Admin/` | Maps `ExternalApplicationsException` to user-friendly messages; optional WAF/gateway hint |
+
+### What stays on the PageModel
+
+The PageModel remains responsible for HTTP concerns that cannot cross into Application:
+
+- `[Authorize]` policies and `[BindProperty]` attributes
+- `TempData` read/write (PRG pattern)
+- Tenant resolution (`ITenantRequestContext`)
+- Local cache invalidation (`ITenantConfigurationCache`, `ITenantIdResolver`)
+- `ModelState` manipulation and `Page()` / `RedirectToPage()` / `File()` return
+- Session reads for presentation (e.g. `FormSessionKeys`)
+- `HttpContext.User` claims extraction (passed as values into the use case)
+
+### PageModel lifecycle (step by step)
+
+```csharp
+// 1. Capture current state into a work-state bag
+var state = CaptureWorkState();
+
+// 2. Call the Application use case
+var outcome = await tenantSettingsAdmin.UpdateAsync(
+ state, category, target, settingsJson, isSecret, cancellationToken);
+
+// 3. Copy mutated state back to PageModel properties
+ApplyWorkState(state);
+
+// 4. Map the outcome to an HTTP result
+return MapOutcome(outcome);
+```
+
+### Concrete example: Tenant Settings
+
+```mermaid
+flowchart LR
+ subgraph Web
+ TSM["TenantSettingsModel
(PageModel, 250 lines)"]
+ end
+
+ subgraph Application
+ ITSA["ITenantSettingsAdmin"]
+ TSA["TenantSettingsAdminService"]
+ TSWS["TenantSettingsWorkState"]
+ APO["AdminPageOutcome"]
+ TSMsg["TenantSettingsMessages"]
+ end
+
+ subgraph Infrastructure
+ TAC["ITenantAdminClient
(API client)"]
+ end
+
+ TSM -->|depends on| ITSA
+ ITSA -.->|implemented by| TSA
+ TSA -->|mutates| TSWS
+ TSA -->|returns| APO
+ TSA -->|uses copy from| TSMsg
+ TSA -->|calls| TAC
+
+ style Web fill:#fce4ec,stroke:#c62828
+ style Application fill:#e3f2fd,stroke:#1565c0
+ style Infrastructure fill:#fff3e0,stroke:#e65100
+```
+
+### Project folder structure
+
+```
+src/
+├── GovUK.Dfe.FlexForms.Domain/
+│ ├── Models/ # FormTemplate, Task, Page, Field, ...
+│ └── FormEngine/ # FormRouteParser, FormStepPolicy, CheckboxValueNormalizer
+│
+├── GovUK.Dfe.FlexForms.Application/
+│ ├── Interfaces/ # Ports: IFormSessionStore, IApplicationResponseService, ...
+│ ├── Admin/ # Admin use cases (one interface + service + workstate + messages per page)
+│ │ ├── ITenantSettingsAdmin + TenantSettingsAdminService
+│ │ ├── IUserManagerAdmin + UserManagerAdminService
+│ │ ├── IRoleManagerAdmin + RoleManagerAdminService
+│ │ ├── IDuplicateTenantAdmin + DuplicateTenantAdminService
+│ │ ├── IOrganisationSettingsAdmin + OrganisationSettingsAdminService
+│ │ ├── IAdminHome + AdminHomeService
+│ │ ├── ... (EventMappings, TemplateManager, CustomStatusLabels, ContributorManagement)
+│ │ ├── AdminPageOutcome # shared outcome type
+│ │ ├── AdminApiErrorMapper # shared error formatting
+│ │ └── AdminSettingsEncoding # Base64 helper
+│ ├── Dashboard/ # IDashboardApplications, DashboardColumnResolver, DashboardAnswerReader
+│ ├── FormEngine/ # Form engine use cases
+│ │ ├── IPrepareFormEngineGet + PrepareFormEngineGetService
+│ │ ├── ISaveFormPage + SaveFormPageService
+│ │ ├── ICompleteFormTask + CompleteFormTaskService
+│ │ ├── ISubmitFormApplication + SubmitFormApplicationService
+│ │ ├── IUploadFormFile / IDeleteFormFile / IDownloadFormFile
+│ │ ├── IRemoveCollectionItem + RemoveCollectionItemService
+│ │ ├── FormEngineOutcome / FormEngineWorkState
+│ │ └── FormFileFieldService, InfectedUploadFilter, ...
+│ └── Validation/ # FormValidationResult, FormValidationError
+│
+├── GovUK.Dfe.FlexForms.Infrastructure/
+│ ├── DependencyInjection.cs # AddInfrastructureDependencyGroup() — all adapter registrations
+│ ├── Services/ # ApplicationResponseService, FormStateManager, ConditionalLogicEngine, ...
+│ ├── Stores/ # HttpFormSessionStore, RedisInfectedFileStore, ApiTemplateStore
+│ ├── Parsers/ # JsonFormTemplateParser
+│ ├── Providers/ # FormTemplateProvider, SchemaEventDefinitionProvider
+│ ├── Consumers/ # ScanResultConsumer (MassTransit)
+│ └── Messaging/ # MessagingEventBusConfigurator
+│
+├── GovUK.Dfe.FlexForms.Web/
+│ ├── Pages/
+│ │ ├── FormEngine/ # RenderForm (partial class, ~340+300 lines), BaseFormEngineModel (~80 lines)
+│ │ ├── Admin/ # Thin PageModels: TenantSettings (250), UserManager (83), RoleManager (124), ...
+│ │ ├── Applications/ # Dashboard (280), Index, Contributors, ...
+│ │ └── Shared/ # BaseFormPageModel
+│ ├── Extensions/
+│ │ └── ServiceCollectionExtensions.cs # AddWebLayerServices() → calls AddInfrastructureDependencyGroup()
+│ ├── Program.cs # Composition root (auth, middleware, MassTransit)
+│ └── ...
+│
+└── Tests/
+ ├── GovUK.Dfe.FlexForms.Domain.Tests/ # 43 tests
+ ├── GovUK.Dfe.FlexForms.Application.Tests/ # 100 tests (Admin + FormEngine use cases)
+ ├── GovUK.Dfe.FlexForms.Infrastructure.UnitTests/ # 64 tests
+ └── GovUK.Dfe.FlexForms.Web.UnitTests/ # 228 tests (incl. architecture guard tests)
+```
+
+### DI wiring
+
+All Infrastructure adapters are registered in one place:
+
+```
+Infrastructure/DependencyInjection.cs → AddInfrastructureDependencyGroup()
+```
+
+The Web composition root calls it via:
+
+```
+Web/Extensions/ServiceCollectionExtensions.cs → AddWebLayerServices()
+ ↳ services.AddInfrastructureDependencyGroup() // Infrastructure adapters
+ ↳ services.AddScoped // Application use cases
+ ↳ services.AddScoped // Web-only services
+```
+
+`Program.cs` calls `AddWebLayerServices()` once. It no longer duplicates Infrastructure registrations.
+
+### Adding a new Admin page (recipe)
+
+1. Create in `Application/Admin/`:
+ - `IMyFeatureAdmin` (interface with XML docs)
+ - `MyFeatureAdminService` (sealed, primary constructor)
+ - `MyFeatureWorkState` (mutable bag)
+ - `MyFeatureMessages` (const strings)
+2. Register in `ServiceCollectionExtensions.AddWebLayerServices()`
+3. Thin the PageModel:
+ - Constructor takes `IMyFeatureAdmin` (not API clients)
+ - `CaptureWorkState()` → use case → `ApplyWorkState()` → `MapOutcome()`
+ - Keep authorization, TempData, cache invalidation on the PageModel
+4. Add tests in `Application.Tests/Admin/` (validation failures + happy path)
+
+---
+
## Form engine
### Domain model
@@ -263,17 +542,19 @@ Full authoring reference: [`docs/Form-Template-Designer-Manual.md`](docs/Form-Te
### Runtime flow
-| Concern | Implementation |
-|---------|----------------|
-| Entry route | `/applications/{referenceNumber}/{taskId?}/{*pageId}` → `RenderForm` |
-| Template load | `ITemplatesClient` → `ApiTemplateStore` → `JsonFormTemplateParser` |
-| Navigation | `FormStateManager`, `FormNavigationService` |
-| Save | Session accumulate → Base64 JSON → `AddApplicationResponseAsync` |
-| Conditional logic | `ConditionalLogicEngine` / orchestrator |
-| Collections | Multi + derived flow handlers on `RenderForm` |
-| Complex fields | Tenant `FormEngine:ComplexFields` (Trust/Academy search, uploads) |
-| File validation | Status column on upload fields; `GetFileValidationGateAsync` blocks preview submit when the API gate says so |
-| Submit | `SubmitApplicationAsync` + `ApplicationSubmissionOrchestrator` (e.g. publish event) |
+| Concern | Use case (Application) | Infrastructure adapter |
+|---------|------------------------|----------------------|
+| Entry / page load | `IPrepareFormEngineGet` | `IFormStateManager`, `IFormNavigationService`, `IFormTemplateProvider` |
+| Save answers | `ISaveFormPage` | `IApplicationResponseService`, `IFormValidationOrchestrator` |
+| Complete task | `ICompleteFormTask` | `IApplicationResponseService` |
+| Submit application | `ISubmitFormApplication` | `IApplicationsClient` |
+| Upload file | `IUploadFormFile` | `IFileUploadService` |
+| Delete file | `IDeleteFormFile` | `IFileUploadService`, `IInfectedFileStore` |
+| Download file | `IDownloadFormFile` | `IApplicationsClient` |
+| Remove collection item | `IRemoveCollectionItem` | `IFormSessionStore` |
+| Conditional logic | `FormEngineConditionalLogic` | `IConditionalLogicEngine` / `IConditionalLogicOrchestrator` |
+| Complex fields | — | `IComplexFieldConfigurationService`, `IComplexFieldRendererFactory` |
+| File validation gate | — | `GetFileValidationGateAsync` blocks preview submit when the API gate says so |
### Template selection
@@ -287,18 +568,27 @@ Full authoring reference: [`docs/Form-Template-Designer-Manual.md`](docs/Form-Te
## Admin area
-Hub: `/admin` (`CanAccessAdminArea`).
-
-| Tool | Route | Who |
-|------|-------|-----|
-| Template Manager | `/admin/template-manager` | Admin / SuperAdmin / Template Manage |
-| Create Template | `/admin/create-template` | Same |
-| Custom status labels | `/admin/custom-status-label-overrides` | Same |
-| User Manager | `/admin/user-manager` | Admin / SuperAdmin / User Manage |
-| Role Manager | `/admin/role-manager` | Admin / SuperAdmin |
-| Tenant Settings | `/admin/tenant-settings` | **SuperAdmin only** |
-
-Tenant Settings uses `ITenantAdminClient` (Base64 settings payloads), then refreshes API tenant cache and local `ITenantConfigurationCache`.
+Hub: `/admin` (`CanAccessAdminArea`). Each admin page follows the [Clean Architecture use-case pattern](#clean-architecture-pattern-use-cases) described above.
+
+| Tool | Route | Who | Application use case |
+|------|-------|-----|---------------------|
+| Admin Home | `/admin` | Admin / SuperAdmin | `IAdminHome` |
+| Template Manager | `/admin/template-manager` | Admin / SuperAdmin / Template Manage | `ITemplateManagerAdmin` |
+| Create Template | `/admin/create-template` | Same | `ITemplateManagerAdmin` |
+| Custom status labels | `/admin/custom-status-label-overrides` | Same | `ICustomStatusLabelOverridesAdmin` |
+| User Manager | `/admin/user-manager` | Admin / SuperAdmin / User Manage | `IUserManagerAdmin` |
+| Add User | `/admin/user-manager-add` | Same | `IUserManagerAddAdmin` |
+| Edit User | `/admin/user-manager-edit` | Same | `IUserManagerEditAdmin` |
+| User Permissions | `/admin/user-manager-permissions` | Same | `IUserManagerPermissionsAdmin` |
+| Role Manager | `/admin/role-manager` | Admin / SuperAdmin | `IRoleManagerAdmin` |
+| Role Permissions | `/admin/role-manager-permissions` | Same | `IRoleManagerPermissionsAdmin` |
+| Organisation Settings | `/admin/organisation-settings` | Admin / SuperAdmin | `IOrganisationSettingsAdmin` |
+| Contributor Management | `/admin/contributor-management` | Admin / SuperAdmin | `IContributorManagementAdmin` |
+| Duplicate Tenant | `/admin/duplicate-tenant` | **SuperAdmin only** | `IDuplicateTenantAdmin` |
+| Tenant Settings | `/admin/tenant-settings` | **SuperAdmin only** | `ITenantSettingsAdmin` |
+| Event Mappings | `/admin/event-mappings` | **SuperAdmin only** | `IEventMappingsAdmin` |
+
+All admin use cases return `AdminPageOutcome` and use `AdminApiErrorMapper` for consistent error presentation.
---
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminApiErrorMapper.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminApiErrorMapper.cs
new file mode 100644
index 0000000..3d26b81
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminApiErrorMapper.cs
@@ -0,0 +1,43 @@
+using GovUK.Dfe.CoreLibs.Http.Models;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Maps API client exceptions to the Admin UI copy previously inlined on PageModels.
+///
+public static class AdminApiErrorMapper
+{
+ public const string GatewayBlockedMessage =
+ "Save was blocked with HTTP 403 (HTML response). "
+ + "This usually means an Azure gateway/WAF rejected the request before the API. "
+ + "Check Front Door / App Gateway logs for /v1/admin/tenants/.../settings.";
+
+ ///
+ /// Returns the API message when present, otherwise a status-aware fallback.
+ ///
+ ///
+ /// When true, HTML 403 responses use the gateway/WAF copy (Tenant Settings).
+ /// Event Mappings omits that hint.
+ ///
+ public static string Format(Exception ex, string fallback, bool includeGatewayHint = false)
+ {
+ if (ex is ExternalApplicationsException apiEx
+ && !string.IsNullOrWhiteSpace(apiEx.Result?.Message))
+ {
+ return apiEx.Result.Message;
+ }
+
+ if (ex is ExternalApplicationsException clientEx)
+ {
+ var body = clientEx.Response?.TrimStart() ?? string.Empty;
+ if (includeGatewayHint && clientEx.StatusCode == 403 && body.StartsWith('<'))
+ return GatewayBlockedMessage;
+
+ if (clientEx.StatusCode > 0)
+ return $"{fallback} (HTTP {clientEx.StatusCode})";
+ }
+
+ return fallback;
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeMessages.cs
new file mode 100644
index 0000000..29d02f6
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeMessages.cs
@@ -0,0 +1,19 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for the Admin home page. Keep these strings identical to the previous PageModel.
+///
+public static class AdminHomeMessages
+{
+ public const string TemplateLive = "Template is now live for end users.";
+
+ public const string TemplateNotLive = "Template is no longer live for end users.";
+
+ public const string SetLiveFailed = "Failed to update template live status. Please try again.";
+
+ public const string TemplateNotInCatalogue = "Template was not found in the tenant catalogue.";
+
+ public const string OpenFailed = "Failed to open template. Please try again.";
+
+ public const string LoadTemplateFailed = "Failed to load template information. Please try again.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeService.cs
new file mode 100644
index 0000000..37da2a5
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeService.cs
@@ -0,0 +1,169 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using Microsoft.Extensions.Logging;
+using Task = System.Threading.Tasks.Task;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Loads tenant templates, template details, and live-status updates for the Admin home page.
+///
+public interface IAdminHome
+{
+ Task LoadAsync(AdminHomeWorkState state, CancellationToken cancellationToken = default);
+
+ Task SetTemplateLiveAsync(
+ Guid templateId,
+ bool isLive,
+ CancellationToken cancellationToken = default);
+
+ Task OpenTemplateAsync(
+ AdminHomeWorkState state,
+ Guid templateId,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class AdminHomeService(
+ IFormTemplateProvider templateProvider,
+ ITemplatesClient templatesClient,
+ ITenantAdminClient tenantAdminClient,
+ ILogger logger) : IAdminHome
+{
+ public async Task LoadAsync(AdminHomeWorkState state, CancellationToken cancellationToken = default)
+ {
+ await LoadTenantTemplatesAsync(state, cancellationToken);
+ await LoadTemplateInformationAsync(state, cancellationToken);
+ await LoadTenantConfigurationSummaryAsync(state, cancellationToken);
+ }
+
+ public async Task SetTemplateLiveAsync(
+ Guid templateId,
+ bool isLive,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ logger.LogInformation(
+ "Setting template {TemplateId} live status to {IsLive}",
+ templateId,
+ isLive);
+
+ await templatesClient.SetTemplateLiveAsync(
+ templateId,
+ new SetTemplateLiveRequest { IsLive = isLive },
+ cancellationToken);
+
+ return AdminPageOutcome.Redirect(
+ successMessage: isLive ? AdminHomeMessages.TemplateLive : AdminHomeMessages.TemplateNotLive);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ ex,
+ "Failed to set live status to {IsLive} for template {TemplateId}",
+ isLive,
+ templateId);
+ return AdminPageOutcome.Redirect(errorMessage: AdminHomeMessages.SetLiveFailed);
+ }
+ }
+
+ public async Task OpenTemplateAsync(
+ AdminHomeWorkState state,
+ Guid templateId,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await LoadTenantTemplatesAsync(state, cancellationToken);
+ if (state.TenantTemplates.All(t => t.TemplateId != templateId))
+ {
+ state.HasError = true;
+ state.ErrorMessage = AdminHomeMessages.TemplateNotInCatalogue;
+ await LoadTemplateInformationAsync(state, cancellationToken);
+ return AdminPageOutcome.Stay(errorMessage: AdminHomeMessages.TemplateNotInCatalogue);
+ }
+
+ state.TemplateToOpen = state.TenantTemplates.First(t => t.TemplateId == templateId);
+ return AdminPageOutcome.Redirect();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to open template {TemplateId}", templateId);
+ state.HasError = true;
+ state.ErrorMessage = AdminHomeMessages.OpenFailed;
+ await LoadTenantTemplatesAsync(state, cancellationToken);
+ await LoadTemplateInformationAsync(state, cancellationToken);
+ return AdminPageOutcome.Stay(errorMessage: AdminHomeMessages.OpenFailed);
+ }
+ }
+
+ private async Task LoadTenantTemplatesAsync(AdminHomeWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken) ?? [];
+ state.TenantTemplates = templates
+ .OrderByDescending(t => t.IsLive)
+ .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to load tenant templates for admin page");
+ state.TenantTemplates = [];
+ }
+ }
+
+ private async Task LoadTemplateInformationAsync(AdminHomeWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (state.SkipTemplateDetails || string.IsNullOrEmpty(state.TemplateId))
+ return;
+
+ var template = await templateProvider.GetTemplateAsync(state.TemplateId, cancellationToken);
+ if (template != null)
+ {
+ state.TemplateName = template.TemplateName;
+ state.TemplateDescription = template.Description;
+ state.TaskGroupCount = template.TaskGroups?.Count ?? 0;
+ }
+
+ var templateResponse = await templatesClient.GetLatestTemplateSchemaAsync(
+ new Guid(state.TemplateId),
+ cancellationToken);
+ state.CurrentTemplateVersion = templateResponse?.VersionNumber;
+
+ logger.LogDebug("Loaded admin information for template {TemplateId}", state.TemplateId);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load template information for admin page");
+ state.HasError = true;
+ state.ErrorMessage = AdminHomeMessages.LoadTemplateFailed;
+ }
+ }
+
+ private async Task LoadTenantConfigurationSummaryAsync(
+ AdminHomeWorkState state,
+ CancellationToken cancellationToken)
+ {
+ if (!state.IncludeTenantConfigurationSummary)
+ return;
+
+ if (state.TenantId is not { } tenantId || tenantId == Guid.Empty)
+ return;
+
+ try
+ {
+ state.TenantConfigurationSummary = await tenantAdminClient.GetEffectiveConfigurationAsync(
+ tenantId,
+ cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to load tenant configuration summary for admin dashboard");
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeWorkState.cs
new file mode 100644
index 0000000..6db8ea8
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminHomeWorkState.cs
@@ -0,0 +1,35 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for the Admin home page.
+///
+public sealed class AdminHomeWorkState
+{
+ public Guid? TenantId { get; set; }
+
+ public bool IncludeTenantConfigurationSummary { get; set; }
+
+ public bool SkipTemplateDetails { get; set; }
+
+ public string? TemplateId { get; set; }
+
+ public string? TemplateName { get; set; }
+
+ public string? TemplateDescription { get; set; }
+
+ public int TaskGroupCount { get; set; }
+
+ public string? CurrentTemplateVersion { get; set; }
+
+ public IReadOnlyList TenantTemplates { get; set; } = [];
+
+ public TenantEffectiveConfigurationDto? TenantConfigurationSummary { get; set; }
+
+ public TemplateDto? TemplateToOpen { get; set; }
+
+ public bool HasError { get; set; }
+
+ public string? ErrorMessage { get; set; }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPageOutcome.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPageOutcome.cs
new file mode 100644
index 0000000..e54568f
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPageOutcome.cs
@@ -0,0 +1,80 @@
+using GovUK.Dfe.FlexForms.Application.Validation;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+public enum AdminPageOutcomeKind
+{
+ StayOnPage,
+ RedirectToPage,
+ FileDownload
+}
+
+///
+/// HTTP-agnostic result of an Admin use case. The PageModel maps this to Page() / RedirectToPage() / File().
+///
+public sealed class AdminPageOutcome
+{
+ public AdminPageOutcomeKind Kind { get; init; }
+
+ public string? SuccessMessage { get; init; }
+
+ public string? ErrorMessage { get; init; }
+
+ public IReadOnlyList Errors { get; init; } = [];
+
+ public bool RefreshLocalCaches { get; init; }
+
+ public byte[]? FileBytes { get; init; }
+
+ public string? FileContentType { get; init; }
+
+ public string? FileDownloadName { get; init; }
+
+ public IReadOnlyDictionary RouteValues { get; init; } =
+ new Dictionary(StringComparer.Ordinal);
+
+ public IReadOnlyList ModelStateKeysToRemove { get; init; } = [];
+
+ public bool ClearModelState { get; init; }
+
+ public static AdminPageOutcome Stay(
+ string? errorMessage = null,
+ string? successMessage = null,
+ IReadOnlyList? errors = null,
+ IReadOnlyList? modelStateKeysToRemove = null,
+ bool clearModelState = false,
+ bool refreshLocalCaches = false) =>
+ new()
+ {
+ Kind = AdminPageOutcomeKind.StayOnPage,
+ ErrorMessage = errorMessage,
+ SuccessMessage = successMessage,
+ Errors = errors ?? [],
+ ModelStateKeysToRemove = modelStateKeysToRemove ?? [],
+ ClearModelState = clearModelState,
+ RefreshLocalCaches = refreshLocalCaches
+ };
+
+ public static AdminPageOutcome Redirect(
+ string? successMessage = null,
+ string? errorMessage = null,
+ bool refreshLocalCaches = false,
+ IReadOnlyDictionary? routeValues = null) =>
+ new()
+ {
+ Kind = AdminPageOutcomeKind.RedirectToPage,
+ SuccessMessage = successMessage,
+ ErrorMessage = errorMessage,
+ RefreshLocalCaches = refreshLocalCaches,
+ RouteValues = routeValues ?? new Dictionary(StringComparer.Ordinal)
+ };
+
+ public static AdminPageOutcome File(byte[] bytes, string contentType, string fileName) =>
+ new()
+ {
+ Kind = AdminPageOutcomeKind.FileDownload,
+ FileBytes = bytes,
+ FileContentType = contentType,
+ FileDownloadName = fileName
+ };
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPermissionGrants.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPermissionGrants.cs
new file mode 100644
index 0000000..fcea416
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminPermissionGrants.cs
@@ -0,0 +1,131 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Encoding, parsing, and validation for Admin permission grant keys
+/// ({ResourceType}|{ResourceKey}|{AccessType}).
+///
+public static class AdminPermissionGrants
+{
+ public const string AnyResourceKey = "Any";
+
+ public static string EncodeGrantKey(ResourceType resourceType, string resourceKey, AccessType accessType) =>
+ $"{resourceType}|{resourceKey.Trim()}|{accessType}";
+
+ public static string FormatGrant(string key)
+ {
+ var parsed = ParseGrantKey(key);
+ return parsed is null
+ ? key
+ : $"{parsed.Value.ResourceType} / {parsed.Value.ResourceKey} / {parsed.Value.AccessType}";
+ }
+
+ public static List NormalizeGrants(IEnumerable? grants) =>
+ (grants ?? [])
+ .Select(ParseGrantKey)
+ .Where(g => g is not null)
+ .Select(g => EncodeGrantKey(g!.Value.ResourceType, g.Value.ResourceKey, g.Value.AccessType))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(g => g, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ public static (ResourceType ResourceType, string ResourceKey, AccessType AccessType)? ParseGrantKey(string? key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ return null;
+
+ var parts = key.Split('|', 3);
+ if (parts.Length != 3)
+ return null;
+
+ if (!Enum.TryParse(parts[0], ignoreCase: true, out var resourceType))
+ return null;
+
+ if (string.IsNullOrWhiteSpace(parts[1]))
+ return null;
+
+ if (!Enum.TryParse(parts[2], ignoreCase: true, out var accessType))
+ return null;
+
+ return (resourceType, parts[1].Trim(), accessType);
+ }
+
+ ///
+ /// Mirrors API RolePermissionGrantRules.
+ ///
+ public static string? ValidateGrant(ResourceType resourceType, string resourceKey, AccessType accessType)
+ {
+ var key = resourceKey.Trim();
+ if (accessType == AccessType.Manage)
+ {
+ if (resourceType != ResourceType.Template && resourceType != ResourceType.User)
+ {
+ return "Access type 'Manage' is only allowed for Template or User permissions.";
+ }
+
+ if (string.Equals(key, AnyResourceKey, StringComparison.OrdinalIgnoreCase))
+ return null;
+ }
+ else if (string.Equals(key, AnyResourceKey, StringComparison.OrdinalIgnoreCase))
+ {
+ if ((resourceType == ResourceType.Template && accessType == AccessType.Write)
+ || (resourceType == ResourceType.Template && accessType == AccessType.Manage)
+ || (resourceType == ResourceType.User && accessType == AccessType.Manage)
+ || (resourceType == ResourceType.Application && accessType == AccessType.Read)
+ || (resourceType == ResourceType.ApplicationFiles && accessType == AccessType.Read)
+ || (resourceType == ResourceType.FileValidation && accessType == AccessType.Write))
+ {
+ return null;
+ }
+
+ return $"Resource key '{AnyResourceKey}' is only allowed for Template — Write, " +
+ "Template — Manage, User — Manage, Application — Read, " +
+ "ApplicationFiles — Read, or FileValidation — Write. " +
+ "For other combinations, use a specific resource id or email.";
+ }
+
+ return resourceType switch
+ {
+ ResourceType.Application or ResourceType.ApplicationFiles or ResourceType.Template
+ or ResourceType.File or ResourceType.FileValidation or ResourceType.Task or ResourceType.TaskGroup
+ or ResourceType.Page or ResourceType.Field
+ when !Guid.TryParse(key, out var id) || id == Guid.Empty
+ => $"{resourceType} resource key must be a valid non-empty GUID (the resource id) or 'Any' (where allowed).",
+
+ ResourceType.User or ResourceType.Notifications
+ when !key.Contains('@', StringComparison.Ordinal) && !Guid.TryParse(key, out _)
+ => $"{resourceType} resource key must be a user email (or a service client id).",
+
+ _ => null
+ };
+ }
+
+ ///
+ /// Same shape rules as role grants, but Manage is never allowed on an individual user.
+ ///
+ public static string? ValidateUserGrant(ResourceType resourceType, string resourceKey, AccessType accessType)
+ {
+ if (accessType == AccessType.Manage)
+ {
+ return "Access type 'Manage' cannot be granted to an individual user. " +
+ "Assign Manage via a tenant role instead.";
+ }
+
+ return ValidateGrant(resourceType, resourceKey, accessType);
+ }
+
+ public static List ToGrantDtos(IEnumerable grants) =>
+ grants
+ .Select(ParseGrantKey)
+ .Where(g => g is not null)
+ .Select(g => g.GetValueOrDefault())
+ .Select(g => new RolePermissionGrantDto
+ {
+ ResourceType = g.ResourceType,
+ ResourceKey = g.ResourceKey,
+ AccessType = g.AccessType
+ })
+ .ToList();
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSelectOption.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSelectOption.cs
new file mode 100644
index 0000000..2cef1c7
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSelectOption.cs
@@ -0,0 +1,6 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// HTTP-agnostic dropdown option. The PageModel maps this to SelectListItem.
+///
+public sealed record AdminSelectOption(string Text, string Value, bool Selected);
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSettingsEncoding.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSettingsEncoding.cs
new file mode 100644
index 0000000..8566d65
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/AdminSettingsEncoding.cs
@@ -0,0 +1,12 @@
+using System.Text;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Encodes TenantConfig JSON as Base64 for the API (WAF-safe; mirrors template schema transport).
+///
+public static class AdminSettingsEncoding
+{
+ public static string ToBase64(string settingsJson) =>
+ Convert.ToBase64String(Encoding.UTF8.GetBytes(settingsJson));
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementAdminService.cs
new file mode 100644
index 0000000..b0fcbf1
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementAdminService.cs
@@ -0,0 +1,58 @@
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Looks up application contributors by reference number.
+///
+public interface IContributorManagementAdmin
+{
+ Task LookupAsync(ContributorManagementWorkState state, CancellationToken cancellationToken = default);
+}
+
+public sealed class ContributorManagementAdminService(
+ IApplicationsClient applicationsClient,
+ ILogger logger) : IContributorManagementAdmin
+{
+ public async Task LookupAsync(
+ ContributorManagementWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ state.LookupPerformed = true;
+ state.ApplicationReference = state.ReferenceNumber;
+
+ try
+ {
+ var application = await applicationsClient.GetApplicationByReferenceAsync(
+ state.ReferenceNumber,
+ cancellationToken);
+
+ state.ApplicationId = application.ApplicationId;
+ state.ApplicationReference = string.IsNullOrWhiteSpace(application.ApplicationReference)
+ ? state.ReferenceNumber
+ : application.ApplicationReference;
+ state.TemplateName = application.TemplateName;
+
+ var contributors = await applicationsClient.GetContributorsAsync(
+ application.ApplicationId,
+ includePermissionDetails: false,
+ cancellationToken);
+
+ state.Contributors = contributors?
+ .OrderBy(c => c.Name)
+ .ThenBy(c => c.Email)
+ .ToList() ?? [];
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to look up contributors for {ReferenceNumber}", state.ReferenceNumber);
+ state.HasError = true;
+ state.ErrorMessage = AdminApiErrorMapper.Format(
+ ex,
+ ContributorManagementMessages.LookupFailed,
+ includeGatewayHint: false);
+ state.Contributors = [];
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementMessages.cs
new file mode 100644
index 0000000..7f45088
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementMessages.cs
@@ -0,0 +1,9 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for Contributor Management. Keep these strings identical to the previous PageModel.
+///
+public static class ContributorManagementMessages
+{
+ public const string LookupFailed = "Could not find that application or load its contributors.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementWorkState.cs
new file mode 100644
index 0000000..5684b18
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/ContributorManagementWorkState.cs
@@ -0,0 +1,25 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for Contributor Management.
+///
+public sealed class ContributorManagementWorkState
+{
+ public string ReferenceNumber { get; set; } = string.Empty;
+
+ public bool LookupPerformed { get; set; }
+
+ public string? ApplicationReference { get; set; }
+
+ public Guid? ApplicationId { get; set; }
+
+ public string? TemplateName { get; set; }
+
+ public IReadOnlyList Contributors { get; set; } = [];
+
+ public bool HasError { get; set; }
+
+ public string? ErrorMessage { get; set; }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesAdminService.cs
new file mode 100644
index 0000000..c889a8d
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesAdminService.cs
@@ -0,0 +1,139 @@
+using System.ComponentModel;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using Microsoft.Extensions.Logging;
+using Task = System.Threading.Tasks.Task;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Loads templates and overrides application status labels.
+///
+public interface ICustomStatusLabelOverridesAdmin
+{
+ Task LoadAvailableTemplatesAsync(
+ CustomStatusLabelOverridesWorkState state,
+ CancellationToken cancellationToken = default);
+
+ Task LoadTemplateDataAsync(
+ CustomStatusLabelOverridesWorkState state,
+ Guid templateId,
+ CancellationToken cancellationToken = default);
+
+ Task LoadStatusOverrideAsync(
+ CustomStatusLabelOverridesWorkState state,
+ Guid templateId,
+ ApplicationStatus status,
+ CancellationToken cancellationToken = default);
+
+ Task OverrideAsync(
+ Guid templateId,
+ ApplicationStatus status,
+ string label,
+ CancellationToken cancellationToken = default);
+
+ void PopulateBaseStatuses(CustomStatusLabelOverridesWorkState state);
+}
+
+public sealed class CustomStatusLabelOverridesAdminService(
+ IFormTemplateProvider formTemplateProvider,
+ ITemplatesClient templatesClient,
+ ILogger logger) : ICustomStatusLabelOverridesAdmin
+{
+ public async Task LoadAvailableTemplatesAsync(
+ CustomStatusLabelOverridesWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken) ?? [];
+ state.AvailableTemplates = templates
+ .OrderByDescending(t => t.IsLive)
+ .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to load available templates for custom status page");
+ state.AvailableTemplates = [];
+ }
+ }
+
+ public async Task LoadTemplateDataAsync(
+ CustomStatusLabelOverridesWorkState state,
+ Guid templateId,
+ CancellationToken cancellationToken = default)
+ {
+ var apiResponse = await templatesClient.GetLatestTemplateSchemaAsync(templateId, cancellationToken);
+ state.CurrentVersionNumber = apiResponse.VersionNumber;
+ state.CurrentTemplate = await formTemplateProvider.GetTemplateAsync(templateId.ToString(), cancellationToken);
+ }
+
+ public async Task LoadStatusOverrideAsync(
+ CustomStatusLabelOverridesWorkState state,
+ Guid templateId,
+ ApplicationStatus status,
+ CancellationToken cancellationToken = default)
+ {
+ state.BaseStatuses = GetBaseApplicationStatuses().OrderBy(x => x.Key).ToList();
+ var statuses = await templatesClient.GetCustomApplicationStatusesAsync(templateId, cancellationToken);
+ state.BaseStatusOverrideValue = GetStatusLabel(status, statuses);
+ }
+
+ public async Task OverrideAsync(
+ Guid templateId,
+ ApplicationStatus status,
+ string label,
+ CancellationToken cancellationToken = default)
+ {
+ await templatesClient.CreateCustomApplicationStatusAsync(
+ templateId,
+ new CustomApplicationStatusRequest
+ {
+ Label = label,
+ ApplicationStatus = status
+ },
+ cancellationToken);
+
+ logger.LogInformation("Successfully overridden application status for {TemplateId}", templateId);
+ }
+
+ public void PopulateBaseStatuses(CustomStatusLabelOverridesWorkState state)
+ {
+ state.BaseStatuses = GetBaseApplicationStatuses().OrderBy(x => x.Key).ToList();
+ }
+
+ internal static List> GetBaseApplicationStatuses()
+ {
+ var baseStatuses = new List>();
+ foreach (var status in Enum.GetValues())
+ {
+ baseStatuses.Add(new KeyValuePair(status, GetBaseStatusLabel(status)));
+ }
+
+ return baseStatuses;
+ }
+
+ internal static string GetStatusLabel(
+ ApplicationStatus status,
+ IEnumerable? customStatuses)
+ {
+ if (customStatuses != null)
+ {
+ var customStatus = customStatuses.FirstOrDefault(x => x.ApplicationStatus == status);
+ if (customStatus?.Label != null)
+ return customStatus.Label;
+ }
+
+ return GetBaseStatusLabel(status);
+ }
+
+ private static string GetBaseStatusLabel(ApplicationStatus status)
+ {
+ var appStatusInfo = status.GetType().GetField(status.ToString());
+ var descriptionAttributes = (DescriptionAttribute[])appStatusInfo!.GetCustomAttributes(typeof(DescriptionAttribute), false);
+ return descriptionAttributes.Length > 0 ? descriptionAttributes[0].Description : status.ToString();
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesMessages.cs
new file mode 100644
index 0000000..b4c54d7
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesMessages.cs
@@ -0,0 +1,11 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for Custom Status Label Overrides. Keep these strings identical to the previous PageModel.
+///
+public static class CustomStatusLabelOverridesMessages
+{
+ public const string SelectTemplate = "Please select a template.";
+
+ public const string OverrideRequired = "An override value is required and cannot be empty";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesWorkState.cs
new file mode 100644
index 0000000..62217f9
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/CustomStatusLabelOverridesWorkState.cs
@@ -0,0 +1,25 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using GovUK.Dfe.FlexForms.Domain.Models;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for Custom Status Label Overrides.
+///
+public sealed class CustomStatusLabelOverridesWorkState
+{
+ public Guid? SelectedTemplateId { get; set; }
+
+ public ApplicationStatus SelectedBaseStatus { get; set; }
+
+ public string BaseStatusOverrideValue { get; set; } = string.Empty;
+
+ public FormTemplate? CurrentTemplate { get; set; }
+
+ public string? CurrentVersionNumber { get; set; }
+
+ public IReadOnlyList AvailableTemplates { get; set; } = [];
+
+ public IReadOnlyList> BaseStatuses { get; set; } = [];
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantAdminService.cs
new file mode 100644
index 0000000..a25fae1
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantAdminService.cs
@@ -0,0 +1,210 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using GovUK.Dfe.FlexForms.Application.Validation;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Clones the current tenant's TenantConfig into a new tenant.
+///
+public interface IDuplicateTenantAdmin
+{
+ Task LoadInternalServiceAuthServicesAsync(
+ DuplicateTenantWorkState state,
+ CancellationToken cancellationToken = default);
+
+ IReadOnlyList ValidateInput(DuplicateTenantWorkState state);
+
+ Task CloneAsync(
+ DuplicateTenantWorkState state,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class DuplicateTenantAdminService(
+ ITenantAdminClient tenantAdminClient,
+ ILogger logger) : IDuplicateTenantAdmin
+{
+ private static readonly JsonSerializerOptions PayloadSerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ public static string GenerateSecretKey(int byteLength = 48) =>
+ Convert.ToBase64String(RandomNumberGenerator.GetBytes(byteLength));
+
+ public static string ToBase64Utf8(string value) =>
+ Convert.ToBase64String(Encoding.UTF8.GetBytes(value ?? string.Empty));
+
+ public async Task LoadInternalServiceAuthServicesAsync(
+ DuplicateTenantWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var response = await tenantAdminClient.GetTenantSettingsAsync(state.SourceTenantId, cancellationToken);
+ var template = response.Settings
+ .Where(s => string.Equals(s.Category, "InternalServiceAuth", StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(s => string.Equals(s.Target, "Api", StringComparison.OrdinalIgnoreCase))
+ .ThenBy(s => s.Target, StringComparer.OrdinalIgnoreCase)
+ .FirstOrDefault();
+
+ if (template is null || string.IsNullOrWhiteSpace(template.SettingsJson))
+ {
+ state.InternalServiceAuthServiceApiKeys = [];
+ return;
+ }
+
+ state.InternalServiceAuthServiceApiKeys = ParseServiceEmails(template.SettingsJson)
+ .Select(email => new DuplicateTenantServiceSecret
+ {
+ Email = email,
+ ApiKey = GenerateSecretKey()
+ })
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(
+ ex,
+ "Could not load InternalServiceAuth services for tenant {TenantId}. Service ApiKey fields will be empty.",
+ state.SourceTenantId);
+ state.InternalServiceAuthServiceApiKeys = [];
+ }
+ }
+
+ public IReadOnlyList ValidateInput(DuplicateTenantWorkState state)
+ {
+ var errors = new List();
+ for (var i = 0; i < state.InternalServiceAuthServiceApiKeys.Count; i++)
+ {
+ var service = state.InternalServiceAuthServiceApiKeys[i];
+ if (string.IsNullOrWhiteSpace(service.Email))
+ {
+ errors.Add(new FormValidationError(
+ $"InternalServiceAuthServiceApiKeys[{i}].Email",
+ DuplicateTenantMessages.ServiceEmailRequired));
+ }
+
+ if (string.IsNullOrWhiteSpace(service.ApiKey) || service.ApiKey.Length < 32)
+ {
+ errors.Add(new FormValidationError(
+ $"InternalServiceAuthServiceApiKeys[{i}].ApiKey",
+ DuplicateTenantMessages.ServiceApiKeyRequired));
+ }
+ }
+
+ if (state.NewTenantId == Guid.Empty)
+ {
+ errors.Add(new FormValidationError("NewTenantId", DuplicateTenantMessages.TenantIdRequired));
+ }
+ else if (state.NewTenantId == state.SourceTenantId)
+ {
+ errors.Add(new FormValidationError("NewTenantId", DuplicateTenantMessages.TenantIdMustDiffer));
+ }
+
+ return errors;
+ }
+
+ public async Task CloneAsync(
+ DuplicateTenantWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ var errors = ValidateInput(state);
+ if (errors.Count > 0)
+ return AdminPageOutcome.Stay(errors: errors);
+
+ try
+ {
+ // WAF-safe: hostname, frontendOrigin, serviceName, and secrets live only inside Base64 payloadJson
+ // so Application Gateway does not see cleartext https:// ARGS (rule 931130 RFI).
+ var secretsPayload = new CloneTenantSecretsPayload
+ {
+ Hostname = state.Hostname,
+ FrontendOrigin = state.FrontendOrigin,
+ AuthorizationApiSecretKey = state.AuthorizationApiSecretKey,
+ InternalServiceAuthSecretKey = state.InternalServiceAuthSecretKey,
+ InternalServiceAuthServiceApiKeys = state.InternalServiceAuthServiceApiKeys
+ .Select(s => new CloneTenantServiceApiKeyPayload
+ {
+ Email = s.Email,
+ ApiKey = s.ApiKey
+ })
+ .ToList()
+ };
+
+ var payloadNode = JsonSerializer.SerializeToNode(secretsPayload, PayloadSerializerOptions)!.AsObject();
+ payloadNode["serviceName"] = state.ServiceName;
+
+ var body = new CloneTenantRequest(
+ state.NewTenantId,
+ state.NewTenantName,
+ ToBase64Utf8(payloadNode.ToJsonString(PayloadSerializerOptions)));
+
+ var response = await tenantAdminClient.CloneTenantAsync(state.SourceTenantId, body, cancellationToken);
+
+ return AdminPageOutcome.Redirect(
+ successMessage: DuplicateTenantMessages.Created(
+ response.NewTenantName,
+ response.NewTenantId,
+ response.SettingsCopied,
+ response.Hostname));
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ ex,
+ "Failed to duplicate tenant {SourceTenantId} to {NewTenantId}",
+ state.SourceTenantId,
+ state.NewTenantId);
+ var message = FormatCloneError(ex);
+ state.HasError = true;
+ state.ErrorMessage = message;
+ return AdminPageOutcome.Stay(errorMessage: message);
+ }
+ }
+
+ internal static IReadOnlyList ParseServiceEmails(string settingsJson)
+ {
+ try
+ {
+ if (JsonNode.Parse(settingsJson) is not JsonObject root ||
+ root["Services"] is not JsonArray services)
+ {
+ return [];
+ }
+
+ return services
+ .OfType()
+ .Select(s => s["Email"]?.GetValue()?.Trim() ?? string.Empty)
+ .Where(email => !string.IsNullOrWhiteSpace(email))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+ catch (JsonException)
+ {
+ return [];
+ }
+ }
+
+ private static string FormatCloneError(Exception ex)
+ {
+ if (ex is ExternalApplicationsException clientEx)
+ {
+ var body = clientEx.Response?.TrimStart() ?? string.Empty;
+ if (clientEx.StatusCode == 403 && body.StartsWith('<'))
+ return DuplicateTenantMessages.CloneBlocked;
+
+ if (clientEx.StatusCode > 0)
+ return DuplicateTenantMessages.CloneFailedHttp(clientEx.StatusCode);
+ }
+
+ return AdminApiErrorMapper.Format(ex, DuplicateTenantMessages.CloneFailed, includeGatewayHint: true);
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantMessages.cs
new file mode 100644
index 0000000..ff88f90
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantMessages.cs
@@ -0,0 +1,37 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for Duplicate Tenant. Keep these strings identical to the previous PageModel.
+///
+public static class DuplicateTenantMessages
+{
+ public const string TenantContextMissing = "Tenant context is not available for this request.";
+
+ public const string ServiceEmailRequired = "Service email is required.";
+
+ public const string ServiceApiKeyRequired = "Enter an ApiKey of at least 32 characters.";
+
+ public const string TenantIdRequired = "Enter a valid tenant id.";
+
+ public const string TenantIdMustDiffer = "New tenant id must be different from the current tenant.";
+
+ public const string CloneFailed = "Could not duplicate tenant.";
+
+ public const string CloneBlocked =
+ "Clone was blocked with HTTP 403 (HTML response). "
+ + "This usually means Front Door / WAF rejected the request before the API. "
+ + "Check WAF logs for POST /v1/admin/tenants/.../clone.";
+
+ public static string CloneFailedHttp(int statusCode) =>
+ $"{CloneFailed} (HTTP {statusCode})";
+
+ public static string Created(
+ string newTenantName,
+ Guid newTenantId,
+ int settingsCopied,
+ string hostname) =>
+ $"Created tenant '{newTenantName}' ({newTenantId}). "
+ + $"Copied {settingsCopied} setting(s). Hostname: {hostname}. "
+ + "Authorization and InternalServiceAuth secrets (SecretKey + service ApiKeys) were applied. "
+ + "Create a template for this tenant before users can access the dashboard.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantWorkState.cs
new file mode 100644
index 0000000..a3c2c0d
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/DuplicateTenantWorkState.cs
@@ -0,0 +1,41 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for the Duplicate Tenant admin page.
+///
+public sealed class DuplicateTenantWorkState
+{
+ public Guid SourceTenantId { get; set; }
+
+ public string SourceTenantName { get; set; } = string.Empty;
+
+ public Guid NewTenantId { get; set; }
+
+ public string NewTenantName { get; set; } = string.Empty;
+
+ public string ServiceName { get; set; } = string.Empty;
+
+ public string Hostname { get; set; } = string.Empty;
+
+ public string FrontendOrigin { get; set; } = string.Empty;
+
+ public string AuthorizationApiSecretKey { get; set; } = string.Empty;
+
+ public string InternalServiceAuthSecretKey { get; set; } = string.Empty;
+
+ public List InternalServiceAuthServiceApiKeys { get; set; } = [];
+
+ public bool HasError { get; set; }
+
+ public string? ErrorMessage { get; set; }
+}
+
+///
+/// One InternalServiceAuth Services[] ApiKey row on the Duplicate Tenant form.
+///
+public sealed class DuplicateTenantServiceSecret
+{
+ public string Email { get; set; } = string.Empty;
+
+ public string ApiKey { get; set; } = string.Empty;
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsAdminService.cs
new file mode 100644
index 0000000..4197490
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsAdminService.cs
@@ -0,0 +1,919 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using GovUK.Dfe.FlexForms.Application.Options;
+using GovUK.Dfe.FlexForms.Application.Validation;
+using GovUK.Dfe.FlexForms.Domain.Models.EventMapping;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Loads and persists EventMappings, SchemaEvents, and EventTriggers for the current tenant.
+///
+public interface IEventMappingsAdmin
+{
+ Task LoadAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default);
+
+ Task SaveTriggerAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default);
+
+ Task DeleteTriggerAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default);
+
+ Task SaveMappingAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default);
+
+ Task SaveSchemaAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default);
+}
+
+public sealed class EventMappingsAdminService(
+ ITenantAdminClient tenantAdminClient,
+ ITemplatesClient templatesClient,
+ IEventTypeRegistry eventTypeRegistry,
+ ISchemaEventDefinitionProvider schemaEventDefinitionProvider,
+ ILogger logger) : IEventMappingsAdmin
+{
+ public const string TargetShared = "Shared";
+ public const string TargetWeb = "Web";
+ public const string CategoryEventMappings = "EventMappings";
+ public const string CategorySchemaEvents = "SchemaEvents";
+ public const string CategoryEventTriggers = "EventTriggers";
+ public const string SystemOnlyEventType = "ScanRequestedEvent";
+
+ public static readonly string[] TriggerNames = ["ApplicationSubmitted", "FileUploaded"];
+
+ private static readonly JsonSerializerOptions JsonWriteOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ WriteIndented = true,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
+ };
+
+ private static readonly JsonSerializerOptions JsonReadOptions = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ private static readonly JsonSerializerOptions JsonPersistOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
+ };
+
+ public async Task LoadAsync(EventMappingsWorkState state, CancellationToken cancellationToken = default)
+ {
+ await LoadPageDataAsync(state, cancellationToken);
+ await LoadSavedTypedMappingsAsync(state, cancellationToken);
+ await LoadSavedTriggersAsync(state, cancellationToken);
+ await LoadSelectedMappingAsync(state, cancellationToken);
+ await LoadSelectedSchemaDefinitionAsync(state, cancellationToken);
+ }
+
+ public async Task SaveTriggerAsync(
+ EventMappingsWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ await LoadAsync(state, cancellationToken);
+
+ var trigger = state.TriggerName?.Trim();
+ var eventType = state.TriggerEventType?.Trim();
+ var mappingId = state.TriggerMappingId?.Trim();
+ var eventKind = string.IsNullOrWhiteSpace(state.TriggerEventKind)
+ ? EventPublishKind.Typed
+ : state.TriggerEventKind.Trim();
+
+ var errors = new List();
+ if (string.IsNullOrWhiteSpace(trigger)
+ || !TriggerNames.Contains(trigger, StringComparer.OrdinalIgnoreCase))
+ {
+ errors.Add(new FormValidationError(nameof(EventMappingsWorkState.TriggerName), EventMappingsMessages.SelectTrigger));
+ }
+
+ if (string.IsNullOrWhiteSpace(eventType))
+ {
+ errors.Add(new FormValidationError(nameof(EventMappingsWorkState.TriggerEventType), EventMappingsMessages.SelectEventType));
+ }
+ else if (string.Equals(eventType, SystemOnlyEventType, StringComparison.OrdinalIgnoreCase))
+ {
+ errors.Add(new FormValidationError(
+ nameof(EventMappingsWorkState.TriggerEventType),
+ EventMappingsMessages.SystemOnlyEventType(SystemOnlyEventType)));
+ }
+
+ if (string.IsNullOrWhiteSpace(mappingId))
+ errors.Add(new FormValidationError(nameof(EventMappingsWorkState.TriggerMappingId), EventMappingsMessages.EnterMappingId));
+
+ if (!string.Equals(eventKind, EventPublishKind.Typed, StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(eventKind, EventPublishKind.Schema, StringComparison.OrdinalIgnoreCase))
+ {
+ errors.Add(new FormValidationError(
+ nameof(EventMappingsWorkState.TriggerEventKind),
+ EventMappingsMessages.EventKindMustBeTypedOrSchema));
+ }
+
+ if (errors.Count > 0)
+ return Stay(state, errors);
+
+ try
+ {
+ var root = await LoadCategoryRootAsync(state, CategoryEventTriggers, cancellationToken);
+ var bindings = root[trigger!] as JsonArray ?? new JsonArray();
+
+ var replaced = false;
+ for (var i = 0; i < bindings.Count; i++)
+ {
+ if (bindings[i] is not JsonObject existing)
+ continue;
+
+ if (!string.Equals(ReadBindingValue(existing, "eventType"), eventType, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ bindings[i] = BuildBindingNode(eventKind, eventType!, mappingId!);
+ replaced = true;
+ break;
+ }
+
+ if (!replaced)
+ bindings.Add(BuildBindingNode(eventKind, eventType!, mappingId!));
+
+ root[trigger!] = bindings;
+
+ await UpsertCategoryAsync(state, CategoryEventTriggers, root, cancellationToken);
+ await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken);
+
+ return Redirect(
+ state,
+ EventMappingsMessages.SavedTrigger(eventType!, trigger!),
+ refreshLocalCaches: true);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to save EventTriggers for {TenantId}", state.TenantId);
+ return StayWithError(state, AdminApiErrorMapper.Format(ex, EventMappingsMessages.SaveTriggerFailed));
+ }
+ }
+
+ public async Task DeleteTriggerAsync(
+ EventMappingsWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ var trigger = state.TriggerName?.Trim();
+ var eventType = state.TriggerEventType?.Trim();
+
+ if (string.IsNullOrWhiteSpace(trigger) || string.IsNullOrWhiteSpace(eventType))
+ return Redirect(state, errorMessage: EventMappingsMessages.DeleteTriggerUnidentified);
+
+ try
+ {
+ var root = await LoadCategoryRootAsync(state, CategoryEventTriggers, cancellationToken);
+ if (root[trigger] is JsonArray bindings)
+ {
+ var remaining = new JsonArray();
+ foreach (var binding in bindings)
+ {
+ if (binding is JsonObject obj
+ && string.Equals(ReadBindingValue(obj, "eventType"), eventType, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ remaining.Add(binding?.DeepClone());
+ }
+
+ if (remaining.Count == 0)
+ root.Remove(trigger);
+ else
+ root[trigger] = remaining;
+
+ await UpsertCategoryAsync(state, CategoryEventTriggers, root, cancellationToken);
+ await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken);
+ }
+
+ return Redirect(
+ state,
+ EventMappingsMessages.RemovedTrigger(eventType, trigger),
+ refreshLocalCaches: true);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to delete EventTriggers binding for {TenantId}", state.TenantId);
+ return Redirect(
+ state,
+ errorMessage: AdminApiErrorMapper.Format(ex, EventMappingsMessages.DeleteTriggerFailed));
+ }
+ }
+
+ public async Task SaveMappingAsync(
+ EventMappingsWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ await LoadPageDataAsync(state, cancellationToken);
+ await LoadSavedTypedMappingsAsync(state, cancellationToken);
+ await LoadSavedTriggersAsync(state, cancellationToken);
+ await LoadSelectedSchemaDefinitionAsync(state, cancellationToken);
+
+ state.SelectedTemplateId = state.SelectedTemplateId?.Trim();
+ state.SelectedEventType = state.SelectedEventType?.Trim();
+
+ var errors = new List();
+ if (string.IsNullOrWhiteSpace(state.SelectedTemplateId))
+ errors.Add(new FormValidationError(nameof(EventMappingsWorkState.SelectedTemplateId), EventMappingsMessages.SelectTemplate));
+ else if (!await IsTemplateAllowedForCurrentTenantAsync(state, state.SelectedTemplateId, cancellationToken))
+ {
+ errors.Add(new FormValidationError(
+ nameof(EventMappingsWorkState.SelectedTemplateId),
+ EventMappingsMessages.SelectTenantTemplate));
+ }
+
+ if (string.IsNullOrWhiteSpace(state.SelectedEventType))
+ errors.Add(new FormValidationError(nameof(EventMappingsWorkState.SelectedEventType), EventMappingsMessages.SelectEventType));
+
+ if (string.IsNullOrWhiteSpace(state.MappingJson))
+ errors.Add(new FormValidationError(nameof(EventMappingsWorkState.MappingJson), EventMappingsMessages.EnterMappingJson));
+
+ if (errors.Count > 0)
+ return Stay(state, errors);
+
+ EventFieldMapping? mapping;
+ try
+ {
+ mapping = JsonSerializer.Deserialize(state.MappingJson!, JsonReadOptions);
+ }
+ catch (JsonException ex)
+ {
+ return Stay(state, [new FormValidationError(
+ nameof(EventMappingsWorkState.MappingJson),
+ EventMappingsMessages.InvalidJson(ex.Message))]);
+ }
+
+ if (mapping is null)
+ {
+ return Stay(state, [new FormValidationError(
+ nameof(EventMappingsWorkState.MappingJson),
+ EventMappingsMessages.MappingParseFailed)]);
+ }
+
+ if (string.IsNullOrWhiteSpace(mapping.MappingId))
+ errors.Add(new FormValidationError(nameof(EventMappingsWorkState.MappingJson), EventMappingsMessages.MappingIdRequired));
+
+ if (string.IsNullOrWhiteSpace(mapping.EventType))
+ mapping.EventType = state.SelectedEventType!;
+ else if (!string.Equals(mapping.EventType, state.SelectedEventType, StringComparison.OrdinalIgnoreCase))
+ errors.Add(new FormValidationError(nameof(EventMappingsWorkState.MappingJson), EventMappingsMessages.EventTypeMustMatch));
+
+ if (mapping.FieldMappings is null || mapping.FieldMappings.Count == 0)
+ errors.Add(new FormValidationError(nameof(EventMappingsWorkState.MappingJson), EventMappingsMessages.FieldMappingsRequired));
+
+ var catalogueItem = state.Catalogue.FirstOrDefault(c =>
+ string.Equals(c.EventTypeName, state.SelectedEventType, StringComparison.OrdinalIgnoreCase));
+ if (catalogueItem is { Kind: EventPublishKind.Typed, Properties.Count: > 0 }
+ && mapping.FieldMappings is { Count: > 0 })
+ {
+ var known = catalogueItem.Properties.Select(p => p).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var unknown = mapping.FieldMappings.Keys
+ .Where(k => !known.Contains(k))
+ .OrderBy(k => k, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ if (unknown.Count > 0)
+ {
+ state.ValidationWarnings = unknown
+ .Select(k => EventMappingsMessages.UnknownProperty(k, state.SelectedEventType!))
+ .ToList();
+ }
+ }
+
+ if (errors.Count > 0)
+ return Stay(state, errors);
+
+ try
+ {
+ var root = await LoadCategoryRootAsync(state, CategoryEventMappings, cancellationToken);
+ var mappingJson = JsonSerializer.Serialize(mapping, JsonPersistOptions);
+ var templateKeys = await ResolveTemplateMappingKeysAsync(state.SelectedTemplateId!, cancellationToken);
+
+ foreach (var templateKey in templateKeys)
+ {
+ var templateNode = root[templateKey] as JsonObject ?? new JsonObject();
+ root[templateKey] = templateNode;
+
+ var mappingNode = JsonNode.Parse(mappingJson)
+ ?? throw new InvalidOperationException("Failed to serialise mapping.");
+ templateNode[state.SelectedEventType!] = mappingNode;
+ }
+
+ await UpsertCategoryAsync(state, CategoryEventMappings, root, cancellationToken);
+ await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken);
+
+ return Redirect(
+ state,
+ EventMappingsMessages.SavedMapping(string.Join(", ", templateKeys), state.SelectedEventType!),
+ refreshLocalCaches: true);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to save EventMappings for {TenantId}", state.TenantId);
+ return StayWithError(state, AdminApiErrorMapper.Format(ex, EventMappingsMessages.SaveMappingFailed));
+ }
+ }
+
+ public async Task SaveSchemaAsync(
+ EventMappingsWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ await LoadPageDataAsync(state, cancellationToken);
+ await LoadSavedTypedMappingsAsync(state, cancellationToken);
+ await LoadSavedTriggersAsync(state, cancellationToken);
+ await LoadSelectedMappingAsync(state, cancellationToken);
+
+ var schemaKey = (state.NewSchemaEventType ?? state.SelectedSchemaEventType)?.Trim();
+ if (string.IsNullOrWhiteSpace(schemaKey))
+ {
+ return Stay(state, [new FormValidationError(
+ nameof(EventMappingsWorkState.NewSchemaEventType),
+ EventMappingsMessages.EnterSchemaEventType)]);
+ }
+
+ if (state.Catalogue.Any(c =>
+ string.Equals(c.Kind, EventPublishKind.Typed, StringComparison.OrdinalIgnoreCase)
+ && string.Equals(c.EventTypeName, schemaKey, StringComparison.OrdinalIgnoreCase)))
+ {
+ return Stay(state, [new FormValidationError(
+ nameof(EventMappingsWorkState.NewSchemaEventType),
+ EventMappingsMessages.TypedEventNameClash(schemaKey))]);
+ }
+
+ if (string.IsNullOrWhiteSpace(state.SchemaDefinitionJson))
+ {
+ return Stay(state, [new FormValidationError(
+ nameof(EventMappingsWorkState.SchemaDefinitionJson),
+ EventMappingsMessages.EnterSchemaDefinitionJson)]);
+ }
+
+ JsonNode? definitionNode;
+ try
+ {
+ definitionNode = JsonNode.Parse(state.SchemaDefinitionJson);
+ }
+ catch (JsonException ex)
+ {
+ return Stay(state, [new FormValidationError(
+ nameof(EventMappingsWorkState.SchemaDefinitionJson),
+ EventMappingsMessages.InvalidJson(ex.Message))]);
+ }
+
+ if (definitionNode is not JsonObject defObj)
+ {
+ return Stay(state, [new FormValidationError(
+ nameof(EventMappingsWorkState.SchemaDefinitionJson),
+ EventMappingsMessages.SchemaMustBeObject)]);
+ }
+
+ var errors = new List();
+ if (defObj["topicName"] is null && defObj["TopicName"] is null)
+ errors.Add(new FormValidationError(nameof(EventMappingsWorkState.SchemaDefinitionJson), EventMappingsMessages.TopicNameRequired));
+
+ if (defObj["jsonSchema"] is null && defObj["JsonSchema"] is null)
+ errors.Add(new FormValidationError(nameof(EventMappingsWorkState.SchemaDefinitionJson), EventMappingsMessages.JsonSchemaRequired));
+
+ if (errors.Count > 0)
+ return Stay(state, errors);
+
+ try
+ {
+ var root = await LoadCategoryRootAsync(state, CategorySchemaEvents, cancellationToken);
+ root[schemaKey] = definitionNode;
+ await UpsertCategoryAsync(state, CategorySchemaEvents, root, cancellationToken);
+ await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken);
+
+ state.SelectedEventType = schemaKey;
+ state.SelectedSchemaEventType = schemaKey;
+ return Redirect(
+ state,
+ EventMappingsMessages.SavedSchema(schemaKey),
+ refreshLocalCaches: true);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to save SchemaEvents for {TenantId}", state.TenantId);
+ return StayWithError(state, AdminApiErrorMapper.Format(ex, EventMappingsMessages.SaveSchemaFailed));
+ }
+ }
+
+ private async Task LoadPageDataAsync(EventMappingsWorkState state, CancellationToken cancellationToken)
+ {
+ await LoadCatalogueAsync(state, cancellationToken);
+ await LoadTemplateOptionsAsync(state, cancellationToken);
+
+ state.SchemaEvents = schemaEventDefinitionProvider.GetAll()
+ .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
+ .Select(kv => new SchemaEventRow(
+ kv.Key,
+ kv.Value.TopicName,
+ kv.Value.Version,
+ kv.Value.Description))
+ .ToList();
+
+ var eventOptions = state.Catalogue
+ .Select(e => new AdminSelectOption(
+ $"{e.EventTypeName} ({e.Kind})",
+ e.EventTypeName,
+ string.Equals(e.EventTypeName, state.SelectedEventType, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+
+ foreach (var schema in state.SchemaEvents)
+ {
+ if (eventOptions.Any(o => string.Equals(o.Value, schema.MessageType, StringComparison.OrdinalIgnoreCase)))
+ continue;
+ eventOptions.Add(new AdminSelectOption(
+ $"{schema.MessageType} (Schema)",
+ schema.MessageType,
+ string.Equals(schema.MessageType, state.SelectedEventType, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ state.EventTypeOptions = eventOptions
+ .OrderBy(o => o.Text, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ state.TriggerOptions = TriggerNames
+ .Select(t => new AdminSelectOption(t, t, string.Equals(t, state.TriggerName, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+
+ state.TriggerEventTypeOptions = state.EventTypeOptions
+ .Where(o => !string.Equals(o.Value, SystemOnlyEventType, StringComparison.OrdinalIgnoreCase))
+ .Select(o => new AdminSelectOption(
+ o.Text,
+ o.Value,
+ string.Equals(o.Value, state.TriggerEventType, StringComparison.OrdinalIgnoreCase)))
+ .ToList();
+
+ if (!string.IsNullOrWhiteSpace(state.SelectedEventType))
+ {
+ var item = state.Catalogue.FirstOrDefault(c =>
+ string.Equals(c.EventTypeName, state.SelectedEventType, StringComparison.OrdinalIgnoreCase));
+ state.ClrPropertyHints = item?.Properties ?? [];
+ }
+ }
+
+ private async Task LoadCatalogueAsync(EventMappingsWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var response = await tenantAdminClient.GetEventCatalogueAsync(cancellationToken);
+ state.Catalogue = (response.Events ?? [])
+ .Select(e => new EventCatalogueRow(
+ e.EventTypeName,
+ e.TopicName ?? "(no topic resolved)",
+ e.ClrTypeName,
+ e.Description,
+ e.Version,
+ string.IsNullOrWhiteSpace(e.Kind) ? EventPublishKind.Typed : e.Kind,
+ (e.Properties ?? []).Select(p => p.Name).ToList()))
+ .ToList();
+ state.CatalogueSource = "API";
+ return;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Event catalogue API unavailable; falling back to local registry.");
+ }
+
+ state.Catalogue = eventTypeRegistry.GetCatalogue()
+ .Select(e => new EventCatalogueRow(
+ e.EventTypeName,
+ e.TopicName ?? "(no topic resolved)",
+ e.ClrType.FullName ?? e.ClrType.Name,
+ Description: null,
+ Version: "local",
+ Kind: EventPublishKind.Typed,
+ Properties: e.ClrType.GetProperties().Select(p => p.Name).ToList()))
+ .ToList();
+ state.CatalogueSource = "local registry (API unavailable)";
+ }
+
+ private async Task LoadTemplateOptionsAsync(EventMappingsWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken) ?? [];
+ var allowed = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ state.TemplateOptions = templates
+ .Where(t => t.TemplateId != Guid.Empty)
+ .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(t =>
+ {
+ var id = t.TemplateId.ToString();
+ allowed.Add(id);
+ var label = string.IsNullOrWhiteSpace(t.Name) ? id : $"{t.Name} ({id})";
+ return new AdminSelectOption(
+ label,
+ id,
+ string.Equals(id, state.SelectedTemplateId, StringComparison.OrdinalIgnoreCase));
+ })
+ .ToList();
+
+ foreach (var template in templates.Where(t => t.TemplateId != Guid.Empty))
+ {
+ foreach (var key in await ResolveTemplateMappingKeysAsync(
+ template.TemplateId.ToString(),
+ cancellationToken))
+ {
+ allowed.Add(key);
+ }
+ }
+
+ state.AllowedTemplateKeys = allowed;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Could not load templates for EventMappings editor");
+ state.TemplateOptions = [];
+ state.AllowedTemplateKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
+ }
+ }
+
+ private async Task IsTemplateAllowedForCurrentTenantAsync(
+ EventMappingsWorkState state,
+ string templateId,
+ CancellationToken cancellationToken)
+ {
+ if (state.AllowedTemplateKeys.Count == 0)
+ await LoadTemplateOptionsAsync(state, cancellationToken);
+
+ return state.AllowedTemplateKeys.Contains(templateId);
+ }
+
+ private async Task> ResolveTemplateMappingKeysAsync(
+ string selectedTemplateId,
+ CancellationToken cancellationToken)
+ {
+ var keys = new List { selectedTemplateId };
+
+ if (!Guid.TryParse(selectedTemplateId, out var templateGuid))
+ return keys;
+
+ try
+ {
+ var schema = await templatesClient.GetLatestTemplateSchemaAsync(templateGuid, cancellationToken);
+ if (string.IsNullOrWhiteSpace(schema?.JsonSchema))
+ return keys;
+
+ var schemaText = schema.JsonSchema.Trim();
+ if (!schemaText.StartsWith('{') && !schemaText.StartsWith('['))
+ {
+ try
+ {
+ schemaText = Encoding.UTF8.GetString(Convert.FromBase64String(schemaText));
+ }
+ catch (FormatException)
+ {
+ return keys;
+ }
+ }
+
+ using var doc = JsonDocument.Parse(schemaText);
+ if (doc.RootElement.TryGetProperty("templateId", out var embeddedId)
+ && embeddedId.ValueKind == JsonValueKind.String
+ && !string.IsNullOrWhiteSpace(embeddedId.GetString()))
+ {
+ var schemaTemplateId = embeddedId.GetString()!.Trim();
+ if (!keys.Contains(schemaTemplateId, StringComparer.OrdinalIgnoreCase))
+ keys.Add(schemaTemplateId);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(
+ ex,
+ "Could not resolve schema templateId alias for EventMappings key {TemplateId}",
+ selectedTemplateId);
+ }
+
+ return keys;
+ }
+
+ private async Task LoadSavedTypedMappingsAsync(EventMappingsWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var root = await LoadCategoryRootAsync(state, CategoryEventMappings, cancellationToken);
+ var schemaNames = state.SchemaEvents
+ .Select(s => s.MessageType)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+
+ var rows = new List();
+ foreach (var templateProperty in root)
+ {
+ if (string.Equals(templateProperty.Key, "BasePath", StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ if (templateProperty.Value is not JsonObject templateNode)
+ continue;
+
+ foreach (var eventProperty in templateNode)
+ {
+ if (eventProperty.Value is not JsonObject mappingNode)
+ continue;
+
+ var eventType = eventProperty.Key;
+ if (schemaNames.Contains(eventType))
+ continue;
+
+ var mappingId = mappingNode["mappingId"]?.GetValue()
+ ?? mappingNode["MappingId"]?.GetValue()
+ ?? "—";
+ var description = mappingNode["description"]?.GetValue()
+ ?? mappingNode["Description"]?.GetValue();
+
+ rows.Add(new SavedMappingRow(
+ templateProperty.Key,
+ eventType,
+ mappingId,
+ description));
+ }
+ }
+
+ state.SavedTypedMappings = rows
+ .Where(r => state.AllowedTemplateKeys.Count == 0 || state.AllowedTemplateKeys.Contains(r.TemplateId))
+ .GroupBy(
+ r => $"{r.TemplateId}|{r.EventType}",
+ StringComparer.OrdinalIgnoreCase)
+ .Select(g => g.First())
+ .OrderBy(r => r.EventType, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(r => r.TemplateId, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Could not load saved typed EventMappings");
+ state.SavedTypedMappings = [];
+ }
+ }
+
+ private static JsonObject BuildBindingNode(string eventKind, string eventType, string mappingId) =>
+ new()
+ {
+ ["eventKind"] = eventKind,
+ ["eventType"] = eventType,
+ ["mappingId"] = mappingId
+ };
+
+ private static string? ReadBindingValue(JsonObject binding, string camelCaseName)
+ {
+ var pascalCaseName = char.ToUpperInvariant(camelCaseName[0]) + camelCaseName[1..];
+ var node = binding[camelCaseName] ?? binding[pascalCaseName];
+ return node?.GetValue();
+ }
+
+ private async Task LoadSavedTriggersAsync(EventMappingsWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var root = await LoadCategoryRootAsync(state, CategoryEventTriggers, cancellationToken);
+ var rows = new List();
+
+ foreach (var triggerProperty in root)
+ {
+ if (triggerProperty.Value is not JsonArray bindings)
+ continue;
+
+ foreach (var binding in bindings)
+ {
+ if (binding is not JsonObject obj)
+ continue;
+
+ var eventType = ReadBindingValue(obj, "eventType");
+ if (string.IsNullOrWhiteSpace(eventType))
+ continue;
+
+ rows.Add(new TriggerBindingRow(
+ triggerProperty.Key,
+ ReadBindingValue(obj, "eventKind") ?? EventPublishKind.Typed,
+ eventType,
+ ReadBindingValue(obj, "mappingId") ?? "—"));
+ }
+ }
+
+ state.SavedTriggers = rows
+ .OrderBy(r => r.Trigger, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(r => r.EventType, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Could not load saved EventTriggers");
+ state.SavedTriggers = [];
+ }
+ }
+
+ private async Task LoadSelectedSchemaDefinitionAsync(EventMappingsWorkState state, CancellationToken cancellationToken)
+ {
+ var key = state.SelectedSchemaEventType?.Trim();
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ if (string.IsNullOrWhiteSpace(state.SchemaDefinitionJson))
+ state.SchemaDefinitionJson = GetEmptySchemaTemplate();
+ return;
+ }
+
+ state.NewSchemaEventType = key;
+ state.SelectedSchemaEventType = key;
+
+ try
+ {
+ var root = await LoadCategoryRootAsync(state, CategorySchemaEvents, cancellationToken);
+ JsonNode? definitionNode = null;
+ foreach (var property in root)
+ {
+ if (string.Equals(property.Key, key, StringComparison.OrdinalIgnoreCase))
+ {
+ definitionNode = property.Value;
+ state.NewSchemaEventType = property.Key;
+ state.SelectedSchemaEventType = property.Key;
+ break;
+ }
+ }
+
+ if (definitionNode is not null)
+ {
+ state.SchemaDefinitionJson = definitionNode.ToJsonString(JsonWriteOptions);
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Could not load SchemaEvents definition for {SchemaKey}", key);
+ }
+
+ var def = schemaEventDefinitionProvider.GetDefinition(key);
+ if (def is not null)
+ {
+ state.SchemaDefinitionJson = JsonSerializer.Serialize(new
+ {
+ topicName = def.TopicName,
+ version = def.Version,
+ description = def.Description,
+ jsonSchema = def.JsonSchema
+ ?? new Dictionary { ["type"] = "object", ["properties"] = new { } }
+ }, JsonWriteOptions);
+ return;
+ }
+
+ state.SchemaDefinitionJson = GetEmptySchemaTemplate();
+ }
+
+ private async Task LoadSelectedMappingAsync(EventMappingsWorkState state, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(state.SelectedTemplateId) || string.IsNullOrWhiteSpace(state.SelectedEventType))
+ {
+ state.MappingJson = GetEmptyMappingTemplate(state.SelectedEventType);
+ return;
+ }
+
+ try
+ {
+ var root = await LoadCategoryRootAsync(state, CategoryEventMappings, cancellationToken);
+ var lookupKeys = await ResolveTemplateMappingKeysAsync(state.SelectedTemplateId, cancellationToken);
+ foreach (var key in lookupKeys)
+ {
+ if (root[key] is JsonObject template
+ && template[state.SelectedEventType] is JsonNode mappingNode)
+ {
+ state.MappingJson = mappingNode.ToJsonString(JsonWriteOptions);
+ return;
+ }
+ }
+
+ foreach (var property in root)
+ {
+ if (property.Value is JsonObject template
+ && template[state.SelectedEventType] is JsonNode mappingNode)
+ {
+ state.MappingJson = mappingNode.ToJsonString(JsonWriteOptions);
+ return;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Could not load existing EventMappings for editor");
+ }
+
+ state.MappingJson = GetEmptyMappingTemplate(state.SelectedEventType);
+ }
+
+ private async Task LoadCategoryRootAsync(
+ EventMappingsWorkState state,
+ string category,
+ CancellationToken cancellationToken)
+ {
+ var response = await tenantAdminClient.GetSafeTenantSettingsAsync(state.TenantId, cancellationToken);
+ state.TenantName = response.TenantName ?? state.TenantName;
+
+ var candidates = (response.Settings ?? [])
+ .Where(s => string.Equals(s.Category, category, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ var setting = candidates.FirstOrDefault(s => string.Equals(s.Target, TargetShared, StringComparison.OrdinalIgnoreCase))
+ ?? candidates.FirstOrDefault(s => string.Equals(s.Target, TargetWeb, StringComparison.OrdinalIgnoreCase))
+ ?? candidates.FirstOrDefault();
+
+ if (string.IsNullOrWhiteSpace(setting?.SettingsJson))
+ return new JsonObject();
+
+ try
+ {
+ return JsonNode.Parse(setting.SettingsJson) as JsonObject ?? new JsonObject();
+ }
+ catch (JsonException)
+ {
+ return new JsonObject();
+ }
+ }
+
+ private async Task UpsertCategoryAsync(
+ EventMappingsWorkState state,
+ string category,
+ JsonObject root,
+ CancellationToken cancellationToken)
+ {
+ var payloadJson = root.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
+ await tenantAdminClient.UpsertSafeTenantSettingAsync(
+ state.TenantId,
+ new UpsertTenantSettingRequest(
+ category,
+ TargetShared,
+ AdminSettingsEncoding.ToBase64(payloadJson),
+ IsSecret: false),
+ cancellationToken);
+ }
+
+ private static string GetEmptyMappingTemplate(string? eventType)
+ {
+ var mapping = new EventFieldMapping
+ {
+ MappingId = string.IsNullOrWhiteSpace(eventType)
+ ? "mapping-v1"
+ : $"{ToKebab(eventType)}-v1",
+ EventType = eventType ?? string.Empty,
+ Description = null,
+ FieldMappings = new Dictionary()
+ };
+
+ return JsonSerializer.Serialize(mapping, JsonWriteOptions);
+ }
+
+ private static string GetEmptySchemaTemplate() =>
+ JsonSerializer.Serialize(new
+ {
+ topicName = "my-custom-topic",
+ version = "1.0",
+ description = "Tenant-defined schema event",
+ jsonSchema = new
+ {
+ type = "object",
+ properties = new { }
+ }
+ }, JsonWriteOptions);
+
+ private static string ToKebab(string value)
+ {
+ var sb = new StringBuilder();
+ for (var i = 0; i < value.Length; i++)
+ {
+ var c = value[i];
+ if (char.IsUpper(c) && i > 0)
+ sb.Append('-');
+ sb.Append(char.ToLowerInvariant(c));
+ }
+
+ return sb.ToString();
+ }
+
+ private static AdminPageOutcome Stay(EventMappingsWorkState state, IReadOnlyList errors) =>
+ AdminPageOutcome.Stay(errors: errors);
+
+ private static AdminPageOutcome StayWithError(EventMappingsWorkState state, string errorMessage)
+ {
+ state.HasError = true;
+ state.ErrorMessage = errorMessage;
+ return AdminPageOutcome.Stay(errorMessage: errorMessage);
+ }
+
+ private static AdminPageOutcome Redirect(
+ EventMappingsWorkState state,
+ string? successMessage = null,
+ string? errorMessage = null,
+ bool refreshLocalCaches = false) =>
+ AdminPageOutcome.Redirect(
+ successMessage: successMessage,
+ errorMessage: errorMessage,
+ refreshLocalCaches: refreshLocalCaches,
+ routeValues: new Dictionary
+ {
+ ["SelectedTemplateId"] = state.SelectedTemplateId,
+ ["SelectedEventType"] = state.SelectedEventType,
+ ["SelectedSchemaEventType"] = state.SelectedSchemaEventType
+ });
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsMessages.cs
new file mode 100644
index 0000000..8e85f17
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsMessages.cs
@@ -0,0 +1,74 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for Event Mappings. Keep these strings identical to the previous PageModel.
+///
+public static class EventMappingsMessages
+{
+ public const string TenantContextMissing = "Tenant context is not available for this request.";
+
+ public const string SelectTrigger = "Select a trigger.";
+
+ public const string SelectEventType = "Select an event type.";
+
+ public const string EnterMappingId = "Enter the mapping ID to use.";
+
+ public const string EventKindMustBeTypedOrSchema = "Event kind must be Typed or Schema.";
+
+ public const string SaveTriggerFailed = "Could not save event trigger.";
+
+ public const string DeleteTriggerUnidentified = "Could not identify the trigger binding to remove.";
+
+ public const string DeleteTriggerFailed = "Could not remove event trigger.";
+
+ public const string SelectTemplate = "Select a template.";
+
+ public const string SelectTenantTemplate = "Select a template that belongs to this tenant.";
+
+ public const string EnterMappingJson = "Enter mapping JSON.";
+
+ public const string MappingParseFailed = "Mapping JSON could not be parsed.";
+
+ public const string MappingIdRequired = "mappingId is required.";
+
+ public const string EventTypeMustMatch = "eventType in JSON must match the selected event type.";
+
+ public const string FieldMappingsRequired = "fieldMappings must contain at least one property.";
+
+ public const string SaveMappingFailed = "Could not save event mapping.";
+
+ public const string EnterSchemaEventType = "Enter a schema event type name.";
+
+ public const string EnterSchemaDefinitionJson = "Enter schema definition JSON.";
+
+ public const string SchemaMustBeObject = "Schema definition must be a JSON object.";
+
+ public const string TopicNameRequired = "topicName is required.";
+
+ public const string JsonSchemaRequired = "jsonSchema is required.";
+
+ public const string SaveSchemaFailed = "Could not save schema event.";
+
+ public static string SystemOnlyEventType(string eventType) =>
+ $"{eventType} is published by the platform for every upload and cannot be configured here.";
+
+ public static string InvalidJson(string message) => $"Invalid JSON: {message}";
+
+ public static string SavedTrigger(string eventType, string trigger) =>
+ $"Saved {eventType} on the {trigger} trigger.";
+
+ public static string RemovedTrigger(string eventType, string trigger) =>
+ $"Removed {eventType} from the {trigger} trigger.";
+
+ public static string SavedMapping(string keysLabel, string eventType) =>
+ $"Saved mapping for template key(s) [{keysLabel}] / {eventType}.";
+
+ public static string SavedSchema(string schemaKey) =>
+ $"Saved schema event '{schemaKey}'.";
+
+ public static string TypedEventNameClash(string schemaKey) =>
+ $"'{schemaKey}' is a platform typed event. Choose a different name for schema events.";
+
+ public static string UnknownProperty(string property, string eventType) =>
+ $"Property '{property}' is not on {eventType}.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsModels.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsModels.cs
new file mode 100644
index 0000000..8370ce0
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsModels.cs
@@ -0,0 +1,30 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+public sealed record EventCatalogueRow(
+ string EventTypeName,
+ string TopicName,
+ string ClrTypeName,
+ string? Description,
+ string Version,
+ string Kind,
+ IReadOnlyList Properties);
+
+public sealed record SchemaEventRow(
+ string MessageType,
+ string TopicName,
+ string Version,
+ string? Description);
+
+public sealed record SavedMappingRow(
+ string TemplateId,
+ string EventType,
+ string MappingId,
+ string? Description);
+
+public sealed record TriggerBindingRow(
+ string Trigger,
+ string EventKind,
+ string EventType,
+ string MappingId);
+
+public sealed record MetadataKeyHint(string Key, string Description);
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsWorkState.cs
new file mode 100644
index 0000000..fab91a0
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/EventMappingsWorkState.cs
@@ -0,0 +1,60 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for the Event Mappings admin page.
+///
+public sealed class EventMappingsWorkState
+{
+ public Guid TenantId { get; set; }
+
+ public string TenantName { get; set; } = string.Empty;
+
+ public bool HasError { get; set; }
+
+ public string? ErrorMessage { get; set; }
+
+ public IReadOnlyList TemplateOptions { get; set; } = [];
+
+ public IReadOnlyList EventTypeOptions { get; set; } = [];
+
+ public IReadOnlyList Catalogue { get; set; } = [];
+
+ public IReadOnlyList SchemaEvents { get; set; } = [];
+
+ public IReadOnlyList SavedTypedMappings { get; set; } = [];
+
+ public IReadOnlyList TriggerOptions { get; set; } = [];
+
+ public IReadOnlyList TriggerEventTypeOptions { get; set; } = [];
+
+ public IReadOnlyList SavedTriggers { get; set; } = [];
+
+ public IReadOnlySet AllowedTemplateKeys { get; set; } =
+ new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ public IReadOnlyList ClrPropertyHints { get; set; } = [];
+
+ public IReadOnlyList ValidationWarnings { get; set; } = [];
+
+ public string? CatalogueSource { get; set; }
+
+ public string? SelectedTemplateId { get; set; }
+
+ public string? SelectedEventType { get; set; }
+
+ public string? SelectedSchemaEventType { get; set; }
+
+ public string? MappingJson { get; set; }
+
+ public string? SchemaDefinitionJson { get; set; }
+
+ public string? NewSchemaEventType { get; set; }
+
+ public string? TriggerName { get; set; }
+
+ public string? TriggerEventKind { get; set; }
+
+ public string? TriggerEventType { get; set; }
+
+ public string? TriggerMappingId { get; set; }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsAdminService.cs
new file mode 100644
index 0000000..15078d6
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsAdminService.cs
@@ -0,0 +1,201 @@
+using System.Text.Json;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Loads and saves non-secret organisation settings (terminology, banner, dashboard).
+///
+public interface IOrganisationSettingsAdmin
+{
+ Task LoadAsync(OrganisationSettingsWorkState state, CancellationToken cancellationToken = default);
+
+ Task SaveAsync(OrganisationSettingsWorkState state, CancellationToken cancellationToken = default);
+}
+
+public sealed class OrganisationSettingsAdminService(
+ ITenantAdminClient tenantAdminClient,
+ ILogger logger) : IOrganisationSettingsAdmin
+{
+ private const string TargetWeb = "Web";
+ private const string CategoryTerminology = "ApplicationTerminology";
+ private const string CategoryBanner = "NotificationBanner";
+ private const string CategoryDashboard = "Dashboard";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ WriteIndented = false
+ };
+
+ public async Task LoadAsync(OrganisationSettingsWorkState state, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var response = await tenantAdminClient.GetSafeTenantSettingsAsync(state.TenantId, cancellationToken);
+ state.TenantName = response.TenantName;
+
+ foreach (var setting in response.Settings ?? [])
+ {
+ ApplySettingJson(state, setting.Category, setting.SettingsJson);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load organisation settings for {TenantId}", state.TenantId);
+ state.HasError = true;
+ state.ErrorMessage = AdminApiErrorMapper.Format(
+ ex,
+ OrganisationSettingsMessages.LoadFailed,
+ includeGatewayHint: false);
+ }
+ }
+
+ public async Task SaveAsync(
+ OrganisationSettingsWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await UpsertCategoryAsync(
+ state.TenantId,
+ CategoryTerminology,
+ new { Singular = state.TerminologySingular, Plural = state.TerminologyPlural },
+ cancellationToken);
+
+ await UpsertCategoryAsync(
+ state.TenantId,
+ CategoryBanner,
+ new { Enabled = state.BannerEnabled, Heading = state.BannerHeading, Message = state.BannerMessage },
+ cancellationToken);
+
+ await UpsertCategoryAsync(
+ state.TenantId,
+ CategoryDashboard,
+ new { PageSize = state.DashboardPageSize, EnableApplicationFilters = state.DashboardEnableFilters },
+ cancellationToken);
+
+ await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken);
+
+ return AdminPageOutcome.Redirect(
+ successMessage: OrganisationSettingsMessages.Saved,
+ refreshLocalCaches: true);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to save organisation settings for {TenantId}", state.TenantId);
+ var message = AdminApiErrorMapper.Format(
+ ex,
+ OrganisationSettingsMessages.SaveFailed,
+ includeGatewayHint: false);
+ state.HasError = true;
+ state.ErrorMessage = message;
+ return AdminPageOutcome.Stay(errorMessage: message);
+ }
+ }
+
+ private async Task UpsertCategoryAsync(
+ Guid tenantId,
+ string category,
+ object payload,
+ CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(payload, JsonOptions);
+ await tenantAdminClient.UpsertSafeTenantSettingAsync(
+ tenantId,
+ new UpsertTenantSettingRequest(category, TargetWeb, AdminSettingsEncoding.ToBase64(json), IsSecret: false),
+ cancellationToken);
+ }
+
+ private void ApplySettingJson(OrganisationSettingsWorkState state, string category, string? settingsJson)
+ {
+ if (string.IsNullOrWhiteSpace(settingsJson))
+ return;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(settingsJson);
+ var root = doc.RootElement;
+
+ if (string.Equals(category, CategoryTerminology, StringComparison.OrdinalIgnoreCase))
+ {
+ if (TryGetString(root, "Singular", out var singular))
+ state.TerminologySingular = singular;
+ if (TryGetString(root, "Plural", out var plural))
+ state.TerminologyPlural = plural;
+ }
+ else if (string.Equals(category, CategoryBanner, StringComparison.OrdinalIgnoreCase))
+ {
+ if (TryGetBool(root, "Enabled", out var enabled))
+ state.BannerEnabled = enabled;
+ if (TryGetString(root, "Heading", out var heading))
+ state.BannerHeading = heading;
+ if (TryGetString(root, "Message", out var message))
+ state.BannerMessage = message;
+ }
+ else if (string.Equals(category, CategoryDashboard, StringComparison.OrdinalIgnoreCase))
+ {
+ if (TryGetInt(root, "PageSize", out var pageSize))
+ state.DashboardPageSize = pageSize;
+ if (TryGetBool(root, "EnableApplicationFilters", out var filters))
+ state.DashboardEnableFilters = filters;
+ }
+ }
+ catch (JsonException ex)
+ {
+ logger.LogWarning(ex, "Could not parse settings JSON for category {Category}", category);
+ }
+ }
+
+ private static bool TryGetString(JsonElement root, string name, out string value)
+ {
+ value = string.Empty;
+ if (!TryGetProperty(root, name, out var prop) || prop.ValueKind != JsonValueKind.String)
+ return false;
+ value = prop.GetString() ?? string.Empty;
+ return true;
+ }
+
+ private static bool TryGetBool(JsonElement root, string name, out bool value)
+ {
+ value = false;
+ if (!TryGetProperty(root, name, out var prop))
+ return false;
+
+ if (prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False)
+ {
+ value = prop.GetBoolean();
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryGetInt(JsonElement root, string name, out int value)
+ {
+ value = 0;
+ if (!TryGetProperty(root, name, out var prop) || prop.ValueKind != JsonValueKind.Number)
+ return false;
+ return prop.TryGetInt32(out value);
+ }
+
+ private static bool TryGetProperty(JsonElement root, string name, out JsonElement property)
+ {
+ if (root.TryGetProperty(name, out property))
+ return true;
+
+ foreach (var p in root.EnumerateObject())
+ {
+ if (string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase))
+ {
+ property = p.Value;
+ return true;
+ }
+ }
+
+ property = default;
+ return false;
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsMessages.cs
new file mode 100644
index 0000000..8e355a9
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsMessages.cs
@@ -0,0 +1,15 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for Organisation Settings. Keep these strings identical to the previous PageModel.
+///
+public static class OrganisationSettingsMessages
+{
+ public const string TenantContextMissing = "Tenant context is not available for this request.";
+
+ public const string Saved = "Organisation settings saved.";
+
+ public const string SaveFailed = "Could not save organisation settings.";
+
+ public const string LoadFailed = "Could not load organisation settings.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsWorkState.cs
new file mode 100644
index 0000000..020a386
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/OrganisationSettingsWorkState.cs
@@ -0,0 +1,29 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for Organisation Settings.
+///
+public sealed class OrganisationSettingsWorkState
+{
+ public Guid TenantId { get; set; }
+
+ public string TenantName { get; set; } = string.Empty;
+
+ public string TerminologySingular { get; set; } = "application";
+
+ public string TerminologyPlural { get; set; } = "applications";
+
+ public bool BannerEnabled { get; set; }
+
+ public string? BannerHeading { get; set; } = "Important";
+
+ public string? BannerMessage { get; set; } = string.Empty;
+
+ public int DashboardPageSize { get; set; } = 50;
+
+ public bool DashboardEnableFilters { get; set; }
+
+ public bool HasError { get; set; }
+
+ public string? ErrorMessage { get; set; }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerAdminService.cs
new file mode 100644
index 0000000..bfd8fd3
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerAdminService.cs
@@ -0,0 +1,148 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using GovUK.Dfe.FlexForms.Application.Validation;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Lists and creates tenant roles.
+///
+public interface IRoleManagerAdmin
+{
+ Task LoadAsync(RoleManagerWorkState state, CancellationToken cancellationToken = default);
+
+ Task CreateAsync(RoleManagerWorkState state, CancellationToken cancellationToken = default);
+
+ Task CreateFromTemplateAsync(
+ RoleManagerWorkState state,
+ string? templateKey,
+ CancellationToken cancellationToken = default);
+
+ Task DeleteAsync(
+ RoleManagerWorkState state,
+ Guid roleId,
+ CancellationToken cancellationToken = default);
+
+ Task RenameAsync(
+ RoleManagerWorkState state,
+ Guid roleId,
+ string? name,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class RoleManagerAdminService(
+ IRolesClient rolesClient,
+ ILogger logger) : IRoleManagerAdmin
+{
+ public async Task LoadAsync(RoleManagerWorkState state, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var roles = await rolesClient.ListAsync(cancellationToken);
+ state.Roles = roles?
+ .OrderBy(r => r.IsSystem ? 0 : 1)
+ .ThenBy(r => r.Name)
+ .ToList() ?? [];
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load tenant roles");
+ state.HasError = true;
+ state.ErrorMessage = AdminApiErrorMapper.Format(ex, RoleManagerMessages.LoadFailed);
+ state.Roles = [];
+ }
+ }
+
+ public async Task CreateAsync(
+ RoleManagerWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var created = await rolesClient.CreateAsync(
+ new CreateTenantRoleRequest { Name = state.NewRoleName.Trim() },
+ cancellationToken);
+
+ return AdminPageOutcome.Redirect(successMessage: RoleManagerMessages.Created(created.Name));
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to create role {Name}", state.NewRoleName);
+ return AdminPageOutcome.Stay(errors:
+ [
+ new FormValidationError(
+ string.Empty,
+ AdminApiErrorMapper.Format(ex, RoleManagerMessages.CreateFailed))
+ ]);
+ }
+ }
+
+ public async Task CreateFromTemplateAsync(
+ RoleManagerWorkState state,
+ string? templateKey,
+ CancellationToken cancellationToken = default)
+ {
+ templateKey = templateKey?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(templateKey))
+ return AdminPageOutcome.Redirect(errorMessage: RoleManagerMessages.TemplateRequired);
+
+ try
+ {
+ var created = await rolesClient.CreateFromTemplateAsync(
+ new CreateTenantRoleFromTemplateRequest(templateKey),
+ cancellationToken);
+ return AdminPageOutcome.Redirect(
+ successMessage: RoleManagerMessages.CreatedFromTemplate(created.Name, templateKey));
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to create role from template {TemplateKey}", templateKey);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, RoleManagerMessages.CreateFromTemplateFailed));
+ }
+ }
+
+ public async Task DeleteAsync(
+ RoleManagerWorkState state,
+ Guid roleId,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await rolesClient.DeleteAsync(roleId, cancellationToken);
+ return AdminPageOutcome.Redirect(successMessage: RoleManagerMessages.Deleted);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to delete role {RoleId}", roleId);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, RoleManagerMessages.DeleteFailed));
+ }
+ }
+
+ public async Task RenameAsync(
+ RoleManagerWorkState state,
+ Guid roleId,
+ string? name,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ return AdminPageOutcome.Redirect(errorMessage: RoleManagerMessages.NameRequired);
+
+ try
+ {
+ await rolesClient.RenameAsync(
+ roleId,
+ new RenameTenantRoleRequest { Name = name.Trim() },
+ cancellationToken);
+ return AdminPageOutcome.Redirect(successMessage: RoleManagerMessages.Renamed);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to rename role {RoleId}", roleId);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, RoleManagerMessages.RenameFailed));
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerMessages.cs
new file mode 100644
index 0000000..0799d40
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerMessages.cs
@@ -0,0 +1,31 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for Role Manager. Keep these strings identical to the previous PageModel.
+///
+public static class RoleManagerMessages
+{
+ public const string TemplateRequired = "Choose a role template.";
+
+ public const string NameRequired = "Enter a role name.";
+
+ public const string Deleted = "Role deleted.";
+
+ public const string Renamed = "Role renamed.";
+
+ public const string CreateFailed = "Could not create the role.";
+
+ public const string CreateFromTemplateFailed = "Could not create the role from template.";
+
+ public const string DeleteFailed = "Could not delete the role.";
+
+ public const string RenameFailed = "Could not rename the role.";
+
+ public const string LoadFailed = "Could not load roles for this tenant.";
+
+ public static string Created(string name) =>
+ $"Role '{name}' has been created.";
+
+ public static string CreatedFromTemplate(string name, string templateKey) =>
+ $"Role '{name}' has been created from the {templateKey} template.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsAdminService.cs
new file mode 100644
index 0000000..e471b71
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsAdminService.cs
@@ -0,0 +1,204 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using GovUK.Dfe.FlexForms.Application.Validation;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Sets RolePermissions for a custom tenant role (ResourceType + ResourceKey + AccessType).
+///
+public interface IRoleManagerPermissionsAdmin
+{
+ Task LoadAsync(
+ RoleManagerPermissionsWorkState state,
+ CancellationToken cancellationToken = default);
+
+ Task AddGrantAsync(
+ RoleManagerPermissionsWorkState state,
+ CancellationToken cancellationToken = default);
+
+ Task RemoveGrantAsync(
+ RoleManagerPermissionsWorkState state,
+ string grantKey,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class RoleManagerPermissionsAdminService(
+ IRolesClient rolesClient,
+ ILogger logger) : IRoleManagerPermissionsAdmin
+{
+ public async Task LoadAsync(
+ RoleManagerPermissionsWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ if (await LoadRoleMetaAsync(state, cancellationToken) is { } failure)
+ return failure;
+
+ if (state.IsSystemRole)
+ {
+ return AdminPageOutcome.Redirect(
+ errorMessage: RoleManagerPermissionsMessages.SystemRoleCannotChangeCreateCustom);
+ }
+
+ try
+ {
+ await LoadPermissionsAsync(state, cancellationToken);
+ return AdminPageOutcome.Stay();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load permissions for role {RoleId}", state.RoleId);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, RoleManagerPermissionsMessages.LoadPermissionsFailed));
+ }
+ }
+
+ public async Task AddGrantAsync(
+ RoleManagerPermissionsWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ if (await LoadRoleMetaAsync(state, cancellationToken) is { } failure)
+ return failure;
+
+ if (state.IsSystemRole)
+ return AdminPageOutcome.Redirect(errorMessage: RoleManagerPermissionsMessages.SystemRoleCannotChange);
+
+ state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants);
+
+ var resourceKey = state.NewResourceKey?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(resourceKey))
+ {
+ return AdminPageOutcome.Stay(errors:
+ [
+ new FormValidationError(
+ nameof(RoleManagerPermissionsWorkState.NewResourceKey),
+ RoleManagerPermissionsMessages.ResourceKeyRequired)
+ ]);
+ }
+
+ var validationError = AdminPermissionGrants.ValidateGrant(
+ state.NewResourceType,
+ resourceKey,
+ state.NewAccessType);
+ if (validationError is not null)
+ {
+ return AdminPageOutcome.Stay(errors:
+ [
+ new FormValidationError(nameof(RoleManagerPermissionsWorkState.NewResourceKey), validationError)
+ ]);
+ }
+
+ var key = AdminPermissionGrants.EncodeGrantKey(state.NewResourceType, resourceKey, state.NewAccessType);
+ if (state.SelectedGrants.Contains(key, StringComparer.OrdinalIgnoreCase))
+ {
+ return AdminPageOutcome.Stay(errors:
+ [
+ new FormValidationError(
+ string.Empty,
+ RoleManagerPermissionsMessages.DuplicateGrant(
+ state.NewResourceType.ToString(),
+ resourceKey,
+ state.NewAccessType.ToString()))
+ ]);
+ }
+
+ state.SelectedGrants.Add(key);
+ state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants);
+
+ return await SaveAndReloadAsync(state, cancellationToken);
+ }
+
+ public async Task RemoveGrantAsync(
+ RoleManagerPermissionsWorkState state,
+ string grantKey,
+ CancellationToken cancellationToken = default)
+ {
+ if (await LoadRoleMetaAsync(state, cancellationToken) is { } failure)
+ return failure;
+
+ if (state.IsSystemRole)
+ return AdminPageOutcome.Redirect(errorMessage: RoleManagerPermissionsMessages.SystemRoleCannotChange);
+
+ state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants);
+ state.SelectedGrants.RemoveAll(g => string.Equals(g, grantKey, StringComparison.OrdinalIgnoreCase));
+
+ return await SaveAndReloadAsync(state, cancellationToken);
+ }
+
+ private async Task SaveAndReloadAsync(
+ RoleManagerPermissionsWorkState state,
+ CancellationToken cancellationToken)
+ {
+ foreach (var grant in state.SelectedGrants.Select(AdminPermissionGrants.ParseGrantKey).Where(g => g is not null))
+ {
+ var error = AdminPermissionGrants.ValidateGrant(
+ grant!.Value.ResourceType,
+ grant.Value.ResourceKey,
+ grant.Value.AccessType);
+ if (error is not null)
+ return AdminPageOutcome.Stay(errors: [new FormValidationError(string.Empty, error)]);
+ }
+
+ try
+ {
+ var grants = AdminPermissionGrants.ToGrantDtos(state.SelectedGrants);
+
+ await rolesClient.SetPermissionsAsync(
+ state.RoleId,
+ new SetRolePermissionsRequest { Permissions = grants },
+ cancellationToken);
+
+ state.NewResourceKey = string.Empty;
+ await LoadPermissionsAsync(state, cancellationToken);
+ return AdminPageOutcome.Stay();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to set permissions for role {RoleId}", state.RoleId);
+ await LoadPermissionsAsync(state, cancellationToken);
+
+ return AdminPageOutcome.Stay(errors:
+ [
+ new FormValidationError(
+ string.Empty,
+ AdminApiErrorMapper.Format(ex, RoleManagerPermissionsMessages.SaveFailed))
+ ]);
+ }
+ }
+
+ private async Task LoadPermissionsAsync(
+ RoleManagerPermissionsWorkState state,
+ CancellationToken cancellationToken)
+ {
+ var existing = await rolesClient.GetPermissionsAsync(state.RoleId, cancellationToken);
+ state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(
+ existing?
+ .Select(p => AdminPermissionGrants.EncodeGrantKey(p.ResourceType, p.ResourceKey, p.AccessType))
+ .ToList() ?? []);
+ }
+
+ /// A redirect outcome when the role cannot be loaded; otherwise null.
+ private async Task LoadRoleMetaAsync(
+ RoleManagerPermissionsWorkState state,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var roles = await rolesClient.ListAsync(cancellationToken);
+ var role = roles?.FirstOrDefault(r => r.RoleId == state.RoleId);
+ if (role is null)
+ return AdminPageOutcome.Redirect(errorMessage: RoleManagerPermissionsMessages.RoleNotFound);
+
+ state.RoleName = role.Name;
+ state.IsSystemRole = role.IsSystem;
+ return null;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load role {RoleId}", state.RoleId);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, RoleManagerPermissionsMessages.LoadRoleFailed));
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsMessages.cs
new file mode 100644
index 0000000..5340829
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsMessages.cs
@@ -0,0 +1,25 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for Role Manager Permissions. Keep these strings identical to the previous PageModel.
+///
+public static class RoleManagerPermissionsMessages
+{
+ public const string SystemRoleCannotChange = "System role permissions cannot be changed.";
+
+ public const string SystemRoleCannotChangeCreateCustom =
+ "System role permissions cannot be changed. Create a custom role instead.";
+
+ public const string RoleNotFound = "Role not found.";
+
+ public const string ResourceKeyRequired = "Enter a resource key.";
+
+ public const string SaveFailed = "Could not save permissions.";
+
+ public const string LoadPermissionsFailed = "Could not load role permissions.";
+
+ public const string LoadRoleFailed = "Could not load role.";
+
+ public static string DuplicateGrant(string resourceType, string resourceKey, string accessType) =>
+ $"{resourceType} / {resourceKey} / {accessType} is already in the list.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsWorkState.cs
new file mode 100644
index 0000000..8a2ca12
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerPermissionsWorkState.cs
@@ -0,0 +1,23 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for the Role Manager Permissions admin page.
+///
+public sealed class RoleManagerPermissionsWorkState
+{
+ public Guid RoleId { get; set; }
+
+ public string RoleName { get; set; } = string.Empty;
+
+ public bool IsSystemRole { get; set; }
+
+ public List SelectedGrants { get; set; } = [];
+
+ public ResourceType NewResourceType { get; set; } = ResourceType.Application;
+
+ public string? NewResourceKey { get; set; }
+
+ public AccessType NewAccessType { get; set; } = AccessType.Read;
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerWorkState.cs
new file mode 100644
index 0000000..2d3f908
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/RoleManagerWorkState.cs
@@ -0,0 +1,17 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for the Role Manager admin page.
+///
+public sealed class RoleManagerWorkState
+{
+ public IReadOnlyList Roles { get; set; } = [];
+
+ public bool HasError { get; set; }
+
+ public string? ErrorMessage { get; set; }
+
+ public string NewRoleName { get; set; } = string.Empty;
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerAdminService.cs
new file mode 100644
index 0000000..dbc63b3
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerAdminService.cs
@@ -0,0 +1,226 @@
+using System.Text;
+using System.Text.Json;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using GovUK.Dfe.FlexForms.Application.Validation;
+using GovUK.Dfe.FlexForms.Domain.Models;
+using GovUK.Dfe.FlexForms.Domain.Templates;
+using Microsoft.Extensions.Logging;
+using Task = System.Threading.Tasks.Task;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Loads template versions, validates schema, creates versions, and grants tenant-wide access.
+///
+public interface ITemplateManagerAdmin
+{
+ Task LoadTemplateDataAsync(TemplateManagerWorkState state, Guid templateId, CancellationToken cancellationToken = default);
+
+ void PrefillNewSchemaIfEmpty(TemplateManagerWorkState state, Guid templateId);
+
+ AdminPageOutcome ValidateNewVersion(TemplateManagerWorkState state);
+
+ Task CreateVersionAsync(TemplateManagerWorkState state, Guid templateId, CancellationToken cancellationToken = default);
+
+ string SuggestNextVersion(string? latestVersion, string? currentVersion);
+
+ Task GrantToAllUsersAsync(TemplateManagerWorkState state, Guid templateId, CancellationToken cancellationToken = default);
+}
+
+public sealed class TemplateManagerAdminService(
+ ITemplatesClient templatesClient,
+ ITemplateValidationService templateValidationService,
+ ILogger logger) : ITemplateManagerAdmin
+{
+ public async Task LoadTemplateDataAsync(
+ TemplateManagerWorkState state,
+ Guid templateId,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ logger.LogDebug("Loading template data for {TemplateId}", templateId);
+
+ state.SelectedTemplate = state.TenantTemplates.First(template => template.TemplateId == templateId);
+ state.SelectedTemplateId = templateId;
+ state.LatestVersionNumber = state.SelectedTemplate.LatestVersionNumber;
+
+ var versions = await templatesClient.GetTemplateVersionsAsync(templateId);
+ state.AvailableVersions = versions.ToList();
+
+ if (state.AvailableVersions.Count == 0)
+ {
+ state.CurrentVersionNumber = null;
+ state.SelectedVersionNumber = null;
+ state.CurrentTemplate = null;
+ state.CurrentTemplateJson = null;
+ return;
+ }
+
+ var requestedVersion = state.SelectedVersionNumber ?? state.SessionVersionNumber;
+ var selectedVersion = state.AvailableVersions.FirstOrDefault(v =>
+ !string.IsNullOrWhiteSpace(requestedVersion) &&
+ string.Equals(v.VersionNumber, requestedVersion, StringComparison.OrdinalIgnoreCase))
+ ?? state.AvailableVersions[0];
+
+ state.SelectedVersionNumber = selectedVersion.VersionNumber;
+ state.CurrentVersionNumber = selectedVersion.VersionNumber;
+ state.LatestVersionNumber = state.AvailableVersions[0].VersionNumber;
+ state.SessionVersionNumber = selectedVersion.VersionNumber;
+
+ var apiResponse = await templatesClient.GetTemplateSchemaByVersionAsync(
+ templateId,
+ selectedVersion.VersionNumber);
+
+ var schemaJson = apiResponse.JsonSchema;
+ if (string.IsNullOrWhiteSpace(schemaJson))
+ {
+ state.CurrentTemplate = null;
+ state.CurrentTemplateJson = null;
+ return;
+ }
+
+ var options = new JsonSerializerOptions { WriteIndented = true, PropertyNameCaseInsensitive = true };
+ state.CurrentTemplate = JsonSerializer.Deserialize(schemaJson, options);
+ state.CurrentTemplateJson = state.CurrentTemplate != null
+ ? JsonSerializer.Serialize(state.CurrentTemplate, options)
+ : PrettyPrintJson(schemaJson);
+
+ logger.LogDebug(
+ "Loaded template {TemplateId} version {VersionNumber} with {TaskGroupCount} task groups",
+ templateId,
+ state.CurrentVersionNumber,
+ state.CurrentTemplate?.TaskGroups?.Count ?? 0);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error loading template data for {TemplateId}", templateId);
+ state.HasError = true;
+ state.ErrorMessage = TemplateManagerMessages.LoadFailed;
+ }
+ }
+
+ public void PrefillNewSchemaIfEmpty(TemplateManagerWorkState state, Guid templateId)
+ {
+ if (!state.ShowAddVersionForm || !string.IsNullOrWhiteSpace(state.NewSchema))
+ return;
+
+ if (!string.IsNullOrWhiteSpace(state.CurrentTemplateJson))
+ {
+ state.NewSchema = state.CurrentTemplateJson;
+ }
+ else
+ {
+ state.NewSchema = StarterFormTemplateSchema.CreateJson(
+ templateId.ToString(),
+ state.SelectedTemplate?.Name ?? "New template");
+ state.NewVersion ??= StarterFormTemplateSchema.DefaultVersionNumber;
+ }
+ }
+
+ public AdminPageOutcome ValidateNewVersion(TemplateManagerWorkState state)
+ {
+ var errors = new List();
+
+ if (string.IsNullOrWhiteSpace(state.NewVersion))
+ errors.Add(new FormValidationError(nameof(TemplateManagerWorkState.NewVersion), TemplateManagerMessages.VersionRequired));
+
+ if (string.IsNullOrWhiteSpace(state.NewSchema))
+ {
+ errors.Add(new FormValidationError(nameof(TemplateManagerWorkState.NewSchema), TemplateManagerMessages.SchemaRequired));
+ }
+ else
+ {
+ var (templateIsValid, validationErrors) = templateValidationService.ValidateTemplateJson(state.NewSchema);
+ if (!templateIsValid)
+ {
+ logger.LogWarning("Template validation failed with {ErrorCount} errors", validationErrors.Count);
+ errors.AddRange(validationErrors.Select(error =>
+ new FormValidationError(nameof(TemplateManagerWorkState.NewSchema), error)));
+ }
+ else
+ {
+ logger.LogInformation("Template validation passed successfully");
+ }
+ }
+
+ if (!state.AcknowledgeReportingImpact)
+ {
+ errors.Add(new FormValidationError(
+ nameof(TemplateManagerWorkState.AcknowledgeReportingImpact),
+ TemplateManagerMessages.AcknowledgeReportingImpact));
+ }
+
+ return errors.Count == 0
+ ? AdminPageOutcome.Stay()
+ : AdminPageOutcome.Stay(errors: errors);
+ }
+
+ public async Task CreateVersionAsync(
+ TemplateManagerWorkState state,
+ Guid templateId,
+ CancellationToken cancellationToken = default)
+ {
+ var base64Schema = Convert.ToBase64String(Encoding.UTF8.GetBytes(state.NewSchema!));
+ await templatesClient.CreateTemplateVersionAsync(
+ templateId,
+ new CreateTemplateVersionRequest(VersionNumber: state.NewVersion!, JsonSchema: base64Schema));
+
+ logger.LogInformation("Successfully created template version {NewVersion} for {TemplateId}",
+ state.NewVersion, templateId);
+
+ return AdminPageOutcome.Redirect(
+ routeValues: new Dictionary { ["success"] = "true" });
+ }
+
+ public string SuggestNextVersion(string? latestVersion, string? currentVersion) =>
+ TemplateVersionPolicy.IncrementPatch(latestVersion ?? currentVersion);
+
+ public async Task GrantToAllUsersAsync(
+ TemplateManagerWorkState state,
+ Guid templateId,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var result = await templatesClient.GrantTemplateAccessToAllUsersAsync(templateId, cancellationToken);
+ var summary = TemplateManagerMessages.GrantedSummary(
+ result.UsersGranted,
+ result.UsersAlreadyHadAccess,
+ result.TotalUsers);
+
+ logger.LogInformation(
+ "Granted template {TemplateId} to all tenant users. Granted={Granted}, AlreadyHad={AlreadyHad}, Total={Total}",
+ templateId,
+ result.UsersGranted,
+ result.UsersAlreadyHadAccess,
+ result.TotalUsers);
+
+ state.GrantToAllUsersSummary = summary;
+ return AdminPageOutcome.Redirect(successMessage: summary);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to grant template {TemplateId} to all tenant users", templateId);
+ state.HasError = true;
+ state.ErrorMessage = TemplateManagerMessages.GrantFailed;
+ await LoadTemplateDataAsync(state, templateId, cancellationToken);
+ return AdminPageOutcome.Stay(errorMessage: TemplateManagerMessages.GrantFailed);
+ }
+ }
+
+ private static string PrettyPrintJson(string json)
+ {
+ try
+ {
+ using var document = JsonDocument.Parse(json);
+ return JsonSerializer.Serialize(document.RootElement, new JsonSerializerOptions { WriteIndented = true });
+ }
+ catch (JsonException)
+ {
+ return json;
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerMessages.cs
new file mode 100644
index 0000000..9bb44a7
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerMessages.cs
@@ -0,0 +1,31 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for Template Manager. Keep these strings identical to the previous PageModel.
+///
+public static class TemplateManagerMessages
+{
+ public const string SelectTemplate = "Select a template.";
+
+ public const string SelectTenantTemplate = "Select a template for this tenant.";
+
+ public const string SelectVersion = "Select a template version.";
+
+ public const string VersionRequired = "Version number is required";
+
+ public const string SchemaRequired = "JSON schema is required";
+
+ public const string AcknowledgeReportingImpact =
+ "You must confirm that you understand the reporting impact before saving.";
+
+ public const string GrantRequiresTemplate = "Select a template before granting access to all users.";
+
+ public const string GrantFailed = "Failed to grant this template to all users in the tenant.";
+
+ public const string LoadFailed = "There was an error loading the template data.";
+
+ public const string ClearFailed = "Failed to clear sessions and caches.";
+
+ public static string GrantedSummary(int granted, int alreadyHad, int total) =>
+ $"Granted to {granted} user(s). {alreadyHad} already had access. Total tenant users checked: {total}.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerWorkState.cs
new file mode 100644
index 0000000..65d2aaa
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TemplateManagerWorkState.cs
@@ -0,0 +1,44 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using GovUK.Dfe.FlexForms.Domain.Models;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for Template Manager.
+///
+public sealed class TemplateManagerWorkState
+{
+ public Guid? SelectedTemplateId { get; set; }
+
+ public string? SelectedVersionNumber { get; set; }
+
+ public string? NewVersion { get; set; }
+
+ public string? NewSchema { get; set; }
+
+ public bool AcknowledgeReportingImpact { get; set; }
+
+ public bool ShowAddVersionForm { get; set; }
+
+ public FormTemplate? CurrentTemplate { get; set; }
+
+ public string? CurrentVersionNumber { get; set; }
+
+ public string? LatestVersionNumber { get; set; }
+
+ public string? CurrentTemplateJson { get; set; }
+
+ public bool HasError { get; set; }
+
+ public string ErrorMessage { get; set; } = string.Empty;
+
+ public IReadOnlyList TenantTemplates { get; set; } = [];
+
+ public IReadOnlyList AvailableVersions { get; set; } = [];
+
+ public TemplateDto? SelectedTemplate { get; set; }
+
+ public string? SessionVersionNumber { get; set; }
+
+ public string? GrantToAllUsersSummary { get; set; }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsAdminService.cs
new file mode 100644
index 0000000..1f50397
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsAdminService.cs
@@ -0,0 +1,369 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using Microsoft.Extensions.Logging;
+using System.Text;
+using System.Text.Json;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Loads and mutates TenantConfig app settings for the current tenant.
+///
+public interface ITenantSettingsAdmin
+{
+ Task LoadAsync(TenantSettingsWorkState state, CancellationToken cancellationToken = default);
+
+ Task ValidateAsync(
+ TenantSettingsWorkState state,
+ string? category,
+ string? target,
+ string? settingsJson,
+ bool isSecret,
+ CancellationToken cancellationToken = default);
+
+ Task DeleteAsync(
+ TenantSettingsWorkState state,
+ string? category,
+ string? target,
+ CancellationToken cancellationToken = default);
+
+ Task UpdateAsync(
+ TenantSettingsWorkState state,
+ string? category,
+ string? target,
+ string? settingsJson,
+ bool isSecret,
+ CancellationToken cancellationToken = default);
+
+ Task AddAsync(
+ TenantSettingsWorkState state,
+ string? category,
+ string? target,
+ string? settingsJson,
+ bool isSecret,
+ CancellationToken cancellationToken = default);
+
+ Task ExportAsync(
+ TenantSettingsWorkState state,
+ CancellationToken cancellationToken = default);
+
+ Task ImportAsync(
+ TenantSettingsWorkState state,
+ string json,
+ CancellationToken cancellationToken = default);
+
+ Task RefreshAsync(
+ TenantSettingsWorkState state,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class TenantSettingsAdminService(
+ ITenantAdminClient tenantAdminClient,
+ ILogger logger) : ITenantSettingsAdmin
+{
+ public static readonly string[] ValidTargets = ["Shared", "Api", "Web"];
+
+ public async Task LoadAsync(TenantSettingsWorkState state, CancellationToken cancellationToken = default)
+ {
+ await LoadSettingsAsync(state, cancellationToken);
+ await LoadHealthAsync(state, cancellationToken);
+ await LoadCookbookAsync(state, cancellationToken);
+ await LoadAuditLogAsync(state, cancellationToken);
+ }
+
+ public async Task ValidateAsync(
+ TenantSettingsWorkState state,
+ string? category,
+ string? target,
+ string? settingsJson,
+ bool isSecret,
+ CancellationToken cancellationToken = default)
+ {
+ category = category?.Trim() ?? string.Empty;
+ target = target?.Trim() ?? string.Empty;
+ settingsJson = settingsJson?.Trim() ?? string.Empty;
+ state.ValidationCategory = category;
+ state.ValidationTarget = target;
+ state.ValidationIsSecret = isSecret;
+
+ await LoadAsync(state, cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(settingsJson))
+ {
+ state.HasError = true;
+ state.ErrorMessage = TenantSettingsMessages.ValidateRequired;
+ return AdminPageOutcome.Stay(errorMessage: TenantSettingsMessages.ValidateRequired);
+ }
+
+ try
+ {
+ state.ValidationPreview = await tenantAdminClient.ValidateTenantSettingAsync(
+ state.TenantId,
+ new ValidateTenantSettingRequest(
+ category,
+ target,
+ AdminSettingsEncoding.ToBase64(settingsJson),
+ isSecret),
+ cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to validate tenant setting {Category}/{Target}", category, target);
+ var message = AdminApiErrorMapper.Format(ex, TenantSettingsMessages.ValidateFailed, includeGatewayHint: true);
+ state.HasError = true;
+ state.ErrorMessage = message;
+ return AdminPageOutcome.Stay(errorMessage: message);
+ }
+
+ return AdminPageOutcome.Stay();
+ }
+
+ public async Task DeleteAsync(
+ TenantSettingsWorkState state,
+ string? category,
+ string? target,
+ CancellationToken cancellationToken = default)
+ {
+ category = category?.Trim() ?? string.Empty;
+ target = target?.Trim() ?? string.Empty;
+
+ try
+ {
+ await tenantAdminClient.DeleteTenantSettingAsync(state.TenantId, category, target, cancellationToken);
+ await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken);
+ return AdminPageOutcome.Redirect(
+ successMessage: TenantSettingsMessages.Deleted(category, target),
+ refreshLocalCaches: true);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to delete tenant setting {Category}/{Target}", category, target);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.DeleteFailed, includeGatewayHint: true));
+ }
+ }
+
+ public async Task UpdateAsync(
+ TenantSettingsWorkState state,
+ string? category,
+ string? target,
+ string? settingsJson,
+ bool isSecret,
+ CancellationToken cancellationToken = default)
+ {
+ category = category?.Trim() ?? string.Empty;
+ target = target?.Trim() ?? string.Empty;
+ settingsJson = settingsJson?.Trim() ?? string.Empty;
+
+ if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(settingsJson))
+ return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.CategoryAndJsonRequired);
+
+ if (!ValidTargets.Contains(target, StringComparer.OrdinalIgnoreCase))
+ return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.InvalidTarget);
+
+ try
+ {
+ await tenantAdminClient.UpsertTenantSettingAsync(
+ state.TenantId,
+ new UpsertTenantSettingRequest(category, target, AdminSettingsEncoding.ToBase64(settingsJson), isSecret),
+ cancellationToken);
+ await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken);
+ return AdminPageOutcome.Redirect(
+ successMessage: TenantSettingsMessages.Updated(category, target),
+ refreshLocalCaches: true);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to update tenant setting {Category}/{Target}", category, target);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.UpdateFailed, includeGatewayHint: true));
+ }
+ }
+
+ public async Task AddAsync(
+ TenantSettingsWorkState state,
+ string? category,
+ string? target,
+ string? settingsJson,
+ bool isSecret,
+ CancellationToken cancellationToken = default)
+ {
+ category = category?.Trim() ?? string.Empty;
+ target = string.IsNullOrWhiteSpace(target) ? "Shared" : target.Trim();
+ settingsJson = settingsJson?.Trim() ?? string.Empty;
+
+ if (string.IsNullOrWhiteSpace(category))
+ return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.CategoryRequired);
+
+ if (category.Length > 50)
+ return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.CategoryTooLong);
+
+ if (!ValidTargets.Contains(target, StringComparer.OrdinalIgnoreCase))
+ return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.InvalidTarget);
+
+ if (string.IsNullOrWhiteSpace(settingsJson))
+ return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.SettingsJsonRequired);
+
+ try
+ {
+ await tenantAdminClient.UpsertTenantSettingAsync(
+ state.TenantId,
+ new UpsertTenantSettingRequest(category, target, AdminSettingsEncoding.ToBase64(settingsJson), isSecret),
+ cancellationToken);
+ await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken);
+ return AdminPageOutcome.Redirect(
+ successMessage: TenantSettingsMessages.Added(category, target),
+ refreshLocalCaches: true);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to add tenant setting {Category}/{Target}", category, target);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.AddFailed, includeGatewayHint: true));
+ }
+ }
+
+ public async Task ExportAsync(
+ TenantSettingsWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var export = await tenantAdminClient.ExportConfigurationAsync(state.TenantId, cancellationToken);
+ var json = JsonSerializer.Serialize(export, new JsonSerializerOptions { WriteIndented = true });
+ var bytes = Encoding.UTF8.GetBytes(json);
+ return AdminPageOutcome.File(
+ bytes,
+ "application/json",
+ $"tenant-config-{state.TenantId:N}-{DateTime.UtcNow:yyyyMMddHHmmss}.json");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to export tenant configuration for {TenantId}", state.TenantId);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.ExportFailed, includeGatewayHint: true));
+ }
+ }
+
+ public async Task ImportAsync(
+ TenantSettingsWorkState state,
+ string json,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var exportBundle = JsonSerializer.Deserialize(
+ json,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+
+ if (exportBundle?.Settings is null || exportBundle.Settings.Count == 0)
+ return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.ImportEmpty);
+
+ var importItems = exportBundle.Settings
+ .Select(s => new TenantSettingImportItemDto(s.Category, s.Target, s.SettingsJson, s.IsSecret))
+ .ToList();
+
+ var bundle = new ImportTenantConfigurationDto(importItems, SkipSecretPlaceholders: true);
+ var result = await tenantAdminClient.ImportConfigurationAsync(state.TenantId, bundle, cancellationToken);
+ await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken);
+
+ return AdminPageOutcome.Redirect(
+ successMessage: TenantSettingsMessages.Imported(result.AppliedCount, result.SkippedCount),
+ refreshLocalCaches: true);
+ }
+ catch (JsonException)
+ {
+ return AdminPageOutcome.Redirect(errorMessage: TenantSettingsMessages.ImportInvalidJson);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to import tenant configuration for {TenantId}", state.TenantId);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.ImportFailed, includeGatewayHint: true));
+ }
+ }
+
+ public async Task RefreshAsync(
+ TenantSettingsWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await tenantAdminClient.RefreshTenantConfigurationAsync(cancellationToken);
+ return AdminPageOutcome.Redirect(
+ successMessage: TenantSettingsMessages.RefreshSuccess,
+ refreshLocalCaches: true);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to refresh tenant configuration for {TenantId}", state.TenantId);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, TenantSettingsMessages.RefreshFailed, includeGatewayHint: true));
+ }
+ }
+
+ private async Task LoadSettingsAsync(TenantSettingsWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var response = await tenantAdminClient.GetTenantSettingsAsync(state.TenantId, cancellationToken);
+ state.TenantName = response.TenantName;
+ state.Settings = response.Settings?.ToList() ?? [];
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load tenant settings for {TenantId}", state.TenantId);
+ state.HasError = true;
+ state.ErrorMessage = AdminApiErrorMapper.Format(ex, TenantSettingsMessages.LoadFailed, includeGatewayHint: true);
+ state.Settings = [];
+ }
+ }
+
+ private async Task LoadHealthAsync(TenantSettingsWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ state.TenantHealth = await tenantAdminClient.GetTenantHealthAsync(state.TenantId, cancellationToken);
+ state.EffectiveConfig = state.TenantHealth.EffectiveConfiguration;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to load tenant health for {TenantId}", state.TenantId);
+ try
+ {
+ state.EffectiveConfig = await tenantAdminClient.GetEffectiveConfigurationAsync(state.TenantId, cancellationToken);
+ }
+ catch (Exception inner)
+ {
+ logger.LogWarning(inner, "Failed to load effective configuration for {TenantId}", state.TenantId);
+ }
+ }
+ }
+
+ private async Task LoadCookbookAsync(TenantSettingsWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var response = await tenantAdminClient.GetCategoryCookbookAsync(cancellationToken);
+ state.Cookbook = response.Categories?.ToList() ?? [];
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to load category cookbook");
+ }
+ }
+
+ private async Task LoadAuditLogAsync(TenantSettingsWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var log = await tenantAdminClient.GetSettingAuditLogAsync(state.TenantId, 20, cancellationToken);
+ state.AuditEntries = log?.Entries?.ToList() ?? [];
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to load audit log for {TenantId}", state.TenantId);
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsMessages.cs
new file mode 100644
index 0000000..0480e9a
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsMessages.cs
@@ -0,0 +1,57 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for Tenant Settings. Keep these strings identical to the previous PageModel.
+///
+public static class TenantSettingsMessages
+{
+ public const string TenantContextMissing = "Tenant context is not available for this request.";
+
+ public const string ValidateRequired = "Category and settings JSON are required to validate.";
+
+ public const string ValidateFailed = "Could not validate setting.";
+
+ public const string CategoryAndJsonRequired = "Category and settings JSON are required.";
+
+ public const string InvalidTarget = "Target must be Shared, Api, or Web.";
+
+ public const string CategoryRequired = "Enter a category name.";
+
+ public const string CategoryTooLong = "Category must not exceed 50 characters.";
+
+ public const string SettingsJsonRequired = "Enter settings JSON.";
+
+ public const string DeleteFailed = "Could not delete setting.";
+
+ public const string UpdateFailed = "Could not update setting.";
+
+ public const string AddFailed = "Could not add setting.";
+
+ public const string ExportFailed = "Could not export configuration.";
+
+ public const string ImportFileRequired = "Select a JSON file to import.";
+
+ public const string ImportEmpty = "The import file contains no settings.";
+
+ public const string ImportInvalidJson = "The file is not valid JSON.";
+
+ public const string ImportFailed = "Could not import configuration.";
+
+ public const string RefreshFailed = "Could not refresh settings.";
+
+ public const string LoadFailed = "Could not load tenant settings.";
+
+ public const string RefreshSuccess = "Tenant configuration cache refreshed.";
+
+ public static string Deleted(string category, string target) =>
+ $"Deleted '{category}' ({target}).";
+
+ public static string Updated(string category, string target) =>
+ $"Updated '{category}' ({target}).";
+
+ public static string Added(string category, string target) =>
+ $"Added '{category}' ({target}).";
+
+ public static string Imported(int appliedCount, int skippedCount) =>
+ $"Imported {appliedCount} settings ({skippedCount} secret placeholders skipped).";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsWorkState.cs
new file mode 100644
index 0000000..1b7e1f3
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/TenantSettingsWorkState.cs
@@ -0,0 +1,35 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for the Tenant Settings admin page.
+///
+public sealed class TenantSettingsWorkState
+{
+ public Guid TenantId { get; set; }
+
+ public string TenantName { get; set; } = string.Empty;
+
+ public IReadOnlyList Settings { get; set; } = [];
+
+ public TenantEffectiveConfigurationDto? EffectiveConfig { get; set; }
+
+ public TenantHealthDto? TenantHealth { get; set; }
+
+ public IReadOnlyList Cookbook { get; set; } = [];
+
+ public IReadOnlyList AuditEntries { get; set; } = [];
+
+ public ValidateTenantSettingResponse? ValidationPreview { get; set; }
+
+ public string? ValidationCategory { get; set; }
+
+ public string? ValidationTarget { get; set; }
+
+ public bool ValidationIsSecret { get; set; }
+
+ public bool HasError { get; set; }
+
+ public string? ErrorMessage { get; set; }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddAdminService.cs
new file mode 100644
index 0000000..1bf625b
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddAdminService.cs
@@ -0,0 +1,146 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using GovUK.Dfe.FlexForms.Application.Validation;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Registers a user into the tenant with a role and optional form access.
+///
+public interface IUserManagerAddAdmin
+{
+ Task LoadAsync(UserManagerAddWorkState state, CancellationToken cancellationToken = default);
+
+ Task AddAsync(UserManagerAddWorkState state, CancellationToken cancellationToken = default);
+}
+
+public sealed class UserManagerAddAdminService(
+ IUsersClient usersClient,
+ ITemplatesClient templatesClient,
+ IRolesClient rolesClient,
+ ILogger logger) : IUserManagerAddAdmin
+{
+ public async Task LoadAsync(UserManagerAddWorkState state, CancellationToken cancellationToken = default)
+ {
+ var errors = new List();
+ await LoadTemplatesAsync(state, errors, cancellationToken);
+ await LoadRolesAsync(state, errors, cancellationToken);
+ state.Errors = errors;
+ }
+
+ public async Task AddAsync(
+ UserManagerAddWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ if (!state.AssignableRoles.Contains(state.Role, StringComparer.OrdinalIgnoreCase))
+ {
+ return Stay(state, new FormValidationError(
+ nameof(UserManagerAddWorkState.Role),
+ UserManagerAddMessages.InvalidRole));
+ }
+
+ var isSystemUserRole = string.Equals(state.Role, "User", StringComparison.OrdinalIgnoreCase);
+ if (isSystemUserRole && (state.SelectedTemplateIds is null || state.SelectedTemplateIds.Count == 0))
+ {
+ return Stay(state, new FormValidationError(
+ nameof(UserManagerAddWorkState.SelectedTemplateIds),
+ UserManagerAddMessages.UserRoleRequiresTemplate));
+ }
+
+ try
+ {
+ var existingUsers = await usersClient.GetTenantUsersAsync(cancellationToken);
+ if (existingUsers?.Any(u =>
+ string.Equals(u.Email, state.Email.Trim(), StringComparison.OrdinalIgnoreCase)) == true)
+ {
+ return Stay(state, new FormValidationError(
+ nameof(UserManagerAddWorkState.Email),
+ UserManagerAddMessages.DuplicateEmail));
+ }
+
+ var created = await usersClient.AssignUserRoleAsync(
+ new AssignUserRoleRequest
+ {
+ Name = state.Name.Trim(),
+ Email = state.Email.Trim(),
+ Role = state.Role,
+ TemplateIds = state.SelectedTemplateIds
+ },
+ createOnly: true,
+ cancellationToken);
+
+ if (created?.UserId is Guid userId && state.SelectedTemplateIds is { Count: > 0 })
+ {
+ await usersClient.UpdateUserTemplateAccessAsync(
+ userId,
+ new UpdateUserTemplateAccessRequest { TemplateIds = state.SelectedTemplateIds },
+ cancellationToken);
+ }
+
+ return AdminPageOutcome.Redirect(
+ successMessage: UserManagerAddMessages.Added(state.Email.Trim(), state.Role));
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to add user {Email}", state.Email);
+ return Stay(state, new FormValidationError(
+ string.Empty,
+ AdminApiErrorMapper.Format(ex, UserManagerAddMessages.AddFailed)));
+ }
+ }
+
+ private async Task LoadTemplatesAsync(
+ UserManagerAddWorkState state,
+ List errors,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken);
+ state.AvailableTemplates = templates?.OrderBy(t => t.Name).ToList() ?? [];
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load templates for add user");
+ errors.Add(new FormValidationError(
+ string.Empty,
+ AdminApiErrorMapper.Format(ex, UserManagerAddMessages.LoadTemplatesFailed)));
+ state.AvailableTemplates = [];
+ }
+ }
+
+ private async Task LoadRolesAsync(
+ UserManagerAddWorkState state,
+ List errors,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var roles = await rolesClient.ListAsync(cancellationToken);
+ state.AssignableRoles = UserManagerAssignableRoles.Resolve(
+ roles?.Select(r => (r.Name, r.IsSystem)),
+ state.IncludeTenantAdmin);
+
+ if (string.IsNullOrWhiteSpace(state.Role)
+ || !state.AssignableRoles.Contains(state.Role, StringComparer.OrdinalIgnoreCase))
+ {
+ state.Role = state.AssignableRoles.FirstOrDefault() ?? "User";
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load roles for add user");
+ errors.Add(new FormValidationError(
+ string.Empty,
+ AdminApiErrorMapper.Format(ex, UserManagerAddMessages.LoadRolesFailed)));
+ state.AssignableRoles = UserManagerAssignableRoles.Resolve(null, state.IncludeTenantAdmin);
+ }
+ }
+
+ private static AdminPageOutcome Stay(UserManagerAddWorkState state, FormValidationError error)
+ {
+ state.Errors = [error];
+ return AdminPageOutcome.Stay(errors: [error]);
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddMessages.cs
new file mode 100644
index 0000000..383a5f2
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddMessages.cs
@@ -0,0 +1,22 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for User Manager Add. Keep these strings identical to the previous PageModel.
+///
+public static class UserManagerAddMessages
+{
+ public const string InvalidRole = "Select a valid role for this tenant.";
+
+ public const string UserRoleRequiresTemplate = "Select at least one form for the User role.";
+
+ public const string DuplicateEmail = "A user with this email address already exists in this tenant.";
+
+ public const string AddFailed = "Could not add the user.";
+
+ public const string LoadTemplatesFailed = "Could not load available forms.";
+
+ public const string LoadRolesFailed = "Could not load available roles.";
+
+ public static string Added(string email, string role) =>
+ $"User {email} has been added with role {role}.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddWorkState.cs
new file mode 100644
index 0000000..389c9eb
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAddWorkState.cs
@@ -0,0 +1,26 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using GovUK.Dfe.FlexForms.Application.Validation;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for the User Manager Add admin page.
+///
+public sealed class UserManagerAddWorkState
+{
+ public string Name { get; set; } = string.Empty;
+
+ public string Email { get; set; } = string.Empty;
+
+ public string Role { get; set; } = "User";
+
+ public List SelectedTemplateIds { get; set; } = [];
+
+ public IReadOnlyList AvailableTemplates { get; set; } = [];
+
+ public IReadOnlyList AssignableRoles { get; set; } = [];
+
+ public bool IncludeTenantAdmin { get; set; }
+
+ public IReadOnlyList Errors { get; set; } = [];
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAdminService.cs
new file mode 100644
index 0000000..9a883f1
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAdminService.cs
@@ -0,0 +1,82 @@
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Lists tenant users and removes membership for the current tenant.
+///
+public interface IUserManagerAdmin
+{
+ Task LoadAsync(UserManagerWorkState state, CancellationToken cancellationToken = default);
+
+ Task RemoveAsync(
+ UserManagerWorkState state,
+ Guid userId,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class UserManagerAdminService(
+ IUsersClient usersClient,
+ ILogger logger) : IUserManagerAdmin
+{
+ public async Task LoadAsync(UserManagerWorkState state, CancellationToken cancellationToken = default)
+ {
+ await LoadUsersAsync(state, cancellationToken);
+ await LoadAccessAuditLogAsync(state, cancellationToken);
+ }
+
+ public async Task RemoveAsync(
+ UserManagerWorkState state,
+ Guid userId,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await usersClient.RemoveUserFromTenantAsync(userId, cancellationToken);
+ return AdminPageOutcome.Redirect(successMessage: UserManagerMessages.Removed);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to remove user {UserId} from tenant", userId);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, UserManagerMessages.RemoveFailed));
+ }
+ }
+
+ private async Task LoadUsersAsync(UserManagerWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var users = await usersClient.GetTenantUsersAsync(cancellationToken);
+ state.Users = users?.OrderBy(u => u.Name).ToList() ?? [];
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load tenant users");
+ state.HasError = true;
+ state.ErrorMessage = AdminApiErrorMapper.Format(ex, UserManagerMessages.LoadFailed);
+ state.Users = [];
+ }
+ }
+
+ private async Task LoadAccessAuditLogAsync(UserManagerWorkState state, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var log = await usersClient.GetAccessAuditLogAsync(take: 50, cancellationToken);
+ state.AccessAuditEntries = log?.Entries?
+ .OrderByDescending(e => e.OccurredAtUtc)
+ .ToList() ?? [];
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to load tenant access audit log");
+ state.AccessAuditEntries = [];
+ state.AuditLogLoadFailed = true;
+ state.AuditLogLoadErrorMessage = AdminApiErrorMapper.Format(
+ ex,
+ UserManagerMessages.AuditLogLoadFailed);
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAssignableRoles.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAssignableRoles.cs
new file mode 100644
index 0000000..b551b6b
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerAssignableRoles.cs
@@ -0,0 +1,36 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Roles shown in User Manager add/edit. Always includes User and non-system roles.
+/// Tenant Admin is included only when is true.
+///
+public static class UserManagerAssignableRoles
+{
+ public static IReadOnlyList Resolve(
+ IEnumerable<(string Name, bool IsSystem)>? roles,
+ bool includeTenantAdmin)
+ {
+ var names = (roles ?? [])
+ .Where(r =>
+ string.Equals(r.Name, "User", StringComparison.OrdinalIgnoreCase)
+ || (includeTenantAdmin
+ && string.Equals(r.Name, "Admin", StringComparison.OrdinalIgnoreCase))
+ || !r.IsSystem)
+ .Select(r => r.Name)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (!names.Any(n => string.Equals(n, "User", StringComparison.OrdinalIgnoreCase)))
+ names.Add("User");
+
+ if (includeTenantAdmin
+ && !names.Any(n => string.Equals(n, "Admin", StringComparison.OrdinalIgnoreCase)))
+ {
+ names.Add("Admin");
+ }
+
+ return names
+ .OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditAdminService.cs
new file mode 100644
index 0000000..b5c89be
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditAdminService.cs
@@ -0,0 +1,168 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using GovUK.Dfe.FlexForms.Application.Validation;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Edits a tenant user's role and form access.
+///
+public interface IUserManagerEditAdmin
+{
+ Task LoadAsync(UserManagerEditWorkState state, CancellationToken cancellationToken = default);
+
+ Task LoadForUpdateAsync(
+ UserManagerEditWorkState state,
+ CancellationToken cancellationToken = default);
+
+ Task UpdateAsync(UserManagerEditWorkState state, CancellationToken cancellationToken = default);
+}
+
+public sealed class UserManagerEditAdminService(
+ IUsersClient usersClient,
+ ITemplatesClient templatesClient,
+ IRolesClient rolesClient,
+ ILogger logger) : IUserManagerEditAdmin
+{
+ public async Task LoadAsync(
+ UserManagerEditWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var errors = await LoadLookupsAsync(state, cancellationToken);
+
+ var users = await usersClient.GetTenantUsersAsync(cancellationToken);
+ var user = users?.FirstOrDefault(u => u.UserId == state.UserId);
+ if (user is null)
+ return AdminPageOutcome.Redirect(errorMessage: UserManagerEditMessages.UserNotFound);
+
+ state.UserName = user.Name;
+ state.UserEmail = user.Email;
+ state.Role = user.Role;
+
+ if (!state.AssignableRoles.Contains(state.Role, StringComparer.OrdinalIgnoreCase)
+ && !string.IsNullOrWhiteSpace(state.Role))
+ {
+ state.AssignableRoles = state.AssignableRoles
+ .Append(state.Role)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(r => r)
+ .ToList();
+ }
+
+ if (state.SelectedTemplateIds.Count == 0)
+ state.SelectedTemplateIds = user.Templates.Select(t => t.TemplateId).ToList();
+
+ state.Errors = errors;
+ return AdminPageOutcome.Stay(errors: errors);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load user {UserId} for edit", state.UserId);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, UserManagerEditMessages.LoadFailed));
+ }
+ }
+
+ public async Task LoadForUpdateAsync(
+ UserManagerEditWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ var errors = await LoadLookupsAsync(state, cancellationToken);
+
+ var users = await usersClient.GetTenantUsersAsync(cancellationToken);
+ var user = users?.FirstOrDefault(u => u.UserId == state.UserId);
+ if (user is null)
+ return AdminPageOutcome.Redirect(errorMessage: UserManagerEditMessages.UserNotFound);
+
+ state.UserName = user.Name;
+ state.UserEmail = user.Email;
+ state.Errors = errors;
+ return AdminPageOutcome.Stay(errors: errors);
+ }
+
+ public async Task UpdateAsync(
+ UserManagerEditWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ if (!state.AssignableRoles.Contains(state.Role, StringComparer.OrdinalIgnoreCase))
+ {
+ var error = new FormValidationError(
+ nameof(UserManagerEditWorkState.Role),
+ UserManagerEditMessages.InvalidRole);
+ state.Errors = [error];
+ return AdminPageOutcome.Stay(errors: [error]);
+ }
+
+ try
+ {
+ await usersClient.AssignUserRoleAsync(
+ new AssignUserRoleRequest
+ {
+ Name = state.UserName,
+ Email = state.UserEmail,
+ Role = state.Role,
+ TemplateIds = state.SelectedTemplateIds
+ },
+ createOnly: false,
+ cancellationToken);
+
+ await usersClient.UpdateUserTemplateAccessAsync(
+ state.UserId,
+ new UpdateUserTemplateAccessRequest { TemplateIds = state.SelectedTemplateIds ?? [] },
+ cancellationToken);
+
+ return AdminPageOutcome.Redirect(successMessage: UserManagerEditMessages.Updated);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to update user {UserId}", state.UserId);
+ var error = new FormValidationError(
+ string.Empty,
+ AdminApiErrorMapper.Format(ex, UserManagerEditMessages.UpdateFailed));
+ state.Errors = [error];
+ return AdminPageOutcome.Stay(errors: [error]);
+ }
+ }
+
+ private async Task> LoadLookupsAsync(
+ UserManagerEditWorkState state,
+ CancellationToken cancellationToken)
+ {
+ var errors = new List();
+
+ try
+ {
+ var templates = await templatesClient.GetAccessibleTemplatesAsync(cancellationToken);
+ state.AvailableTemplates = templates?.OrderBy(t => t.Name).ToList() ?? [];
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load templates for edit user");
+ errors.Add(new FormValidationError(
+ string.Empty,
+ AdminApiErrorMapper.Format(ex, UserManagerEditMessages.LoadTemplatesFailed)));
+ state.AvailableTemplates = [];
+ }
+
+ try
+ {
+ var roles = await rolesClient.ListAsync(cancellationToken);
+ state.AssignableRoles = UserManagerAssignableRoles.Resolve(
+ roles?.Select(r => (r.Name, r.IsSystem)),
+ state.IncludeTenantAdmin);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load roles for edit user");
+ errors.Add(new FormValidationError(
+ string.Empty,
+ AdminApiErrorMapper.Format(ex, UserManagerEditMessages.LoadRolesFailed)));
+ state.AssignableRoles = UserManagerAssignableRoles.Resolve(null, state.IncludeTenantAdmin);
+ }
+
+ return errors;
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditMessages.cs
new file mode 100644
index 0000000..47eba25
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditMessages.cs
@@ -0,0 +1,21 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for User Manager Edit. Keep these strings identical to the previous PageModel.
+///
+public static class UserManagerEditMessages
+{
+ public const string UserNotFound = "User not found in this tenant.";
+
+ public const string InvalidRole = "Select a valid role for this tenant.";
+
+ public const string Updated = "User role and form access updated.";
+
+ public const string UpdateFailed = "Could not update the user.";
+
+ public const string LoadFailed = "Could not load user details.";
+
+ public const string LoadTemplatesFailed = "Could not load available forms.";
+
+ public const string LoadRolesFailed = "Could not load available roles.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditWorkState.cs
new file mode 100644
index 0000000..6e69e31
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerEditWorkState.cs
@@ -0,0 +1,28 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using GovUK.Dfe.FlexForms.Application.Validation;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for the User Manager Edit admin page.
+///
+public sealed class UserManagerEditWorkState
+{
+ public Guid UserId { get; set; }
+
+ public string UserName { get; set; } = string.Empty;
+
+ public string UserEmail { get; set; } = string.Empty;
+
+ public string Role { get; set; } = string.Empty;
+
+ public List SelectedTemplateIds { get; set; } = [];
+
+ public IReadOnlyList AvailableTemplates { get; set; } = [];
+
+ public IReadOnlyList AssignableRoles { get; set; } = [];
+
+ public bool IncludeTenantAdmin { get; set; }
+
+ public IReadOnlyList Errors { get; set; } = [];
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerMessages.cs
new file mode 100644
index 0000000..12f18bf
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerMessages.cs
@@ -0,0 +1,16 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for User Manager. Keep these strings identical to the previous PageModel.
+///
+public static class UserManagerMessages
+{
+ public const string Removed = "User removed from this tenant.";
+
+ public const string RemoveFailed = "Could not remove the user from this tenant.";
+
+ public const string LoadFailed = "Could not load users for this tenant.";
+
+ public const string AuditLogLoadFailed =
+ "Could not load the access audit trail. Ensure the API is up to date and database migrations have been applied.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsAdminService.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsAdminService.cs
new file mode 100644
index 0000000..78f4390
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsAdminService.cs
@@ -0,0 +1,193 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using GovUK.Dfe.FlexForms.Application.Validation;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Sets user-level Permissions for a tenant member (ResourceType + ResourceKey + AccessType).
+/// Does not affect permissions inherited from the user's role.
+///
+public interface IUserManagerPermissionsAdmin
+{
+ Task LoadAsync(
+ UserManagerPermissionsWorkState state,
+ CancellationToken cancellationToken = default);
+
+ Task AddGrantAsync(
+ UserManagerPermissionsWorkState state,
+ CancellationToken cancellationToken = default);
+
+ Task RemoveGrantAsync(
+ UserManagerPermissionsWorkState state,
+ string grantKey,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class UserManagerPermissionsAdminService(
+ IUsersClient usersClient,
+ ILogger logger) : IUserManagerPermissionsAdmin
+{
+ public async Task LoadAsync(
+ UserManagerPermissionsWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ if (await LoadUserMetaAsync(state, cancellationToken) is { } failure)
+ return failure;
+
+ try
+ {
+ await LoadPermissionsAsync(state, cancellationToken);
+ return AdminPageOutcome.Stay();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load permissions for user {UserId}", state.UserId);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, UserManagerPermissionsMessages.LoadPermissionsFailed));
+ }
+ }
+
+ public async Task AddGrantAsync(
+ UserManagerPermissionsWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ if (await LoadUserMetaAsync(state, cancellationToken) is { } failure)
+ return failure;
+
+ state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants);
+
+ var resourceKey = state.NewResourceKey?.Trim() ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(resourceKey))
+ {
+ return AdminPageOutcome.Stay(errors:
+ [
+ new FormValidationError(
+ nameof(UserManagerPermissionsWorkState.NewResourceKey),
+ UserManagerPermissionsMessages.ResourceKeyRequired)
+ ]);
+ }
+
+ var validationError = AdminPermissionGrants.ValidateUserGrant(
+ state.NewResourceType,
+ resourceKey,
+ state.NewAccessType);
+ if (validationError is not null)
+ {
+ return AdminPageOutcome.Stay(errors:
+ [
+ new FormValidationError(nameof(UserManagerPermissionsWorkState.NewResourceKey), validationError)
+ ]);
+ }
+
+ var key = AdminPermissionGrants.EncodeGrantKey(state.NewResourceType, resourceKey, state.NewAccessType);
+ if (state.SelectedGrants.Contains(key, StringComparer.OrdinalIgnoreCase))
+ {
+ return AdminPageOutcome.Stay(errors:
+ [
+ new FormValidationError(
+ string.Empty,
+ UserManagerPermissionsMessages.DuplicateGrant(
+ state.NewResourceType.ToString(),
+ resourceKey,
+ state.NewAccessType.ToString()))
+ ]);
+ }
+
+ state.SelectedGrants.Add(key);
+ state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants);
+
+ return await SaveAndReloadAsync(state, cancellationToken);
+ }
+
+ public async Task RemoveGrantAsync(
+ UserManagerPermissionsWorkState state,
+ string grantKey,
+ CancellationToken cancellationToken = default)
+ {
+ if (await LoadUserMetaAsync(state, cancellationToken) is { } failure)
+ return failure;
+
+ state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(state.SelectedGrants);
+ state.SelectedGrants.RemoveAll(g => string.Equals(g, grantKey, StringComparison.OrdinalIgnoreCase));
+
+ return await SaveAndReloadAsync(state, cancellationToken);
+ }
+
+ private async Task SaveAndReloadAsync(
+ UserManagerPermissionsWorkState state,
+ CancellationToken cancellationToken)
+ {
+ foreach (var grant in state.SelectedGrants.Select(AdminPermissionGrants.ParseGrantKey).Where(g => g is not null))
+ {
+ var error = AdminPermissionGrants.ValidateUserGrant(
+ grant!.Value.ResourceType,
+ grant.Value.ResourceKey,
+ grant.Value.AccessType);
+ if (error is not null)
+ return AdminPageOutcome.Stay(errors: [new FormValidationError(string.Empty, error)]);
+ }
+
+ try
+ {
+ var grants = AdminPermissionGrants.ToGrantDtos(state.SelectedGrants);
+
+ await usersClient.SetUserPermissionsAsync(
+ state.UserId,
+ new SetUserPermissionsRequest { Permissions = grants },
+ cancellationToken);
+
+ state.NewResourceKey = string.Empty;
+ await LoadPermissionsAsync(state, cancellationToken);
+ return AdminPageOutcome.Stay();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to save permissions for user {UserId}", state.UserId);
+ await LoadPermissionsAsync(state, cancellationToken);
+
+ return AdminPageOutcome.Stay(errors:
+ [
+ new FormValidationError(
+ string.Empty,
+ AdminApiErrorMapper.Format(ex, UserManagerPermissionsMessages.SaveFailed))
+ ]);
+ }
+ }
+
+ private async Task LoadPermissionsAsync(
+ UserManagerPermissionsWorkState state,
+ CancellationToken cancellationToken)
+ {
+ var existing = await usersClient.GetUserPermissionsAsync(state.UserId, cancellationToken);
+ state.SelectedGrants = AdminPermissionGrants.NormalizeGrants(
+ existing?
+ .Select(p => AdminPermissionGrants.EncodeGrantKey(p.ResourceType, p.ResourceKey, p.AccessType))
+ .ToList() ?? []);
+ }
+
+ /// A redirect outcome when the user cannot be loaded; otherwise null.
+ private async Task LoadUserMetaAsync(
+ UserManagerPermissionsWorkState state,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var users = await usersClient.GetTenantUsersAsync(cancellationToken);
+ var user = users?.FirstOrDefault(u => u.UserId == state.UserId);
+ if (user is null)
+ return AdminPageOutcome.Redirect(errorMessage: UserManagerPermissionsMessages.UserNotFound);
+
+ state.UserName = user.Name;
+ state.UserEmail = user.Email;
+ return null;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load user {UserId}", state.UserId);
+ return AdminPageOutcome.Redirect(
+ errorMessage: AdminApiErrorMapper.Format(ex, UserManagerPermissionsMessages.LoadUserFailed));
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsMessages.cs
new file mode 100644
index 0000000..a8ebce2
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsMessages.cs
@@ -0,0 +1,20 @@
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// User-facing copy for User Manager Permissions. Keep these strings identical to the previous PageModel.
+///
+public static class UserManagerPermissionsMessages
+{
+ public const string UserNotFound = "User not found.";
+
+ public const string ResourceKeyRequired = "Enter a resource key.";
+
+ public const string SaveFailed = "Could not save permissions.";
+
+ public const string LoadPermissionsFailed = "Could not load user permissions.";
+
+ public const string LoadUserFailed = "Could not load user.";
+
+ public static string DuplicateGrant(string resourceType, string resourceKey, string accessType) =>
+ $"{resourceType} / {resourceKey} / {accessType} is already in the list.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsWorkState.cs
new file mode 100644
index 0000000..006ee9e
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerPermissionsWorkState.cs
@@ -0,0 +1,23 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for the User Manager Permissions admin page.
+///
+public sealed class UserManagerPermissionsWorkState
+{
+ public Guid UserId { get; set; }
+
+ public string UserName { get; set; } = string.Empty;
+
+ public string UserEmail { get; set; } = string.Empty;
+
+ public List SelectedGrants { get; set; } = [];
+
+ public ResourceType NewResourceType { get; set; } = ResourceType.Application;
+
+ public string? NewResourceKey { get; set; }
+
+ public AccessType NewAccessType { get; set; } = AccessType.Read;
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerWorkState.cs b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerWorkState.cs
new file mode 100644
index 0000000..9669b98
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Admin/UserManagerWorkState.cs
@@ -0,0 +1,21 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+
+namespace GovUK.Dfe.FlexForms.Application.Admin;
+
+///
+/// Mutable view-state bag for the User Manager admin page.
+///
+public sealed class UserManagerWorkState
+{
+ public IReadOnlyList Users { get; set; } = [];
+
+ public IReadOnlyList AccessAuditEntries { get; set; } = [];
+
+ public bool HasError { get; set; }
+
+ public string? ErrorMessage { get; set; }
+
+ public bool AuditLogLoadFailed { get; set; }
+
+ public string? AuditLogLoadErrorMessage { get; set; }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Dashboard/ApplicationWithCalculatedStatus.cs b/src/GovUK.Dfe.FlexForms.Application/Dashboard/ApplicationWithCalculatedStatus.cs
new file mode 100644
index 0000000..26ac1d4
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Dashboard/ApplicationWithCalculatedStatus.cs
@@ -0,0 +1,28 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+
+namespace GovUK.Dfe.FlexForms.Application.Dashboard;
+
+///
+/// Dashboard row with a display status and optional custom column values.
+/// Shared by the user dashboard and the admin applications index.
+///
+public sealed class ApplicationWithCalculatedStatus
+{
+ public ApplicationDto Application { get; set; } = null!;
+
+ public KeyValuePair CalculatedStatus { get; set; }
+
+ public IReadOnlyDictionary CustomColumnValues { get; set; } =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ public Guid ApplicationId => Application.ApplicationId;
+
+ public string ApplicationReference => Application.ApplicationReference;
+
+ public string TemplateName => Application.TemplateName;
+
+ public DateTime DateCreated => Application.DateCreated;
+
+ public DateTime? DateSubmitted => Application.DateSubmitted;
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationListQuery.cs b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationListQuery.cs
new file mode 100644
index 0000000..6239455
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationListQuery.cs
@@ -0,0 +1,64 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+
+namespace GovUK.Dfe.FlexForms.Application.Dashboard;
+
+public enum DashboardApplicationListScope
+{
+ Mine,
+ AllForTemplate
+}
+
+///
+/// Query for listing applications on the dashboard or admin index.
+///
+public sealed class DashboardApplicationListQuery
+{
+ public Guid TemplateId { get; init; }
+
+ public int CurrentPage { get; init; } = 1;
+
+ public int PageSize { get; init; } = 50;
+
+ public DashboardApplicationListScope Scope { get; init; } = DashboardApplicationListScope.Mine;
+
+ public bool IncludeCustomColumns { get; init; }
+
+ public IReadOnlyList Columns { get; init; } = DashboardColumnResolver.DefaultColumns;
+
+ public IReadOnlyList CustomStatuses { get; init; } = [];
+
+ public string? SearchReference { get; init; }
+
+ public DateTime? DateStartedFrom { get; init; }
+
+ public DateTime? DateStartedTo { get; init; }
+
+ public DateTime? DateSubmittedFrom { get; init; }
+
+ public DateTime? DateSubmittedTo { get; init; }
+
+ public ApplicationStatus? Status { get; init; }
+}
+
+///
+/// Result of a dashboard application list query.
+///
+public sealed class DashboardApplicationListResult
+{
+ public IReadOnlyList Applications { get; init; } = [];
+
+ public int TotalPages { get; init; }
+
+ public int CurrentPage { get; init; } = 1;
+}
+
+///
+/// Result of creating an application from the dashboard.
+///
+public sealed class DashboardCreateApplicationResult
+{
+ public ApplicationDto Application { get; init; } = null!;
+
+ public bool ContributorsEnabled { get; init; }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationStatusCalculator.cs b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationStatusCalculator.cs
new file mode 100644
index 0000000..2f26f26
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationStatusCalculator.cs
@@ -0,0 +1,99 @@
+using System.ComponentModel;
+using System.Text.Json;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Enums;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.Dashboard;
+
+///
+/// Calculates dashboard display status from an application and custom labels.
+/// Behaviour matches the previous Web ApplicationStatusService helpers.
+///
+public static class DashboardApplicationStatusCalculator
+{
+ public static KeyValuePair GetCalculatedStatus(
+ ApplicationDto application,
+ IReadOnlyList customStatuses,
+ ILogger logger)
+ {
+ try
+ {
+ if (application.Status == ApplicationStatus.Submitted)
+ {
+ return new KeyValuePair(
+ ApplicationStatus.Submitted,
+ GetStatusLabel(ApplicationStatus.Submitted, customStatuses));
+ }
+
+ if (application.LatestResponse?.ResponseBody != null)
+ {
+ try
+ {
+ string responseJson;
+ try
+ {
+ var decodedBytes = Convert.FromBase64String(application.LatestResponse.ResponseBody);
+ responseJson = System.Text.Encoding.UTF8.GetString(decodedBytes);
+ }
+ catch
+ {
+ responseJson = application.LatestResponse.ResponseBody;
+ }
+
+ var responseData = JsonSerializer.Deserialize>(responseJson);
+ if (responseData != null && responseData.Any())
+ {
+ var hasFieldData = responseData.Any(kvp =>
+ !kvp.Key.StartsWith("TaskStatus_") &&
+ kvp.Value.ValueKind != JsonValueKind.Null &&
+ !string.IsNullOrWhiteSpace(kvp.Value.ToString()));
+
+ if (hasFieldData)
+ {
+ return new KeyValuePair(
+ ApplicationStatus.InProgress,
+ GetStatusLabel(ApplicationStatus.InProgress, customStatuses));
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to parse response data for application {ApplicationId}", application.ApplicationId);
+ }
+ }
+
+ var currentStatus = application.Status.HasValue ? application.Status.Value : ApplicationStatus.Created;
+ return new KeyValuePair(currentStatus, GetStatusLabel(currentStatus, customStatuses));
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to calculate application status for {ApplicationId}, defaulting to InProgress",
+ application.ApplicationId);
+ return new KeyValuePair(
+ ApplicationStatus.InProgress,
+ GetStatusLabel(ApplicationStatus.InProgress, customStatuses));
+ }
+ }
+
+ public static string GetStatusLabel(
+ ApplicationStatus status,
+ IReadOnlyList? customStatuses)
+ {
+ if (customStatuses != null)
+ {
+ var customStatus = customStatuses.FirstOrDefault(x => x.ApplicationStatus == status);
+ if (customStatus?.Label != null)
+ return customStatus.Label;
+ }
+
+ return GetBaseStatusLabel(status);
+ }
+
+ public static string GetBaseStatusLabel(ApplicationStatus status)
+ {
+ var appStatusInfo = status.GetType().GetField(status.ToString());
+ var descriptionAttributes = (DescriptionAttribute[])appStatusInfo!.GetCustomAttributes(typeof(DescriptionAttribute), false);
+ return descriptionAttributes.Length > 0 ? descriptionAttributes[0].Description : status.ToString();
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationsService.cs b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationsService.cs
new file mode 100644
index 0000000..2c49323
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardApplicationsService.cs
@@ -0,0 +1,147 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
+using GovUK.Dfe.FlexForms.Api.Client.Contracts;
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using Microsoft.Extensions.Logging;
+using Task = System.Threading.Tasks.Task;
+
+namespace GovUK.Dfe.FlexForms.Application.Dashboard;
+
+///
+/// Loads dashboard columns and lists or creates applications for the current template.
+///
+public interface IDashboardApplications
+{
+ Task> ResolveColumnsAsync(
+ Guid? templateId,
+ CancellationToken cancellationToken = default);
+
+ Task ListAsync(
+ DashboardApplicationListQuery query,
+ CancellationToken cancellationToken = default);
+
+ Task CreateAsync(
+ Guid templateId,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class DashboardApplicationsService(
+ IApplicationsClient applicationsClient,
+ IFormTemplateProvider formTemplateProvider,
+ IContributorPatternService contributorPatternService,
+ ILogger logger) : IDashboardApplications
+{
+ public async Task> ResolveColumnsAsync(
+ Guid? templateId,
+ CancellationToken cancellationToken = default)
+ {
+ if (!templateId.HasValue)
+ return DashboardColumnResolver.DefaultColumns;
+
+ try
+ {
+ var template = await formTemplateProvider.GetTemplateAsync(templateId.Value.ToString(), cancellationToken);
+ return DashboardColumnResolver.Resolve(template);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(
+ ex,
+ "Failed to load dashboard columns from latest template {TemplateId}; using defaults",
+ templateId);
+ return DashboardColumnResolver.DefaultColumns;
+ }
+ }
+
+ public async Task ListAsync(
+ DashboardApplicationListQuery query,
+ CancellationToken cancellationToken = default)
+ {
+ var result = query.Scope == DashboardApplicationListScope.AllForTemplate
+ ? await applicationsClient.GetApplicationsByTemplateAsync(
+ templateId: query.TemplateId,
+ pageNumber: query.CurrentPage,
+ pageSize: query.PageSize,
+ applicationReference: string.IsNullOrWhiteSpace(query.SearchReference) ? null : query.SearchReference,
+ dateStartedFrom: query.DateStartedFrom,
+ dateStartedTo: query.DateStartedTo,
+ dateSubmittedFrom: query.DateSubmittedFrom,
+ dateSubmittedTo: query.DateSubmittedTo,
+ status: query.Status,
+ cancellationToken: cancellationToken)
+ : await applicationsClient.GetMyApplicationsAsync(
+ templateId: query.TemplateId,
+ pageNumber: query.CurrentPage,
+ pageSize: query.PageSize,
+ applicationReference: string.IsNullOrWhiteSpace(query.SearchReference) ? null : query.SearchReference,
+ dateStartedFrom: query.DateStartedFrom,
+ dateStartedTo: query.DateStartedTo,
+ dateSubmittedFrom: query.DateSubmittedFrom,
+ dateSubmittedTo: query.DateSubmittedTo,
+ status: query.Status,
+ cancellationToken: cancellationToken);
+
+ var totalPages = result.TotalPages;
+ var currentPage = Math.Clamp(query.CurrentPage, 1, Math.Max(1, totalPages));
+ var fieldColumns = query.IncludeCustomColumns
+ ? query.Columns.Where(c => c.Kind == DashboardColumnKind.Field).ToList()
+ : [];
+
+ var applications = result.Items
+ .Select(app =>
+ {
+ IReadOnlyDictionary customValues =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ if (fieldColumns.Count > 0)
+ {
+ var formData = DashboardAnswerReader.ParseFormData(app.LatestResponse?.ResponseBody);
+ customValues = fieldColumns.ToDictionary(
+ c => c.Key,
+ c => DashboardAnswerReader.GetDisplayValue(c.FieldId!, formData),
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ return new ApplicationWithCalculatedStatus
+ {
+ Application = app,
+ CalculatedStatus = DashboardApplicationStatusCalculator.GetCalculatedStatus(
+ app,
+ query.CustomStatuses,
+ logger),
+ CustomColumnValues = customValues
+ };
+ })
+ .OrderByDescending(a => a.DateCreated)
+ .ToList();
+
+ return new DashboardApplicationListResult
+ {
+ Applications = applications,
+ TotalPages = totalPages,
+ CurrentPage = currentPage
+ };
+ }
+
+ public async Task CreateAsync(
+ Guid templateId,
+ CancellationToken cancellationToken = default)
+ {
+ var response = await applicationsClient.CreateApplicationAsync(
+ new CreateApplicationRequest
+ {
+ InitialResponseBody = "{}",
+ TemplateId = templateId
+ },
+ cancellationToken);
+
+ var contributorsEnabled = await contributorPatternService.IsEnabledAsync(
+ templateId.ToString(),
+ cancellationToken: cancellationToken);
+
+ return new DashboardCreateApplicationResult
+ {
+ Application = response,
+ ContributorsEnabled = contributorsEnabled
+ };
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardMessages.cs b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardMessages.cs
new file mode 100644
index 0000000..4c3815e
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/Dashboard/DashboardMessages.cs
@@ -0,0 +1,9 @@
+namespace GovUK.Dfe.FlexForms.Application.Dashboard;
+
+///
+/// User-facing copy for the applications dashboard. Keep these strings identical to the previous PageModel.
+///
+public static class DashboardMessages
+{
+ public const string TemplateNotConfigured = "Template is not configured. Please refresh the page.";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/CollectionFlowProgressStore.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/CollectionFlowProgressStore.cs
new file mode 100644
index 0000000..cd8dfa5
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/CollectionFlowProgressStore.cs
@@ -0,0 +1,58 @@
+using System.Text.Json;
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using GovUK.Dfe.FlexForms.Domain.Caching;
+
+namespace GovUK.Dfe.FlexForms.Application.FormEngine;
+
+public sealed class CollectionFlowProgressStore(IFormSessionStore sessionStore) : ICollectionFlowProgressStore
+{
+ public Dictionary Load(string flowId, string instanceId)
+ {
+ if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId))
+ return new Dictionary();
+
+ var json = sessionStore.GetString(FormSessionKeys.FlowProgress(flowId, instanceId));
+ if (string.IsNullOrWhiteSpace(json))
+ return new Dictionary();
+
+ try
+ {
+ return JsonSerializer.Deserialize>(json)
+ ?? new Dictionary();
+ }
+ catch (JsonException)
+ {
+ return new Dictionary();
+ }
+ }
+
+ public void Save(string flowId, string instanceId, Dictionary latest)
+ {
+ if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId))
+ return;
+
+ var existing = Load(flowId, instanceId);
+ foreach (var kv in latest)
+ existing[kv.Key] = kv.Value;
+
+ sessionStore.SetString(FormSessionKeys.FlowProgress(flowId, instanceId), JsonSerializer.Serialize(existing));
+ }
+
+ public void SetField(string flowId, string instanceId, string fieldId, object value)
+ {
+ if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId) || string.IsNullOrEmpty(fieldId))
+ return;
+
+ var existing = Load(flowId, instanceId);
+ existing[fieldId] = value;
+ sessionStore.SetString(FormSessionKeys.FlowProgress(flowId, instanceId), JsonSerializer.Serialize(existing));
+ }
+
+ public void Clear(string flowId, string instanceId)
+ {
+ if (string.IsNullOrEmpty(flowId) || string.IsNullOrEmpty(instanceId))
+ return;
+
+ sessionStore.Remove(FormSessionKeys.FlowProgress(flowId, instanceId));
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/CompleteFormTaskService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/CompleteFormTaskService.cs
new file mode 100644
index 0000000..8d5be69
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/CompleteFormTaskService.cs
@@ -0,0 +1,204 @@
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using GovUK.Dfe.FlexForms.Application.Validation;
+using GovUK.Dfe.FlexForms.Domain.FormEngine;
+using GovUK.Dfe.FlexForms.Domain.Models;
+using Microsoft.Extensions.Logging;
+using TaskModel = GovUK.Dfe.FlexForms.Domain.Models.Task;
+
+namespace GovUK.Dfe.FlexForms.Application.FormEngine;
+
+///
+/// Marks a task complete (or reverts it) from the task-summary POST.
+///
+public interface ICompleteFormTask
+{
+ Task ExecuteAsync(FormEngineWorkState state, CancellationToken cancellationToken = default);
+}
+
+public sealed class CompleteFormTaskService(
+ IApplicationStateService applicationStateService,
+ IFieldRequirementService fieldRequirementService,
+ IConditionalLogicOrchestrator conditionalLogicOrchestrator,
+ ILogger logger) : ICompleteFormTask
+{
+ public async Task ExecuteAsync(
+ FormEngineWorkState state,
+ CancellationToken cancellationToken = default)
+ {
+ var conditionalState = await ApplyTaskSummaryConditionalLogicAsync(state);
+ var visibility = new FormEngineVisibilityEvaluator(
+ state.Template,
+ conditionalState,
+ conditionalLogicOrchestrator,
+ state.CurrentPageId,
+ state.TaskId,
+ logger);
+
+ if (state.CurrentTask != null && state.ApplicationId.HasValue)
+ {
+ if (state.IsTaskCompleted)
+ {
+ var errorLines = CollectCompletionErrors(state, visibility);
+ if (errorLines.Count > 0)
+ {
+ var errorMessage =
+ "You cannot mark this section as complete because some required questions have not been answered:\n" +
+ string.Join("\n", errorLines);
+ return FormEngineOutcome.Stay(
+ formState: FormState.TaskSummary,
+ errors: [new FormValidationError(string.Empty, errorMessage)],
+ clearModelState: true,
+ isTaskCompleted: false,
+ conditionalState: conditionalState);
+ }
+
+ await applicationStateService.SaveTaskStatusAsync(
+ state.ApplicationId.Value,
+ state.CurrentTask.TaskId,
+ Domain.Models.TaskStatus.Completed);
+ }
+ else
+ {
+ var currentStatus = applicationStateService.CalculateTaskStatus(
+ state.CurrentTask.TaskId,
+ state.Template!,
+ state.FormData,
+ state.ApplicationId,
+ state.ApplicationStatus);
+ if (currentStatus == Domain.Models.TaskStatus.Completed)
+ {
+ var calculatedStatus = HasAnyTaskData(state.CurrentTask, state.FormData)
+ ? Domain.Models.TaskStatus.InProgress
+ : Domain.Models.TaskStatus.NotStarted;
+ await applicationStateService.SaveTaskStatusAsync(
+ state.ApplicationId.Value,
+ state.CurrentTask.TaskId,
+ calculatedStatus);
+ }
+ }
+ }
+
+ return FormEngineOutcome.Redirect($"/applications/{state.ReferenceNumber}");
+ }
+
+ private async Task ApplyTaskSummaryConditionalLogicAsync(FormEngineWorkState state)
+ {
+ try
+ {
+ if (state.Template?.ConditionalLogic != null && state.Template.ConditionalLogic.Any())
+ {
+ var context = new ConditionalLogicContext
+ {
+ CurrentPageId = state.CurrentPageId,
+ CurrentTaskId = state.TaskId,
+ IsClientSide = false,
+ Trigger = "task_summary_validation"
+ };
+ return await conditionalLogicOrchestrator.ApplyConditionalLogicAsync(
+ state.Template,
+ state.FormData,
+ context);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error applying conditional logic in task summary validation");
+ }
+
+ return new FormConditionalState();
+ }
+
+ private List CollectCompletionErrors(FormEngineWorkState state, FormEngineVisibilityEvaluator visibility)
+ {
+ var errorLines = new List();
+ var missingFieldsWithMessages = fieldRequirementService.GetMissingRequiredFieldsWithMessages(
+ state.CurrentTask!,
+ state.Template!,
+ state.FormData,
+ visibility.IsFieldHidden);
+
+ errorLines.AddRange(missingFieldsWithMessages.Values);
+
+ if (state.CurrentTask!.Summary?.Mode?.Equals("multiCollectionFlow", StringComparison.OrdinalIgnoreCase) != true
+ || state.CurrentTask.Summary.Flows == null
+ || !state.CurrentTask.Summary.Flows.Any())
+ {
+ return errorLines;
+ }
+
+ foreach (var flow in state.CurrentTask.Summary.Flows)
+ {
+ var items = FormEngineCollectionItems.Read(state.FormData, flow.FieldId);
+ var requiredMin = flow.MinItems ?? 1;
+ if (items.Count < requiredMin)
+ {
+ var flowTitle = string.IsNullOrWhiteSpace(flow.Title)
+ ? (string.IsNullOrWhiteSpace(state.CurrentTask.TaskName) ? "this section" : state.CurrentTask.TaskName)
+ : flow.Title;
+ errorLines.Add($"• Add at least {requiredMin} item(s) to {flowTitle}");
+ logger.LogInformation(
+ "Collection flow '{FlowId}' requires at least {MinItems} items but has {Count}",
+ flow.FlowId,
+ requiredMin,
+ items.Count);
+ }
+
+ if (flow.Pages == null || items.Count == 0)
+ continue;
+
+ foreach (var item in items)
+ {
+ var requiredFieldIds = flow.Pages
+ .Where(p => p?.Fields != null)
+ .SelectMany(p => p.Fields)
+ .Where(f => fieldRequirementService.IsFieldRequired(f, state.Template!))
+ .Select(f => f.FieldId)
+ .ToList();
+ visibility.EnsureItemFieldVisibility(item, requiredFieldIds);
+
+ var itemHasMissingFields = flow.Pages
+ .Where(page => page?.Fields != null)
+ .SelectMany(page => page.Fields)
+ .Where(field => fieldRequirementService.IsFieldRequired(field, state.Template!))
+ .Where(field => !visibility.IsFieldHiddenForItem(field.FieldId, item))
+ .Any(field =>
+ {
+ var hasValue = item.TryGetValue(field.FieldId, out var val)
+ && val != null
+ && !string.IsNullOrWhiteSpace(val.ToString());
+ return !hasValue;
+ });
+
+ if (!itemHasMissingFields)
+ continue;
+
+ var flowTitle = string.IsNullOrWhiteSpace(flow.Title)
+ ? (string.IsNullOrWhiteSpace(state.CurrentTask.TaskName) ? "this section" : state.CurrentTask.TaskName)
+ : flow.Title;
+ errorLines.Add($"Complete all required questions for each item in {flowTitle}");
+ logger.LogInformation("Collection flow '{FlowId}' has an item with incomplete required fields", flow.FlowId);
+ break;
+ }
+ }
+
+ return errorLines;
+ }
+
+ private static bool HasAnyTaskData(TaskModel task, Dictionary formData)
+ {
+ var taskFieldIds = new List();
+ if (task.Pages != null)
+ {
+ taskFieldIds.AddRange(task.Pages.SelectMany(p => p.Fields).Select(f => f.FieldId));
+ }
+
+ if (task.Summary?.Mode?.Equals(FormStepPolicy.MultiCollectionFlowMode, StringComparison.OrdinalIgnoreCase) == true
+ && task.Summary.Flows != null)
+ {
+ taskFieldIds.AddRange(task.Summary.Flows.Select(f => f.FieldId));
+ }
+
+ return taskFieldIds.Any(fieldId =>
+ formData.ContainsKey(fieldId) && !string.IsNullOrWhiteSpace(formData[fieldId]?.ToString()));
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/DeleteFormFileService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/DeleteFormFileService.cs
new file mode 100644
index 0000000..d15a671
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/DeleteFormFileService.cs
@@ -0,0 +1,91 @@
+using System.Text.Json;
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.FormEngine;
+
+public sealed record DeleteFormFileRequest(
+ Guid ApplicationId,
+ Guid FileId,
+ string FieldId,
+ string? ReturnUrl,
+ bool Confirmed);
+
+///
+/// Deletes an uploaded file and persists the remaining field list.
+///
+public interface IDeleteFormFile
+{
+ Task ExecuteAsync(
+ FormEngineWorkState state,
+ DeleteFormFileRequest request,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class DeleteFormFileService(
+ IFormFileFieldService formFileFieldService,
+ IFileUploadService fileUploadService,
+ IApplicationResponseService applicationResponseService,
+ ILogger logger) : IDeleteFormFile
+{
+ public async Task ExecuteAsync(
+ FormEngineWorkState state,
+ DeleteFormFileRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (state.Template == null)
+ state.Template = FormEngineConstants.CreateDummyTemplate();
+
+ if (!request.Confirmed)
+ {
+ logger.LogInformation("DeleteFile handler executing for validation - file will not be deleted yet");
+ return FormEngineOutcome.Redirect(request.ReturnUrl ?? $"/applications/{state.ReferenceNumber}");
+ }
+
+ try
+ {
+ await fileUploadService.DeleteFileAsync(request.FileId, request.ApplicationId, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to delete file {FileId} for application {ApplicationId}", request.FileId, request.ApplicationId);
+ throw;
+ }
+
+ var context = new FormFileFieldContext(request.ApplicationId, state.FlowId, state.InstanceId);
+ var currentFieldFiles = formFileFieldService.GetFiles(context, request.FieldId).ToList();
+ currentFieldFiles.RemoveAll(f => f.Id == request.FileId);
+ formFileFieldService.SaveFiles(context, request.FieldId, currentFieldFiles);
+ await SaveUploadedFilesToResponseAsync(request.ApplicationId, request.FieldId, currentFieldFiles, cancellationToken);
+
+ if (!string.IsNullOrEmpty(request.ReturnUrl))
+ {
+ return FormEngineOutcome.Redirect(
+ request.ReturnUrl,
+ successMessage: FormEngineMessages.FileDeleted,
+ notificationContext: $"file-delete|{request.FileId}");
+ }
+
+ return FormEngineOutcome.Stay(
+ successMessage: FormEngineMessages.FileDeleted,
+ files: currentFieldFiles,
+ notificationContext: $"file-delete|{request.FileId}");
+ }
+
+ private async Task SaveUploadedFilesToResponseAsync(
+ Guid appId,
+ string fieldId,
+ IReadOnlyList files,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(fieldId))
+ return;
+
+ var json = JsonSerializer.Serialize(files);
+ await applicationResponseService.SaveApplicationResponseAsync(
+ appId,
+ new Dictionary { { fieldId, json } },
+ cancellationToken);
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/DownloadFormFileService.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/DownloadFormFileService.cs
new file mode 100644
index 0000000..514d87f
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/DownloadFormFileService.cs
@@ -0,0 +1,63 @@
+using System.Net;
+using System.Text.RegularExpressions;
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.FormEngine;
+
+public sealed record DownloadFormFileRequest(Guid ApplicationId, Guid FileId);
+
+///
+/// Downloads an uploaded file as a stream with content-type and file name.
+///
+public interface IDownloadFormFile
+{
+ Task ExecuteAsync(
+ FormEngineWorkState state,
+ DownloadFormFileRequest request,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class DownloadFormFileService(
+ IFileUploadService fileUploadService,
+ ILogger logger) : IDownloadFormFile
+{
+ public async Task ExecuteAsync(
+ FormEngineWorkState state,
+ DownloadFormFileRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ if (state.Template == null)
+ state.Template = FormEngineConstants.CreateDummyTemplate();
+
+ var fileResponse = await fileUploadService.DownloadFileAsync(
+ request.FileId,
+ request.ApplicationId,
+ cancellationToken);
+
+ var contentType = fileResponse.Headers.TryGetValue("Content-Type", out var ct)
+ ? ct.FirstOrDefault()
+ : "application/octet-stream";
+
+ var fileName = "downloadedfile";
+ if (fileResponse.Headers.TryGetValue("Content-Disposition", out var cd))
+ {
+ var disposition = cd.FirstOrDefault();
+ if (!string.IsNullOrEmpty(disposition))
+ {
+ var fileNameMatch = Regex.Match(
+ disposition,
+ @"filename\*=UTF-8''(?.+)|filename=""?(?[^\"";]+)""?");
+ if (fileNameMatch.Success)
+ fileName = WebUtility.UrlDecode(fileNameMatch.Groups["fileName"].Value);
+ }
+ }
+
+ logger.LogInformation(
+ "Downloading file {FileId} for application {ApplicationId}",
+ request.FileId,
+ request.ApplicationId);
+
+ return FormEngineOutcome.File(fileResponse.Stream, contentType ?? "application/octet-stream", fileName);
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineCollectionItems.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineCollectionItems.cs
new file mode 100644
index 0000000..397f327
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineCollectionItems.cs
@@ -0,0 +1,28 @@
+using System.Text.Json;
+
+namespace GovUK.Dfe.FlexForms.Application.FormEngine;
+
+///
+/// Reads collection-flow item lists stored as JSON in accumulated form data.
+///
+public static class FormEngineCollectionItems
+{
+ public static List> Read(Dictionary formData, string fieldId)
+ {
+ if (!formData.TryGetValue(fieldId, out var value) || value == null)
+ return [];
+
+ var json = value.ToString();
+ if (string.IsNullOrWhiteSpace(json) || !json.TrimStart().StartsWith('['))
+ return [];
+
+ try
+ {
+ return JsonSerializer.Deserialize>>(json) ?? [];
+ }
+ catch (JsonException)
+ {
+ return [];
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConditionalLogic.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConditionalLogic.cs
new file mode 100644
index 0000000..12cfdd9
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConditionalLogic.cs
@@ -0,0 +1,65 @@
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using GovUK.Dfe.FlexForms.Domain.Models;
+using Microsoft.Extensions.Logging;
+
+namespace GovUK.Dfe.FlexForms.Application.FormEngine;
+
+internal static class FormEngineConditionalLogic
+{
+ public static async Task ApplyAsync(
+ FormTemplate? template,
+ Dictionary data,
+ Dictionary formData,
+ IConditionalLogicOrchestrator orchestrator,
+ string pageId,
+ string taskId,
+ string trigger,
+ ILogger logger,
+ Dictionary? accumulatedForChange = null)
+ {
+ try
+ {
+ if (template?.ConditionalLogic == null || !template.ConditionalLogic.Any())
+ return new FormConditionalState();
+
+ var dataForConditionalLogic = data.Count > 0
+ ? new Dictionary(data)
+ : new Dictionary(formData);
+
+ if (trigger == "change" && accumulatedForChange != null)
+ {
+ foreach (var kvp in accumulatedForChange)
+ {
+ if (!dataForConditionalLogic.ContainsKey(kvp.Key))
+ dataForConditionalLogic[kvp.Key] = kvp.Value;
+ }
+ }
+
+ var context = new ConditionalLogicContext
+ {
+ CurrentPageId = pageId,
+ CurrentTaskId = taskId,
+ IsClientSide = false,
+ Trigger = trigger
+ };
+
+ var conditionalState = await orchestrator.ApplyConditionalLogicAsync(
+ template,
+ dataForConditionalLogic,
+ context);
+
+ if (conditionalState.FieldValues.Any())
+ {
+ foreach (var kvp in conditionalState.FieldValues)
+ data[kvp.Key] = kvp.Value;
+ }
+
+ return conditionalState;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "CONDITIONAL LOGIC ERROR: {Message}", ex.Message);
+ return new FormConditionalState();
+ }
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConstants.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConstants.cs
new file mode 100644
index 0000000..8de44c0
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineConstants.cs
@@ -0,0 +1,19 @@
+using GovUK.Dfe.FlexForms.Domain.Models;
+
+namespace GovUK.Dfe.FlexForms.Application.FormEngine;
+
+public static class FormEngineConstants
+{
+ public const string UploadFieldSessionPlaceholder = "UPLOAD_FIELD_SESSION_DATA";
+
+ public const string CurrentAccumulatedApplicationIdWriteKey = "CurrentAccumulatedApplicationId";
+
+ public static FormTemplate CreateDummyTemplate() =>
+ new()
+ {
+ TemplateId = "dummy",
+ TemplateName = "dummy",
+ Description = "dummy",
+ TaskGroups = []
+ };
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineDerivedItems.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineDerivedItems.cs
new file mode 100644
index 0000000..ec4143c
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineDerivedItems.cs
@@ -0,0 +1,106 @@
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using GovUK.Dfe.FlexForms.Domain.Models;
+using Microsoft.Extensions.Logging;
+using PageModel = GovUK.Dfe.FlexForms.Domain.Models.Page;
+
+namespace GovUK.Dfe.FlexForms.Application.FormEngine;
+
+internal static class FormEngineDerivedItems
+{
+ public static void LoadItemData(
+ DerivedCollectionFlowConfiguration config,
+ string itemId,
+ FormEngineWorkState state,
+ IDerivedCollectionFlowService derivedCollectionFlowService,
+ ILogger logger)
+ {
+ try
+ {
+ var existingData = derivedCollectionFlowService.GetItemDeclarationData(config.FieldId, itemId, state.FormData);
+ foreach (var kvp in existingData)
+ state.Data[kvp.Key] = kvp.Value;
+
+ var derivedItems = derivedCollectionFlowService.GenerateItemsFromSourceField(
+ config.SourceFieldId,
+ state.FormData,
+ config);
+ var currentItem = derivedItems.FirstOrDefault(item => item.Id == itemId);
+
+ if (currentItem != null)
+ {
+ foreach (var kvp in currentItem.PrefilledData)
+ {
+ if (!state.Data.ContainsKey(kvp.Key))
+ state.Data[kvp.Key] = kvp.Value;
+ }
+
+ logger.LogInformation(
+ "Loaded derived item data for item {ItemId} in flow {FlowId} with {Count} fields",
+ itemId,
+ config.FlowId,
+ currentItem.PrefilledData.Count);
+ }
+
+ if (state.CurrentPage != null)
+ {
+ foreach (var field in state.CurrentPage.Fields)
+ {
+ if (field.Label != null)
+ field.Label.IsVisible = true;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to load derived item data for item {ItemId} in flow {FlowId}", itemId, config.FlowId);
+ }
+ }
+
+ public static void ApplyDisplayNamePlaceholders(PageModel page, string displayName)
+ {
+ if (!string.IsNullOrEmpty(page.Title))
+ {
+ page.Title = page.Title
+ .Replace("{displayName}", displayName)
+ .Replace("{name}", displayName);
+ }
+
+ if (!string.IsNullOrEmpty(page.Description))
+ {
+ page.Description = page.Description
+ .Replace("{displayName}", displayName)
+ .Replace("{name}", displayName);
+ }
+ }
+
+ public static string GetDisplayName(
+ DerivedCollectionFlowConfiguration config,
+ string itemId,
+ Dictionary formData,
+ IDerivedCollectionFlowService derivedCollectionFlowService)
+ {
+ try
+ {
+ var items = derivedCollectionFlowService.GenerateItemsFromSourceField(config.SourceFieldId, formData, config);
+ var match = items.FirstOrDefault(i => string.Equals(i.Id, itemId, StringComparison.OrdinalIgnoreCase));
+ if (match != null)
+ {
+ if (!string.IsNullOrWhiteSpace(match.DisplayName))
+ return match.DisplayName;
+
+ if (match.PrefilledData != null
+ && match.PrefilledData.TryGetValue(config.ItemTitleBinding, out var value)
+ && !string.IsNullOrWhiteSpace(value?.ToString()))
+ {
+ return value.ToString()!;
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // Fall back to the raw item id when source data cannot be read.
+ }
+
+ return itemId;
+ }
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineMessages.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineMessages.cs
new file mode 100644
index 0000000..f428985
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineMessages.cs
@@ -0,0 +1,27 @@
+namespace GovUK.Dfe.FlexForms.Application.FormEngine;
+
+///
+/// User-facing copy for form-engine use cases. Keep these strings identical to the previous PageModel.
+///
+public static class FormEngineMessages
+{
+ public const string NoWritePermission =
+ "You do not have permission to make changes to this application.";
+
+ public const string AllSectionsMustBeCompleted =
+ "All sections must be completed before you can submit your application.";
+
+ public const string ApplicationNotFound =
+ "Application not found. Please try again.";
+
+ public const string SelectAFile = "Select a file to upload";
+
+ public const string DuplicateFileName =
+ "The selected file has already been uploaded. Upload a file with a different name.\n ";
+
+ public const string InvalidFileId = "Invalid file ID.";
+
+ public const string FileDeleted = "File deleted.";
+
+ public const string FieldIdAndItemIdRequired = "Field ID and Item ID are required";
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineOutcome.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineOutcome.cs
new file mode 100644
index 0000000..b9d2472
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineOutcome.cs
@@ -0,0 +1,159 @@
+using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using GovUK.Dfe.FlexForms.Application.Interfaces;
+using GovUK.Dfe.FlexForms.Application.Validation;
+using GovUK.Dfe.FlexForms.Domain.Models;
+
+namespace GovUK.Dfe.FlexForms.Application.FormEngine;
+
+public enum FormEngineOutcomeKind
+{
+ StayOnPage,
+ Redirect,
+ RedirectToPage,
+ NotFound,
+ BadRequest,
+ FileDownload
+}
+
+///
+/// HTTP-agnostic result of a form-engine use case. The PageModel maps this to Page() / Redirect().
+///
+public sealed class FormEngineOutcome
+{
+ public FormEngineOutcomeKind Kind { get; init; }
+
+ public string? RedirectUrl { get; init; }
+
+ public string? PageName { get; init; }
+
+ public object? RouteValues { get; init; }
+
+ public IReadOnlyList Errors { get; init; } = [];
+
+ public bool ClearModelState { get; init; }
+
+ public FormState? FormState { get; init; }
+
+ public bool? IsTaskCompleted { get; init; }
+
+ public string? SuccessMessage { get; init; }
+
+ public string? ErrorMessage { get; init; }
+
+ public IReadOnlyList? Files { get; init; }
+
+ public bool FileValidationBlocksSubmit { get; init; }
+
+ public IReadOnlyList BlockingFiles { get; init; } = [];
+
+ public bool PersistErrors { get; init; }
+
+ public string? ErrorContextKey { get; init; }
+
+ public IReadOnlyList ErrorStoreKeysToClear { get; init; } = [];
+
+ public IReadOnlyList ModelStateKeysToRemove { get; init; } = [];
+
+ public bool ReloadFormData { get; init; }
+
+ public FormConditionalState? ConditionalState { get; init; }
+
+ public Stream? FileStream { get; init; }
+
+ public string? FileContentType { get; init; }
+
+ public string? FileDownloadName { get; init; }
+
+ public string? NotificationContext { get; init; }
+
+ public static FormEngineOutcome Stay(
+ FormState? formState = null,
+ IReadOnlyList? errors = null,
+ bool clearModelState = false,
+ bool? isTaskCompleted = null,
+ bool reloadFormData = false,
+ FormConditionalState? conditionalState = null,
+ bool persistErrors = false,
+ string? errorContextKey = null,
+ string? errorMessage = null,
+ string? successMessage = null,
+ IReadOnlyList? files = null,
+ bool fileValidationBlocksSubmit = false,
+ IReadOnlyList? blockingFiles = null,
+ IReadOnlyList? errorStoreKeysToClear = null,
+ IReadOnlyList? modelStateKeysToRemove = null,
+ string? notificationContext = null) =>
+ new()
+ {
+ Kind = FormEngineOutcomeKind.StayOnPage,
+ FormState = formState,
+ Errors = errors ?? [],
+ ClearModelState = clearModelState,
+ IsTaskCompleted = isTaskCompleted,
+ ReloadFormData = reloadFormData,
+ ConditionalState = conditionalState,
+ PersistErrors = persistErrors,
+ ErrorContextKey = errorContextKey,
+ ErrorMessage = errorMessage,
+ SuccessMessage = successMessage,
+ Files = files,
+ FileValidationBlocksSubmit = fileValidationBlocksSubmit,
+ BlockingFiles = blockingFiles ?? [],
+ ErrorStoreKeysToClear = errorStoreKeysToClear ?? [],
+ ModelStateKeysToRemove = modelStateKeysToRemove ?? [],
+ NotificationContext = notificationContext
+ };
+
+ public static FormEngineOutcome Redirect(
+ string url,
+ string? successMessage = null,
+ IReadOnlyList? errors = null,
+ bool persistErrors = false,
+ string? errorContextKey = null,
+ IReadOnlyList? files = null,
+ string? errorMessage = null,
+ IReadOnlyList? errorStoreKeysToClear = null,
+ IReadOnlyList? modelStateKeysToRemove = null,
+ string? notificationContext = null) =>
+ new()
+ {
+ Kind = FormEngineOutcomeKind.Redirect,
+ RedirectUrl = url,
+ SuccessMessage = successMessage,
+ Errors = errors ?? [],
+ PersistErrors = persistErrors,
+ ErrorContextKey = errorContextKey,
+ Files = files,
+ ErrorMessage = errorMessage,
+ ErrorStoreKeysToClear = errorStoreKeysToClear ?? [],
+ ModelStateKeysToRemove = modelStateKeysToRemove ?? [],
+ NotificationContext = notificationContext
+ };
+
+ public static FormEngineOutcome RedirectToPage(string pageName, object? routeValues = null) =>
+ new()
+ {
+ Kind = FormEngineOutcomeKind.RedirectToPage,
+ PageName = pageName,
+ RouteValues = routeValues
+ };
+
+ public static FormEngineOutcome NotFound() =>
+ new() { Kind = FormEngineOutcomeKind.NotFound };
+
+ public static FormEngineOutcome BadRequest(string message) =>
+ new()
+ {
+ Kind = FormEngineOutcomeKind.BadRequest,
+ ErrorMessage = message
+ };
+
+ public static FormEngineOutcome File(Stream stream, string contentType, string fileName) =>
+ new()
+ {
+ Kind = FormEngineOutcomeKind.FileDownload,
+ FileStream = stream,
+ FileContentType = contentType,
+ FileDownloadName = fileName
+ };
+}
diff --git a/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineSuccessMessages.cs b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineSuccessMessages.cs
new file mode 100644
index 0000000..9b149c0
--- /dev/null
+++ b/src/GovUK.Dfe.FlexForms.Application/FormEngine/FormEngineSuccessMessages.cs
@@ -0,0 +1,92 @@
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace GovUK.Dfe.FlexForms.Application.FormEngine;
+
+///
+/// Success-message templates previously built in the PageModel/DisplayHelpers.
+/// Keep the strings identical.
+///
+internal static class FormEngineSuccessMessages
+{
+ public static Dictionary? ExpandEncodedJson(Dictionary? itemData)
+ {
+ if (itemData == null)
+ return null;
+
+ var expanded = new Dictionary();
+ foreach (var kvp in itemData)
+ expanded[kvp.Key] = TransformEncodedJsonString(kvp.Value);
+ return expanded;
+ }
+
+ public static string Generate(string? customMessage, string operation, Dictionary? itemData, string? flowTitle)
+ {
+ if (!string.IsNullOrEmpty(customMessage))
+ {
+ customMessage = customMessage.Replace("{flowTitle}", flowTitle ?? "collection");
+ return Interpolate(customMessage, itemData);
+ }
+
+ var displayName = GetDisplayNameFromItemData(itemData);
+ var lowerFlowTitle = flowTitle?.ToLowerInvariant() ?? "collection";
+
+ return operation switch
+ {
+ "add" => $"{displayName} has been added to {lowerFlowTitle}",
+ "update" => $"{displayName} has been updated",
+ "delete" => $"{displayName} has been removed from {lowerFlowTitle}",
+ _ => $"{displayName} has been processed"
+ };
+ }
+
+ private static object TransformEncodedJsonString(object value)
+ {
+ if (value is JsonElement { ValueKind: JsonValueKind.String } jsonString)
+ {
+ try
+ {
+ return JsonSerializer.Deserialize(jsonString.GetString() ?? "") ;
+ }
+ catch (JsonException)
+ {
+ return value;
+ }
+ }
+
+ return value;
+ }
+
+ private static string Interpolate(string message, Dictionary