Files
L-Ami-Fiduciaire/_bmad-output/implementation-artifacts/1-2-team-management-page-view-and-invite-members.md

421 lines
23 KiB
Markdown
Raw Normal View History

# 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