merge: agent session work

This commit is contained in:
Ahmed Darrazi 2026-02-01 10:56:00 +01:00
commit da610d5c5a
39 changed files with 1541 additions and 70 deletions

View File

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\ManagedTenants\ManagedTenantContext;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class ArchivedStatus extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'managed-tenants/archived';
protected static ?string $title = 'Archived managed tenant';
protected string $view = 'filament.pages.managed-tenants.archived-status';
public ?Tenant $tenant = null;
public function mount(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->tenantMemberships()->exists()) {
abort(404);
}
$this->tenant = ManagedTenantContext::archivedTenant();
if (! $this->tenant instanceof Tenant) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $this->tenant)) {
abort(404);
}
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
abort(403);
}
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('back_to_managed_tenants')
->label('Back to managed tenants')
->url(Index::getUrl()),
UiEnforcement::forTableAction(
Action::make('restore')
->label('Restore')
->icon('heroicon-o-arrow-path')
->action(function (): void {
$tenant = $this->tenant;
if (! $tenant instanceof Tenant) {
abort(404);
}
$tenant->restore();
$tenant->refresh();
ManagedTenantContext::setCurrentTenant($tenant);
ManagedTenantContext::clearArchivedTenant();
Notification::make()
->title('Managed tenant restored')
->success()
->send();
$this->redirect(Current::getUrl());
}),
fn () => $this->tenant,
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_RESTORE)
->destructive()
->apply(),
UiEnforcement::forTableAction(
Action::make('force_delete')
->label('Force delete')
->icon('heroicon-o-trash')
->color('danger')
->action(function (): void {
$tenant = $this->tenant;
if (! $tenant instanceof Tenant) {
abort(404);
}
$tenant->forceDelete();
ManagedTenantContext::clear();
Notification::make()
->title('Managed tenant deleted')
->success()
->send();
$this->redirect(Index::getUrl());
}),
fn () => $this->tenant,
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)
->destructive()
->apply(),
];
}
}

View File

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\ManagedTenants\ManagedTenantContext;
use Filament\Actions;
use Filament\Pages\Page;
class Current extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'managed-tenants/current';
protected static ?string $title = 'Current managed tenant';
protected string $view = 'filament.pages.managed-tenants.current';
public ?Tenant $tenant = null;
public function mount(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->tenantMemberships()->exists()) {
abort(404);
}
$currentTenantId = ManagedTenantContext::currentTenantId();
if (is_int($currentTenantId)) {
$selectedTenant = Tenant::withTrashed()->find($currentTenantId);
if (! $selectedTenant instanceof Tenant) {
ManagedTenantContext::clearCurrentTenant();
} elseif (! $selectedTenant->isActive()) {
ManagedTenantContext::clearCurrentTenant();
ManagedTenantContext::setArchivedTenant($selectedTenant);
$this->redirect(ArchivedStatus::getUrl());
return;
} else {
$this->tenant = $selectedTenant;
}
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$canViewAny = Tenant::query()
->whereIn('id', $user->tenants()->withTrashed()->pluck('tenants.id'))
->cursor()
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW));
if (! $canViewAny) {
abort(403);
}
if (! $this->tenant instanceof Tenant) {
return;
}
if (! $resolver->isMember($user, $this->tenant)) {
abort(404);
}
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
abort(403);
}
// The active status is already verified above.
}
/**
* @return array<Actions\Action>
*/
protected function getHeaderActions(): array
{
return [
Actions\Action::make('back_to_managed_tenants')
->label('Back to managed tenants')
->url(Index::getUrl()),
];
}
}

View File

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use Filament\Forms;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Schema;
class EditManagedTenant extends Page implements HasForms
{
use InteractsWithForms;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'managed-tenants/{managedTenant}/edit';
protected static ?string $title = 'Edit managed tenant';
protected string $view = 'filament.pages.managed-tenants.edit';
public Tenant $tenant;
/**
* @var array<string, mixed>
*/
public array $data = [];
public function mount(string $managedTenant): void
{
$this->tenant = Tenant::withTrashed()->findOrFail($managedTenant);
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $this->tenant)) {
abort(404);
}
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_MANAGE)) {
abort(403);
}
if (! $this->tenant->isActive()) {
\App\Support\ManagedTenants\ManagedTenantContext::setArchivedTenant($this->tenant);
$this->redirect(ArchivedStatus::getUrl());
}
$this->form->fill([
'name' => $this->tenant->name,
'environment' => $this->tenant->environment,
'tenant_id' => $this->tenant->tenant_id,
'domain' => $this->tenant->domain,
'app_client_id' => $this->tenant->app_client_id,
'app_client_secret' => null,
'app_certificate_thumbprint' => $this->tenant->app_certificate_thumbprint,
'app_notes' => $this->tenant->app_notes,
]);
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\Select::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
])
->default('other')
->required(),
Forms\Components\TextInput::make('tenant_id')
->label('Tenant ID (GUID)')
->required()
->maxLength(255),
Forms\Components\TextInput::make('domain')
->label('Primary domain')
->maxLength(255),
Forms\Components\TextInput::make('app_client_id')
->label('App Client ID')
->maxLength(255),
Forms\Components\TextInput::make('app_client_secret')
->label('App Client Secret')
->password()
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
Forms\Components\TextInput::make('app_certificate_thumbprint')
->label('Certificate thumbprint')
->maxLength(255),
Forms\Components\Textarea::make('app_notes')
->label('Notes')
->rows(3),
])
->statePath('data');
}
public function save(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_MANAGE)) {
abort(403);
}
$data = $this->form->getState();
$this->tenant->fill($data);
$this->tenant->save();
Notification::make()
->title('Managed tenant updated')
->success()
->send();
$this->redirect(ViewManagedTenant::getUrl(['tenant' => $this->tenant]));
}
}

