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" };
|
var memberRole = new IdentityRole("Member") { Id = "role-member", NormalizedName = "MEMBER" };
|
||||||
db.Roles.AddRange(adminRole, memberRole);
|
db.Roles.AddRange(adminRole, memberRole);
|
||||||
var usersToAdd = Enumerable.Range(1, 75).Select(index => User(index == 1 ? "admin-1" : $"user-{index}")).ToList();
|
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.Users.AddRange(usersToAdd);
|
||||||
db.UserRoles.Add(new IdentityUserRole<string> { UserId = "admin-1", RoleId = adminRole.Id });
|
db.UserRoles.Add(new IdentityUserRole<string> { UserId = "admin-1", RoleId = adminRole.Id });
|
||||||
foreach (var user in usersToAdd.Skip(1))
|
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);
|
var rows = Assert.IsType<List<UsersController.UserDto>>(Assert.IsType<OkObjectResult>(action.Result).Value);
|
||||||
Assert.Equal(75, rows.Count);
|
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);
|
Assert.Equal(2, counter.ReaderCount);
|
||||||
manager.Verify(userManager => userManager.GetRolesAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
manager.Verify(userManager => userManager.GetRolesAsync(It.IsAny<ApplicationUser>()), Times.Never);
|
||||||
manager.Verify(userManager => userManager.GetUsersInRoleAsync(It.IsAny<string>()), Times.Never);
|
manager.Verify(userManager => userManager.GetUsersInRoleAsync(It.IsAny<string>()), Times.Never);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public sealed class UsersController : ControllerBase
|
|||||||
string? LastName,
|
string? LastName,
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
bool EmailConfirmed,
|
bool EmailConfirmed,
|
||||||
|
bool TwoFactorEnabled,
|
||||||
string? GoogleEmail,
|
string? GoogleEmail,
|
||||||
DateTimeOffset? GoogleLinkedAt,
|
DateTimeOffset? GoogleLinkedAt,
|
||||||
List<string> Roles,
|
List<string> Roles,
|
||||||
@@ -292,6 +293,7 @@ public sealed class UsersController : ControllerBase
|
|||||||
user.LastName,
|
user.LastName,
|
||||||
user.DisplayName,
|
user.DisplayName,
|
||||||
user.EmailConfirmed,
|
user.EmailConfirmed,
|
||||||
|
user.TwoFactorEnabled,
|
||||||
user.GoogleEmail,
|
user.GoogleEmail,
|
||||||
user.GoogleLinkedAt,
|
user.GoogleLinkedAt,
|
||||||
roles,
|
roles,
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ test.each([['en', 'Rows per page:'], ['nb', 'Rader per side:']])('desktop user-g
|
|||||||
expect(mockedApi.get).toHaveBeenCalledTimes(1);
|
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 () => {
|
test('failed users read stays unavailable and Retry only reads', async () => {
|
||||||
mockedApi.get.mockRejectedValueOnce(new Error('offline')); renderPage([]);
|
mockedApi.get.mockRejectedValueOnce(new Error('offline')); renderPage([]);
|
||||||
const retry=await screen.findByRole('button', { name: 'Retry' });
|
const retry=await screen.findByRole('button', { name: 'Retry' });
|
||||||
|
|||||||
@@ -2226,6 +2226,10 @@ export const translations = {
|
|||||||
adminUsersDelete: "Delete",
|
adminUsersDelete: "Delete",
|
||||||
adminUsersRolesLabel: "Roles",
|
adminUsersRolesLabel: "Roles",
|
||||||
adminUsersConfirmed: "Confirmed",
|
adminUsersConfirmed: "Confirmed",
|
||||||
|
adminUsersAccountStatus: "Account status",
|
||||||
|
adminUsers2faEnabled: "2FA enabled",
|
||||||
|
adminUsers2faDisabled: "2FA disabled",
|
||||||
|
adminUsers2faUnavailable: "2FA status unavailable",
|
||||||
adminUsersActions: "Actions",
|
adminUsersActions: "Actions",
|
||||||
adminUsersNoUsers: "No users.",
|
adminUsersNoUsers: "No users.",
|
||||||
adminUsersRolesUpdated: "Roles updated.",
|
adminUsersRolesUpdated: "Roles updated.",
|
||||||
@@ -5452,6 +5456,10 @@ export const translations = {
|
|||||||
adminUsersDelete: "Slett",
|
adminUsersDelete: "Slett",
|
||||||
adminUsersRolesLabel: "Roller",
|
adminUsersRolesLabel: "Roller",
|
||||||
adminUsersConfirmed: "Bekreftet",
|
adminUsersConfirmed: "Bekreftet",
|
||||||
|
adminUsersAccountStatus: "Kontostatus",
|
||||||
|
adminUsers2faEnabled: "2FA aktivert",
|
||||||
|
adminUsers2faDisabled: "2FA deaktivert",
|
||||||
|
adminUsers2faUnavailable: "2FA-status utilgjengelig",
|
||||||
adminUsersActions: "Handlinger",
|
adminUsersActions: "Handlinger",
|
||||||
adminUsersNoUsers: "Ingen brukere.",
|
adminUsersNoUsers: "Ingen brukere.",
|
||||||
adminUsersRolesUpdated: "Roller oppdatert.",
|
adminUsersRolesUpdated: "Roller oppdatert.",
|
||||||
|
|||||||
@@ -31,11 +31,24 @@ type UserDto = {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
emailConfirmed: boolean;
|
emailConfirmed: boolean;
|
||||||
|
twoFactorEnabled?: boolean;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
isCurrentUser: boolean;
|
isCurrentUser: boolean;
|
||||||
canRemoveAdmin: 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() {
|
export default function AdminUsersPage() {
|
||||||
const isMobile = useMediaQuery("(max-width:767.95px)");
|
const isMobile = useMediaQuery("(max-width:767.95px)");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -183,11 +196,9 @@ export default function AdminUsersPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "emailConfirmed",
|
field: "emailConfirmed",
|
||||||
headerName: t("adminUsersConfirmed"),
|
headerName: t("adminUsersAccountStatus"),
|
||||||
width: 130,
|
width: 155,
|
||||||
renderCell: (params) => (
|
renderCell: (params) => <UserStatus user={params.row.raw as UserDto} />,
|
||||||
<Chip size="small" label={params.value ? t("yes") : t("noWord")} color={params.value ? "success" : "default"} variant={params.value ? "filled" : "outlined"} />
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: "actions",
|
field: "actions",
|
||||||
@@ -271,8 +282,8 @@ export default function AdminUsersPage() {
|
|||||||
{(row.roles as string[]).length ? (row.roles as string[]).map((role) => (
|
{(row.roles as string[]).length ? (row.roles as string[]).map((role) => (
|
||||||
<Chip key={role} size="small" label={role} variant="outlined" />
|
<Chip key={role} size="small" label={role} variant="outlined" />
|
||||||
)) : <Chip size="small" label="—" 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>
|
</Box>
|
||||||
|
<UserStatus user={user} />
|
||||||
|
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<Tooltip describeChild title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
|
<Tooltip describeChild title={isAdmin && !user.canRemoveAdmin ? t("adminUsersLastAdminHelp") : ""}>
|
||||||
|
|||||||
Reference in New Issue
Block a user