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>
This commit is contained in:
2026-03-18 00:12:50 +00:00
parent 5dffd2d063
commit c89d1879bf
83 changed files with 5850 additions and 314 deletions

View File

@@ -0,0 +1,230 @@
# Story 1.1: Permission Configuration & Controller Traits
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
As a developer,
I want a centralized permission configuration and reusable controller traits for workspace scoping and permission checking,
So that all future controllers can enforce role-based access consistently with minimal code duplication.
## Acceptance Criteria
1. **Permission config file**`config/permissions.php` defines default permissions per role:
- Owner: `['*']` (all permissions)
- Manager: configurable defaults (`can_manage_team: false`, `can_view_activity_logs: true`, `can_configure_portal: false`)
- Worker: `[]` (no configurable permissions)
2. **Permission enum**`app/Enums/Permission.php` exists with all permission keys as snake_case values (`can_manage_team`, `can_view_activity_logs`, `can_configure_portal`)
3. **Rename `Member` to `Worker`** — The existing `WorkspaceUserRole` enum value `Member` must be renamed to `Worker` across models, migrations, seeders, and any references. A data migration updates existing `member` values in `workspace_user.role` to `worker`.
4. **`HasWorkspaceScope` trait** — `app/Concerns/HasWorkspaceScope.php` provides:
- `currentWorkspace(): Workspace` — resolves workspace from session `current_workspace_id`
- `authorizeWorkspaceAccess(): void` — verifies resource belongs to current workspace, `abort(404)` if not
5. **`AuthorizesPermissions` trait** — `app/Concerns/AuthorizesPermissions.php` provides:
- `authorizePermission(string $permission): void`
- Owner: always passes (returns immediately)
- Worker: always fails (`abort(404)`)
- Manager: checks `workspace_user.permissions` JSON column — `abort(404)` if key is `false` or absent
- Unknown permission keys default to `false`
6. **Authorization failures return `abort(404)`** — never `abort(403)`, per architecture convention (NFR9)
7. **Unit tests** verify:
- Permission checking logic for all three roles (Owner passes, Worker fails, Manager checks JSON)
- Unknown permission keys default to `false` for Managers
- `HasWorkspaceScope` resolves workspace correctly from session
- Data migration renames `member``worker` in `workspace_user.role`
## Tasks / Subtasks
- [x] Task 1: Rename `Member` to `Worker` in `WorkspaceUserRole` enum (AC: #3)
- [x] 1.1 Update `app/Enums/WorkspaceUserRole.php`: rename `Member` case to `Worker` with value `'worker'`
- [x] 1.2 Create data migration `database/migrations/2026_03_14_000001_rename_member_to_worker_in_workspace_user.php` — updates existing `member` values to `worker`
- [x] 1.3 Update `WorkspaceUser` model default role to `WorkspaceUserRole::Worker` (N/A — no default role in model; updated in original migration)
- [x] 1.4 Update `WorkspaceUserFactory` default role to `WorkspaceUserRole::Worker` (N/A — no WorkspaceUserFactory exists)
- [x] 1.5 Update `DatabaseSeeder` — change any `member` references to `worker`
- [x] 1.6 Search codebase for any `Member` / `member` references in role context and update (updated: WorkspaceController, tests, WorkspaceForm.vue)
- [x] Task 2: Create Permission enum (AC: #2)
- [x] 2.1 Create `app/Enums/Permission.php` using `bensampo/laravel-enum` with values: `CanManageTeam = 'can_manage_team'`, `CanViewActivityLogs = 'can_view_activity_logs'`, `CanConfigurePortal = 'can_configure_portal'`
- [x] Task 3: Create permission config file (AC: #1)
- [x] 3.1 Create `config/permissions.php` with `defaults` array keyed by role
- [x] Task 4: Create `HasWorkspaceScope` trait (AC: #4)
- [x] 4.1 Create `app/Concerns/HasWorkspaceScope.php` with `currentWorkspace()` and `authorizeWorkspaceAccess()` methods
- [x] 4.2 `currentWorkspace()` retrieves workspace via `session('current_workspace_id')` and `auth()->user()->workspaces()` relationship
- [x] 4.3 `authorizeWorkspaceAccess()` compares resource `workspace_id` to current workspace, `abort(404)` on mismatch
- [x] Task 5: Create `AuthorizesPermissions` trait (AC: #5, #6)
- [x] 5.1 Create `app/Concerns/AuthorizesPermissions.php`
- [x] 5.2 Implement `authorizePermission(string $permission)` with Owner/Worker/Manager branching
- [x] 5.3 Manager branch reads `permissions` JSON column from `WorkspaceUser` pivot — `abort(404)` if key missing or `false`
- [x] Task 6: Write tests (AC: #7)
- [x] 6.1 Create `tests/Unit/PermissionCheckTest.php` — test `AuthorizesPermissions` logic for all 3 roles + unknown keys
- [x] 6.2 Create `tests/Feature/Team/WorkspaceScopeTest.php` — test `HasWorkspaceScope` trait
- [x] 6.3 Create `tests/Feature/Database/MemberToWorkerMigrationTest.php` — test data migration
- [x] 6.4 Run full test suite: `composer test` — 105 passed, 0 failures
## 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
### Existing Code to Build On
- **`WorkspaceUser` model** (`app/Models/WorkspaceUser.php`): Already extends `Pivot`, has `role` column, and `permissions` JSON column (added in Story 0.5). Cast `permissions` to `array` if not already done.
- **`WorkspaceUserRole` enum** (`app/Enums/WorkspaceUserRole.php`): Currently has `Owner`, `Manager`, `Member` — rename `Member``Worker`
- **`EnsureUserHasWorkspace` middleware** (`app/Http/Middleware/EnsureUserHasWorkspace.php`): Already validates workspace access via session. The new `HasWorkspaceScope` trait complements this (middleware checks access, trait provides controller helpers).
- **`DeclarationController`/`ClientController`**: Already use `abort(404)` pattern with `authorizeDeclaration()`/`authorizeClient()` methods — the new traits will replace these inline patterns.
- **Existing traits location**: `app/Concerns/` (contains `PasswordValidationRules.php`, `ProfileValidationRules.php`)
### Permission Checking Implementation Pattern
```php
// app/Concerns/AuthorizesPermissions.php
protected function authorizePermission(string $permission): void
{
$workspaceUser = auth()->user()->currentWorkspaceUser();
if ($workspaceUser->role === WorkspaceUserRole::Owner) {
return; // Owners can do everything
}
if ($workspaceUser->role === WorkspaceUserRole::Worker) {
abort(404); // Workers never have configurable permissions
}
// Manager: check JSON permissions column
if (!($workspaceUser->permissions[$permission] ?? false)) {
abort(404); // 404 not 403 per project convention
}
}
```
### User Model Requirements
The `User` model needs a `currentWorkspaceUser(): WorkspaceUser` method (if not already present) that returns the pivot record for the current workspace. Check if this exists; if not, add it:
```php
public function currentWorkspaceUser(): WorkspaceUser
{
return WorkspaceUser::where('user_id', $this->id)
->where('workspace_id', session('current_workspace_id'))
->firstOrFail();
}
```
### Data Migration for Member → Worker Rename
```php
// Migration up(): Update existing 'member' values to 'worker'
DB::table('workspace_user')
->where('role', 'member')
->update(['role' => 'worker']);
// Migration down(): Revert 'worker' back to 'member'
DB::table('workspace_user')
->where('role', 'worker')
->update(['role' => 'member']);
```
### Project Structure Notes
- Traits go in `app/Concerns/` (established pattern)
- Enums go in `app/Enums/` (established pattern)
- Config files go in `config/` (standard Laravel)
- Feature tests in `tests/Feature/Team/` and `tests/Feature/Database/`
- Unit tests in `tests/Unit/`
- No new middleware needed — traits are used inside controllers, not as middleware
### Testing Standards
- **Framework**: Pest 4 with `test()` closures and `expect()` assertions
- **`RefreshDatabase`**: Auto-applied via `Pest.php` — do NOT add manually
- **Run command**: `composer test` (clears config → runs Pint → runs tests)
- **Test both**: Happy path (authorized) AND sad path (unauthorized → 404)
- **Factory usage**: Use `WorkspaceUser::factory()` to create test users with specific roles and permissions
- **Route helper**: Use `route()` helper, never hardcoded URLs
### References
- [Source: _bmad-output/planning-artifacts/epics.md#Epic-1 — Story 1.1 requirements]
- [Source: _bmad-output/planning-artifacts/architecture.md#D1-Permission-Toggle-Storage — JSON column decision]
- [Source: _bmad-output/planning-artifacts/architecture.md#Permission-Checking-Pattern — authorizePermission() code pattern]
- [Source: _bmad-output/planning-artifacts/architecture.md#Phase-1-Files — file locations]
- [Source: _bmad-output/implementation-artifacts/0-5-*.md — previous story patterns and learnings]
### Previous Story Intelligence (Epic 0 Learnings)
- **Enum convention**: `bensampo/laravel-enum` with `labels(): array` for French display and custom methods
- **Observer pattern**: Register in `AppServiceProvider::boot()` using `Model::observe()`
- **FK constraints**: Use explicit `->on('table_name')` (never bare `->constrained()`)
- **Scope discipline**: Only modify files directly required by acceptance criteria — no cosmetic changes
- **Test organization**: Group by domain (`tests/Feature/Team/`, `tests/Feature/Database/`)
- **Code review feedback**: Business logic belongs in models/observers/traits, not controllers
### Git Intelligence
Recent commits show Epic 0 is complete. Branch `l-ami-fiduciaire-v1.0.0` has 4 commits. All Epic 0 stories implemented and retrospective done.
## Dev Agent Record
### Agent Model Used
Claude Opus 4.6
### Debug Log References
- bensampo/laravel-enum uses `->is()` for comparisons, not `===` (Enum instance vs string constant). Fixed in AuthorizesPermissions trait.
- `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.
- Added new migration shifted rollback step count in RenameFoldersToDeclarationsTest (5 → 6).
### Completion Notes List
- Ultimate context engine analysis completed — comprehensive developer guide created
- Renamed `Member``Worker` across enum, migrations, seeders, controllers, tests, and frontend
- Created `Permission` enum with 3 permission keys (CanManageTeam, CanViewActivityLogs, CanConfigurePortal)
- Created `config/permissions.php` with role-based default permissions
- Created `HasWorkspaceScope` trait for workspace resolution and resource access verification
- Created `AuthorizesPermissions` trait for role-based permission checking (Owner/Worker/Manager)
- Added `currentWorkspaceUser()` method to User model
- Created 12 new tests (6 unit + 4 feature/team + 2 feature/database), all passing
- Full test suite: 105 tests, 255 assertions, 0 failures
### File List
**New files:**
- app/Enums/Permission.php
- app/Concerns/HasWorkspaceScope.php
- app/Concerns/AuthorizesPermissions.php
- config/permissions.php
- database/migrations/2026_03_14_000001_rename_member_to_worker_in_workspace_user.php
- tests/Unit/PermissionCheckTest.php
- tests/Feature/Team/WorkspaceScopeTest.php
- tests/Feature/Database/MemberToWorkerMigrationTest.php
**Modified files:**
- app/Enums/WorkspaceUserRole.php (Member → Worker)
- app/Models/User.php (added currentWorkspaceUser() method, added 'permissions' to withPivot)
- app/Http/Controllers/WorkspaceController.php (Member → Worker references)
- database/migrations/2026_02_26_220354_create_workspace_user_table.php (default role Member → Worker)
- database/seeders/DatabaseSeeder.php (Member → Worker references)
- resources/js/components/WorkspaceForm.vue (defaultRole 'member' → 'worker')
- tests/Feature/Notification/DeclarationMentionTest.php ('member' → 'worker' role strings)
- tests/Feature/Declaration/MediaDownloadTest.php ('member' → 'worker' role string)
- tests/Feature/Database/RenameFoldersToDeclarationsTest.php (rollback step count 5 → 6)
## Change Log
- 2026-03-14: Story 1.1 implemented — Permission configuration, controller traits (HasWorkspaceScope, AuthorizesPermissions), Permission enum, Member→Worker rename with data migration. All 105 tests passing.
- 2026-03-15: Code review fixes — [H1] Added 'permissions' to withPivot() in User→workspaces relationship. [M1] Rewrote MemberToWorkerMigrationTest to invoke actual migration file instead of duplicating SQL inline. [M2] Noted: config/permissions.php defaults are defined but not consumed — by design, future stories will apply defaults. All 105 tests passing.

View File

@@ -0,0 +1,420 @@
# 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

View File

@@ -0,0 +1,458 @@
# Story 1.3: Role Assignment & Member Removal
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## 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
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="openRoleDialog(member)">
<UserCog class="mr-2 h-4 w-4" />
Changer le rôle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-destructive" @click="openRemoveDialog(member)">
<UserMinus class="mr-2 h-4 w-4" />
Retirer de l'espace
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
```
**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.

View File

@@ -0,0 +1,195 @@
# Story 1.4: Manager Permission Toggle Matrix
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
As a firm owner,
I want to configure which specific permissions each Manager has in my workspace,
so that I can grant or restrict Manager capabilities based on my trust level and firm structure.
## Acceptance Criteria
1. A permissions page (or slide-over panel) displays all configurable permission toggles with descriptive labels
2. Each toggle shows the permission name in French (e.g., "Gerer l'equipe", "Voir les journaux d'activite", "Configurer le portail client")
3. Toggles reflect the Manager's current permission state (from JSON column)
4. Toggling a permission immediately saves via PUT request to `/team/{workspaceUser}/permissions`
5. A success toast confirms "Permissions updated"
6. The permission toggles are only visible for Manager role members (not Workers or Owners)
7. Only Owners can access the permissions management (Managers with `can_manage_team` cannot modify other Managers' permissions)
8. Permission changes are logged via Spatie Activity Log
9. If a Manager's `can_manage_team` is toggled off, the "Invite Member" button disappears from their Team page view on next load
## Tasks / Subtasks
- [x] Task 1: Backend — Add `updatePermissions()` method to TeamController (AC: #4, #7, #8)
- [x] 1.1 Create `UpdatePermissionsRequest` form request with authorization (Owner-only) and validation (permission keys must exist in Permission enum)
- [x] 1.2 Add `updatePermissions()` method: load WorkspaceUser by ID scoped to current workspace, verify target is Manager role, update JSON permissions column, log activity
- [x] 1.3 Add PUT route `/team/{workspaceUserId}/permissions` in `routes/web.php`
- [x] Task 2: Backend — Add permissions data to TeamController index (AC: #1, #3, #6)
- [x] 2.1 Extend `index()` to pass `permissionsUrl` per Manager member and `availablePermissions` list (from Permission enum with French labels)
- [x] 2.2 Pass each Manager member's current `permissions` array from the pivot
- [x] Task 3: Frontend — Build Permission Toggle Matrix UI (AC: #1, #2, #3, #5, #6)
- [x] 3.1 Create permission toggle slide-over or inline panel in Team Index page
- [x] 3.2 Render toggle switches for each permission with French labels
- [x] 3.3 Implement immediate save via PUT using Inertia `router.put()` on toggle change
- [x] 3.4 Show success toast on save confirmation
- [x] 3.5 Only show "Manage Permissions" action for Manager-role members when current user is Owner
- [x] Task 4: Tests — Feature tests for permission toggle endpoint (AC: #4, #5, #7, #8, #9)
- [x] 4.1 Test Owner can update Manager permissions
- [x] 4.2 Test Manager cannot update permissions (even with `can_manage_team`)
- [x] 4.3 Test Worker cannot access permissions endpoint (404)
- [x] 4.4 Test cannot update Owner's permissions (no toggles for Owners)
- [x] 4.5 Test cannot update Worker's permissions (no toggles for Workers)
- [x] 4.6 Test invalid permission key is rejected
- [x] 4.7 Test activity log entry created on permission change
- [x] 4.8 Test toggling `can_manage_team` off removes invite capability on next page load
## Dev Notes
### Critical Architecture Patterns (from Stories 1.1-1.3)
- **Authorization: `abort(404)` NEVER `abort(403)`** — hides workspace existence per project convention
- **Enum comparisons: use `->is()` with bensampo/laravel-enum**, NOT `===`
- **WorkspaceUser extends Pivot** — route model binding does NOT work; use manual `WorkspaceUser::where()` lookup scoped to current workspace via session
- **Permission checking:** Role-based with JSON column on `workspace_user` pivot. NO Spatie policies/gates
- **Activity logging:** Use `activity()` helper for business operations (non-model-level)
- **Frontend URLs:** All URLs via `route()` helper passed as Inertia props — never hardcode
- **Form handling:** For simple toggle saves, use `router.put()` (not `useForm`) since no complex form state needed
- **Transactions:** Wrap permission update + activity log in `DB::transaction()`
- **Model casts:** Use `protected function casts(): array` method, NOT `$casts` property
- **Flash messages:** `HandleInertiaRequests` shares `success`/`error` flash keys; `AppSidebarLayout` displays toast
### Permission Keys & French Labels
| Permission Key (snake_case) | Enum Value | French Label |
|---|---|---|
| `can_manage_team` | `Permission::CanManageTeam` | Gerer l'equipe |
| `can_view_activity_logs` | `Permission::CanViewActivityLogs` | Voir les journaux d'activite |
| `can_configure_portal` | `Permission::CanConfigurePortal` | Configurer le portail client |
### Existing Permission Infrastructure
The permission system is fully built from Stories 1.1-1.3:
- `config/permissions.php` — Default permissions per role (Manager defaults: `can_manage_team: false`, `can_view_activity_logs: true`, `can_configure_portal: false`)
- `app/Enums/Permission.php` — Three permission cases
- `app/Enums/WorkspaceUserRole.php` — Owner, Manager, Worker
- `app/Concerns/AuthorizesPermissions.php``authorizePermission()` trait method
- `app/Concerns/HasWorkspaceScope.php``currentWorkspace()` and `authorizeWorkspaceAccess()`
- `app/Models/WorkspaceUser.php` — Pivot with `permissions` JSON cast
- `app/Http/Controllers/TeamController.php``index()`, `invite()`, `updateRole()`, `remove()` already implemented
### What This Story Adds
This is the **only missing CRUD operation** on the permission system: the ability for Owners to toggle individual Manager permissions. Everything else (reading permissions for authorization, resetting on role change, default application) is already implemented.
### Key Implementation Decisions
1. **UI Pattern:** Add a "Manage Permissions" option to the existing DropdownMenu on Manager rows (same pattern as "Change Role" and "Remove"). Opens a Dialog/slide-over with toggle switches.
2. **Save Strategy:** Immediate save per toggle (no "Save All" button) — each toggle fires a PUT request independently. Use `router.put()` with `preserveScroll: true`.
3. **Validation:** Backend must validate that only known permission keys (from Permission enum) are submitted. Reject unknown keys.
4. **Owner-Only Access:** This is stricter than other team actions — even Managers with `can_manage_team` CANNOT modify permissions. Only Owners.
### Project Structure Notes
- Alignment with existing team management patterns established in Stories 1.2-1.3
- New route follows existing pattern: `/team/{workspaceUserId}/permissions` (PUT)
- No new Vue pages needed — extend existing `resources/js/pages/team/Index.vue`
- New form request: `app/Http/Requests/UpdatePermissionsRequest.php`
- No new models or migrations needed — uses existing `permissions` JSON column
### References
- [Source: _bmad-output/planning-artifacts/epics.md#Epic 1 - Story 1.4]
- [Source: _bmad-output/planning-artifacts/architecture.md#RBAC Implementation]
- [Source: _bmad-output/planning-artifacts/prd.md#Permission Matrix]
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Team Interaction Patterns]
- [Source: _bmad-output/implementation-artifacts/1-1-permission-configuration-and-controller-traits.md]
- [Source: _bmad-output/implementation-artifacts/1-2-team-management-page-view-and-invite-members.md]
- [Source: _bmad-output/implementation-artifacts/1-3-role-assignment-and-member-removal.md]
- [Source: config/permissions.php]
- [Source: app/Enums/Permission.php]
- [Source: app/Http/Controllers/TeamController.php]
### Previous Story Intelligence
**From Story 1.3 (most recent):**
- WorkspaceUser route model binding doesn't work — use manual query: `WorkspaceUser::where('workspace_id', $workspace->id)->where('id', $id)->firstOrFail()`
- Role change resets permissions to `config('permissions.defaults.{newRole}')` — this pattern confirms the config structure works
- Activity logging pattern: `activity()->performedOn($workspaceUser)->causedBy(auth()->user())->withProperties([...])->log('description')`
- Frontend dialog pattern: Dialog + DialogContent + DialogHeader + DialogFooter from shadcn/ui
- Loading states on action buttons to prevent double-click
- Wrapped operations in `DB::transaction()`
**From Story 1.2:**
- `canManageTeam` prop already passed to frontend — reuse pattern for `isOwner` check
- Flash message toast pattern already working via `HandleInertiaRequests`
- Team member data structure includes `workspace_user_id` for URL construction
**Git Intelligence:**
- Last commits: Epic 0 retrospective and foundation setup complete
- Stories 1.1-1.3 are implemented but not yet committed to main (on feature branch `l-ami-fiduciaire-v1.0.0`)
### Technology Versions
- Laravel 12, PHP 8.2+, Vue 3.5, TypeScript 5.2 (strict), Inertia.js 2.0
- Pest 4.4 for testing
- bensampo/laravel-enum for enums
- Spatie Activity Log for audit trail
- shadcn-vue for UI components (Dialog, DropdownMenu, Switch/Toggle, etc.)
## Dev Agent Record
### Agent Model Used
Claude Opus 4.6
### Debug Log References
No blockers or debug issues encountered.
### Completion Notes List
- Created `UpdatePermissionsRequest` with Owner-only authorization and Permission enum key validation
- Added `updatePermissions()` method to TeamController with DB::transaction wrapping permission update + Spatie activity log
- Added PUT route `/team/{workspaceUserId}/permissions` following existing team route patterns
- Extended `index()` to pass `isOwner`, `availablePermissions` (French labels), `permissions`, and `permissionsUrl` per Manager member
- Added `permissionLabels()` protected method returning French labels for all 3 permissions
- Installed shadcn-vue Switch component for toggle UI
- Added permissions Dialog with Switch toggles per permission, opened from "Gérer les permissions" dropdown item (Shield icon)
- Immediate save per toggle via `router.put()` with `preserveScroll: true`
- Success toast via existing flash message infrastructure ("Permissions mises à jour")
- "Gérer les permissions" dropdown item only visible for Manager-role rows when current user is Owner
- 8 comprehensive Pest feature tests covering all ACs: owner can update, manager/worker cannot, owner/worker targets rejected, invalid keys rejected, activity log verified, can_manage_team toggle affects invite capability
- All 143 tests pass (380 assertions), zero regressions
- PHP Pint linting passes clean
- ESLint errors all pre-existing (not in changed files)
### Code Review Fixes (AI)
- [H1] Removed dead `$validKeys` variable in `UpdatePermissionsRequest::rules()`
- [H2] Added completeness validation: all permission keys must be present in request to prevent silent permission loss on partial submissions
- [M1] Fixed enum comparison in `TeamController::index()` to use `->is()` with `instanceof` check per project convention
- [M2] Added cross-workspace isolation test (owner of workspace A cannot update manager in workspace B)
- [M3] Replaced magic role strings `'manager'`/`'owner'` with `ROLE_MANAGER`/`ROLE_OWNER` constants in `Index.vue`
- [M4] Added `watch()` on `props.members` to keep `permissionsMember` ref in sync during Inertia partial reloads
- Updated tests 4.4 and 4.5 to send all 3 permission keys (required by H2 completeness validation)
- All 144 tests pass (382 assertions) after review fixes, zero regressions
### Change Log
- 2026-03-15: Implemented Story 1.4 — Manager Permission Toggle Matrix (all tasks complete)
- 2026-03-15: Code review fixes — 2 HIGH, 4 MEDIUM issues resolved (6 fixes applied)
### File List
- app/Http/Requests/UpdatePermissionsRequest.php (new, review-fixed — removed dead code, added completeness validation)
- app/Http/Controllers/TeamController.php (modified — added updatePermissions(), extended index(), added permissionLabels(); review-fixed enum comparison)
- routes/web.php (modified — added PUT team/{workspaceUserId}/permissions route)
- resources/js/types/team.ts (modified — added permissions?, permissionsUrl?, isOwner, availablePermissions)
- resources/js/pages/team/Index.vue (modified — added permissions dialog, Switch toggles, dropdown item; review-fixed role constants + props watcher)
- resources/js/components/ui/switch/Switch.vue (new — shadcn-vue component)
- resources/js/components/ui/switch/index.ts (new — shadcn-vue barrel)
- tests/Feature/Team/PermissionToggleTest.php (new — 9 tests; review-added cross-workspace test + fixed partial permission payloads)

View File

@@ -0,0 +1,324 @@
# Story 1.5: Role-Based Access Enforcement Across Views
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
As a firm worker,
I want to see only my assigned clients and declarations when I navigate the platform,
so that I can focus on my work without being overwhelmed by the entire firm's data.
## Acceptance Criteria
1. Workers navigating to Clients page see only clients that have at least one declaration with `assigned_to` = their user ID; client count reflects scoped view
2. Workers navigating to Declarations page see only declarations where `assigned_to` = their user ID; `Declaration::scopeForUser()` applies: Workers get `where('assigned_to', $userId)`, Owners/Managers get unscoped
3. Owners/Managers see all workspace items on all pages with no scoping restrictions
4. Workers accessing a declaration not assigned to them via direct URL receive 404 (not 403)
5. Workers accessing a client that has zero declarations assigned to them via direct URL receive 404
6. `DeclarationController`, `ClientController`, and all existing controllers apply `HasWorkspaceScope` trait and role scoping consistently
7. ~~Activity log viewing is scoped: Owners see all, Managers see all if `can_view_activity_logs` is true (else 404), Workers see only their own actions~~ *(Deferred to Epic 2 — Dashboard activity feed; no activity log page exists yet)*
8. Sidebar navigation adapts per role: Owner/Manager sees "Dashboard, Clients, Declarations, Team, Settings"; Worker sees "Dashboard, My Declarations, Settings"
9. `HandleInertiaRequests` shares the current user's workspace role globally via `auth.workspaceRole`
10. Frontend conditionally hides create/edit/delete action buttons for Workers on Clients and Declarations pages
## Tasks / Subtasks
- [x] Task 1: Backend — Share workspace role in HandleInertiaRequests (AC: #9)
- [x] 1.1 In `HandleInertiaRequests::share()`, resolve the current user's `WorkspaceUser` pivot and share `auth.workspaceRole` (string: `'owner'|'manager'|'worker'|null`)
- [x] 1.2 Update `resources/js/types/auth.ts` — add `workspaceRole?: 'owner' | 'manager' | 'worker' | null` to `Auth` type
- [x] Task 2: Backend — Add `scopeForUser()` to Declaration model (AC: #2)
- [x] 2.1 Add `scopeForUser(Builder $query, User $user, WorkspaceUser $workspaceUser): Builder` on Declaration model
- [x] 2.2 Logic: if role is Worker → `$query->where('assigned_to', $user->id)`; else return unmodified `$query`
- [x] 2.3 Use `$workspaceUser->role->is(WorkspaceUserRole::Worker)` for comparison (bensampo `->is()` pattern)
- [x] Task 3: Backend — Refactor ClientController to use traits and role scoping (AC: #1, #4, #5, #6, #10)
- [x] 3.1 Add `use HasWorkspaceScope, AuthorizesPermissions;` traits to ClientController
- [x] 3.2 Remove the manual `currentWorkspace()` method (replaced by trait)
- [x] 3.3 In `index()`: for Workers, scope clients query to only those having at least one declaration assigned to the worker: `$workspace->clients()->whereHas('declarations', fn($q) => $q->where('assigned_to', $user->id))`; Owners/Managers get unscoped
- [x] 3.4 In `show()`, `edit()`, `update()`, `destroy()`: for Workers, verify the client has at least one declaration assigned to them, else `abort(404)`
- [x] 3.5 In `create()` and `store()`: Workers cannot create clients — `abort(404)` if Worker role
- [x] 3.6 Pass `canCreate`, `canEdit`, `canDelete` boolean props to frontend views (false for Workers)
- [x] 3.7 Remove manual `authorizeClient()` method — replace with `authorizeWorkspaceAccess()` from trait + role check
- [x] Task 4: Backend — Refactor DeclarationController to use traits and role scoping (AC: #2, #3, #4, #6, #10)
- [x] 4.1 Add `use HasWorkspaceScope, AuthorizesPermissions;` traits to DeclarationController
- [x] 4.2 Remove the manual `currentWorkspace()` method (replaced by trait)
- [x] 4.3 In `index()`: apply `Declaration::scopeForUser()` to scope worker queries
- [x] 4.4 In `show()`: for Workers, verify `$declaration->assigned_to === auth()->id()`, else `abort(404)`
- [x] 4.5 In `edit()`, `update()`: Workers cannot edit declarations — `abort(404)` if Worker role
- [x] 4.6 In `create()`, `store()`: Workers cannot create declarations — `abort(404)` if Worker role
- [x] 4.7 In `destroy()`: Workers cannot delete declarations — `abort(404)` if Worker role
- [x] 4.8 Pass `canCreate`, `canEdit`, `canDelete` boolean props to frontend views
- [x] 4.9 Remove manual `authorizeDeclaration()` method — replace with `authorizeWorkspaceAccess()` + role check
- [x] 4.10 Replace inline `canMention` role check in `show()` with `AuthorizesPermissions` pattern
- [x] Task 5: Frontend — Role-based sidebar navigation (AC: #8)
- [x] 5.1 In `AppSidebar.vue`, read `auth.workspaceRole` from `usePage()` props
- [x] 5.2 For Worker role: show only "Dashboard" and "Mes declarations" (href: `/declarations`) items; hide "Clients" and "Team" nav items
- [x] 5.3 For Owner/Manager: show full nav (Dashboard, Clients, Declarations, Team)
- [x] 5.4 Rename "Declarations" to "Mes declarations" in nav label when role is Worker
- [x] Task 6: Frontend — Conditional action buttons on Clients pages (AC: #10)
- [x] 6.1 In `clients/Index.vue`: hide "Add Client" button when `canCreate` is false
- [x] 6.2 In `clients/Index.vue`: hide edit/delete action links per row when `canEdit`/`canDelete` are false
- [x] 6.3 In `clients/Edit.vue` / `clients/Create.vue`: these pages are not accessible by Workers (backend returns 404), no frontend changes needed
- [x] Task 7: Frontend — Conditional action buttons on Declarations pages (AC: #10)
- [x] 7.1 In `declarations/Index.vue`: hide "New Declaration" button when `canCreate` is false
- [x] 7.2 In `declarations/Index.vue`: hide edit/delete action links per row when `canEdit`/`canDelete` are false
- [x] 7.3 In `declarations/Show.vue`: hide edit button and mention capability when Worker
- [x] Task 8: Tests — Comprehensive role-based access enforcement tests (AC: #1-#10)
- [x] 8.1 Test Worker sees only assigned declarations in index (scoped query)
- [x] 8.2 Test Worker sees only clients with assigned declarations in clients index
- [x] 8.3 Test Owner/Manager sees all declarations and clients (unscoped)
- [x] 8.4 Test Worker gets 404 accessing unassigned declaration via direct URL
- [x] 8.5 Test Worker gets 404 accessing client with no assigned declarations
- [x] 8.6 Test Worker gets 404 on create/store/edit/update/destroy for clients
- [x] 8.7 Test Worker gets 404 on create/store/edit/update/destroy for declarations
- [x] 8.8 Test Manager can access all CRUD operations on clients and declarations
- [x] 8.9 Test Owner can access all CRUD operations on clients and declarations
- [x] 8.10 Test `auth.workspaceRole` is shared correctly in Inertia props for each role
- [x] 8.11 Test cross-workspace isolation: Worker in workspace A cannot see declarations in workspace B
## Dev Notes
### Critical Architecture Patterns (from Stories 1.1-1.4)
- **Authorization: `abort(404)` NEVER `abort(403)`** — hides workspace existence per project convention (NFR9)
- **Enum comparisons: use `->is()` with bensampo/laravel-enum**, NOT `===`
- **WorkspaceUser extends Pivot** — route model binding does NOT work; use manual `WorkspaceUser::where()` lookup scoped to current workspace via session
- **Permission checking:** Role-based with JSON column on `workspace_user` pivot. NO Spatie policies/gates
- **Activity logging:** Use `activity()` helper for business operations (non-model-level)
- **Frontend URLs:** All URLs via `route()` helper passed as Inertia props — never hardcode routes in Vue
- **Transactions:** Wrap multi-step mutations in `DB::transaction()`
- **Model casts:** Use `protected function casts(): array` method, NOT `$casts` property
- **Flash messages:** `HandleInertiaRequests` shares `success`/`error` flash keys; `AppSidebarLayout` displays toast
- **Form Requests:** Always use dedicated Form Request classes for validation, never inline `$request->validate()`
- **Controller authorization:** Custom `authorizeXxx()` protected methods, NO Gates/Policies
- **Inertia render paths:** Lowercase subdirectory: `'clients/Index'`, not `'Clients/Index'`
### Existing Permission Infrastructure (Built in Stories 1.1-1.4)
| Component | Location | Purpose |
|---|---|---|
| `Permission` enum | `app/Enums/Permission.php` | 3 permissions: `CanManageTeam`, `CanViewActivityLogs`, `CanConfigurePortal` |
| `WorkspaceUserRole` enum | `app/Enums/WorkspaceUserRole.php` | 3 roles: `Owner`, `Manager`, `Worker` |
| `config/permissions.php` | Config file | Default permissions per role |
| `AuthorizesPermissions` trait | `app/Concerns/AuthorizesPermissions.php` | `authorizePermission()` — Owner always passes, Worker always 404, Manager checks JSON |
| `HasWorkspaceScope` trait | `app/Concerns/HasWorkspaceScope.php` | `currentWorkspace()` and `authorizeWorkspaceAccess()` |
| `WorkspaceUser` model | `app/Models/WorkspaceUser.php` | Pivot with `role` + `permissions` JSON columns |
| `TeamController` | `app/Http/Controllers/TeamController.php` | **Reference implementation** — uses both traits, full role enforcement |
### Current State of Controllers (What Needs Changing)
**ClientController** (`app/Http/Controllers/ClientController.php`):
- Does NOT use `AuthorizesPermissions` or `HasWorkspaceScope` traits
- Has its own `currentWorkspace()` method (duplicated, should use trait)
- Has `authorizeClient()` that only checks workspace_id match (no role scoping)
- `index()` returns ALL workspace clients — no Worker filtering
- No `canCreate`/`canEdit`/`canDelete` props passed to frontend
**DeclarationController** (`app/Http/Controllers/DeclarationController.php`):
- Does NOT use `AuthorizesPermissions` or `HasWorkspaceScope` traits
- Has its own `currentWorkspace()` method (duplicated, should use trait)
- Has `authorizeDeclaration()` that only checks workspace_id match (no role scoping)
- `index()` returns ALL workspace declarations — no Worker filtering
- `show()` has inline `canMention` role check (lines 239-242) — should use trait pattern
- No `canCreate`/`canEdit`/`canDelete` props passed to frontend
### Database Schema (Existing — No New Migrations Needed)
**declarations table:**
- `assigned_to` (nullable FK → users.id) — Worker assignment column
- `workspace_id` (FK → workspaces.id)
- `created_by` (FK → users.id)
**clients table:**
- `internal_responsible_id` (nullable FK → users.id) — NOT used for Worker scoping
- `workspace_id` (FK → workspaces.id)
- Workers see clients via declaration assignment, NOT via `internal_responsible_id`
**workspace_user pivot table:**
- `role` (enum: owner/manager/worker)
- `permissions` (JSON column)
### Worker Scoping Logic (Critical)
**Declarations:** Simple — `where('assigned_to', $userId)` via `scopeForUser()`
**Clients:** Indirect — Workers see clients that have at least one declaration assigned to them:
```php
$workspace->clients()->whereHas('declarations', fn ($q) => $q->where('assigned_to', $user->id))
```
**Single Resource Access (show/edit/destroy):**
- Declaration: verify `$declaration->assigned_to === auth()->id()` for Workers
- Client: verify client has at least one declaration with `assigned_to === auth()->id()` for Workers
### HandleInertiaRequests Changes
Current `auth` shared prop structure:
```php
'auth' => [
'user' => $user,
'workspaces' => $workspaces,
'currentWorkspace' => $currentWorkspace,
]
```
Must add `workspaceRole`:
```php
'auth' => [
'user' => $user,
'workspaces' => $workspaces,
'currentWorkspace' => $currentWorkspace,
'workspaceRole' => $user ? $user->workspaces()
->where('workspaces.id', $currentWorkspaceId)
->first()?->pivot?->role?->value : null,
]
```
### Frontend Auth Type Update
Add to `resources/js/types/auth.ts`:
```typescript
export type Auth = {
user: User | null;
workspaces?: Workspace[];
currentWorkspace?: Workspace | null;
workspaceRole?: 'owner' | 'manager' | 'worker' | null; // NEW
};
```
### Sidebar Navigation Per Role
| Role | Nav Items |
|---|---|
| **Owner/Manager** | Dashboard, Clients, Declarations, Team (if `can_manage_team` or Owner) |
| **Worker** | Dashboard, Mes declarations |
Note: Team nav item is already gated by the Team page itself (Workers get 404). But hiding it from the sidebar prevents confusion. Settings is accessed via the user menu, not the sidebar.
### AppSidebar.vue Changes
Current sidebar at `resources/js/components/AppSidebar.vue`:
- Lines 39-52: Shows Clients + Declarations if `currentWorkspace` exists — needs role gating
- Uses `page.props.auth?.currentWorkspace` — will also use `page.props.auth?.workspaceRole`
- Already conditionally shows admin nav items based on `page.props.auth.user?.group`
- Team nav item is NOT in the sidebar currently — it's accessed via the workspace settings/team page
### What This Story Does NOT Include
- No changes to the Team page (already fully authorized in Stories 1.1-1.4)
- No new database migrations
- No changes to settings pages (accessible to all roles)
- No activity log viewing page (AC #7 — deferred until Epic 2 Dashboard, where activity feed is implemented; the scoping logic should be noted for that story)
- No changes to client portal routes (`/c/*`) — those use token-based middleware, not auth
### Project Structure Notes
- Alignment with established patterns from TeamController (reference implementation)
- Both `HasWorkspaceScope` and `AuthorizesPermissions` traits are already built and tested
- No new Vue pages needed — modify existing Index/Show pages
- No new models or migrations — uses existing `assigned_to` and `workspace_user.role` columns
- Test files: `tests/Feature/Clients/RoleBasedAccessTest.php` and `tests/Feature/Declarations/RoleBasedAccessTest.php`
### References
- [Source: _bmad-output/planning-artifacts/epics.md#Story 1.5]
- [Source: _bmad-output/planning-artifacts/prd.md#FR10 — "System enforces role-based access — Workers see only assigned items, Managers/Owners see all"]
- [Source: _bmad-output/planning-artifacts/architecture.md#Permission Checking Patterns]
- [Source: _bmad-output/planning-artifacts/architecture.md#Workspace & Tenant Scoping Patterns]
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Collapsible sidebar with role-driven sections]
- [Source: _bmad-output/implementation-artifacts/1-4-manager-permission-toggle-matrix.md]
- [Source: app/Concerns/AuthorizesPermissions.php]
- [Source: app/Concerns/HasWorkspaceScope.php]
- [Source: app/Http/Controllers/TeamController.php — reference implementation]
- [Source: app/Http/Controllers/ClientController.php — needs refactoring]
- [Source: app/Http/Controllers/DeclarationController.php — needs refactoring]
- [Source: app/Http/Middleware/HandleInertiaRequests.php — needs workspaceRole]
- [Source: app/Models/Declaration.php — needs scopeForUser()]
- [Source: resources/js/components/AppSidebar.vue — needs role-based nav]
- [Source: resources/js/types/auth.ts — needs workspaceRole field]
### Previous Story Intelligence
**From Story 1.4 (most recent):**
- `AuthorizesPermissions` trait works: Owner always passes, Worker always 404, Manager checks JSON permissions
- `HasWorkspaceScope` trait provides `currentWorkspace()` and `authorizeWorkspaceAccess()` — ready to adopt in ClientController/DeclarationController
- `WorkspaceUser::where('workspace_id', $workspace->id)->where('user_id', $userId)->firstOrFail()` pattern for pivot lookup
- Role comparison: use `$workspaceUser->role->is(WorkspaceUserRole::Worker)` — never `===`
- Frontend patterns: Dialog, DropdownMenu, Switch from shadcn-vue; `router.put()` with `preserveScroll: true`
- Loading states on action buttons to prevent double-click
- Flash message toast pattern via `HandleInertiaRequests`
**From Story 1.3:**
- Role change resets permissions to defaults — confirms `config('permissions.defaults.{role}')` pattern works
- Activity logging: `activity()->performedOn($target)->causedBy(auth()->user())->withProperties([...])->log('desc')`
**From Story 1.2:**
- `canManageTeam` prop passed from TeamController to frontend — same pattern for `canCreate`/`canEdit`/`canDelete`
- Team member data includes `workspace_user_id` for URL construction
**Git Intelligence:**
- All Epic 0 + Stories 1.1-1.4 implemented on branch `l-ami-fiduciaire-v1.0.0`
- 144 tests pass (382 assertions) — zero regressions baseline
- PHP Pint linting clean; ESLint errors are pre-existing in unchanged files
### Technology Versions
- Laravel 12, PHP 8.2+, Vue 3.5, TypeScript 5.2 (strict), Inertia.js 2.0
- Pest 4.4 for testing
- bensampo/laravel-enum ^6.12 for enums
- Spatie Activity Log ^4.12 for audit trail
- shadcn-vue (reka-ui) for UI components
- lucide-vue-next for icons
- Laravel Wayfinder ^0.1.9 for type-safe frontend routes
## Dev Agent Record
### Agent Model Used
Claude Opus 4.6
### Debug Log References
- Fixed MediaDownloadTest regression: changed `otherUser` role from `worker` to `manager` since the test is about per-user download tracking, not role access.
- Test data for store/update tests required valid payloads to pass Form Request validation before reaching controller Worker checks.
### Completion Notes List
- Implemented `auth.workspaceRole` sharing via `HandleInertiaRequests` middleware using `WorkspaceUser` pivot lookup
- Added `scopeForUser()` scope to Declaration model — Workers see only assigned declarations, Owners/Managers see all
- Refactored `ClientController` and `DeclarationController` to use `HasWorkspaceScope` trait, removing duplicated `currentWorkspace()` and manual authorization methods
- Workers get `abort(404)` on all create/store/edit/update/destroy operations for both clients and declarations
- Worker client scoping: `whereHas('declarations', fn($q) => $q->where('assigned_to', $user->id))`
- Sidebar adapts per role: Workers see Dashboard + "Mes declarations"; Owners/Managers see Dashboard, Clients, Declarations, Equipe
- Frontend `canCreate`/`canEdit`/`canDelete` props conditionally hide action buttons for Workers on Index, Show pages
- 32 new tests covering 10 of 11 acceptance criteria including cross-workspace isolation (AC #7 deferred to Epic 2)
- Full suite: 176 tests passing (638 assertions), zero regressions
### File List
- app/Http/Middleware/HandleInertiaRequests.php (modified — added workspaceRole to auth shared props)
- app/Models/Declaration.php (modified — added scopeForUser() scope)
- app/Models/User.php (modified — memoized currentWorkspaceUser() to avoid duplicate queries per request)
- app/Http/Controllers/ClientController.php (modified — refactored with traits, role scoping, canCreate/canEdit/canDelete props, Worker-scoped declarations/stats in show)
- app/Http/Controllers/DeclarationController.php (modified — refactored with traits, role scoping, canCreate/canEdit/canDelete/canMention props)
- resources/js/types/auth.ts (modified — added workspaceRole to Auth type)
- resources/js/components/AppSidebar.vue (modified — role-based navigation with Wayfinder route helpers)
- resources/js/pages/clients/Index.vue (modified — conditional action buttons)
- resources/js/pages/declarations/Index.vue (modified — conditional action buttons)
- resources/js/pages/declarations/Show.vue (modified — conditional edit button and canEdit/canDelete props)
- tests/Feature/Client/RoleBasedAccessTest.php (new — 14 tests for client role-based access)
- tests/Feature/Declaration/RoleBasedAccessTest.php (new — 18 tests for declaration role-based access)
- tests/Feature/Declaration/MediaDownloadTest.php (modified — fixed otherUser role for compatibility)
## Change Log
- 2026-03-15: Implemented Story 1.5 — Role-based access enforcement across all views. Workers see only assigned items, Owners/Managers see all. Sidebar and action buttons adapt per role. 32 new tests added.
- 2026-03-15: Code review fixes — memoized `currentWorkspaceUser()` to eliminate N+1 queries, scoped client show declarations list and stats for Workers (information leak fix), replaced hardcoded sidebar URLs with Wayfinder route helpers, removed unused `AuthorizesPermissions` trait from both controllers, clarified AC #7 deferral.

View File

@@ -0,0 +1,231 @@
# Story 1.6: Workspace Switching for Multi-Workspace Owners
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
As a firm owner with multiple workspaces,
I want to switch between my workspaces from the sidebar,
so that I can manage multiple cabinets without logging out and back in.
## Acceptance Criteria
1. Owners with multiple workspaces see a workspace switcher dropdown in the sidebar header showing all their workspaces with a visual indicator (checkmark or highlight) on the currently active workspace
2. Clicking a different workspace in the dropdown switches the session's `current_workspace_id`, redirects to the dashboard, and all subsequent data is scoped to the newly selected workspace
3. Managers or Workers who belong to a single workspace see the workspace name displayed in the sidebar header but the switcher dropdown is either hidden or shows only one entry (no switching action available)
4. The existing `WorkspaceSwitchController` is enhanced to redirect to the dashboard (not `back()`) after switching, so the user lands on a known safe page with correct workspace context
5. Workspace switching is logged in the activity log with the previous and new workspace IDs
6. After switching workspaces, the sidebar navigation items, shared Inertia props (`auth.currentWorkspace`, `auth.workspaceRole`), and all data queries correctly reflect the new workspace
7. The workspace switcher shows the workspace logo/initial, name, and slug for each workspace entry
8. Users who belong to only one workspace (regardless of role) do not see the expand/collapse chevron or dropdown trigger — the workspace header is static
## Tasks / Subtasks
- [x] Task 1: Backend — Enhance `WorkspaceSwitchController` (AC: #2, #4, #5)
- [x] 1.1 Change redirect from `back()` to `redirect()->route('dashboard')` so users land on a known safe page after switching
- [x] 1.2 Add activity log entry on successful switch: `activity()->causedBy($user)->withProperties(['previous_workspace_id' => $previousId, 'new_workspace_id' => $workspaceId])->log('Switched workspace')`
- [x] 1.3 Store the previous `current_workspace_id` from session before overwriting, for the activity log
- [x] Task 2: Frontend — Enhance `WorkspaceSwitcher.vue` for improved UX (AC: #1, #3, #7, #8)
- [x] 2.1 Add a visual indicator (checkmark icon or primary color highlight) on the currently active workspace in the dropdown list
- [x] 2.2 When `workspaces.length <= 1`, hide the `ChevronsUpDown` icon and disable the dropdown trigger — render the workspace header as a static display (no `DropdownMenuTrigger`)
- [x] 2.3 Show workspace initials or first letter in the icon area for each dropdown entry (instead of generic `Building2` icon for all)
- [x] 2.4 Replace the hardcoded URL `/workspace/switch` with a Wayfinder route helper (generate if needed, or pass as shared prop)
- [x] 2.5 Prevent double-click by adding a loading state (`isSwitching` ref) that disables the dropdown while the switch request is in flight
- [x] Task 3: Backend — Share workspace switch URL as Inertia prop (AC: #2)
- [x] 3.1 In `HandleInertiaRequests::share()`, add `'workspaceSwitchUrl' => route('workspace.switch')` to the shared props so the frontend never hardcodes the URL
- [x] 3.2 Update `resources/js/types/auth.ts` to include `workspaceSwitchUrl?: string` on the `Auth` type (or create a new shared prop)
- [x] Task 4: Tests — Workspace switching functionality (AC: #1-#8)
- [x] 4.1 Test Owner with 2+ workspaces can switch: POST to workspace.switch with new workspace_id, assert session `current_workspace_id` changed, assert redirect to dashboard
- [x] 4.2 Test Owner switching logs activity with previous and new workspace IDs
- [x] 4.3 Test Worker/Manager cannot switch to a workspace they don't belong to (existing behavior — verify `back()` redirect stays unchanged for unauthorized)
- [x] 4.4 Test switching updates `auth.currentWorkspace` and `auth.workspaceRole` in Inertia shared props on next page load
- [x] 4.5 Test user with single workspace: POST to workspace.switch with same workspace_id — no error, session unchanged
- [x] 4.6 Test cross-workspace isolation: after switching, data queries return only new workspace's data (clients, declarations)
## Dev Notes
### Critical Architecture Patterns (from Stories 1.1-1.5)
- **Authorization: `abort(404)` NEVER `abort(403)`** — hides workspace existence per project convention (NFR9)
- **Enum comparisons: use `->is()` with bensampo/laravel-enum**, NOT `===`
- **WorkspaceUser extends Pivot** — route model binding does NOT work; use manual `WorkspaceUser::where()` lookup scoped to current workspace via session
- **Activity logging:** Use `activity()` helper for business operations (non-model-level). Pattern: `activity()->causedBy(auth()->user())->withProperties([...])->log('description')`
- **Frontend URLs:** All URLs via `route()` helper passed as Inertia props — never hardcode routes in Vue. **The current `WorkspaceSwitcher.vue` hardcodes `/workspace/switch` — this MUST be fixed.**
- **Flash messages:** `HandleInertiaRequests` shares `success`/`error` flash keys; `AppSidebarLayout` displays toast
- **Inertia render paths:** Lowercase subdirectory: `'clients/Index'`, not `'Clients/Index'`
- **Loading states:** All action buttons must have loading states to prevent double-click (pattern from Story 1.4)
### Existing Infrastructure (What Already Works)
| Component | Location | Status |
|---|---|---|
| `WorkspaceSwitchController` | `app/Http/Controllers/WorkspaceSwitchController.php` | EXISTS — needs enhancement (redirect + activity log) |
| `WorkspaceSwitcher.vue` | `resources/js/components/WorkspaceSwitcher.vue` | EXISTS — needs UX enhancement (active indicator, static mode, loading state) |
| `HandleInertiaRequests` | `app/Http/Middleware/HandleInertiaRequests.php` | EXISTS — already shares `auth.workspaces`, `auth.currentWorkspace`, `auth.workspaceRole` |
| `EnsureUserHasWorkspace` | `app/Http/Middleware/EnsureUserHasWorkspace.php` | EXISTS — validates session workspace access, clears invalid sessions |
| Route `workspace.switch` | `routes/web.php` line 12 | EXISTS — `POST workspace/switch` outside `workspace` middleware group (correct) |
| `User::workspaces()` | `app/Models/User.php` | EXISTS — BelongsToMany with pivot role+permissions |
| `User::currentWorkspaceUser()` | `app/Models/User.php` | EXISTS — memoized pivot lookup from session |
### Current WorkspaceSwitchController Analysis
```php
// CURRENT (needs changes):
public function __invoke(Request $request): RedirectResponse
{
$workspaceId = $request->input('workspace_id');
$user = $request->user();
$hasAccess = $user->workspaces()->where('workspaces.id', $workspaceId)->exists();
if (! $hasAccess) {
return back(); // CHANGE: should redirect to dashboard for unauthorized
}
$request->session()->put('current_workspace_id', (int) $workspaceId);
return back(); // CHANGE: should redirect to dashboard
}
```
**Changes needed:**
1. Store previous workspace ID before overwriting session
2. Add activity log entry
3. Change `back()` to `redirect()->route('dashboard')` — after switching workspace, going "back" would show stale data from the previous workspace context
### Current WorkspaceSwitcher.vue Analysis
**Issues to fix:**
1. **Hardcoded URL** (line 27): `router.post('/workspace/switch', ...)` — must use shared prop or Wayfinder route
2. **No active workspace indicator** — all entries look the same in the dropdown
3. **No loading state** — double-click could trigger duplicate switch requests
4. **Always shows dropdown** — even for single-workspace users, the chevron and dropdown trigger are visible
5. **Generic icons** — all workspaces use `Building2` icon; could show workspace initial for differentiation
### HandleInertiaRequests — Already Correct
The middleware already:
- Loads all user workspaces with id, name, slug
- Sets `currentWorkspace` from session (with auto-fallback to first workspace)
- Shares `workspaceRole` from `WorkspaceUser` pivot
- Auto-sets `current_workspace_id` in session if not set and workspaces exist
**No changes needed to the core sharing logic** — just add `workspaceSwitchUrl` to shared props.
### PRD Context (FR11)
> **FR11:** Owner can switch between multiple owned workspaces
Per PRD RBAC matrix:
- **Worker:** Single workspace only — no cross-workspace scenarios
- **Manager:** Single workspace only — doesn't span multiple cabinets
- **Owner:** May have multiple workspaces — workspace switcher available for Owners only
**Important clarification:** The PRD says "Owners only" but technically the `WorkspaceSwitchController` allows any user who belongs to multiple workspaces to switch. The BDD acceptance criteria in the epics file says: "Given a Manager or Worker belongs to a single workspace, When they view the sidebar, Then the workspace name is displayed but the switcher dropdown is not available (or shows only one entry)." This implies the restriction is based on **number of workspaces** (1 vs multiple), not role. If a Manager somehow belongs to 2 workspaces, they should be able to switch. The backend already handles this correctly.
### Database Schema (No New Migrations Needed)
**workspace_user pivot:**
- `user_id` (FK → users.id)
- `workspace_id` (FK → workspaces.id)
- `role` (enum: owner/manager/worker)
- `permissions` (JSON)
**workspaces table:**
- `id`, `name`, `slug`, `created_at`, `updated_at`, `deleted_at`
### What This Story Does NOT Include
- No new database migrations
- No new Vue pages — only modifying existing `WorkspaceSwitcher.vue`
- No changes to the `WorkspaceController` (admin CRUD for workspaces)
- No changes to `EnsureUserHasWorkspace` middleware (already handles invalid sessions)
- No workspace creation from the switcher (the commented-out "Manage workspaces" link in `WorkspaceSwitcher.vue` stays commented out)
- No changes to client/declaration controllers — workspace scoping already works via session
### Project Structure Notes
- Alignment with established patterns from previous stories
- This is a small enhancement story — touching 3 files (controller, Vue component, middleware) plus tests
- Test file: `tests/Feature/Workspace/WorkspaceSwitchTest.php`
- No new models, enums, or traits needed
### References
- [Source: _bmad-output/planning-artifacts/epics.md#Story 1.6]
- [Source: _bmad-output/planning-artifacts/prd.md#FR11 — "Owner can switch between multiple owned workspaces"]
- [Source: _bmad-output/planning-artifacts/prd.md#RBAC — "Owner may have multiple workspaces"]
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Sidebar Navigation — "WorkspaceSwitcher at the top of sidebar"]
- [Source: app/Http/Controllers/WorkspaceSwitchController.php — existing controller]
- [Source: resources/js/components/WorkspaceSwitcher.vue — existing component]
- [Source: app/Http/Middleware/HandleInertiaRequests.php — shared props]
- [Source: app/Http/Middleware/EnsureUserHasWorkspace.php — workspace validation]
- [Source: app/Models/User.php — workspaces relationship + currentWorkspaceUser()]
- [Source: _bmad-output/implementation-artifacts/1-5-role-based-access-enforcement-across-views.md — previous story patterns]
### Previous Story Intelligence
**From Story 1.5 (most recent):**
- `auth.workspaceRole` sharing works via `HandleInertiaRequests` — WorkspaceUser pivot lookup
- `scopeForUser()` on Declaration model works — workspace-scoped queries are established pattern
- Frontend reads `page.props.auth?.workspaceRole` via `usePage()` — same pattern for workspace count check
- Wayfinder route helpers imported as `import { dashboard } from '@/routes'` etc.
- All sidebar nav items use Wayfinder route helpers — `WorkspaceSwitcher.vue` is the ONE component that still hardcodes a URL
- 176 tests passing (638 assertions) — zero regressions baseline
**From Story 1.4:**
- Loading states on action buttons to prevent double-click — use same pattern for workspace switch
- Activity logging: `activity()->performedOn($target)->causedBy(auth()->user())->withProperties([...])->log('desc')`
- Flash message toast pattern via `HandleInertiaRequests`
**Git Intelligence:**
- Branch: `l-ami-fiduciaire-v1.0.0`
- All Stories 1.1-1.5 implemented and passing
- PHP Pint linting clean; ESLint errors are pre-existing in unchanged files
### Technology Versions
- Laravel 12, PHP 8.2+, Vue 3.5, TypeScript 5.2 (strict), Inertia.js 2.0
- Pest 4.4 for testing
- bensampo/laravel-enum ^6.12 for enums
- Spatie Activity Log ^4.12 for audit trail
- shadcn-vue (reka-ui) for UI components
- lucide-vue-next for icons (Check icon available for active indicator)
- Laravel Wayfinder ^0.1.9 for type-safe frontend routes
## Dev Agent Record
### Agent Model Used
Claude Opus 4.6
### Debug Log References
None — clean implementation with no blocking issues.
### Completion Notes List
- Task 1: Enhanced `WorkspaceSwitchController` — changed both `back()` returns to `redirect()->route('dashboard')`, stored previous workspace ID from session before overwriting, added Spatie activity log entry with previous/new workspace IDs.
- Task 2: Rewrote `WorkspaceSwitcher.vue` — added `Check` icon for active workspace indicator, conditional rendering (dropdown for multi-workspace, static display for single workspace), workspace initial letters instead of generic `Building2` icons, `isSwitching` loading state ref to prevent double-click, used shared prop URL instead of hardcoded path.
- Task 3: Added `workspaceSwitchUrl` to `HandleInertiaRequests::share()` and updated `Auth` TypeScript type.
- Task 4: Created 6 Pest feature tests covering: successful switch, activity logging, unauthorized access, Inertia shared prop updates, single-workspace idempotent switch, and cross-workspace data isolation.
- All 182 tests pass (675 assertions), zero regressions. PHP Pint and Prettier clean.
### Implementation Plan
Followed red-green-refactor: implemented backend changes first, then frontend, then wrote comprehensive tests. Used shared Inertia prop for workspace switch URL to eliminate the last hardcoded route in the Vue codebase.
### File List
- `app/Http/Controllers/WorkspaceSwitchController.php` (modified)
- `app/Http/Requests/SwitchWorkspaceRequest.php` (new)
- `resources/js/components/WorkspaceSwitcher.vue` (modified)
- `app/Http/Middleware/HandleInertiaRequests.php` (modified)
- `resources/js/types/auth.ts` (modified)
- `tests/Feature/Workspace/WorkspaceSwitchTest.php` (new)
## Change Log
- 2026-03-16: Implemented Story 1.6 — Enhanced workspace switching with dashboard redirect, activity logging, improved UX (active indicator, static mode for single workspace, loading state), shared URL prop, and 6 feature tests.
- 2026-03-16: Code review fixes — [H1] Added SwitchWorkspaceRequest Form Request for input validation. [M1] Removed dead `session()` call in test helper. [M2] Added early return when switching to same workspace (no noisy activity log). [M3] Added assertion verifying no activity log on unauthorized switch. [M4] Added assertion verifying workspaceSwitchUrl shared prop. [L1] Made Vue computed refs reactive for persistent layout compatibility. All 182 tests pass (677 assertions).

View File

@@ -50,13 +50,13 @@ development_status:
epic-0-retrospective: done
# Epic 1: Team Management & Permission System
epic-1: backlog
1-1-permission-configuration-and-controller-traits: backlog
1-2-team-management-page-view-and-invite-members: backlog
1-3-role-assignment-and-member-removal: backlog
1-4-manager-permission-toggle-matrix: backlog
1-5-role-based-access-enforcement-across-views: backlog
1-6-workspace-switching-for-multi-workspace-owners: backlog
epic-1: in-progress
1-1-permission-configuration-and-controller-traits: done
1-2-team-management-page-view-and-invite-members: done
1-3-role-assignment-and-member-removal: done
1-4-manager-permission-toggle-matrix: done
1-5-role-based-access-enforcement-across-views: done
1-6-workspace-switching-for-multi-workspace-owners: done
epic-1-retrospective: optional
# Epic 2: Role-Driven Dashboard & Command Center

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Concerns;
use App\Enums\WorkspaceUserRole;
trait AuthorizesPermissions
{
/**
* Authorize the current user has the given permission.
*
* Owner: always passes.
* Worker: always fails (abort 404).
* Manager: checks the permissions JSON column on workspace_user pivot.
* Unknown permission keys default to false.
*/
protected function authorizePermission(string $permission): void
{
$workspaceUser = auth()->user()->currentWorkspaceUser();
if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) {
return;
}
if ($workspaceUser->role->is(WorkspaceUserRole::Worker)) {
abort(404);
}
// Manager: check JSON permissions column
if (! ($workspaceUser->permissions[$permission] ?? false)) {
abort(404);
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Concerns;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Model;
trait HasWorkspaceScope
{
/**
* Resolve the current workspace from the session.
*/
protected function currentWorkspace(): Workspace
{
return auth()->user()->workspaces()
->where('workspaces.id', session('current_workspace_id'))
->firstOrFail();
}
/**
* Verify the given resource belongs to the current workspace.
* Aborts with 404 if the resource does not belong to the workspace.
*/
protected function authorizeWorkspaceAccess(Model $resource): void
{
if ($resource->workspace_id !== (int) session('current_workspace_id')) {
abort(404);
}
}
}

14
app/Enums/Permission.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
use BenSampo\Enum\Enum;
final class Permission extends Enum
{
const CanManageTeam = 'can_manage_team';
const CanViewActivityLogs = 'can_view_activity_logs';
const CanConfigurePortal = 'can_configure_portal';
}

View File

@@ -10,5 +10,5 @@ final class WorkspaceUserRole extends Enum
const Manager = 'manager';
const Member = 'member';
const Worker = 'worker';
}

View File

@@ -2,12 +2,13 @@
namespace App\Http\Controllers;
use App\Concerns\HasWorkspaceScope;
use App\Enums\ClientStatus;
use App\Enums\LegalForm;
use App\Enums\WorkspaceUserRole;
use App\Http\Requests\StoreClientRequest;
use App\Http\Requests\UpdateClientRequest;
use App\Models\Client;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -16,6 +17,8 @@ use Inertia\Response;
class ClientController extends Controller
{
use HasWorkspaceScope;
protected function legalFormLabels(): array
{
$labels = [
@@ -42,13 +45,6 @@ class ClientController extends Controller
];
}
protected function currentWorkspace(Request $request): Workspace
{
$workspaceId = $request->session()->get('current_workspace_id');
return Workspace::query()->findOrFail($workspaceId);
}
protected function serializeContacts(Client $client): array
{
return $client->contacts->map(fn ($c) => [
@@ -61,16 +57,29 @@ class ClientController extends Controller
])->all();
}
protected function isWorker(): bool
{
return auth()->user()->currentWorkspaceUser()->role->is(WorkspaceUserRole::Worker);
}
/**
* Display a listing of the clients.
*/
public function index(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
$workspace = $this->currentWorkspace();
$user = auth()->user();
$isWorker = $this->isWorker();
$perPage = min(max((int) $request->input('per_page', 10), 10), 100);
$clients = $workspace->clients()
$query = $workspace->clients();
if ($isWorker) {
$query->whereHas('declarations', fn ($q) => $q->where('assigned_to', $user->id));
}
$clients = $query
->latest()
->paginate($perPage)
->through(fn (Client $client) => [
@@ -88,6 +97,9 @@ class ClientController extends Controller
'clients' => $clients,
'createUrl' => route('clients.create'),
'workspaceName' => $workspace->name,
'canCreate' => ! $isWorker,
'canEdit' => ! $isWorker,
'canDelete' => ! $isWorker,
]);
}
@@ -96,7 +108,11 @@ class ClientController extends Controller
*/
public function create(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
if ($this->isWorker()) {
abort(404);
}
$workspace = $this->currentWorkspace();
return Inertia::render('clients/Create', [
'indexUrl' => route('clients.index'),
@@ -116,7 +132,11 @@ class ClientController extends Controller
*/
public function store(StoreClientRequest $request): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
if ($this->isWorker()) {
abort(404);
}
$workspace = $this->currentWorkspace();
$data = $request->validated();
$contacts = $data['contacts'];
unset($data['contacts']);
@@ -136,12 +156,30 @@ class ClientController extends Controller
*/
public function show(Request $request, Client $client): Response
{
$workspace = $this->currentWorkspace($request);
$this->authorizeClient($workspace, $client);
$this->authorizeWorkspaceAccess($client);
$isWorker = $this->isWorker();
$user = auth()->user();
if ($isWorker) {
$hasAssignedDeclarations = $client->declarations()
->where('assigned_to', $user->id)
->exists();
if (! $hasAssignedDeclarations) {
abort(404);
}
}
$client->load(['internalResponsible', 'contacts']);
$declarations = $client->declarations()
$declarationsQuery = $client->declarations();
if ($isWorker) {
$declarationsQuery->where('assigned_to', $user->id);
}
$declarations = (clone $declarationsQuery)
->with(['assignee'])
->latest()
->limit(50)
@@ -158,7 +196,7 @@ class ClientController extends Controller
->values()
->all();
$allDeclarations = $client->declarations()->get();
$allDeclarations = (clone $declarationsQuery)->get();
$stats = [
'total' => $allDeclarations->count(),
'by_status' => $allDeclarations->groupBy(fn ($f) => $f->status->value)
@@ -190,6 +228,8 @@ class ClientController extends Controller
'indexUrl' => route('clients.index'),
'editUrl' => route('clients.edit', $client),
'createDeclarationUrl' => route('declarations.create', ['client_id' => $client->id]),
'canEdit' => ! $isWorker,
'canDelete' => ! $isWorker,
]);
}
@@ -198,8 +238,13 @@ class ClientController extends Controller
*/
public function edit(Request $request, Client $client): Response
{
$workspace = $this->currentWorkspace($request);
$this->authorizeClient($workspace, $client);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($client);
$workspace = $this->currentWorkspace();
$client->load('contacts');
@@ -235,8 +280,11 @@ class ClientController extends Controller
*/
public function update(UpdateClientRequest $request, Client $client): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$this->authorizeClient($workspace, $client);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($client);
$data = $request->validated();
$contacts = $data['contacts'];
@@ -276,18 +324,14 @@ class ClientController extends Controller
*/
public function destroy(Request $request, Client $client): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$this->authorizeClient($workspace, $client);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($client);
$client->delete();
return to_route('clients.index');
}
protected function authorizeClient(Workspace $workspace, Client $client): void
{
if ($client->workspace_id !== $workspace->id) {
abort(404);
}
}
}

View File

@@ -2,15 +2,16 @@
namespace App\Http\Controllers;
use App\Concerns\HasWorkspaceScope;
use App\Enums\DeclarationPriority;
use App\Enums\DeclarationStatus;
use App\Enums\DeclarationType;
use App\Enums\WorkspaceUserRole;
use App\Http\Requests\StoreDeclarationRequest;
use App\Http\Requests\UpdateDeclarationRequest;
use App\Models\Client;
use App\Models\Declaration;
use App\Models\MediaDownload;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -18,6 +19,8 @@ use Inertia\Response;
class DeclarationController extends Controller
{
use HasWorkspaceScope;
protected function declarationTypeLabels(): array
{
return [
@@ -46,11 +49,9 @@ class DeclarationController extends Controller
];
}
protected function currentWorkspace(Request $request): Workspace
protected function isWorker(): bool
{
$workspaceId = $request->session()->get('current_workspace_id');
return Workspace::query()->findOrFail($workspaceId);
return auth()->user()->currentWorkspaceUser()->role->is(WorkspaceUserRole::Worker);
}
/**
@@ -58,11 +59,15 @@ class DeclarationController extends Controller
*/
public function index(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
$workspace = $this->currentWorkspace();
$user = auth()->user();
$workspaceUser = $user->currentWorkspaceUser();
$isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker);
$perPage = min(max((int) $request->input('per_page', 10), 10), 100);
$declarations = $workspace->declarations()
->forUser($user, $workspaceUser)
->with(['client', 'assignee'])
->latest()
->paginate($perPage)
@@ -82,6 +87,9 @@ class DeclarationController extends Controller
'declarations' => $declarations,
'createUrl' => route('declarations.create'),
'workspaceName' => $workspace->name,
'canCreate' => ! $isWorker,
'canEdit' => ! $isWorker,
'canDelete' => ! $isWorker,
]);
}
@@ -90,7 +98,11 @@ class DeclarationController extends Controller
*/
public function create(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
if ($this->isWorker()) {
abort(404);
}
$workspace = $this->currentWorkspace();
$initialClientId = $request->integer('client_id', 0) ?: null;
return Inertia::render('declarations/Create', [
@@ -121,7 +133,11 @@ class DeclarationController extends Controller
*/
public function store(StoreDeclarationRequest $request): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
if ($this->isWorker()) {
abort(404);
}
$workspace = $this->currentWorkspace();
$data = $request->validated();
$data['workspace_id'] = $workspace->id;
$data['created_by'] = $request->user()?->id;
@@ -143,8 +159,16 @@ class DeclarationController extends Controller
*/
public function show(Request $request, Declaration $declaration): Response
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $declaration);
$this->authorizeWorkspaceAccess($declaration);
$workspace = $this->currentWorkspace();
$user = auth()->user();
$workspaceUser = $user->currentWorkspaceUser();
$isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker);
if ($isWorker && $declaration->assigned_to !== $user->id) {
abort(404);
}
$declaration->load(['client', 'creator', 'assignee', 'messages' => fn ($q) => $q->with(['senderUser', 'senderClient'])->latest()]);
@@ -197,6 +221,8 @@ class DeclarationController extends Controller
'is_downloaded' => in_array($m->id, $downloadedMediaIds),
])->values()->all();
$canMention = ! $isWorker;
return Inertia::render('declarations/Show', [
'declaration' => [
'id' => $declaration->id,
@@ -236,10 +262,9 @@ class DeclarationController extends Controller
->get()->map(fn ($u) => ['id' => $u->id, 'name' => $u->name])
->values()->all(),
'mentionStoreUrl' => route('declarations.mentions.store', $declaration),
'canMention' => in_array(
$workspace->users()->where('users.id', $request->user()->id)->first()?->pivot?->role?->value,
['owner', 'manager']
),
'canMention' => $canMention,
'canEdit' => ! $isWorker,
'canDelete' => ! $isWorker,
]);
}
@@ -248,8 +273,13 @@ class DeclarationController extends Controller
*/
public function edit(Request $request, Declaration $declaration): Response
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $declaration);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($declaration);
$workspace = $this->currentWorkspace();
return Inertia::render('declarations/Edit', [
'declaration' => [
@@ -294,8 +324,11 @@ class DeclarationController extends Controller
*/
public function update(UpdateDeclarationRequest $request, Declaration $declaration): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $declaration);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($declaration);
$data = $request->validated();
@@ -315,18 +348,14 @@ class DeclarationController extends Controller
*/
public function destroy(Request $request, Declaration $declaration): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $declaration);
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($declaration);
$declaration->delete();
return to_route('declarations.index');
}
protected function authorizeDeclaration(Workspace $workspace, Declaration $declaration): void
{
if ($declaration->workspace_id !== $workspace->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace App\Http\Controllers;
use App\Concerns\AuthorizesPermissions;
use App\Concerns\HasWorkspaceScope;
use App\Enums\Permission;
use App\Enums\WorkspaceUserRole;
use App\Http\Requests\InviteTeamMemberRequest;
use App\Http\Requests\UpdatePermissionsRequest;
use App\Http\Requests\UpdateTeamMemberRoleRequest;
use App\Mail\TeamInvitationMail;
use App\Models\TeamInvitation;
use App\Models\User;
use App\Models\WorkspaceUser;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Inertia\Inertia;
use Inertia\Response;
class TeamController extends Controller
{
use AuthorizesPermissions;
use HasWorkspaceScope;
/**
* Display the team management page.
*/
public function index(): Response
{
$workspaceUser = auth()->user()->currentWorkspaceUser();
// Block Workers entirely — team page is Owner/Manager only
if ($workspaceUser->role->is(WorkspaceUserRole::Worker)) {
abort(404);
}
$workspace = $this->currentWorkspace();
$isOwner = $workspaceUser->role->is(WorkspaceUserRole::Owner);
// Load members with pivot data
$members = $workspace->users()
->withPivot('id', 'role', 'permissions', 'created_at')
->get()
->map(function ($user) use ($isOwner) {
$pivotRole = $user->pivot->role;
$role = $pivotRole?->value ?? $pivotRole;
$data = [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $role,
'joined_at' => $user->pivot->created_at,
'status' => 'active',
'workspace_user_id' => $user->pivot->id,
'updateRoleUrl' => route('team.updateRole', $user->pivot->id),
'removeUrl' => route('team.remove', $user->pivot->id),
];
// Add permissions data for Manager members (only visible to Owners)
if ($pivotRole instanceof WorkspaceUserRole && $pivotRole->is(WorkspaceUserRole::Manager) && $isOwner) {
$data['permissions'] = $user->pivot->permissions ?? [];
$data['permissionsUrl'] = route('team.updatePermissions', $user->pivot->id);
}
return $data;
});
// 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,
'isOwner' => $isOwner,
'availablePermissions' => $isOwner ? $this->permissionLabels() : [],
'authUserId' => auth()->id(),
'inviteUrl' => route('team.invite'),
'roles' => $this->roleLabels(),
]);
}
/**
* Invite a new team member.
*/
public function invite(InviteTeamMemberRequest $request): RedirectResponse
{
$workspace = $this->currentWorkspace();
$invitation = TeamInvitation::create([
'workspace_id' => $workspace->id,
'email' => $request->validated('email'),
'role' => $request->validated('role'),
'invited_by' => auth()->id(),
'expires_at' => now()->addDays(7),
]);
Mail::to($invitation->email)->send(new TeamInvitationMail($workspace, $invitation));
return redirect()->back()->with('success', 'Invitation envoyée');
}
/**
* Update a team member's role.
*/
public function updateRole(UpdateTeamMemberRoleRequest $request, int $workspaceUserId): RedirectResponse
{
$this->authorizePermission(Permission::CanManageTeam);
$workspaceUser = WorkspaceUser::where('id', $workspaceUserId)
->where('workspace_id', session('current_workspace_id'))
->firstOrFail();
if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) {
abort(404);
}
if ($workspaceUser->user_id === auth()->id()) {
abort(404);
}
$oldRole = $workspaceUser->role->value;
$newRole = $request->validated('role');
DB::transaction(function () use ($workspaceUser, $oldRole, $newRole) {
$workspaceUser->update([
'role' => $newRole,
'permissions' => config("permissions.defaults.{$newRole}", []),
]);
$targetUser = User::findOrFail($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');
}
/**
* Update a Manager's individual permissions.
*/
public function updatePermissions(UpdatePermissionsRequest $request, int $workspaceUserId): RedirectResponse
{
$workspaceUser = WorkspaceUser::where('id', $workspaceUserId)
->where('workspace_id', session('current_workspace_id'))
->firstOrFail();
// Only Manager permissions can be toggled
if (! $workspaceUser->role->is(WorkspaceUserRole::Manager)) {
abort(404);
}
$newPermissions = $request->validated('permissions');
DB::transaction(function () use ($workspaceUser, $newPermissions) {
$oldPermissions = $workspaceUser->permissions ?? [];
$workspaceUser->update([
'permissions' => $newPermissions,
]);
$targetUser = User::findOrFail($workspaceUser->user_id);
activity()
->performedOn($workspaceUser)
->withProperties([
'target_user' => $targetUser->name,
'old_permissions' => $oldPermissions,
'new_permissions' => $newPermissions,
])
->log('permissions_updated');
});
return redirect()->back()->with('success', 'Permissions mises à jour');
}
/**
* Remove a team member from the workspace.
*/
public function remove(int $workspaceUserId): RedirectResponse
{
$this->authorizePermission(Permission::CanManageTeam);
$workspaceUser = WorkspaceUser::where('id', $workspaceUserId)
->where('workspace_id', session('current_workspace_id'))
->firstOrFail();
if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) {
abort(404);
}
if ($workspaceUser->user_id === auth()->id()) {
abort(404);
}
$targetUser = User::findOrFail($workspaceUser->user_id);
DB::transaction(function () use ($workspaceUser, $targetUser) {
$workspaceUser->delete();
activity()
->withProperties([
'target_user' => $targetUser->name,
'target_email' => $targetUser->email,
'role' => $workspaceUser->role->value,
])
->log('member_removed');
});
return redirect()->back()->with('success', 'Membre retiré');
}
/**
* Get role labels for the frontend.
*
* @return array<string, string>
*/
protected function roleLabels(): array
{
return [
'manager' => 'Gestionnaire',
'worker' => 'Collaborateur',
];
}
/**
* Get permission labels (French) for the frontend.
*
* @return array<string, string>
*/
protected function permissionLabels(): array
{
return [
Permission::CanManageTeam => "Gérer l'équipe",
Permission::CanViewActivityLogs => "Voir les journaux d'activité",
Permission::CanConfigurePortal => 'Configurer le portail client',
];
}
}

View File

@@ -66,7 +66,7 @@ class WorkspaceController extends Controller
$workspace = Workspace::query()->create($data);
$syncData = collect($userIds)->mapWithKeys(fn ($userId) => [
(int) $userId => ['role' => $userRoles[$userId] ?? WorkspaceUserRole::Member],
(int) $userId => ['role' => $userRoles[$userId] ?? WorkspaceUserRole::Worker],
])->all();
$workspace->users()->sync($syncData);
@@ -158,7 +158,7 @@ class WorkspaceController extends Controller
$workspace->update($data);
$syncData = collect($userIds)->mapWithKeys(fn ($userId) => [
(int) $userId => ['role' => $userRoles[$userId] ?? WorkspaceUserRole::Member],
(int) $userId => ['role' => $userRoles[$userId] ?? WorkspaceUserRole::Worker],
])->all();
$workspace->users()->sync($syncData);

View File

@@ -2,27 +2,41 @@
namespace App\Http\Controllers;
use App\Http\Requests\SwitchWorkspaceRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class WorkspaceSwitchController extends Controller
{
/**
* Switch the current workspace.
*/
public function __invoke(Request $request): RedirectResponse
public function __invoke(SwitchWorkspaceRequest $request): RedirectResponse
{
$workspaceId = $request->input('workspace_id');
$workspaceId = (int) $request->validated('workspace_id');
$user = $request->user();
$hasAccess = $user->workspaces()->where('workspaces.id', $workspaceId)->exists();
if (! $hasAccess) {
return back();
return redirect()->route('dashboard');
}
$request->session()->put('current_workspace_id', (int) $workspaceId);
$previousWorkspaceId = $request->session()->get('current_workspace_id');
return back();
if ($previousWorkspaceId === $workspaceId) {
return redirect()->route('dashboard');
}
$request->session()->put('current_workspace_id', $workspaceId);
activity()
->causedBy($user)
->withProperties([
'previous_workspace_id' => $previousWorkspaceId,
'new_workspace_id' => $workspaceId,
])
->log('Switched workspace');
return redirect()->route('dashboard');
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Models\WorkspaceUser;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
@@ -62,12 +63,21 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'flash' => $request->session()->get('flash'),
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
],
'name' => config('app.name'),
'auth' => [
'user' => $user,
'workspaces' => $workspaces,
'currentWorkspace' => $currentWorkspace,
'workspaceRole' => $user && $currentWorkspace
? WorkspaceUser::where('user_id', $user->id)
->where('workspace_id', $currentWorkspace['id'])
->first()?->role?->value
: null,
'workspaceSwitchUrl' => $user ? route('workspace.switch') : null,
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'userNotifications' => [

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Http\Requests;
use App\Enums\Permission;
use App\Enums\WorkspaceUserRole;
use App\Models\TeamInvitation;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
class InviteTeamMemberRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$workspaceUser = $this->user()->currentWorkspaceUser();
if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) {
return true;
}
if ($workspaceUser->role->is(WorkspaceUserRole::Manager)) {
return (bool) ($workspaceUser->permissions[Permission::CanManageTeam] ?? false);
}
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'role' => ['required', 'in:manager,worker'],
];
}
/**
* Configure the validator instance.
*/
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
$workspaceId = session('current_workspace_id');
$email = $this->input('email');
// Check if email is already a member of the workspace
$alreadyMember = \App\Models\Workspace::find($workspaceId)
?->users()
->where('email', $email)
->exists();
if ($alreadyMember) {
$validator->errors()->add('email', 'Cet utilisateur fait déjà partie de l\'équipe.');
}
// Check for existing active invitation
$existingInvitation = TeamInvitation::where('workspace_id', $workspaceId)
->where('email', $email)
->whereNull('accepted_at')
->where('expires_at', '>', now())
->exists();
if ($existingInvitation) {
$validator->errors()->add('email', 'Une invitation est déjà en cours pour cette adresse email.');
}
});
}
/**
* Handle a failed authorization attempt.
*/
protected function failedAuthorization(): void
{
abort(404);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SwitchWorkspaceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'workspace_id' => ['required', 'integer', 'exists:workspaces,id'],
];
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Requests;
use App\Enums\Permission;
use App\Enums\WorkspaceUserRole;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePermissionsRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
* Only Owners can update permissions Managers with can_manage_team CANNOT.
*/
public function authorize(): bool
{
$workspaceUser = $this->user()->currentWorkspaceUser();
return $workspaceUser->role->is(WorkspaceUserRole::Owner);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'permissions' => ['required', 'array'],
'permissions.*' => ['boolean'],
];
}
/**
* Configure the validator instance.
*/
public function withValidator(\Illuminate\Validation\Validator $validator): void
{
$validator->after(function (\Illuminate\Validation\Validator $validator) {
$permissions = $this->input('permissions', []);
$validKeys = Permission::getValues();
foreach (array_keys($permissions) as $key) {
if (! in_array($key, $validKeys, true)) {
$validator->errors()->add(
'permissions',
"Invalid permission key: {$key}"
);
}
}
// Ensure ALL permission keys are present to prevent silent permission loss
$missingKeys = array_diff($validKeys, array_keys($permissions));
if (! empty($missingKeys)) {
$validator->errors()->add(
'permissions',
'Missing permission keys: '.implode(', ', $missingKeys)
);
}
});
}
/**
* Handle a failed authorization attempt.
*/
protected function failedAuthorization(): void
{
abort(404);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use App\Enums\Permission;
use App\Enums\WorkspaceUserRole;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTeamMemberRoleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$workspaceUser = $this->user()->currentWorkspaceUser();
if ($workspaceUser->role->is(WorkspaceUserRole::Owner)) {
return true;
}
if ($workspaceUser->role->is(WorkspaceUserRole::Manager)) {
return (bool) ($workspaceUser->permissions[Permission::CanManageTeam] ?? false);
}
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'role' => ['required', 'in:manager,worker'],
];
}
/**
* Handle a failed authorization attempt.
*/
protected function failedAuthorization(): void
{
abort(404);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Mail;
use App\Models\TeamInvitation;
use App\Models\Workspace;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TeamInvitationMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Workspace $workspace,
public TeamInvitation $invitation
) {}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'Invitation à rejoindre '.$this->workspace->name,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
$roleLabels = [
'manager' => 'Gestionnaire',
'worker' => 'Collaborateur',
];
return new Content(
markdown: 'emails.team-invitation',
with: [
'workspaceName' => $this->workspace->name,
'roleLabel' => $roleLabels[$this->invitation->role] ?? $this->invitation->role,
'registerUrl' => route('register', ['invitation' => $this->invitation->token]),
'expiresAt' => $this->invitation->expires_at->format('d/m/Y à H:i'),
]
);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Enums\DeclarationPriority;
use App\Enums\DeclarationStatus;
use App\Enums\DeclarationType;
use App\Enums\WorkspaceUserRole;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -144,6 +145,19 @@ class Declaration extends Model implements HasMedia
return $this->hasMany(DeclarationInvitation::class);
}
/**
* Scope declarations based on user role.
* Workers see only declarations assigned to them; Owners/Managers see all.
*/
public function scopeForUser(Builder $query, User $user, WorkspaceUser $workspaceUser): Builder
{
if ($workspaceUser->role->is(WorkspaceUserRole::Worker)) {
return $query->where('assigned_to', $user->id);
}
return $query;
}
/**
* Scope a query to only include active (non-archived) declarations.
*/

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
class TeamInvitation extends Model
{
use LogsActivity;
protected $table = 'team_invitations';
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'workspace_id',
'email',
'role',
'token',
'invited_by',
'accepted_at',
'expires_at',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'accepted_at' => 'datetime',
'expires_at' => 'datetime',
];
}
/**
* Boot the model.
*/
protected static function boot(): void
{
parent::boot();
static::creating(function (TeamInvitation $invitation) {
if (empty($invitation->token)) {
$invitation->token = Str::uuid()->toString();
}
});
}
/**
* Get the workspace that owns the invitation.
*
* @return BelongsTo<Workspace, $this>
*/
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/**
* Get the user who sent the invitation.
*
* @return BelongsTo<User, $this>
*/
public function invitedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'invited_by');
}
/**
* Check if the invitation is valid (not accepted, not expired).
*/
public function isValid(): bool
{
if ($this->accepted_at !== null) {
return false;
}
return $this->expires_at->isFuture();
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
}

View File

@@ -66,10 +66,30 @@ class User extends Authenticatable
{
return $this->belongsToMany(Workspace::class, 'workspace_user')
->using(\App\Models\WorkspaceUser::class)
->withPivot('role')
->withPivot('role', 'permissions')
->withTimestamps();
}
/**
* Memoized workspace-user pivot instances, keyed by workspace ID.
*
* @var array<int, WorkspaceUser>
*/
protected array $resolvedWorkspaceUsers = [];
/**
* Get the workspace-user pivot for the current session workspace.
* Result is memoized per workspace ID to avoid duplicate queries within a request.
*/
public function currentWorkspaceUser(): WorkspaceUser
{
$workspaceId = (int) session('current_workspace_id');
return $this->resolvedWorkspaceUsers[$workspaceId] ??= WorkspaceUser::where('user_id', $this->id)
->where('workspace_id', $workspaceId)
->firstOrFail();
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()

View File

@@ -75,10 +75,20 @@ class Workspace extends Model
{
return $this->belongsToMany(User::class, 'workspace_user')
->using(WorkspaceUser::class)
->withPivot('role')
->withPivot('role', 'permissions')
->withTimestamps();
}
/**
* Get the team invitations for the workspace.
*
* @return HasMany<TeamInvitation>
*/
public function teamInvitations(): HasMany
{
return $this->hasMany(TeamInvitation::class);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()

31
config/permissions.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
use App\Enums\Permission;
use App\Enums\WorkspaceUserRole;
return [
/*
|--------------------------------------------------------------------------
| Default Permissions Per Role
|--------------------------------------------------------------------------
|
| Defines the default permission values for each workspace role.
| Owner has all permissions ('*'), Worker has none ([]),
| and Manager has individually configurable permissions.
|
*/
'defaults' => [
WorkspaceUserRole::Owner => ['*'],
WorkspaceUserRole::Manager => [
Permission::CanManageTeam => false,
Permission::CanViewActivityLogs => true,
Permission::CanConfigurePortal => false,
],
WorkspaceUserRole::Worker => [],
],
];

View File

@@ -16,7 +16,7 @@ return new class extends Migration
$table->id();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->default(WorkspaceUserRole::Member);
$table->string('role')->default(WorkspaceUserRole::Worker);
$table->timestamps();
$table->unique(['workspace_id', 'user_id']);

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('workspace_user')
->where('role', 'member')
->update(['role' => 'worker']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::table('workspace_user')
->where('role', 'worker')
->update(['role' => 'member']);
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('team_invitations', function (Blueprint $table) {
$table->id();
$table->foreignId('workspace_id')->constrained()->on('workspaces')->cascadeOnDelete();
$table->string('email');
$table->string('role');
$table->uuid('token')->unique();
$table->foreignId('invited_by')->constrained()->on('users')->cascadeOnDelete();
$table->timestamp('accepted_at')->nullable();
$table->timestamp('expires_at');
$table->timestamps();
$table->index(['workspace_id', 'email']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('team_invitations');
}
};

View File

@@ -68,8 +68,8 @@ class DatabaseSeeder extends Seeder
// Attach users to workspaces
$wsCasa->users()->attach($admin->id, ['role' => WorkspaceUserRole::Owner]);
$wsCasa->users()->attach($fatima->id, ['role' => WorkspaceUserRole::Manager]);
$wsCasa->users()->attach($youssef->id, ['role' => WorkspaceUserRole::Member]);
$wsCasa->users()->attach($khadija->id, ['role' => WorkspaceUserRole::Member]);
$wsCasa->users()->attach($youssef->id, ['role' => WorkspaceUserRole::Worker]);
$wsCasa->users()->attach($khadija->id, ['role' => WorkspaceUserRole::Worker]);
$wsRabat->users()->attach($admin->id, ['role' => WorkspaceUserRole::Owner]);
$wsRabat->users()->attach($omar->id, ['role' => WorkspaceUserRole::Manager]);

32
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "html",
"name": "l'ami fiduciaire",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -11,7 +11,7 @@
"clsx": "^2.1.1",
"laravel-vite-plugin": "^2.0.0",
"lucide-vue-next": "^0.468.0",
"reka-ui": "^2.8.2",
"reka-ui": "^2.9.2",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5",
@@ -79,6 +79,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -588,6 +589,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1592,6 +1594,7 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -2374,6 +2377,7 @@
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2429,6 +2433,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -3240,6 +3245,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3684,6 +3690,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4832,6 +4839,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5018,6 +5026,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5103,6 +5112,7 @@
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"globals": "^13.24.0",
@@ -6241,6 +6251,7 @@
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -8308,6 +8319,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8421,6 +8433,7 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -8710,9 +8723,9 @@
}
},
"node_modules/reka-ui": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.8.2.tgz",
"integrity": "sha512-8lTKcJhmG+D3UyJxhBnNnW/720sLzm0pbA9AC1MWazmJ5YchJAyTSl+O00xP/kxBmEN0fw5JqWVHguiFmsGjzA==",
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.2.tgz",
"integrity": "sha512-/t4e6y1hcG+uDuRfpg6tbMz3uUEvRzNco6NeYTufoJeUghy5Iosxos5YL/p+ieAsid84sdMX9OrgDqpEuCJhBw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.13",
@@ -8731,7 +8744,7 @@
"url": "https://github.com/sponsors/zernonia"
},
"peerDependencies": {
"vue": ">= 3.2.0"
"vue": ">= 3.4.0"
}
},
"node_modules/reka-ui/node_modules/@vueuse/core": {
@@ -9909,6 +9922,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10153,6 +10167,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10255,6 +10270,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -10346,6 +10362,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -10447,6 +10464,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10466,6 +10484,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29",
@@ -10852,6 +10871,7 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -38,7 +38,7 @@
"clsx": "^2.1.1",
"laravel-vite-plugin": "^2.0.0",
"lucide-vue-next": "^0.468.0",
"reka-ui": "^2.8.2",
"reka-ui": "^2.9.2",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5",

View File

@@ -89,75 +89,74 @@
}
}
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.708 0 0);
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;

View File

@@ -35,8 +35,8 @@ import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils';
import type { BreadcrumbItem, NavItem } from '@/types';
import { dashboard } from '@/routes';
import type { BreadcrumbItem, NavItem } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];

View File

@@ -9,8 +9,8 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate leading-tight font-semibold"
>{{ $page.props.name }}</span
>
<span class="mb-0.5 truncate leading-tight font-semibold">{{
$page.props.name
}}</span>
</div>
</template>

View File

@@ -8,6 +8,7 @@ import {
HelpCircle,
LayoutGrid,
Users,
UsersRound,
} from 'lucide-vue-next';
import { computed } from 'vue';
import NavFooter from '@/components/NavFooter.vue';
@@ -23,11 +24,17 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { index as clientsIndex } from '@/routes/clients';
import { index as declarationsIndex } from '@/routes/declarations';
import { index as teamIndex } from '@/routes/team';
import type { NavItem } from '@/types';
import AppLogo from './AppLogo.vue';
import WorkspaceSwitcher from './WorkspaceSwitcher.vue';
const page = usePage();
const workspaceRole = computed(() => page.props.auth?.workspaceRole);
const isWorker = computed(() => workspaceRole.value === 'worker');
const mainNavItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
@@ -37,18 +44,31 @@ const mainNavItems = computed<NavItem[]>(() => {
},
];
if (page.props.auth?.currentWorkspace) {
items.push(
{
title: 'Clients',
href: '/clients',
icon: Briefcase,
},
{
title: 'Déclarations',
href: '/declarations',
if (isWorker.value) {
items.push({
title: 'Mes déclarations',
href: declarationsIndex.url(),
icon: FileStack,
},
);
});
} else {
items.push(
{
title: 'Clients',
href: clientsIndex.url(),
icon: Briefcase,
},
{
title: 'Déclarations',
href: declarationsIndex.url(),
icon: FileStack,
},
{
title: 'Équipe',
href: teamIndex.url(),
icon: UsersRound,
},
);
}
}
return items;
});

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
@@ -16,7 +17,6 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
const passwordInput = useTemplateRef('passwordInput');
</script>

View File

@@ -1,6 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import { router } from '@inertiajs/vue3';
import {
ChevronFirst,
ChevronLast,
ChevronLeft,
ChevronRight,
} from 'lucide-vue-next';
import { computed } from 'vue';
import { Button } from '@/components/ui/button';
import {
@@ -10,7 +16,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from 'lucide-vue-next';
interface PaginationData {
current_page: number;
@@ -33,7 +38,9 @@ const props = withDefaults(defineProps<Props>(), {
});
const canGoPrevious = computed(() => props.pagination.current_page > 1);
const canGoNext = computed(() => props.pagination.current_page < props.pagination.last_page);
const canGoNext = computed(
() => props.pagination.current_page < props.pagination.last_page,
);
const handlePerPageChange = (value: unknown): void => {
const str = value != null ? String(value) : null;
@@ -43,10 +50,14 @@ const handlePerPageChange = (value: unknown): void => {
url.searchParams.set('per_page', perPage.toString());
url.searchParams.set('page', '1');
router.get(url.pathname + url.search, {}, {
preserveState: true,
preserveScroll: true,
});
router.get(
url.pathname + url.search,
{},
{
preserveState: true,
preserveScroll: true,
},
);
};
const goToPage = (page: number): void => {
@@ -57,27 +68,34 @@ const goToPage = (page: number): void => {
const url = new URL(window.location.href);
url.searchParams.set('page', page.toString());
router.get(url.pathname + url.search, {}, {
preserveState: true,
preserveScroll: true,
});
router.get(
url.pathname + url.search,
{},
{
preserveState: true,
preserveScroll: true,
},
);
};
</script>
<template>
<div class="flex flex-col gap-4 px-4 sm:flex-row sm:items-center sm:justify-between">
<div class="text-muted-foreground text-sm">
<div
class="flex flex-col gap-4 px-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="text-sm text-muted-foreground">
<span v-if="selectedCount > 0">
{{ selectedCount }} sur {{ pagination.total }} ligne(s) sélectionnée(s).
</span>
<span v-else>
{{ pagination.total }} ligne(s) au total
{{ selectedCount }} sur {{ pagination.total }} ligne(s)
sélectionnée(s).
</span>
<span v-else> {{ pagination.total }} ligne(s) au total </span>
</div>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-6">
<div class="flex items-center gap-2">
<span class="text-muted-foreground hidden text-sm sm:inline">Lignes par page</span>
<span class="hidden text-sm text-muted-foreground sm:inline"
>Lignes par page</span
>
<Select
:model-value="pagination.per_page.toString()"
@update:model-value="handlePerPageChange"
@@ -97,8 +115,9 @@ const goToPage = (page: number): void => {
</Select>
</div>
<div class="text-muted-foreground hidden text-sm md:inline">
Page {{ pagination.current_page }} sur {{ pagination.last_page }}
<div class="hidden text-sm text-muted-foreground md:inline">
Page {{ pagination.current_page }} sur
{{ pagination.last_page }}
</div>
<div class="flex items-center justify-center gap-2">
@@ -118,7 +137,7 @@ const goToPage = (page: number): void => {
>
<ChevronLeft class="size-4" />
</Button>
<div class="text-muted-foreground mx-2 text-sm md:hidden">
<div class="mx-2 text-sm text-muted-foreground md:hidden">
{{ pagination.current_page }}/{{ pagination.last_page }}
</div>
<Button

View File

@@ -21,8 +21,8 @@ import {
import { Spinner } from '@/components/ui/spinner';
import { useAppearance } from '@/composables/useAppearance';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import type { TwoFactorConfigContent } from '@/types';
import { confirm } from '@/routes/two-factor';
import type { TwoFactorConfigContent } from '@/types';
type Props = {
requiresConfirmation: boolean;

View File

@@ -71,7 +71,11 @@ const emit = defineEmits<{
type="password"
:required="passwordRequired"
autocomplete="new-password"
:placeholder="passwordRequired ? 'Password' : 'Leave blank to keep current'"
:placeholder="
passwordRequired
? 'Password'
: 'Leave blank to keep current'
"
aria-invalid="!!form.errors.password"
/>
<InputError :message="form.errors.password" />
@@ -97,7 +101,7 @@ const emit = defineEmits<{
id="group"
v-model="form.group"
required
class="border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring h-9 w-full rounded-md border px-3 py-1 text-sm shadow-xs outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50"
class="h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-xs outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
:aria-invalid="!!form.errors.group"
>
<option value="" disabled>Select a group</option>

View File

@@ -8,9 +8,9 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import UserInfo from '@/components/UserInfo.vue';
import type { User } from '@/types';
import { logout } from '@/routes';
import { edit } from '@/routes/profile';
import type { User } from '@/types';
type Props = {
user: User;

View File

@@ -34,7 +34,7 @@ const emit = defineEmits<{
submit: [];
}>();
const defaultRole = 'member';
const defaultRole = 'worker';
function onUserToggle(userId: number, checked: boolean) {
if (checked) {
@@ -43,9 +43,14 @@ function onUserToggle(userId: number, checked: boolean) {
props.form.user_ids = [...ids, userId];
}
const roles = props.form.user_roles ?? {};
props.form.user_roles = { ...roles, [userId]: roles[userId] ?? defaultRole };
props.form.user_roles = {
...roles,
[userId]: roles[userId] ?? defaultRole,
};
} else {
props.form.user_ids = (props.form.user_ids ?? []).filter((id) => id !== userId);
props.form.user_ids = (props.form.user_ids ?? []).filter(
(id) => id !== userId,
);
const roles = { ...(props.form.user_roles ?? {}) };
delete roles[userId];
props.form.user_roles = roles;
@@ -113,10 +118,15 @@ function getUserRole(userId: number): string {
type="checkbox"
:value="user.id"
:checked="isUserSelected(user.id)"
class="border-input size-4 shrink-0 rounded-[4px] border focus-visible:ring-2 focus-visible:ring-ring"
@change="onUserToggle(user.id, ($event.target as HTMLInputElement).checked)"
class="size-4 shrink-0 rounded-[4px] border border-input focus-visible:ring-2 focus-visible:ring-ring"
@change="
onUserToggle(
user.id,
($event.target as HTMLInputElement).checked,
)
"
/>
<div class="min-w-0 flex-1 flex flex-col">
<div class="flex min-w-0 flex-1 flex-col">
<span class="text-sm font-medium">{{ user.name }}</span>
<span class="text-xs text-muted-foreground">{{
user.email
@@ -125,7 +135,7 @@ function getUserRole(userId: number): string {
<select
:value="getUserRole(user.id)"
:disabled="!isUserSelected(user.id)"
class="border-input bg-background h-8 shrink-0 rounded-md border px-2 text-sm disabled:opacity-50"
class="h-8 shrink-0 rounded-md border border-input bg-background px-2 text-sm disabled:opacity-50"
@change="
onRoleChange(
user.id,

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import { BoxSelect, Building2, ChevronsUpDown, Plus } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import { router, usePage } from '@inertiajs/vue3';
import { BoxSelect, Check, ChevronsUpDown } from 'lucide-vue-next';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
@@ -19,30 +19,58 @@ import {
const page = usePage();
const { isMobile } = useSidebar();
const workspaces = page.props.auth?.workspaces ?? [];
const currentWorkspace = page.props.auth?.currentWorkspace ?? null;
const workspaces = computed(() => page.props.auth?.workspaces ?? []);
const currentWorkspace = computed(
() => page.props.auth?.currentWorkspace ?? null,
);
const workspaceSwitchUrl = computed(
() => page.props.auth?.workspaceSwitchUrl ?? '',
);
const canSwitch = computed(() => workspaces.value.length > 1);
const isSwitching = ref(false);
function getInitial(name: string): string {
return name.charAt(0).toUpperCase();
}
function switchWorkspace(workspace: { id: number }) {
router.post('/workspace/switch', { workspace_id: workspace.id }, {
preserveState: false,
onSuccess: () => router.reload(),
});
if (isSwitching.value || workspace.id === currentWorkspace.value?.id) {
return;
}
isSwitching.value = true;
router.post(
workspaceSwitchUrl.value,
{ workspace_id: workspace.id },
{
preserveState: false,
onFinish: () => {
isSwitching.value = false;
},
},
);
}
</script>
<template>
<SidebarMenu v-if="workspaces.length > 0">
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<!-- Multi-workspace: show dropdown -->
<DropdownMenu v-if="canSwitch">
<DropdownMenuTrigger as-child :disabled="isSwitching">
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground"
>
<Building2 class="size-4" />
{{
currentWorkspace
? getInitial(currentWorkspace.name)
: '?'
}}
</div>
<div
class="grid flex-1 text-left text-sm leading-tight"
@@ -75,46 +103,57 @@ function switchWorkspace(workspace: { id: number }) {
v-for="workspace in workspaces"
:key="workspace.id"
class="gap-2 p-2"
:disabled="isSwitching"
@click="switchWorkspace(workspace)"
>
<div
class="flex size-6 items-center justify-center rounded-sm border"
class="flex size-6 items-center justify-center rounded-sm border text-xs font-semibold"
>
<Building2 class="size-3.5 shrink-0" />
{{ getInitial(workspace.name) }}
</div>
<div class="flex flex-col">
<div class="flex flex-1 flex-col">
<span>{{ workspace.name }}</span>
<span class="text-xs text-muted-foreground">{{
workspace.slug
}}</span>
</div>
<Check
v-if="currentWorkspace?.id === workspace.id"
class="ml-auto size-4 text-primary"
/>
</DropdownMenuItem>
<!-- <DropdownMenuSeparator />
<DropdownMenuItem as-child>
<Link
href="/workspaces"
class="flex w-full cursor-pointer items-center gap-2 p-2"
>
<div
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
>
<Plus class="size-4" />
</div>
<span class="font-medium text-muted-foreground">
Manage workspaces
</span>
</Link>
</DropdownMenuItem> -->
</DropdownMenuContent>
</DropdownMenu>
<!-- Single workspace: static display -->
<SidebarMenuButton v-else size="lg">
<div
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground"
>
{{
currentWorkspace
? getInitial(currentWorkspace.name)
: '?'
}}
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">
{{ currentWorkspace?.name ?? 'Select workspace' }}
</span>
<span
v-if="currentWorkspace"
class="truncate text-xs text-muted-foreground"
>
{{ currentWorkspace.slug }}
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu v-else>
<SidebarMenuItem>
<SidebarMenuButton size="lg" as-child>
<div
class="flex items-center gap-2 text-muted-foreground"
>
<div class="flex items-center gap-2 text-muted-foreground">
<div
class="flex aspect-square size-8 items-center justify-center rounded-lg border border-dashed"
>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SwitchRoot,
SwitchThumb,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SwitchRoot
v-slot="slotProps"
data-slot="switch"
v-bind="forwarded"
:class="cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
<SwitchThumb
data-slot="switch-thumb"
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
>
<slot name="thumb" v-bind="slotProps" />
</SwitchThumb>
</SwitchRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Switch } from "./Switch.vue"

View File

@@ -1,4 +1,7 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { CheckCircle, XCircle, X } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import AppContent from '@/components/AppContent.vue';
import AppShell from '@/components/AppShell.vue';
import AppSidebar from '@/components/AppSidebar.vue';
@@ -12,6 +15,34 @@ type Props = {
withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
});
const page = usePage<{
flash: { success?: string; error?: string };
}>();
const flashMessage = ref<{ type: 'success' | 'error'; text: string } | null>(
null,
);
const flash = computed(() => page.props.flash);
watch(
flash,
(val) => {
if (val?.success) {
flashMessage.value = { type: 'success', text: val.success };
setTimeout(() => {
flashMessage.value = null;
}, 4000);
} else if (val?.error) {
flashMessage.value = { type: 'error', text: val.error };
setTimeout(() => {
flashMessage.value = null;
}, 4000);
}
},
{ immediate: true },
);
</script>
<template>
@@ -21,5 +52,40 @@ withDefaults(defineProps<Props>(), {
<AppSidebarHeader :breadcrumbs="breadcrumbs" />
<slot />
</AppContent>
<!-- Flash Messages -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-2 opacity-0"
>
<div
v-if="flashMessage"
class="fixed right-4 bottom-4 z-50 flex max-w-sm items-center gap-3 rounded-lg border bg-background px-4 py-3 shadow-lg"
:class="
flashMessage.type === 'success'
? 'border-green-200 dark:border-green-800'
: 'border-red-200 dark:border-red-800'
"
>
<CheckCircle
v-if="flashMessage.type === 'success'"
class="h-5 w-5 shrink-0 text-green-600"
/>
<XCircle v-else class="h-5 w-5 shrink-0 text-red-600" />
<span class="text-sm">{{ flashMessage.text }}</span>
<button
class="ml-auto shrink-0 text-muted-foreground hover:text-foreground"
@click="flashMessage = null"
>
<X class="h-4 w-4" />
</button>
</div>
</Transition>
</Teleport>
</AppShell>
</template>

View File

@@ -5,11 +5,11 @@ import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { toUrl } from '@/lib/utils';
import type { NavItem } from '@/types';
import { edit as editAppearance } from '@/routes/appearance';
import { edit as editProfile } from '@/routes/profile';
import { show } from '@/routes/two-factor';
import { edit as editPassword } from '@/routes/user-password';
import type { NavItem } from '@/types';
const sidebarNavItems: NavItem[] = [
{

View File

@@ -10,8 +10,8 @@ import {
InputOTPSlot,
} from '@/components/ui/input-otp';
import AuthLayout from '@/layouts/AuthLayout.vue';
import type { TwoFactorConfigContent } from '@/types';
import { store } from '@/routes/two-factor/login';
import type { TwoFactorConfigContent } from '@/types';
const authConfigContent = computed<TwoFactorConfigContent>(() => {
if (showRecoveryInput.value) {

View File

@@ -3,8 +3,8 @@ import { Head, Link, useForm } from '@inertiajs/vue3';
import ClientForm from '@/components/ClientForm.vue';
import type { ClientFormData } from '@/components/ClientForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type WorkspaceUser = {
id: number;

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import ClientForm from '@/components/ClientForm.vue';
import type { ClientContactData, ClientFormData } from '@/components/ClientForm.vue';
import type {
ClientContactData,
ClientFormData,
} from '@/components/ClientForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type WorkspaceUser = {
id: number;

View File

@@ -2,9 +2,9 @@
import { Head, Link, router } from '@inertiajs/vue3';
import { Building2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import Pagination from '@/components/Pagination.vue';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type Client = {
id: number;
@@ -36,9 +36,12 @@ type Props = {
clients: PaginatedData<Client>;
createUrl: string;
workspaceName: string;
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
};
defineProps<Props>();
const props = defineProps<Props>();
function destroy(client: Client) {
if (
@@ -73,11 +76,7 @@ function getLegalFormLabel(legalForm: string): string {
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Clients' },
]"
>
<AppLayout :breadcrumbs="[{ title: 'Clients' }]">
<Head title="Clients" />
<div class="flex flex-col space-y-6 p-4">
@@ -87,40 +86,42 @@ function getLegalFormLabel(legalForm: string): string {
title="Clients"
:description="`Gérer les clients du workspace « ${workspaceName} »`"
/>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl">Ajouter un client</Link>
</Button>
</div>
<div
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden"
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-sidebar-border/70 bg-muted/50">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Raison sociale
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Forme juridique
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
ICE
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Statut
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
class="h-10 px-4 text-right align-middle font-medium"
>
Actions
</th>
@@ -147,20 +148,33 @@ function getLegalFormLabel(legalForm: string): string {
{{ client.ice || '—' }}
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ client.status ? statusLabels[client.status] ?? client.status : '—' }}
{{
client.status
? (statusLabels[client.status] ??
client.status)
: '—'
}}
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<Link :href="client.showUrl"
>Voir</Link
>
<td class="space-x-2 px-4 py-3 text-right">
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="client.showUrl">Voir</Link>
</Button>
<Button variant="outline" size="sm" as-child>
<Button
v-if="props.canEdit"
variant="outline"
size="sm"
as-child
>
<Link :href="client.editUrl"
>Modifier</Link
>
</Button>
<Button
v-if="props.canDelete"
variant="destructive"
size="sm"
@click="destroy(client)"
@@ -174,10 +188,12 @@ function getLegalFormLabel(legalForm: string): string {
colspan="5"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<div
class="flex flex-col items-center gap-2"
>
<Building2 class="h-10 w-10" />
<p>Aucun client pour le moment.</p>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl"
>Ajouter votre premier
client</Link

View File

@@ -37,9 +37,12 @@ type Props = {
declarations: PaginatedData<Declaration>;
createUrl: string;
workspaceName: string;
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
};
defineProps<Props>();
const props = defineProps<Props>();
function destroy(declaration: Declaration) {
if (
@@ -86,7 +89,7 @@ const statusLabels: Record<string, string> = {
title="Déclarations"
:description="`Gérer les déclarations du workspace « ${workspaceName} »`"
/>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl">Nouvelle déclaration</Link>
</Button>
</div>
@@ -175,6 +178,7 @@ const statusLabels: Record<string, string> = {
>
</Button>
<Button
v-if="props.canEdit"
variant="outline"
size="sm"
as-child
@@ -184,6 +188,7 @@ const statusLabels: Record<string, string> = {
>
</Button>
<Button
v-if="props.canDelete"
variant="destructive"
size="sm"
@click="destroy(declaration)"
@@ -204,7 +209,7 @@ const statusLabels: Record<string, string> = {
<p>
Aucune déclaration pour le moment.
</p>
<Button as-child>
<Button v-if="props.canCreate" as-child>
<Link :href="createUrl"
>Créer votre première
déclaration</Link

View File

@@ -79,6 +79,8 @@ type Props = {
workspaceUsers: WorkspaceUser[];
mentionStoreUrl: string;
canMention: boolean;
canEdit: boolean;
canDelete: boolean;
};
const props = defineProps<Props>();
@@ -344,7 +346,7 @@ const declarationTimelineItems = computed(() => {
typeLabels[declaration.type] ?? declaration.type
"
/>
<Button variant="outline" as-child>
<Button v-if="props.canEdit" variant="outline" as-child>
<Link :href="editUrl">Modifier la déclaration</Link>
</Button>
</div>

View File

@@ -4,8 +4,8 @@ import AppearanceTabs from '@/components/AppearanceTabs.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import { edit } from '@/routes/appearance';
import type { BreadcrumbItem } from '@/types';
const breadcrumbItems: BreadcrumbItem[] = [
{

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
@@ -7,9 +8,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
import { edit } from '@/routes/user-password';
import type { BreadcrumbItem } from '@/types';
const breadcrumbItems: BreadcrumbItem[] = [
{

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Form, Head, Link, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
@@ -9,10 +10,9 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import { edit } from '@/routes/profile';
import { send } from '@/routes/verification';
import type { BreadcrumbItem } from '@/types';
type Props = {
mustVerifyEmail: boolean;

View File

@@ -10,8 +10,8 @@ import { Button } from '@/components/ui/button';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import AppLayout from '@/layouts/AppLayout.vue';
import SettingsLayout from '@/layouts/settings/Layout.vue';
import type { BreadcrumbItem } from '@/types';
import { disable, enable, show } from '@/routes/two-factor';
import type { BreadcrumbItem } from '@/types';
type Props = {
requiresConfirmation?: boolean;

View File

@@ -0,0 +1,606 @@
<script setup lang="ts">
import { Head, router, useForm } from '@inertiajs/vue3';
import {
MoreHorizontal,
Shield,
UserCog,
UserMinus,
UserPlus,
Users,
} from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes';
import { index as teamIndex } from '@/routes/team';
import type { BreadcrumbItem, TeamPageProps, TeamMember } from '@/types';
type Props = TeamPageProps;
const ROLE_OWNER = 'owner';
const ROLE_MANAGER = 'manager';
const props = defineProps<Props>();
const showInviteDialog = ref(false);
const form = useForm({
email: '',
role: 'worker',
});
function submitInvite() {
form.post(props.inviteUrl, {
onSuccess: () => {
showInviteDialog.value = false;
form.reset();
},
});
}
// ── Role Change Dialog ──
const showRoleDialog = ref(false);
const roleChangeMember = ref<TeamMember | null>(null);
const selectedRole = ref('');
const roleChangeProcessing = ref(false);
function openRoleDialog(member: TeamMember) {
roleChangeMember.value = member;
selectedRole.value = member.role;
showRoleDialog.value = true;
}
const isRoleUnchanged = computed(
() => selectedRole.value === roleChangeMember.value?.role,
);
function submitRoleChange() {
if (!roleChangeMember.value || isRoleUnchanged.value) return;
router.patch(
roleChangeMember.value.updateRoleUrl,
{ role: selectedRole.value },
{
onStart: () => {
roleChangeProcessing.value = true;
},
onFinish: () => {
roleChangeProcessing.value = false;
},
onSuccess: () => {
showRoleDialog.value = false;
roleChangeMember.value = null;
},
},
);
}
// ── Remove Member Dialog ──
const showRemoveDialog = ref(false);
const removeMember = ref<TeamMember | null>(null);
const removeProcessing = ref(false);
function openRemoveDialog(member: TeamMember) {
removeMember.value = member;
showRemoveDialog.value = true;
}
function submitRemove() {
if (!removeMember.value) return;
router.delete(removeMember.value.removeUrl, {
onStart: () => {
removeProcessing.value = true;
},
onFinish: () => {
removeProcessing.value = false;
},
onSuccess: () => {
showRemoveDialog.value = false;
removeMember.value = null;
},
});
}
// ── Permissions Dialog ──
const showPermissionsDialog = ref(false);
const permissionsMember = ref<TeamMember | null>(null);
const permissionsProcessing = ref(false);
function openPermissionsDialog(member: TeamMember) {
permissionsMember.value = member;
showPermissionsDialog.value = true;
}
function togglePermission(key: string, value: boolean) {
if (!permissionsMember.value?.permissionsUrl) return;
const updatedPermissions = {
...permissionsMember.value.permissions,
[key]: value,
};
permissionsProcessing.value = true;
router.put(
permissionsMember.value.permissionsUrl,
{ permissions: updatedPermissions },
{
preserveScroll: true,
onFinish: () => {
permissionsProcessing.value = false;
},
onSuccess: () => {
if (permissionsMember.value) {
permissionsMember.value = {
...permissionsMember.value,
permissions: updatedPermissions,
};
}
},
},
);
}
// Keep permissionsMember in sync when props update (e.g. Inertia partial reload)
watch(
() => props.members,
(members) => {
if (!permissionsMember.value || !showPermissionsDialog.value) return;
const updated = members.find(
(m) => m.workspace_user_id === permissionsMember.value!.workspace_user_id,
);
if (updated) {
permissionsMember.value = updated;
}
},
);
function canShowActions(member: TeamMember): boolean {
// No actions for current user's own row
if (member.id === props.authUserId) return false;
// No actions for Owner rows (managers cannot modify owners)
if (member.role === ROLE_OWNER) return false;
return true;
}
const allMembers = computed(() => {
const active = props.members.map((m) => ({
id: `member-${m.id}`,
name: m.name,
email: m.email,
role: m.role,
date: m.joined_at,
status: m.status,
}));
const pending = props.pendingInvitations.map((inv) => ({
id: `invite-${inv.id}`,
name: null as string | null,
email: inv.email,
role: inv.role,
date: inv.invited_at,
status: inv.status,
}));
return [...active, ...pending];
});
const isEmpty = computed(
() => props.members.length <= 1 && props.pendingInvitations.length === 0,
);
const roleLabels = computed<Record<string, string>>(() => ({
owner: 'Propriétaire',
...props.roles,
}));
const statusLabels: Record<string, string> = {
active: 'Actif',
pending: 'En attente',
};
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function getMemberData(member: {
id: string;
status: string;
}): TeamMember | null {
if (member.status !== 'active') return null;
const userId = Number(member.id.replace('member-', ''));
return props.members.find((m) => m.id === userId) ?? null;
}
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Dashboard', href: dashboard().url },
{ title: 'Équipe', href: teamIndex().url },
];
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Équipe" />
<!-- Invite Dialog (shared across empty state and header) -->
<Dialog v-model:open="showInviteDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Inviter un membre</DialogTitle>
<DialogDescription>
Envoyez une invitation par email pour ajouter un membre
à votre équipe.
</DialogDescription>
</DialogHeader>
<form class="space-y-4" @submit.prevent="submitInvite">
<div class="space-y-2">
<Label for="email">Adresse email</Label>
<Input
id="email"
v-model="form.email"
type="email"
placeholder="nom@exemple.com"
required
/>
<p
v-if="form.errors.email"
class="text-sm text-destructive"
>
{{ form.errors.email }}
</p>
</div>
<div class="space-y-2">
<Label for="role">Rôle</Label>
<Select v-model="form.role">
<SelectTrigger>
<SelectValue
placeholder="Sélectionner un rôle"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="(label, value) in roles"
:key="value"
:value="value"
>
{{ label }}
</SelectItem>
</SelectContent>
</Select>
<p
v-if="form.errors.role"
class="text-sm text-destructive"
>
{{ form.errors.role }}
</p>
</div>
<DialogFooter>
<Button type="submit" :disabled="form.processing">
Envoyer l'invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<!-- Role Change Dialog -->
<Dialog v-model:open="showRoleDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Changer le rôle</DialogTitle>
<DialogDescription>
Sélectionnez le nouveau rôle pour
{{ roleChangeMember?.name }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label>Rôle</Label>
<Select v-model="selectedRole">
<SelectTrigger>
<SelectValue
placeholder="Sélectionner un rôle"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="(label, value) in roles"
:key="value"
:value="value"
>
{{ label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
:disabled="isRoleUnchanged || roleChangeProcessing"
@click="submitRoleChange"
>
Confirmer
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
<!-- Permissions Dialog -->
<Dialog v-model:open="showPermissionsDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Gérer les permissions</DialogTitle>
<DialogDescription>
Configurez les permissions de
{{ permissionsMember?.name }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div
v-for="(label, key) in availablePermissions"
:key="key"
class="flex items-center justify-between rounded-lg border p-3"
>
<Label :for="`perm-${key}`" class="cursor-pointer">
{{ label }}
</Label>
<Switch
:id="`perm-${key}`"
:checked="
permissionsMember?.permissions?.[key] ?? false
"
:disabled="permissionsProcessing"
@update:checked="
(val: boolean) => togglePermission(key, val)
"
/>
</div>
</div>
</DialogContent>
</Dialog>
<!-- Remove Member Dialog -->
<Dialog v-model:open="showRemoveDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Retirer le membre</DialogTitle>
<DialogDescription>
Êtes-vous sûr de vouloir retirer
{{ removeMember?.name }} de l'espace de travail ? Cette
action est irréversible.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="showRemoveDialog = false">
Annuler
</Button>
<Button
variant="destructive"
:disabled="removeProcessing"
@click="submitRemove"
>
Retirer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div class="flex flex-col space-y-6 p-4">
<div class="flex items-center justify-between">
<Heading
variant="small"
title="Équipe"
description="Gérer les membres de votre équipe"
/>
<Button v-if="canManageTeam" @click="showInviteDialog = true">
<UserPlus class="mr-2 h-4 w-4" />
Inviter un membre
</Button>
</div>
<!-- Empty State -->
<div
v-if="isEmpty"
class="flex flex-col items-center justify-center rounded-xl border border-sidebar-border/70 px-4 py-16 text-center dark:border-sidebar-border"
>
<Users class="mb-4 h-12 w-12 text-muted-foreground" />
<h3 class="text-lg font-medium">Aucun membre</h3>
<p class="mt-1 text-sm text-muted-foreground">
Invitez votre premier membre d'équipe
</p>
<Button
v-if="canManageTeam"
class="mt-4"
@click="showInviteDialog = true"
>
<UserPlus class="mr-2 h-4 w-4" />
Inviter un membre
</Button>
</div>
<!-- Team Table -->
<div
v-else
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Nom
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Email
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Rôle
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Rejoint le
</th>
<th
class="h-10 px-4 text-left align-middle font-medium"
>
Statut
</th>
<th
v-if="canManageTeam"
class="h-10 w-12 px-4 text-left align-middle font-medium"
>
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="member in allMembers"
:key="member.id"
class="border-b border-sidebar-border/50 last:border-0 hover:bg-muted/50"
>
<td class="px-4 py-3 font-medium">
{{ member.name ?? '' }}
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ member.email }}
</td>
<td class="px-4 py-3">
<Badge variant="secondary">
{{
roleLabels[member.role] ??
member.role
}}
</Badge>
</td>
<td class="px-4 py-3 text-muted-foreground">
{{ formatDate(member.date) }}
</td>
<td class="px-4 py-3">
<Badge
:variant="
member.status === 'active'
? 'default'
: 'outline'
"
>
{{ statusLabels[member.status] }}
</Badge>
</td>
<td v-if="canManageTeam" class="px-4 py-3">
<template
v-if="
getMemberData(member) &&
canShowActions(
getMemberData(member)!,
)
"
>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="ghost"
size="icon"
>
<MoreHorizontal
class="h-4 w-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-if="
isOwner &&
getMemberData(member)!
.role ===
ROLE_MANAGER
"
@click="
openPermissionsDialog(
getMemberData(
member,
)!,
)
"
>
<Shield
class="mr-2 h-4 w-4"
/>
Gérer les permissions
</DropdownMenuItem>
<DropdownMenuItem
@click="
openRoleDialog(
getMemberData(
member,
)!,
)
"
>
<UserCog
class="mr-2 h-4 w-4"
/>
Changer le rôle
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
class="text-destructive"
@click="
openRemoveDialog(
getMemberData(
member,
)!,
)
"
>
<UserMinus
class="mr-2 h-4 w-4"
/>
Retirer de l'espace
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AppLayout>
</template>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import UserForm from '@/components/UserForm.vue';
import type { UserFormData } from '@/components/UserForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import type { BreadcrumbItem } from '@/types';

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import UserForm from '@/components/UserForm.vue';
import type { UserFormData } from '@/components/UserForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type User = {

View File

@@ -2,10 +2,10 @@
import { Head, Link, router } from '@inertiajs/vue3';
import { Users } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import Pagination from '@/components/Pagination.vue';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type User = {
id: number;
@@ -51,11 +51,7 @@ function formatGroup(group: string): string {
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Users' },
]"
>
<AppLayout :breadcrumbs="[{ title: 'Users' }]">
<Head title="Users" />
<div class="flex flex-col space-y-6 p-4">
@@ -71,29 +67,31 @@ function formatGroup(group: string): string {
</div>
<div
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden"
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-sidebar-border/70 bg-muted/50">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Name
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Email
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Group
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
class="h-10 px-4 text-right align-middle font-medium"
>
Actions
</th>
@@ -114,8 +112,12 @@ function formatGroup(group: string): string {
{{ formatGroup(user.group) }}
</Badge>
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<td class="space-x-2 px-4 py-3 text-right">
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="user.editUrl">Edit</Link>
</Button>
<Button
@@ -132,7 +134,9 @@ function formatGroup(group: string): string {
colspan="4"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<div
class="flex flex-col items-center gap-2"
>
<Users class="h-10 w-10" />
<p>No users yet.</p>
<Button as-child>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import WorkspaceForm from '@/components/WorkspaceForm.vue';
import type { WorkspaceFormData } from '@/components/WorkspaceForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type Props = {

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import Heading from '@/components/Heading.vue';
import WorkspaceForm from '@/components/WorkspaceForm.vue';
import type { WorkspaceFormData } from '@/components/WorkspaceForm.vue';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
type Workspace = {

View File

@@ -2,10 +2,10 @@
import { Head, Link, router } from '@inertiajs/vue3';
import { Building2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import AppLayout from '@/layouts/AppLayout.vue';
import Pagination from '@/components/Pagination.vue';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
type Workspace = {
id: number;
@@ -51,11 +51,7 @@ function destroy(workspace: Workspace) {
</script>
<template>
<AppLayout
:breadcrumbs="[
{ title: 'Workspaces' },
]"
>
<AppLayout :breadcrumbs="[{ title: 'Workspaces' }]">
<Head title="Workspaces" />
<div class="flex flex-col space-y-6 p-4">
@@ -71,29 +67,31 @@ function destroy(workspace: Workspace) {
</div>
<div
class="rounded-xl border border-sidebar-border/70 dark:border-sidebar-border overflow-hidden"
class="overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-sidebar-border/70 bg-muted/50">
<thead
class="border-b border-sidebar-border/70 bg-muted/50"
>
<tr>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Name
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Slug
</th>
<th
class="h-10 px-4 text-left font-medium align-middle"
class="h-10 px-4 text-left align-middle font-medium"
>
Users
</th>
<th
class="h-10 px-4 text-right font-medium align-middle"
class="h-10 px-4 text-right align-middle font-medium"
>
Actions
</th>
@@ -125,13 +123,21 @@ function destroy(workspace: Workspace) {
}}
</Badge>
</td>
<td class="px-4 py-3 text-right space-x-2">
<Button variant="outline" size="sm" as-child>
<td class="space-x-2 px-4 py-3 text-right">
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="workspace.showUrl"
>View</Link
>
</Button>
<Button variant="outline" size="sm" as-child>
<Button
variant="outline"
size="sm"
as-child
>
<Link :href="workspace.editUrl"
>Edit</Link
>
@@ -150,7 +156,9 @@ function destroy(workspace: Workspace) {
colspan="4"
class="px-4 py-8 text-center text-muted-foreground"
>
<div class="flex flex-col items-center gap-2">
<div
class="flex flex-col items-center gap-2"
>
<Building2 class="h-10 w-10" />
<p>No workspaces yet.</p>
<Button as-child>

View File

@@ -19,6 +19,8 @@ export type Auth = {
user: User | null;
workspaces?: Workspace[];
currentWorkspace?: Workspace | null;
workspaceRole?: 'owner' | 'manager' | 'worker' | null;
workspaceSwitchUrl?: string | null;
};
export type TwoFactorConfigContent = {

View File

@@ -1,3 +1,4 @@
export * from './auth';
export * from './navigation';
export * from './team';
export * from './ui';

View File

@@ -0,0 +1,32 @@
export type TeamMember = {
id: number;
name: string;
email: string;
role: string;
joined_at: string;
status: 'active';
workspace_user_id: number;
updateRoleUrl: string;
removeUrl: string;
permissions?: Record<string, boolean>;
permissionsUrl?: string;
};
export type TeamInvitation = {
id: number;
email: string;
role: string;
invited_at: string;
status: 'pending';
};
export type TeamPageProps = {
members: TeamMember[];
pendingInvitations: TeamInvitation[];
canManageTeam: boolean;
isOwner: boolean;
availablePermissions: Record<string, string>;
authUserId: number;
inviteUrl: string;
roles: Record<string, string>;
};

View File

@@ -0,0 +1,17 @@
<x-mail::message>
# Invitation à rejoindre {{ $workspaceName }}
Bonjour,
Vous êtes invité(e) à rejoindre le cabinet **{{ $workspaceName }}** en tant que **{{ $roleLabel }}**.
Cliquez sur le bouton ci-dessous pour créer votre compte et rejoindre l'équipe.
<x-mail::button :url="$registerUrl" color="primary">
Rejoindre l'équipe
</x-mail::button>
Ce lien est valide jusqu'au {{ $expiresAt }}.
Si vous n'avez pas demandé cette invitation, vous pouvez ignorer cet email.
</x-mail::message>

View File

@@ -25,6 +25,12 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::post('declarations/{declaration}/mentions', [\App\Http\Controllers\DeclarationMentionController::class, 'store'])
->middleware('throttle:10,1')
->name('declarations.mentions.store');
Route::get('team', [\App\Http\Controllers\TeamController::class, 'index'])->name('team.index');
Route::post('team/invite', [\App\Http\Controllers\TeamController::class, 'invite'])->name('team.invite');
Route::patch('team/{workspaceUserId}/role', [\App\Http\Controllers\TeamController::class, 'updateRole'])->name('team.updateRole');
Route::put('team/{workspaceUserId}/permissions', [\App\Http\Controllers\TeamController::class, 'updatePermissions'])->name('team.updatePermissions');
Route::delete('team/{workspaceUserId}', [\App\Http\Controllers\TeamController::class, 'remove'])->name('team.remove');
});
Route::post('notifications/{id}/read', [\App\Http\Controllers\NotificationController::class, 'markAsRead'])->name('notifications.read');

View File

@@ -0,0 +1,245 @@
<?php
use App\Enums\WorkspaceUserRole;
use App\Models\Client;
use App\Models\Declaration;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia as Assert;
function setupClientTestUser(string $role, array $permissions = []): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, [
'role' => $role,
'permissions' => $permissions,
]);
session(['current_workspace_id' => $workspace->id]);
return [$user, $workspace];
}
// AC #1: Workers see only clients with assigned declarations
test('worker sees only clients with assigned declarations in index', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$clientWithAssignment = Client::factory()->create(['workspace_id' => $workspace->id]);
$clientWithoutAssignment = Client::factory()->create(['workspace_id' => $workspace->id]);
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $clientWithAssignment->id,
'assigned_to' => $worker->id,
]);
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $clientWithoutAssignment->id,
'assigned_to' => null,
]);
$response = $this->actingAs($worker)->get(route('clients.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('clients/Index')
->has('clients.data', 1)
->where('clients.data.0.id', $clientWithAssignment->id)
);
});
// AC #3: Owners see all workspace clients
test('owner sees all workspace clients in index', function () {
[$owner, $workspace] = setupClientTestUser(WorkspaceUserRole::Owner);
Client::factory()->count(3)->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($owner)->get(route('clients.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('clients/Index')
->has('clients.data', 3)
);
});
// AC #3: Managers see all workspace clients
test('manager sees all workspace clients in index', function () {
[$manager, $workspace] = setupClientTestUser(WorkspaceUserRole::Manager);
Client::factory()->count(3)->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($manager)->get(route('clients.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('clients/Index')
->has('clients.data', 3)
);
});
// AC #5: Worker gets 404 accessing client with no assigned declarations
test('worker gets 404 accessing client with no assigned declarations', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
// No declarations assigned to this worker for this client
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => null,
]);
$response = $this->actingAs($worker)->get(route('clients.show', $client));
$response->assertNotFound();
});
// Worker can access client show when they have assigned declarations
test('worker can access client show when they have assigned declarations', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $worker->id,
]);
$response = $this->actingAs($worker)->get(route('clients.show', $client));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('clients/Show')
);
});
// AC #6: Worker gets 404 on create
test('worker gets 404 on client create', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->get(route('clients.create'));
$response->assertNotFound();
});
// AC #6: Worker gets 404 on store
test('worker gets 404 on client store', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->post(route('clients.store'), [
'company_name' => 'Test',
'legal_form' => 'sarl',
'contacts' => [['full_name' => 'Test', 'is_principal' => true]],
]);
$response->assertNotFound();
});
// AC #6: Worker gets 404 on edit
test('worker gets 404 on client edit', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($worker)->get(route('clients.edit', $client));
$response->assertNotFound();
});
// AC #6: Worker gets 404 on update
test('worker gets 404 on client update', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($worker)->put(route('clients.update', $client), [
'company_name' => 'Updated',
'legal_form' => 'sarl',
'contacts' => [
['full_name' => 'Test Contact', 'is_principal' => true],
],
]);
$response->assertNotFound();
});
// AC #6: Worker gets 404 on destroy
test('worker gets 404 on client destroy', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($worker)->delete(route('clients.destroy', $client));
$response->assertNotFound();
});
// AC #3: Manager can access all CRUD operations
test('manager can access all client CRUD operations', function () {
[$manager, $workspace] = setupClientTestUser(WorkspaceUserRole::Manager);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
// Index
$this->actingAs($manager)->get(route('clients.index'))->assertOk();
// Show
$this->actingAs($manager)->get(route('clients.show', $client))->assertOk();
// Create page
$this->actingAs($manager)->get(route('clients.create'))->assertOk();
// Edit page
$this->actingAs($manager)->get(route('clients.edit', $client))->assertOk();
});
// AC #3: Owner can access all CRUD operations
test('owner can access all client CRUD operations', function () {
[$owner, $workspace] = setupClientTestUser(WorkspaceUserRole::Owner);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
// Index
$this->actingAs($owner)->get(route('clients.index'))->assertOk();
// Show
$this->actingAs($owner)->get(route('clients.show', $client))->assertOk();
// Create page
$this->actingAs($owner)->get(route('clients.create'))->assertOk();
// Edit page
$this->actingAs($owner)->get(route('clients.edit', $client))->assertOk();
});
// AC #10: canCreate/canEdit/canDelete props are false for Workers
test('worker gets canCreate canEdit canDelete as false in index', function () {
[$worker, $workspace] = setupClientTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->get(route('clients.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canCreate', false)
->where('canEdit', false)
->where('canDelete', false)
);
});
// AC #10: canCreate/canEdit/canDelete props are true for Owners
test('owner gets canCreate canEdit canDelete as true in index', function () {
[$owner, $workspace] = setupClientTestUser(WorkspaceUserRole::Owner);
$response = $this->actingAs($owner)->get(route('clients.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canCreate', true)
->where('canEdit', true)
->where('canDelete', true)
);
});

View File

@@ -0,0 +1,64 @@
<?php
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;
function getMigrationInstance(): object
{
return require database_path('migrations/2026_03_14_000001_rename_member_to_worker_in_workspace_user.php');
}
test('data migration renames member to worker in workspace_user role', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
// Insert a record with the old 'member' value directly via DB
DB::table('workspace_user')->insert([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'role' => 'member',
'created_at' => now(),
'updated_at' => now(),
]);
// Verify it has 'member' role
expect(
DB::table('workspace_user')
->where('user_id', $user->id)
->where('workspace_id', $workspace->id)
->value('role')
)->toBe('member');
// Run the actual migration file
getMigrationInstance()->up();
// Verify it now has 'worker' role
expect(
DB::table('workspace_user')
->where('user_id', $user->id)
->where('workspace_id', $workspace->id)
->value('role')
)->toBe('worker');
});
test('data migration does not affect owner or manager roles', function () {
$owner = User::factory()->create();
$manager = User::factory()->create();
$worker = User::factory()->create();
$workspace = Workspace::factory()->create();
DB::table('workspace_user')->insert([
['workspace_id' => $workspace->id, 'user_id' => $owner->id, 'role' => 'owner', 'created_at' => now(), 'updated_at' => now()],
['workspace_id' => $workspace->id, 'user_id' => $manager->id, 'role' => 'manager', 'created_at' => now(), 'updated_at' => now()],
['workspace_id' => $workspace->id, 'user_id' => $worker->id, 'role' => 'member', 'created_at' => now(), 'updated_at' => now()],
]);
// Run the actual migration file
getMigrationInstance()->up();
// Owner and manager should remain unchanged
expect(DB::table('workspace_user')->where('user_id', $owner->id)->value('role'))->toBe('owner');
expect(DB::table('workspace_user')->where('user_id', $manager->id)->value('role'))->toBe('manager');
expect(DB::table('workspace_user')->where('user_id', $worker->id)->value('role'))->toBe('worker');
});

View File

@@ -36,8 +36,8 @@ test('composite index exists on messages declaration_id and created_at', functio
});
test('migration is reversible and rollback restores folder tables', function () {
// Rollback foundation migrations (3) + polymorphic update + rename migration
$this->artisan('migrate:rollback', ['--step' => 5]);
// Rollback team_invitations (1) + member-to-worker rename (1) + foundation migrations (3) + polymorphic update + rename migration
$this->artisan('migrate:rollback', ['--step' => 7]);
expect(Schema::hasTable('folders'))->toBeTrue();
expect(Schema::hasTable('declarations'))->toBeFalse();

View File

@@ -80,7 +80,7 @@ test('re-downloading updates timestamp without creating duplicates', function ()
test('download status is per-user in show endpoint', function () {
[$user, $workspace, $declaration, $media] = setupDeclarationWithMedia();
$otherUser = User::factory()->create();
$workspace->users()->attach($otherUser, ['role' => 'member']);
$workspace->users()->attach($otherUser, ['role' => 'manager']);
session(['current_workspace_id' => $workspace->id]);
MediaDownload::query()->create([

View File

@@ -0,0 +1,377 @@
<?php
use App\Enums\WorkspaceUserRole;
use App\Models\Client;
use App\Models\Declaration;
use App\Models\User;
use App\Models\Workspace;
use Inertia\Testing\AssertableInertia as Assert;
function setupDeclarationTestUser(string $role, array $permissions = []): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, [
'role' => $role,
'permissions' => $permissions,
]);
session(['current_workspace_id' => $workspace->id]);
return [$user, $workspace];
}
// AC #2: Worker sees only assigned declarations in index
test('worker sees only assigned declarations in index', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$assignedDeclaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $worker->id,
]);
$unassignedDeclaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => null,
]);
$otherUser = User::factory()->create();
$otherAssignedDeclaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $otherUser->id,
]);
$response = $this->actingAs($worker)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('declarations/Index')
->has('declarations.data', 1)
->where('declarations.data.0.id', $assignedDeclaration->id)
);
});
// AC #3: Owner sees all declarations
test('owner sees all declarations in index', function () {
[$owner, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Owner);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
Declaration::factory()->count(5)->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($owner)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('declarations/Index')
->has('declarations.data', 5)
);
});
// AC #3: Manager sees all declarations
test('manager sees all declarations in index', function () {
[$manager, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Manager);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
Declaration::factory()->count(3)->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($manager)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('declarations/Index')
->has('declarations.data', 3)
);
});
// AC #4: Worker gets 404 accessing unassigned declaration via direct URL
test('worker gets 404 accessing unassigned declaration via direct URL', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => null,
]);
$response = $this->actingAs($worker)->get(route('declarations.show', $declaration));
$response->assertNotFound();
});
// Worker can access assigned declaration show
test('worker can access assigned declaration show', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $worker->id,
]);
$response = $this->actingAs($worker)->get(route('declarations.show', $declaration));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('declarations/Show')
);
});
// AC #6: Worker gets 404 on create
test('worker gets 404 on declaration create', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->get(route('declarations.create'));
$response->assertNotFound();
});
// Worker gets 404 on store
test('worker gets 404 on declaration store', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$response = $this->actingAs($worker)->post(route('declarations.store'), [
'title' => 'Test',
'type' => 'vat_monthly',
'client_id' => $client->id,
'period_year' => 2026,
'period_month' => 1,
]);
$response->assertNotFound();
});
// Worker gets 404 on edit
test('worker gets 404 on declaration edit', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($worker)->get(route('declarations.edit', $declaration));
$response->assertNotFound();
});
// Worker gets 404 on update
test('worker gets 404 on declaration update', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($worker)->put(route('declarations.update', $declaration), [
'title' => 'Updated',
'type' => 'vat_monthly',
'client_id' => $client->id,
'period_year' => 2026,
'period_month' => 1,
]);
$response->assertNotFound();
});
// Worker gets 404 on destroy
test('worker gets 404 on declaration destroy', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($worker)->delete(route('declarations.destroy', $declaration));
$response->assertNotFound();
});
// Manager can access all CRUD operations
test('manager can access all declaration CRUD operations', function () {
[$manager, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Manager);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
// Index
$this->actingAs($manager)->get(route('declarations.index'))->assertOk();
// Show
$this->actingAs($manager)->get(route('declarations.show', $declaration))->assertOk();
// Create page
$this->actingAs($manager)->get(route('declarations.create'))->assertOk();
// Edit page
$this->actingAs($manager)->get(route('declarations.edit', $declaration))->assertOk();
});
// Owner can access all CRUD operations
test('owner can access all declaration CRUD operations', function () {
[$owner, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Owner);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
// Index
$this->actingAs($owner)->get(route('declarations.index'))->assertOk();
// Show
$this->actingAs($owner)->get(route('declarations.show', $declaration))->assertOk();
// Create page
$this->actingAs($owner)->get(route('declarations.create'))->assertOk();
// Edit page
$this->actingAs($owner)->get(route('declarations.edit', $declaration))->assertOk();
});
// AC #10: canCreate/canEdit/canDelete props false for Workers
test('worker gets canCreate canEdit canDelete as false in declarations index', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canCreate', false)
->where('canEdit', false)
->where('canDelete', false)
);
});
// AC #10: canCreate/canEdit/canDelete props true for Owners
test('owner gets canCreate canEdit canDelete as true in declarations index', function () {
[$owner, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Owner);
$response = $this->actingAs($owner)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canCreate', true)
->where('canEdit', true)
->where('canDelete', true)
);
});
// AC #9: auth.workspaceRole is shared correctly
test('auth.workspaceRole is shared correctly for each role', function () {
// Test Owner
[$owner, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Owner);
$response = $this->actingAs($owner)->get(route('declarations.index'));
$response->assertInertia(fn (Assert $page) => $page
->where('auth.workspaceRole', 'owner')
);
// Test Manager
[$manager, $workspace2] = setupDeclarationTestUser(WorkspaceUserRole::Manager);
$response = $this->actingAs($manager)->get(route('declarations.index'));
$response->assertInertia(fn (Assert $page) => $page
->where('auth.workspaceRole', 'manager')
);
// Test Worker
[$worker, $workspace3] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($worker)->get(route('declarations.index'));
$response->assertInertia(fn (Assert $page) => $page
->where('auth.workspaceRole', 'worker')
);
});
// AC #11: Cross-workspace isolation
test('worker in workspace A cannot see declarations in workspace B', function () {
[$worker, $workspaceA] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$workspaceB = Workspace::factory()->create();
$clientB = Client::factory()->create(['workspace_id' => $workspaceB->id]);
$declarationInB = Declaration::factory()->create([
'workspace_id' => $workspaceB->id,
'client_id' => $clientB->id,
'assigned_to' => $worker->id,
]);
// Worker should not see workspace B declarations in their index
$response = $this->actingAs($worker)->get(route('declarations.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->has('declarations.data', 0)
);
// Worker should get 404 trying to access workspace B declaration directly
$response = $this->actingAs($worker)->get(route('declarations.show', $declarationInB));
$response->assertNotFound();
});
// canMention is false for workers in declaration show
test('worker gets canMention as false in declaration show', function () {
[$worker, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Worker);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
'assigned_to' => $worker->id,
]);
$response = $this->actingAs($worker)->get(route('declarations.show', $declaration));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canMention', false)
->where('canEdit', false)
->where('canDelete', false)
);
});
// canMention is true for owners/managers in declaration show
test('owner gets canMention as true in declaration show', function () {
[$owner, $workspace] = setupDeclarationTestUser(WorkspaceUserRole::Owner);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
'workspace_id' => $workspace->id,
'client_id' => $client->id,
]);
$response = $this->actingAs($owner)->get(route('declarations.show', $declaration));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->where('canMention', true)
->where('canEdit', true)
->where('canDelete', true)
);
});

View File

@@ -13,7 +13,7 @@ function setupMentionScenario(string $role = 'owner'): array
$target = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($sender, ['role' => $role]);
$workspace->users()->attach($target, ['role' => 'member']);
$workspace->users()->attach($target, ['role' => 'worker']);
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
$declaration = Declaration::factory()->create([
@@ -52,9 +52,9 @@ test('manager can mention a workspace user', function () {
Notification::assertSentTo($target, DeclarationMentionNotification::class);
});
test('member cannot mention a workspace user', function () {
test('worker cannot mention a workspace user', function () {
Notification::fake();
[$sender, $target, $workspace, $declaration] = setupMentionScenario('member');
[$sender, $target, $workspace, $declaration] = setupMentionScenario('worker');
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($sender)->post(route('declarations.mentions.store', $declaration), [

View File

@@ -0,0 +1,214 @@
<?php
use App\Enums\Permission;
use App\Enums\WorkspaceUserRole;
use App\Mail\TeamInvitationMail;
use App\Models\TeamInvitation;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Mail;
use Inertia\Testing\AssertableInertia as Assert;
function setupTeamUser(string $role, array $permissions = []): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, [
'role' => $role,
'permissions' => $permissions,
]);
session(['current_workspace_id' => $workspace->id]);
return [$user, $workspace];
}
// ── Team Index ──────────────────────────────────────────────
test('owner can view team index page', function () {
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Owner);
$response = $this->actingAs($user)->get(route('team.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('team/Index')
->has('members')
->has('pendingInvitations')
->where('canManageTeam', true)
->has('inviteUrl')
->has('roles')
);
});
test('manager with can_manage_team can view team index page', function () {
[$user] = setupTeamUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => true,
]);
$response = $this->actingAs($user)->get(route('team.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('team/Index')
->where('canManageTeam', true)
);
});
test('manager without can_manage_team can view team index but canManageTeam is false', function () {
[$user] = setupTeamUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => false,
]);
$response = $this->actingAs($user)->get(route('team.index'));
$response->assertOk();
$response->assertInertia(fn (Assert $page) => $page
->component('team/Index')
->where('canManageTeam', false)
);
});
test('worker receives 404 on team index', function () {
[$user] = setupTeamUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($user)->get(route('team.index'));
$response->assertNotFound();
});
// ── Invite Member ───────────────────────────────────────────
test('owner can invite a new member', function () {
Mail::fake();
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Owner);
$response = $this->actingAs($user)->post(route('team.invite'), [
'email' => 'newmember@example.com',
'role' => 'worker',
]);
$response->assertRedirect();
$response->assertSessionHas('success', 'Invitation envoyée');
expect(TeamInvitation::where('workspace_id', $workspace->id)
->where('email', 'newmember@example.com')
->exists()
)->toBeTrue();
Mail::assertQueued(TeamInvitationMail::class, function ($mail) {
return $mail->hasTo('newmember@example.com');
});
});
test('manager with can_manage_team can invite a new member', function () {
Mail::fake();
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => true,
]);
$response = $this->actingAs($user)->post(route('team.invite'), [
'email' => 'newmanager@example.com',
'role' => 'manager',
]);
$response->assertRedirect();
$response->assertSessionHas('success', 'Invitation envoyée');
expect(TeamInvitation::where('workspace_id', $workspace->id)
->where('email', 'newmanager@example.com')
->where('role', 'manager')
->exists()
)->toBeTrue();
});
test('manager without permission gets 404 on invite', function () {
[$user] = setupTeamUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => false,
]);
$response = $this->actingAs($user)->post(route('team.invite'), [
'email' => 'blocked@example.com',
'role' => 'worker',
]);
$response->assertNotFound();
});
test('worker gets 404 on invite', function () {
[$user] = setupTeamUser(WorkspaceUserRole::Worker);
$response = $this->actingAs($user)->post(route('team.invite'), [
'email' => 'blocked@example.com',
'role' => 'worker',
]);
$response->assertNotFound();
});
test('cannot invite email already in workspace', function () {
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Owner);
$existingMember = User::factory()->create(['email' => 'existing@example.com']);
$workspace->users()->attach($existingMember->id, ['role' => 'worker']);
$response = $this->actingAs($user)->post(route('team.invite'), [
'email' => 'existing@example.com',
'role' => 'worker',
]);
$response->assertSessionHasErrors('email');
});
test('invitation creates team invitation record with correct data', function () {
Mail::fake();
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Owner);
$this->actingAs($user)->post(route('team.invite'), [
'email' => 'record@example.com',
'role' => 'manager',
]);
$invitation = TeamInvitation::where('email', 'record@example.com')->first();
expect($invitation)->not->toBeNull()
->and($invitation->workspace_id)->toBe($workspace->id)
->and($invitation->role)->toBe('manager')
->and($invitation->invited_by)->toBe($user->id)
->and($invitation->token)->not->toBeEmpty()
->and($invitation->expires_at)->not->toBeNull()
->and($invitation->accepted_at)->toBeNull();
});
test('invitation sends email', function () {
Mail::fake();
[$user] = setupTeamUser(WorkspaceUserRole::Owner);
$this->actingAs($user)->post(route('team.invite'), [
'email' => 'mailto@example.com',
'role' => 'worker',
]);
Mail::assertQueued(TeamInvitationMail::class, function ($mail) {
return $mail->hasTo('mailto@example.com');
});
});
test('cannot send duplicate active invitation to same email', function () {
Mail::fake();
[$user, $workspace] = setupTeamUser(WorkspaceUserRole::Owner);
// Create an existing active invitation
TeamInvitation::create([
'workspace_id' => $workspace->id,
'email' => 'duplicate@example.com',
'role' => 'worker',
'invited_by' => $user->id,
'expires_at' => now()->addDays(7),
]);
$response = $this->actingAs($user)->post(route('team.invite'), [
'email' => 'duplicate@example.com',
'role' => 'manager',
]);
$response->assertSessionHasErrors('email');
});

View File

@@ -0,0 +1,248 @@
<?php
use App\Enums\Permission;
use App\Enums\WorkspaceUserRole;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceUser;
use Spatie\Activitylog\Models\Activity;
function setupPermTestUser(string $role, array $permissions = []): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, [
'role' => $role,
'permissions' => $permissions,
]);
session(['current_workspace_id' => $workspace->id]);
return [$user, $workspace];
}
function createPermWorkspaceMember(Workspace $workspace, string $role, array $permissions = []): User
{
$member = User::factory()->create();
$workspace->users()->attach($member->id, [
'role' => $role,
'permissions' => $permissions,
]);
return $member;
}
function getPermWorkspaceUserId(Workspace $workspace, User $user): int
{
return WorkspaceUser::where('workspace_id', $workspace->id)
->where('user_id', $user->id)
->firstOrFail()
->id;
}
// ── 4.1: Owner can update Manager permissions ──
test('owner can update manager permissions', function () {
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
$manager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
Permission::CanManageTeam => false,
Permission::CanViewActivityLogs => true,
Permission::CanConfigurePortal => false,
]);
$pivotId = getPermWorkspaceUserId($workspace, $manager);
$response = $this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
'permissions' => [
Permission::CanManageTeam => true,
Permission::CanViewActivityLogs => true,
Permission::CanConfigurePortal => true,
],
]);
$response->assertRedirect();
$response->assertSessionHas('success', 'Permissions mises à jour');
$updatedPivot = WorkspaceUser::find($pivotId);
expect($updatedPivot->permissions[Permission::CanManageTeam])->toBeTrue()
->and($updatedPivot->permissions[Permission::CanViewActivityLogs])->toBeTrue()
->and($updatedPivot->permissions[Permission::CanConfigurePortal])->toBeTrue();
});
// ── 4.2: Manager cannot update permissions (even with can_manage_team) ──
test('manager with can_manage_team cannot update permissions', function () {
[$manager, $workspace] = setupPermTestUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => true,
]);
$otherManager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
Permission::CanManageTeam => false,
]);
$pivotId = getPermWorkspaceUserId($workspace, $otherManager);
$response = $this->actingAs($manager)->put(route('team.updatePermissions', $pivotId), [
'permissions' => [
Permission::CanManageTeam => true,
],
]);
$response->assertNotFound();
});
// ── 4.3: Worker cannot access permissions endpoint ──
test('worker cannot access permissions endpoint', function () {
[$worker, $workspace] = setupPermTestUser(WorkspaceUserRole::Worker);
$manager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager);
$pivotId = getPermWorkspaceUserId($workspace, $manager);
$response = $this->actingAs($worker)->put(route('team.updatePermissions', $pivotId), [
'permissions' => [
Permission::CanManageTeam => true,
],
]);
$response->assertNotFound();
});
// ── 4.4: Cannot update Owner's permissions ──
test('cannot update owner permissions', function () {
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
$otherOwner = createPermWorkspaceMember($workspace, WorkspaceUserRole::Owner);
$pivotId = getPermWorkspaceUserId($workspace, $otherOwner);
$response = $this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
'permissions' => [
Permission::CanManageTeam => true,
Permission::CanViewActivityLogs => true,
Permission::CanConfigurePortal => true,
],
]);
$response->assertNotFound();
});
// ── 4.5: Cannot update Worker's permissions ──
test('cannot update worker permissions', function () {
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
$worker = createPermWorkspaceMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getPermWorkspaceUserId($workspace, $worker);
$response = $this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
'permissions' => [
Permission::CanManageTeam => true,
Permission::CanViewActivityLogs => true,
Permission::CanConfigurePortal => true,
],
]);
$response->assertNotFound();
});
// ── 4.6: Invalid permission key is rejected ──
test('invalid permission key is rejected', function () {
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
$manager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager);
$pivotId = getPermWorkspaceUserId($workspace, $manager);
$response = $this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
'permissions' => [
'invalid_permission_key' => true,
],
]);
$response->assertSessionHasErrors('permissions');
});
// ── 4.7: Activity log entry created on permission change ──
test('activity log entry created on permission change', function () {
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
$manager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
Permission::CanManageTeam => false,
Permission::CanViewActivityLogs => true,
Permission::CanConfigurePortal => false,
]);
$pivotId = getPermWorkspaceUserId($workspace, $manager);
$this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
'permissions' => [
Permission::CanManageTeam => true,
Permission::CanViewActivityLogs => true,
Permission::CanConfigurePortal => false,
],
]);
$log = Activity::latest('id')->first();
expect($log->description)->toBe('permissions_updated')
->and($log->properties['target_user'])->toBe($manager->name)
->and($log->properties['old_permissions'][Permission::CanManageTeam])->toBeFalse()
->and($log->properties['new_permissions'][Permission::CanManageTeam])->toBeTrue();
});
// ── 4.8: Toggling can_manage_team off removes invite capability on next page load ──
test('toggling can_manage_team off removes invite capability on next page load', function () {
[$owner, $workspace] = setupPermTestUser(WorkspaceUserRole::Owner);
$manager = createPermWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
Permission::CanManageTeam => true,
Permission::CanViewActivityLogs => true,
Permission::CanConfigurePortal => false,
]);
$pivotId = getPermWorkspaceUserId($workspace, $manager);
// Toggle can_manage_team OFF
$this->actingAs($owner)->put(route('team.updatePermissions', $pivotId), [
'permissions' => [
Permission::CanManageTeam => false,
Permission::CanViewActivityLogs => true,
Permission::CanConfigurePortal => false,
],
]);
// Now load team page as the manager — canManageTeam should be false
session(['current_workspace_id' => $workspace->id]);
$response = $this->actingAs($manager)->get(route('team.index'));
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('team/Index')
->where('canManageTeam', false)
);
});
// ── Cross-workspace isolation: Owner cannot update permissions in another workspace ──
test('owner cannot update manager permissions in another workspace', function () {
[$owner, $workspaceA] = setupPermTestUser(WorkspaceUserRole::Owner);
// Create a separate workspace B with its own manager
$workspaceB = Workspace::factory()->create();
$managerB = User::factory()->create();
$workspaceB->users()->attach($managerB->id, [
'role' => WorkspaceUserRole::Manager,
'permissions' => [
Permission::CanManageTeam => false,
],
]);
$pivotIdB = WorkspaceUser::where('workspace_id', $workspaceB->id)
->where('user_id', $managerB->id)
->firstOrFail()
->id;
// Session is set to workspace A — attempt to update manager in workspace B
$response = $this->actingAs($owner)->put(route('team.updatePermissions', $pivotIdB), [
'permissions' => [
Permission::CanManageTeam => true,
Permission::CanViewActivityLogs => true,
Permission::CanConfigurePortal => true,
],
]);
$response->assertNotFound();
// Verify permissions were NOT changed
$pivot = WorkspaceUser::find($pivotIdB);
expect($pivot->permissions[Permission::CanManageTeam])->toBeFalse();
});

View File

@@ -0,0 +1,153 @@
<?php
use App\Enums\Permission;
use App\Enums\WorkspaceUserRole;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceUser;
use Spatie\Activitylog\Models\Activity;
function setupRemovalTestUser(string $role, array $permissions = []): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, [
'role' => $role,
'permissions' => $permissions,
]);
session(['current_workspace_id' => $workspace->id]);
return [$user, $workspace];
}
function createRemovalTestMember(Workspace $workspace, string $role, array $permissions = []): User
{
$member = User::factory()->create();
$workspace->users()->attach($member->id, [
'role' => $role,
'permissions' => $permissions,
]);
return $member;
}
function getRemovalPivotId(Workspace $workspace, User $user): int
{
return WorkspaceUser::where('workspace_id', $workspace->id)
->where('user_id', $user->id)
->firstOrFail()
->id;
}
// ── Owner can remove a member ──────────────────────────────
test('owner can remove a member', function () {
[$owner, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Owner);
$worker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getRemovalPivotId($workspace, $worker);
$response = $this->actingAs($owner)->delete(route('team.remove', $pivotId));
$response->assertRedirect();
$response->assertSessionHas('success', 'Membre retiré');
// Pivot row should be deleted
expect(WorkspaceUser::find($pivotId))->toBeNull();
});
// ── Owner cannot remove themselves ─────────────────────────
test('owner cannot remove themselves', function () {
[$owner, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Owner);
$pivotId = getRemovalPivotId($workspace, $owner);
$response = $this->actingAs($owner)->delete(route('team.remove', $pivotId));
$response->assertNotFound();
});
// ── Manager with can_manage_team can remove worker ─────────
test('manager with can_manage_team can remove worker', function () {
[$manager, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => true,
]);
$worker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getRemovalPivotId($workspace, $worker);
$response = $this->actingAs($manager)->delete(route('team.remove', $pivotId));
$response->assertRedirect();
$response->assertSessionHas('success', 'Membre retiré');
});
// ── Manager with can_manage_team cannot remove owner ───────
test('manager with can_manage_team cannot remove owner', function () {
[$manager, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => true,
]);
$owner = createRemovalTestMember($workspace, WorkspaceUserRole::Owner);
$pivotId = getRemovalPivotId($workspace, $owner);
$response = $this->actingAs($manager)->delete(route('team.remove', $pivotId));
$response->assertNotFound();
});
// ── Manager without can_manage_team gets 404 ───────────────
test('manager without can_manage_team gets 404 on remove', function () {
[$manager, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => false,
]);
$worker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getRemovalPivotId($workspace, $worker);
$response = $this->actingAs($manager)->delete(route('team.remove', $pivotId));
$response->assertNotFound();
});
// ── Worker gets 404 on remove attempt ──────────────────────
test('worker gets 404 on remove attempt', function () {
[$worker, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Worker);
$otherWorker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getRemovalPivotId($workspace, $otherWorker);
$response = $this->actingAs($worker)->delete(route('team.remove', $pivotId));
$response->assertNotFound();
});
// ── Member removal is logged ───────────────────────────────
test('member removal is logged in activity log', function () {
[$owner, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Owner);
$worker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getRemovalPivotId($workspace, $worker);
$this->actingAs($owner)->delete(route('team.remove', $pivotId));
$log = Activity::latest('id')->first();
expect($log->description)->toBe('member_removed')
->and($log->properties['target_user'])->toBe($worker->name)
->and($log->properties['target_email'])->toBe($worker->email)
->and($log->properties['role'])->toBe('worker');
});
// ── Removed user account still exists ──────────────────────
test('removed user account still exists after removal', function () {
[$owner, $workspace] = setupRemovalTestUser(WorkspaceUserRole::Owner);
$worker = createRemovalTestMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getRemovalPivotId($workspace, $worker);
$this->actingAs($owner)->delete(route('team.remove', $pivotId));
// User record still exists
expect(User::find($worker->id))->not->toBeNull();
// But pivot row is deleted
expect(WorkspaceUser::find($pivotId))->toBeNull();
});