View File

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class Index extends Page implements HasTable
{
use InteractsWithTable;
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = true;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|UnitEnum|null $navigationGroup = 'Managed tenants';
protected static ?string $navigationLabel = 'Managed tenants';
protected static ?string $slug = 'managed-tenants';
protected static ?string $title = 'Managed tenants';
protected string $view = 'filament.pages.managed-tenants.index';
public function mount(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->tenantMemberships()->exists()) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$canViewAny = Tenant::query()
->whereIn('id', $user->tenants()->withTrashed()->pluck('tenants.id'))
->cursor()
->contains(fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW));
if (! $canViewAny) {
abort(403);
}
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
Action::make('add_managed_tenant')
->label('Add managed tenant')
->icon('heroicon-o-plus')
->url('/admin/managed-tenants/onboarding'),
];
}
public function table(Table $table): Table
{
return $table
->query($this->managedTenantsQuery())
->columns([
TextColumn::make('name')->searchable(),
TextColumn::make('tenant_id')->label('Tenant ID')->copyable()->searchable(),
TextColumn::make('environment')->badge()->sortable(),
TextColumn::make('status')->badge()->sortable(),
])
->actions([
UiEnforcement::forTableAction(
Action::make('open')
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): string => "/admin/managed-tenants/{$record->getKey()}/open"),
fn () => Filament::getTenant(),
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
->apply(),
UiEnforcement::forTableAction(
Action::make('view')
->label('View')
->url(fn (Tenant $record): string => ViewManagedTenant::getUrl(['managedTenant' => $record])),
fn () => Filament::getTenant(),
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
->apply(),
UiEnforcement::forTableAction(
Action::make('edit')
->label('Edit')
->url(fn (Tenant $record): string => EditManagedTenant::getUrl(['managedTenant' => $record])),
fn () => Filament::getTenant(),
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
->apply(),
])
->emptyStateHeading('No managed tenants')
->emptyStateDescription('Add your first managed tenant to begin onboarding.')
->emptyStateActions([
Action::make('empty_add_managed_tenant')
->label('Add managed tenant')
->icon('heroicon-o-plus')
->url('/admin/managed-tenants/onboarding'),
]);
}
private function managedTenantsQuery(): Builder
{
$user = auth()->user();
if (! $user instanceof User) {
return Tenant::query()->whereRaw('1 = 0');
}
$tenantIds = $user->tenants()
->withTrashed()
->pluck('tenants.id');
return Tenant::query()
->withTrashed()
->whereIn('id', $tenantIds);
}
}

View File

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Support\Auth\Capabilities;
use Filament\Forms;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Schema;
class Onboarding extends Page implements HasForms
{
use InteractsWithForms;
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'managed-tenants/onboarding';
protected static ?string $title = 'Add managed tenant';
protected string $view = 'filament.pages.managed-tenants.onboarding';
/**
* @var array<string, mixed>
*/
public array $data = [];
public function mount(): void
{
static::abortIfNonMember();
if (! static::canView()) {
abort(403);
}
$this->form->fill();
}
public static function canView(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
if ($tenantIds->isEmpty()) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGED_TENANTS_CREATE)) {
return true;
}
}
return false;
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\Select::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
])
->default('other')
->required(),
Forms\Components\TextInput::make('tenant_id')
->label('Tenant ID (GUID)')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\Components\TextInput::make('domain')
->label('Primary domain')
->maxLength(255),
Forms\Components\TextInput::make('app_client_id')
->label('App Client ID')
->maxLength(255),
Forms\Components\TextInput::make('app_client_secret')
->label('App Client Secret')
->password()
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
Forms\Components\TextInput::make('app_certificate_thumbprint')
->label('Certificate thumbprint')
->maxLength(255),
Forms\Components\Textarea::make('app_notes')
->label('Notes')
->rows(3),
])
->statePath('data');
}
public function create(AuditLogger $auditLogger): void
{
static::abortIfNonMember();
if (! static::canView()) {
abort(403);
}
$data = $this->form->getState();
$tenant = Tenant::query()->create($data);
$user = auth()->user();
if ($user instanceof User) {
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => [
'role' => 'owner',
'source' => 'manual',
'created_by_user_id' => $user->getKey(),
],
]);
$auditLogger->log(
tenant: $tenant,
action: 'managed_tenant.onboarding.created',
context: [
'metadata' => [
'internal_tenant_id' => (int) $tenant->getKey(),
'tenant_guid' => (string) $tenant->tenant_id,
],
],
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
}
Notification::make()
->title('Managed tenant added')
->success()
->send();
$this->redirect(TenantResource::getUrl('view', ['record' => $tenant]));
}
private static function abortIfNonMember(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->tenantMemberships()->exists()) {
abort(404);
}
}
}

View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\ManagedTenants;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\ManagedTenants\ManagedTenantContext;
use App\Support\Rbac\UiEnforcement;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use UnitEnum;
class ViewManagedTenant extends Page
{
protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'managed-tenants/{managedTenant}';
protected static ?string $title = 'Managed tenant';
protected string $view = 'filament.pages.managed-tenants.view';
public Tenant $tenant;
public function mount(string $managedTenant): void
{
$this->tenant = Tenant::withTrashed()->findOrFail($managedTenant);
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $this->tenant)) {
abort(404);
}
if (! $resolver->can($user, $this->tenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
abort(403);
}
if (! $this->tenant->isActive()) {
ManagedTenantContext::setArchivedTenant($this->tenant);
$this->redirect(ArchivedStatus::getUrl());
}
}
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [
UiEnforcement::forTableAction(
Action::make('open')
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (): string => "/admin/managed-tenants/{$this->tenant->getKey()}/open"),
fn () => $this->tenant,
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
->apply(),
UiEnforcement::forTableAction(
Action::make('edit')
->label('Edit')
->url(fn (): string => EditManagedTenant::getUrl(['managedTenant' => $this->tenant])),
fn () => $this->tenant,
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
->apply(),
];
}
}

View File

