fix(auth): enforce dashboard authorization and improve team deletion

Add authorization gates to Project and Server creation buttons in the dashboard to prevent non-admin users from accessing resource creation. Improve team deletion to clear cache before deletion and automatically switch to the user's next available team.

- Hide create buttons from non-admin users in dashboard
- Clear cache before team deletion to prevent stale session resolution
- Switch user session to next available team when current team is deleted
- Handle refreshSession when user has no remaining teams
- Add tests for dashboard authorization enforcement and team deletion flow
This commit is contained in:
Andras Bacsai 2026-02-25 14:47:35 +01:00
parent 86b05b902a
commit fcc58ca08a
5 changed files with 234 additions and 53 deletions

View file

@ -7,6 +7,7 @@
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
@ -98,8 +99,6 @@ public function delete()
try { try {
$currentTeam = currentTeam(); $currentTeam = currentTeam();
$this->authorize('delete', $currentTeam); $this->authorize('delete', $currentTeam);
$currentTeam->delete();
$currentTeam->members->each(function ($user) use ($currentTeam) { $currentTeam->members->each(function ($user) use ($currentTeam) {
if ($user->id === Auth::id()) { if ($user->id === Auth::id()) {
return; return;
@ -111,7 +110,13 @@ public function delete()
} }
}); });
refreshSession(); // Clear stale cache before deleting so refreshSession doesn't resolve the deleted team
Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id);
$currentTeam->delete();
// Switch to the user's next available team
$newTeam = Auth::user()->teams()->first();
refreshSession($newTeam);
return redirect()->route('team.index'); return redirect()->route('team.index');
} catch (\Throwable $e) { } catch (\Throwable $e) {

View file

@ -186,6 +186,14 @@ function refreshSession(?Team $team = null): void
$team = User::find(Auth::id())->teams->first(); $team = User::find(Auth::id())->teams->first();
} }
} }
if (! $team) {
session()->forget('currentTeam');
Cache::forget('team:'.Auth::id());
return;
}
// Clear old cache key format for backwards compatibility // Clear old cache key format for backwards compatibility
Cache::forget('team:'.Auth::id()); Cache::forget('team:'.Auth::id());
// Use new cache key format that includes team ID // Use new cache key format that includes team ID

View file

@ -17,20 +17,23 @@
<section class="-mt-2"> <section class="-mt-2">
<div class="flex items-center gap-2 pb-2"> <div class="flex items-center gap-2 pb-2">
<h3>Projects</h3> <h3>Projects</h3>
@if ($projects->count() > 0) @can('create', App\Models\Project::class)
<x-modal-input buttonTitle="Add" title="New Project"> @if ($projects->count() > 0)
<x-slot:content> <x-modal-input buttonTitle="Add" title="New Project">
<button <x-slot:content>
class="flex items-center justify-center size-4 text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300 cursor-pointer"> <button
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="flex items-center justify-center size-4 text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300 cursor-pointer">
stroke-width="2" stroke="currentColor"> <svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none"
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round"
</button> d="M12 4.5v15m7.5-7.5h-15" />
</x-slot:content> </svg>
<livewire:project.add-empty /> </button>
</x-modal-input> </x-slot:content>
@endif <livewire:project.add-empty />
</x-modal-input>
@endif
@endcan
</div> </div>
@if ($projects->count() > 0) @if ($projects->count() > 0)
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2"> <div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
@ -70,12 +73,15 @@ class="flex items-center justify-center size-4 text-white rounded hover:bg-coolg
@else @else
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class='font-bold dark:text-warning'>No projects found.</div> <div class='font-bold dark:text-warning'>No projects found.</div>
<div class="flex items-center gap-1"> @can('create', App\Models\Project::class)
<x-modal-input buttonTitle="Add" title="New Project"> <div class="flex items-center gap-1">
<livewire:project.add-empty /> <x-modal-input buttonTitle="Add" title="New Project">
</x-modal-input> your first project or <livewire:project.add-empty />
go to the <a class="underline dark:text-white" href="{{ route('onboarding') }}" {{ wireNavigate() }}>onboarding</a> page. </x-modal-input> your first project or
</div> go to the <a class="underline dark:text-white" href="{{ route('onboarding') }}"
{{ wireNavigate() }}>onboarding</a> page.
</div>
@endcan
</div> </div>
@endif @endif
</section> </section>
@ -83,20 +89,23 @@ class="flex items-center justify-center size-4 text-white rounded hover:bg-coolg
<section> <section>
<div class="flex items-center gap-2 pb-2"> <div class="flex items-center gap-2 pb-2">
<h3>Servers</h3> <h3>Servers</h3>
@if ($servers->count() > 0 && $privateKeys->count() > 0) @can('create', App\Models\Server::class)
<x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false"> @if ($servers->count() > 0 && $privateKeys->count() > 0)
<x-slot:content> <x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false">
<button <x-slot:content>
class="flex items-center justify-center size-4 text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300 cursor-pointer"> <button
<svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="flex items-center justify-center size-4 text-white rounded hover:bg-coolgray-400 dark:hover:bg-coolgray-300 cursor-pointer">
stroke-width="2" stroke="currentColor"> <svg class="size-3" xmlns="http://www.w3.org/2000/svg" fill="none"
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round"
</button> d="M12 4.5v15m7.5-7.5h-15" />
</x-slot:content> </svg>
<livewire:server.create /> </button>
</x-modal-input> </x-slot:content>
@endif <livewire:server.create />
</x-modal-input>
@endif
@endcan
</div> </div>
@if ($servers->count() > 0) @if ($servers->count() > 0)
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2"> <div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
@ -133,26 +142,34 @@ class="flex items-center justify-center size-4 text-white rounded hover:bg-coolg
@if ($privateKeys->count() === 0) @if ($privateKeys->count() === 0)
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class='font-bold dark:text-warning'>No private keys found.</div> <div class='font-bold dark:text-warning'>No private keys found.</div>
<div class="flex items-center gap-1">Before you can add your server, first <x-modal-input @can('create', App\Models\Server::class)
buttonTitle="add" title="New Private Key"> <div class="flex items-center gap-1">Before you can add your server, first <x-modal-input
<livewire:security.private-key.create from="server" /> buttonTitle="add" title="New Private Key">
</x-modal-input> a private key <livewire:security.private-key.create from="server" />
or </x-modal-input> a private key
go to the <a class="underline dark:text-white" href="{{ route('onboarding') }}" {{ wireNavigate() }}>onboarding</a> or
page. go to the <a class="underline dark:text-white"
</div> href="{{ route('onboarding') }}"
{{ wireNavigate() }}>onboarding</a>
page.
</div>
@endcan
</div> </div>
@else @else
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div class='font-bold dark:text-warning'>No servers found.</div> <div class='font-bold dark:text-warning'>No servers found.</div>
<div class="flex items-center gap-1"> @can('create', App\Models\Server::class)
<x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false"> <div class="flex items-center gap-1">
<livewire:server.create /> <x-modal-input buttonTitle="Add" title="New Server" :closeOutside="false">
</x-modal-input> your first server <livewire:server.create />
or </x-modal-input> your first server
go to the <a class="underline dark:text-white" href="{{ route('onboarding') }}" {{ wireNavigate() }}>onboarding</a> or
page. go to the <a class="underline dark:text-white"
</div> href="{{ route('onboarding') }}"
{{ wireNavigate() }}>onboarding</a>
page.
</div>
@endcan
</div> </div>
@endif @endif
@endif @endif

