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,34 @@
<?php
namespace App\Concerns;
use App\Enums\WorkspaceUserRole;
trait AuthorizesPermissions
{
/**
* Authorize the current user has the given permission.
*
* Owner: always passes.
* Worker: always fails (abort 404).
* Manager: checks the permissions JSON column on workspace_user pivot.
* Unknown permission keys default to false.
*/
protected function authorizePermission(string $permission): void
{
$workspaceUser = auth()->user()->currentWorkspaceUser();
if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) {
return;
}
if ($workspaceUser->role->is(WorkspaceUserRole::Worker)) {
abort(404);
}
// Manager: check JSON permissions column
if (! ($workspaceUser->permissions[$permission] ?? false)) {
abort(404);
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Concerns;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Model;
trait HasWorkspaceScope
{
/**
* Resolve the current workspace from the session.
*/
protected function currentWorkspace(): Workspace
{
return auth()->user()->workspaces()
->where('workspaces.id', session('current_workspace_id'))
->firstOrFail();
}
/**
* Verify the given resource belongs to the current workspace.
* Aborts with 404 if the resource does not belong to the workspace.
*/
protected function authorizeWorkspaceAccess(Model $resource): void
{
if ($resource->workspace_id !== (int) session('current_workspace_id')) {
abort(404);
}
}
}

14
app/Enums/Permission.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
use BenSampo\Enum\Enum;
final class Permission extends Enum
{
const CanManageTeam = 'can_manage_team';
const CanViewActivityLogs = 'can_view_activity_logs';
const CanConfigurePortal = 'can_configure_portal';
}

View File

@@ -10,5 +10,5 @@ final class WorkspaceUserRole extends Enum
const Manager = 'manager';
const Member = 'member';
const Worker = 'worker';
}

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);
}
}
}

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);
}
}
}

View 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',
];
}
}

View File

@@ -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);

View File

@@ -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');
}
}

View File

@@ -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' => [

View 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);
}
}

View 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'],
];
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Mail;
use App\Models\TeamInvitation;
use App\Models\Workspace;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TeamInvitationMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Workspace $workspace,
public TeamInvitation $invitation
) {}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Invitation à rejoindre '.$this->workspace->name,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
$roleLabels = [
'manager' => 'Gestionnaire',
'worker' => 'Collaborateur',
];
return new Content(
markdown: 'emails.team-invitation',
with: [
'workspaceName' => $this->workspace->name,
'roleLabel' => $roleLabels[$this->invitation->role] ?? $this->invitation->role,
'registerUrl' => route('register', ['invitation' => $this->invitation->token]),
'expiresAt' => $this->invitation->expires_at->format('d/m/Y à H:i'),
]
);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Enums\DeclarationPriority;
use App\Enums\DeclarationStatus;
use App\Enums\DeclarationType;
use App\Enums\WorkspaceUserRole;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -144,6 +145,19 @@ class Declaration extends Model implements HasMedia
return $this->hasMany(DeclarationInvitation::class);
}
/**
* Scope declarations based on user role.
* Workers see only declarations assigned to them; Owners/Managers see all.
*/
public function scopeForUser(Builder $query, User $user, WorkspaceUser $workspaceUser): Builder
{
if ($workspaceUser->role->is(WorkspaceUserRole::Worker)) {
return $query->where('assigned_to', $user->id);
}
return $query;
}
/**
* Scope a query to only include active (non-archived) declarations.
*/

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
class TeamInvitation extends Model
{
use LogsActivity;
protected $table = 'team_invitations';
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'workspace_id',
'email',
'role',
'token',
'invited_by',
'accepted_at',
'expires_at',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'accepted_at' => 'datetime',
'expires_at' => 'datetime',
];
}
/**
* Boot the model.
*/
protected static function boot(): void
{
parent::boot();
static::creating(function (TeamInvitation $invitation) {
if (empty($invitation->token)) {
$invitation->token = Str::uuid()->toString();
}
});
}
/**
* Get the workspace that owns the invitation.
*
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* Get the user who sent the invitation.
*
* @return BelongsTo<User, $this>
*/
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by');
}
/**
* Check if the invitation is valid (not accepted, not expired).
*/
public function isValid(): bool
{
if ($this->accepted_at !== null) {
return false;
}
return $this->expires_at->isFuture();
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
}

View File

@@ -66,10 +66,30 @@ class User extends Authenticatable
{
return $this->belongsToMany(Workspace::class, 'workspace_user')
->using(\App\Models\WorkspaceUser::class)
->withPivot('role')
->withPivot('role', 'permissions')
->withTimestamps();
}
/**
* Memoized workspace-user pivot instances, keyed by workspace ID.
*
* @var array<int, WorkspaceUser>
*/
protected array $resolvedWorkspaceUsers = [];
/**
* Get the workspace-user pivot for the current session workspace.
* Result is memoized per workspace ID to avoid duplicate queries within a request.
*/
public function currentWorkspaceUser(): WorkspaceUser
{
$workspaceId = (int) session('current_workspace_id');
return $this->resolvedWorkspaceUsers[$workspaceId] ??= WorkspaceUser::where('user_id', $this->id)
->where('workspace_id', $workspaceId)
->firstOrFail();
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()

View File

@@ -75,10 +75,20 @@ class Workspace extends Model
{
return $this->belongsToMany(User::class, 'workspace_user')
->using(WorkspaceUser::class)
->withPivot('role')
->withPivot('role', 'permissions')
->withTimestamps();
}
/**
* Get the team invitations for the workspace.
*
* @return HasMany<TeamInvitation>
*/
public function teamInvitations(): HasMany
{
return $this->hasMany(TeamInvitation::class);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()