Files
L-Ami-Fiduciaire/_bmad-output/implementation-artifacts/1-2-team-management-page-view-and-invite-members.md
Saad Ibn-Ezzoubayr c89d1879bf 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>
2026-03-18 00:12:50 +00:00

421 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Story 1.2: Team Management Page — View & Invite Members
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
As a firm owner,
I want to view all team members in my workspace and invite new members via email,
So that I can build my team and see who has access to my firm's data.
## Acceptance Criteria
1. **Team index page** — A route `GET /team` renders a team page displaying all workspace members in a table with columns: name, email, role (as Badge), join date, and status (active/pending).
2. **Pending invitations** — Invited users who have not yet accepted show a "Pending" StatusBadge in the status column.
3. **Invite Member button** — An "Inviter un membre" button opens a Dialog/Sheet form with:
- Email input (required, valid email format)
- Role selection dropdown (Manager or Worker — Owner is never assignable)
4. **Invitation email** — Submitting the invite form sends an invitation email to the specified address with a link to register/join the workspace.
5. **Immediate feedback** — The invitation appears in the team list as "Pending" immediately after submission. A success toast confirms: "Invitation envoyée".
6. **Workspace scoping** — The `TeamController` uses the `HasWorkspaceScope` trait to scope members to the current workspace.
7. **Worker access denied** — Workers cannot access the Team page (`abort(404)` via `AuthorizesPermissions`).
8. **Manager conditional access** — Managers can view the team list but only see the "Inviter un membre" button if they have `can_manage_team` permission.
9. **Layout & breadcrumbs** — The page uses `AppLayout` with breadcrumbs: `Dashboard / Équipe`.
10. **EmptyState** — When the workspace has only the owner, an EmptyState is shown: "Invitez votre premier membre d'équipe" with the invite action button.
## Tasks / Subtasks
- [x] Task 1: Create `TeamController` with `index` and `invite` methods (AC: #1, #6, #7, #8)
- [x] 1.1 Create `app/Http/Controllers/TeamController.php` using `HasWorkspaceScope` and `AuthorizesPermissions` traits
- [x] 1.2 `index()` method: block Workers (`authorizePermission` with early abort for Worker role), load workspace members with pivot data, load pending invitations, render Inertia page `team/Index`
- [x] 1.3 `invite()` method: validate with `InviteTeamMemberRequest`, create invitation record, send invitation email, redirect back with success toast
- [x] 1.4 Pass `canManageTeam` boolean prop to frontend (true for Owner, check permission for Manager)
- [x] Task 2: Create `InviteTeamMemberRequest` form request (AC: #3)
- [x] 2.1 Create `app/Http/Requests/InviteTeamMemberRequest.php`
- [x] 2.2 `authorize()`: verify user is Owner OR Manager with `can_manage_team` permission
- [x] 2.3 `rules()`: validate `email` (required, email format, not already in workspace), `role` (required, in: manager, worker)
- [x] Task 3: Create `TeamInvitation` model and migration (AC: #2, #4)
- [x] 3.1 Create migration `create_team_invitations_table`: `id`, `workspace_id` (FK), `email`, `role` (string), `token` (uuid, unique), `invited_by` (FK to users), `accepted_at` (nullable datetime), `expires_at` (datetime), timestamps
- [x] 3.2 Create `app/Models/TeamInvitation.php` with fillable, casts, relationships (`workspace`, `invitedBy`), `isValid()` method, auto-generate token on creating
- [x] 3.3 Add `teamInvitations(): HasMany` relationship to `Workspace` model
- [x] Task 4: Create `TeamInvitationMail` mailable (AC: #4)
- [x] 4.1 Create `app/Mail/TeamInvitationMail.php` following existing mailable pattern (envelope + content + markdown)
- [x] 4.2 Create markdown email template `resources/views/emails/team-invitation.blade.php` with workspace name, role, and registration/accept link
- [x] 4.3 Queue the mail dispatch (use `ShouldQueue` or dispatch via queue)
- [x] Task 5: Create routes (AC: #1)
- [x] 5.1 Add to `routes/web.php` inside the workspace middleware group:
- `Route::get('team', [TeamController::class, 'index'])->name('team.index')`
- `Route::post('team/invite', [TeamController::class, 'invite'])->name('team.invite')`
- [x] Task 6: Create `team/Index.vue` page (AC: #1, #2, #3, #5, #8, #9, #10)
- [x] 6.1 Create `resources/js/pages/team/Index.vue` with `<script setup lang="ts">`
- [x] 6.2 Define Props type: `members` (array of team member objects), `pendingInvitations` (array), `canManageTeam` (boolean)
- [x] 6.3 Render table with columns: Name, Email, Role (Badge), Joined (date), Status (active/pending Badge)
- [x] 6.4 "Inviter un membre" button visible only when `canManageTeam` is true
- [x] 6.5 Invite Dialog with email input + role Select (Manager/Worker) using Inertia `useForm`
- [x] 6.6 EmptyState when members count is 1 (only owner) and no pending invitations
- [x] 6.7 AppLayout with breadcrumbs `[{ title: 'Dashboard', href: route('dashboard') }, { title: 'Équipe', href: route('team.index') }]`
- [x] Task 7: Create TypeScript types (AC: #1)
- [x] 7.1 Create `resources/js/types/team.ts` with `TeamMember`, `TeamInvitation`, and `TeamPageProps` types
- [x] 7.2 Export from `resources/js/types/index.ts`
- [x] Task 8: Write tests (AC: #1#10)
- [x] 8.1 Create `tests/Feature/Team/ManageTeamTest.php`
- [x] 8.2 Test: Owner can view team index page
- [x] 8.3 Test: Manager with `can_manage_team` can view team index page
- [x] 8.4 Test: Manager without `can_manage_team` can view team index but cannot see invite button (check Inertia prop `canManageTeam: false`)
- [x] 8.5 Test: Worker receives 404 on team index
- [x] 8.6 Test: Owner can invite a new member (POST /team/invite)
- [x] 8.7 Test: Manager with `can_manage_team` can invite a new member
- [x] 8.8 Test: Manager without permission gets 404 on invite
- [x] 8.9 Test: Worker gets 404 on invite
- [x] 8.10 Test: Cannot invite email already in workspace
- [x] 8.11 Test: Invitation creates TeamInvitation record with correct data
- [x] 8.12 Test: Invitation sends email (Mail::fake assertion)
- [x] 8.13 Run full test suite: `composer test`
## Dev Notes
### Architecture Constraints (MUST FOLLOW)
- **Enum library**: Use `bensampo/laravel-enum` ^6.12 (NOT native PHP enums). All existing enums follow this pattern.
- **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 models must add Spatie `LogsActivity` trait + `getActivitylogOptions()`
### Controller Pattern (from Story 1.1)
The `TeamController` must use both traits created in Story 1.1:
```php
use App\Concerns\HasWorkspaceScope;
use App\Concerns\AuthorizesPermissions;
class TeamController extends Controller
{
use HasWorkspaceScope;
use AuthorizesPermissions;
public function index(): Response
{
// Block Workers entirely — team page is Owner/Manager only
$workspaceUser = auth()->user()->currentWorkspaceUser();
if ($workspaceUser->role->is(WorkspaceUserRole::Worker)) {
abort(404);
}
$workspace = $this->currentWorkspace();
// Load members with pivot data
$members = $workspace->users()
->withPivot('role', 'permissions', 'created_at')
->get()
->map(fn ($user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->pivot->role,
'joined_at' => $user->pivot->created_at,
'status' => 'active',
]);
// 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,
'inviteUrl' => route('team.invite'),
'roles' => $this->roleLabels(),
]);
}
}
```
### Permission Checking for Team Page
- **Workers**: `abort(404)` immediately — Workers never see the team page
- **Managers without `can_manage_team`**: Can VIEW the team list but cannot see the invite button. The `canManageTeam` prop controls this on the frontend.
- **Managers with `can_manage_team`**: Full access — view list + invite members
- **Owners**: Full access always
This is NOT a standard `authorizePermission()` call because Managers can view even without the permission — they just can't invite. The controller must handle this custom logic.
### Invitation Flow Design
**Database-backed invitations** (NOT just "add user by email"):
1. Owner/Manager fills invite form (email + role)
2. Backend creates `TeamInvitation` record with UUID token + expiry (7 days)
3. Backend sends email with invitation link: `/register?invitation={token}`
4. Invited user registers (or logs in if account exists) → invitation accepted, user attached to workspace with specified role
5. The invitation acceptance flow is **NOT part of this story** — Story 1.2 only covers the invite + pending display. Acceptance will be handled when the user registers (existing registration flow can be enhanced later).
**Why a dedicated `TeamInvitation` model?** Because the existing `DeclarationInvitation` model is for client portal tokens (different purpose). Team invitations need different fields (role, invited_by, workspace_id) and different lifecycle.
### Existing Code to Build On
- **`HasWorkspaceScope` trait** (`app/Concerns/HasWorkspaceScope.php`): Provides `currentWorkspace()` and `authorizeWorkspaceAccess()`. Use `currentWorkspace()` to get the workspace for member queries.
- **`AuthorizesPermissions` trait** (`app/Concerns/AuthorizesPermissions.php`): Provides `authorizePermission()`. NOT directly used for index (custom logic needed), but use for invite authorization.
- **`User` model** (`app/Models/User.php`): Has `workspaces(): BelongsToMany` with `withPivot('role', 'permissions')` and `currentWorkspaceUser()` method.
- **`Workspace` model** (`app/Models/Workspace.php`): Has `users(): BelongsToMany` relationship.
- **`WorkspaceUser` model** (`app/Models/WorkspaceUser.php`): Pivot model with `role` (WorkspaceUserRole enum) and `permissions` (array cast).
- **`WorkspaceUserRole` enum** (`app/Enums/WorkspaceUserRole.php`): `Owner`, `Manager`, `Worker` — use `->is()` for comparison (bensampo/laravel-enum).
- **`Permission` enum** (`app/Enums/Permission.php`): `CanManageTeam`, `CanViewActivityLogs`, `CanConfigurePortal`.
- **`EnsureUserHasWorkspace` middleware**: Already validates workspace access via session. Applied via 'workspace' middleware group in routes.
- **`DeclarationInvitation` model**: Reference pattern for token-based invitations (auto UUID, expiry, `isValid()` method).
- **Existing mailable pattern**: `DeclarationInviteMail` — envelope + content + markdown template.
### Vue Component Patterns (from existing pages)
**Table pattern** (from `clients/Index.vue`, `users/Index.vue`):
```vue
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Rejoint le</TableHead>
<TableHead>Statut</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="member in allMembers" :key="member.id" class="hover:bg-muted/50">
<!-- cells -->
</TableRow>
</TableBody>
</Table>
```
**Dialog/Form pattern** (from existing forms):
```vue
<Dialog v-model:open="showInviteDialog">
<DialogTrigger as-child>
<Button v-if="canManageTeam">
<UserPlus class="mr-2 h-4 w-4" />
Inviter un membre
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Inviter un membre</DialogTitle>
</DialogHeader>
<form @submit.prevent="submitInvite">
<!-- email + role fields -->
</form>
</DialogContent>
</Dialog>
```
**Inertia form usage**:
```vue
const form = useForm({
email: '',
role: 'worker',
});
function submitInvite() {
form.post(props.inviteUrl, {
onSuccess: () => {
showInviteDialog.value = false;
form.reset();
},
});
}
```
### French Labels for UI
- Page title: "Équipe"
- Invite button: "Inviter un membre"
- Dialog title: "Inviter un membre"
- Email label: "Adresse email"
- Role label: "Rôle"
- Role options: "Gestionnaire" (Manager), "Collaborateur" (Worker)
- Submit button: "Envoyer l'invitation"
- Success toast: "Invitation envoyée"
- Status badges: "Actif", "En attente"
- Empty state title: "Aucun membre"
- Empty state description: "Invitez votre premier membre d'équipe"
- Table headers: "Nom", "Email", "Rôle", "Rejoint le", "Statut"
- Breadcrumbs: "Dashboard" / "Équipe"
### Project Structure Notes
- **New controller**: `app/Http/Controllers/TeamController.php`
- **New form request**: `app/Http/Requests/InviteTeamMemberRequest.php`
- **New model**: `app/Models/TeamInvitation.php`
- **New migration**: `database/migrations/xxxx_create_team_invitations_table.php`
- **New mailable**: `app/Mail/TeamInvitationMail.php`
- **New email template**: `resources/views/emails/team-invitation.blade.php`
- **New Vue page**: `resources/js/pages/team/Index.vue`
- **New types**: `resources/js/types/team.ts`
- **New tests**: `tests/Feature/Team/ManageTeamTest.php`
- **Modified routes**: `routes/web.php` (add team routes inside workspace middleware group)
- **Modified model**: `app/Models/Workspace.php` (add `teamInvitations()` relationship)
- **Modified types index**: `resources/js/types/index.ts` (export team types)
### 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
- **Mail testing**: Use `Mail::fake()` to assert emails are sent
- **Inertia assertions**: Use `$response->assertInertia(fn (Assert $page) => ...)` for prop validation
- **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' => 'owner'])`
### References
- [Source: _bmad-output/planning-artifacts/epics.md#Epic-1 — Story 1.2 requirements and acceptance criteria]
- [Source: _bmad-output/planning-artifacts/architecture.md#Phase-1-Files — TeamController, InviteTeamMemberRequest, team/ pages]
- [Source: _bmad-output/planning-artifacts/architecture.md#Route-Structure — team.index and team routes]
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md — Role-driven sidebar, EmptyState pattern, French-native UI]
- [Source: _bmad-output/planning-artifacts/architecture.md#D1-Permission-Toggle-Storage — JSON permissions, can_manage_team]
- [Source: _bmad-output/project-context.md — All coding rules and conventions]
- [Source: _bmad-output/implementation-artifacts/1-1-*.md — Previous story patterns and learnings]
### Previous Story Intelligence (Story 1.1 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 attaching via relationship (causes double-encoding)
- **Unit tests in `tests/Unit/`**: Need explicit `uses(Tests\TestCase::class, RefreshDatabase::class)` since Pest.php only auto-applies these for Feature tests
- **Migration count matters**: Adding new migrations shifts rollback step counts in existing migration tests (Story 1.1 already fixed this for RenameFoldersToDeclarationsTest)
- **Code review feedback from 1.1**: [H1] Always include 'permissions' in withPivot() when querying workspace users. [M2] Config defaults defined but not consumed yet — future stories will apply defaults on invite.
- **Enum convention**: `bensampo/laravel-enum` with `labels(): array` for French display and custom methods
- **FK constraints**: Use explicit `->on('table_name')` (never bare `->constrained()`)
- **Scope discipline**: Only modify files directly required by acceptance criteria — no cosmetic changes
### Git Intelligence
Recent commits show Epic 0 complete and Story 1.1 implemented. Branch `l-ami-fiduciaire-v1.0.0` has 4 commits. Story 1.1 created:
- `app/Concerns/HasWorkspaceScope.php` and `AuthorizesPermissions.php` traits
- `app/Enums/Permission.php` enum
- `config/permissions.php` config
- `User::currentWorkspaceUser()` method
- Renamed Member → Worker across codebase
All 105 tests passing after Story 1.1 code review fixes.
### Critical Implementation Warnings
1. **Do NOT create a TeamController that uses `authorizePermission(Permission::CanManageTeam)` in `index()`** — this would block Managers without the permission from VIEWING the team list. Managers can always VIEW, they just can't INVITE. Use custom role checking logic.
2. **Do NOT install any new packages** — use Laravel's built-in Mail and existing queue infrastructure. No new Composer or npm dependencies needed.
3. **Apply role defaults on invite** — When a new member is invited with role Manager, the `permissions` JSON column should be populated from `config('permissions.defaults')` for that role. This was noted as "not yet consumed" in Story 1.1 code review.
4. **Token-based invitations** — Use UUID tokens (like `DeclarationInvitation`). The acceptance flow will be handled later, but the token must be generated and included in the email link.
5. **Invitation email should be queued** — Use `ShouldQueue` interface or dispatch via `Mail::queue()` to avoid blocking the HTTP request.
6. **Duplicate invitation check** — Prevent sending multiple active invitations to the same email for the same workspace. Check for existing unexpired, unaccepted invitations before creating a new one.
## Dev Agent Record
### Agent Model Used
Claude Opus 4.6
### Debug Log References
- Fixed `config('permissions.defaults')` array access with enum object key — removed unused `$defaultPermissions` computation (defaults will be applied on invitation acceptance, not creation)
- Fixed `Mail::assertSent``Mail::assertQueued` in tests since `TeamInvitationMail` implements `ShouldQueue`
- Fixed `Workspace::users()` withPivot to include `'permissions'` (Story 1.1 code review [H1] finding)
- Fixed `RenameFoldersToDeclarationsTest` rollback step count (6 → 7) due to new migration
- Serialized enum role value in controller (`$user->pivot->role?->value`) to avoid passing enum objects to Inertia
### Completion Notes List
- All 10 acceptance criteria satisfied
- 12 new tests added in ManageTeamTest, all passing
- Full regression suite: 117 tests, 317 assertions, 0 failures
- TeamInvitation model includes LogsActivity trait per project conventions
- Invitation emails are queued via ShouldQueue for non-blocking HTTP
- Duplicate invitation prevention implemented (same email, same workspace, active invitation)
- Worker authorization fails with 404 (not 403) per security conventions
- No new packages installed — used existing Laravel Mail and queue infrastructure
### Senior Developer Review (AI)
**Reviewer:** Saad (via Claude Opus 4.6) | **Date:** 2026-03-15
**Issues Found:** 3 High, 3 Medium, 2 Low
| ID | Severity | Description | Resolution |
|----|----------|-------------|------------|
| H1 | HIGH | Empty state invite Dialog had no DialogContent — clicking invite in empty state was broken | Fixed: extracted Dialog+DialogContent to top level, both buttons use `@click` |
| H2 | HIGH | Breadcrumbs used hardcoded URLs (`/dashboard`, `/team`) violating project rules | Fixed: use wayfinder routes (`dashboard()`, `teamIndex()`), typed as `BreadcrumbItem[]` |
| H3 | HIGH | No flash message handler — success toast (AC #5) was silently discarded | Fixed: HandleInertiaRequests now shares `success`/`error` flash keys; added toast display in AppSidebarLayout |
| M1 | MEDIUM | Role labels duplicated in 3 places (controller, mail, Vue) | Fixed: Vue `roleLabels` now computed from `props.roles` + owner. Mail duplication acceptable (queue serialization) |
| M2 | MEDIUM | `TeamInvitationMail` used hardcoded `url('/register')` instead of `route()` | Fixed: uses `route('register', ['invitation' => $token])` |
| M3 | MEDIUM | Breadcrumbs not typed as `BreadcrumbItem[]` | Fixed: as part of H2 |
| L1 | LOW | `roles` prop passed but local `roleLabels` also hardcoded in Vue | Fixed: as part of M1 |
| L2 | LOW | No test for empty state rendering | Not fixed — optional enhancement |
### Change Log
- 2026-03-15: Implemented Story 1.2 — Team Management Page with view and invite members functionality
- 2026-03-15: Code review fixes — Dialog structure, wayfinder breadcrumbs, flash messages, route helper in mail
### File List
**New files:**
- `app/Http/Controllers/TeamController.php`
- `app/Http/Requests/InviteTeamMemberRequest.php`
- `app/Models/TeamInvitation.php`
- `app/Mail/TeamInvitationMail.php`
- `database/migrations/2026_03_15_000001_create_team_invitations_table.php`
- `resources/views/emails/team-invitation.blade.php`
- `resources/js/pages/team/Index.vue`
- `resources/js/types/team.ts`
- `tests/Feature/Team/ManageTeamTest.php`
**Modified files:**
- `app/Models/Workspace.php` — added `teamInvitations()` relationship, added `'permissions'` to `withPivot`
- `routes/web.php` — added `team.index` and `team.invite` routes
- `resources/js/types/index.ts` — exported team types
- `tests/Feature/Database/RenameFoldersToDeclarationsTest.php` — updated rollback step count (6 → 7)
- `app/Http/Middleware/HandleInertiaRequests.php` — fixed flash data sharing (success/error keys)
- `resources/js/layouts/app/AppSidebarLayout.vue` — added flash message toast display