@ -60,7 +60,9 @@ class TenantResource extends Resource
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
protected static string|UnitEnum|null $navigationGroup = 'Managed tenants';
protected static ?string $navigationLabel = 'Managed tenants';
public static function canCreate(): bool
{
@ -85,7 +87,7 @@ public static function canEdit(Model $record): bool
$resolver = app(CapabilityResolver::class);
return $record instanceof Tenant
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_MANAGE);
}
public static function canDelete(Model $record): bool
@ -100,7 +102,7 @@ public static function canDelete(Model $record): bool
$resolver = app(CapabilityResolver::class);
return $record instanceof Tenant
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE);
}
public static function canDeleteAny(): bool
@ -118,14 +120,14 @@ private static function userCanManageAnyTenant(User $user): bool
{
return $user->tenantMemberships()
->pluck('role')
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGED_TENANTS_CREATE));
}
private static function userCanDeleteAnyTenant(User $user): bool
{
return $user->tenantMemberships()
->pluck('role')
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE));
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE));
}
public static function form(Schema $schema): Schema
@ -260,6 +262,11 @@ public static function table(Table $table): Table
])
->actions([
ActionGroup::make([
Actions\Action::make('open')
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): string => "/admin/managed-tenants/{$record->getKey()}/open")
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW),
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
@ -390,7 +397,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
)
->requireCapability(Capabilities::TENANT_MANAGE)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('restore')
@ -410,7 +417,7 @@ public static function table(Table $table): Table
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_RESTORE)) {
abort(403);
}
@ -427,7 +434,7 @@ public static function table(Table $table): Table
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_RESTORE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('admin_consent')
@ -497,7 +504,7 @@ public static function table(Table $table): Table
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)) {
abort(403);
}
@ -520,7 +527,7 @@ public static function table(Table $table): Table
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
@ -543,7 +550,7 @@ public static function table(Table $table): Table
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)) {
abort(403);
}
@ -576,7 +583,7 @@ public static function table(Table $table): Table
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)
->apply(),
]),
])

View File

