feat(admin): show read-only account 2FA status
This commit is contained in:
@@ -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' });
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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") : ""}>
|
||||
|
||||
Reference in New Issue
Block a user