Skip to content

Commit eacb0ff

Browse files
authored
Redesign user management interface with improved side pane and enhanced permissions (#772)
### Summary & Motivation This introduces a comprehensive redesign of the user management interface, focusing on improved user experience and proper permission handling. The main feature is a new user profile side pane that provides quick access to user details while maintaining context within the user list. - Implement a new user profile side pane that slides in from the right and allows viewing user details without leaving the user list page. - Add deep linking support for individual users with a new API endpoint to fetch user details by ID. - Enhance permission controls by restricting tenant and user management actions to Owner role only. - Make the side menu resizable with user preferences saved in local storage. - Display tenant name in the side menu instead of the logo. - Improve responsive behavior with proper mobile handling and dynamic filter button collapse based on available space. - Add comprehensive accessibility improvements including aria-labels, keyboard navigation, and proper semantic HTML. - Update the change user role flow to use a standard dialog pattern with OK/Cancel buttons. - Fix multiple UI issues including table scrolling, z-index layering, and hover states. - Add tooltips to icon-only buttons and improve visual feedback for disabled actions. - Implement proper data freshness warnings when user data differs between views. ### Downstream projects Add TenantName to the SharedSideMenu ```diff +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; export function SharedSideMenu({ children, ariaLabel }: Readonly<SharedSideMenuProps>) { + const userInfo = useUserInfo(); return ( - <SideMenu ariaLabel={ariaLabel}> + <SideMenu ariaLabel={ariaLabel} tenantName={userInfo?.tenantName}> ``` This change introduces more changes to the UI components. Please review your application to ensure everything looks as it should. This change introduces more changes to the AppLayout and is part of a bigger redesign. Consider looking at these changes holistically. ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents e6a764f + 8c76dbc commit eacb0ff

File tree

44 files changed

+2035
-623
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+2035
-623
lines changed

application/account-management/Api/Endpoints/TenantEndpoints.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
1919
).Produces<TenantResponse>();
2020

2121
group.MapPut("/current", async Task<ApiResult> (UpdateCurrentTenantCommand command, IMediator mediator)
22-
=> await mediator.Send(command)
22+
=> (await mediator.Send(command)).AddRefreshAuthenticationTokens()
2323
);
2424

2525
routes.MapDelete("/internal-api/account-management/tenants/{id}", async Task<ApiResult> (TenantId id, IMediator mediator)

application/account-management/Api/Endpoints/UserEndpoints.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
1818
=> await mediator.Send(query)
1919
).Produces<UsersResponse>();
2020

21+
group.MapGet("/{id}", async Task<ApiResult<UserDetails>> (UserId id, IMediator mediator)
22+
=> await mediator.Send(new GetUserByIdQuery(id))
23+
).Produces<UserDetails>();
24+
2125
group.MapGet("/summary", async Task<ApiResult<UserSummaryResponse>> (IMediator mediator)
2226
=> await mediator.Send(new GetUserSummaryQuery())
2327
).Produces<UserSummaryResponse>();

application/account-management/Core/Configuration.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public static IServiceCollection AddAccountManagementServices(this IServiceColle
3030

3131
return services
3232
.AddSharedServices<AccountManagementDbContext>(Assembly)
33-
.AddScoped<AvatarUpdater>();
33+
.AddScoped<AvatarUpdater>()
34+
.AddScoped<UserInfoFactory>();
3435
}
3536
}

application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
using JetBrains.Annotations;
2-
using Mapster;
32
using PlatformPlatform.AccountManagement.Features.Authentication.Domain;
43
using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands;
54
using PlatformPlatform.AccountManagement.Features.Users.Domain;
65
using PlatformPlatform.AccountManagement.Features.Users.Shared;
76
using PlatformPlatform.AccountManagement.Integrations.Gravatar;
8-
using PlatformPlatform.SharedKernel.Authentication;
97
using PlatformPlatform.SharedKernel.Authentication.TokenGeneration;
108
using PlatformPlatform.SharedKernel.Cqrs;
119
using PlatformPlatform.SharedKernel.Telemetry;
@@ -22,6 +20,7 @@ public sealed record CompleteLoginCommand(string OneTimePassword) : ICommand, IR
2220
public sealed class CompleteLoginHandler(
2321
IUserRepository userRepository,
2422
ILoginRepository loginRepository,
23+
UserInfoFactory userInfoFactory,
2524
AuthenticationTokenService authenticationTokenService,
2625
IMediator mediator,
2726
AvatarUpdater avatarUpdater,
@@ -75,7 +74,8 @@ public async Task<Result> Handle(CompleteLoginCommand command, CancellationToken
7574
login.MarkAsCompleted();
7675
loginRepository.Update(login);
7776

78-
authenticationTokenService.CreateAndSetAuthenticationTokens(user.Adapt<UserInfo>());
77+
var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken);
78+
authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo);
7979

8080
events.CollectEvent(new LoginCompleted(user.Id, completeEmailConfirmationResult.Value!.ConfirmationTimeInSeconds));
8181

