- 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>
23 KiB
Story 1.2: Team Management Page — View & Invite Members
Status: done
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
-
Team index page — A route
GET /teamrenders a team page displaying all workspace members in a table with columns: name, email, role (as Badge), join date, and status (active/pending). -
Pending invitations — Invited users who have not yet accepted show a "Pending" StatusBadge in the status column.
-
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)
-
Invitation email — Submitting the invite form sends an invitation email to the specified address with a link to register/join the workspace.
-
Immediate feedback — The invitation appears in the team list as "Pending" immediately after submission. A success toast confirms: "Invitation envoyée".
-
Workspace scoping — The
TeamControlleruses theHasWorkspaceScopetrait to scope members to the current workspace. -
Worker access denied — Workers cannot access the Team page (
abort(404)viaAuthorizesPermissions). -
Manager conditional access — Managers can view the team list but only see the "Inviter un membre" button if they have
can_manage_teampermission. -
Layout & breadcrumbs — The page uses
AppLayoutwith breadcrumbs:Dashboard / Équipe. -
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
-
Task 1: Create
TeamControllerwithindexandinvitemethods (AC: #1, #6, #7, #8)- 1.1 Create
app/Http/Controllers/TeamController.phpusingHasWorkspaceScopeandAuthorizesPermissionstraits - 1.2
index()method: block Workers (authorizePermissionwith early abort for Worker role), load workspace members with pivot data, load pending invitations, render Inertia pageteam/Index - 1.3
invite()method: validate withInviteTeamMemberRequest, create invitation record, send invitation email, redirect back with success toast - 1.4 Pass
canManageTeamboolean prop to frontend (true for Owner, check permission for Manager)
- 1.1 Create
-
Task 2: Create
InviteTeamMemberRequestform request (AC: #3)- 2.1 Create
app/Http/Requests/InviteTeamMemberRequest.php - 2.2
authorize(): verify user is Owner OR Manager withcan_manage_teampermission - 2.3
rules(): validateemail(required, email format, not already in workspace),role(required, in: manager, worker)
- 2.1 Create
-
Task 3: Create
TeamInvitationmodel and migration (AC: #2, #4)- 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 - 3.2 Create
app/Models/TeamInvitation.phpwith fillable, casts, relationships (workspace,invitedBy),isValid()method, auto-generate token on creating - 3.3 Add
teamInvitations(): HasManyrelationship toWorkspacemodel
- 3.1 Create migration
-
Task 4: Create
TeamInvitationMailmailable (AC: #4)- 4.1 Create
app/Mail/TeamInvitationMail.phpfollowing existing mailable pattern (envelope + content + markdown) - 4.2 Create markdown email template
resources/views/emails/team-invitation.blade.phpwith workspace name, role, and registration/accept link - 4.3 Queue the mail dispatch (use
ShouldQueueor dispatch via queue)
- 4.1 Create
-
Task 5: Create routes (AC: #1)
- 5.1 Add to
routes/web.phpinside the workspace middleware group:Route::get('team', [TeamController::class, 'index'])->name('team.index')Route::post('team/invite', [TeamController::class, 'invite'])->name('team.invite')
- 5.1 Add to
-
Task 6: Create
team/Index.vuepage (AC: #1, #2, #3, #5, #8, #9, #10)- 6.1 Create
resources/js/pages/team/Index.vuewith<script setup lang="ts"> - 6.2 Define Props type:
members(array of team member objects),pendingInvitations(array),canManageTeam(boolean) - 6.3 Render table with columns: Name, Email, Role (Badge), Joined (date), Status (active/pending Badge)
- 6.4 "Inviter un membre" button visible only when
canManageTeamis true - 6.5 Invite Dialog with email input + role Select (Manager/Worker) using Inertia
useForm - 6.6 EmptyState when members count is 1 (only owner) and no pending invitations
- 6.7 AppLayout with breadcrumbs
[{ title: 'Dashboard', href: route('dashboard') }, { title: 'Équipe', href: route('team.index') }]
- 6.1 Create
-
Task 7: Create TypeScript types (AC: #1)
- 7.1 Create
resources/js/types/team.tswithTeamMember,TeamInvitation, andTeamPagePropstypes - 7.2 Export from
resources/js/types/index.ts
- 7.1 Create
-
Task 8: Write tests (AC: #1–#10)
- 8.1 Create
tests/Feature/Team/ManageTeamTest.php - 8.2 Test: Owner can view team index page
- 8.3 Test: Manager with
can_manage_teamcan view team index page - 8.4 Test: Manager without
can_manage_teamcan view team index but cannot see invite button (check Inertia propcanManageTeam: false) - 8.5 Test: Worker receives 404 on team index
- 8.6 Test: Owner can invite a new member (POST /team/invite)
- 8.7 Test: Manager with
can_manage_teamcan invite a new member - 8.8 Test: Manager without permission gets 404 on invite
- 8.9 Test: Worker gets 404 on invite
- 8.10 Test: Cannot invite email already in workspace
- 8.11 Test: Invitation creates TeamInvitation record with correct data
- 8.12 Test: Invitation sends email (Mail::fake assertion)
- 8.13 Run full test suite:
composer test
- 8.1 Create
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$castsproperty) - Authorization: Always
abort(404)for permission failures (NEVERabort(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_userpivot — do NOT installspatie/laravel-permission - Mass assignment: Explicit
$fillablearrays (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
LogsActivitytrait +getActivitylogOptions()
Controller Pattern (from Story 1.1)
The TeamController must use both traits created in Story 1.1:
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. ThecanManageTeamprop 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"):
- Owner/Manager fills invite form (email + role)
- Backend creates
TeamInvitationrecord with UUID token + expiry (7 days) - Backend sends email with invitation link:
/register?invitation={token} - Invited user registers (or logs in if account exists) → invitation accepted, user attached to workspace with specified role
- 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
HasWorkspaceScopetrait (app/Concerns/HasWorkspaceScope.php): ProvidescurrentWorkspace()andauthorizeWorkspaceAccess(). UsecurrentWorkspace()to get the workspace for member queries.AuthorizesPermissionstrait (app/Concerns/AuthorizesPermissions.php): ProvidesauthorizePermission(). NOT directly used for index (custom logic needed), but use for invite authorization.Usermodel (app/Models/User.php): Hasworkspaces(): BelongsToManywithwithPivot('role', 'permissions')andcurrentWorkspaceUser()method.Workspacemodel (app/Models/Workspace.php): Hasusers(): BelongsToManyrelationship.WorkspaceUsermodel (app/Models/WorkspaceUser.php): Pivot model withrole(WorkspaceUserRole enum) andpermissions(array cast).WorkspaceUserRoleenum (app/Enums/WorkspaceUserRole.php):Owner,Manager,Worker— use->is()for comparison (bensampo/laravel-enum).Permissionenum (app/Enums/Permission.php):CanManageTeam,CanViewActivityLogs,CanConfigurePortal.EnsureUserHasWorkspacemiddleware: Already validates workspace access via session. Applied via 'workspace' middleware group in routes.DeclarationInvitationmodel: 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):
<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):
<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:
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(addteamInvitations()relationship) - Modified types index:
resources/js/types/index.ts(export team types)
Testing Standards
- Framework: Pest 4 with
test()closures andexpect()assertions RefreshDatabase: Auto-applied viaPest.phpfor 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) permissionscolumn: Already cast toarrayon WorkspaceUser model — do NOTjson_encode()when attaching via relationship (causes double-encoding)- Unit tests in
tests/Unit/: Need explicituses(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-enumwithlabels(): arrayfor 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.phpandAuthorizesPermissions.phptraitsapp/Enums/Permission.phpenumconfig/permissions.phpconfigUser::currentWorkspaceUser()method- Renamed Member → Worker across codebase All 105 tests passing after Story 1.1 code review fixes.
Critical Implementation Warnings
-
Do NOT create a TeamController that uses
authorizePermission(Permission::CanManageTeam)inindex()— 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. -
Do NOT install any new packages — use Laravel's built-in Mail and existing queue infrastructure. No new Composer or npm dependencies needed.
-
Apply role defaults on invite — When a new member is invited with role Manager, the
permissionsJSON column should be populated fromconfig('permissions.defaults')for that role. This was noted as "not yet consumed" in Story 1.1 code review. -
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. -
Invitation email should be queued — Use
ShouldQueueinterface or dispatch viaMail::queue()to avoid blocking the HTTP request. -
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$defaultPermissionscomputation (defaults will be applied on invitation acceptance, not creation) - Fixed
Mail::assertSent→Mail::assertQueuedin tests sinceTeamInvitationMailimplementsShouldQueue - Fixed
Workspace::users()withPivot to include'permissions'(Story 1.1 code review [H1] finding) - Fixed
RenameFoldersToDeclarationsTestrollback 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.phpapp/Http/Requests/InviteTeamMemberRequest.phpapp/Models/TeamInvitation.phpapp/Mail/TeamInvitationMail.phpdatabase/migrations/2026_03_15_000001_create_team_invitations_table.phpresources/views/emails/team-invitation.blade.phpresources/js/pages/team/Index.vueresources/js/types/team.tstests/Feature/Team/ManageTeamTest.php
Modified files:
app/Models/Workspace.php— addedteamInvitations()relationship, added'permissions'towithPivotroutes/web.php— addedteam.indexandteam.inviteroutesresources/js/types/index.ts— exported team typestests/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