diff --git a/src/Yavsc.Org/Views/Client/Details.cshtml b/src/Yavsc.Org/Views/Client/Details.cshtml index 188f4269..b39a3f70 100644 --- a/src/Yavsc.Org/Views/Client/Details.cshtml +++ b/src/Yavsc.Org/Views/Client/Details.cshtml @@ -1,71 +1,296 @@ @model Client +@using Microsoft.AspNetCore.Html +@using System.Text + +@functions { + // Lifetimes in IdentityServer are stored as seconds (int). Render them + // in a human-friendly way: "5 min", "2 h", "30 d", "—" for 0/negative. + // Zero typically means "use the global default" — show it as such so + // an admin isn't misled into thinking the value is unset. + static string HumanLifetime(int? seconds) + { + if (!seconds.HasValue) return "—"; + var s = seconds.Value; + if (s <= 0) return "default"; + if (s < 60) return $"{s} s"; + if (s < 3600) return $"{s / 60} min"; + if (s < 86400) return $"{s / 3600} h"; + if (s < 86400 * 30) return $"{s / 86400} d"; + return $"{s / 86400} d ({s / 86400 / 30} mo)"; + } + + // IdentityServer8 stores enum-valued config columns as plain `int` + // (no enum type metadata at the storage layer). Render the numeric + // value directly. The matching enum name lives on the wire side + // (Discovery / token claims), not in this admin view. + static string IntOrZero(int value) => value.ToString(); + + static IHtmlContent BoolBadge(bool value) => + new HtmlString(value + ? "yes" + : "no"); + + static IHtmlContent EnabledBadge(bool value) => + new HtmlString(value + ? "enabled" + : "disabled"); + + // IdentityServer8: ClientSecret.Expiration is DateTime? (null = no + // expiry). ClientSecret.Created is DateTime, defaulted to UtcNow by + // the framework, so it never carries a MinValue sentinel — we render + // it as-is. + static string DateOrDash(DateTime? value) => + value?.ToString("yyyy-MM-dd HH:mm") ?? "—"; +}

@Localizer["Details"]

-
-

Client

-
-
-
- @Html.DisplayNameFor(model => model.ClientId) -
-
- @Html.DisplayFor(model => model.ClientId) -
-
- @Html.DisplayNameFor(model => model.Enabled) -
-
- @Html.DisplayFor(model => model.Enabled) -
-
- @Html.DisplayNameFor(model => model.ClientName) -
-
- @Html.DisplayFor(model => model.ClientName) -
-
- @Html.DisplayNameFor(model => model.FrontChannelLogoutUri) -
- @Html.DisplayFor(model => model.FrontChannelLogoutUri) - -
- @Html.DisplayNameFor(model => model.RedirectUris) -
-
-
    - @foreach (var uri in Model.RedirectUris) - {
  • @uri.RedirectUri
  • } -
-
-
- @Html.DisplayNameFor(model => model.AbsoluteRefreshTokenLifetime) -
-
- @Html.DisplayFor(model => model.AbsoluteRefreshTokenLifetime) -
-
- @Html.DisplayNameFor(model => model.ClientSecrets) -
-
-
    - @foreach(var secret in Model.ClientSecrets) - { -
  • @Html.DisplayForModel(secret)
  • - } -
-
+

+ @Model.ClientId + @EnabledBadge(Model.Enabled) + @if (!string.IsNullOrWhiteSpace(Model.ClientName)) + { + — @Model.ClientName + } +

+
-
- @Html.DisplayNameFor(model => model.AccessTokenType) -
-
- @Html.DisplayFor(model => model.AccessTokenType) -
-
+
+
+
+ Identity +
+
@Html.DisplayNameFor(m => m.ClientId)
+
@Model.ClientId
+ +
@Html.DisplayNameFor(m => m.Enabled)
+
@EnabledBadge(Model.Enabled)
+ +
@Html.DisplayNameFor(m => m.ProtocolType)
+
@(string.IsNullOrEmpty(Model.ProtocolType) ? "—" : Model.ProtocolType)
+ +
@Html.DisplayNameFor(m => m.ClientName)
+
@(Model.ClientName ?? "—")
+ +
@Html.DisplayNameFor(m => m.Description)
+
@(Model.Description ?? "—")
+ +
@Html.DisplayNameFor(m => m.ClientUri)
+
+ @if (!string.IsNullOrWhiteSpace(Model.ClientUri)) + { + @Model.ClientUri + } + else + { + + } +
+ +
@Html.DisplayNameFor(m => m.LogoUri)
+
+ @if (!string.IsNullOrWhiteSpace(Model.LogoUri)) + { + + logo + + } + else + { + + } +
+
+
+ +
+ Security + + + + + + + + + + + + + + + + +
@Html.DisplayNameFor(m => m.RequireConsent)@BoolBadge(Model.RequireConsent)
@Html.DisplayNameFor(m => m.RequirePkce)@BoolBadge(Model.RequirePkce)
@Html.DisplayNameFor(m => m.RequireRequestObject)@BoolBadge(Model.RequireRequestObject)
@Html.DisplayNameFor(m => m.RequireClientSecret)@BoolBadge(Model.RequireClientSecret)
@Html.DisplayNameFor(m => m.AllowPlainTextPkce)@BoolBadge(Model.AllowPlainTextPkce)
@Html.DisplayNameFor(m => m.AllowOfflineAccess)@BoolBadge(Model.AllowOfflineAccess)
@Html.DisplayNameFor(m => m.AllowRememberConsent)@BoolBadge(Model.AllowRememberConsent)
@Html.DisplayNameFor(m => m.EnableLocalLogin)@BoolBadge(Model.EnableLocalLogin)
@Html.DisplayNameFor(m => m.AlwaysIncludeUserClaimsInIdToken)@BoolBadge(Model.AlwaysIncludeUserClaimsInIdToken)
@Html.DisplayNameFor(m => m.AlwaysSendClientClaims)@BoolBadge(Model.AlwaysSendClientClaims)
@Html.DisplayNameFor(m => m.IncludeJwtId)@BoolBadge(Model.IncludeJwtId)
@Html.DisplayNameFor(m => m.UpdateAccessTokenClaimsOnRefresh)@BoolBadge(Model.UpdateAccessTokenClaimsOnRefresh)
@Html.DisplayNameFor(m => m.AllowAccessTokensViaBrowser)@BoolBadge(Model.AllowAccessTokensViaBrowser)
+
+
+ +
+
+ Tokens + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@Html.DisplayNameFor(m => m.AccessTokenType)@IntOrZero(Model.AccessTokenType)
@Html.DisplayNameFor(m => m.IdentityTokenLifetime)@HumanLifetime(Model.IdentityTokenLifetime)
@Html.DisplayNameFor(m => m.AccessTokenLifetime)@HumanLifetime(Model.AccessTokenLifetime)
@Html.DisplayNameFor(m => m.AuthorizationCodeLifetime)@HumanLifetime(Model.AuthorizationCodeLifetime)
@Html.DisplayNameFor(m => m.AbsoluteRefreshTokenLifetime)@HumanLifetime(Model.AbsoluteRefreshTokenLifetime)
@Html.DisplayNameFor(m => m.SlidingRefreshTokenLifetime)@HumanLifetime(Model.SlidingRefreshTokenLifetime)
@Html.DisplayNameFor(m => m.RefreshTokenUsage)@IntOrZero(Model.RefreshTokenUsage)
@Html.DisplayNameFor(m => m.RefreshTokenExpiration)@IntOrZero(Model.RefreshTokenExpiration)
@Html.DisplayNameFor(m => m.ConsentLifetime)@HumanLifetime(Model.ConsentLifetime)
@Html.DisplayNameFor(m => m.UserSsoLifetime)@HumanLifetime(Model.UserSsoLifetime)
@Html.DisplayNameFor(m => m.DeviceCodeLifetime)@HumanLifetime(Model.DeviceCodeLifetime)
@Html.DisplayNameFor(m => m.UserCodeType)@(string.IsNullOrEmpty(Model.UserCodeType) ? "—" : Model.UserCodeType)
@Html.DisplayNameFor(m => m.AllowedIdentityTokenSigningAlgorithms)@(string.IsNullOrEmpty(Model.AllowedIdentityTokenSigningAlgorithms) ? "—" : Model.AllowedIdentityTokenSigningAlgorithms)
@Html.DisplayNameFor(m => m.ClientClaimsPrefix)@(string.IsNullOrEmpty(Model.ClientClaimsPrefix) ? "—" : Model.ClientClaimsPrefix)
@Html.DisplayNameFor(m => m.PairWiseSubjectSalt)@(string.IsNullOrEmpty(Model.PairWiseSubjectSalt) ? "—" : Model.PairWiseSubjectSalt)
+
+ +
+ Logout +
+
@Html.DisplayNameFor(m => m.FrontChannelLogoutUri)
+
@(string.IsNullOrEmpty(Model.FrontChannelLogoutUri) ? "—" : Model.FrontChannelLogoutUri)
+
@Html.DisplayNameFor(m => m.FrontChannelLogoutSessionRequired)
+
@BoolBadge(Model.FrontChannelLogoutSessionRequired)
+
@Html.DisplayNameFor(m => m.BackChannelLogoutUri)
+
@(string.IsNullOrEmpty(Model.BackChannelLogoutUri) ? "—" : Model.BackChannelLogoutUri)
+
@Html.DisplayNameFor(m => m.BackChannelLogoutSessionRequired)
+
@BoolBadge(Model.BackChannelLogoutSessionRequired)
+
+
+
+ +
+ Collections + +
+ +
+ + Client Secrets + (@(Model.ClientSecrets?.Count ?? 0)) + + + @if (Model.ClientSecrets == null || Model.ClientSecrets.Count == 0) + { +

No secrets on file. The client relies entirely on PKCE (no secret in the handshake).

+ } + else + { + + + + + + + + + + + + @foreach (var secret in Model.ClientSecrets) + { + // Expiration is DateTime?: null means "no expiry". + // Created is DateTime, defaulted to UtcNow at insert time. + var hasExpiry = secret.Expiration.HasValue; + var expired = hasExpiry && secret.Expiration.Value < DateTime.UtcNow; + var expiresSoon = hasExpiry && !expired && secret.Expiration.Value < DateTime.UtcNow.AddDays(7); + + + + + + + + } + +
TypeDescriptionCreated (UTC)Expires (UTC)Status
@(secret.Type ?? "SharedSecret")@(secret.Description ?? "—")@DateOrDash(secret.Created)@DateOrDash(secret.Expiration) + @if (!hasExpiry) + { + no expiry + } + else if (expired) + { + expired + } + else if (expiresSoon) + { + expires soon + } + else + { + active + } +
+

+ Secret values are never displayed here. IdentityServer stores them + hashed; only the freshly generated value is shown once on the + Regenerate Secret flow. +

+ } +
+

- @Localizer["Edit"] | - @Localizer["Regenerate secret"] | - @Localizer["Back to List"] + @Localizer["Edit"] + @Localizer["Regenerate secret"] + @Localizer["Back to List"]