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

23 KiB
Raw Permalink Blame History

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

  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

  • Task 1: Create TeamController with index and invite methods (AC: #1, #6, #7, #8)

    • 1.1 Create app/Http/Controllers/TeamController.php using HasWorkspaceScope and AuthorizesPermissions traits
    • 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
    • 1.3 invite() method: validate with InviteTeamMemberRequest, create invitation record, send invitation email, redirect back with success toast
    • 1.4 Pass canManageTeam boolean prop to frontend (true for Owner, check permission for Manager)
  • Task 2: Create InviteTeamMemberRequest form request (AC: #3)

    • 2.1 Create app/Http/Requests/InviteTeamMemberRequest.php
    • 2.2 authorize(): verify user is Owner OR Manager with can_manage_team permission
    • 2.3 rules(): validate email (required, email format, not already in workspace), role (required, in: manager, worker)
  • Task 3: Create TeamInvitation model 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.php with fillable, casts, relationships (workspace, invitedBy), isValid() method, auto-generate token on creating
    • 3.3 Add teamInvitations(): HasMany relationship to Workspace model
  • Task 4: Create TeamInvitationMail mailable (AC: #4)

    • 4.1 Create app/Mail/TeamInvitationMail.php following existing mailable pattern (envelope + content + markdown)
    • 4.2 Create markdown email template resources/views/emails/team-invitation.blade.php with workspace name, role, and registration/accept link
    • 4.3 Queue the mail dispatch (use ShouldQueue or dispatch via queue)
  • Task 5: Create routes (AC: #1)

    • 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')
  • Task 6: Create team/Index.vue page (AC: #1, #2, #3, #5, #8, #9, #10)

    • 6.1 Create resources/js/pages/team/Index.vue with <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 canManageTeam is 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') }]
  • Task 7: Create TypeScript types (AC: #1)

    • 7.1 Create resources/js/types/team.ts with TeamMember, TeamInvitation, and TeamPageProps types
    • 7.2 Export from resources/js/types/index.ts
  • 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_team can view team index page
    • 8.4 Test: Manager without can_manage_team can view team index but cannot see invite button (check Inertia prop canManageTeam: 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_team can 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

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:

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):

<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 (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::assertSentMail::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