Skip to content

Commit f8c871b

Browse files
authored
Add tenant switching functionality for users with multiple tenant access (#783)
### Summary & Motivation Enable users to switch between multiple tenants when logged in with an email that has access to multiple tenants. Previously, users with the same email across multiple tenants could only access the first tenant upon login. This implementation provides a complete tenant switching solution with real-time synchronization across browser tabs. - Add API endpoints for fetching user's tenants and switching between them. - Implement tenant selector UI component with pending invitation badges. - Enable automatic copying of user name, title, avatar, and preferred language when switching to a new tenant. - Add invitation acceptance/decline functionality directly from the tenant selector. - Implement cross-tab synchronization that detects tenant or user changes and prompts for reload. - Store the last selected tenant in local storage for default selection on next login. - Add mobile-responsive tenant switcher in the mobile menu. - Include comprehensive end-to-end tests for all tenant switching scenarios. ### Downstream projects Update `your-self-contained-system/WebApp/routes/__root.tsx` to integrate the federated AuthSyncModal component for cross-tab synchronization: ```diff +import { AuthSyncModal } from "@repo/infrastructure/auth/AuthSyncModal"; import { Outlet, createRootRoute, useNavigate } from "@tanstack/react-router"; +import { lazy } from "react"; + +// biome-ignore lint/suspicious/noExplicitAny: Federated module import from account-management where type is not known +const FederatedAuthSyncModal = lazy(() => import("account-management/AuthSyncModal" as any)); <AuthenticationProvider navigate={(options) => navigate(options)}> <AddToHomescreen /> <PageTracker /> <Outlet /> + <AuthSyncModal modalComponent={FederatedAuthSyncModal} /> </AuthenticationProvider> ``` ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents 01b6320 + 85ffd89 commit f8c871b

File tree

54 files changed

+3512
-214
lines changed

Some content is hidden

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

54 files changed

+3512
-214
lines changed

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,22 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
1919
=> await mediator.Send(command)
2020
).Produces<StartLoginResponse>().AllowAnonymous();
2121