@ -10,6 +10,11 @@ class CreateTenant extends CreateRecord
{
protected static string $resource = TenantResource::class;
public function mount(): void
{
$this->redirect('/admin/managed-tenants/onboarding');
}
protected function afterCreate(): void
{
$user = auth()->user();

View File

@ -28,8 +28,8 @@ protected function getHeaderActions(): array
$record->delete();
})
)
->requireCapability(Capabilities::TENANT_DELETE)
->tooltip('You do not have permission to archive tenants.')
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)
->tooltip('You do not have permission to archive managed tenants.')
->preserveVisibility()
->destructive()
->apply(),

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\User;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
@ -10,10 +11,24 @@ class ListTenants extends ListRecords
{
protected static string $resource = TenantResource::class;
public function mount(): void
{
parent::mount();
$user = auth()->user();
if ($user instanceof User && ! $user->tenantMemberships()->exists()) {
abort(404);
}
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
Actions\Action::make('add_managed_tenant')
->label('Add managed tenant')
->icon('heroicon-o-plus')
->url('/admin/managed-tenants/onboarding')
->disabled(fn (): bool => ! TenantResource::canCreate())
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
];

View File

@ -30,6 +30,14 @@ protected function getHeaderActions(): array
{
return [
Actions\ActionGroup::make([
UiEnforcement::forAction(
Actions\Action::make('open_managed_tenant')
->label('Open')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record): string => "/admin/managed-tenants/{$record->getKey()}/open")
)
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')

View File

@ -33,6 +33,9 @@
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions\Action as FilamentAction;
use Filament\Actions\BulkAction as FilamentBulkAction;
use Filament\Events\TenantSet;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
@ -84,6 +87,28 @@ public function register(): void
*/
public function boot(): void
{
if (! FilamentAction::hasMacro('requireCapability')) {
FilamentAction::macro('requireCapability', function (string $capability): FilamentAction {
UiEnforcement::forAction($this)
->preserveVisibility()
->requireCapability($capability)
->apply();
return $this;
});
}
if (! FilamentBulkAction::hasMacro('requireCapability')) {
FilamentBulkAction::macro('requireCapability', function (string $capability): FilamentBulkAction {
UiEnforcement::forBulkAction($this)
->preserveVisibility()
->requireCapability($capability)
->apply();
return $this;
});
}
RateLimiter::for('entra-callback', function (Request $request) {
return Limit::perMinute(20)->by((string) $request->ip());
});

View File

@ -4,11 +4,21 @@
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ManagedTenants\ArchivedStatus;
use App\Filament\Pages\ManagedTenants\Current;
use App\Filament\Pages\ManagedTenants\EditManagedTenant;
use App\Filament\Pages\ManagedTenants\Index as ManagedTenantsIndex;
use App\Filament\Pages\ManagedTenants\Onboarding;
use App\Filament\Pages\ManagedTenants\ViewManagedTenant;
use App\Filament\Pages\NoAccess;
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Middleware\DenyNonMemberTenantAccess;
use App\Support\Middleware\EnsureFilamentTenantSelected;
use App\Support\Auth\Capabilities;
use App\Support\ManagedTenants\ManagedTenantContext;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
@ -23,6 +33,7 @@
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Route;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
@ -39,12 +50,57 @@ public function panel(Panel $panel): Panel
->authenticatedRoutes(function (Panel $panel): void {
ChooseTenant::registerRoutes($panel);
NoAccess::registerRoutes($panel);
ManagedTenantsIndex::registerRoutes($panel);
Onboarding::registerRoutes($panel);
Current::registerRoutes($panel);
ArchivedStatus::registerRoutes($panel);
ViewManagedTenant::registerRoutes($panel);
EditManagedTenant::registerRoutes($panel);
Route::get('managed-tenants/{managedTenant}/open', function (string $managedTenant) {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->tenantMemberships()->exists()) {
abort(404);
}
$managedTenant = Tenant::withTrashed()->findOrFail($managedTenant);
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $managedTenant)) {
abort(404);
}
if (! $resolver->can($user, $managedTenant, Capabilities::TENANT_MANAGED_TENANTS_VIEW)) {
abort(403);
}
if ($managedTenant->isActive()) {
ManagedTenantContext::setCurrentTenant($managedTenant);
ManagedTenantContext::clearArchivedTenant();
return redirect('/admin/managed-tenants/current');
}
ManagedTenantContext::setArchivedTenant($managedTenant);
return redirect('/admin/managed-tenants/archived');
});
Route::redirect('new', '/admin/managed-tenants/onboarding');
})
->tenant(Tenant::class, slugAttribute: 'external_id')
->tenantRoutePrefix('t')
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
->searchableTenantMenu()
->tenantRegistration(RegisterTenant::class)
->colors([
'primary' => Color::Amber,
])
@ -79,6 +135,7 @@ public function panel(Panel $panel): Panel
VerifyCsrfToken::class,
SubstituteBindings::class,
'ensure-correct-guard:web',
EnsureFilamentTenantSelected::class,
DenyNonMemberTenantAccess::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,

View File

@ -19,6 +19,13 @@ class RoleCapabilityMap
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_DELETE,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
Capabilities::TENANT_MANAGED_TENANTS_CREATE,
Capabilities::TENANT_MANAGED_TENANTS_MANAGE,
Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE,
Capabilities::TENANT_MANAGED_TENANTS_RESTORE,
Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
@ -42,6 +49,12 @@ class RoleCapabilityMap
Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
Capabilities::TENANT_MANAGED_TENANTS_CREATE,
Capabilities::TENANT_MANAGED_TENANTS_MANAGE,
Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE,
Capabilities::TENANT_MANAGED_TENANTS_RESTORE,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
@ -62,6 +75,8 @@ class RoleCapabilityMap
TenantRole::Operator->value => [
Capabilities::TENANT_VIEW,
Capabilities::TENANT_SYNC,
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
@ -79,6 +94,8 @@ class RoleCapabilityMap
TenantRole::Readonly->value => [
Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_ROLE_MAPPING_VIEW,

View File

@ -24,6 +24,19 @@ class Capabilities
public const TENANT_SYNC = 'tenant.sync';
// Managed tenants (tenantless CRUD + onboarding)
public const TENANT_MANAGED_TENANTS_VIEW = 'tenant_managed_tenants.view';
public const TENANT_MANAGED_TENANTS_CREATE = 'tenant_managed_tenants.create';
public const TENANT_MANAGED_TENANTS_MANAGE = 'tenant_managed_tenants.manage';
public const TENANT_MANAGED_TENANTS_ARCHIVE = 'tenant_managed_tenants.archive';
public const TENANT_MANAGED_TENANTS_RESTORE = 'tenant_managed_tenants.restore';
public const TENANT_MANAGED_TENANTS_FORCE_DELETE = 'tenant_managed_tenants.force_delete';
// Inventory
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Support\ManagedTenants;
use App\Models\Tenant;
use Illuminate\Support\Facades\Session;
final class ManagedTenantContext
{
public const CURRENT_TENANT_ID_SESSION_KEY = 'managed_tenants.current_id';
public const ARCHIVED_TENANT_ID_SESSION_KEY = 'managed_tenants.archived_id';
public static function setCurrentTenant(Tenant $tenant): void
{
Session::put(self::CURRENT_TENANT_ID_SESSION_KEY, (int) $tenant->getKey());
Session::forget(self::ARCHIVED_TENANT_ID_SESSION_KEY);
}
public static function setArchivedTenant(Tenant $tenant): void
{
Session::put(self::ARCHIVED_TENANT_ID_SESSION_KEY, (int) $tenant->getKey());
}
public static function currentTenantId(): ?int
{
$id = Session::get(self::CURRENT_TENANT_ID_SESSION_KEY);
if (is_int($id)) {
return $id;
}
if (is_string($id) && ctype_digit($id)) {
return (int) $id;
}
return null;
}
public static function archivedTenantId(): ?int
{
$id = Session::get(self::ARCHIVED_TENANT_ID_SESSION_KEY);
if (is_int($id)) {
return $id;
}
if (is_string($id) && ctype_digit($id)) {
return (int) $id;
}
return null;
}
public static function currentTenant(): ?Tenant
{
$id = self::currentTenantId();
if (! is_int($id)) {
return null;
}
$tenant = Tenant::query()->find($id);
if (! $tenant?->isActive()) {
self::clearCurrentTenant();
return null;
}
return $tenant;
}
public static function archivedTenant(): ?Tenant
{
$id = self::archivedTenantId();
if (! is_int($id)) {
return null;
}
return Tenant::withTrashed()->find($id);
}
public static function clear(): void
{
self::clearCurrentTenant();
self::clearArchivedTenant();
}
public static function clearCurrentTenant(): void
{
Session::forget(self::CURRENT_TENANT_ID_SESSION_KEY);
}
public static function clearArchivedTenant(): void
{
Session::forget(self::ARCHIVED_TENANT_ID_SESSION_KEY);
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Support\Middleware;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use Closure;
use Filament\Facades\Filament;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureFilamentTenantSelected
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
if (filled(Filament::getTenant())) {
return $next($request);
}
$routeTenant = $request->route()?->parameter('tenant');
if ($routeTenant instanceof Tenant) {
Filament::setTenant($routeTenant);
return $next($request);
}
$user = $request->user();
if (! $user instanceof User) {
return $next($request);
}
$tenant = null;
try {
$tenant = Tenant::current();
} catch (\RuntimeException) {
$tenant = null;
}
if ($tenant instanceof Tenant && ! app(CapabilityResolver::class)->isMember($user, $tenant)) {
$tenant = null;
}
if (! $tenant) {
$tenant = $user->tenants()
->whereNull('deleted_at')
->where('status', 'active')
->first();
}
if (! $tenant) {
$tenant = $user->tenants()
->whereNull('deleted_at')
->first();
}
if (! $tenant) {
$tenant = $user->tenants()
->withTrashed()
->first();
}
if ($tenant) {
Filament::setTenant($tenant, true);
}
return $next($request);
}
}

View File

@ -20,6 +20,7 @@
<php>
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:ej4OCgTfaJuqfgkVtofS3hJlmskyw8nM1bRPbLoXEwk="/>
<env name="INTUNE_TENANT_ID" value="" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>

View File

@ -0,0 +1,37 @@
<x-filament::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Archived managed tenant
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
This managed tenant is archived or deactivated. You can restore it (if authorized) or force delete it.
</div>
@if ($this->tenant)
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Name</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ $this->tenant->name }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Tenant ID</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ $this->tenant->tenant_id }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Environment</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ strtoupper((string) $this->tenant->environment) }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Status</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ $this->tenant->status }}</dd>
</div>
</dl>
@endif
</div>
</x-filament::section>
</x-filament::page>

