yavsc-org: OAuth2 client admin editor overhaul — per-collection pages + missing fields

The OAuth2 client editor at /Client/Edit/{id} previously exposed 8
fields out of ~30 scalars and 10 collections on the IdentityServer8
Client entity. Editing the collections (RedirectUris, Scopes, Grant
Types, Cors Origins, IdP Restrictions, Claims, Properties, Secrets)
was either impossible or jammed into a single broken text input that
bound against an IEnumerable<string> property.

Restructure into per-collection subpages, each with its own
list/add/remove flow:

- RedirectUris       /Client/EditRedirectUris/{id}
- PostLogoutRedirectUris /Client/EditPostLogoutRedirectUris/{id}
- Scopes             /Client/EditScopes/{id}
- GrantTypes         /Client/EditGrantTypes/{id}
- CorsOrigins        /Client/EditCorsOrigins/{id}
- IdPRestrictions    /Client/EditIdPRestrictions/{id}
- Claims             /Client/EditClaims/{id}
- Properties         /Client/EditProperties/{id}
- Secrets            /Client/EditSecrets/{id}

Implementation:

- New partial class ClientController.Collections.cs with one
  GET/Add/Remove trio per collection. Add/Remove dispatch through
  generic helpers that handle the EF row + ClientId check.
- Shared _EditableStringList.cshtml partial consumed by the six
  single-string-field collection pages. Uses reflection to pull
  the value field and the row Id off the entity — avoids six
  nearly-identical table+form copies.
- Claims / Properties / Secrets each have their own view because
  they carry 2+ fields (Type+Value, Key+Value, or
  Type+Value+Description+Expiration).
- Main Edit.cshtml enriched: ClientId/Id hidden, all scalar
  fields split into fieldsets (Core, Security, Logout, Tokens,
  Device flow, Tokens extra), nav links to the 9 subpages with
  current row counts as badges.
- ClientController.Edit(int) GET now loads the client with all
  navigations via LoadClientAsync so the Edit.cshtml nav badges
  render real counts.

Field-correctness notes (verified by disassembling HigginsSoft
IdentityServer8.EntityFramework.Entities.Client 8.0.5-preview-net9):

- The property is PairWiseSubjectSalt, not PairwiseSubjectSalt
  (capital W on 'Wise').
- CibaLifetime and PollingInterval do NOT exist on Client in this
  IdentityServer8 version — those properties were a guess. The
  Device flow fieldset contains DeviceCodeLifetime + UserCodeType
  instead.
- AllowedIdentityTokenSigningAlgorithms and AllowAccessTokensViaBrowser
  were missing from the original form and are now exposed.
- ConsentLifetime and UserSsoLifetime are int? (nullable); the form
  binds them as plain int fields which accept empty strings.

Security:

- All new actions stay under [Authorize('AdministratorOnly')].
- Each Add/Remove takes an explicit id (Client.Id) and the row's
  ClientId is checked on the server before any delete; a rowId
  from another client returns NotFound.

Docs:

- doc/dev-tracking/client-editor-overhaul.md — inventory, status,
  follow-up ideas (confirmation prompts, validation, MVC tests).
This commit is contained in:
Paul Schneider 2026-06-21 16:53:34 +01:00
commit ecac359344
14 changed files with 1255 additions and 31 deletions

View file