application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
using System.IdentityModel.Tokens.Jwt;
22
using System.Security.Claims;
33
using JetBrains.Annotations;
4-
using Mapster;
54
using Microsoft.AspNetCore.Http;
65
using PlatformPlatform.AccountManagement.Features.Users.Domain;
7-
using PlatformPlatform.SharedKernel.Authentication;
6+
using PlatformPlatform.AccountManagement.Features.Users.Shared;
87
using PlatformPlatform.SharedKernel.Authentication.TokenGeneration;
98
using PlatformPlatform.SharedKernel.Cqrs;
109
using PlatformPlatform.SharedKernel.Domain;
@@ -17,6 +16,7 @@ public sealed record RefreshAuthenticationTokensCommand : ICommand, IRequest<Res
1716

1817
public sealed class RefreshAuthenticationTokensHandler(
1918
IUserRepository userRepository,
19+
UserInfoFactory userInfoFactory,
2020
IHttpContextAccessor httpContextAccessor,
2121
AuthenticationTokenService authenticationTokenService,
2222
ITelemetryEventsCollector events,
@@ -77,7 +77,8 @@ public async Task<Result> Handle(RefreshAuthenticationTokensCommand command, Can
7777

7878
// TODO: Check if the refreshTokenId exists in the database and if the jwtId and refreshTokenVersion are valid
7979

80-
authenticationTokenService.RefreshAuthenticationTokens(user.Adapt<UserInfo>(), refreshTokenId, refreshTokenVersion, refreshTokenExpires);
80+
var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken);
81+
authenticationTokenService.RefreshAuthenticationTokens(userInfo, refreshTokenId, refreshTokenVersion, refreshTokenExpires);
8182
events.CollectEvent(new AuthenticationTokensRefreshed());
8283

8384
return Result.Success();

application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
using JetBrains.Annotations;
2-
using Mapster;
32
using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands;
43
using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain;
54
using PlatformPlatform.AccountManagement.Features.Tenants.Commands;
65
using PlatformPlatform.AccountManagement.Features.Users.Domain;
7-
using PlatformPlatform.SharedKernel.Authentication;
6+
using PlatformPlatform.AccountManagement.Features.Users.Shared;
87
using PlatformPlatform.SharedKernel.Authentication.TokenGeneration;
98
using PlatformPlatform.SharedKernel.Cqrs;
109
using PlatformPlatform.SharedKernel.Telemetry;
@@ -20,6 +19,7 @@ public sealed record CompleteSignupCommand(string OneTimePassword, string Prefer
2019

2120
public sealed class CompleteSignupHandler(
2221
IUserRepository userRepository,
22+
UserInfoFactory userInfoFactory,
2323
AuthenticationTokenService authenticationTokenService,
2424
IMediator mediator,
2525
ITelemetryEventsCollector events
@@ -42,7 +42,8 @@ public async Task<Result> Handle(CompleteSignupCommand command, CancellationToke
4242
if (!createTenantResult.IsSuccess) return Result.From(createTenantResult);
4343

4444
var user = await userRepository.GetByIdAsync(createTenantResult.Value!.UserId, cancellationToken);
45-
authenticationTokenService.CreateAndSetAuthenticationTokens(user!.Adapt<UserInfo>());
45+
var userInfo = await userInfoFactory.CreateUserInfoAsync(user!, cancellationToken);
46+
authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo);
4647

4748
events.CollectEvent(
4849
new SignupCompleted(createTenantResult.Value.TenantId, completeEmailConfirmationResult.Value!.ConfirmationTimeInSeconds)

application/account-management/Core/Features/Tenants/Commands/UpdateCurrentTenant.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
using FluentValidation;
22
using JetBrains.Annotations;
33
using PlatformPlatform.AccountManagement.Features.Tenants.Domain;
4+
using PlatformPlatform.AccountManagement.Features.Users.Domain;
45
using PlatformPlatform.SharedKernel.Cqrs;
6+
using PlatformPlatform.SharedKernel.ExecutionContext;
57
using PlatformPlatform.SharedKernel.Telemetry;
68

79
namespace PlatformPlatform.AccountManagement.Features.Tenants.Commands;
@@ -20,11 +22,19 @@ public UpdateCurrentTenantValidator()
2022
}
2123
}
2224

23-
public sealed class UpdateTenantHandler(ITenantRepository tenantRepository, ITelemetryEventsCollector events)
24-
: IRequestHandler<UpdateCurrentTenantCommand, Result>
25+
public sealed class UpdateTenantHandler(
26+
ITenantRepository tenantRepository,
27+
IExecutionContext executionContext,
28+
ITelemetryEventsCollector events
29+
) : IRequestHandler<UpdateCurrentTenantCommand, Result>
2530
{
2631
public async Task<Result> Handle(UpdateCurrentTenantCommand command, CancellationToken cancellationToken)
2732
{
33+
if (executionContext.UserInfo.Role != UserRole.Owner.ToString())
34+
{
35+
return Result.Forbidden("Only owners are allowed to update tenant information.");
36+
}
37+
2838
var tenant = await tenantRepository.GetCurrentTenantAsync(cancellationToken);
2939

3040
tenant.Update(command.Name);

application/account-management/Core/Features/Users/Domain/UserRepository.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,15 +158,28 @@ CancellationToken cancellationToken
158158
? users.OrderBy(u => u.ModifiedAt)
159159
: users.OrderByDescending(u => u.ModifiedAt),
160160
SortableUserProperties.Name => sortOrder == SortOrder.Ascending
161-
? users.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
162-
: users.OrderByDescending(u => u.FirstName).ThenByDescending(u => u.LastName),
161+
? users.OrderBy(u => u.FirstName == null ? 1 : 0)
162+
.ThenBy(u => u.FirstName)
163+
.ThenBy(u => u.LastName == null ? 1 : 0)
164+
.ThenBy(u => u.LastName)
165+
.ThenBy(u => u.Email)
166+
: users.OrderBy(u => u.FirstName == null ? 0 : 1)
167+
.ThenByDescending(u => u.FirstName)
168+
.ThenBy(u => u.LastName == null ? 0 : 1)
169+
.ThenByDescending(u => u.LastName)
170+
.ThenBy(u => u.Email),
163171
SortableUserProperties.Email => sortOrder == SortOrder.Ascending
164172
? users.OrderBy(u => u.Email)
165173
: users.OrderByDescending(u => u.Email),
166174
SortableUserProperties.Role => sortOrder == SortOrder.Ascending
167175
? users.OrderBy(u => u.Role)
168176
: users.OrderByDescending(u => u.Role),
169177
_ => users
178+
.OrderBy(u => u.FirstName == null ? 1 : 0)
179+
.ThenBy(u => u.FirstName)
180+
.ThenBy(u => u.LastName == null ? 1 : 0)
181+
.ThenBy(u => u.LastName)
182+
.ThenBy(u => u.Email)
170183
};
171184

172185
pageSize ??= 50;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using JetBrains.Annotations;
2+
using Mapster;
3+
using PlatformPlatform.AccountManagement.Features.Users.Domain;
4+
using PlatformPlatform.SharedKernel.Cqrs;
5+
using PlatformPlatform.SharedKernel.Domain;
6+
7+
namespace PlatformPlatform.AccountManagement.Features.Users.Queries;
8+
9+
[PublicAPI]
10+
public sealed record GetUserByIdQuery(UserId Id) : IRequest<Result<UserDetails>>;
11+
12+
public sealed class GetUserByIdHandler(IUserRepository userRepository)
13+
: IRequestHandler<GetUserByIdQuery, Result<UserDetails>>
14+
{
15+
public async Task<Result<UserDetails>> Handle(GetUserByIdQuery query, CancellationToken cancellationToken)
16+
{
17+
var user = await userRepository.GetByIdAsync(query.Id, cancellationToken);
18+
19+
if (user is null)
20+
{
21+
return Result<UserDetails>.NotFound($"User with ID '{query.Id}' not found.");
22+
}
23+
24+
return user.Adapt<UserDetails>();
25+
}
26+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using PlatformPlatform.AccountManagement.Features.Tenants.Domain;
2+
using PlatformPlatform.AccountManagement.Features.Users.Domain;
3+
using PlatformPlatform.SharedKernel.Authentication;
4+
5+
namespace PlatformPlatform.AccountManagement.Features.Users.Shared;
6+
7+
/// <summary>
8+
/// Factory for creating UserInfo instances with tenant information.
9+
/// Centralizes the logic for creating UserInfo to follow SRP and avoid duplication.
10+
/// </summary>
11+
public sealed class UserInfoFactory(ITenantRepository tenantRepository)
12+
{
13+
/// <summary>
14+
/// Creates a UserInfo instance from a User entity, including tenant name.
15+
/// </summary>
16+
/// <param name="user">The user entity</param>
17+
/// <param name="cancellationToken">Cancellation token</param>
18+
/// <returns>UserInfo with all required properties including tenant name</returns>
19+
public async Task<UserInfo> CreateUserInfoAsync(User user, CancellationToken cancellationToken)
20+
{
21+
var tenant = await tenantRepository.GetByIdAsync(user.TenantId, cancellationToken);
22+
23+
return new UserInfo
24+
{
25+
IsAuthenticated = true,
26+
Id = user.Id,
27+
TenantId = user.TenantId,
28+
Role = user.Role.ToString(),
29+
Email = user.Email,
30+
FirstName = user.FirstName,
31+
LastName = user.LastName,
32+
Title = user.Title,
33+
AvatarUrl = user.Avatar.Url,
34+
TenantName = tenant?.Name,
35+
Locale = user.Locale
36+
};
37+
}
38+
}

0 commit comments

Comments
 (0)