From 9794ce2686a2aa70cd113afcdbfda8d587d66503 Mon Sep 17 00:00:00 2001 From: dimon1936 <61885400+dimon1936@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:10:55 +0300 Subject: [PATCH] feat(ui): show two-factor status of team members (#11186) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> --- app/Livewire/Team/Member/Index.php | 7 +- resources/css/app.css | 12 ++ .../components/two-factor-badge.blade.php | 25 +++ .../views/livewire/team/member.blade.php | 10 +- .../livewire/team/member/index.blade.php | 23 ++- .../Feature/TeamMemberTwoFactorStatusTest.php | 144 ++++++++++++++++++ 6 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 resources/views/components/two-factor-badge.blade.php create mode 100644 tests/Feature/TeamMemberTwoFactorStatusTest.php diff --git a/app/Livewire/Team/Member/Index.php b/app/Livewire/Team/Member/Index.php index e057ba3f6..33776539a 100644 --- a/app/Livewire/Team/Member/Index.php +++ b/app/Livewire/Team/Member/Index.php @@ -22,6 +22,11 @@ class Index extends Component public function render() { - return view('livewire.team.member.index'); + $members = currentTeam()->members; + + return view('livewire.team.member.index', [ + 'members' => $members, + 'membersWithoutTwoFactorCount' => $members->whereNull('two_factor_confirmed_at')->count(), + ]); } } diff --git a/resources/css/app.css b/resources/css/app.css index 16713718d..67562aa08 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2487,6 +2487,10 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1.55fr) 7rem minmax(10rem, 0.9fr); } +.team-members-table-grid-2fa { + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1.55fr) 7rem 4rem minmax(10rem, 0.9fr); +} + .admin-users-table-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) 5rem; } @@ -2551,6 +2555,10 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(0, 1fr) 7rem 7rem; } + .team-members-table-grid-2fa { + grid-template-columns: minmax(0, 1fr) 7rem 4rem 7rem; + } + .team-members-table-grid > :nth-child(2) { display: none; } @@ -2602,6 +2610,10 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(0, 1fr) 6.5rem; } + .team-members-table-grid-2fa { + grid-template-columns: minmax(0, 1fr) 4rem 6.5rem; + } + .team-members-table-grid > :nth-child(3) { display: none; } diff --git a/resources/views/components/two-factor-badge.blade.php b/resources/views/components/two-factor-badge.blade.php new file mode 100644 index 000000000..e43201c14 --- /dev/null +++ b/resources/views/components/two-factor-badge.blade.php @@ -0,0 +1,25 @@ +@props([ + 'enabled' => false, +]) + +class([ + 'inline-flex items-center', + 'text-green-600 dark:text-green-400' => $enabled, + 'text-neutral-400 dark:text-fg-faint' => ! $enabled, + ]) }}> + @if ($enabled) + + @else + + @endif + Two-factor authentication is {{ $enabled ? 'enabled' : 'disabled' }} + diff --git a/resources/views/livewire/team/member.blade.php b/resources/views/livewire/team/member.blade.php index f7ef26f4c..724256e90 100644 --- a/resources/views/livewire/team/member.blade.php +++ b/resources/views/livewire/team/member.blade.php @@ -1,7 +1,10 @@
+ @class([ + 'data-table-row team-members-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]', + 'team-members-table-grid-2fa' => auth()->user()?->can('manageMembers', currentTeam()), + ])>
+ @can('manageMembers', currentTeam()) +
+ +
+ @endcan
@can('manageMembers', currentTeam()) @if ($member->id !== Auth::id()) diff --git a/resources/views/livewire/team/member/index.blade.php b/resources/views/livewire/team/member/index.blade.php index cb32f4517..b2ad88710 100644 --- a/resources/views/livewire/team/member/index.blade.php +++ b/resources/views/livewire/team/member/index.blade.php @@ -4,7 +4,7 @@ sortBy: 'name_asc', page: 1, perPage: 10, - members: @js(currentTeam()->members->map(fn ($member) => [ + members: @js($members->map(fn ($member) => [ 'id' => $member->id, 'name' => $member->name, 'email' => $member->email, @@ -95,14 +95,31 @@
+ @can('manageMembers', currentTeam()) +
+ @if ($membersWithoutTwoFactorCount > 0) + {{ $membersWithoutTwoFactorCount }} of {{ $members->count() }} {{ Str::plural('member', $members->count()) }} {{ $membersWithoutTwoFactorCount === 1 ? 'does' : 'do' }} not have two-factor authentication enabled. + @else + All members have two-factor authentication enabled. + @endif +
+ @endcan +
-
+
auth()->user()?->can('manageMembers', currentTeam()), + ])> Name Email Role + @can('manageMembers', currentTeam()) + 2FA + @endcan Actions
- @foreach (currentTeam()->members as $member) + @foreach ($members as $member) @endforeach
diff --git a/tests/Feature/TeamMemberTwoFactorStatusTest.php b/tests/Feature/TeamMemberTwoFactorStatusTest.php new file mode 100644 index 000000000..0caaf4207 --- /dev/null +++ b/tests/Feature/TeamMemberTwoFactorStatusTest.php @@ -0,0 +1,144 @@ + InstanceSettings::query()->create([ + 'id' => 0, + ])); + + $this->team = Team::factory()->create(); +}); + +function createTeamMember(Team $team, string $role, bool $twoFactorEnabled = false): User +{ + $user = User::factory()->create([ + 'name' => fake()->unique()->userName(), + 'two_factor_confirmed_at' => $twoFactorEnabled ? now() : null, + ]); + + $team->members()->attach($user->id, ['role' => $role]); + + return $user; +} + +function actAsTeamMember(User $user, Team $team): void +{ + test()->actingAs($user); + session(['currentTeam' => $team]); +} + +test('admins see the two factor column with the status of every member', function () { + $admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true); + createTeamMember($this->team, 'member'); + + actAsTeamMember($admin, $this->team); + + Livewire::test(Index::class) + ->assertSee('2FA') + ->assertSee('Two-factor authentication is enabled') + ->assertSee('Two-factor authentication is disabled'); +}); + +test('owners see the two factor column and summary', function () { + $owner = createTeamMember($this->team, 'owner', twoFactorEnabled: true); + createTeamMember($this->team, 'member'); + + actAsTeamMember($owner, $this->team); + + Livewire::test(Index::class) + ->assertSee('2FA') + ->assertSee('1 of 2 members does not have two-factor authentication enabled'); +}); + +test('members without member management rights do not see the two factor column', function () { + createTeamMember($this->team, 'owner', twoFactorEnabled: true); + $member = createTeamMember($this->team, 'member'); + + actAsTeamMember($member, $this->team); + + Livewire::test(Index::class) + ->assertDontSee('2FA') + ->assertDontSee('Two-factor authentication is enabled') + ->assertDontSee('Two-factor authentication is disabled') + ->assertDontSee('two-factor authentication enabled'); +}); + +test('the member row renders an enabled badge when two factor is confirmed', function () { + $admin = createTeamMember($this->team, 'admin'); + $memberWithTwoFactor = createTeamMember($this->team, 'member', twoFactorEnabled: true); + + actAsTeamMember($admin, $this->team); + + Livewire::test(Member::class, ['member' => $memberWithTwoFactor]) + ->assertSee('Two-factor authentication is enabled') + ->assertDontSee('Two-factor authentication is disabled'); +}); + +test('the member row renders a disabled badge when two factor is not configured', function () { + $admin = createTeamMember($this->team, 'admin'); + $memberWithoutTwoFactor = createTeamMember($this->team, 'member'); + + actAsTeamMember($admin, $this->team); + + Livewire::test(Member::class, ['member' => $memberWithoutTwoFactor]) + ->assertSee('Two-factor authentication is disabled') + ->assertDontSee('Two-factor authentication is enabled'); +}); + +test('admins see the two factor status of every role, including owners and other admins', function (string $role, bool $twoFactorEnabled, string $expectedStatus) { + $admin = createTeamMember($this->team, 'admin'); + $otherMember = createTeamMember($this->team, $role, twoFactorEnabled: $twoFactorEnabled); + + actAsTeamMember($admin, $this->team); + + Livewire::test(Member::class, ['member' => $otherMember]) + ->assertSee($expectedStatus); +})->with([ + 'owner with two factor' => ['owner', true, 'Two-factor authentication is enabled'], + 'owner without two factor' => ['owner', false, 'Two-factor authentication is disabled'], + 'another admin with two factor' => ['admin', true, 'Two-factor authentication is enabled'], + 'another admin without two factor' => ['admin', false, 'Two-factor authentication is disabled'], + 'member with two factor' => ['member', true, 'Two-factor authentication is enabled'], + 'member without two factor' => ['member', false, 'Two-factor authentication is disabled'], +]); + +test('admins see the two factor status on their own row', function () { + $admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true); + + actAsTeamMember($admin, $this->team); + + Livewire::test(Member::class, ['member' => $admin]) + ->assertSee('You') + ->assertSee('Two-factor authentication is enabled'); +}); + +test('the summary counts the members that are missing two factor authentication', function () { + $admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true); + createTeamMember($this->team, 'member'); + + actAsTeamMember($admin, $this->team); + + Livewire::test(Index::class) + ->assertSee('1 of 2') + ->assertSee('does not have two-factor authentication enabled'); +}); + +test('the summary confirms when every member has two factor authentication', function () { + $admin = createTeamMember($this->team, 'admin', twoFactorEnabled: true); + createTeamMember($this->team, 'member', twoFactorEnabled: true); + + actAsTeamMember($admin, $this->team); + + Livewire::test(Index::class) + ->assertSee('All members have two-factor authentication enabled') + ->assertDontSee('do not have two-factor authentication enabled'); +});