@ -4,10 +4,12 @@
<form asp-action="Edit">
<div class="form-horizontal">
<h4>Client</h4>
<h4>Client <code>@Model.ClientId</code></h4>
<hr />
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Id" />
<input type="hidden" asp-for="ClientId" />
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<div class="checkbox">
@ -20,60 +22,285 @@
<label asp-for="ClientName" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="ClientName" class="form-control" />
<span asp-validation-for="ClientName" class="text-danger" ></span>
<span asp-validation-for="ClientName" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="FrontChannelLogoutUri" class="col-md-2 control-label"></label>
<label asp-for="Description" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="FrontChannelLogoutUri" class="form-control" />
<span asp-validation-for="FrontChannelLogoutUri" class="text-danger" ></span>
<input asp-for="Description" class="form-control" />
<span asp-validation-for="Description" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="RedirectUris" class="col-md-2 control-label"></label>
<label asp-for="ClientUri" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="RedirectUris" class="form-control" />
<span asp-validation-for="RedirectUris" class="text-danger" ></span>
<input asp-for="ClientUri" class="form-control" />
<span asp-validation-for="ClientUri" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="IdentityTokenLifetime" class="col-md-2 control-label"></label>
<label asp-for="LogoUri" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="IdentityTokenLifetime" class="form-control" />
<span asp-validation-for="IdentityTokenLifetime" class="text-danger" ></span>
<input asp-for="LogoUri" class="form-control" />
<span asp-validation-for="LogoUri" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="AbsoluteRefreshTokenLifetime" class="col-md-2 control-label"></label>
<label asp-for="ProtocolType" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="AbsoluteRefreshTokenLifetime" class="form-control" />
<span asp-validation-for="AbsoluteRefreshTokenLifetime" class="text-danger" ></span>
<input asp-for="ProtocolType" class="form-control" />
<span asp-validation-for="ProtocolType" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="ClientSecrets" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="ClientSecrets" class="form-control" />
<span asp-validation-for="ClientSecrets" class="text-danger" ></span>
<fieldset>
<legend>Security</legend>
<div class="form-group">
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="RequireConsent" />
<label asp-for="RequireConsent"></label>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="RequirePkce" />
<label asp-for="RequirePkce"></label>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="RequireRequestObject" />
<label asp-for="RequireRequestObject"></label>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="RequireClientSecret" />
<label asp-for="RequireClientSecret"></label>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="AllowPlainTextPkce" />
<label asp-for="AllowPlainTextPkce"></label>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="AllowOfflineAccess" />
<label asp-for="AllowOfflineAccess"></label>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="AllowRememberConsent" />
<label asp-for="AllowRememberConsent"></label>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="EnableLocalLogin" />
<label asp-for="EnableLocalLogin"></label>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="AlwaysIncludeUserClaimsInIdToken" />
<label asp-for="AlwaysIncludeUserClaimsInIdToken"></label>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="AlwaysSendClientClaims" />
<label asp-for="AlwaysSendClientClaims"></label>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="IncludeJwtId" />
<label asp-for="IncludeJwtId"></label>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="UpdateAccessTokenClaimsOnRefresh" />
<label asp-for="UpdateAccessTokenClaimsOnRefresh"></label>
</div>
</div>
</div>
<div class="form-group">
<label asp-for="AccessTokenType" class="col-md-2 control-label"></label>
<div class="col-md-10">
@Html.DropDownList("AccessTokenType")
<span asp-validation-for="AccessTokenType" class="text-danger" ></span>
</fieldset>
<fieldset>
<legend>Logout</legend>
<div class="form-group">
<label asp-for="FrontChannelLogoutUri" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="FrontChannelLogoutUri" class="form-control" />
<span asp-validation-for="FrontChannelLogoutUri" class="text-danger"></span>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="FrontChannelLogoutSessionRequired" />
<label asp-for="FrontChannelLogoutSessionRequired"></label>
</div>
</div>
</div>
<div class="form-group">
<label asp-for="BackChannelLogoutUri" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="BackChannelLogoutUri" class="form-control" />
<span asp-validation-for="BackChannelLogoutUri" class="text-danger"></span>
</div>
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="BackChannelLogoutSessionRequired" />
<label asp-for="BackChannelLogoutSessionRequired"></label>
</div>
</div>
</fieldset>
<fieldset>
<legend>Tokens</legend>
<div class="form-group">
<label asp-for="AccessTokenType" class="col-md-2 control-label"></label>
<div class="col-md-10">
@Html.DropDownList("AccessTokenType")
<span asp-validation-for="AccessTokenType" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="IdentityTokenLifetime" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="IdentityTokenLifetime" class="form-control" />
<span asp-validation-for="IdentityTokenLifetime" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="AccessTokenLifetime" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="AccessTokenLifetime" class="form-control" />
<span asp-validation-for="AccessTokenLifetime" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="AuthorizationCodeLifetime" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="AuthorizationCodeLifetime" class="form-control" />
<span asp-validation-for="AuthorizationCodeLifetime" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="AbsoluteRefreshTokenLifetime" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="AbsoluteRefreshTokenLifetime" class="form-control" />
<span asp-validation-for="AbsoluteRefreshTokenLifetime" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="SlidingRefreshTokenLifetime" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="SlidingRefreshTokenLifetime" class="form-control" />
<span asp-validation-for="SlidingRefreshTokenLifetime" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="RefreshTokenUsage" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="RefreshTokenUsage" class="form-control" />
<span asp-validation-for="RefreshTokenUsage" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="RefreshTokenExpiration" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="RefreshTokenExpiration" class="form-control" />
<span asp-validation-for="RefreshTokenExpiration" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="ConsentLifetime" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="ConsentLifetime" class="form-control" />
<span asp-validation-for="ConsentLifetime" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="UserSsoLifetime" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="UserSsoLifetime" class="form-control" />
<span asp-validation-for="UserSsoLifetime" class="text-danger"></span>
</div>
</div>
</fieldset>
<fieldset>
<legend>Device / CIBA</legend>
<div class="form-group">
<label asp-for="DeviceCodeLifetime" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="DeviceCodeLifetime" class="form-control" />
<span asp-validation-for="DeviceCodeLifetime" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="UserCodeType" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="UserCodeType" class="form-control" />
<span asp-validation-for="UserCodeType" class="text-danger"></span>
</div>
</div>
</fieldset>
<fieldset>
<legend>Tokens (extra)</legend>
<div class="form-group">
<label asp-for="AllowedIdentityTokenSigningAlgorithms" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="AllowedIdentityTokenSigningAlgorithms" class="form-control" />
<span asp-validation-for="AllowedIdentityTokenSigningAlgorithms" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10 checkbox">
<input asp-for="AllowAccessTokensViaBrowser" />
<label asp-for="AllowAccessTokensViaBrowser"></label>
</div>
</div>
<div class="form-group">
<label asp-for="ClientClaimsPrefix" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="ClientClaimsPrefix" class="form-control" />
<span asp-validation-for="ClientClaimsPrefix" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="PairWiseSubjectSalt" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="PairWiseSubjectSalt" class="form-control" />
<span asp-validation-for="PairWiseSubjectSalt" class="text-danger"></span>
</div>
</div>
</fieldset>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<input type="submit" value="Save" class="btn btn-default" />
<input type="submit" value="Save" class="btn btn-primary" />
</div>
</div>
</div>
</form>
<hr />
<h3>Collections</h3>
<p class="text-muted">
The following pages edit collections of related rows for this client.
</p>
<div class="list-group">
<a asp-action="EditRedirectUris" asp-route-id="@Model.Id" class="list-group-item">
Redirect URIs <span class="badge">@Model.RedirectUris.Count</span>
</a>
<a asp-action="EditPostLogoutRedirectUris" asp-route-id="@Model.Id" class="list-group-item">
Post-logout Redirect URIs <span class="badge">@Model.PostLogoutRedirectUris.Count</span>
</a>
<a asp-action="EditScopes" asp-route-id="@Model.Id" class="list-group-item">
Allowed Scopes <span class="badge">@Model.AllowedScopes.Count</span>
</a>
<a asp-action="EditGrantTypes" asp-route-id="@Model.Id" class="list-group-item">
Allowed Grant Types <span class="badge">@Model.AllowedGrantTypes.Count</span>
</a>
<a asp-action="EditCorsOrigins" asp-route-id="@Model.Id" class="list-group-item">
Allowed CORS Origins <span class="badge">@Model.AllowedCorsOrigins.Count</span>
</a>
<a asp-action="EditIdPRestrictions" asp-route-id="@Model.Id" class="list-group-item">
Identity Provider Restrictions <span class="badge">@Model.IdentityProviderRestrictions.Count</span>
</a>
<a asp-action="EditClaims" asp-route-id="@Model.Id" class="list-group-item">
Client Claims <span class="badge">@Model.Claims.Count</span>
</a>
<a asp-action="EditProperties" asp-route-id="@Model.Id" class="list-group-item">
Client Properties <span class="badge">@Model.Properties.Count</span>
</a>
<a asp-action="EditSecrets" asp-route-id="@Model.Id" class="list-group-item">
Client Secrets <span class="badge">@(Model.ClientSecrets?.Count ?? 0)</span>
</a>
</div>
<div>
<a asp-action="Index">@Localizer["Back to List"]</a>
</div>

