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

@@ -2,12 +2,13 @@
namespace App\Http\Controllers;
use App\Concerns\HasWorkspaceScope;
use App\Enums\ClientStatus;
use App\Enums\LegalForm;
use App\Enums\WorkspaceUserRole;
use App\Http\Requests\StoreClientRequest;
use App\Http\Requests\UpdateClientRequest;
use App\Models\Client;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -16,6 +17,8 @@ use Inertia\Response;
class ClientController extends Controller
{
use HasWorkspaceScope;
protected function legalFormLabels(): array
{
$labels = [
@@ -42,13 +45,6 @@ class ClientController extends Controller
];
}
protected function currentWorkspace(Request $request): Workspace
{
$workspaceId = $request->session()->get('current_workspace_id');
return Workspace::query()->findOrFail($workspaceId);
}
protected function serializeContacts(Client $client): array
{
return $client->contacts->map(fn ($c) => [
@@ -61,16 +57,29 @@ class ClientController extends Controller
])->all();
}
protected function isWorker(): bool
{
return auth()->user()->currentWorkspaceUser()->role->is(WorkspaceUserRole::Worker);
}
/**
* Display a listing of the clients.
*/
public function index(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
$workspace = $this->currentWorkspace();
$user = auth()->user();
$isWorker = $this->isWorker();
$perPage = min(max((int) $request->input('per_page', 10), 10), 100);
$clients = $workspace->clients()
$query = $workspace->clients();
if ($isWorker) {
$query->whereHas('declarations', fn ($q) => $q->where('assigned_to', $user->id));
}
$clients = $query
->latest()
->paginate($perPage)
->through(fn (Client $client) => [
@@ -88,6 +97,9 @@ class ClientController extends Controller
'clients' => $clients,
'createUrl' => route('clients.create'),
'workspaceName' => $workspace->name,
'canCreate' => ! $isWorker,
'canEdit' => ! $isWorker,
'canDelete' => ! $isWorker,
]);
}
@@ -96,7 +108,11 @@ class ClientController extends Controller
*/
public function create(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
if ($this->isWorker()) {
abort(404);
}
$workspace = $this->currentWorkspace();
return Inertia::render('clients/Create', [
'indexUrl' => route('clients.index'),
@@ -116,7 +132,11 @@ class ClientController extends Controller
*/
public function store(StoreClientRequest $request): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
if ($this->isWorker()) {
abort(404);
}
$workspace = $this->currentWorkspace();
$data = $request->validated();
$contacts = $data['contacts'];
unset($data['contacts']);
@@ -136,12 +156,30 @@ class ClientController extends Controller
*/
public function show(Request $request, Client $client): Response
{
$workspace = $this->currentWorkspace($request);
$this->authorizeClient($workspace, $client);
$this->authorizeWorkspaceAccess($client);
$isWorker = $this->isWorker();
$user = auth()->user();
if ($isWorker) {
$hasAssignedDeclarations = $client->declarations()
->where('assigned_to', $user->id)
->exists();
if (! $hasAssignedDeclarations) {
abort(404);
}
}
$client->load(['internalResponsible', 'contacts']);
$declarations = $client->declarations()
$declarationsQuery = $client->declarations();
if ($isWorker) {
$declarationsQuery->where('assigned_to', $user->id);
}
$declarations = (clone $declarationsQuery)
->with(['assignee'])
->latest()
->limit(50)
@@ -158,7 +196,7 @@ class ClientController extends Controller
->values()
->all();
$allDeclarations = $client->declarations()->get();
$allDeclarations = (clone $declarationsQuery)->get();
$stats = [
'total' => $allDeclarations->count(),
'by_status' => $allDeclarations->groupBy(fn ($f) => $f->status->value)
@@ -190,6 +228,8 @@ class ClientController extends Controller
'indexUrl' => route('clients.index'),
'editUrl' => route('clients.edit', $client),
'createDeclarationUrl' => route('declarations.create', ['client_id' => $client->id]),
'canEdit' => ! $isWorker,
'canDelete' => ! $isWorker,
]);
}
@@ -198,8 +238,13 @@ class ClientController extends Controller
*/
public function edit(Request $request, Client $client): Response
{
$workspace = $this->currentWorkspace($request);
$this->authorizeClient($workspace, $client);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($client);
$workspace = $this->currentWorkspace();
$client->load('contacts');
@@ -235,8 +280,11 @@ class ClientController extends Controller
*/
public function update(UpdateClientRequest $request, Client $client): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$this->authorizeClient($workspace, $client);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($client);
$data = $request->validated();
$contacts = $data['contacts'];
@@ -276,18 +324,14 @@ class ClientController extends Controller
*/
public function destroy(Request $request, Client $client): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$this->authorizeClient($workspace, $client);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($client);
$client->delete();
return to_route('clients.index');
}
protected function authorizeClient(Workspace $workspace, Client $client): void
{
if ($client->workspace_id !== $workspace->id) {
abort(404);
}
}
}