View file

@ -0,0 +1,97 @@
<?php
use App\Livewire\Dashboard;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function setupDashboardUser(string $role): array
{
$team = Team::factory()->create();
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => $role]);
return [$user, $team];
}
function createProjectForTeam(Team $team): void
{
Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $team->id,
]);
}
function createServerWithKeyForTeam(Team $team): void
{
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $team->id,
'created_at' => now(),
'updated_at' => now(),
]);
Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $keyId,
]);
}
test('admin sees add project button on dashboard', function () {
[$user, $team] = setupDashboardUser('admin');
$this->actingAs($user);
session(['currentTeam' => $team]);
createProjectForTeam($team);
Livewire::test(Dashboard::class)
->assertSee('New Project');
});
test('member does not see add project button on dashboard', function () {
[$user, $team] = setupDashboardUser('member');
$this->actingAs($user);
session(['currentTeam' => $team]);
createProjectForTeam($team);
Livewire::test(Dashboard::class)
->assertDontSee('New Project');
});
test('admin sees add server button on dashboard', function () {
[$user, $team] = setupDashboardUser('admin');
$this->actingAs($user);
session(['currentTeam' => $team]);
createServerWithKeyForTeam($team);
Livewire::test(Dashboard::class)
->assertSee('New Server');
});
test('member does not see add server button on dashboard', function () {
[$user, $team] = setupDashboardUser('member');
$this->actingAs($user);
session(['currentTeam' => $team]);
createServerWithKeyForTeam($team);
Livewire::test(Dashboard::class)
->assertDontSee('New Server');
});

View file

@ -0,0 +1,54 @@
<?php
use App\Livewire\Team\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::updateOrCreate(['id' => 0]);
$this->owner = User::factory()->create();
// The owner's personal team (created by factory)
$this->personalTeam = $this->owner->teams()->first();
$this->owner->teams()->updateExistingPivot($this->personalTeam->id, ['role' => 'owner']);
// A second team to delete
$this->teamToDelete = Team::create(['name' => 'Deletable Team', 'personal_team' => false]);
$this->teamToDelete->members()->attach($this->owner->id, ['role' => 'owner']);
});
test('deleting a team switches session to another team without error', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->teamToDelete]);
Livewire::test(Index::class)
->call('delete')
->assertRedirect(route('team.index'));
// Team should be deleted from the database
expect(Team::find($this->teamToDelete->id))->toBeNull();
// Session should now have the personal team
$sessionTeam = session('currentTeam');
expect($sessionTeam)->not->toBeNull()
->and($sessionTeam->id)->toBe($this->personalTeam->id);
});
test('refreshSession clears session when no team exists', function () {
$user = User::factory()->create();
// Detach all teams so user has none
$user->teams()->detach();
$this->actingAs($user);
session(['currentTeam' => null]);
// Should not throw when no team can be resolved
refreshSession(null);
expect(session('currentTeam'))->toBeNull();
});