22-
group.MapPost("login/{id}/complete", async Task<ApiResult> (LoginId id, CompleteLoginCommand command, IMediator mediator)
22+
group.MapPost("/login/{id}/complete", async Task<ApiResult> (LoginId id, CompleteLoginCommand command, IMediator mediator)
2323
=> await mediator.Send(command with { Id = id })
2424
).AllowAnonymous();
2525

26-
group.MapPost("login/{emailConfirmationId}/resend-code", async Task<ApiResult<ResendEmailConfirmationCodeResponse>> (EmailConfirmationId emailConfirmationId, IMediator mediator)
26+
group.MapPost("/login/{emailConfirmationId}/resend-code", async Task<ApiResult<ResendEmailConfirmationCodeResponse>> (EmailConfirmationId emailConfirmationId, IMediator mediator)
2727
=> await mediator.Send(new ResendEmailConfirmationCodeCommand { Id = emailConfirmationId })
2828
).Produces<ResendEmailConfirmationCodeResponse>().AllowAnonymous();
2929

30-
group.MapPost("logout", async Task<ApiResult> (IMediator mediator)
30+
group.MapPost("/logout", async Task<ApiResult> (IMediator mediator)
3131
=> await mediator.Send(new LogoutCommand())
3232
);
3333

34+
group.MapPost("/switch-tenant", async Task<ApiResult> (SwitchTenantCommand command, IMediator mediator)
35+
=> await mediator.Send(command)
36+
);
37+
3438
// Note: This endpoint must be called with the refresh token as Bearer token in the Authorization header
3539
routes.MapPost("/internal-api/account-management/authentication/refresh-authentication-tokens", async Task<ApiResult> (IMediator mediator)
3640
=> await mediator.Send(new RefreshAuthenticationTokensCommand())

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
1818
=> await mediator.Send(command)
1919
).Produces<StartSignupResponse>().AllowAnonymous();
2020

21-
group.MapPost("{emailConfirmationId}/complete", async Task<ApiResult> (EmailConfirmationId emailConfirmationId, CompleteSignupCommand command, IMediator mediator)
21+
group.MapPost("/{emailConfirmationId}/complete", async Task<ApiResult> (EmailConfirmationId emailConfirmationId, CompleteSignupCommand command, IMediator mediator)
2222
=> await mediator.Send(command with { EmailConfirmationId = emailConfirmationId })
2323
).AllowAnonymous();
2424

25-
group.MapPost("{emailConfirmationId}/resend-code", async Task<ApiResult<ResendEmailConfirmationCodeResponse>> (EmailConfirmationId emailConfirmationId, IMediator mediator)
25+
group.MapPost("/{emailConfirmationId}/resend-code", async Task<ApiResult<ResendEmailConfirmationCodeResponse>> (EmailConfirmationId emailConfirmationId, IMediator mediator)
2626
=> await mediator.Send(new ResendEmailConfirmationCodeCommand { Id = emailConfirmationId })
2727
).Produces<ResendEmailConfirmationCodeResponse>().AllowAnonymous();
2828
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
2222
=> (await mediator.Send(command)).AddRefreshAuthenticationTokens()
2323
);
2424

25+
group.MapGet("/", async Task<ApiResult<GetTenantsForUserResponse>> (IMediator mediator)
26+
=> await mediator.Send(new GetTenantsForUserQuery())
27+
).Produces<GetTenantsForUserResponse>();
28+
2529
group.MapPost("/current/update-logo", async Task<ApiResult> (IFormFile file, IMediator mediator)
2630
=> await mediator.Send(new UpdateTenantLogoCommand(file.OpenReadStream(), file.ContentType))
2731
).DisableAntiforgery();

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
4242
=> await mediator.Send(command)
4343
);
4444

45+
group.MapPost("/decline-invitation", async Task<ApiResult> (DeclineInvitationCommand command, IMediator mediator)
46+
=> await mediator.Send(command)
47+
);
48+
4549
// The following endpoints are for the current user only
4650
group.MapGet("/me", async Task<ApiResult<CurrentUserResponse>> ([AsParameters] GetUserQuery query, IMediator mediator)
4751
=> await mediator.Send(query)

application/account-management/Core/Configuration.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using Microsoft.Extensions.Hosting;
33
using PlatformPlatform.AccountManagement.Database;
4-
using PlatformPlatform.AccountManagement.Features.Tenants;
54
using PlatformPlatform.AccountManagement.Features.Users.Shared;
65
using PlatformPlatform.AccountManagement.Integrations.Gravatar;
76
using PlatformPlatform.SharedKernel.Configuration;
@@ -29,8 +28,6 @@ public static IServiceCollection AddAccountManagementServices(this IServiceColle
2928
}
3029
);
3130

32-
TenantMapsterConfig.Configure();
33-
3431
return services
3532
.AddSharedServices<AccountManagementDbContext>(Assembly)
3633
.AddScoped<AvatarUpdater>()

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
using PlatformPlatform.AccountManagement.Integrations.Gravatar;
77
using PlatformPlatform.SharedKernel.Authentication.TokenGeneration;
88
using PlatformPlatform.SharedKernel.Cqrs;
9+
using PlatformPlatform.SharedKernel.Domain;
910
using PlatformPlatform.SharedKernel.Telemetry;
1011

1112
namespace PlatformPlatform.AccountManagement.Features.Authentication.Commands;
1213

1314
[PublicAPI]
14-
public sealed record CompleteLoginCommand(string OneTimePassword) : ICommand, IRequest<Result>
15+
public sealed record CompleteLoginCommand(string OneTimePassword, TenantId? PreferredTenantId = null) : ICommand, IRequest<Result>
1516
{
1617
[JsonIgnore] // Removes this property from the API contract
1718
public LoginId Id { get; init; } = null!;
@@ -32,7 +33,6 @@ ILogger<CompleteLoginHandler> logger
3233
public async Task<Result> Handle(CompleteLoginCommand command, CancellationToken cancellationToken)
3334
{
3435
var login = await loginRepository.GetByIdAsync(command.Id, cancellationToken);
35-
3636
if (login is null)
3737
{
3838
// For security, avoid confirming the existence of login IDs
@@ -42,7 +42,7 @@ public async Task<Result> Handle(CompleteLoginCommand command, CancellationToken
4242
if (login.Completed)
4343
{
4444
logger.LogWarning("Login with id '{LoginId}' has already been completed", login.Id);
45-
return Result.BadRequest($"The login process {login.Id} for user {login.UserId} has already been completed.");
45+
return Result.BadRequest($"The login process '{login.Id}' for user '{login.UserId}' has already been completed.");
4646
}
4747

4848
var completeEmailConfirmationResult = await mediator.Send(
@@ -54,6 +54,18 @@ public async Task<Result> Handle(CompleteLoginCommand command, CancellationToken
5454

5555
var user = (await userRepository.GetByIdUnfilteredAsync(login.UserId, cancellationToken))!;
5656

57+
// Check if PreferredTenantId is provided and valid
58+
if (command.PreferredTenantId is not null)
59+
{
60+
var usersWithSameEmail = await userRepository.GetUsersByEmailUnfilteredAsync(user.Email, cancellationToken);
61+
var preferredTenantUser = usersWithSameEmail.SingleOrDefault(u => u.TenantId == command.PreferredTenantId);
62+
63+
if (preferredTenantUser is not null)
64+
{
65+
user = preferredTenantUser;
66+
}
67+
}
68+
5769
if (!user.EmailConfirmed)
5870
{
5971
CompleteUserInvite(user);

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,39 +31,39 @@ public async Task<Result> Handle(RefreshAuthenticationTokensCommand command, Can
3131
if (!UserId.TryParse(httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
3232
{
3333
logger.LogWarning("No valid 'sub' claim found in refresh token");
34-
return Result.Unauthorized("Invalid refresh token");
34+
return Result.Unauthorized("Invalid refresh token.");
3535
}
3636

3737
if (!RefreshTokenId.TryParse(httpContext.User.FindFirstValue("rtid"), out var refreshTokenId))
3838
{
3939
logger.LogWarning("No valid 'rtid' claim found in refresh token");
40-
return Result.Unauthorized("Invalid refresh token");
40+
return Result.Unauthorized("Invalid refresh token.");
4141
}
4242

4343
if (!int.TryParse(httpContext.User.FindFirstValue("rtv"), out var refreshTokenVersion))
4444
{
4545
logger.LogWarning("No valid 'rtv' claim found in refresh token");
46-
return Result.Unauthorized("Invalid refresh token");
46+
return Result.Unauthorized("Invalid refresh token.");
4747
}
4848

4949
var jwtId = httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Jti);
5050
if (jwtId is null)
5151
{
5252
logger.LogWarning("No 'jti' claim found in refresh token");
53-
return Result.Unauthorized("Invalid refresh token");
53+
return Result.Unauthorized("Invalid refresh token.");
5454
}
5555

5656
var expiresClaim = httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Exp);
5757
if (expiresClaim is null)
5858
{
5959
logger.LogWarning("No 'exp' claim found in refresh token");
60-
return Result.Unauthorized("Invalid refresh token");
60+
return Result.Unauthorized("Invalid refresh token.");
6161
}
6262

6363
if (!long.TryParse(expiresClaim, out var expiresUnixSeconds))
6464
{
6565
logger.LogWarning("Invalid 'exp' claim format in refresh token");
66-
return Result.Unauthorized("Invalid refresh token");
66+
return Result.Unauthorized("Invalid refresh token.");
6767
}
6868

6969
var refreshTokenExpires = DateTimeOffset.FromUnixTimeSeconds(expiresUnixSeconds);
@@ -72,7 +72,7 @@ public async Task<Result> Handle(RefreshAuthenticationTokensCommand command, Can
7272
if (user is null)
7373
{
7474
logger.LogWarning("No user found with user id {UserId} found", userId);
75-
return Result.Unauthorized($"No user found with user id {userId} found.");
75+
return Result.Unauthorized($"No user found with user id '{userId}' found.");
7676
}
7777

7878
// TODO: Check if the refreshTokenId exists in the database and if the jwtId and refreshTokenVersion are valid
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using JetBrains.Annotations;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using PlatformPlatform.AccountManagement.Features.Users.Domain;
4+
using PlatformPlatform.AccountManagement.Features.Users.Shared;
5+
using PlatformPlatform.SharedKernel.Authentication.TokenGeneration;
6+
using PlatformPlatform.SharedKernel.Cqrs;
7+
using PlatformPlatform.SharedKernel.Domain;
8+
using PlatformPlatform.SharedKernel.ExecutionContext;
9+
using PlatformPlatform.SharedKernel.Integrations.BlobStorage;
10+
using PlatformPlatform.SharedKernel.Telemetry;
11+
12+
namespace PlatformPlatform.AccountManagement.Features.Authentication.Commands;
13+
14+
[PublicAPI]
15+
public sealed record SwitchTenantCommand(TenantId TenantId) : ICommand, IRequest<Result>;
16+
17+
public sealed class SwitchTenantHandler(
18+
IUserRepository userRepository,
19+
UserInfoFactory userInfoFactory,
20+
AuthenticationTokenService authenticationTokenService,
21+
AvatarUpdater avatarUpdater,
22+
[FromKeyedServices("account-management-storage")]
23+
BlobStorageClient blobStorageClient,
24+
IExecutionContext executionContext,
25+
ITelemetryEventsCollector events,
26+
ILogger<SwitchTenantHandler> logger
27+
) : IRequestHandler<SwitchTenantCommand, Result>
28+
{
29+
public async Task<Result> Handle(SwitchTenantCommand command, CancellationToken cancellationToken)
30+
{
31+
var users = await userRepository.GetUsersByEmailUnfilteredAsync(executionContext.UserInfo.Email!, cancellationToken);
32+
var targetUser = users.SingleOrDefault(u => u.TenantId == command.TenantId);
33+
if (targetUser is null)
34+
{
35+
logger.LogWarning("UserId '{UserId}' does not have access to TenantId '{TenantId}'", executionContext.UserInfo.Id, command.TenantId);
36+
return Result.Forbidden($"User does not have access to tenant '{command.TenantId}'.");
37+
}
38+
39+
// If the user's email is not confirmed, confirm it and copy profile data from current user
40+
if (!targetUser.EmailConfirmed)
41+
{
42+
await CopyProfileDataFromCurrentUser(targetUser, cancellationToken);
43+
}
44+
45+
var userInfo = await userInfoFactory.CreateUserInfoAsync(targetUser, cancellationToken);
46+
authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo);
47+
48+
events.CollectEvent(new TenantSwitched(executionContext.TenantId!, command.TenantId, targetUser.Id));
49+
50+
return Result.Success();
51+
}
52+
53+
private async Task CopyProfileDataFromCurrentUser(User targetUser, CancellationToken cancellationToken)
54+
{
55+
// Get the current user to copy profile data from
56+
var currentUser = await userRepository.GetByIdUnfilteredAsync(executionContext.UserInfo.Id!, cancellationToken);
57+
targetUser.Update(
58+
currentUser!.FirstName ?? targetUser.FirstName ?? "",
59+
currentUser.LastName ?? targetUser.LastName ?? "",
60+
currentUser.Title ?? targetUser.Title ?? ""
61+
);
62+
63+
// Copy locale (preferred language)
64+
targetUser.ChangeLocale(currentUser.Locale);
65+
66+
// Copy avatar if the current user has one
67+
if (targetUser.Avatar.Url is null && currentUser.Avatar.Url?.StartsWith("/avatars/") == true)
68+
{
69+
// Blob-stored avatar - copy the blob to the new tenant
70+
var sourceBlobPath = currentUser.Avatar.Url[9..]; // Skip "/avatars/" prefix
71+
72+
// Download the avatar blob from the source tenant
73+
var avatarData = await blobStorageClient.DownloadAsync("avatars", sourceBlobPath, cancellationToken);
74+
if (avatarData is not null)
75+
{
76+
// Upload the avatar to the target tenant's storage location
77+
await avatarUpdater.UpdateAvatar(targetUser, false, avatarData.Value.ContentType, avatarData.Value.Stream, cancellationToken);
78+
}
79+
}
80+
81+
targetUser.ConfirmEmail();
82+
userRepository.Update(targetUser);
83+
84+
// Calculate how long it took to accept the invitation
85+
var inviteAcceptedTimeInMinutes = (int)(DateTimeOffset.UtcNow - targetUser.CreatedAt).TotalMinutes;
86+
events.CollectEvent(new UserInviteAccepted(targetUser.Id, inviteAcceptedTimeInMinutes));
87+
}
88+
}

application/account-management/Core/Features/TelemetryEvents.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ public sealed class TenantLogoRemoved
5757
public sealed class TenantLogoUpdated(string contentType, long size)
5858
: TelemetryEvent(("content_type", contentType), ("size", size));
5959

60+
public sealed class TenantSwitched(TenantId fromTenantId, TenantId toTenantId, UserId userId)
61+
: TelemetryEvent(("from_tenant_id", fromTenantId), ("to_tenant_id", toTenantId), ("user_id", userId));
62+
6063
public sealed class TenantUpdated
6164
: TelemetryEvent;
6265

@@ -81,6 +84,9 @@ public sealed class UsersBulkDeleted(int count)
8184
public sealed class UserInviteAccepted(UserId userId, int inviteAcceptedTimeInMinutes)
8285
: TelemetryEvent(("user_id", userId), ("invite_accepted_time_in_minutes", inviteAcceptedTimeInMinutes));
8386

87+
public sealed class UserInviteDeclined(UserId userId, int inviteExistedTimeInMinutes)
88+
: TelemetryEvent(("user_id", userId), ("invite_existed_time_in_minutes", inviteExistedTimeInMinutes));
89+
8490
public sealed class UserInvited(UserId userId)
8591
: TelemetryEvent(("user_id", userId));
8692

application/account-management/Core/Features/Tenants/Domain/TenantRepository.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Microsoft.EntityFrameworkCore;
12
using PlatformPlatform.AccountManagement.Database;
23
using PlatformPlatform.SharedKernel.Domain;
34
using PlatformPlatform.SharedKernel.ExecutionContext;
@@ -10,6 +11,8 @@ public interface ITenantRepository : ICrudRepository<Tenant, TenantId>
1011
Task<Tenant> GetCurrentTenantAsync(CancellationToken cancellationToken);
1112

1213
Task<bool> ExistsAsync(TenantId id, CancellationToken cancellationToken);
14+
15+
Task<Tenant[]> GetByIdsAsync(TenantId[] ids, CancellationToken cancellationToken);
1316
}
1417

1518
internal sealed class TenantRepository(AccountManagementDbContext accountManagementDbContext, IExecutionContext executionContext)
@@ -21,4 +24,9 @@ public async Task<Tenant> GetCurrentTenantAsync(CancellationToken cancellationTo
2124
return await GetByIdAsync(executionContext.TenantId, cancellationToken) ??
2225
throw new InvalidOperationException("Active tenant not found.");
2326
}
27+
28+
public async Task<Tenant[]> GetByIdsAsync(TenantId[] ids, CancellationToken cancellationToken)
29+
{
30+
return await DbSet.Where(t => ids.Contains(t.Id)).ToArrayAsync(cancellationToken);
31+
}
2432
}

0 commit comments

Comments
 (0)