View File

@@ -0,0 +1,217 @@
<?php
use App\Enums\Permission;
use App\Enums\WorkspaceUserRole;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceUser;
use Spatie\Activitylog\Models\Activity;
function setupRoleTestUser(string $role, array $permissions = []): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, [
'role' => $role,
'permissions' => $permissions,
]);
session(['current_workspace_id' => $workspace->id]);
return [$user, $workspace];
}
function createWorkspaceMember(Workspace $workspace, string $role, array $permissions = []): User
{
$member = User::factory()->create();
$workspace->users()->attach($member->id, [
'role' => $role,
'permissions' => $permissions,
]);
return $member;
}
function getWorkspaceUserId(Workspace $workspace, User $user): int
{
return WorkspaceUser::where('workspace_id', $workspace->id)
->where('user_id', $user->id)
->firstOrFail()
->id;
}
// ── Owner can change roles ──────────────────────────────────
test('owner can change a member role from worker to manager', function () {
[$owner, $workspace] = setupRoleTestUser(WorkspaceUserRole::Owner);
$worker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getWorkspaceUserId($workspace, $worker);
$response = $this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
'role' => 'manager',
]);
$response->assertRedirect();
$response->assertSessionHas('success', 'Rôle mis à jour');
$updatedPivot = WorkspaceUser::find($pivotId);
expect($updatedPivot->role->is(WorkspaceUserRole::Manager))->toBeTrue();
});
test('owner can change a member role from manager to worker', function () {
[$owner, $workspace] = setupRoleTestUser(WorkspaceUserRole::Owner);
$manager = createWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
Permission::CanManageTeam => true,
Permission::CanViewActivityLogs => true,
]);
$pivotId = getWorkspaceUserId($workspace, $manager);
$response = $this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
'role' => 'worker',
]);
$response->assertRedirect();
$response->assertSessionHas('success', 'Rôle mis à jour');
$updatedPivot = WorkspaceUser::find($pivotId);
expect($updatedPivot->role->is(WorkspaceUserRole::Worker))->toBeTrue();
});
// ── Role change resets permissions ──────────────────────────
test('role change resets permissions to defaults for new role', function () {
[$owner, $workspace] = setupRoleTestUser(WorkspaceUserRole::Owner);
$worker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getWorkspaceUserId($workspace, $worker);
$this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
'role' => 'manager',
]);
$updatedPivot = WorkspaceUser::find($pivotId);
$managerDefaults = config('permissions.defaults.manager');
expect($updatedPivot->permissions)->toBe($managerDefaults);
// Now change back to worker — permissions should be empty
$this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
'role' => 'worker',
]);
$updatedPivot->refresh();
expect($updatedPivot->permissions)->toBe([]);
});
// ── Owner cannot change own role ────────────────────────────
test('owner cannot change own role', function () {
[$owner, $workspace] = setupRoleTestUser(WorkspaceUserRole::Owner);
$pivotId = getWorkspaceUserId($workspace, $owner);
$response = $this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
'role' => 'manager',
]);
$response->assertNotFound();
});
// ── Manager with can_manage_team can change worker role ─────
test('manager with can_manage_team can change worker role', function () {
[$manager, $workspace] = setupRoleTestUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => true,
]);
$worker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getWorkspaceUserId($workspace, $worker);
$response = $this->actingAs($manager)->patch(route('team.updateRole', $pivotId), [
'role' => 'manager',
]);
$response->assertRedirect();
$response->assertSessionHas('success', 'Rôle mis à jour');
});
// ── Manager with can_manage_team can change another manager role ─
test('manager with can_manage_team can change another manager role', function () {
[$manager, $workspace] = setupRoleTestUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => true,
]);
$otherManager = createWorkspaceMember($workspace, WorkspaceUserRole::Manager, [
Permission::CanManageTeam => false,
]);
$pivotId = getWorkspaceUserId($workspace, $otherManager);
$response = $this->actingAs($manager)->patch(route('team.updateRole', $pivotId), [
'role' => 'worker',
]);
$response->assertRedirect();
$response->assertSessionHas('success', 'Rôle mis à jour');
$updatedPivot = WorkspaceUser::find($pivotId);
expect($updatedPivot->role->is(WorkspaceUserRole::Worker))->toBeTrue();
});
// ── Manager with can_manage_team cannot change owner role ───
test('manager with can_manage_team cannot change owner role', function () {
[$manager, $workspace] = setupRoleTestUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => true,
]);
$owner = createWorkspaceMember($workspace, WorkspaceUserRole::Owner);
$pivotId = getWorkspaceUserId($workspace, $owner);
$response = $this->actingAs($manager)->patch(route('team.updateRole', $pivotId), [
'role' => 'worker',
]);
$response->assertNotFound();
});
// ── Manager without can_manage_team gets 404 ───────────────
test('manager without can_manage_team gets 404 on role change', function () {
[$manager, $workspace] = setupRoleTestUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => false,
]);
$worker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getWorkspaceUserId($workspace, $worker);
$response = $this->actingAs($manager)->patch(route('team.updateRole', $pivotId), [
'role' => 'manager',
]);
$response->assertNotFound();
});
// ── Worker gets 404 on role change attempt ──────────────────
test('worker gets 404 on role change attempt', function () {
[$worker, $workspace] = setupRoleTestUser(WorkspaceUserRole::Worker);
$otherWorker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getWorkspaceUserId($workspace, $otherWorker);
$response = $this->actingAs($worker)->patch(route('team.updateRole', $pivotId), [
'role' => 'manager',
]);
$response->assertNotFound();
});
// ── Activity log ────────────────────────────────────────────
test('role change is logged in activity log', function () {
[$owner, $workspace] = setupRoleTestUser(WorkspaceUserRole::Owner);
$worker = createWorkspaceMember($workspace, WorkspaceUserRole::Worker);
$pivotId = getWorkspaceUserId($workspace, $worker);
$this->actingAs($owner)->patch(route('team.updateRole', $pivotId), [
'role' => 'manager',
]);
$log = Activity::latest('id')->first();
expect($log->description)->toBe('role_changed')
->and($log->properties['target_user'])->toBe($worker->name)
->and($log->properties['old_role'])->toBe('worker')
->and($log->properties['new_role'])->toBe('manager');
});

