mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-17 06:14:40 +00:00
feat(ui): show two-factor status of team members (#11186)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
This commit is contained in:
6 files changed
+216
-5
No files matched your search
@@ -22,6 +22,11 @@ class Index extends Component
|
|||||||
|
|
||||||
public function render()
|
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(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
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 {
|
.admin-users-table-grid {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) 5rem;
|
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;
|
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) {
|
.team-members-table-grid > :nth-child(2) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -2602,6 +2610,10 @@ input[type="search"]::-webkit-search-results-decoration {
|
|||||||
grid-template-columns: minmax(0, 1fr) 6.5rem;
|
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) {
|
.team-members-table-grid > :nth-child(3) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
@props([
|
||||||
|
'enabled' => false,
|
||||||
|
])
|
||||||
|
|
||||||
|
<span title="{{ $enabled ? 'Two-factor authentication is enabled for this account.' : 'This account is not protected by two-factor authentication.' }}"
|
||||||
|
{{ $attributes->class([
|
||||||
|
'inline-flex items-center',
|
||||||
|
'text-green-600 dark:text-green-400' => $enabled,
|
||||||
|
'text-neutral-400 dark:text-fg-faint' => ! $enabled,
|
||||||
|
]) }}>
|
||||||
|
@if ($enabled)
|
||||||
|
<svg class="size-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
|
||||||
|
</svg>
|
||||||
|
@else
|
||||||
|
<svg class="size-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
d="M12 9v3.75m0-10.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Zm0 13.036h.008v.008H12v-.008Z" />
|
||||||
|
</svg>
|
||||||
|
@endif
|
||||||
|
<span class="sr-only">Two-factor authentication is {{ $enabled ? 'enabled' : 'disabled' }}</span>
|
||||||
|
</span>
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<div wire:key="team-member-row-{{ $member->id }}"
|
<div wire:key="team-member-row-{{ $member->id }}"
|
||||||
x-cloak x-show="isMemberVisible({{ $member->id }})"
|
x-cloak x-show="isMemberVisible({{ $member->id }})"
|
||||||
x-bind:style="{ order: memberOrder({{ $member->id }}) }"
|
x-bind:style="{ order: memberOrder({{ $member->id }}) }"
|
||||||
class="data-table-row team-members-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
@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()),
|
||||||
|
])>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
@@ -24,6 +27,11 @@
|
|||||||
{{ data_get($member, 'pivot.role') }}
|
{{ data_get($member, 'pivot.role') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@can('manageMembers', currentTeam())
|
||||||
|
<div class="flex items-center">
|
||||||
|
<x-two-factor-badge :enabled="filled($member->two_factor_confirmed_at)" />
|
||||||
|
</div>
|
||||||
|
@endcan
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
@can('manageMembers', currentTeam())
|
@can('manageMembers', currentTeam())
|
||||||
@if ($member->id !== Auth::id())
|
@if ($member->id !== Auth::id())
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
sortBy: 'name_asc',
|
sortBy: 'name_asc',
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 10,
|
perPage: 10,
|
||||||
members: @js(currentTeam()->members->map(fn ($member) => [
|
members: @js($members->map(fn ($member) => [
|
||||||
'id' => $member->id,
|
'id' => $member->id,
|
||||||
'name' => $member->name,
|
'name' => $member->name,
|
||||||
'email' => $member->email,
|
'email' => $member->email,
|
||||||
@@ -95,14 +95,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@can('manageMembers', currentTeam())
|
||||||
|
<div
|
||||||
|
class="border-b border-neutral-200 px-4 py-2.5 text-[12px] dark:border-white/[0.08]">
|
||||||
|
@if ($membersWithoutTwoFactorCount > 0)
|
||||||
|
<span class="text-warning-700 dark:text-warning">{{ $membersWithoutTwoFactorCount }} of {{ $members->count() }} {{ Str::plural('member', $members->count()) }} {{ $membersWithoutTwoFactorCount === 1 ? 'does' : 'do' }} not have two-factor authentication enabled.</span>
|
||||||
|
@else
|
||||||
|
<span class="text-neutral-500 dark:text-fg-dim">All members have two-factor authentication enabled.</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endcan
|
||||||
|
|
||||||
<div x-cloak x-show="filteredMembers.length > 0" class="data-table flex flex-col">
|
<div x-cloak x-show="filteredMembers.length > 0" class="data-table flex flex-col">
|
||||||
<div class="data-table-header team-members-table-grid">
|
<div @class([
|
||||||
|
'data-table-header team-members-table-grid',
|
||||||
|
'team-members-table-grid-2fa' => auth()->user()?->can('manageMembers', currentTeam()),
|
||||||
|
])>
|
||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<span>Email</span>
|
<span>Email</span>
|
||||||
<span>Role</span>
|
<span>Role</span>
|
||||||
|
@can('manageMembers', currentTeam())
|
||||||
|
<span>2FA</span>
|
||||||
|
@endcan
|
||||||
<span class="text-right">Actions</span>
|
<span class="text-right">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
@foreach (currentTeam()->members as $member)
|
@foreach ($members as $member)
|
||||||
<livewire:team.member :member="$member" :wire:key="$member->id" />
|
<livewire:team.member :member="$member" :wire:key="$member->id" />
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Team\Member;
|
||||||
|
use App\Livewire\Team\Member\Index;
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
InstanceSettings::unguarded(fn () => 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');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user