Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[release/8.0] Add User Profile Menu for OpenID Connect login #3557

Merged
merged 11 commits into from
Apr 10, 2024
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@
<PackageVersion Include="Humanizer.Core" Version="2.14.1" />
<PackageVersion Include="KubernetesClient" Version="13.0.11" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="5.2.0" />
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.5.0" />
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.5.0" />
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.6.0" />
<PackageVersion Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.6.0" />
<PackageVersion Include="MongoDB.Driver" Version="2.24.0" />
<PackageVersion Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="1.4.0" />
<PackageVersion Include="MySqlConnector.DependencyInjection" Version="2.3.5" />
Expand Down
33 changes: 33 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/UserProfile.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<AuthorizeView>
<Authorized>
@if (_showUserProfileMenu)
{
<div class="profile-menu-container">
<FluentProfileMenu Initials="@_initials"
EMail="@_username"
FullName="@_name"
ButtonSize="@ButtonSize"
FooterLink="">
<HeaderTemplate>
<FluentStack VerticalAlignment="VerticalAlignment.Center">
<FluentLabel>@Loc[nameof(Resources.Login.LoggedInAs)]</FluentLabel>
<FluentSpacer />
<form @formname="sign-out-form" class="sign-out-form" action="authentication/logout" method="post">
<AntiforgeryToken />
<FluentButton Appearance="Appearance.Stealth" Type="ButtonType.Submit">Sign out</FluentButton>
</form>
</FluentStack>
</HeaderTemplate>
<ChildContent>
<FluentStack class="inner-persona-container">
<FluentPersona Initials="@_initials" ImageSize="@ImageSize" Class="inner-persona">
<div class="full-name">@_name</div>
<div class="user-id">@_username</div>
</FluentPersona>
</FluentStack>
</ChildContent>
</FluentProfileMenu>
</div>
}
</Authorized>
</AuthorizeView>
72 changes: 72 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/UserProfile.razor.cs
Original file line number Diff line number Diff line change
@@ -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> DashboardOptions { get; set; }

[CascadingParameter]
public required Task<AuthenticationState> AuthenticationState { get; set; }

[Inject]
public required IStringLocalizer<Login> Loc { get; set; }

[Inject]
public required ILogger<UserProfile> 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.");
}
}
}
}
60 changes: 60 additions & 0 deletions src/Aspire.Dashboard/Components/Controls/UserProfile.razor.css
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
Title="@Loc[nameof(Layout.MainLayoutLaunchSettings)]" aria-label="@Loc[nameof(Layout.MainLayoutLaunchSettings)]">
<FluentIcon Value="@(new Icons.Regular.Size24.Settings())" Color="Color.Neutral" />
</FluentButton>
<UserProfile />
</FluentHeader>
<NavMenu />
<div class="messagebar-container">
Expand Down
8 changes: 4 additions & 4 deletions src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions src/Aspire.Dashboard/Components/_Imports.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions src/Aspire.Dashboard/Configuration/DashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<string>? errorMessages)
{
List<string>? 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,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]));
}
}

/// <summary>
Expand Down
27 changes: 27 additions & 0 deletions src/Aspire.Dashboard/Extensions/ClaimsIdentityExtensions.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Searches the claims in the <see cref="ClaimsIdentity.Claims"/> for each of the claim types in <paramref name="claimTypes" />
/// in the order presented and returns the first one that it finds.
/// </summary>
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;
}
}
45 changes: 27 additions & 18 deletions src/Aspire.Dashboard/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Shortens a string by replacing the middle with an ellipsis.
/// </summary>
/// <param name="text">The string to shorten</param>
/// <param name="maxLength">The max length of the result</param>
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);
Expand All @@ -49,4 +32,30 @@ static bool IsValidHtmlIdCharacter(char c)
return char.IsLetterOrDigit(c) || c == '_' || c == '-';
}
}

/// <summary>
/// Returns the two initial letters of the first and last words in the specified <paramref name="name"/>.
/// If only one word is present, a single initial is returned. If <paramref name="name"/> is null, empty or
/// white space only, <paramref name="defaultValue"/> is returned.
/// </summary>
[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])}";
}
}
Loading