diff --git a/src/Aspire.Dashboard/Components/Controls/UserProfile.razor b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor new file mode 100644 index 0000000000..4689efe062 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor @@ -0,0 +1,33 @@ + + + @if (_showUserProfileMenu) + { +
+ + + + @Loc[nameof(Resources.Login.LoggedInAs)] + +
+ + Sign out + +
+
+ + + +
@_name
+
@_username
+
+
+
+
+
+ } +
+
diff --git a/src/Aspire.Dashboard/Components/Controls/UserProfile.razor.cs b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor.cs new file mode 100644 index 0000000000..1a471cbf67 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; +using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Extensions; +using Aspire.Dashboard.Resources; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; + +namespace Aspire.Dashboard.Components.Controls; + +public partial class UserProfile : ComponentBase +{ + [Inject] + public required IOptionsMonitor DashboardOptions { get; set; } + + [CascadingParameter] + public required Task AuthenticationState { get; set; } + + [Inject] + public required IStringLocalizer Loc { get; set; } + + [Inject] + public required ILogger Logger { get; set; } + + [Parameter] + public string ButtonSize { get; set; } = "24px"; + + [Parameter] + public string ImageSize { get; set; } = "52px"; + + private bool _showUserProfileMenu; + private string? _name; + private string? _username; + private string? _initials; + + protected override async Task OnParametersSetAsync() + { + if (DashboardOptions.CurrentValue.Frontend.AuthMode == FrontendAuthMode.OpenIdConnect) + { + var authState = await AuthenticationState; + + var claimsIdentity = authState.User.Identity as ClaimsIdentity; + + if (claimsIdentity?.IsAuthenticated == true) + { + _showUserProfileMenu = true; + _name = claimsIdentity.FindFirst(DashboardOptions.CurrentValue.Frontend.OpenIdConnect.GetNameClaimTypes()); + if (string.IsNullOrWhiteSpace(_name)) + { + // Make sure there's always a name, even if that name is a placeholder + _name = Loc[nameof(Login.AuthorizedUser)]; + } + + _username = claimsIdentity.FindFirst(DashboardOptions.CurrentValue.Frontend.OpenIdConnect.GetUsernameClaimTypes()); + _initials = _name.GetInitials(); + } + else + { + // If we don't have an authenticated user, don't show the user profile menu. This shouldn't happen. + _showUserProfileMenu = false; + _name = null; + _username = null; + _initials = null; + Logger.LogError("Dashboard:Frontend:AuthMode is configured for OpenIDConnect, but there is no authenticated user."); + } + } + } +} diff --git a/src/Aspire.Dashboard/Components/Controls/UserProfile.razor.css b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor.css new file mode 100644 index 0000000000..e61ff5a8ae --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/UserProfile.razor.css @@ -0,0 +1,60 @@ + +/* + Workaround for issue in fluent-anchored-region when the content + is near the top-right corner of the screen. Addressed in + https://github.com/microsoft/fluentui-blazor/pull/1795 which + we can use instead when it is available +*/ +.profile-menu-container ::deep .fluent-profile-menu fluent-anchored-region { + transform: unset !important; + left: unset; + right: -4px; +} + +::deep fluent-button[appearance=stealth]:not(:hover):not(:active)::part(control) { + background-color: var(--neutral-layer-floating); +} + +::deep fluent-button[appearance=stealth]:hover::part(control) { + background-color: var(--neutral-fill-secondary-hover); +} + +::deep .fluent-profile-menu > .fluent-persona { + margin: 0 4px; +} + +::deep .fluent-profile-menu > .fluent-persona > .initials { + font-size: var(--type-ramp-minus-1-font-size); +} + +::deep .full-name, +::deep .user-id { + color: var(--neutral-foreground-rest); + max-width: 200px; + overflow-x: hidden; + text-overflow: ellipsis; +} + +::deep .full-name { + font-size: var(--type-ramp-base-font-size); +} + +::deep .user-id { + font-size: var(--type-ramp-minus-1-font-size); + font-weight: normal; + max-width: 200px; +} + +::deep .inner-persona-container { + height: 100%; +} + +::deep .fluent-persona.inner-persona { + margin-right: 40px; + align-items: normal; +} + +/* The form takes up space and throws off the alignment if we don't change its display */ +::deep .sign-out-form { + display: flex; +} diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor index ee36d176bf..30f6a5873e 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor @@ -30,6 +30,7 @@ Title="@Loc[nameof(Layout.MainLayoutLaunchSettings)]" aria-label="@Loc[nameof(Layout.MainLayoutLaunchSettings)]"> +
diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css index 7e0dabeeba..0cc0d10f4d 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css @@ -74,9 +74,9 @@ margin-left: auto; } -::deep.layout > header fluent-button[appearance=stealth]:not(:hover)::part(control), -::deep.layout > header fluent-anchor[appearance=stealth]:not(:hover)::part(control), -::deep.layout > header fluent-anchor[appearance=stealth].logo::part(control), +::deep.layout > header > .header-gutters > fluent-button[appearance=stealth]:not(:hover)::part(control), +::deep.layout > header > .header-gutters > fluent-anchor[appearance=stealth]:not(:hover)::part(control), +::deep.layout > header > .header-gutters > fluent-anchor[appearance=stealth].logo::part(control), ::deep.layout > .aspire-icon fluent-anchor[appearance=stealth].logo::part(control) { background-color: var(--neutral-layer-4); } @@ -86,7 +86,7 @@ margin-bottom: 0; } -::deep.layout > header fluent-anchor { +::deep.layout > header > .header-gutters > fluent-anchor { font-size: var(--type-ramp-plus-2-font-size); } diff --git a/src/Aspire.Dashboard/Components/_Imports.razor b/src/Aspire.Dashboard/Components/_Imports.razor index 4940736b97..f39980ae12 100644 --- a/src/Aspire.Dashboard/Components/_Imports.razor +++ b/src/Aspire.Dashboard/Components/_Imports.razor @@ -3,6 +3,7 @@ @using Aspire.Dashboard.Authentication @using Microsoft.AspNetCore.Authentication.OpenIdConnect @using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 6b78af2d75..71f9ae222e 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -114,6 +114,7 @@ public sealed class FrontendOptions public string? EndpointUrls { get; set; } public FrontendAuthMode? AuthMode { get; set; } public string? BrowserToken { get; set; } + public OpenIdConnectOptions OpenIdConnect { get; set; } = new OpenIdConnectOptions(); public byte[]? GetBrowserTokenBytes() => _browserTokenBytes; @@ -163,3 +164,53 @@ public sealed class TelemetryLimitOptions public int MaxAttributeLength { get; set; } = int.MaxValue; public int MaxSpanEventCount { get; set; } = int.MaxValue; } + +// Don't set values after validating/parsing options. +public sealed class OpenIdConnectOptions +{ + private string[]? _nameClaimTypes; + private string[]? _usernameClaimTypes; + + public string NameClaimType { get; set; } = "name"; + public string UsernameClaimType { get; set; } = "preferred_username"; + + public string[] GetNameClaimTypes() + { + Debug.Assert(_nameClaimTypes is not null, "Should have been parsed during validation."); + return _nameClaimTypes; + } + + public string[] GetUsernameClaimTypes() + { + Debug.Assert(_usernameClaimTypes is not null, "Should have been parsed during validation."); + return _usernameClaimTypes; + } + + internal bool TryParseOptions([NotNullWhen(false)] out IEnumerable? errorMessages) + { + List? messages = null; + if (string.IsNullOrWhiteSpace(NameClaimType)) + { + messages ??= []; + messages.Add("OpenID Connect claim type for name not configured. Specify a Dashboard:OpenIdConnect:NameClaimType value."); + } + else + { + _nameClaimTypes = NameClaimType.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + if (string.IsNullOrWhiteSpace(UsernameClaimType)) + { + messages ??= []; + messages.Add("OpenID Connect claim type for username not configured. Specify a Dashboard:OpenIdConnect:UsernameClaimType value."); + } + else + { + _usernameClaimTypes = UsernameClaimType.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + errorMessages = messages; + + return messages is null; + } +} diff --git a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs index 961d8395d8..9321efd323 100644 --- a/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/ValidateDashboardOptions.cs @@ -22,6 +22,10 @@ public ValidateOptionsResult Validate(string? name, DashboardOptions options) case FrontendAuthMode.Unsecured: break; case FrontendAuthMode.OpenIdConnect: + if (!options.Frontend.OpenIdConnect.TryParseOptions(out var messages)) + { + errorMessages.AddRange(messages); + } break; case FrontendAuthMode.BrowserToken: if (string.IsNullOrEmpty(options.Frontend.BrowserToken)) diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 64c5975ec9..a6e37ca358 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -275,6 +275,10 @@ await Microsoft.AspNetCore.Authentication.AuthenticationHttpContextExtensions.Si }); #endif } + else if (dashboardOptions.Frontend.AuthMode == FrontendAuthMode.OpenIdConnect) + { + _app.MapPost("/authentication/logout", () => TypedResults.SignOut(authenticationSchemes: [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme])); + } } private ILogger GetLogger() diff --git a/src/Aspire.Dashboard/Extensions/ClaimsIdentityExtensions.cs b/src/Aspire.Dashboard/Extensions/ClaimsIdentityExtensions.cs new file mode 100644 index 0000000000..be91611c34 --- /dev/null +++ b/src/Aspire.Dashboard/Extensions/ClaimsIdentityExtensions.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; + +namespace Aspire.Dashboard.Extensions; + +internal static class ClaimsIdentityExtensions +{ + /// + /// Searches the claims in the for each of the claim types in + /// in the order presented and returns the first one that it finds. + /// + public static string? FindFirst(this ClaimsIdentity identity, string[] claimTypes) + { + foreach (var claimType in claimTypes) + { + var claim = identity.FindFirst(claimType); + if (claim is not null) + { + return claim.Value; + } + } + + return null; + } +} diff --git a/src/Aspire.Dashboard/Extensions/StringExtensions.cs b/src/Aspire.Dashboard/Extensions/StringExtensions.cs index 41289b069e..c201c1122e 100644 --- a/src/Aspire.Dashboard/Extensions/StringExtensions.cs +++ b/src/Aspire.Dashboard/Extensions/StringExtensions.cs @@ -1,30 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using System.Text; namespace Aspire.Dashboard.Extensions; internal static class StringExtensions { - /// - /// Shortens a string by replacing the middle with an ellipsis. - /// - /// The string to shorten - /// The max length of the result - public static string TrimMiddle(this string text, int maxLength) - { - if (text.Length <= maxLength) - { - return text; - } - - var firstPart = (maxLength - 1) / 2; - var lastPart = firstPart + ((maxLength - 1) % 2); - - return $"{text[..firstPart]}…{text[^lastPart..]}"; - } - public static string SanitizeHtmlId(this string input) { var sanitizedBuilder = new StringBuilder(capacity: input.Length); @@ -49,4 +32,30 @@ static bool IsValidHtmlIdCharacter(char c) return char.IsLetterOrDigit(c) || c == '_' || c == '-'; } } + + /// + /// Returns the two initial letters of the first and last words in the specified . + /// If only one word is present, a single initial is returned. If is null, empty or + /// white space only, is returned. + /// + [return: NotNullIfNotNull(nameof(defaultValue))] + public static string? GetInitials(this string name, string? defaultValue = default) + { + var s = name.AsSpan().Trim(); + + if (s.Length == 0) + { + return defaultValue; + } + + var lastSpaceIndex = s.LastIndexOf(' '); + + if (lastSpaceIndex == -1) + { + return s[0].ToString().ToUpperInvariant(); + } + + // The name contained two or more words. Return the initials from the first and last. + return $"{char.ToUpperInvariant(s[0])}{char.ToUpperInvariant(s[lastSpaceIndex + 1])}"; + } } diff --git a/src/Aspire.Dashboard/README.md b/src/Aspire.Dashboard/README.md index a01d3530e7..0daad4be3c 100644 --- a/src/Aspire.Dashboard/README.md +++ b/src/Aspire.Dashboard/README.md @@ -53,10 +53,12 @@ Set `Dashboard:Frontend:AuthMode` to `BrowserToken`. Browser token authenticatio Set `Dashboard:Frontend:AuthMode` to `OpenIdConnect`, then add the following configuration: -- `Authentication:Schemes:OpenIdConnect:Authority` — URL to the identity provider (IdP) -- `Authentication:Schemes:OpenIdConnect:ClientId` — Identity of the relying party (RP) -- `Authentication:Schemes:OpenIdConnect:ClientSecret`— A secret that only the real RP would know +- `Authentication:Schemes:OpenIdConnect:Authority` URL to the identity provider (IdP) +- `Authentication:Schemes:OpenIdConnect:ClientId` Identity of the relying party (RP) +- `Authentication:Schemes:OpenIdConnect:ClientSecret` A secret that only the real RP would know - Other properties of [`OpenIdConnectOptions`](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.builder.openidconnectoptions) specified in configuration container `Authentication:Schemes:OpenIdConnect:*` +- `Dashboard:Frontend:OpenIdConnect:NameClaimType` specifies the claim type(s) that should be used to display the authenticated user's full name. Can be a single claim type or a comma-delimited list of claim types. Defaults to `name`. +- `Dashboard:Frontend:OpenIdConnect:UsernameClaimType` specifies the claim type(s) that should be used to display the authenticated user's username. Can be a single claim type or a comma-delimited list of claim types. Defaults to `preferred_username`. ### OTLP authentication diff --git a/src/Aspire.Dashboard/Resources/Login.Designer.cs b/src/Aspire.Dashboard/Resources/Login.Designer.cs index ef69c5eca5..896c171886 100644 --- a/src/Aspire.Dashboard/Resources/Login.Designer.cs +++ b/src/Aspire.Dashboard/Resources/Login.Designer.cs @@ -60,6 +60,15 @@ internal Login() { } } + /// + /// Looks up a localized string similar to Authorized User. + /// + public static string AuthorizedUser { + get { + return ResourceManager.GetString("AuthorizedUser", resourceCulture); + } + } + /// /// Looks up a localized string similar to {0} dashboard. /// @@ -87,6 +96,15 @@ public static string InvalidTokenErrorMessage { } } + /// + /// Looks up a localized string similar to Logged in as:. + /// + public static string LoggedInAs { + get { + return ResourceManager.GetString("LoggedInAs", resourceCulture); + } + } + /// /// Looks up a localized string similar to Log in. /// diff --git a/src/Aspire.Dashboard/Resources/Login.resx b/src/Aspire.Dashboard/Resources/Login.resx index b3ab3ddfe2..03ff6345b9 100644 --- a/src/Aspire.Dashboard/Resources/Login.resx +++ b/src/Aspire.Dashboard/Resources/Login.resx @@ -117,6 +117,10 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard {0} is an application name @@ -127,6 +131,9 @@ Invalid token. Please try again + + Logged in as: + Log in diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.cs.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.cs.xlf index 48505e9076..a8b8db8843 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.cs.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.cs.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard Řídicí panel aplikace {0} @@ -22,6 +27,11 @@ Přihlásit se + + Logged in as: + Logged in as: + + More information Další informace diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.de.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.de.xlf index a2fb0f1675..2a0754ed93 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.de.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.de.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard {0}-Dashboard @@ -22,6 +27,11 @@ Anmelden + + Logged in as: + Logged in as: + + More information Weitere Informationen diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.es.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.es.xlf index 1c8ac2c31c..a49a64ef85 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.es.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.es.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard Panel de {0} @@ -22,6 +27,11 @@ Iniciar sesión + + Logged in as: + Logged in as: + + More information Más información diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.fr.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.fr.xlf index fa7c190feb..3440ccabf5 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.fr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.fr.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard Tableau de bord {0} @@ -22,6 +27,11 @@ Connexion + + Logged in as: + Logged in as: + + More information Obtenir plus d’informations diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.it.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.it.xlf index 38b2d62c6f..56c31b8216 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.it.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.it.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard Dashboard {0} @@ -22,6 +27,11 @@ Accedi + + Logged in as: + Logged in as: + + More information Altre informazioni diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.ja.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.ja.xlf index 98c7c5f4dd..15659d6e8c 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.ja.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.ja.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard {0} ダッシュボード @@ -22,6 +27,11 @@ ログイン + + Logged in as: + Logged in as: + + More information 詳細情報 diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.ko.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.ko.xlf index 8c6bdf23b4..b1994a4ac8 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.ko.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.ko.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard {0} 대시보드 @@ -22,6 +27,11 @@ 로그인 + + Logged in as: + Logged in as: + + More information 자세한 정보 diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.pl.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.pl.xlf index 7e485d3cde..71a6431b48 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.pl.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.pl.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard Pulpit nawigacyjny aplikacji {0} @@ -22,6 +27,11 @@ Zaloguj się + + Logged in as: + Logged in as: + + More information Więcej informacji diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.pt-BR.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.pt-BR.xlf index 4aeb8fb5b2..2a4308463e 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.pt-BR.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.pt-BR.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard Painel {0} @@ -22,6 +27,11 @@ Fazer logon + + Logged in as: + Logged in as: + + More information Mais informações diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.ru.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.ru.xlf index 7c84c623be..44734a2334 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.ru.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.ru.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard Панель мониторинга {0} @@ -22,6 +27,11 @@ Войти + + Logged in as: + Logged in as: + + More information Дополнительные сведения diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.tr.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.tr.xlf index e1f27ba255..41e4430227 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.tr.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.tr.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard {0} panosu @@ -22,6 +27,11 @@ Oturum aç + + Logged in as: + Logged in as: + + More information Daha fazla bilgi diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hans.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hans.xlf index c338ad9491..9a53624404 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hans.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hans.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard {0} 仪表板 @@ -22,6 +27,11 @@ 登录 + + Logged in as: + Logged in as: + + More information 详细信息 diff --git a/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hant.xlf b/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hant.xlf index 3cb68b48d9..46842b52c4 100644 --- a/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hant.xlf +++ b/src/Aspire.Dashboard/Resources/xlf/Login.zh-Hant.xlf @@ -2,6 +2,11 @@ + + Authorized User + Authorized User + Placeholder text for the name of the authorized user when no name claim exists + {0} dashboard {0} 儀表板 @@ -22,6 +27,11 @@ 登入 + + Logged in as: + Logged in as: + + More information 詳細資訊 diff --git a/tests/Aspire.Dashboard.Tests/StringExtensionsTests.cs b/tests/Aspire.Dashboard.Tests/StringExtensionsTests.cs index 8e35757b5e..0ed63b3614 100644 --- a/tests/Aspire.Dashboard.Tests/StringExtensionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/StringExtensionsTests.cs @@ -9,21 +9,29 @@ namespace Aspire.Dashboard.Tests; public class StringExtensionsTests { [Theory] - [InlineData("/usr", 5, "/usr")] - [InlineData("~/src/repos/AspireStarterProject/MyApp/MyApp.csproj", 5, "~/…oj")] - [InlineData("~/src/repos/AspireStarterProject/MyApp/MyApp.csproj", 25, "~/src/repos/…MyApp.csproj")] - [InlineData("~/src/repos/AspireStarterProject/MyApp/MyApp.csproj", 26, "~/src/repos/…/MyApp.csproj")] - [InlineData("/home/username/src/repos/AspireStarterProject/MyApp/MyApp.csproj", 5, "/h…oj")] - [InlineData("/home/username/src/repos/AspireStarterProject/MyApp/MyApp.csproj", 38, "/home/username/src…/MyApp/MyApp.csproj")] - [InlineData("/home/username/src/repos/AspireStarterProject/MyApp/MyApp.csproj", 39, "/home/username/src/…/MyApp/MyApp.csproj")] - [InlineData("c:\\", 5, "c:\\")] - [InlineData("c:\\src\\repos\\AspireStarterProject\\MyApp\\MyApp.csproj", 5, "c:…oj")] - [InlineData("c:\\src\\repos\\AspireStarterProject\\MyApp\\MyApp.csproj", 32, "c:\\src\\repos\\As…App\\MyApp.csproj")] - [InlineData("c:\\src\\repos\\AspireStarterProject\\MyApp\\MyApp.csproj", 33, "c:\\src\\repos\\Asp…App\\MyApp.csproj")] - [InlineData("c:\\src\\repos\\AspireStarterProject\\MyApp\\MyApp.csproj", 48, "c:\\src\\repos\\AspireStar…oject\\MyApp\\MyApp.csproj")] - public void TrimMiddle(string path, int targetLength, string expectedResult) + [InlineData("", "DefaultValue", "DefaultValue")] + [InlineData(" ", "DefaultValue", "DefaultValue")] + [InlineData("\t", "DefaultValue", "DefaultValue")] + [InlineData("SingleNameOnly", null, "S")] + [InlineData("singleNameOnly", null, "S")] + [InlineData("Two Names", null, "TN")] + [InlineData("two Names", null, "TN")] + [InlineData("Two names", null, "TN")] + [InlineData("two names", null, "TN")] + [InlineData("With Three Names", null, "WN")] + [InlineData("with Three Names", null, "WN")] + [InlineData("With Three names", null, "WN")] + [InlineData("with Three names", null, "WN")] + [InlineData("With Hyphenated-Name", null, "WH")] + [InlineData("with Hyphenated-Name", null, "WH")] + [InlineData("With hyphenated-Name", null, "WH")] + [InlineData("With Hyphenated-name", null, "WH")] + [InlineData("with hyphenated-Name", null, "WH")] + [InlineData("with Hyphenated-name", null, "WH")] + [InlineData("with hyphenated-name", null, "WH")] + public void GetInitials(string name, string? defaultValue, string expectedResult) { - var actual = StringExtensions.TrimMiddle(path, targetLength); + var actual = StringExtensions.GetInitials(name, defaultValue); Assert.Equal(expectedResult, actual); }