diff --git a/application/account-management/Api/Endpoints/AuthenticationEndpoints.cs b/application/account-management/Api/Endpoints/AuthenticationEndpoints.cs index 208ea6572..5107357e6 100644 --- a/application/account-management/Api/Endpoints/AuthenticationEndpoints.cs +++ b/application/account-management/Api/Endpoints/AuthenticationEndpoints.cs @@ -4,6 +4,7 @@ using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; using PlatformPlatform.SharedKernel.ApiResults; +using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; using PlatformPlatform.SharedKernel.Endpoints; namespace PlatformPlatform.AccountManagement.Api.Endpoints; @@ -40,6 +41,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(query) ).Produces(); + group.MapDelete("/sessions/{id}", async Task (SessionId id, IMediator mediator) + => await mediator.Send(new RevokeSessionCommand { Id = id }) + ); + // Note: This endpoint must be called with the refresh token as Bearer token in the Authorization header routes.MapPost("/internal-api/account-management/authentication/refresh-authentication-tokens", async Task (IMediator mediator) => await mediator.Send(new RefreshAuthenticationTokensCommand()) diff --git a/application/account-management/Core/Features/Authentication/Commands/RevokeSession.cs b/application/account-management/Core/Features/Authentication/Commands/RevokeSession.cs new file mode 100644 index 000000000..732baee48 --- /dev/null +++ b/application/account-management/Core/Features/Authentication/Commands/RevokeSession.cs @@ -0,0 +1,54 @@ +using JetBrains.Annotations; +using PlatformPlatform.AccountManagement.Features.Authentication.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.ExecutionContext; +using PlatformPlatform.SharedKernel.Telemetry; + +namespace PlatformPlatform.AccountManagement.Features.Authentication.Commands; + +[PublicAPI] +public sealed record RevokeSessionCommand : ICommand, IRequest +{ + [JsonIgnore] + public SessionId Id { get; init; } = null!; +} + +public sealed class RevokeSessionHandler( + ISessionRepository sessionRepository, + IUserRepository userRepository, + IExecutionContext executionContext, + ITelemetryEventsCollector events, + TimeProvider timeProvider +) : IRequestHandler +{ + public async Task Handle(RevokeSessionCommand command, CancellationToken cancellationToken) + { + var userEmail = executionContext.UserInfo.Email!; + + var session = await sessionRepository.GetByIdUnfilteredAsync(command.Id, cancellationToken); + if (session is null) + { + return Result.NotFound($"Session with id '{command.Id}' not found."); + } + + var sessionUser = await userRepository.GetByIdUnfilteredAsync(session.UserId, cancellationToken); + if (sessionUser?.Email != userEmail) + { + return Result.Forbidden("You can only revoke your own sessions."); + } + + if (session.IsRevoked) + { + return Result.BadRequest($"Session with id '{command.Id}' is already revoked."); + } + + session.Revoke(timeProvider.GetUtcNow(), SessionRevokedReason.Revoked); + sessionRepository.Update(session); + + events.CollectEvent(new SessionRevoked(SessionRevokedReason.Revoked)); + + return Result.Success(); + } +} diff --git a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs index fc9c5b32b..19f322c10 100644 --- a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs +++ b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs @@ -47,6 +47,25 @@ public async Task Handle(SwitchTenantCommand command, CancellationToken await CopyProfileDataFromCurrentUser(targetUser, cancellationToken); } + var currentSessionId = executionContext.UserInfo.SessionId; + var currentSession = await sessionRepository.GetByIdUnfilteredAsync(currentSessionId!, cancellationToken); + if (currentSession is null) + { + logger.LogWarning("Current session '{SessionId}' not found", currentSessionId); + return Result.Unauthorized("Current session not found."); + } + + if (currentSession.IsRevoked) + { + logger.LogWarning("Current session '{SessionId}' is already revoked", currentSessionId); + return Result.Unauthorized("Session has been revoked."); + } + + var now = timeProvider.GetUtcNow(); + currentSession.Revoke(now, SessionRevokedReason.SwitchTenant); + sessionRepository.Update(currentSession); + events.CollectEvent(new SessionRevoked(SessionRevokedReason.SwitchTenant)); + var userAgent = httpContextAccessor.HttpContext?.Request.Headers.UserAgent.ToString() ?? string.Empty; var ipAddress = executionContext.ClientIpAddress; @@ -54,7 +73,7 @@ public async Task Handle(SwitchTenantCommand command, CancellationToken await sessionRepository.AddAsync(session, cancellationToken); var userInfo = await userInfoFactory.CreateUserInfoAsync(targetUser, cancellationToken, session.Id); - authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti); + authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti, currentSession.ExpiresAt); events.CollectEvent(new SessionCreated(session.Id)); events.CollectEvent(new TenantSwitched(executionContext.TenantId!, command.TenantId, targetUser.Id)); diff --git a/application/account-management/Core/Features/Authentication/Domain/Session.cs b/application/account-management/Core/Features/Authentication/Domain/Session.cs index 9b1d234ac..6dce6a2da 100644 --- a/application/account-management/Core/Features/Authentication/Domain/Session.cs +++ b/application/account-management/Core/Features/Authentication/Domain/Session.cs @@ -42,6 +42,8 @@ private Session(TenantId tenantId, UserId userId, DeviceType deviceType, string public bool IsRevoked => RevokedAt is not null; + public DateTimeOffset ExpiresAt => CreatedAt.AddHours(RefreshTokenGenerator.ValidForHours); + public TenantId TenantId { get; } public static Session Create(TenantId tenantId, UserId userId, string userAgent, IPAddress ipAddress) diff --git a/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs b/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs index 67705c45f..c286d034b 100644 --- a/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs +++ b/application/account-management/Core/Features/Authentication/Domain/SessionRepository.cs @@ -15,6 +15,12 @@ public interface ISessionRepository : ICrudRepository Task GetByIdUnfilteredAsync(SessionId sessionId, CancellationToken cancellationToken); Task GetActiveSessionsForUserAsync(UserId userId, CancellationToken cancellationToken); + + /// + /// Retrieves all active sessions for multiple users across all tenants without applying query filters. + /// This method should only be used in the Sessions dialog where users need to see all sessions for their email. + /// + Task GetActiveSessionsForUsersUnfilteredAsync(UserId[] userIds, CancellationToken cancellationToken); } public sealed class SessionRepository(AccountManagementDbContext accountManagementDbContext) @@ -32,4 +38,13 @@ public async Task GetActiveSessionsForUserAsync(UserId userId, Cancel .ToArrayAsync(cancellationToken); return sessions.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ToArray(); } + + public async Task GetActiveSessionsForUsersUnfilteredAsync(UserId[] userIds, CancellationToken cancellationToken) + { + var sessions = await DbSet + .IgnoreQueryFilters() + .Where(s => userIds.AsEnumerable().Contains(s.UserId) && s.RevokedAt == null) + .ToArrayAsync(cancellationToken); + return sessions.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ToArray(); + } } diff --git a/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs b/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs index 8b7a427fa..e8730abf9 100644 --- a/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs +++ b/application/account-management/Core/Features/Authentication/Domain/SessionTypes.cs @@ -17,5 +17,7 @@ public enum DeviceType public enum SessionRevokedReason { LoggedOut, - ReplayAttackDetected + Revoked, + ReplayAttackDetected, + SwitchTenant } diff --git a/application/account-management/Core/Features/Authentication/Queries/GetUserSessions.cs b/application/account-management/Core/Features/Authentication/Queries/GetUserSessions.cs index f23f190a2..a4046d12a 100644 --- a/application/account-management/Core/Features/Authentication/Queries/GetUserSessions.cs +++ b/application/account-management/Core/Features/Authentication/Queries/GetUserSessions.cs @@ -1,5 +1,7 @@ using JetBrains.Annotations; using PlatformPlatform.AccountManagement.Features.Authentication.Domain; +using PlatformPlatform.AccountManagement.Features.Tenants.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; using PlatformPlatform.SharedKernel.Cqrs; using PlatformPlatform.SharedKernel.ExecutionContext; @@ -20,18 +22,30 @@ public sealed record UserSessionInfo( string UserAgent, string IpAddress, DateTimeOffset LastActivityAt, - bool IsCurrent + bool IsCurrent, + string TenantName ); -public sealed class GetUserSessionsHandler(ISessionRepository sessionRepository, IExecutionContext executionContext) - : IRequestHandler> +public sealed class GetUserSessionsHandler( + ISessionRepository sessionRepository, + IUserRepository userRepository, + ITenantRepository tenantRepository, + IExecutionContext executionContext +) : IRequestHandler> { public async Task> Handle(GetUserSessionsQuery query, CancellationToken cancellationToken) { - var userId = executionContext.UserInfo.Id!; + var userEmail = executionContext.UserInfo.Email!; var currentSessionId = executionContext.UserInfo.SessionId; - var sessions = await sessionRepository.GetActiveSessionsForUserAsync(userId, cancellationToken); + var users = await userRepository.GetUsersByEmailUnfilteredAsync(userEmail, cancellationToken); + var userIds = users.Select(u => u.Id).ToArray(); + + var sessions = await sessionRepository.GetActiveSessionsForUsersUnfilteredAsync(userIds, cancellationToken); + + var tenantIds = sessions.Select(s => s.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsAsync(tenantIds, cancellationToken); + var tenantLookup = tenants.ToDictionary(t => t.Id, t => t.Name); var sessionInfos = sessions.Select(s => new UserSessionInfo( s.Id, @@ -40,7 +54,8 @@ public async Task> Handle(GetUserSessionsQuery quer s.UserAgent, s.IpAddress, s.ModifiedAt ?? s.CreatedAt, - currentSessionId is not null && s.Id == currentSessionId + currentSessionId is not null && s.Id == currentSessionId, + tenantLookup.GetValueOrDefault(s.TenantId) ?? string.Empty ) ).ToArray(); diff --git a/application/account-management/Tests/Authentication/GetUserSessionsTests.cs b/application/account-management/Tests/Authentication/GetUserSessionsTests.cs index a35a81b1d..c8fa5c3ef 100644 --- a/application/account-management/Tests/Authentication/GetUserSessionsTests.cs +++ b/application/account-management/Tests/Authentication/GetUserSessionsTests.cs @@ -4,6 +4,7 @@ using PlatformPlatform.AccountManagement.Features.Authentication.Domain; using PlatformPlatform.AccountManagement.Features.Authentication.Queries; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; +using PlatformPlatform.SharedKernel.Domain; using PlatformPlatform.SharedKernel.Tests; using PlatformPlatform.SharedKernel.Tests.Persistence; using Xunit; @@ -75,6 +76,34 @@ public async Task GetUserSessions_ShouldNotReturnOtherUserSessions() responseBody.Sessions.Should().Contain(s => s.Id == DatabaseSeeder.Tenant1OwnerSession.Id); } + [Fact] + public async Task GetUserSessions_ShouldReturnSessionsAcrossAllTenants() + { + // Arrange + var tenant2Name = "Tenant 2"; + var tenant2Id = InsertTenant(tenant2Name); + var user2Id = UserId.NewId(); + InsertUser(tenant2Id, user2Id, DatabaseSeeder.Tenant1Owner.Email); + var tenant2SessionId = InsertSession(tenant2Id, user2Id); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync("/api/account-management/authentication/sessions"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseBody = await response.DeserializeResponse(); + responseBody.Should().NotBeNull(); + responseBody.Sessions.Length.Should().Be(2); + responseBody.Sessions.Should().Contain(s => s.Id == DatabaseSeeder.Tenant1OwnerSession.Id); + responseBody.Sessions.Should().Contain(s => s.Id == new SessionId(tenant2SessionId)); + + var tenant1Session = responseBody.Sessions.Single(s => s.Id == DatabaseSeeder.Tenant1OwnerSession.Id); + tenant1Session.TenantName.Should().Be(DatabaseSeeder.Tenant1.Name); + + var tenant2Session = responseBody.Sessions.Single(s => s.Id == new SessionId(tenant2SessionId)); + tenant2Session.TenantName.Should().Be(tenant2Name); + } + [Fact] public async Task GetUserSessions_ShouldNotReturnRevokedSessions() { @@ -94,6 +123,45 @@ public async Task GetUserSessions_ShouldNotReturnRevokedSessions() responseBody.Sessions.Should().Contain(s => s.Id == DatabaseSeeder.Tenant1OwnerSession.Id); } + private long InsertTenant(string name) + { + var tenantId = TenantId.NewId().Value; + var now = TimeProvider.System.GetUtcNow(); + + Connection.Insert("Tenants", [ + ("Id", tenantId), + ("CreatedAt", now), + ("ModifiedAt", null), + ("Name", name), + ("State", "Active"), + ("Logo", """{"Url":null,"Version":0}""") + ] + ); + + return tenantId; + } + + private void InsertUser(long tenantId, UserId userId, string email) + { + var now = TimeProvider.System.GetUtcNow(); + + Connection.Insert("Users", [ + ("TenantId", tenantId), + ("Id", userId.ToString()), + ("CreatedAt", now), + ("ModifiedAt", null), + ("Email", email), + ("EmailConfirmed", true), + ("FirstName", "Test"), + ("LastName", "User"), + ("Title", null), + ("Avatar", """{"Url":null,"Version":0,"IsGravatar":false}"""), + ("Role", "Owner"), + ("Locale", "en-US") + ] + ); + } + private string InsertSession(long tenantId, string userId, bool isRevoked = false) { var sessionId = SessionId.NewId().ToString(); diff --git a/application/account-management/Tests/Authentication/RevokeSessionTests.cs b/application/account-management/Tests/Authentication/RevokeSessionTests.cs new file mode 100644 index 000000000..695ead843 --- /dev/null +++ b/application/account-management/Tests/Authentication/RevokeSessionTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.AccountManagement.Features.Authentication.Domain; +using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; +using PlatformPlatform.SharedKernel.Tests; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Authentication; + +public sealed class RevokeSessionTests : EndpointBaseTest +{ + [Fact] + public async Task RevokeSession_WhenValid_ShouldRevokeSession() + { + // Arrange + var sessionId = InsertSession(DatabaseSeeder.Tenant1Owner.TenantId, DatabaseSeeder.Tenant1Owner.Id); + + // Act + var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/api/account-management/authentication/sessions/{sessionId}"); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + object[] parameters = [new { id = sessionId }]; + Connection.ExecuteScalar("SELECT RevokedAt FROM Sessions WHERE Id = @id", parameters).Should().NotBeNull(); + Connection.ExecuteScalar("SELECT RevokedReason FROM Sessions WHERE Id = @id", parameters).Should().Be("Revoked"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionRevoked"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.reason"].Should().Be("Revoked"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task RevokeSession_WhenSessionNotFound_ShouldReturnNotFound() + { + // Arrange + var nonExistentSessionId = SessionId.NewId(); + + // Act + var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/api/account-management/authentication/sessions/{nonExistentSessionId}"); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, $"Session with id '{nonExistentSessionId}' not found."); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + } + + [Fact] + public async Task RevokeSession_WhenSessionBelongsToOtherUser_ShouldReturnForbidden() + { + // Arrange + var otherUserSessionId = InsertSession(DatabaseSeeder.Tenant1Member.TenantId, DatabaseSeeder.Tenant1Member.Id); + + // Act + var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/api/account-management/authentication/sessions/{otherUserSessionId}"); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.Forbidden, "You can only revoke your own sessions."); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + } + + [Fact] + public async Task RevokeSession_WhenSessionAlreadyRevoked_ShouldReturnBadRequest() + { + // Arrange + var sessionId = InsertSession(DatabaseSeeder.Tenant1Owner.TenantId, DatabaseSeeder.Tenant1Owner.Id, true); + + // Act + var response = await AuthenticatedOwnerHttpClient.DeleteAsync($"/api/account-management/authentication/sessions/{sessionId}"); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, $"Session with id '{sessionId}' is already revoked."); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + } + + [Fact] + public async Task RevokeSession_WhenNotAuthenticated_ShouldReturnUnauthorized() + { + // Arrange + var sessionId = SessionId.NewId(); + + // Act + var response = await AnonymousHttpClient.DeleteAsync($"/api/account-management/authentication/sessions/{sessionId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + private string InsertSession(long tenantId, string userId, bool isRevoked = false) + { + var sessionId = SessionId.NewId().ToString(); + var jti = RefreshTokenJti.NewId().ToString(); + var now = TimeProvider.System.GetUtcNow(); + + Connection.Insert("Sessions", [ + ("TenantId", tenantId), + ("Id", sessionId), + ("UserId", userId), + ("CreatedAt", now), + ("ModifiedAt", null), + ("RefreshTokenJti", jti), + ("PreviousRefreshTokenJti", null), + ("RefreshTokenVersion", 1), + ("DeviceType", nameof(DeviceType.Desktop)), + ("UserAgent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), + ("IpAddress", "127.0.0.1"), + ("RevokedAt", isRevoked ? now : null), + ("RevokedReason", null) + ] + ); + + return sessionId; + } +} diff --git a/application/account-management/Tests/Authentication/SwitchTenantTests.cs b/application/account-management/Tests/Authentication/SwitchTenantTests.cs index 2aa512dc3..cd1e920f1 100644 --- a/application/account-management/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account-management/Tests/Authentication/SwitchTenantTests.cs @@ -61,12 +61,20 @@ public async Task SwitchTenant_WhenUserExistsInTargetTenant_ShouldSwitchSuccessf response.Headers.Count(h => h.Key == "x-refresh-token").Should().Be(1); response.Headers.Count(h => h.Key == "x-access-token").Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); - TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionCreated"); - TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("TenantSwitched"); - TelemetryEventsCollectorSpy.CollectedEvents[1].Properties["event.from_tenant_id"].Should().Be(DatabaseSeeder.Tenant1.Id.ToString()); - TelemetryEventsCollectorSpy.CollectedEvents[1].Properties["event.to_tenant_id"].Should().Be(tenant2Id.ToString()); - TelemetryEventsCollectorSpy.CollectedEvents[1].Properties["event.user_id"].Should().Be(user2Id.ToString()); + var oldSessionRevokedReason = Connection.ExecuteScalar( + "SELECT RevokedReason FROM Sessions WHERE Id = @Id", + [new { Id = DatabaseSeeder.Tenant1MemberSession.Id.ToString() }] + ); + oldSessionRevokedReason.Should().Be("SwitchTenant"); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(3); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("SessionRevoked"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.reason"].Should().Be("SwitchTenant"); + TelemetryEventsCollectorSpy.CollectedEvents[1].GetType().Name.Should().Be("SessionCreated"); + TelemetryEventsCollectorSpy.CollectedEvents[2].GetType().Name.Should().Be("TenantSwitched"); + TelemetryEventsCollectorSpy.CollectedEvents[2].Properties["event.from_tenant_id"].Should().Be(DatabaseSeeder.Tenant1.Id.ToString()); + TelemetryEventsCollectorSpy.CollectedEvents[2].Properties["event.to_tenant_id"].Should().Be(tenant2Id.ToString()); + TelemetryEventsCollectorSpy.CollectedEvents[2].Properties["event.user_id"].Should().Be(user2Id.ToString()); } [Fact] @@ -287,7 +295,7 @@ public async Task SwitchTenant_WhenAcceptingInvite_ShouldCopyProfileData() } [Fact] - public async Task SwitchTenant_RapidSwitching_ShouldHandleCorrectly() + public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorized() { // Arrange var tenant2Id = TenantId.NewId(); @@ -319,24 +327,20 @@ public async Task SwitchTenant_RapidSwitching_ShouldHandleCorrectly() ] ); - // Act + // First switch succeeds and revokes the current session var response1 = await AuthenticatedMemberHttpClient.PostAsJsonAsync( "/api/account-management/authentication/switch-tenant", new SwitchTenantCommand(tenant2Id) ); + await response1.ShouldBeSuccessfulPostRequest(hasLocation: false); + TelemetryEventsCollectorSpy.Reset(); + + // Act - Attempt to switch again with the same (now revoked) session var response2 = await AuthenticatedMemberHttpClient.PostAsJsonAsync( "/api/account-management/authentication/switch-tenant", new SwitchTenantCommand(DatabaseSeeder.Tenant1.Id) ); - var response3 = await AuthenticatedMemberHttpClient.PostAsJsonAsync( - "/api/account-management/authentication/switch-tenant", new SwitchTenantCommand(tenant2Id) - ); // Assert - await response1.ShouldBeSuccessfulPostRequest(hasLocation: false); - await response2.ShouldBeSuccessfulPostRequest(hasLocation: false); - await response3.ShouldBeSuccessfulPostRequest(hasLocation: false); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(6); - TelemetryEventsCollectorSpy.CollectedEvents.Where(e => e.GetType().Name == "SessionCreated").Should().HaveCount(3); - TelemetryEventsCollectorSpy.CollectedEvents.Where(e => e.GetType().Name == "TenantSwitched").Should().HaveCount(3); + await response2.ShouldHaveErrorStatusCode(HttpStatusCode.Unauthorized, "Session has been revoked."); + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); } } diff --git a/application/account-management/WebApp/federated-modules/common/SessionsModal.tsx b/application/account-management/WebApp/federated-modules/common/SessionsModal.tsx new file mode 100644 index 000000000..daaf70c32 --- /dev/null +++ b/application/account-management/WebApp/federated-modules/common/SessionsModal.tsx @@ -0,0 +1,272 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { AlertDialog } from "@repo/ui/components/AlertDialog"; +import { Badge } from "@repo/ui/components/Badge"; +import { Button } from "@repo/ui/components/Button"; +import { Dialog } from "@repo/ui/components/Dialog"; +import { DialogContent, DialogFooter, DialogHeader } from "@repo/ui/components/DialogFooter"; +import { Heading } from "@repo/ui/components/Heading"; +import { Modal } from "@repo/ui/components/Modal"; +import { toastQueue } from "@repo/ui/components/Toast"; +import { formatDate } from "@repo/utils/date/formatDate"; +import { useQueryClient } from "@tanstack/react-query"; +import { InfoIcon, LaptopIcon, LoaderIcon, MonitorIcon, SmartphoneIcon, TabletIcon, XIcon } from "lucide-react"; +import { useState } from "react"; +import { SmartDate } from "@/shared/components/SmartDate"; +import { api, type components, DeviceType } from "@/shared/lib/api/client"; + +type UserSessionInfo = components["schemas"]["UserSessionInfo"]; + +type SessionsModalProps = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +function getDeviceIcon(deviceType: UserSessionInfo["deviceType"]) { + switch (deviceType) { + case DeviceType.Mobile: + return