yavsc/doc/dev-tracking/client-editor-overhaul.md
Paul Schneider 6aaff74082 fix(client-controller): single constructor with IHtmlLocalizer
The partial class ClientController had two constructors declared
across ClientController.cs and ClientController.Collections.cs.
ASP.NET Core DI failed to pick one at request time with:

  System.InvalidOperationException: Multiple constructors accepting
  all given argument types have been found in type
  'Yavsc.Controllers.ClientController'.

Move IHtmlLocalizer<ClientController> into the primary constructor
in ClientController.cs and drop the duplicate one in
ClientController.Collections.cs. The Collections partial now keeps
only its readonly field and action methods; the constructor and
field assignment are unified on the main file.

Also add the missing 'using Microsoft.AspNetCore.Mvc.Localization;'
to ClientController.cs so IHtmlLocalizer resolves.
2026-06-21 21:14:20 +01:00

13 KiB

Client editor overhaul — Yavsc.Org administration

Goal

Bring the OAuth2 client administration UI (/Client/Edit/{id} and friends) in Yavsc.Org to feature parity with the IdentityServer8 Client entity model. Today the editor only exposes a handful of scalar fields and a few single-line inputs for collections; the bulk of the entity and its related collections are unreachable from the UI.

Inventory — current state

Properties exposed by Views/Client/Edit.cshtml

Field Type Notes
ClientId string hidden, identifier
Enabled bool checkbox
ClientName string display name
FrontChannelLogoutUri string only front-channel, no back-channel
RedirectUris collection rendered as a single text input
IdentityTokenLifetime int seconds
AbsoluteRefreshTokenLifetime int seconds
ClientSecrets collection rendered as a single text input
AccessTokenType enum dropdown (custom SetAppTypesInputValues)

Properties of IdentityServer8.EntityFramework.Entities.Client NOT in the editor

Core scalars (16 fields missing):

  • Description
  • ClientUri
  • LogoUri
  • RequireConsent
  • RequirePkce
  • RequireRequestObject
  • RequireClientSecret
  • AllowPlainTextPkce
  • AllowOfflineAccess
  • AllowRememberConsent
  • AlwaysIncludeUserClaimsInIdToken
  • AlwaysSendClientClaims
  • AuthorizationCodeLifetime
  • BackChannelLogoutUri
  • BackChannelLogoutSessionRequired
  • CibaLifetime
  • ClientClaimsPrefix
  • ConsentLifetime
  • Created
  • DeviceCodeLifetime
  • EnableLocalLogin
  • Enabled
  • FrontChannelLogoutSessionRequired
  • IncludeJwtId
  • LastAccessed
  • LogoUri
  • NonEditable
  • PairwiseSubjectSalt
  • PollingInterval
  • ProtocolType
  • RefreshTokenExpiration
  • RefreshTokenUsage
  • SlidingRefreshTokenLifetime
  • UpdateAccessTokenClaimsOnRefresh
  • Updated
  • UserCodeType
  • UserSsoLifetime

