-
Notifications
You must be signed in to change notification settings - Fork 480
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
Add User Profile Menu for OpenID Connect login #3443
Changes from all commits
3648519
85be6ca
566867b
afdbc98
db73a49
d5f4d8d
8996a54
222bedd
56bf361
750321e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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> |
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."); | ||
} | ||
} | ||
} | ||
} |
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; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Made a bunch of these header styles more specific to the elements we were trying to impact so they wouldn't change the style of things within the FluentProfileMenu |
||
::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); | ||
} | ||
|
||
|
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; | ||
} | ||
} |
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This hasn't been used for a while, so I deleted it and the unit test cases. |
||
{ | ||
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 == '-'; | ||
} | ||
} | ||
|
||
/// <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) | ||
drewnoakes marked this conversation as resolved.
Show resolved
Hide resolved
drewnoakes marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
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])}"; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Open to suggestions on what, if anything, we should display in this case. I've seen this type of placeholder before so I went with it for now.