# Story 1.3: Role Assignment & Member Removal Status: done ## Story As a firm owner, I want to change a team member's role or remove them from the workspace, So that I can adjust team structure as my firm's needs evolve. ## Acceptance Criteria 1. **Action menu on member rows** — Each active team member row (except the current user) displays a "..." action button that opens a DropdownMenu with "Changer le rôle" and "Retirer de l'espace" options. 2. **Change Role dialog** — Clicking "Changer le rôle" opens a Dialog with a Select dropdown pre-filled with the member's current role (Manager/Worker — Owner is never assignable). Confirming sends a `PATCH /team/{workspaceUser}/role` request. 3. **Role change resets permissions** — When a member's role changes, the `permissions` JSON column is reset to `config('permissions.defaults')` for the new role. Changing from Manager to Worker clears all permissions. Changing from Worker to Manager sets Manager defaults. 4. **Remove member confirmation** — Clicking "Retirer de l'espace" opens a confirmation Dialog with the member's name and a destructive "Retirer" button. Confirming sends a `DELETE /team/{workspaceUser}` request. 5. **Removal detaches, not deletes** — Removing a member detaches them from the workspace (`workspace_user` pivot row deleted). Their user account remains intact. 6. **Self-action prevention** — The action menu is NOT shown for the current authenticated user's own row. Owners cannot change their own role or remove themselves. 7. **Manager with `can_manage_team` access** — Managers with `can_manage_team` permission can also change roles and remove members, EXCEPT they cannot modify or remove the Owner. 8. **Activity logging** — All role changes and removals are logged via Spatie Activity Log with actor, target user name, old role, and new role (or "removed"). 9. **Success toasts** — A success toast confirms each action: "Rôle mis à jour" for role changes, "Membre retiré" for removals. 10. **Immediate list update** — The team list updates immediately after each action (Inertia redirect back). ## Tasks / Subtasks - [x] Task 1: Add `updateRole` and `remove` methods to `TeamController` (AC: #2, #3, #4, #5, #6, #7, #8, #9, #10) - [x] 1.1 Add `updateRole(Request $request, WorkspaceUser $workspaceUser)` method - Validate role is `in:manager,worker` via `UpdateTeamMemberRoleRequest` - Block if target is Owner (abort 404) - Block if target is current user (abort 404) - Block if actor is Manager and target is Owner (abort 404) - Update `role` column on `workspace_user` pivot - Reset `permissions` to `config('permissions.defaults.{new_role}')` - Log activity via `activity()->performedOn($workspaceUser)->withProperties([...])->log('role_changed')` - Redirect back with `'success' => 'Rôle mis à jour'` - [x] 1.2 Add `remove(WorkspaceUser $workspaceUser)` method - Block if target is Owner (abort 404) - Block if target is current user (abort 404) - Block if actor is Manager and target is Owner (abort 404) - Detach user from workspace: `$workspace->users()->detach($workspaceUser->user_id)` - Log activity via `activity()->withProperties([...])->log('member_removed')` - Redirect back with `'success' => 'Membre retiré'` - [x] 1.3 Both methods use `authorizePermission(Permission::CanManageTeam)` for authorization (Owners auto-pass, Workers abort 404, Managers check permission) - [x] Task 2: Create `UpdateTeamMemberRoleRequest` form request (AC: #2, #7) - [x] 2.1 Create `app/Http/Requests/UpdateTeamMemberRoleRequest.php` - [x] 2.2 `authorize()`: verify user is Owner OR Manager with `can_manage_team` permission - [x] 2.3 `rules()`: validate `role` (required, in: manager, worker) - [x] Task 3: Add routes (AC: #2, #4) - [x] 3.1 Add inside workspace middleware group in `routes/web.php`: - `Route::patch('team/{workspaceUserId}/role', [TeamController::class, 'updateRole'])->name('team.updateRole')` - `Route::delete('team/{workspaceUserId}', [TeamController::class, 'remove'])->name('team.remove')` - [x] Task 4: Update `TeamController::index()` to pass action URLs and auth user ID (AC: #1, #6) - [x] 4.1 Pass `authUserId` prop (current user's ID) to frontend for self-action prevention - [x] 4.2 Pass `updateRoleUrl` pattern and `removeUrl` pattern — OR pass per-member action URLs in the members array - [x] 4.3 Include `workspace_user_id` (pivot ID) in each member's data for route model binding - [x] Task 5: Update `team/Index.vue` page (AC: #1, #2, #4, #6, #9, #10) - [x] 5.1 Add "Actions" column to table (only visible when `canManageTeam` is true) - [x] 5.2 Add DropdownMenu with MoreHorizontal icon trigger for each active member row (not for pending invitations, not for current user) - [x] 5.3 Add "Changer le rôle" DropdownMenuItem opening a role change Dialog with Select and confirm Button - [x] 5.4 Add "Retirer de l'espace" DropdownMenuItem opening a destructive confirmation Dialog - [x] 5.5 Use `router.patch()` for role update and `router.delete()` for removal via Inertia - [x] 5.6 Hide action menu for Owner rows (when viewed by a Manager) - [x] Task 6: Update TypeScript types (AC: #1) - [x] 6.1 Add `workspace_user_id: number` to `TeamMember` type - [x] 6.2 Add `authUserId: number` and action URL props to `TeamPageProps` - [x] 6.3 Add `updateRoleUrl` and `removeUrl` function type or string template - [x] Task 7: Write tests (AC: #1–#10) - [x] 7.1 Create `tests/Feature/Team/TeamRoleAssignmentTest.php` - [x] 7.2 Test: Owner can change a member's role from Worker to Manager - [x] 7.3 Test: Owner can change a member's role from Manager to Worker - [x] 7.4 Test: Role change resets permissions to defaults for new role - [x] 7.5 Test: Owner cannot change own role (returns 404) - [x] 7.6 Test: Manager with `can_manage_team` can change Worker's role - [x] 7.7 Test: Manager with `can_manage_team` cannot change Owner's role (returns 404) - [x] 7.8 Test: Manager without `can_manage_team` gets 404 - [x] 7.9 Test: Worker gets 404 on role change attempt - [x] 7.10 Test: Role change is logged in activity log - [x] 7.11 Create `tests/Feature/Team/TeamMemberRemovalTest.php` - [x] 7.12 Test: Owner can remove a member (user detached, not deleted) - [x] 7.13 Test: Owner cannot remove themselves (returns 404) - [x] 7.14 Test: Manager with `can_manage_team` can remove Worker - [x] 7.15 Test: Manager with `can_manage_team` cannot remove Owner (returns 404) - [x] 7.16 Test: Manager without `can_manage_team` gets 404 - [x] 7.17 Test: Worker gets 404 on remove attempt - [x] 7.18 Test: Member removal is logged in activity log - [x] 7.19 Test: Removed user's account still exists (not deleted) - [x] 7.20 Run full test suite: `composer test` ## Dev Notes ### Architecture Constraints (MUST FOLLOW) - **Enum library**: Use `bensampo/laravel-enum` ^6.12 (NOT native PHP enums). Use `->is()` for comparisons (NOT `===`). - **Model casts**: Use method-based `protected function casts(): array` (NEVER `$casts` property) - **Authorization**: Always `abort(404)` for permission failures (NEVER `abort(403)`) — intentional to hide workspace existence - **No Policies/Gates**: This project uses custom `authorizeXxx()` methods in traits/controllers, NOT Laravel Policies or Gates - **No Spatie Permission package**: Permissions use the JSON column on `workspace_user` pivot — do NOT install `spatie/laravel-permission` - **Mass assignment**: Explicit `$fillable` arrays (NEVER `$guarded = []`) - **Workspace scoping**: Always from `session('current_workspace_id')`, never from request params - **Validation**: Use dedicated FormRequest classes, never inline `$request->validate()` - **URLs in Vue**: All URLs must be passed as props from PHP controllers via `route()` helper — never hardcode routes in Vue - **Inertia render paths**: Use lowercase subdirectory: `'team/Index'` (not `'Team/Index'`) - **Activity logging**: New business operations must log via Spatie Activity Log with `activity()` helper ### Route Model Binding for WorkspaceUser The `WorkspaceUser` model extends `Pivot` (not `Model`). Laravel's route model binding works differently with Pivot models. You **MUST** verify this works: - `WorkspaceUser` extends `Illuminate\Database\Eloquent\Relations\Pivot` - Route model binding on Pivot models may NOT work out of the box - **If route model binding fails**, use explicit resolution: receive `int $workspaceUserId` parameter and manually query `WorkspaceUser::findOrFail($workspaceUserId)` - **CRITICAL**: Always verify the resolved WorkspaceUser belongs to the current workspace (from session). If `$workspaceUser->workspace_id !== session('current_workspace_id')`, `abort(404)`. ### Controller Pattern (extending Story 1.2's TeamController) Add methods to the EXISTING `app/Http/Controllers/TeamController.php`: ```php use App\Http\Requests\UpdateTeamMemberRoleRequest; public function updateRole(UpdateTeamMemberRoleRequest $request, int $workspaceUserId): RedirectResponse { $workspaceUser = WorkspaceUser::where('id', $workspaceUserId) ->where('workspace_id', session('current_workspace_id')) ->firstOrFail(); // Cannot modify Owner if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) { abort(404); } // Cannot modify self if ($workspaceUser->user_id === auth()->id()) { abort(404); } $oldRole = $workspaceUser->role->value; $newRole = $request->validated('role'); $workspaceUser->update([ 'role' => $newRole, 'permissions' => config("permissions.defaults.{$newRole}", []), ]); // Log with actor context $targetUser = User::find($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'); } public function remove(int $workspaceUserId): RedirectResponse { $this->authorizePermission(Permission::CanManageTeam); $workspaceUser = WorkspaceUser::where('id', $workspaceUserId) ->where('workspace_id', session('current_workspace_id')) ->firstOrFail(); // Cannot remove Owner if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) { abort(404); } // Cannot remove self if ($workspaceUser->user_id === auth()->id()) { abort(404); } $targetUser = User::find($workspaceUser->user_id); activity() ->withProperties([ 'target_user' => $targetUser->name, 'target_email' => $targetUser->email, 'role' => $workspaceUser->role->value, ]) ->log('member_removed'); $workspaceUser->delete(); // Delete the pivot row return redirect()->back()->with('success', 'Membre retiré'); } ``` ### WorkspaceUser Pivot ID The `workspace_user` table has an auto-incrementing `id` column (standard for Pivot models using `->withTimestamps()`). Check this — if the table does NOT have an `id` column, you need to use composite key lookup `(workspace_id, user_id)` instead. To pass the pivot ID to frontend, update the members mapping in `index()`: ```php 'workspace_user_id' => $user->pivot->id, ``` **If the pivot table has no `id` column:** Use `user_id` instead and adjust routes to use `{userId}` with manual workspace scoping. ### Permission Defaults Application When resetting permissions on role change, use the config values: ```php // config/permissions.php 'defaults' => [ 'owner' => ['*'], 'manager' => [ 'can_manage_team' => false, 'can_view_activity_logs' => true, 'can_configure_portal' => false, ], 'worker' => [], ] ``` For `updateRole`, set `permissions` to `config("permissions.defaults.{$newRole}", [])`. ### Activity Log Pattern This project uses `spatie/laravel-activitylog`. For custom logging (not model-level), use the `activity()` helper: ```php activity() ->performedOn($model) // optional: attach to a model ->causedBy(auth()->user()) // auto-detected if auth user exists ->withProperties([...]) // key-value pairs for context ->log('description'); // the log message ``` The `causedBy` is auto-set when an authenticated user exists, so you don't need to call it explicitly. ### Frontend Action Pattern **Available UI components for actions:** - `DropdownMenu` + variants (installed, in `@/components/ui/dropdown-menu`) - `Dialog` (installed, in `@/components/ui/dialog`) — use for both role change and remove confirmation - `Select` (installed) — for role selection - **NO** `AlertDialog` installed — use `Dialog` with destructive button styling for confirmations - **NO** `Popover` installed — use `Dialog` for role change **Action column pattern:** ```vue Changer le rôle Retirer de l'espace ``` **Inertia programmatic requests (NOT useForm for these):** ```vue import { router } from '@inertiajs/vue3'; function submitRoleChange() { router.patch(roleChangeUrl.value, { role: selectedRole.value }, { onSuccess: () => { showRoleDialog.value = false; }, }); } function submitRemove() { router.delete(removeUrl.value, { onSuccess: () => { showRemoveDialog.value = false; }, }); } ``` **Lucide icons to use:** `MoreHorizontal` (action trigger), `UserCog` (change role), `UserMinus` (remove member). ### Self-Action Prevention Logic Frontend: Hide the action menu for rows where `member.id === authUserId`. Backend: Double-check in both `updateRole` and `remove` — `abort(404)` if `$workspaceUser->user_id === auth()->id()`. Owners viewing their own row: no action menu shown. The Owner row also has no action menu when a Manager views the team (Managers cannot modify Owners). ### French Labels for New UI Elements - Action menu: no label (icon-only "..." button) - Change role option: "Changer le rôle" - Remove option: "Retirer de l'espace" - Role change dialog title: "Changer le rôle" - Role change dialog description: "Sélectionnez le nouveau rôle pour {member.name}" - Role change confirm button: "Confirmer" - Remove dialog title: "Retirer le membre" - Remove dialog description: "Êtes-vous sûr de vouloir retirer {member.name} de l'espace de travail ? Cette action est irréversible." - Remove confirm button: "Retirer" (destructive styling) - Success toasts: "Rôle mis à jour", "Membre retiré" ### Project Structure Notes - **Modified controller**: `app/Http/Controllers/TeamController.php` (add `updateRole`, `remove` methods) - **New form request**: `app/Http/Requests/UpdateTeamMemberRoleRequest.php` - **Modified routes**: `routes/web.php` (add PATCH and DELETE team routes) - **Modified Vue page**: `resources/js/pages/team/Index.vue` (add actions column, dialogs) - **Modified types**: `resources/js/types/team.ts` (add `workspace_user_id`, `authUserId`, action URLs) - **New tests**: `tests/Feature/Team/TeamRoleAssignmentTest.php`, `tests/Feature/Team/TeamMemberRemovalTest.php` - **No new models, no new migrations, no new packages** ### Testing Standards - **Framework**: Pest 4 with `test()` closures and `expect()` assertions - **`RefreshDatabase`**: Auto-applied via `Pest.php` for Feature tests — do NOT add manually - **Run command**: `composer test` (clears config → runs Pint → runs tests) - **Test both**: Happy path (authorized) AND sad path (unauthorized → 404) - **Route helper**: Use `route()` helper, never hardcoded URLs - **Activity log testing**: Use `Activity::all()` or `Activity::latest()->first()` to assert log entries exist with correct properties - **Session setup**: `session(['current_workspace_id' => $workspace->id])` - **Auth**: `$this->actingAs($user)` - **Factory pattern**: Create workspace, attach users with roles via `$workspace->users()->attach($user, ['role' => 'worker', 'permissions' => []])` - **Rollback step count**: Adding NO new migrations — existing `RenameFoldersToDeclarationsTest` rollback step count should NOT change ### References - [Source: _bmad-output/planning-artifacts/epics.md#Epic-1 — Story 1.3 requirements and acceptance criteria] - [Source: _bmad-output/planning-artifacts/architecture.md#Route-Structure — team routes (PATCH role, DELETE member)] - [Source: _bmad-output/planning-artifacts/architecture.md#D1-Permission-Toggle-Storage — JSON permissions reset on role change] - [Source: _bmad-output/planning-artifacts/architecture.md#Phase-1-Files — file locations] - [Source: _bmad-output/planning-artifacts/ux-design-specification.md — "Action at the point of recognition" principle, contextual menus] - [Source: _bmad-output/project-context.md — All coding rules and conventions] - [Source: _bmad-output/implementation-artifacts/1-1-*.md — Permission system, AuthorizesPermissions trait] - [Source: _bmad-output/implementation-artifacts/1-2-*.md — TeamController, team page, invitation patterns] ### Previous Story Intelligence (Story 1.1 + 1.2 Learnings) - **bensampo/laravel-enum**: Uses `->is()` for comparisons, NOT `===` (Enum instance vs string constant) - **`permissions` column**: Already cast to `array` on WorkspaceUser model — do NOT `json_encode()` when setting via `update()` (causes double-encoding) - **Migration count matters**: Adding new migrations shifts rollback step counts — but this story adds NO migrations - **FK constraints**: Use explicit `->on('table_name')` (never bare `->constrained()`) - **Scope discipline**: Only modify files directly required by acceptance criteria — no cosmetic changes - **Flash messages**: `HandleInertiaRequests` already shares `success`/`error` flash keys (fixed in Story 1.2 code review) - **Toast display**: `AppSidebarLayout` already has flash message toast display (added in Story 1.2 code review) - **Wayfinder routes**: Use wayfinder-generated typed routes for breadcrumbs (e.g., `dashboard()`, `teamIndex()`) - **Role serialization**: Use `$user->pivot->role?->value` when passing enum values to Inertia (avoid passing enum objects) - **Code review findings from 1.2**: [H1] Empty state Dialog structure matters. [H2] Always use wayfinder routes for breadcrumbs. [H3] Flash message handling must be in HandleInertiaRequests. [M1] Role labels should come from props, not be hardcoded in Vue. - **Config defaults not consumed yet**: Story 1.1 code review noted that `config('permissions.defaults')` was defined but not consumed. THIS STORY must consume it when resetting permissions on role change. ### Git Intelligence Recent commits (4 total on branch `l-ami-fiduciaire-v1.0.0`): - `5dffd2d` chore: complete Epic 0 retrospective - `fd43a6f` feat: complete Epic 0 - `d380df4` chore: add BMAD workflow commands - `35545c2` feat: full codebase with Story 0.1 complete Stories 1.1 and 1.2 are implemented but not yet committed (visible in git status as modified/untracked files). Current test suite: 117 tests, 317 assertions, 0 failures. ### Critical Implementation Warnings 1. **Do NOT create a new migration** — The `workspace_user` table already has `role` and `permissions` columns. This story only updates existing pivot rows. 2. **Do NOT install any new packages** — Use existing UI components (DropdownMenu, Dialog, Select). No `AlertDialog` or `Popover` needed. 3. **WorkspaceUser extends Pivot, NOT Model** — Route model binding may not auto-resolve. Use manual query with `WorkspaceUser::where(...)` and scope to current workspace. 4. **Reset permissions on role change** — This is the FIRST story that actually consumes `config('permissions.defaults')`. When changing Worker→Manager, apply Manager defaults. When changing Manager→Worker, set empty array. 5. **Detach, NOT delete user** — `remove()` only deletes the `workspace_user` pivot row. The user account in the `users` table must remain intact. 6. **Manager cannot modify Owner** — Both `updateRole()` and `remove()` must check if target is Owner and abort(404). A Manager with `can_manage_team` can modify Workers and other Managers, but NEVER the Owner. 7. **Owner protection is CRITICAL** — This is a security boundary. The Owner must never be removable or demotable. Test this thoroughly. 8. **Activity log for non-model operations** — Use `activity()` helper (not model-level LogsActivity trait) since we're logging business operations, not model mutations directly. ## Dev Agent Record ### Agent Model Used Claude Opus 4.6 ### Debug Log References - Fixed pivot `id` not being available: added `'id'` to `withPivot()` call in `TeamController::index()` — Pivot models don't include `id` by default, causing `route()` generation to fail with null parameter. ### Completion Notes List - Implemented `updateRole()` and `remove()` methods on `TeamController` with full authorization checks (Owner auto-pass, Manager permission check, Worker block), self-action prevention, and activity logging. - Used manual `WorkspaceUser::where()` lookup instead of route model binding since `WorkspaceUser` extends `Pivot` — scoped to current workspace via session. - Role changes reset permissions to `config('permissions.defaults.{role}')` — this is the first story consuming these config defaults. - `remove()` deletes the pivot row only (`$workspaceUser->delete()`), user account remains intact. - Created `UpdateTeamMemberRoleRequest` with `authorize()` mirroring the `InviteTeamMemberRequest` pattern and `failedAuthorization()` returning 404. - Frontend uses `DropdownMenu` with `MoreHorizontal` trigger, `Dialog` for role change (with `Select`) and removal confirmation (with destructive button styling). - Action URLs passed as per-member props from PHP controller — no hardcoded routes in Vue. - Self-action prevention: frontend hides action menu for `authUserId` rows; backend double-checks in both methods. - Owner rows also hidden from Manager action menus. - 17 new tests across 2 test files covering all acceptance criteria, authorization paths, permission resets, activity logging, and user account preservation. - Full test suite: 134 tests, 350 assertions, 0 failures, no regressions. ### File List - `app/Http/Controllers/TeamController.php` — Modified: added `updateRole()`, `remove()` methods; updated `index()` to pass `authUserId`, `workspace_user_id`, `updateRoleUrl`, `removeUrl` per member; added `'id'` to `withPivot()` - `app/Http/Requests/UpdateTeamMemberRoleRequest.php` — New: form request with authorization and role validation - `routes/web.php` — Modified: added PATCH `team/{workspaceUserId}/role` and DELETE `team/{workspaceUserId}` routes - `resources/js/pages/team/Index.vue` — Modified: added actions column with DropdownMenu, role change Dialog, and remove confirmation Dialog - `resources/js/types/team.ts` — Modified: added `workspace_user_id`, `updateRoleUrl`, `removeUrl` to `TeamMember`; added `authUserId` to `TeamPageProps` - `tests/Feature/Team/TeamRoleAssignmentTest.php` — New: 10 tests for role assignment (9 original + 1 added in review) - `tests/Feature/Team/TeamMemberRemovalTest.php` — New: 8 tests for member removal ## Senior Developer Review (AI) **Reviewer:** Saad on 2026-03-15 **Outcome:** Approved (after fixes applied) ### Findings & Fixes Applied | # | Severity | Issue | Fix | |---|----------|-------|-----| | H1 | HIGH | `User::find()` could return null in `updateRole()` and `remove()`, causing 500 error | Changed to `User::findOrFail()` | | H2 | HIGH | No DB transaction around multi-step operations (update + activity log) | Wrapped both methods in `DB::transaction()` | | M1 | MEDIUM | No loading/disabled state on role change and remove buttons — double-click risk | Added `roleChangeProcessing`/`removeProcessing` refs with `:disabled` bindings | | M2 | MEDIUM | Same-role submission allowed, pointlessly resetting permissions | Added `isRoleUnchanged` computed, disabled confirm button when unchanged | | M3 | MEDIUM | Authorization approach inconsistency between `updateRole` (FormRequest) and `remove` (trait) | Added `authorizePermission()` call to `updateRole()` for consistency | | L1 | LOW | Redundant `getMemberData()` calls (3x per row) in template | Not fixed — minor perf, acceptable for team-size lists | | L2 | LOW | Missing test: Manager changing another Manager's role | Added test: "manager with can_manage_team can change another manager role" | | L3 | LOW | Activity logged before delete without transaction in `remove()` | Fixed via H2 — delete now runs first inside transaction | ### Post-Review Test Results - **135 tests, 353 assertions, 0 failures** (up from 134/350 — 1 new test added) ## Change Log - 2026-03-15: Implemented Story 1.3 — Role Assignment & Member Removal. Added `updateRole` and `remove` endpoints with authorization, permission reset on role change, activity logging, frontend action menus with dialogs, and 17 comprehensive tests. - 2026-03-15: Code review — Fixed 5 HIGH/MEDIUM issues (null safety, DB transactions, loading states, same-role guard, auth consistency). Added 1 missing test. 135 tests, 353 assertions, 0 failures.