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,15 +2,16 @@
namespace App\Http\Controllers;
use App\Concerns\HasWorkspaceScope;
use App\Enums\DeclarationPriority;
use App\Enums\DeclarationStatus;
use App\Enums\DeclarationType;
use App\Enums\WorkspaceUserRole;
use App\Http\Requests\StoreDeclarationRequest;
use App\Http\Requests\UpdateDeclarationRequest;
use App\Models\Client;
use App\Models\Declaration;
use App\Models\MediaDownload;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -18,6 +19,8 @@ use Inertia\Response;
class DeclarationController extends Controller
{
use HasWorkspaceScope;
protected function declarationTypeLabels(): array
{
return [
@@ -46,11 +49,9 @@ class DeclarationController extends Controller
];
}
protected function currentWorkspace(Request $request): Workspace
protected function isWorker(): bool
{
$workspaceId = $request->session()->get('current_workspace_id');
return Workspace::query()->findOrFail($workspaceId);
return auth()->user()->currentWorkspaceUser()->role->is(WorkspaceUserRole::Worker);
}
/**
@@ -58,11 +59,15 @@ class DeclarationController extends Controller
*/
public function index(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
$workspace = $this->currentWorkspace();
$user = auth()->user();
$workspaceUser = $user->currentWorkspaceUser();
$isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker);
$perPage = min(max((int) $request->input('per_page', 10), 10), 100);
$declarations = $workspace->declarations()
->forUser($user, $workspaceUser)
->with(['client', 'assignee'])
->latest()
->paginate($perPage)
@@ -82,6 +87,9 @@ class DeclarationController extends Controller
'declarations' => $declarations,
'createUrl' => route('declarations.create'),
'workspaceName' => $workspace->name,
'canCreate' => ! $isWorker,
'canEdit' => ! $isWorker,
'canDelete' => ! $isWorker,
]);
}
@@ -90,7 +98,11 @@ class DeclarationController extends Controller
*/
public function create(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
if ($this->isWorker()) {
abort(404);
}
$workspace = $this->currentWorkspace();
$initialClientId = $request->integer('client_id', 0) ?: null;
return Inertia::render('declarations/Create', [
@@ -121,7 +133,11 @@ class DeclarationController extends Controller
*/
public function store(StoreDeclarationRequest $request): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
if ($this->isWorker()) {
abort(404);
}
$workspace = $this->currentWorkspace();
$data = $request->validated();
$data['workspace_id'] = $workspace->id;
$data['created_by'] = $request->user()?->id;
@@ -143,8 +159,16 @@ class DeclarationController extends Controller
*/
public function show(Request $request, Declaration $declaration): Response
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $declaration);
$this->authorizeWorkspaceAccess($declaration);
$workspace = $this->currentWorkspace();
$user = auth()->user();
$workspaceUser = $user->currentWorkspaceUser();
$isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker);
if ($isWorker && $declaration->assigned_to !== $user->id) {
abort(404);
}
$declaration->load(['client', 'creator', 'assignee', 'messages' => fn ($q) => $q->with(['senderUser', 'senderClient'])->latest()]);
@@ -197,6 +221,8 @@ class DeclarationController extends Controller
'is_downloaded' => in_array($m->id, $downloadedMediaIds),
])->values()->all();
$canMention = ! $isWorker;
return Inertia::render('declarations/Show', [
'declaration' => [
'id' => $declaration->id,
@@ -236,10 +262,9 @@ class DeclarationController extends Controller
->get()->map(fn ($u) => ['id' => $u->id, 'name' => $u->name])
->values()->all(),
'mentionStoreUrl' => route('declarations.mentions.store', $declaration),
'canMention' => in_array(
$workspace->users()->where('users.id', $request->user()->id)->first()?->pivot?->role?->value,
['owner', 'manager']
),
'canMention' => $canMention,
'canEdit' => ! $isWorker,
'canDelete' => ! $isWorker,
]);
}
@@ -248,8 +273,13 @@ class DeclarationController extends Controller
*/
public function edit(Request $request, Declaration $declaration): Response
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $declaration);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($declaration);
$workspace = $this->currentWorkspace();
return Inertia::render('declarations/Edit', [
'declaration' => [
@@ -294,8 +324,11 @@ class DeclarationController extends Controller
*/
public function update(UpdateDeclarationRequest $request, Declaration $declaration): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $declaration);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($declaration);
$data = $request->validated();
@@ -315,18 +348,14 @@ class DeclarationController extends Controller
*/
public function destroy(Request $request, Declaration $declaration): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $declaration);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($declaration);
$declaration->delete();
return to_route('declarations.index');
}
protected function authorizeDeclaration(Workspace $workspace, Declaration $declaration): void
{
if ($declaration->workspace_id !== $workspace->id) {
abort(404);
}
}
}