View File

@ -0,0 +1,41 @@
<x-filament::page>
<x-filament::section>
<div class="flex flex-col gap-4">
@if (! $this->tenant)
<div class="text-sm text-gray-600 dark:text-gray-300">
No managed tenant is currently selected.
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Use the managed tenants list to open one.
</div>
@else
<div class="text-sm text-gray-600 dark:text-gray-300">
Current managed tenant selection.
</div>
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Name</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ $this->tenant->name }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Tenant ID</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ $this->tenant->tenant_id }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Environment</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ strtoupper((string) $this->tenant->environment) }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Status</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ $this->tenant->status }}</dd>
</div>
</dl>
@endif
</div>
</x-filament::section>
</x-filament::page>

View File

@ -0,0 +1,15 @@
<x-filament::page>
<div class="flex flex-col gap-6">
<x-filament::section>
<form wire:submit="save" class="flex flex-col gap-6">
{{ $this->form }}
<div class="flex justify-end">
<x-filament::button type="submit" color="primary">
Save
</x-filament::button>
</div>
</form>
</x-filament::section>
</div>
</x-filament::page>

View File

@ -0,0 +1,3 @@
<x-filament::page>
{{ $this->table }}
</x-filament::page>

View File

@ -0,0 +1,21 @@
<x-filament::page>
<div class="flex flex-col gap-6">
<x-filament::section>
<div class="flex flex-col gap-6">
<div class="text-sm text-gray-600 dark:text-gray-300">
Register a new managed tenant in TenantPilot. After creation, you will automatically be assigned the <span class="font-medium">Owner</span> role for that tenant.
</div>
<form wire:submit="create" class="flex flex-col gap-6">
{{ $this->form }}
<div class="flex justify-end">
<x-filament::button type="submit" color="primary">
Add managed tenant
</x-filament::button>
</div>
</form>
</div>
</x-filament::section>
</div>
</x-filament::page>

View File

@ -0,0 +1,31 @@
<x-filament::page>
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="text-sm text-gray-600 dark:text-gray-300">
Managed tenant details.
</div>
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Name</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ $this->tenant->name }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Tenant ID</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ $this->tenant->tenant_id }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Environment</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ strtoupper((string) $this->tenant->environment) }}</dd>
</div>
<div>
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Status</dt>
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ $this->tenant->status }}</dd>
</div>
</dl>
</div>
</x-filament::section>
</x-filament::page>

View File

