Files
L-Ami-Fiduciaire/_bmad-output/implementation-artifacts/1-6-workspace-switching-for-multi-workspace-owners.md
Saad Ibn-Ezzoubayr c89d1879bf feat: complete Epic 1 — team management & permission system
- Story 1.1: Permission enum, config, AuthorizesPermissions & HasWorkspaceScope traits, member→worker migration
- Story 1.2: Team page with member list, invitation system with queued email
- Story 1.3: Role assignment (Manager/Worker) and member removal with activity logging
- Story 1.4: Owner-only permission toggle matrix for Managers (manage team, view logs, configure portal)
- Story 1.5: Role-based access enforcement — Workers see only assigned declarations/clients, sidebar scoping
- Story 1.6: Workspace switcher dropdown for multi-workspace users with session-based switching
- 83 new/modified files, 182 tests passing with zero regressions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 00:12:50 +00:00

232 lines
15 KiB
Markdown

# 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).