feat: complete Epic 1 — team management & permission system

- Story 1.1: Permission enum, config, AuthorizesPermissions & HasWorkspaceScope traits, member→worker migration
- Story 1.2: Team page with member list, invitation system with queued email
- Story 1.3: Role assignment (Manager/Worker) and member removal with activity logging
- Story 1.4: Owner-only permission toggle matrix for Managers (manage team, view logs, configure portal)
- Story 1.5: Role-based access enforcement — Workers see only assigned declarations/clients, sidebar scoping
- Story 1.6: Workspace switcher dropdown for multi-workspace users with session-based switching
- 83 new/modified files, 182 tests passing with zero regressions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 00:12:50 +00:00
parent 5dffd2d063
commit c89d1879bf
83 changed files with 5850 additions and 314 deletions

View File

@@ -0,0 +1,68 @@
<?php
use App\Concerns\HasWorkspaceScope;
use App\Models\Client;
use App\Models\User;
use App\Models\Workspace;
function createScopeChecker(): object
{
return new class
{
use HasWorkspaceScope;
public function getWorkspace(): \App\Models\Workspace
{
return $this->currentWorkspace();
}
public function checkAccess(\Illuminate\Database\Eloquent\Model $resource): void
{
$this->authorizeWorkspaceAccess($resource);
}
};
}
test('currentWorkspace resolves workspace from session', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, ['role' => 'owner']);
session(['current_workspace_id' => $workspace->id]);
$this->actingAs($user);
$checker = createScopeChecker();
$resolved = $checker->getWorkspace();
expect($resolved->id)->toBe($workspace->id);
});
test('currentWorkspace fails when user not in workspace', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
session(['current_workspace_id' => $workspace->id]);
$this->actingAs($user);
$checker = createScopeChecker();
$checker->getWorkspace();
})->throws(Illuminate\Database\Eloquent\ModelNotFoundException::class);
test('authorizeWorkspaceAccess passes for matching workspace', function () {
$workspace = Workspace::factory()->create();
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
session(['current_workspace_id' => $workspace->id]);
$checker = createScopeChecker();
$checker->checkAccess($client);
expect(true)->toBeTrue(); // No exception thrown
});
test('authorizeWorkspaceAccess aborts 404 for mismatched workspace', function () {
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
$client = Client::factory()->create(['workspace_id' => $workspace1->id]);
session(['current_workspace_id' => $workspace2->id]);
$checker = createScopeChecker();
$checker->checkAccess($client);
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);