View file

@ -0,0 +1,64 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientClaim>
@{
ViewData["Title"] = "Edit Client Claims";
var clientId = Model.FirstOrDefault()?.ClientId ?? 0;
}
<h2>Client Claims</h2>
<p class="text-muted">
Claims issued by IdentityServer on this client's behalf. Use sparingly:
these claims are emitted on every token, regardless of the user's
identity. For user-derived claims, prefer the API resource scope.
</p>
@if (TempData["Error"] is string err)
{
<div class="alert alert-danger">@err</div>
}
<table class="table">
<thead>
<tr><th>Type</th><th>Value</th><th></th></tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr><td colspan="3" class="text-muted">—</td></tr>
}
else
{
foreach (var c in Model)
{
<tr>
<td>@c.Type</td>
<td>@c.Value</td>
<td>
<form asp-action="RemoveClaim" method="post" class="form-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@clientId" />
<input type="hidden" name="rowId" value="@c.Id" />
<button type="submit" class="btn btn-link btn-sm text-danger">Remove</button>
</form>
</td>
</tr>
}
}
</tbody>
</table>
<form asp-action="AddClaim" method="post" class="form-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@clientId" />
<div class="form-group mr-2">
<input type="text" name="type" class="form-control" placeholder="role" />
</div>
<div class="form-group mr-2">
<input type="text" name="value" class="form-control" placeholder="admin" />
</div>
<button type="submit" class="btn btn-primary">Add</button>
</form>
<p>
<a asp-action="Edit" asp-route-id="@clientId">Back to client</a>
</p>

