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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
261
app/Http/Controllers/TeamController.php
Normal file
261
app/Http/Controllers/TeamController.php
Normal file
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Concerns\AuthorizesPermissions;
|
||||
use App\Concerns\HasWorkspaceScope;
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\WorkspaceUserRole;
|
||||
use App\Http\Requests\InviteTeamMemberRequest;
|
||||
use App\Http\Requests\UpdatePermissionsRequest;
|
||||
use App\Http\Requests\UpdateTeamMemberRoleRequest;
|
||||
use App\Mail\TeamInvitationMail;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkspaceUser;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class TeamController extends Controller
|
||||
{
|
||||
use AuthorizesPermissions;
|
||||
use HasWorkspaceScope;
|
||||
|
||||
/**
|
||||
* Display the team management page.
|
||||
*/
|
||||
public function index(): Response
|
||||
{
|
||||
$workspaceUser = auth()->user()->currentWorkspaceUser();
|
||||
|
||||
// Block Workers entirely — team page is Owner/Manager only
|
||||
if ($workspaceUser->role->is(WorkspaceUserRole::Worker)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$workspace = $this->currentWorkspace();
|
||||
|
||||
$isOwner = $workspaceUser->role->is(WorkspaceUserRole::Owner);
|
||||
|
||||
// Load members with pivot data
|
||||
$members = $workspace->users()
|
||||
->withPivot('id', 'role', 'permissions', 'created_at')
|
||||
->get()
|
||||
->map(function ($user) use ($isOwner) {
|
||||
$pivotRole = $user->pivot->role;
|
||||
$role = $pivotRole?->value ?? $pivotRole;
|
||||
$data = [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'role' => $role,
|
||||
'joined_at' => $user->pivot->created_at,
|
||||
'status' => 'active',
|
||||
'workspace_user_id' => $user->pivot->id,
|
||||
'updateRoleUrl' => route('team.updateRole', $user->pivot->id),
|
||||
'removeUrl' => route('team.remove', $user->pivot->id),
|
||||
];
|
||||
|
||||
// Add permissions data for Manager members (only visible to Owners)
|
||||
if ($pivotRole instanceof WorkspaceUserRole && $pivotRole->is(WorkspaceUserRole::Manager) && $isOwner) {
|
||||
$data['permissions'] = $user->pivot->permissions ?? [];
|
||||
$data['permissionsUrl'] = route('team.updatePermissions', $user->pivot->id);
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
// Load pending invitations
|
||||
$pendingInvitations = TeamInvitation::where('workspace_id', $workspace->id)
|
||||
->whereNull('accepted_at')
|
||||
->where('expires_at', '>', now())
|
||||
->get()
|
||||
->map(fn ($inv) => [
|
||||
'id' => $inv->id,
|
||||
'email' => $inv->email,
|
||||
'role' => $inv->role,
|
||||
'invited_at' => $inv->created_at,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
// Can manage team: Owner always, Manager checks permission
|
||||
$canManageTeam = $workspaceUser->role->is(WorkspaceUserRole::Owner)
|
||||
|| ($workspaceUser->permissions[Permission::CanManageTeam] ?? false);
|
||||
|
||||
return Inertia::render('team/Index', [
|
||||
'members' => $members,
|
||||
'pendingInvitations' => $pendingInvitations,
|
||||
'canManageTeam' => $canManageTeam,
|
||||
'isOwner' => $isOwner,
|
||||
'availablePermissions' => $isOwner ? $this->permissionLabels() : [],
|
||||
'authUserId' => auth()->id(),
|
||||
'inviteUrl' => route('team.invite'),
|
||||
'roles' => $this->roleLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a new team member.
|
||||
*/
|
||||
public function invite(InviteTeamMemberRequest $request): RedirectResponse
|
||||
{
|
||||
$workspace = $this->currentWorkspace();
|
||||
|
||||
$invitation = TeamInvitation::create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'email' => $request->validated('email'),
|
||||
'role' => $request->validated('role'),
|
||||
'invited_by' => auth()->id(),
|
||||
'expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
Mail::to($invitation->email)->send(new TeamInvitationMail($workspace, $invitation));
|
||||
|
||||
return redirect()->back()->with('success', 'Invitation envoyée');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a team member's role.
|
||||
*/
|
||||
public function updateRole(UpdateTeamMemberRoleRequest $request, int $workspaceUserId): RedirectResponse
|
||||
{
|
||||
$this->authorizePermission(Permission::CanManageTeam);
|
||||
|
||||
$workspaceUser = WorkspaceUser::where('id', $workspaceUserId)
|
||||
->where('workspace_id', session('current_workspace_id'))
|
||||
->firstOrFail();
|
||||
|
||||
if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($workspaceUser->user_id === auth()->id()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$oldRole = $workspaceUser->role->value;
|
||||
$newRole = $request->validated('role');
|
||||
|
||||
DB::transaction(function () use ($workspaceUser, $oldRole, $newRole) {
|
||||
$workspaceUser->update([
|
||||
'role' => $newRole,
|
||||
'permissions' => config("permissions.defaults.{$newRole}", []),
|
||||
]);
|
||||
|
||||
$targetUser = User::findOrFail($workspaceUser->user_id);
|
||||
activity()
|
||||
->performedOn($workspaceUser)
|
||||
->withProperties([
|
||||
'target_user' => $targetUser->name,
|
||||
'old_role' => $oldRole,
|
||||
'new_role' => $newRole,
|
||||
])
|
||||
->log('role_changed');
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'Rôle mis à jour');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a Manager's individual permissions.
|
||||
*/
|
||||
public function updatePermissions(UpdatePermissionsRequest $request, int $workspaceUserId): RedirectResponse
|
||||
{
|
||||
$workspaceUser = WorkspaceUser::where('id', $workspaceUserId)
|
||||
->where('workspace_id', session('current_workspace_id'))
|
||||
->firstOrFail();
|
||||
|
||||
// Only Manager permissions can be toggled
|
||||
if (! $workspaceUser->role->is(WorkspaceUserRole::Manager)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$newPermissions = $request->validated('permissions');
|
||||
|
||||
DB::transaction(function () use ($workspaceUser, $newPermissions) {
|
||||
$oldPermissions = $workspaceUser->permissions ?? [];
|
||||
|
||||
$workspaceUser->update([
|
||||
'permissions' => $newPermissions,
|
||||
]);
|
||||
|
||||
$targetUser = User::findOrFail($workspaceUser->user_id);
|
||||
activity()
|
||||
->performedOn($workspaceUser)
|
||||
->withProperties([
|
||||
'target_user' => $targetUser->name,
|
||||
'old_permissions' => $oldPermissions,
|
||||
'new_permissions' => $newPermissions,
|
||||
])
|
||||
->log('permissions_updated');
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'Permissions mises à jour');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a team member from the workspace.
|
||||
*/
|
||||
public function remove(int $workspaceUserId): RedirectResponse
|
||||
{
|
||||
$this->authorizePermission(Permission::CanManageTeam);
|
||||
|
||||
$workspaceUser = WorkspaceUser::where('id', $workspaceUserId)
|
||||
->where('workspace_id', session('current_workspace_id'))
|
||||
->firstOrFail();
|
||||
|
||||
if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if ($workspaceUser->user_id === auth()->id()) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$targetUser = User::findOrFail($workspaceUser->user_id);
|
||||
|
||||
DB::transaction(function () use ($workspaceUser, $targetUser) {
|
||||
$workspaceUser->delete();
|
||||
|
||||
activity()
|
||||
->withProperties([
|
||||
'target_user' => $targetUser->name,
|
||||
'target_email' => $targetUser->email,
|
||||
'role' => $workspaceUser->role->value,
|
||||
])
|
||||
->log('member_removed');
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'Membre retiré');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role labels for the frontend.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function roleLabels(): array
|
||||
{
|
||||
return [
|
||||
'manager' => 'Gestionnaire',
|
||||
'worker' => 'Collaborateur',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permission labels (French) for the frontend.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function permissionLabels(): array
|
||||
{
|
||||
return [
|
||||
Permission::CanManageTeam => "Gérer l'équipe",
|
||||
Permission::CanViewActivityLogs => "Voir les journaux d'activité",
|
||||
Permission::CanConfigurePortal => 'Configurer le portail client',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class WorkspaceController extends Controller
|
||||
|
||||
$workspace = Workspace::query()->create($data);
|
||||
$syncData = collect($userIds)->mapWithKeys(fn ($userId) => [
|
||||
(int) $userId => ['role' => $userRoles[$userId] ?? WorkspaceUserRole::Member],
|
||||
(int) $userId => ['role' => $userRoles[$userId] ?? WorkspaceUserRole::Worker],
|
||||
])->all();
|
||||
$workspace->users()->sync($syncData);
|
||||
|
||||
@@ -158,7 +158,7 @@ class WorkspaceController extends Controller
|
||||
|
||||
$workspace->update($data);
|
||||
$syncData = collect($userIds)->mapWithKeys(fn ($userId) => [
|
||||
(int) $userId => ['role' => $userRoles[$userId] ?? WorkspaceUserRole::Member],
|
||||
(int) $userId => ['role' => $userRoles[$userId] ?? WorkspaceUserRole::Worker],
|
||||
])->all();
|
||||
$workspace->users()->sync($syncData);
|
||||
|
||||
|
||||
@@ -2,27 +2,41 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\SwitchWorkspaceRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WorkspaceSwitchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Switch the current workspace.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
public function __invoke(SwitchWorkspaceRequest $request): RedirectResponse
|
||||
{
|
||||
$workspaceId = $request->input('workspace_id');
|
||||
$workspaceId = (int) $request->validated('workspace_id');
|
||||
|
||||
$user = $request->user();
|
||||
$hasAccess = $user->workspaces()->where('workspaces.id', $workspaceId)->exists();
|
||||
|
||||
if (! $hasAccess) {
|
||||
return back();
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
$request->session()->put('current_workspace_id', (int) $workspaceId);
|
||||
$previousWorkspaceId = $request->session()->get('current_workspace_id');
|
||||
|
||||
return back();
|
||||
if ($previousWorkspaceId === $workspaceId) {
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
$request->session()->put('current_workspace_id', $workspaceId);
|
||||
|
||||
activity()
|
||||
->causedBy($user)
|
||||
->withProperties([
|
||||
'previous_workspace_id' => $previousWorkspaceId,
|
||||
'new_workspace_id' => $workspaceId,
|
||||
])
|
||||
->log('Switched workspace');
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\WorkspaceUser;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
@@ -62,12 +63,21 @@ class HandleInertiaRequests extends Middleware
|
||||
|
||||
return [
|
||||
...parent::share($request),
|
||||
'flash' => $request->session()->get('flash'),
|
||||
'flash' => [
|
||||
'success' => $request->session()->get('success'),
|
||||
'error' => $request->session()->get('error'),
|
||||
],
|
||||
'name' => config('app.name'),
|
||||
'auth' => [
|
||||
'user' => $user,
|
||||
'workspaces' => $workspaces,
|
||||
'currentWorkspace' => $currentWorkspace,
|
||||
'workspaceRole' => $user && $currentWorkspace
|
||||
? WorkspaceUser::where('user_id', $user->id)
|
||||
->where('workspace_id', $currentWorkspace['id'])
|
||||
->first()?->role?->value
|
||||
: null,
|
||||
'workspaceSwitchUrl' => $user ? route('workspace.switch') : null,
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'userNotifications' => [
|
||||
|
||||
83
app/Http/Requests/InviteTeamMemberRequest.php
Normal file
83
app/Http/Requests/InviteTeamMemberRequest.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\WorkspaceUserRole;
|
||||
use App\Models\TeamInvitation;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class InviteTeamMemberRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
$workspaceUser = $this->user()->currentWorkspaceUser();
|
||||
|
||||
if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($workspaceUser->role->is(WorkspaceUserRole::Manager)) {
|
||||
return (bool) ($workspaceUser->permissions[Permission::CanManageTeam] ?? false);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'role' => ['required', 'in:manager,worker'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the validator instance.
|
||||
*/
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator) {
|
||||
$workspaceId = session('current_workspace_id');
|
||||
$email = $this->input('email');
|
||||
|
||||
// Check if email is already a member of the workspace
|
||||
$alreadyMember = \App\Models\Workspace::find($workspaceId)
|
||||
?->users()
|
||||
->where('email', $email)
|
||||
->exists();
|
||||
|
||||
if ($alreadyMember) {
|
||||
$validator->errors()->add('email', 'Cet utilisateur fait déjà partie de l\'équipe.');
|
||||
}
|
||||
|
||||
// Check for existing active invitation
|
||||
$existingInvitation = TeamInvitation::where('workspace_id', $workspaceId)
|
||||
->where('email', $email)
|
||||
->whereNull('accepted_at')
|
||||
->where('expires_at', '>', now())
|
||||
->exists();
|
||||
|
||||
if ($existingInvitation) {
|
||||
$validator->errors()->add('email', 'Une invitation est déjà en cours pour cette adresse email.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed authorization attempt.
|
||||
*/
|
||||
protected function failedAuthorization(): void
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/SwitchWorkspaceRequest.php
Normal file
29
app/Http/Requests/SwitchWorkspaceRequest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SwitchWorkspaceRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => ['required', 'integer', 'exists:workspaces,id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
71
app/Http/Requests/UpdatePermissionsRequest.php
Normal file
71
app/Http/Requests/UpdatePermissionsRequest.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\WorkspaceUserRole;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdatePermissionsRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
* Only Owners can update permissions — Managers with can_manage_team CANNOT.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
$workspaceUser = $this->user()->currentWorkspaceUser();
|
||||
|
||||
return $workspaceUser->role->is(WorkspaceUserRole::Owner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'permissions' => ['required', 'array'],
|
||||
'permissions.*' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the validator instance.
|
||||
*/
|
||||
public function withValidator(\Illuminate\Validation\Validator $validator): void
|
||||
{
|
||||
$validator->after(function (\Illuminate\Validation\Validator $validator) {
|
||||
$permissions = $this->input('permissions', []);
|
||||
$validKeys = Permission::getValues();
|
||||
|
||||
foreach (array_keys($permissions) as $key) {
|
||||
if (! in_array($key, $validKeys, true)) {
|
||||
$validator->errors()->add(
|
||||
'permissions',
|
||||
"Invalid permission key: {$key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure ALL permission keys are present to prevent silent permission loss
|
||||
$missingKeys = array_diff($validKeys, array_keys($permissions));
|
||||
if (! empty($missingKeys)) {
|
||||
$validator->errors()->add(
|
||||
'permissions',
|
||||
'Missing permission keys: '.implode(', ', $missingKeys)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed authorization attempt.
|
||||
*/
|
||||
protected function failedAuthorization(): void
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
48
app/Http/Requests/UpdateTeamMemberRoleRequest.php
Normal file
48
app/Http/Requests/UpdateTeamMemberRoleRequest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\WorkspaceUserRole;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTeamMemberRoleRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
$workspaceUser = $this->user()->currentWorkspaceUser();
|
||||
|
||||
if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($workspaceUser->role->is(WorkspaceUserRole::Manager)) {
|
||||
return (bool) ($workspaceUser->permissions[Permission::CanManageTeam] ?? false);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'role' => ['required', 'in:manager,worker'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed authorization attempt.
|
||||
*/
|
||||
protected function failedAuthorization(): void
|
||||
{
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user