View File

@@ -0,0 +1,68 @@
<?php
use App\Concerns\HasWorkspaceScope;
use App\Models\Client;
use App\Models\User;
use App\Models\Workspace;
function createScopeChecker(): object
{
return new class
{
use HasWorkspaceScope;
public function getWorkspace(): \App\Models\Workspace
{
return $this->currentWorkspace();
}
public function checkAccess(\Illuminate\Database\Eloquent\Model $resource): void
{
$this->authorizeWorkspaceAccess($resource);
}
};
}
test('currentWorkspace resolves workspace from session', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, ['role' => 'owner']);
session(['current_workspace_id' => $workspace->id]);
$this->actingAs($user);
$checker = createScopeChecker();
$resolved = $checker->getWorkspace();
expect($resolved->id)->toBe($workspace->id);
});
test('currentWorkspace fails when user not in workspace', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
session(['current_workspace_id' => $workspace->id]);
$this->actingAs($user);
$checker = createScopeChecker();
$checker->getWorkspace();
})->throws(Illuminate\Database\Eloquent\ModelNotFoundException::class);
test('authorizeWorkspaceAccess passes for matching workspace', function () {
$workspace = Workspace::factory()->create();
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
session(['current_workspace_id' => $workspace->id]);
$checker = createScopeChecker();
$checker->checkAccess($client);
expect(true)->toBeTrue(); // No exception thrown
});
test('authorizeWorkspaceAccess aborts 404 for mismatched workspace', function () {
$workspace1 = Workspace::factory()->create();
$workspace2 = Workspace::factory()->create();
$client = Client::factory()->create(['workspace_id' => $workspace1->id]);
session(['current_workspace_id' => $workspace2->id]);
$checker = createScopeChecker();
$checker->checkAccess($client);
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);

View File

@@ -0,0 +1,123 @@
<?php
use App\Enums\WorkspaceUserRole;
use App\Models\Client;
use App\Models\User;
use App\Models\Workspace;
use Spatie\Activitylog\Models\Activity;
function setupSwitchTestUser(string $role, int $workspaceCount = 2): array
{
$user = User::factory()->create();
$workspaces = [];
for ($i = 0; $i < $workspaceCount; $i++) {
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, [
'role' => $role,
'permissions' => [],
]);
$workspaces[] = $workspace;
}
return [$user, $workspaces];
}
test('owner with multiple workspaces can switch workspace', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Owner);
$response = $this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $workspaces[1]->id,
]);
$response->assertRedirect(route('dashboard'));
expect(session('current_workspace_id'))->toBe($workspaces[1]->id);
});
test('workspace switching logs activity with previous and new workspace ids', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Owner);
$this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $workspaces[1]->id,
]);
$log = Activity::latest('id')->first();
expect($log->description)->toBe('Switched workspace')
->and($log->causer_id)->toBe($user->id)
->and($log->properties['previous_workspace_id'])->toBe($workspaces[0]->id)
->and($log->properties['new_workspace_id'])->toBe($workspaces[1]->id);
});
test('user cannot switch to a workspace they do not belong to', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Worker, 1);
$otherWorkspace = Workspace::factory()->create();
$response = $this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $otherWorkspace->id,
]);
$response->assertRedirect(route('dashboard'));
expect(session('current_workspace_id'))->toBe($workspaces[0]->id)
->and(Activity::where('description', 'Switched workspace')->count())->toBe(0);
});
test('switching updates auth shared props on next page load', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Owner);
$this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $workspaces[1]->id,
]);
$response = $this->actingAs($user)->get(route('dashboard'));
$response->assertInertia(fn ($page) => $page
->where('auth.currentWorkspace.id', $workspaces[1]->id)
->where('auth.workspaceRole', 'owner')
->has('auth.workspaceSwitchUrl')
);
});
test('user with single workspace can post switch with same workspace id without error', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Worker, 1);
$response = $this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $workspaces[0]->id,
]);
$response->assertRedirect(route('dashboard'));
expect(session('current_workspace_id'))->toBe($workspaces[0]->id);
});
test('cross-workspace isolation: after switching, data queries return only new workspace data', function () {
[$user, $workspaces] = setupSwitchTestUser(WorkspaceUserRole::Owner);
$clientA = Client::factory()->create(['workspace_id' => $workspaces[0]->id]);
$clientB = Client::factory()->create(['workspace_id' => $workspaces[1]->id]);
$this->actingAs($user)
->withSession(['current_workspace_id' => $workspaces[0]->id])
->post(route('workspace.switch'), [
'workspace_id' => $workspaces[1]->id,
]);
$response = $this->actingAs($user)->get(route('clients.index'));
$response->assertInertia(fn ($page) => $page
->has('clients.data', 1)
->where('clients.data.0.id', $clientB->id)
);
});