View file

@ -0,0 +1,26 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientCorsOrigin>
@{
ViewData["Title"] = "Edit Allowed CORS Origins";
ViewData["addAction"] = "AddCorsOrigin";
ViewData["removeAction"] = "RemoveCorsOrigin";
ViewData["rowKey"] = "CORS Origin";
ViewData["valueField"] = "Origin";
ViewData["inputName"] = "origin";
ViewData["placeholder"] = "https://app.example.com";
ViewData["clientId"] = Model.FirstOrDefault()?.ClientId ?? 0;
}
<h2>Allowed CORS Origins</h2>
<p class="text-muted">
Origins allowed to call the token and discovery endpoints from a
browser via CORS. Only required for JavaScript clients running in
the user's browser. Origin = scheme + host + port, no trailing
slash.
</p>
@await Html.PartialAsync("_EditableStringList", Model)
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>

View file

@ -0,0 +1,27 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientGrantType>
@{
ViewData["Title"] = "Edit Allowed Grant Types";
ViewData["addAction"] = "AddGrantType";
ViewData["removeAction"] = "RemoveGrantType";
ViewData["rowKey"] = "Grant Type";
ViewData["valueField"] = "GrantType";
ViewData["inputName"] = "grantType";
ViewData["placeholder"] = "authorization_code";
ViewData["clientId"] = Model.FirstOrDefault()?.ClientId ?? 0;
}
<h2>Allowed Grant Types</h2>
<p class="text-muted">
OAuth2 grant types this client is allowed to use. Common values:
<code>authorization_code</code> (web/native apps with PKCE),
<code>client_credentials</code> (server-to-server),
<code>refresh_token</code> (used implicitly alongside the others),
<code>password</code> (legacy, avoid).
</p>
@await Html.PartialAsync("_EditableStringList", Model)
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>

View file

@ -0,0 +1,24 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientIdPRestriction>
@{
ViewData["Title"] = "Edit Identity Provider Restrictions";
ViewData["addAction"] = "AddIdPRestriction";
ViewData["removeAction"] = "RemoveIdPRestriction";
ViewData["rowKey"] = "Provider";
ViewData["valueField"] = "Provider";
ViewData["inputName"] = "provider";
ViewData["placeholder"] = "Google";
ViewData["clientId"] = Model.FirstOrDefault()?.ClientId ?? 0;
}
<h2>Identity Provider Restrictions</h2>
<p class="text-muted">
Optional allow-list of external identity providers this client may
use. Empty list = any configured IdP is allowed.
</p>
@await Html.PartialAsync("_EditableStringList", Model)
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>

View file

