TenantAtlas/specs/107-workspace-chooser/research.md
ahmido e15eee8f26 fix: consolidate tenant creation + harden selection flows (#131)
## Summary
- Removes the legacy Tenant CRUD create page (`/admin/tenants/create`) so tenant creation is handled exclusively via the onboarding wizard.
- Updates tenant selection flows and pages to prevent Livewire polling/notification-related 404s on workspace-scoped routes.
- Aligns empty-state UX with enterprise patterns (avoid duplicate CTAs).

## Key changes
- Tenant creation
  - Removed `CreateTenant` page + route from `TenantResource`.
  - `TenantResource::canCreate()` now returns `false` (CRUD creation disabled).
  - Tenants list now surfaces an **Add tenant** action that links to onboarding (`admin.onboarding`).
- Onboarding wizard
  - Removed redundant legacy step-cards from the blade view (Wizard schema is the source of truth).
  - Disabled topbar on the onboarding page to avoid lazy-loaded notifications.
- Choose tenant
  - Enterprise UI redesign + workspace context.
  - Uses Livewire `selectTenant()` instead of a form POST.
  - Disabled topbar and gated BODY_END hook to avoid background polling.
- Baseline profiles
  - Hide header create action when table is empty to avoid duplicate CTAs.

## Tests
- `vendor/bin/sail artisan test --compact --filter='Onboarding|ManagedTenantOnboarding'`
- `vendor/bin/sail artisan test --compact --filter='ManagedTenantsLivewireUpdate'`
- `vendor/bin/sail artisan test --compact --filter='TenantSetup|TenantResourceAuth|TenantAdminAuth|ListTenants'`
- `vendor/bin/sail artisan test --compact --filter='BaselineProfile'`
- `vendor/bin/sail artisan test --compact --filter='ChooseTenant|TenantMake|TenantScoping|AdminTenantScoped|AdminHomeRedirect|WorkspaceContext'`

## Notes
- Filament v5 / Livewire v4 compatible.
- No new assets introduced; no deploy pipeline changes required.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #131
2026-02-22 19:54:24 +00:00

107 lines
9.1 KiB
Markdown

# Research: Workspace Chooser v1
**Feature**: 107-workspace-chooser | **Date**: 2026-02-22
## R1: Middleware Refactor Strategy
**Question**: Should we create a new `EnsureActiveWorkspace` middleware or refactor the existing `EnsureWorkspaceSelected`?
- **Decision**: Refactor the existing `EnsureWorkspaceSelected` middleware in-place.
- **Rationale**: The existing middleware is already registered in both `AdminPanelProvider` and `TenantPanelProvider` middleware stacks as `'ensure-workspace-selected'`, and referenced by alias in `bootstrap/app.php`. Creating a new class would require changing all registration points and updating existing tests. The current class already handles the same responsibilities — it just doesn't implement them according to the spec.
- **Alternatives considered**:
- New `EnsureActiveWorkspace` class: rejected because it would require renaming the middleware alias everywhere, with no functional benefit beyond a name change. The alias can remain `ensure-workspace-selected` for backward compatibility.
## R2: Audit Event Integration Pattern
**Question**: How should workspace selection audit events be emitted?
- **Decision**: Call `WorkspaceAuditLogger::log()` directly from the middleware (for auto-selections) and from `ChooseWorkspace::selectWorkspace()` (for manual selections). No events/listeners needed.
- **Rationale**: `WorkspaceAuditLogger` is a simple synchronous service — no queue, no listener. The codebase pattern (used in workspace membership, settings, alert destinations, baselines, etc.) is direct `$logger->log(...)` calls at the mutation point. Workspace selection audit is similarly < 1ms DB insert.
- **Alternatives considered**:
- Laravel Events + Listeners: rejected overkill for a synchronous log write. No other systems need to react to workspace selection events.
- Observer on `User` model (`last_workspace_id` change): rejected would miss cases where only the session changes (auto-resume from session), and would conflate preference persistence with audit semantics.
## R3: `AuditActionId` Enum Values
**Question**: What enum values and string representations to use?
- **Decision**: Add two cases:
- `WorkspaceAutoSelected = 'workspace.auto_selected'` for auto-resume (single membership or last-used).
- `WorkspaceSelected = 'workspace.selected'` for manual selection from chooser.
- **Rationale**: Follows the existing naming pattern (`case CamelName = 'snake.dotted_value'`). The `method` (auto/manual) and `reason` (single_membership/last_used/chooser) are stored in the audit log's `metadata` JSONB, not in separate enum values.
- **Alternatives considered**:
- Three separate enum values (one per reason): rejected metadata provides sufficient granularity; enum values should represent the action type, not the trigger.
## R4: Redirect After Selection (Tenant-Count Branching)
**Question**: Where does redirect logic live? Should it be deduplicated?
- **Decision**: Extract the tenant-count branching logic into a shared helper method on `WorkspaceContext` or a dedicated `WorkspaceRedirectResolver` to avoid duplicating it across:
1. `EnsureWorkspaceSelected` middleware (auto-resume redirects)
2. `ChooseWorkspace::selectWorkspace()` (manual selection redirect)
3. `SwitchWorkspaceController::__invoke()` (POST switch redirect)
4. `routes/web.php` `/admin` route handler
Currently, the same branching logic (0 tenants managed-tenants, 1 tenant dashboard, >1 → choose-tenant) is copy-pasted in all four locations.
- **Rationale**: DRY — the branching is identical in all cases and is the single authority for "where to go after workspace is set." A single method eliminates the risk of divergence as new conditions are added.
- **Alternatives considered**:
- Leave duplicated: rejected — 4 copies of the same logic is a maintenance hazard.
- Put on `ChooseWorkspace` page: rejected — the middleware and controller both need it but don't have access to the page class.
## R5: `?choose=1` Handling Location
**Question**: Should the `?choose=1` forced-chooser parameter be handled in the middleware or in the page?
- **Decision**: Handle in the middleware — step 2 of the algorithm. If `choose=1` is present, redirect to `/admin/choose-workspace?choose=1` and skip auto-resume logic.
- **Rationale**: The middleware is the single entry point for all `/admin/*` requests. Handling it there prevents auto-resume from overriding the explicit user intent to see the chooser.
- **Alternatives considered**:
- Handle in `ChooseWorkspace` page: rejected — the middleware would auto-resume BEFORE the page loads, so the user would never see the chooser.
## R6: User Menu Integration
**Question**: How to add "Switch workspace" to the Filament user menu?
- **Decision**: Register via `->userMenuItems()` in `AdminPanelProvider::panel()`. Use `Filament\Navigation\MenuItem::make()` with `->url('/admin/choose-workspace?choose=1')` and a `->visible()` callback that checks workspace membership count > 1.
- **Rationale**: This is the documented Filament v5 pattern from the constitution + blueprint. The menu item is a navigation-only action (URL link), not a destructive action, so no confirmation needed.
- **Alternatives considered**:
- Context bar link only: rejected — specification explicitly requires a user menu entry (FR-008).
- Adding to both user menu and context bar: the context bar already has "Switch workspace" — the user menu entry provides an additional discovery point per spec.
## R7: Badge Rendering for Workspace Role
**Question**: Should workspace membership role badges use `BadgeCatalog`/`BadgeDomain`?
- **Decision**: No. Workspace membership role is a **tag/category** (owner, admin, member), not a status-like value. Per constitution (BADGE-001), tag/category chips are not governed by `BadgeCatalog`. Use a simple Filament `<x-filament::badge>` with a color mapping (e.g., owner → primary, admin → warning, member → gray).
- **Rationale**: The role is static metadata, not a state transition. No existing `BadgeDomain` for workspace roles. Adding one would be over-engineering for 3 static values.
- **Alternatives considered**:
- Create a `WorkspaceRoleBadgeDomain`: rejected — violates the "tag, not status" exemption in BADGE-001.
## R8: Blade Template vs. Livewire Component for Chooser
**Question**: Should the chooser cards remain as a Blade template or be converted to a Livewire component?
- **Decision**: Keep as Blade template rendered by the existing Livewire-backed `ChooseWorkspace` Filament Page. The page class already extends `Filament\Pages\Page` (which is Livewire). The `wire:click` for "Open" calls the existing `selectWorkspace()` method. No separate Livewire component needed.
- **Rationale**: The existing pattern works. The page is already a Livewire component (all Filament Pages are). Converting to a separate Livewire component adds complexity with no benefit — the chooser has no real-time reactive needs.
- **Alternatives considered**:
- Separate `WorkspaceChooserCard` Livewire component: rejected — unnecessary abstraction for a simple card grid.
## R9: Existing `SwitchWorkspaceController` Coexistence
**Question**: The chooser currently uses POST to `SwitchWorkspaceController`. Should we switch to Livewire `wire:click` or keep the POST?
- **Decision**: Migrate the chooser page to use Livewire `wire:click` calling `selectWorkspace($workspaceId)` (which already exists). The `SwitchWorkspaceController` is still needed for the `workspace-switcher.blade.php` partial (context-bar dropdown) which uses a form POST. Both paths converge on `WorkspaceContext::setCurrentWorkspace()`.
- **Rationale**: The Livewire path already exists in `ChooseWorkspace::selectWorkspace()` — the blade template just needs to call it via `wire:click` instead of a form POST. This simplifies the chooser page and makes audit integration easier (audit logging happens in the PHP method, not in a controller).
- **Alternatives considered**:
- Keep form POST from chooser: rejected — the `selectWorkspace()` method is where we add audit logging. Using `wire:click` means a single code path.
- Remove `SwitchWorkspaceController` entirely: deferred — the context-bar dropdown still uses it. Can be unified in a future PR.
## R10: Flash Warning Implementation
**Question**: How to show "Your access to {workspace_name} was removed" when stale membership detected?
- **Decision**: Use Filament database notifications or session flash + `Filament\Notifications\Notification::make()->danger()`. Since the middleware redirects to the chooser, and the chooser is a Filament page, Filament's notification system renders flash/database notifications automatically.
- **Rationale**: The chooser is a Filament page — Filament's notification toast system is already wired. Session-based `Notification::make()` works for redirect→page scenarios.
- **Alternatives considered**:
- Custom Blade banner: rejected — Filament notifications already solve this and are consistent with the rest of the app.
- Session flash only (no Filament notification): rejected — the Filament notification system provides better UX (auto-dismiss, consistent styling).