View File

@@ -0,0 +1,92 @@
<?php
use App\Concerns\AuthorizesPermissions;
use App\Enums\Permission;
use App\Enums\WorkspaceUserRole;
use App\Models\User;
use App\Models\Workspace;
uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class);
// Create a testable class that uses the trait
function createPermissionChecker(): object
{
return new class
{
use AuthorizesPermissions;
public function check(string $permission): void
{
$this->authorizePermission($permission);
}
};
}
function setupWorkspaceUser(string $role, array $permissions = []): array
{
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
$workspace->users()->attach($user->id, [
'role' => $role,
'permissions' => $permissions,
]);
session(['current_workspace_id' => $workspace->id]);
return [$user, $workspace];
}
test('owner always passes permission check', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Owner);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check(Permission::CanManageTeam);
expect(true)->toBeTrue(); // No exception thrown
});
test('worker always fails permission check with 404', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Worker);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check(Permission::CanManageTeam);
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);
test('manager with granted permission passes', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Manager, [
Permission::CanViewActivityLogs => true,
]);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check(Permission::CanViewActivityLogs);
expect(true)->toBeTrue(); // No exception thrown
});
test('manager with denied permission fails with 404', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Manager, [
Permission::CanManageTeam => false,
]);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check(Permission::CanManageTeam);
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);
test('manager with unknown permission key defaults to false and fails with 404', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Manager, []);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check('some_unknown_permission');
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);
test('manager with empty permissions fails with 404', function () {
[$user] = setupWorkspaceUser(WorkspaceUserRole::Manager, []);
$this->actingAs($user);
$checker = createPermissionChecker();
$checker->check(Permission::CanConfigurePortal);
})->throws(Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class);