@ -25,21 +25,20 @@ ## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Confirm local tooling and repo structure for safe iteration.
- [ ] T001 Verify Sail app boots and admin routes resolve using docker-compose.yml and routes/web.php
- [ ] T002 [P] Identify existing managed-tenant CRUD entry points to de-duplicate using app/Filament/Resources/TenantResource.php and app/Filament/Resources/TenantResource/Pages/ListTenants.php
- [x] T001 Verify Sail app boots and admin routes resolve using docker-compose.yml and routes/web.php
- [x] T002 [P] Identify existing managed-tenant CRUD entry points to de-duplicate using app/Filament/Resources/TenantResource.php and app/Filament/Resources/TenantResource/Pages/ListTenants.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: RBAC capability primitives and shared context helpers needed by all user stories.
- [ ] T003 Add managed-tenant capability constants to app/Support/Auth/Capabilities.php
- [ ] T004 Update default role-to-capability mapping for managed-tenant capabilities in app/Services/Auth/RoleCapabilityMap.php
- [ ] T005 [P] Ensure Gate registration picks up new capabilities (and add regression assertions if needed) in app/Providers/AuthServiceProvider.php
- [ ] T006 [P] Add/extend capability registry tests for managed-tenant capabilities in tests/Unit/Auth/CapabilitiesRegistryTest.php
- [ ] T007 [P] Add/extend “unknown capability” guard coverage for managed-tenant capabilities usage in tests/Unit/Auth/UnknownCapabilityGuardTest.php
- [ ] T008 [P] Add/extend UI enforcement unit coverage for managed-tenant actions (disabled vs hidden vs executable) in tests/Unit/Support/Rbac/UiEnforcementTest.php
- [x] T003 Add managed-tenant capability constants to app/Support/Auth/Capabilities.php
- [x] T004 Update default role-to-capability mapping for managed-tenant capabilities in app/Services/Auth/RoleCapabilityMap.php
- [x] T005 [P] Ensure Gate registration picks up new capabilities (and add regression assertions if needed) in app/Providers/AuthServiceProvider.php
- [x] T006 [P] Add/extend capability registry tests for managed-tenant capabilities in tests/Unit/Auth/CapabilitiesRegistryTest.php
- [x] T007 [P] Add/extend “unknown capability” guard coverage for managed-tenant capabilities usage in tests/Unit/Auth/UnknownCapabilityGuardTest.php
- [x] T008 [P] Add/extend UI enforcement unit coverage for managed-tenant actions (disabled vs hidden vs executable) in tests/Unit/Support/Rbac/UiEnforcementTest.php
**Checkpoint**: Capability strings exist, role mapping covers them, and unit tests enforce the registry.
@ -55,18 +54,18 @@ ## Phase 3: User Story 1 — Add managed tenant via single front door (Priority:
### Tests for User Story 1
- [ ] T009 [P] [US1] Add feature test for legacy redirect `/admin/new``/admin/managed-tenants/onboarding` in tests/Feature/ManagedTenants/OnboardingRedirectTest.php
- [ ] T010 [P] [US1] Add feature test that onboarding page is reachable for authorized users in tests/Feature/ManagedTenants/OnboardingPageTest.php
- [ ] T011 [P] [US1] Add feature test ensuring the tenants list exposes only one onboarding CTA in tests/Feature/ManagedTenants/SingleEntryPointTest.php
- [x] T009 [P] [US1] Add feature test for legacy redirect `/admin/new``/admin/managed-tenants/onboarding` in tests/Feature/ManagedTenants/OnboardingRedirectTest.php
- [x] T010 [P] [US1] Add feature test that onboarding page is reachable for authorized users in tests/Feature/ManagedTenants/OnboardingPageTest.php
- [x] T011 [P] [US1] Add feature test ensuring the tenants list exposes only one onboarding CTA in tests/Feature/ManagedTenants/SingleEntryPointTest.php
### Implementation for User Story 1
- [ ] T012 [US1] Create tenantless onboarding Filament page class in app/Filament/Pages/ManagedTenants/Onboarding.php
- [ ] T013 [P] [US1] Create onboarding page Blade view in resources/views/filament/pages/managed-tenants/onboarding.blade.php
- [ ] T014 [US1] Register canonical onboarding route `/admin/managed-tenants/onboarding` via panel authenticated routes in app/Providers/Filament/AdminPanelProvider.php
- [ ] T015 [US1] Register legacy redirect `/admin/new` → canonical onboarding in app/Providers/Filament/AdminPanelProvider.php
- [ ] T016 [US1] Update tenants list header action to a single labeled CTA (“Add managed tenant”) and ensure it links to canonical onboarding in app/Filament/Resources/TenantResource/Pages/ListTenants.php
- [ ] T017 [US1] Redirect the resource create route (if still reachable) to canonical onboarding to avoid a second onboarding entry point in app/Filament/Resources/TenantResource/Pages/CreateTenant.php
- [x] T012 [US1] Create tenantless onboarding Filament page class in app/Filament/Pages/ManagedTenants/Onboarding.php
- [x] T013 [P] [US1] Create onboarding page Blade view in resources/views/filament/pages/managed-tenants/onboarding.blade.php
- [x] T014 [US1] Register canonical onboarding route `/admin/managed-tenants/onboarding` via panel authenticated routes in app/Providers/Filament/AdminPanelProvider.php
- [x] T015 [US1] Register legacy redirect `/admin/new` → canonical onboarding in app/Providers/Filament/AdminPanelProvider.php
- [x] T016 [US1] Update tenants list header action to a single labeled CTA (“Add managed tenant”) and ensure it links to canonical onboarding in app/Filament/Resources/TenantResource/Pages/ListTenants.php
- [x] T017 [US1] Redirect the resource create route (if still reachable) to canonical onboarding to avoid a second onboarding entry point in app/Filament/Resources/TenantResource/Pages/CreateTenant.php
**Checkpoint**: US1 complete — one front door, legacy redirect works, tests pass.
@ -82,16 +81,16 @@ ## Phase 4: User Story 2 — Manage tenants without tenant-in-tenant navigation
### Tests for User Story 2
- [ ] T018 [P] [US2] Add feature test that managed-tenant list route is tenantless (no `/t/` prefix) in tests/Feature/ManagedTenants/TenantlessRoutesTest.php
- [ ] T019 [P] [US2] Add feature test that managed-tenant view route is tenantless (no `/t/` prefix) in tests/Feature/ManagedTenants/TenantlessRoutesTest.php
- [ ] T020 [P] [US2] Add feature test that managed-tenant edit route is tenantless (no `/t/` prefix) in tests/Feature/ManagedTenants/TenantlessRoutesTest.php
- [X] T018 [P] [US2] Add feature test that managed-tenant list route is tenantless (no `/t/` prefix) in tests/Feature/ManagedTenants/TenantlessRoutesTest.php
- [X] T019 [P] [US2] Add feature test that managed-tenant view route is tenantless (no `/t/` prefix) in tests/Feature/ManagedTenants/TenantlessRoutesTest.php
- [X] T020 [P] [US2] Add feature test that managed-tenant edit route is tenantless (no `/t/` prefix) in tests/Feature/ManagedTenants/TenantlessRoutesTest.php
### Implementation for User Story 2
- [ ] T021 [US2] Confirm resource is tenantless-scoped and keep it that way (`$isScopedToTenant = false`) in app/Filament/Resources/TenantResource.php
- [ ] T022 [US2] Ensure navigation label/grouping uses “Managed tenants” terminology in app/Filament/Resources/TenantResource.php
- [ ] T023 [US2] Ensure direct URL access to view/edit enforces RBAC-UX (non-member → 404, member missing capability → 403) in app/Filament/Resources/TenantResource.php
- [ ] T023a [P] [US2] Add feature test: member without `tenant_managed_tenants.manage` receives 403 when attempting to access the managed-tenant edit page in tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php
- [X] T021 [US2] Confirm resource is tenantless-scoped and keep it that way (`$isScopedToTenant = false`) in app/Filament/Resources/TenantResource.php
- [X] T022 [US2] Ensure navigation label/grouping uses “Managed tenants” terminology in app/Filament/Resources/TenantResource.php
- [X] T023 [US2] Ensure direct URL access to view/edit enforces RBAC-UX (non-member → 404, member missing capability → 403) via tenantless managed-tenant pages in app/Filament/Pages/ManagedTenants/
- [X] T023a [P] [US2] Add feature test: member without `tenant_managed_tenants.manage` receives 403 when attempting to access the managed-tenant edit page in tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php
**Checkpoint**: US2 complete — tenantless management routes and consistent auth semantics.
@ -108,22 +107,22 @@ ## Phase 5: User Story 3 — Open an active tenant context and handle archived t
### Tests for User Story 3
- [ ] T024 [P] [US3] Add feature test: “Open” on active tenant sets session selection and redirects deterministically in tests/Feature/ManagedTenants/OpenActiveTenantTest.php
- [ ] T025 [P] [US3] Add feature test: “Open” on archived tenant shows status page (not 404) in tests/Feature/ManagedTenants/OpenArchivedTenantTest.php
- [ ] T026 [P] [US3] Add feature test: archived status page hides or disables restore/force-delete when unauthorized in tests/Feature/ManagedTenants/ArchivedActionsAuthorizationTest.php
- [ ] T027 [P] [US3] Add feature test: restore/force-delete require confirmation and are server-side blocked when unauthorized in tests/Feature/ManagedTenants/ArchivedActionsAuthorizationTest.php
- [X] T024 [P] [US3] Add feature test: “Open” on active tenant sets session selection and redirects deterministically in tests/Feature/ManagedTenants/OpenActiveTenantTest.php
- [X] T025 [P] [US3] Add feature test: “Open” on archived tenant shows status page (not 404) in tests/Feature/ManagedTenants/OpenArchivedTenantTest.php
- [X] T026 [P] [US3] Add feature test: archived status page hides or disables restore/force-delete when unauthorized in tests/Feature/ManagedTenants/ArchivedActionsAuthorizationTest.php
- [X] T027 [P] [US3] Add feature test: restore/force-delete require confirmation and are server-side blocked when unauthorized in tests/Feature/ManagedTenants/ArchivedActionsAuthorizationTest.php
### Implementation for User Story 3
- [ ] T028 [P] [US3] Implement session-backed managed-tenant context helper (get/set/clear) in app/Support/ManagedTenants/ManagedTenantContext.php
- [ ] T029 [US3] Create deterministic tenantless “current managed tenant” landing page in app/Filament/Pages/ManagedTenants/Current.php
- [ ] T030 [P] [US3] Create landing page Blade view showing selected tenant identity in resources/views/filament/pages/managed-tenants/current.blade.php
- [ ] T031 [US3] Create archived status Filament page in app/Filament/Pages/ManagedTenants/ArchivedStatus.php
- [ ] T032 [P] [US3] Create archived status Blade view in resources/views/filament/pages/managed-tenants/archived-status.blade.php
- [ ] T032a [US3] Register tenantless page routes for “current” and “archived status” in app/Providers/Filament/AdminPanelProvider.php (ensure paths match spec: `/admin/managed-tenants/current` and `/admin/managed-tenants/archived`)
- [ ] T033 [US3] Add “Open” table action on managed-tenant list, authorized by `tenant_managed_tenants.view`, with archived handling in app/Filament/Resources/TenantResource.php
- [ ] T034 [US3] Add “Open” action on the managed-tenant view page (if present) with same behavior in app/Filament/Resources/TenantResource/Pages/ViewTenant.php
- [ ] T035 [US3] Add restore + force-delete actions on archived status page with `->action(...)` + `->requiresConfirmation()` + server-side auth checks in app/Filament/Pages/ManagedTenants/ArchivedStatus.php
- [X] T028 [P] [US3] Implement session-backed managed-tenant context helper (get/set/clear) in app/Support/ManagedTenants/ManagedTenantContext.php
- [X] T029 [US3] Create deterministic tenantless “current managed tenant” landing page in app/Filament/Pages/ManagedTenants/Current.php
- [X] T030 [P] [US3] Create landing page Blade view showing selected tenant identity in resources/views/filament/pages/managed-tenants/current.blade.php
- [X] T031 [US3] Create archived status Filament page in app/Filament/Pages/ManagedTenants/ArchivedStatus.php
- [X] T032 [P] [US3] Create archived status Blade view in resources/views/filament/pages/managed-tenants/archived-status.blade.php
- [X] T032a [US3] Register tenantless page routes for “current” and “archived status” in app/Providers/Filament/AdminPanelProvider.php (ensure paths match spec: `/admin/managed-tenants/current` and `/admin/managed-tenants/archived`)
- [X] T033 [US3] Add “Open” table action on managed-tenant list, authorized by `tenant_managed_tenants.view`, with archived handling in app/Filament/Resources/TenantResource.php
- [X] T034 [US3] Add “Open” action on the managed-tenant view page (if present) with same behavior in app/Filament/Resources/TenantResource/Pages/ViewTenant.php
- [X] T035 [US3] Add restore + force-delete actions on archived status page with `->action(...)` + `->requiresConfirmation()` + server-side auth checks in app/Filament/Pages/ManagedTenants/ArchivedStatus.php
**Checkpoint**: US3 complete — “Open” is deterministic for active tenants and safe/clear for archived tenants.
@ -133,9 +132,9 @@ ## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Hardening, consistency, and developer ergonomics.
- [ ] T036 [P] Ensure all destructive-like actions use `->action(...)` + `->requiresConfirmation()` consistently in app/Filament/Resources/TenantResource.php
- [ ] T037 [P] Run Pint on changed files to match repo style in composer.json (via `vendor/bin/sail bin pint --dirty`)
- [ ] T038 Run targeted Pest suite for this feature in tests/Feature/ManagedTenants/
- [x] T036 [P] Ensure all destructive-like actions use `->action(...)` + `->requiresConfirmation()` consistently in app/Filament/Resources/TenantResource.php
- [x] T037 [P] Run Pint on changed files to match repo style in composer.json (via `vendor/bin/sail bin pint --dirty`)
- [X] T038 Run targeted Pest suite for this feature in tests/Feature/ManagedTenants/
---

