feat(admin): show read-only account 2FA status

This commit is contained in:
cesnimda
2026-11-10 12:45:11 +01:00
parent 13c9c65352
commit e22f2d6450
5 changed files with 45 additions and 6 deletions
@@ -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<string> { 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<List<UsersController.UserDto>>(Assert.IsType<OkObjectResult>(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<ApplicationUser>()), Times.Never);
manager.Verify(userManager => userManager.GetUsersInRoleAsync(It.IsAny<string>()), Times.Never);
@@ -41,6 +41,7 @@ public sealed class UsersController : ControllerBase
string? LastName,
string? DisplayName,
bool EmailConfirmed,
bool TwoFactorEnabled,
string? GoogleEmail,
DateTimeOffset? GoogleLinkedAt,
List<string> Roles,
@@ -292,6 +293,7 @@ public sealed class UsersController : ControllerBase
user.LastName,
user.DisplayName,
user.EmailConfirmed,
user.TwoFactorEnabled,
user.GoogleEmail,
user.GoogleLinkedAt,
roles,
@@ -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' });
+8
View File
@@ -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.",
+17 -6
View File
@@ -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 (
<Stack spacing={0.5} sx={{ py: 0.5, alignItems: "flex-start" }}>
<Chip size="small" label={`${t("adminUsersConfirmed")}: ${user.emailConfirmed ? t("yes") : t("noWord")}`} color={user.emailConfirmed ? "success" : "default"} variant={user.emailConfirmed ? "filled" : "outlined"} />
<Typography variant="body2" color="text.secondary">
{user.twoFactorEnabled === true ? t("adminUsers2faEnabled") : user.twoFactorEnabled === false ? t("adminUsers2faDisabled") : t("adminUsers2faUnavailable")}
</Typography>
</Stack>
);
}
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) => (
<Chip size="small" label={params.value ? t("yes") : t("noWord")} color={params.value ? "success" : "default"} variant={params.value ? "filled" : "outlined"} />
),
headerName: t("adminUsersAccountStatus"),
width: 155,
renderCell: (params) => <UserStatus user={params.row.raw as UserDto} />,
},
{
field: "actions",
@@ -271,8 +282,8 @@ export default function AdminUsersPage() {
{(row.roles as string[]).length ? (row.roles as string[]).map((role) => (
<Chip key={role} size="small" label={role} variant="outlined" />
)) : <Chip size="small" label="—" variant="outlined" />}
<Chip size="small" label={row.emailConfirmed ? t("yes") : t("noWord")} color={row.emailConfirmed ? "success" : "default"} variant={row.emailConfirmed ? "filled" : "outlined"} />
</Box>
<UserStatus user={user} />
<Stack spacing={1}>
<Tooltip describeChild title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>