diff --git a/application/account-management/Core/Database/DataMigrations/20260106160001_PopulateUserLastSeenAt.cs b/application/account-management/Core/Database/DataMigrations/20260106160001_PopulateUserLastSeenAt.cs new file mode 100644 index 000000000..0dd5220ed --- /dev/null +++ b/application/account-management/Core/Database/DataMigrations/20260106160001_PopulateUserLastSeenAt.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.Database; + +namespace PlatformPlatform.AccountManagement.Database.DataMigrations; + +public sealed class PopulateUserLastSeenAt(AccountManagementDbContext dbContext) : IDataMigration +{ + public string Id => "20260106160001_PopulateUserLastSeenAt"; + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + var rowsAffected = await dbContext.Database.ExecuteSqlRawAsync( + """ + UPDATE Users + SET LastSeenAt = COALESCE(ModifiedAt, CreatedAt) + WHERE EmailConfirmed = 1 AND LastSeenAt IS NULL + """, + cancellationToken + ); + + await dbContext.SaveChangesAsync(cancellationToken); + + return $"Updated {rowsAffected} users with LastSeenAt"; + } +} diff --git a/application/account-management/Core/Database/Migrations/20260106160000_AddUserLastSeenAt.cs b/application/account-management/Core/Database/Migrations/20260106160000_AddUserLastSeenAt.cs new file mode 100644 index 000000000..2aa9dc896 --- /dev/null +++ b/application/account-management/Core/Database/Migrations/20260106160000_AddUserLastSeenAt.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using PlatformPlatform.AccountManagement.Database; + +namespace PlatformPlatform.AccountManagement.Database.Migrations; + +[DbContext(typeof(AccountManagementDbContext))] +[Migration("20260106160000_AddUserLastSeenAt")] +public sealed class AddUserLastSeenAt : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn("LastSeenAt", "Users", "datetimeoffset", nullable: true); + } +} diff --git a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs b/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs index d4f6ecf6f..cfa555f5f 100644 --- a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs +++ b/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs @@ -98,6 +98,9 @@ public async Task Handle(CompleteLoginCommand command, CancellationToken var session = Session.Create(user.TenantId, user.Id, userAgent, ipAddress); await sessionRepository.AddAsync(session, cancellationToken); + user.UpdateLastSeen(timeProvider.GetUtcNow()); + userRepository.Update(user); + var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken, session.Id); authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti); diff --git a/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs b/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs index 5fe9801f4..8c828539a 100644 --- a/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs +++ b/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs @@ -109,6 +109,9 @@ public async Task Handle(RefreshAuthenticationTokensCommand command, Can { session.Refresh(); sessionRepository.Update(session); + + user.UpdateLastSeen(now); + userRepository.Update(user); } var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken, session.Id); diff --git a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs index 19f322c10..a8267caee 100644 --- a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs +++ b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs @@ -72,6 +72,9 @@ public async Task Handle(SwitchTenantCommand command, CancellationToken var session = Session.Create(targetUser.TenantId, targetUser.Id, userAgent, ipAddress); await sessionRepository.AddAsync(session, cancellationToken); + targetUser.UpdateLastSeen(timeProvider.GetUtcNow()); + userRepository.Update(targetUser); + var userInfo = await userInfoFactory.CreateUserInfoAsync(targetUser, cancellationToken, session.Id); authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti, currentSession.ExpiresAt); diff --git a/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs b/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs index 9da9bad9c..e45693733 100644 --- a/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs +++ b/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs @@ -28,7 +28,8 @@ public sealed class CompleteSignupHandler( IHttpContextAccessor httpContextAccessor, IExecutionContext executionContext, IMediator mediator, - ITelemetryEventsCollector events + ITelemetryEventsCollector events, + TimeProvider timeProvider ) : IRequestHandler { public async Task Handle(CompleteSignupCommand command, CancellationToken cancellationToken) @@ -55,6 +56,9 @@ public async Task Handle(CompleteSignupCommand command, CancellationToke var session = Session.Create(user!.TenantId, user.Id, userAgent, ipAddress); await sessionRepository.AddAsync(session, cancellationToken); + user.UpdateLastSeen(timeProvider.GetUtcNow()); + userRepository.Update(user); + var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken, session.Id); authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo, session.Id, session.RefreshTokenJti); diff --git a/application/account-management/Core/Features/Users/Domain/User.cs b/application/account-management/Core/Features/Users/Domain/User.cs index 678f57ac3..4bc05b419 100644 --- a/application/account-management/Core/Features/Users/Domain/User.cs +++ b/application/account-management/Core/Features/Users/Domain/User.cs @@ -39,6 +39,8 @@ public string Email public bool IsInternalUser => Email.EndsWith(Settings.Current.Identity.InternalEmailDomain, StringComparison.OrdinalIgnoreCase); + public DateTimeOffset? LastSeenAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } [NotMapped] @@ -102,6 +104,11 @@ public void ChangeLocale(string locale) { Locale = locale; } + + public void UpdateLastSeen(DateTimeOffset lastSeenAt) + { + LastSeenAt = lastSeenAt; + } } public sealed record Avatar(string? Url = null, int Version = 0, bool IsGravatar = false); diff --git a/application/account-management/Core/Features/Users/Domain/UserRepository.cs b/application/account-management/Core/Features/Users/Domain/UserRepository.cs index f4866e6c2..29d63f1d3 100644 --- a/application/account-management/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account-management/Core/Features/Users/Domain/UserRepository.cs @@ -1,3 +1,4 @@ +using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; using PlatformPlatform.AccountManagement.Database; using PlatformPlatform.SharedKernel.Domain; @@ -115,20 +116,23 @@ public async Task GetDeletedByIdsAsync(UserId[] ids, CancellationToken c public async Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserSummaryAsync(CancellationToken cancellationToken) { - var thirtyDaysAgo = timeProvider.GetUtcNow().AddDays(-30); - - var summary = await DbSet - .GroupBy(_ => 1) // Group all records into a single group to calculate multiple COUNT aggregates in one query - .Select(g => new - { - TotalUsers = g.Count(), - ActiveUsers = g.Count(u => u.EmailConfirmed && u.ModifiedAt >= thirtyDaysAgo), - PendingUsers = g.Count(u => !u.EmailConfirmed) - } - ) + var thirtyDaysAgo = timeProvider.GetUtcNow().AddDays(-30).ToString("O"); + var tenantId = executionContext.TenantId!.Value.ToString(); + + var sql = """ + SELECT + COUNT(*) AS TotalUsers, + SUM(CASE WHEN EmailConfirmed = 1 AND LastSeenAt >= {0} THEN 1 ELSE 0 END) AS ActiveUsers, + SUM(CASE WHEN EmailConfirmed = 0 THEN 1 ELSE 0 END) AS PendingUsers + FROM Users + WHERE TenantId = {1} AND DeletedAt IS NULL + """; + + var result = await accountManagementDbContext.Database + .SqlQueryRaw(sql, thirtyDaysAgo, tenantId) .SingleAsync(cancellationToken); - return (summary.TotalUsers, summary.ActiveUsers, summary.PendingUsers); + return (result.TotalUsers, result.ActiveUsers, result.PendingUsers); } public async Task<(User[] Users, int TotalItems, int TotalPages)> Search( @@ -182,9 +186,9 @@ CancellationToken cancellationToken SortableUserProperties.CreatedAt => sortOrder == SortOrder.Ascending ? users.OrderBy(u => u.CreatedAt) : users.OrderByDescending(u => u.CreatedAt), - SortableUserProperties.ModifiedAt => sortOrder == SortOrder.Ascending - ? users.OrderBy(u => u.ModifiedAt) - : users.OrderByDescending(u => u.ModifiedAt), + SortableUserProperties.LastSeenAt => sortOrder == SortOrder.Ascending + ? users.OrderBy(u => u.LastSeenAt) + : users.OrderByDescending(u => u.LastSeenAt), SortableUserProperties.Name => sortOrder == SortOrder.Ascending ? users.OrderBy(u => u.FirstName == null ? 1 : 0) .ThenBy(u => u.FirstName) @@ -239,4 +243,7 @@ public async Task GetUsersByEmailUnfilteredAsync(string email, Cancellat .Where(u => u.Email == email.ToLowerInvariant()) .ToArrayAsync(cancellationToken); } + + [UsedImplicitly] + private sealed record UserSummaryResult(int TotalUsers, int ActiveUsers, int PendingUsers); } diff --git a/application/account-management/Core/Features/Users/Domain/UserTypes.cs b/application/account-management/Core/Features/Users/Domain/UserTypes.cs index a8780dc47..4a341bfd5 100644 --- a/application/account-management/Core/Features/Users/Domain/UserTypes.cs +++ b/application/account-management/Core/Features/Users/Domain/UserTypes.cs @@ -23,7 +23,7 @@ public enum UserStatus public enum SortableUserProperties { CreatedAt, - ModifiedAt, + LastSeenAt, Name, Email, Role diff --git a/application/account-management/Core/Features/Users/Queries/GetUsers.cs b/application/account-management/Core/Features/Users/Queries/GetUsers.cs index b33fb4ba6..6c4267581 100644 --- a/application/account-management/Core/Features/Users/Queries/GetUsers.cs +++ b/application/account-management/Core/Features/Users/Queries/GetUsers.cs @@ -29,6 +29,7 @@ public sealed record UserDetails( UserId Id, DateTimeOffset CreatedAt, DateTimeOffset? ModifiedAt, + DateTimeOffset? LastSeenAt, string Email, UserRole Role, string FirstName, diff --git a/application/account-management/Tests/Users/GetUserSummaryTests.cs b/application/account-management/Tests/Users/GetUserSummaryTests.cs new file mode 100644 index 000000000..90c0ce75b --- /dev/null +++ b/application/account-management/Tests/Users/GetUserSummaryTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Queries; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.Tests; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Users; + +public sealed class GetUserSummaryTests : EndpointBaseTest +{ + [Fact] + public async Task GetUserSummary_WhenUsersHaveVariousLastSeenDates_ShouldCountActiveUsersCorrectly() + { + // Arrange + var now = TimeProvider.GetUtcNow(); + var thirtyOneDaysAgo = now.AddDays(-31); + + // Set the seeded owner user as active (LastSeenAt within 30 days) + Connection.Update("Users", "Id", DatabaseSeeder.Tenant1Owner.Id.ToString(), [("LastSeenAt", now)]); + + // Insert an active user (LastSeenAt within 30 days, confirmed) + Connection.Insert("Users", [ + ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), + ("Id", UserId.NewId().ToString()), + ("CreatedAt", now.AddMinutes(-10)), + ("ModifiedAt", null), + ("Email", "active@example.com"), + ("FirstName", "Active"), + ("LastName", "User"), + ("Title", null), + ("Role", nameof(UserRole.Member)), + ("EmailConfirmed", true), + ("LastSeenAt", now.AddDays(-5)), + ("Avatar", JsonSerializer.Serialize(new Avatar())), + ("Locale", "en-US") + ] + ); + + // Insert an inactive user (LastSeenAt older than 30 days, confirmed) + Connection.Insert("Users", [ + ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), + ("Id", UserId.NewId().ToString()), + ("CreatedAt", now.AddDays(-60)), + ("ModifiedAt", null), + ("Email", "inactive@example.com"), + ("FirstName", "Inactive"), + ("LastName", "User"), + ("Title", null), + ("Role", nameof(UserRole.Member)), + ("EmailConfirmed", true), + ("LastSeenAt", thirtyOneDaysAgo), + ("Avatar", JsonSerializer.Serialize(new Avatar())), + ("Locale", "en-US") + ] + ); + + // Insert a pending user (not confirmed, no LastSeenAt) + Connection.Insert("Users", [ + ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), + ("Id", UserId.NewId().ToString()), + ("CreatedAt", now.AddMinutes(-5)), + ("ModifiedAt", null), + ("Email", "pending@example.com"), + ("FirstName", null), + ("LastName", null), + ("Title", null), + ("Role", nameof(UserRole.Member)), + ("EmailConfirmed", false), + ("LastSeenAt", null), + ("Avatar", JsonSerializer.Serialize(new Avatar())), + ("Locale", "en-US") + ] + ); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync("/api/account-management/users/summary"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var summaryResponse = await response.DeserializeResponse(); + summaryResponse.Should().NotBeNull(); + summaryResponse.TotalUsers.Should().Be(5); + summaryResponse.ActiveUsers.Should().Be(2); + summaryResponse.PendingUsers.Should().Be(1); + } +} diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 79f6e5c5b..ffde2aeec 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -119,9 +119,9 @@ function UserProfileContent({
- Modified + Last seen - {formatDate(user.modifiedAt, true)} + {formatDate(user.lastSeenAt, true)}
diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx index bbd93f84b..2bb4c8a77 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -250,8 +250,8 @@ export function UserTable({ )} {isMediumViewportOrLarger() && ( - - Modified + + Last seen )} {isSmallViewportOrLarger() && ( @@ -304,7 +304,7 @@ export function UserTable({ )} {isMediumViewportOrLarger() && ( - + )} {isSmallViewportOrLarger() && ( diff --git a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json index a06ce7229..92758aeff 100644 --- a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json +++ b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json @@ -1708,6 +1708,11 @@ "format": "date-time", "nullable": true }, + "lastSeenAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, "email": { "type": "string" }, @@ -1763,14 +1768,14 @@ "description": "", "x-enumNames": [ "CreatedAt", - "ModifiedAt", + "LastSeenAt", "Name", "Email", "Role" ], "enum": [ "CreatedAt", - "ModifiedAt", + "LastSeenAt", "Name", "Email", "Role" diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 215e0150e..f9925a1da 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -445,6 +445,9 @@ msgstr "Sidst aktiv:" msgid "Last name" msgstr "Efternavn" +msgid "Last seen" +msgstr "Sidst set" + msgid "Leave" msgstr "Forlad" @@ -508,9 +511,6 @@ msgstr "Microsoft opdaterer og sikrer automatisk al PaaS infrastruktur" msgid "Mobile" msgstr "Mobil" -msgid "Modified" -msgstr "Ændret" - msgid "Modified date" msgstr "Ændret dato" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index c1bd10e02..0b2fe52bb 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -445,6 +445,9 @@ msgstr "Last active:" msgid "Last name" msgstr "Last name" +msgid "Last seen" +msgstr "Last seen" + msgid "Leave" msgstr "Leave" @@ -508,9 +511,6 @@ msgstr "Microsoft patches and secures all PaaS infrastructure automatically" msgid "Mobile" msgstr "Mobile" -msgid "Modified" -msgstr "Modified" - msgid "Modified date" msgstr "Modified date" diff --git a/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts index df161ed09..b025d04da 100644 --- a/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts @@ -462,16 +462,17 @@ test.describe("@smoke", () => { test.describe("@comprehensive", () => { /** - * USER DELETION WORKFLOWS WITH DASHBOARD INTEGRATION + * USER DELETION WORKFLOWS WITH DASHBOARD INTEGRATION AND LASTSEENAT VERIFICATION * * Tests comprehensive user deletion functionality with dashboard context including: * - Dashboard metrics integration (user count displays) * - URL-based filtering (active users link) * - Advanced filtering (role, status combinations) + * - Role change with LastSeenAt preservation verification * - Single user soft deletion via menu actions * - Bulk user selection and bulk deletion * - Owner protection mechanisms (deletion restrictions) - * - Restore user from Recycle bin tab + * - Restore user from Recycle bin tab with LastSeenAt preservation verification * - Permanent delete user via confirmation dialog * - Empty recycle bin functionality */ @@ -738,11 +739,38 @@ test.describe("@comprehensive", () => { await expect(page.locator("tbody").first()).toContainText(user2.email); })(); - // === SOFT DELETION SECTION === - await step("Soft delete user1 via menu & verify removal from All users")(async () => { + // === ROLE CHANGE WITH LASTSEENAT PRESERVATION SECTION === + await step("Change user1 role to Admin & verify LastSeenAt unchanged")(async () => { + const user1Row = page.locator("tbody").first().locator("tr").filter({ hasText: user1.email }); + const user1LastSeenAtBefore = await user1Row.locator("td").nth(3).innerText(); + + const user1ActionsButton = user1Row.locator("button[aria-label='User actions']").first(); + await user1ActionsButton.evaluate((el: HTMLElement) => el.click()); + + await expect(page.getByRole("menu")).toBeVisible(); + await page.getByRole("menuitem", { name: "Change role" }).click(); + + await expect(page.getByRole("dialog", { name: "Change user role" })).toBeVisible(); + await page.getByRole("radio", { name: "Admin" }).check({ force: true }); + await page.getByRole("button", { name: "Save changes" }).click(); + const user1FullName = `${user1.firstName} ${user1.lastName}`; + await expectToastMessage(context, `User role updated successfully for ${user1FullName}`); + await expect(page.getByRole("dialog", { name: "Change user role" })).not.toBeVisible(); + + await expect(user1Row.locator("td").nth(3)).toHaveText(user1LastSeenAtBefore); + await expect(user1Row).toContainText("Admin"); + })(); + + // === SOFT DELETION WITH LASTSEENAT PRESERVATION SECTION === + let user1LastSeenAtBeforeDelete: string; + await step("Capture user1 LastSeenAt & soft delete via menu")(async () => { + const user1FullName = `${user1.firstName} ${user1.lastName}`; const user1Row = page.locator("tbody").first().locator("tr").filter({ hasText: user1.email }); + + user1LastSeenAtBeforeDelete = await user1Row.locator("td").nth(3).innerText(); + const user1ActionsButton = user1Row.locator("button[aria-label='User actions']").first(); await user1ActionsButton.evaluate((el: HTMLElement) => el.click()); await page.getByRole("menuitem", { name: "Delete" }).click(); @@ -845,7 +873,7 @@ test.describe("@comprehensive", () => { await expect(page.getByRole("main").getByText("No deleted users").last()).toBeVisible(); })(); - await step("Navigate to All users tab & verify restored user appears")(async () => { + await step("Navigate to All users & verify restored user LastSeenAt unchanged")(async () => { await page.getByRole("link", { name: "All users" }).click(); await expect(page).toHaveURL("/admin/users"); @@ -853,6 +881,9 @@ test.describe("@comprehensive", () => { await expect(page.locator("tbody").first()).toContainText(owner.email); await expect(page.locator("tbody").first()).toContainText(user1.email); await expect(page.locator("tbody").first()).not.toContainText(user2.email); + + const user1Row = page.locator("tbody").first().locator("tr").filter({ hasText: user1.email }); + await expect(user1Row.locator("td").nth(3)).toHaveText(user1LastSeenAtBeforeDelete); })(); }); });