@ -0,0 +1,25 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientPostLogoutRedirectUri>
@{
ViewData["Title"] = "Edit Post-logout Redirect URIs";
ViewData["addAction"] = "AddPostLogoutRedirectUri";
ViewData["removeAction"] = "RemovePostLogoutRedirectUri";
ViewData["rowKey"] = "Post-logout URI";
ViewData["valueField"] = "PostLogoutRedirectUri";
ViewData["inputName"] = "postLogoutRedirectUri";
ViewData["placeholder"] = "https://app.example.com/";
ViewData["clientId"] = Model.FirstOrDefault()?.ClientId ?? 0;
}
<h2>Post-logout Redirect URIs</h2>
<p class="text-muted">
URIs the authorization server will redirect the browser to after a
front-channel logout. Use these when the client participates in the
OIDC front-channel logout flow.
</p>
@await Html.PartialAsync("_EditableStringList", Model)
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>

View file

@ -0,0 +1,65 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientProperty>
@{
ViewData["Title"] = "Edit Client Properties";
var clientId = Model.FirstOrDefault()?.ClientId ?? 0;
}
<h2>Client Properties</h2>
<p class="text-muted">
Free-form key/value bag attached to the client. IdentityServer does
not interpret these; they're surfaced via the introspection endpoint
and read by your custom code. Useful for tagging, ownership, feature
flags, etc.
</p>
@if (TempData["Error"] is string err)
{
<div class="alert alert-danger">@err</div>
}
<table class="table">
<thead>
<tr><th>Key</th><th>Value</th><th></th></tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr><td colspan="3" class="text-muted">—</td></tr>
}
else
{
foreach (var p in Model)
{
<tr>
<td>@p.Key</td>
<td>@p.Value</td>
<td>
<form asp-action="RemoveProperty" method="post" class="form-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@clientId" />
<input type="hidden" name="rowId" value="@p.Id" />
<button type="submit" class="btn btn-link btn-sm text-danger">Remove</button>
</form>
</td>
</tr>
}
}
</tbody>
</table>
<form asp-action="AddProperty" method="post" class="form-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@clientId" />
<div class="form-group mr-2">
<input type="text" name="key" class="form-control" placeholder="owner-team" />
</div>
<div class="form-group mr-2">
<input type="text" name="value" class="form-control" placeholder="platform" />
</div>
<button type="submit" class="btn btn-primary">Add</button>
</form>
<p>
<a asp-action="Edit" asp-route-id="@clientId">Back to client</a>
</p>

View file

@ -0,0 +1,25 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientRedirectUri>
@{
ViewData["Title"] = "Edit Redirect URIs";
ViewData["addAction"] = "AddRedirectUri";
ViewData["removeAction"] = "RemoveRedirectUri";
ViewData["rowKey"] = "Redirect URI";
ViewData["valueField"] = "RedirectUri";
ViewData["inputName"] = "redirectUri";
ViewData["placeholder"] = "https://app.example.com/callback";
ViewData["clientId"] = Model.FirstOrDefault()?.ClientId ?? 0;
}
<h2>Redirect URIs</h2>
<p class="text-muted">
URIs the authorization server will redirect the browser to after a
successful login. Must match exactly the URL your client uses to
catch the response.
</p>
@await Html.PartialAsync("_EditableStringList", Model)
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>

View file

@ -0,0 +1,25 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientScope>
@{
ViewData["Title"] = "Edit Allowed Scopes";
ViewData["addAction"] = "AddScope";
ViewData["removeAction"] = "RemoveScope";
ViewData["rowKey"] = "Scope";
ViewData["valueField"] = "Scope";
ViewData["inputName"] = "scope";
ViewData["placeholder"] = "openid";
ViewData["clientId"] = Model.FirstOrDefault()?.ClientId ?? 0;
}
<h2>Allowed Scopes</h2>
<p class="text-muted">
OAuth2 scopes this client is allowed to request. Each scope must be
defined in the IdentityServer resource store. <code>openid</code> is
mandatory for OIDC clients.
</p>
@await Html.PartialAsync("_EditableStringList", Model)
<p>
<a asp-action="Edit" asp-route-id="@ViewData["clientId"]">Back to client</a>
</p>

View file

