- 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>
15 KiB
Story 1.6: Workspace Switching for Multi-Workspace Owners
Status: done
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
- 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
- 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 - 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)
- The existing
WorkspaceSwitchControlleris enhanced to redirect to the dashboard (notback()) after switching, so the user lands on a known safe page with correct workspace context - Workspace switching is logged in the activity log with the previous and new workspace IDs
- After switching workspaces, the sidebar navigation items, shared Inertia props (
auth.currentWorkspace,auth.workspaceRole), and all data queries correctly reflect the new workspace - The workspace switcher shows the workspace logo/initial, name, and slug for each workspace entry
- 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
-
Task 1: Backend — Enhance
WorkspaceSwitchController(AC: #2, #4, #5)- 1.1 Change redirect from
back()toredirect()->route('dashboard')so users land on a known safe page after switching - 1.2 Add activity log entry on successful switch:
activity()->causedBy($user)->withProperties(['previous_workspace_id' => $previousId, 'new_workspace_id' => $workspaceId])->log('Switched workspace') - 1.3 Store the previous
current_workspace_idfrom session before overwriting, for the activity log
- 1.1 Change redirect from
-
Task 2: Frontend — Enhance
WorkspaceSwitcher.vuefor improved UX (AC: #1, #3, #7, #8)- 2.1 Add a visual indicator (checkmark icon or primary color highlight) on the currently active workspace in the dropdown list
- 2.2 When
workspaces.length <= 1, hide theChevronsUpDownicon and disable the dropdown trigger — render the workspace header as a static display (noDropdownMenuTrigger) - 2.3 Show workspace initials or first letter in the icon area for each dropdown entry (instead of generic
Building2icon for all) - 2.4 Replace the hardcoded URL
/workspace/switchwith a Wayfinder route helper (generate if needed, or pass as shared prop) - 2.5 Prevent double-click by adding a loading state (
isSwitchingref) that disables the dropdown while the switch request is in flight
-
Task 3: Backend — Share workspace switch URL as Inertia prop (AC: #2)
- 3.1 In
HandleInertiaRequests::share(), add'workspaceSwitchUrl' => route('workspace.switch')to the shared props so the frontend never hardcodes the URL - 3.2 Update
resources/js/types/auth.tsto includeworkspaceSwitchUrl?: stringon theAuthtype (or create a new shared prop)
- 3.1 In
-
Task 4: Tests — Workspace switching functionality (AC: #1-#8)
- 4.1 Test Owner with 2+ workspaces can switch: POST to workspace.switch with new workspace_id, assert session
current_workspace_idchanged, assert redirect to dashboard - 4.2 Test Owner switching logs activity with previous and new workspace IDs
- 4.3 Test Worker/Manager cannot switch to a workspace they don't belong to (existing behavior — verify
back()redirect stays unchanged for unauthorized) - 4.4 Test switching updates
auth.currentWorkspaceandauth.workspaceRolein Inertia shared props on next page load - 4.5 Test user with single workspace: POST to workspace.switch with same workspace_id — no error, session unchanged
- 4.6 Test cross-workspace isolation: after switching, data queries return only new workspace's data (clients, declarations)
- 4.1 Test Owner with 2+ workspaces can switch: POST to workspace.switch with new workspace_id, assert session
Dev Notes
Critical Architecture Patterns (from Stories 1.1-1.5)
- Authorization:
abort(404)NEVERabort(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 currentWorkspaceSwitcher.vuehardcodes/workspace/switch— this MUST be fixed. - Flash messages:
HandleInertiaRequestssharessuccess/errorflash keys;AppSidebarLayoutdisplays 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
// 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:
- Store previous workspace ID before overwriting session
- Add activity log entry
- Change
back()toredirect()->route('dashboard')— after switching workspace, going "back" would show stale data from the previous workspace context
Current WorkspaceSwitcher.vue Analysis
Issues to fix:
- Hardcoded URL (line 27):
router.post('/workspace/switch', ...)— must use shared prop or Wayfinder route - No active workspace indicator — all entries look the same in the dropdown
- No loading state — double-click could trigger duplicate switch requests
- Always shows dropdown — even for single-workspace users, the chevron and dropdown trigger are visible
- Generic icons — all workspaces use
Building2icon; could show workspace initial for differentiation
HandleInertiaRequests — Already Correct
The middleware already:
- Loads all user workspaces with id, name, slug
- Sets
currentWorkspacefrom session (with auto-fallback to first workspace) - Shares
workspaceRolefromWorkspaceUserpivot - Auto-sets
current_workspace_idin 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
EnsureUserHasWorkspacemiddleware (already handles invalid sessions) - No workspace creation from the switcher (the commented-out "Manage workspaces" link in
WorkspaceSwitcher.vuestays 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.workspaceRolesharing works viaHandleInertiaRequests— WorkspaceUser pivot lookupscopeForUser()on Declaration model works — workspace-scoped queries are established pattern- Frontend reads
page.props.auth?.workspaceRoleviausePage()— 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.vueis 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 bothback()returns toredirect()->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— addedCheckicon for active workspace indicator, conditional rendering (dropdown for multi-workspace, static display for single workspace), workspace initial letters instead of genericBuilding2icons,isSwitchingloading state ref to prevent double-click, used shared prop URL instead of hardcoded path. - Task 3: Added
workspaceSwitchUrltoHandleInertiaRequests::share()and updatedAuthTypeScript 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).