Spec 077: Workspace Global Mode + context bar redundancy cleanup #94
@ -14,6 +14,7 @@
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ChooseWorkspace extends Page
|
||||
{
|
||||
@ -38,6 +39,12 @@ protected function getHeaderActions(): array
|
||||
Action::make('createWorkspace')
|
||||
->label('Create workspace')
|
||||
->modalHeading('Create workspace')
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
return $user instanceof User
|
||||
&& Gate::forUser($user)->check('create', Workspace::class);
|
||||
})
|
||||
->form([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
@ -117,6 +124,8 @@ public function createWorkspace(array $data): void
|
||||
abort(403);
|
||||
}
|
||||
|
||||
Gate::forUser($user)->authorize('create', Workspace::class);
|
||||
|
||||
$workspace = Workspace::query()->create([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'] ?? null,
|
||||
|
||||
@ -3,12 +3,14 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceIntendedUrl;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response as HttpResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@ -29,27 +31,14 @@ public function handle(Request $request, Closure $next): Response
|
||||
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
if ($this->isWorkspaceOptionalPath($request, $path)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/admin/t/')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $path) === 1) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (in_array($path, ['/admin/no-access', '/admin/choose-workspace'], true)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
@ -74,7 +63,11 @@ public function handle(Request $request, Closure $next): Response
|
||||
->exists()
|
||||
: $membershipQuery->exists();
|
||||
|
||||
$target = $hasAnyActiveMembership ? '/admin/choose-workspace' : '/admin/no-access';
|
||||
$canCreateWorkspace = Gate::forUser($user)->check('create', Workspace::class);
|
||||
|
||||
$target = ($hasAnyActiveMembership || $canCreateWorkspace)
|
||||
? '/admin/choose-workspace'
|
||||
: '/admin/no-access';
|
||||
|
||||
if ($target === '/admin/choose-workspace') {
|
||||
WorkspaceIntendedUrl::storeFromRequest($request);
|
||||
@ -82,4 +75,26 @@ public function handle(Request $request, Closure $next): Response
|
||||
|
||||
return new HttpResponse('', 302, ['Location' => $target]);
|
||||
}
|
||||
|
||||
private function isWorkspaceOptionalPath(Request $request, string $path): bool
|
||||
{
|
||||
if (str_starts_with($path, '/admin/workspaces')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($path, ['/admin/choose-workspace', '/admin/no-access', '/admin/onboarding'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($path === '/livewire/update') {
|
||||
$refererPath = parse_url((string) $request->headers->get('referer', ''), PHP_URL_PATH) ?? '';
|
||||
$refererPath = '/'.ltrim((string) $refererPath, '/');
|
||||
|
||||
if (preg_match('#^/admin/operations/[^/]+$#', $refererPath) === 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return preg_match('#^/admin/operations/[^/]+$#', $path) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
@ -17,11 +16,7 @@ class WorkspacePolicy
|
||||
*/
|
||||
public function viewAny(User $user): bool|Response
|
||||
{
|
||||
$isMember = WorkspaceMembership::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->exists();
|
||||
|
||||
return $isMember ? Response::allow() : Response::denyAsNotFound();
|
||||
return Response::allow();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -42,24 +37,7 @@ public function view(User $user, Workspace $workspace): bool|Response
|
||||
*/
|
||||
public function create(User $user): bool|Response
|
||||
{
|
||||
$hasAnyMembership = WorkspaceMembership::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->exists();
|
||||
|
||||
if (! $hasAnyMembership) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$rolesWithManageCapability = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MANAGE);
|
||||
|
||||
$canManageAnyWorkspace = WorkspaceMembership::query()
|
||||
->where('user_id', $user->getKey())
|
||||
->whereIn('role', $rolesWithManageCapability)
|
||||
->exists();
|
||||
|
||||
return $canManageAnyWorkspace
|
||||
? Response::allow()
|
||||
: Response::deny();
|
||||
return Response::allow();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -57,18 +57,13 @@ public function panel(Panel $panel): Panel
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make('Switch workspace')
|
||||
->url(fn (): string => ChooseWorkspace::getUrl())
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
->sort(10),
|
||||
NavigationItem::make('Manage workspaces')
|
||||
->url(function (): string {
|
||||
return route('filament.admin.resources.workspaces.index');
|
||||
})
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
->sort(20)
|
||||
->sort(10)
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
@ -107,10 +102,6 @@ public function panel(Panel $panel): Panel
|
||||
PanelsRenderHook::TOPBAR_START,
|
||||
fn () => view('filament.partials.context-bar')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::USER_MENU_PROFILE_AFTER,
|
||||
fn () => view('filament.partials.workspace-switcher')->render()
|
||||
)
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_END,
|
||||
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Support\Middleware;
|
||||
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
@ -157,19 +156,12 @@ private function configureNavigationForRequest(\Filament\Panel $panel): void
|
||||
|
||||
$panel->navigation(function (): NavigationBuilder {
|
||||
return app(NavigationBuilder::class)
|
||||
->item(
|
||||
NavigationItem::make('Switch workspace')
|
||||
->url(fn (): string => ChooseWorkspace::getUrl())
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
->sort(10),
|
||||
)
|
||||
->item(
|
||||
NavigationItem::make('Manage workspaces')
|
||||
->url(fn (): string => route('filament.admin.resources.workspaces.index'))
|
||||
->icon('heroicon-o-squares-2x2')
|
||||
->group('Settings')
|
||||
->sort(20)
|
||||
->sort(10)
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
|
||||
@ -43,6 +43,9 @@
|
||||
$currentTenant = Filament::getTenant();
|
||||
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
|
||||
|
||||
$path = '/'.ltrim(request()->path(), '/');
|
||||
$isTenantScopedRoute = request()->route()?->hasParameter('tenant') || str_starts_with($path, '/admin/t/');
|
||||
|
||||
$lastTenantId = $workspaceContext->lastTenantId(request());
|
||||
$canClearTenantContext = $currentTenantName !== null || $lastTenantId !== null;
|
||||
@endphp
|
||||
@ -67,20 +70,36 @@ class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
Switch workspace
|
||||
</a>
|
||||
|
||||
@if ($canSeeAllWorkspaceTenants)
|
||||
<a
|
||||
href="{{ route('filament.admin.resources.workspaces.index') }}"
|
||||
class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
Manage workspaces
|
||||
</a>
|
||||
@endif
|
||||
</x-filament::dropdown.list>
|
||||
</x-filament::dropdown>
|
||||
|
||||
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700"></div>
|
||||
|
||||
@if (! $workspace)
|
||||
<div class="flex items-center gap-2">
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
outlined
|
||||
size="sm"
|
||||
icon="heroicon-o-building-office-2"
|
||||
disabled
|
||||
>
|
||||
Select tenant
|
||||
</x-filament::button>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Choose a workspace first.</div>
|
||||
</div>
|
||||
@elseif ($isTenantScopedRoute)
|
||||
<x-filament::button
|
||||
color="gray"
|
||||
outlined
|
||||
size="sm"
|
||||
icon="heroicon-o-building-office-2"
|
||||
disabled
|
||||
>
|
||||
{{ $currentTenantName ?? 'Tenant' }}
|
||||
</x-filament::button>
|
||||
@else
|
||||
<x-filament::dropdown placement="bottom-start" teleport>
|
||||
<x-slot name="trigger">
|
||||
<x-filament::button
|
||||
@ -102,9 +121,7 @@ class="block px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (! $workspace)
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Choose a workspace first.</div>
|
||||
@elseif ($tenants->isEmpty())
|
||||
@if ($tenants->isEmpty())
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $canSeeAllWorkspaceTenants ? 'No tenants exist in this workspace.' : 'No tenants you can access in this workspace.' }}
|
||||
</div>
|
||||
@ -153,4 +170,5 @@ class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-80
|
||||
</div>
|
||||
</x-filament::dropdown.list>
|
||||
</x-filament::dropdown>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -28,9 +28,11 @@ ### Workspace management (CRUD)
|
||||
|
||||
Contract semantics:
|
||||
|
||||
- Workspace context is optional on `/admin/workspaces` (Global Mode).
|
||||
- Index lists only workspaces the user is a member of.
|
||||
- If user attempts to access a workspace record they are not a member of → 404 (deny-as-not-found)
|
||||
- If user is a member but lacks the required capability for a protected action/screen (create/edit/membership management) → 403
|
||||
- Workspace creation is self-serve for authenticated users (policy-driven).
|
||||
- If user is a member but lacks the required capability for a protected action/screen (edit/membership management) → 403
|
||||
- If user is authorized → normal Filament behavior
|
||||
|
||||
### Monitoring hub — Operations
|
||||
|
||||
@ -125,17 +125,13 @@ ## Phase 1 — Design & Contracts (complete)
|
||||
- Route/security contracts: [contracts/routes.md](contracts/routes.md)
|
||||
- Manual validation steps + suggested test filters: [quickstart.md](quickstart.md)
|
||||
|
||||
Agent context update:
|
||||
|
||||
- Re-run `.specify/scripts/bash/update-agent-context.sh copilot` after finalizing this plan file (the earlier run happened while this file contained placeholders).
|
||||
|
||||
## Phase 2 — Implementation Plan (ready for tasks)
|
||||
|
||||
### Step 1 — Navigation labels: “one label, one meaning”
|
||||
|
||||
- Update admin navigation to include:
|
||||
- **Switch workspace** → `/admin/choose-workspace`
|
||||
- **Manage workspaces** → `/admin/workspaces`
|
||||
- **Switch workspace** (topbar context switcher) → `/admin/choose-workspace`
|
||||
- **Manage workspaces** (sidebar Settings) → `/admin/workspaces`
|
||||
- Remove/replace any navigation items labeled only “Workspaces”.
|
||||
|
||||
Implementation targets:
|
||||
@ -149,7 +145,7 @@ ### Step 1 — Navigation labels: “one label, one meaning”
|
||||
|
||||
### Step 2 — Enforce workspace-scoped RBAC semantics for `/admin/workspaces`
|
||||
|
||||
- `/admin/workspaces` stays tenantless and workspace-scoped.
|
||||
- `/admin/workspaces` stays tenantless and is **Global Mode** (workspace-optional).
|
||||
- Enforce strict non-leakage semantics:
|
||||
- Non-member attempting to access a workspace record → **404** (deny-as-not-found)
|
||||
- Member missing required capability for protected actions/screens → **403**
|
||||
@ -158,7 +154,7 @@ ### Step 2 — Enforce workspace-scoped RBAC semantics for `/admin/workspaces`
|
||||
|
||||
- Scope the Workspaces query (index) to only workspaces the user is a member of.
|
||||
- Ensure `WorkspacePolicy` returns 404 semantics for non-members (record access).
|
||||
- Gate create/edit/membership-management behind canonical workspace capabilities (no raw strings).
|
||||
- Workspace creation is self-serve (policy-driven). Gate edit/membership-management behind canonical workspace capabilities (no raw strings).
|
||||
- Hide “Manage workspaces” navigation unless the user can manage something workspace-admin related (capability-based).
|
||||
|
||||
### Step 3 — Workspace selection redirect + return-to-intended
|
||||
|
||||
@ -2,14 +2,15 @@ # Feature Specification: Workspace-first Navigation & Monitoring Hub
|
||||
|
||||
**Feature Branch**: `077-workspace-nav-monitoring-hub`
|
||||
**Created**: 2026-02-06
|
||||
**Status**: Draft
|
||||
**Status**: Implemented
|
||||
**Input**: User description: "Workspace-first navigation and monitoring hub for an enterprise admin suite: remove workspace navigation ambiguity, lock canonical operations deep links, apply tenant context only as default filters, and enforce strict 404/403 access semantics without information leakage."
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-06
|
||||
|
||||
- Q: What is the authorization plane + status-code rule for `/admin/workspaces` ("Manage workspaces")? → A: Tenant plane (`/admin`, Entra users). Workspace management is workspace-scoped: non-members receive 404 (deny-as-not-found); members missing required capabilities receive 403.
|
||||
- Q: What is the authorization plane + status-code rule for `/admin/workspaces` ("Manage workspaces")? → A: Tenant plane (`/admin`, Entra users). `/admin/workspaces` is **Global Mode** (workspace-optional). Index lists only the user’s workspaces; per-record access for non-members is 404 (deny-as-not-found); protected actions/screens return 403 when unauthorized.
|
||||
- Q: Should `/admin/workspaces` require an active `current_workspace_id`? → A: No. `/admin/workspaces` is **Global Mode** (workspace-optional). The index lists only workspaces the user is a member of; per-record access for non-members remains 404.
|
||||
- Q: How should the tenant-context default filter on `/admin/operations` be implemented? → A: Server-side default state with a removable filter chip; URL remains `/admin/operations`.
|
||||
- Q: What happens when a user visits a workspace-scoped page (e.g. `/admin/operations`) with no `current_workspace_id` selected? → A: Redirect to `/admin/choose-workspace` and return to the originally requested URL after selection.
|
||||
- Q: If tenant context is active but the tenant is not in the current workspace (e.g., user switches workspaces), what should happen? → A: Auto-clear tenant context and continue on tenantless workspace pages.
|
||||
@ -97,6 +98,7 @@ ### Functional Requirements
|
||||
- "Switch workspace" for selecting the active workspace context.
|
||||
- "Manage workspaces" for workspace CRUD/administration.
|
||||
- **FR-002 (Canonical workspace switch route)**: "Switch workspace" MUST navigate to `/admin/choose-workspace`.
|
||||
- **UX note**: "Switch workspace" is a global context control and MUST NOT be registered as a sidebar navigation item.
|
||||
- **FR-003 (Canonical workspace management route)**: "Manage workspaces" MUST navigate to `/admin/workspaces` and MUST NOT be labeled simply "Workspaces".
|
||||
- **FR-004 (Breadcrumb correctness)**: Breadcrumbs in workspace management MUST point back to `/admin/workspaces` and must not send users to the workspace switcher.
|
||||
- **FR-005 (Monitoring is workspace-level)**: Monitoring pages MUST be workspace-scoped and reachable without tenant context.
|
||||
|
||||
@ -139,6 +139,11 @@ ## Phase 6: Polish & Cross-Cutting Concerns
|
||||
### Post-implementation bugfixes
|
||||
|
||||
- [X] T058 Fix route conflict so Operations “View” consistently hits canonical `/admin/operations/{run}` by moving Filament resource view route to `/admin/operations/r/{record}` in app/Filament/Resources/OperationRunResource.php
|
||||
- [X] T059 Remove “Switch workspace” from sidebar navigation (workspace switching is topbar-only) in app/Providers/Filament/AdminPanelProvider.php and app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- [X] T060 Define Global Mode: make `/admin/workspaces` workspace-optional + add explicit allowlist in app/Http/Middleware/EnsureWorkspaceSelected.php
|
||||
- [X] T061 Disable tenant picker when no workspace is active (Global Mode) in resources/views/filament/partials/context-bar.blade.php
|
||||
- [X] T062 Remove “Manage workspaces” link from the topbar context switcher to avoid redundant entry points in resources/views/filament/partials/context-bar.blade.php
|
||||
- [X] T063 Unify workspace creation authorization: ChooseWorkspace create action must use WorkspacePolicy (Gate) in app/Filament/Pages/ChooseWorkspace.php and app/Policies/WorkspacePolicy.php
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
->assertDontSee($tenantB->name);
|
||||
});
|
||||
|
||||
test('user menu renders a workspace switcher when a workspace is selected', function () {
|
||||
test('user menu does not render a workspace switcher (topbar context bar is the single entry point)', function () {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
|
||||
$workspace = Workspace::query()->whereKey($tenant->workspace_id)->firstOrFail();
|
||||
@ -61,6 +61,5 @@
|
||||
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
|
||||
->assertOk()
|
||||
->assertSee($workspace->name)
|
||||
->assertSee('Switch workspace')
|
||||
->assertSee('name="workspace_id"', escape: false);
|
||||
->assertDontSee('name="workspace_id"', escape: false);
|
||||
});
|
||||
|
||||
@ -40,6 +40,44 @@
|
||||
->assertRedirect();
|
||||
});
|
||||
|
||||
it('disables the tenant picker when no workspace is active (Global Mode)', function (): void {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$workspace = \App\Models\Workspace::factory()->create();
|
||||
\App\Models\WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
session()->forget(WorkspaceContext::SESSION_KEY);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/workspaces')
|
||||
->assertOk()
|
||||
->assertSee('Select workspace')
|
||||
->assertSee('Select tenant')
|
||||
->assertSee('Choose a workspace first.')
|
||||
->assertDontSee('Search tenants…');
|
||||
});
|
||||
|
||||
it('renders the tenant indicator read-only on tenant-scoped pages (Filament tenant menu is primary)', function (): void {
|
||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])
|
||||
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
|
||||
->assertOk()
|
||||
->assertSee($tenant->getFilamentName())
|
||||
->assertDontSee('Search tenants…')
|
||||
->assertDontSee('admin/select-tenant')
|
||||
->assertDontSee('Clear tenant context');
|
||||
});
|
||||
|
||||
it('filters the header tenant picker to tenants the user can access', function (): void {
|
||||
$tenantA = Tenant::factory()->create(['status' => 'active']);
|
||||
[$user, $tenantA] = createUserWithTenant($tenantA, role: 'owner', workspaceRole: 'readonly');
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows "Switch workspace" navigation when no tenant is selected', function (): void {
|
||||
it('does not show "Switch workspace" in sidebar navigation (topbar-only)', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
@ -34,6 +34,7 @@
|
||||
->map(static fn ($item): string => $item->getLabel())
|
||||
->all();
|
||||
|
||||
expect($labels)->toContain('Switch workspace');
|
||||
expect($labels)->not->toContain('Switch workspace');
|
||||
expect($labels)->toContain('Manage workspaces');
|
||||
expect($labels)->not->toContain('Workspaces');
|
||||
});
|
||||
|
||||
@ -32,6 +32,23 @@
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('serves /admin/workspaces without an active workspace selected (Global Mode)', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$workspace = Workspace::factory()->create(['slug' => 'acme']);
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => $workspace->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/admin/workspaces')
|
||||
->assertOk()
|
||||
->assertSee('Select workspace')
|
||||
->assertSee('Choose a workspace first.');
|
||||
});
|
||||
|
||||
it('serves the Workspaces view page tenantless at /admin/workspaces/{record}', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user