@ -0,0 +1,83 @@
@model IEnumerable<IdentityServer8.EntityFramework.Entities.ClientSecret>
@{
ViewData["Title"] = "Edit Client Secrets";
var clientId = Model.FirstOrDefault()?.ClientId ?? 0;
}
<h2>Client Secrets</h2>
<p class="text-muted">
Shared secrets the client uses to authenticate to IdentityServer.
Required for confidential clients; leave empty for public clients
(rely on PKCE instead). Secrets are stored hashed — you only see
the value at creation time. Use <em>Regenerate</em> from the
Details page to issue a fresh one and view it once.
</p>
@if (TempData["Error"] is string err)
{
<div class="alert alert-danger">@err</div>
}
<table class="table">
<thead>
<tr>
<th>Type</th>
<th>Description</th>
<th>Created (UTC)</th>
<th>Expiration (UTC)</th>
<th></th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr><td colspan="5" class="text-muted">— no secrets configured —</td></tr>
}
else
{
foreach (var s in Model)
{
<tr>
<td>@s.Type</td>
<td>@s.Description</td>
<td>@s.Created.ToString("u")</td>
<td>@s.Expiration?.ToString("u")</td>
<td>
<form asp-action="RemoveSecret" method="post" class="form-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@clientId" />
<input type="hidden" name="rowId" value="@s.Id" />
<button type="submit" class="btn btn-link btn-sm text-danger">Remove</button>
</form>
</td>
</tr>
}
}
</tbody>
</table>
<h3>Add a new secret</h3>
<form asp-action="AddSecret" method="post" class="form">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@clientId" />
<div class="form-group">
<label>Value</label>
<input type="text" name="value" class="form-control" />
<small class="text-muted">Hashed on save. Make sure you've copied it elsewhere first.</small>
</div>
<div class="form-group">
<label>Description</label>
<input type="text" name="description" class="form-control" />
</div>
<div class="form-group">
<label>Expiration (UTC, optional)</label>
<input type="datetime-local" name="expiration" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Add</button>
</form>
<p>
<a asp-action="Edit" asp-route-id="@clientId">Back to client</a>
<a asp-action="RegenerateSecret" asp-route-id="@clientId" class="ml-3">Regenerate a single secret</a>
</p>

View file

@ -0,0 +1,77 @@
@model IEnumerable<object>
@using System.Reflection
@*
Shared partial for client collection pages whose row type carries a
single string field plus an `Id` (used for Remove).
Inputs (via ViewData):
- addAction: name of the Add action (e.g. "AddRedirectUri")
- removeAction: name of the Remove action
- rowKey: display label for the column header
- valueField: name of the string property to display (e.g. "RedirectUri")
- inputName: name attribute on the Add form's text input
- clientId: passed through as a hidden field on both forms
- placeholder: optional placeholder text
Each row is rendered with an "X" button that POSTs to removeAction with
the row's `Id`. Add is a separate form with a single text input.
*@
@{
var addAction = ViewData["addAction"] as string ?? "Add";
var removeAction = ViewData["removeAction"] as string ?? "Remove";
var rowKey = ViewData["rowKey"] as string ?? "Value";
var valueField = ViewData["valueField"] as string ?? "Value";
var inputName = ViewData["inputName"] as string ?? "value";
var clientId = ViewData["clientId"];
var placeholder = ViewData["placeholder"] as string ?? string.Empty;
var idProp = typeof(object).GetProperty("Id");
}
@if (TempData["Error"] is string err)
{
<div class="alert alert-danger">@err</div>
}
<table class="table">
<thead>
<tr>
<th>@rowKey</th>
<th></th>
</tr>
</thead>
<tbody>
@if (!Model.Any())
{
<tr><td colspan="2" class="text-muted">—</td></tr>
}
else
{
foreach (var row in Model)
{
var value = row.GetType().GetProperty(valueField)?.GetValue(row) as string ?? string.Empty;
var rowId = row.GetType().GetProperty("Id")?.GetValue(row);
<tr>
<td>@value</td>
<td>
<form asp-action="@removeAction" method="post" class="form-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@clientId" />
<input type="hidden" name="rowId" value="@rowId" />
<button type="submit" class="btn btn-link btn-sm text-danger">Remove</button>
</form>
</td>
</tr>
}
}
</tbody>
</table>
<form asp-action="@addAction" method="post" class="form-inline">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@clientId" />
<div class="form-group mr-2">
<input type="text" name="@inputName" class="form-control" placeholder="@placeholder" />
</div>
<button type="submit" class="btn btn-primary">Add</button>
</form>