From e22f2d6450e6d1d52c3b9e5356e1fdb90422a790 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 10 Nov 2026 12:45:11 +0100 Subject: [PATCH] feat(admin): show read-only account 2FA status --- JobTrackerApi.Tests/UsersControllerTests.cs | 3 +++ JobTrackerApi/Controllers/UsersController.cs | 2 ++ job-tracker-ui/src/admin-users-page.test.tsx | 15 +++++++++++++ job-tracker-ui/src/i18n/translations.ts | 8 +++++++ job-tracker-ui/src/views/AdminUsersPage.tsx | 23 +++++++++++++++----- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/JobTrackerApi.Tests/UsersControllerTests.cs b/JobTrackerApi.Tests/UsersControllerTests.cs index 168b2d01..5977ff29 100644 --- a/JobTrackerApi.Tests/UsersControllerTests.cs +++ b/JobTrackerApi.Tests/UsersControllerTests.cs @@ -170,6 +170,7 @@ public sealed class UsersControllerTests var memberRole = new IdentityRole("Member") { Id = "role-member", NormalizedName = "MEMBER" }; db.Roles.AddRange(adminRole, memberRole); var usersToAdd = Enumerable.Range(1, 75).Select(index => User(index == 1 ? "admin-1" : $"user-{index}")).ToList(); + usersToAdd[0].TwoFactorEnabled = true; db.Users.AddRange(usersToAdd); db.UserRoles.Add(new IdentityUserRole { UserId = "admin-1", RoleId = adminRole.Id }); foreach (var user in usersToAdd.Skip(1)) @@ -183,6 +184,8 @@ public sealed class UsersControllerTests var rows = Assert.IsType>(Assert.IsType(action.Result).Value); Assert.Equal(75, rows.Count); + Assert.True(Assert.Single(rows, row => row.Id == "admin-1").TwoFactorEnabled); + Assert.False(Assert.Single(rows, row => row.Id == "user-2").TwoFactorEnabled); Assert.Equal(2, counter.ReaderCount); manager.Verify(userManager => userManager.GetRolesAsync(It.IsAny()), Times.Never); manager.Verify(userManager => userManager.GetUsersInRoleAsync(It.IsAny()), Times.Never); diff --git a/JobTrackerApi/Controllers/UsersController.cs b/JobTrackerApi/Controllers/UsersController.cs index c7906add..f3fc418e 100644 --- a/JobTrackerApi/Controllers/UsersController.cs +++ b/JobTrackerApi/Controllers/UsersController.cs @@ -41,6 +41,7 @@ public sealed class UsersController : ControllerBase string? LastName, string? DisplayName, bool EmailConfirmed, + bool TwoFactorEnabled, string? GoogleEmail, DateTimeOffset? GoogleLinkedAt, List Roles, @@ -292,6 +293,7 @@ public sealed class UsersController : ControllerBase user.LastName, user.DisplayName, user.EmailConfirmed, + user.TwoFactorEnabled, user.GoogleEmail, user.GoogleLinkedAt, roles, diff --git a/job-tracker-ui/src/admin-users-page.test.tsx b/job-tracker-ui/src/admin-users-page.test.tsx index 13587088..e1358f48 100644 --- a/job-tracker-ui/src/admin-users-page.test.tsx +++ b/job-tracker-ui/src/admin-users-page.test.tsx @@ -64,6 +64,21 @@ test.each([['en', 'Rows per page:'], ['nb', 'Rader per side:']])('desktop user-g expect(mockedApi.get).toHaveBeenCalledTimes(1); }); +test.each([true, false])('reports 2FA state without mistaking an older response for disabled (mobile %s)', async mobile => { + (window.matchMedia as jest.Mock).mockImplementation(() => ({ matches: mobile, addEventListener: jest.fn(), removeEventListener: jest.fn(), addListener: jest.fn(), removeListener: jest.fn() })); + const common = { roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }; + renderPage([ + { ...common, id: 'enabled', email: 'enabled@example.test', twoFactorEnabled: true }, + { ...common, id: 'disabled', email: 'disabled@example.test', twoFactorEnabled: false }, + { ...common, id: 'unknown', email: 'unknown@example.test' }, + ]); + expect(await screen.findByText('2FA enabled')).toBeVisible(); + expect(screen.getByText('2FA disabled')).toBeVisible(); + expect(screen.getByText('2FA status unavailable')).toBeVisible(); + expect(mockedApi.get).toHaveBeenCalledWith('/users'); + expect(mockedApi.post).not.toHaveBeenCalled(); +}); + test('failed users read stays unavailable and Retry only reads', async () => { mockedApi.get.mockRejectedValueOnce(new Error('offline')); renderPage([]); const retry=await screen.findByRole('button', { name: 'Retry' }); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 493c9a28..cc98a4fa 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -2226,6 +2226,10 @@ export const translations = { adminUsersDelete: "Delete", adminUsersRolesLabel: "Roles", adminUsersConfirmed: "Confirmed", + adminUsersAccountStatus: "Account status", + adminUsers2faEnabled: "2FA enabled", + adminUsers2faDisabled: "2FA disabled", + adminUsers2faUnavailable: "2FA status unavailable", adminUsersActions: "Actions", adminUsersNoUsers: "No users.", adminUsersRolesUpdated: "Roles updated.", @@ -5452,6 +5456,10 @@ export const translations = { adminUsersDelete: "Slett", adminUsersRolesLabel: "Roller", adminUsersConfirmed: "Bekreftet", + adminUsersAccountStatus: "Kontostatus", + adminUsers2faEnabled: "2FA aktivert", + adminUsers2faDisabled: "2FA deaktivert", + adminUsers2faUnavailable: "2FA-status utilgjengelig", adminUsersActions: "Handlinger", adminUsersNoUsers: "Ingen brukere.", adminUsersRolesUpdated: "Roller oppdatert.", diff --git a/job-tracker-ui/src/views/AdminUsersPage.tsx b/job-tracker-ui/src/views/AdminUsersPage.tsx index 399040bf..a3d084ac 100644 --- a/job-tracker-ui/src/views/AdminUsersPage.tsx +++ b/job-tracker-ui/src/views/AdminUsersPage.tsx @@ -31,11 +31,24 @@ type UserDto = { email?: string | null; userName?: string | null; emailConfirmed: boolean; + twoFactorEnabled?: boolean; roles: string[]; isCurrentUser: boolean; canRemoveAdmin: boolean; }; +function UserStatus({ user }: { user: UserDto }) { + const { t } = useI18n(); + return ( + + + + {user.twoFactorEnabled === true ? t("adminUsers2faEnabled") : user.twoFactorEnabled === false ? t("adminUsers2faDisabled") : t("adminUsers2faUnavailable")} + + + ); +} + export default function AdminUsersPage() { const isMobile = useMediaQuery("(max-width:767.95px)"); const { toast } = useToast(); @@ -183,11 +196,9 @@ export default function AdminUsersPage() { }, { field: "emailConfirmed", - headerName: t("adminUsersConfirmed"), - width: 130, - renderCell: (params) => ( - - ), + headerName: t("adminUsersAccountStatus"), + width: 155, + renderCell: (params) => , }, { field: "actions", @@ -271,8 +282,8 @@ export default function AdminUsersPage() { {(row.roles as string[]).length ? (row.roles as string[]).map((role) => ( )) : } - +