View File

@ -11,6 +11,8 @@
uses(RefreshDatabase::class);
test('policy sync updates selected policies from graph and updates the operation run', function () {
config()->set('graph.enabled', true);
$tenant = Tenant::factory()->create([
'status' => 'active',
]);

View File

@ -1,6 +1,6 @@
<?php
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use App\Filament\Pages\ManagedTenants\Onboarding;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\Tenant;
use App\Models\TenantPermission;
@ -64,7 +64,7 @@ public function request(string $method, string $path, array $options = []): Grap
]);
Filament::setTenant($contextTenant, true);
Livewire::test(CreateTenant::class)
Livewire::test(Onboarding::class)
->fillForm([
'name' => 'Contoso',
'environment' => 'other',

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\ManagedTenants\ArchivedStatus;
use App\Models\Tenant;
use App\Support\ManagedTenants\ManagedTenantContext;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('disables restore and force-delete actions for members without permission and does not execute them', function (): void {
$activeTenant = Tenant::factory()->create(['status' => 'active']);
$archivedTenant = Tenant::factory()->create(['status' => 'active']);
[$user] = createUserWithTenant($activeTenant, role: 'owner');
$user->tenants()->syncWithoutDetaching([
$archivedTenant->getKey() => [
'role' => 'readonly',
'source' => 'manual',
'created_by_user_id' => $user->getKey(),
],
]);
$archivedTenant->delete();
$this->actingAs($user);
$activeTenant->makeCurrent();
Filament::setTenant($activeTenant, true);
$this->withSession([
ManagedTenantContext::ARCHIVED_TENANT_ID_SESSION_KEY => $archivedTenant->getKey(),
]);
Livewire::test(ArchivedStatus::class)
->assertActionVisible('restore')
->assertActionDisabled('restore')
->assertActionExists('restore', fn (Action $action): bool => $action->isConfirmationRequired())
->assertActionVisible('force_delete')
->assertActionDisabled('force_delete')
->assertActionExists('force_delete', fn (Action $action): bool => $action->isConfirmationRequired())
->mountAction('restore')
->callMountedAction()
->assertSuccessful();
expect(Tenant::withTrashed()->find($archivedTenant->getKey())?->trashed())->toBeTrue();
Livewire::test(ArchivedStatus::class)
->mountAction('force_delete')
->callMountedAction()
->assertSuccessful();
expect(Tenant::withTrashed()->find($archivedTenant->getKey()))->not->toBeNull();
});

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
it('returns 403 for a member without managed-tenant manage capability when accessing edit', function (): void {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'readonly');
$this->actingAs($user)
->get("/admin/managed-tenants/{$tenant->id}/edit")
->assertForbidden();
});
it('returns 404 for a non-member attempting to access the managed-tenant list', function (): void {
$tenant = Tenant::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->get('/admin/managed-tenants')
->assertNotFound();
});

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
it('allows authorized users to reach the managed-tenant onboarding page', function (): void {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user)
->get('/admin/managed-tenants/onboarding')
->assertOk()
->assertSee('Add managed tenant');
});

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
it('redirects /admin/new to the canonical managed-tenant onboarding page', function (): void {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user)
->get('/admin/new')
->assertRedirect('/admin/managed-tenants/onboarding');
});

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Support\ManagedTenants\ManagedTenantContext;
it('stores session selection and redirects deterministically when opening an active managed tenant', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user] = createUserWithTenant($tenant, role: 'readonly');
$this->actingAs($user)
->get("/admin/managed-tenants/{$tenant->id}/open")
->assertRedirect('/admin/managed-tenants/current')
->assertSessionHas(ManagedTenantContext::CURRENT_TENANT_ID_SESSION_KEY, $tenant->id);
$this->withSession([
ManagedTenantContext::CURRENT_TENANT_ID_SESSION_KEY => $tenant->id,
])->actingAs($user)
->get('/admin/managed-tenants/current')
->assertOk()
->assertSee($tenant->name);
});

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Support\ManagedTenants\ManagedTenantContext;
it('shows a status screen (not 404) when opening an archived managed tenant', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user] = createUserWithTenant($tenant, role: 'readonly');
$tenant->delete();
$this->actingAs($user)
->get("/admin/managed-tenants/{$tenant->id}/open")
->assertRedirect('/admin/managed-tenants/archived')
->assertSessionHas(ManagedTenantContext::ARCHIVED_TENANT_ID_SESSION_KEY, $tenant->id);
$this->withSession([
ManagedTenantContext::ARCHIVED_TENANT_ID_SESSION_KEY => $tenant->id,
])->actingAs($user)
->get('/admin/managed-tenants/archived')
->assertOk()
->assertSee('Archived')
->assertSee($tenant->name);
});

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
it('shows only the canonical onboarding entry point on the managed-tenant list', function (): void {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'owner');
$response = $this->actingAs($user)
->get('/admin/managed-tenants')
->assertOk();
$response->assertSee('/admin/managed-tenants/onboarding');
$response->assertDontSee('/admin/tenants/create');
});

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
it('serves the managed-tenant list route without a tenant route prefix', function (): void {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user)
->get('/admin/managed-tenants')
->assertOk();
});
it('serves the managed-tenant view route without a tenant route prefix', function (): void {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user)
->get("/admin/managed-tenants/{$tenant->id}")
->assertOk();
});
it('serves the managed-tenant edit route without a tenant route prefix', function (): void {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'owner');
$this->actingAs($user)
->get("/admin/managed-tenants/{$tenant->id}/edit")
->assertOk();
});