Collections (8 missing — currently either not exposed at all, or jammed into a single-line text input that doesn't work for an IEnumerable):

  • AllowedGrantTypesClientGrantType (GrantType)
  • AllowedScopesClientScope (Scope)
  • RedirectUrisClientRedirectUri (RedirectUri) — exposed but broken
  • PostLogoutRedirectUrisClientPostLogoutRedirectUri (PostLogoutRedirectUri)
  • AllowedCorsOriginsClientCorsOrigin (Origin)
  • IdentityProviderRestrictionsClientIdPRestriction (Provider)
  • ClaimsClientClaim (Type, Value)
  • PropertiesClientProperty (Key, Value)
  • ClientSecretsClientSecret (Type, Value, Description, Created, Expiration) — exposed but broken
  • AllowedSigningAlgorithms → scalar string collection on Client itself

Pages to add

Pattern: one Razor page per collection under Views/Client/Edit{Collection}.cshtml. Each page lists existing rows, offers an "Add" form with the relevant fields, and a per-row remove button. The main Edit.cshtml becomes a hub page with links to each subpage plus the scalar fields it already has.

Page Route Form fields
Edit.cshtml GET /Client/Edit/{id} (existing) scalar fields + nav links
EditRedirectUris.cshtml GET /Client/EditRedirectUris/{id} RedirectUri
EditPostLogoutRedirectUris.cshtml GET /Client/EditPostLogoutRedirectUris/{id} PostLogoutRedirectUri
EditScopes.cshtml GET /Client/EditScopes/{id} Scope (with select of known scopes)
EditGrantTypes.cshtml GET /Client/EditGrantTypes/{id} GrantType (with select of known types)
EditCorsOrigins.cshtml GET /Client/EditCorsOrigins/{id} Origin
EditIdPRestrictions.cshtml GET /Client/EditIdPRestrictions/{id} Provider
EditClaims.cshtml GET /Client/EditClaims/{id} Type, Value
EditProperties.cshtml GET /Client/EditProperties/{id} Key, Value
EditSecrets.cshtml (replacement) GET /Client/EditSecrets/{id} Type, Value, Description, Expiration

Partial view _EditableList.cshtml factored once and consumed by all of the above.

Controller actions to add

For each collection Foo:

  • GET EditFoo(int id) — load the client, render the page
  • POST AddFoo(int id, …) — append a row, redirect to EditFoo
  • POST RemoveFoo(int id, int rowId) — delete a row, redirect

Verification

  • dotnet build src/Yavsc.Org/Yavsc.Org.csproj → 0 errors
  • No tests in Yavsc.Org.Tests exercise the controller today (per find … -name "ClientController*" -not -path "*/bin/*"). Smoke-test by logging in as admin, hitting /Client/Edit/1, then each Edit*/1 page, and verifying the add/remove POSTs.
  • Existing seed flow (MigratePostItClientToPublic in HostingExtensions.cs) must keep working — the editor changes are additive, not destructive.

Out of scope

  • Tests (no MVC test infrastructure currently exists for this controller)
  • Migration of existing collection fields (the broken RedirectUris text input will simply be replaced by the new subpage)
  • Per-collection authorization policies (the controller is already [Authorize("AdministratorOnly")])
  • Client cloning / templating / JSON import-export

Status

2026-06-21 16:04 — kickoff. Inventory done. Pages not yet started.

2026-06-21 16:11 — first delivery, build does not compile by design (per Paul: "Tu peux même me laisser un travail qui ne compile pas"). The structural work is done; the residual errors are easy fixes Paul will do in a debug session.

Files added (working tree, not yet committed):

  • src/Yavsc.Org/Controllers/Administration/ClientController.Collections.cs — partial class with the per-collection GET / Add / Remove actions.
  • src/Yavsc.Org/Views/Client/EditRedirectUris.cshtml
  • src/Yavsc.Org/Views/Client/EditPostLogoutRedirectUris.cshtml
  • src/Yavsc.Org/Views/Client/EditScopes.cshtml
  • src/Yavsc.Org/Views/Client/EditGrantTypes.cshtml
  • src/Yavsc.Org/Views/Client/EditCorsOrigins.cshtml
  • src/Yavsc.Org/Views/Client/EditIdPRestrictions.cshtml
  • src/Yavsc.Org/Views/Client/EditClaims.cshtml
  • src/Yavsc.Org/Views/Client/EditProperties.cshtml
  • src/Yavsc.Org/Views/Client/EditSecrets.cshtml
  • src/Yavsc.Org/Views/Client/_EditableStringList.cshtml — partial consumed by the single-string-field collection pages.

Files modified:

  • src/Yavsc.Org/Controllers/Administration/ClientController.csclasspartial class; the Edit(int id) GET now uses LoadClientAsync to load all navigations (so the new Edit.cshtml can render counts in its nav links).
  • src/Yavsc.Org/Views/Client/Edit.cshtml — significantly enriched: nav links to the 9 sub-pages, all the scalar fields split into fieldsets (Security, Logout, Tokens, Device / CIBA, Tokens-extra), ClientId / Id hidden.

Known residual compile errors (4 errors total)

Paul is fixing these in a debug session. The structure is sound; the errors are missing properties on the Client entity, a Razor nullable quirk, and a Localizer injection miss.

  1. Edit.cshtml:249PairwiseSubjectSalt doesn't exist on IdentityServer8.EntityFramework.Entities.Client. Fix: drop the field from Edit.cshtml; IdentityServer8 likely uses a different property name (e.g. on a related entity) or doesn't expose it.
  2. Edit.cshtml:221CibaLifetime doesn't exist on Client. Fix: same as above. CIBA flow may be configured elsewhere (resource-level) or via a different property.
  3. ClientController.Collections.cs lines 181, 217, 253, 304 — Localizer is not available in the partial class. Fix: inject IStringLocalizer<ClientController> via the constructor, or inline the strings ("BothTypeAndValueRequired", "KeyRequired", "ValueRequired", "SecretValueRequired").
  4. EditSecrets.cshtml:44s.Expiration?.ToString("u") on a DateTime?. Fix: just s.Expiration?.ToString("u") works if you write s.Expiration.Value.ToString("u"), or use (s.Expiration is null ? "" : s.Expiration.Value.ToString("u")), or s.Expiration?.ToString("u") ?? string.Empty.

Suggested next session

Once the 4 compile errors are fixed and the pages render:

  1. Smoke test by logging in as admin, hitting /Client/Edit/1, then each Edit*/1 page, and verifying add/remove POSTs.
  2. Add a confirmation prompt (or 2-step form) for Remove actions — removing a Redirect URI is destructive and one click is too easy.
  3. Wire up some collection-level validation (e.g. redirect URI must be a valid URL) at the controller level.
  4. Add tests — the project doesn't have MVC test infrastructure today; consider adding a Yavsc.Org.Tests project that drives the controller via WebApplicationFactory<Program>.

Test bootstrap notes (session of 2026-06-21 17:00+)

When adding new integration tests against WebServerFixture:

  1. Skip /Account/Login roundtrip. The fixture ships without MapRazorPages() (commented out in HostingExtensions.ConfigurePipeline), so /Identity/Account/Login is 404, and the custom /Account/Login route requires a complex antiforgery dance. Instead, build a ClaimsPrincipal for the test user via UserManager + IUserClaimsPrincipalFactory<ApplicationUser>, then call IAuthenticationService.SignInAsync on a synthetic DefaultHttpContext and replay the resulting Set-Cookie header into the test HttpClient. See ClientControllerCollectionTests.IssueIdentityCookie.

  2. Create the Administrator role before assigning it. ASP.NET Identity stores roles in AspNetRoles; there is no automatic seed. The constant name is YavscConstants.AdminGroupName = "Administrator". Use RoleManager<IdentityRole>.CreateAsync(new IdentityRole("Administrator")) before AddToRoleAsync.

  3. Use InMemory connection string to bypass the prod signing-cert requirement. HostingExtensions.AddIdentityServer requires a PEM cert unless builder.Environment.IsDevelopment() OR UsesInMemoryProvider(connectionString). The fixture already uses InMemory, so AddDeveloperSigningCredential() is called automatically — but only after we wired this check in (see commit history).

  4. Field-name gotchas (from disassembling HigginsSoft IdentityServer8.EntityFramework.Entities.Client 8.0.5-preview-net9):

    • PairWiseSubjectSalt (capital W on "Wise"), not PairwiseSubjectSalt.
    • CibaLifetime and PollingInterval do NOT exist on Client in this version.
    • ConsentLifetime and UserSsoLifetime are int?.
  5. MapStaticAssets() fails on test projects. Calling MapStaticAssets() resolves a manifest file (<project>.staticwebassets.endpoints.json) that test projects don't produce. Skip when WebRootPath points at the test assembly directory.

  6. Routing 404 on /Client/Edit/{id} via WebServerFixture. As of this session, the GET endpoint returns 404 even with admin header. The route mapping is intact (MapDefaultControllerRoute()), so this is likely an MVC convention routing issue with the Controllers/Administration/ subdirectory. To investigate next session: log middleware pipeline or hit /Client index first to see if any Client route resolves.

  7. MapStaticAssets() is unconditional in prod, but blocks tests. WebApplication.CreateBuilder defaults ContentRootPath to AppContext.BaseDirectory. In test runs that resolves to src/Yavsc.Org.Tests/bin/Debug/net10.0/, where Yavsc.Org.Tests.staticwebassets.endpoints.json doesn't exist (it's generated only by projects with the Web SDK). The app.MapStaticAssets() call inside ConfigurePipeline then throws and the fixture fails to start — taking every test in the [Collection("Yavsc Server")] down with it.

    This is a pre-existing fragility of the WebServerFixture that the new test work surfaced. Fixing it cleanly requires either: (a) moving the test project to the Web SDK so it produces its own manifest, (b) copying the manifest at build time via an MSBuild target, or (c) routing MapStaticAssets through an assembly-resolution fallback. None attempted in this session — recorded for next session.