View File

@ -9,7 +9,7 @@
use Livewire\Livewire;
describe('Edit tenant archive action UI enforcement', function () {
it('shows archive action as visible but disabled for manager members', function () {
it('allows manager members to archive tenant', function () {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant(tenant: $tenant, role: 'manager');
@ -20,16 +20,13 @@
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
->assertActionVisible('archive')
->assertActionDisabled('archive')
->assertActionExists('archive', function (Action $action): bool {
return $action->getTooltip() === 'You do not have permission to archive tenants.';
})
->assertActionEnabled('archive')
->mountAction('archive')
->callMountedAction()
->assertSuccessful();
->assertHasNoActionErrors();
$tenant->refresh();
expect($tenant->trashed())->toBeFalse();
expect($tenant->trashed())->toBeTrue();
});
it('allows owner members to archive tenant', function () {

View File

@ -1,7 +1,7 @@
<?php
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use App\Filament\Pages\ManagedTenants\Onboarding;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -25,6 +25,6 @@
$this->actingAs($user);
Livewire::actingAs($user)
->test(CreateTenant::class)
->test(Onboarding::class)
->assertStatus(403);
});

View File

@ -94,6 +94,10 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string
$tenant->getKey() => ['role' => $role],
]);
if (! $tenant->trashed() && $tenant->status === 'active') {
$tenant->makeCurrent();
}
return [$user, $tenant];
}

View File

@ -77,7 +77,7 @@
->action(fn () => null);
$enforcement = UiEnforcement::forAction($action)
->requireCapability(Capabilities::PROVIDER_MANAGE);
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW);
expect($enforcement)->toBeInstanceOf(UiEnforcement::class);
});