Merge remote-tracking branch 'origin/068-workspace-foundation-v1' into feat/999-merge-integration-session-1769990000
This commit is contained in:
commit
14daee6964
131
app/Filament/Pages/ManagedTenants/ArchivedStatus.php
Normal file
131
app/Filament/Pages/ManagedTenants/ArchivedStatus.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/Filament/Pages/ManagedTenants/Current.php
Normal file
98
app/Filament/Pages/ManagedTenants/Current.php
Normal 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()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/Filament/Pages/ManagedTenants/EditManagedTenant.php
Normal file
148
app/Filament/Pages/ManagedTenants/EditManagedTenant.php
Normal 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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
146
app/Filament/Pages/ManagedTenants/Index.php
Normal file
146
app/Filament/Pages/ManagedTenants/Index.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
app/Filament/Pages/ManagedTenants/Onboarding.php
Normal file
179
app/Filament/Pages/ManagedTenants/Onboarding.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Filament/Pages/ManagedTenants/ViewManagedTenant.php
Normal file
86
app/Filament/Pages/ManagedTenants/ViewManagedTenant.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -60,7 +60,9 @@ class TenantResource extends Resource
|
|||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
|
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
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
@ -85,7 +87,7 @@ public static function canEdit(Model $record): bool
|
|||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return $record instanceof Tenant
|
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
|
public static function canDelete(Model $record): bool
|
||||||
@ -100,7 +102,7 @@ public static function canDelete(Model $record): bool
|
|||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
return $record instanceof Tenant
|
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
|
public static function canDeleteAny(): bool
|
||||||
@ -118,14 +120,14 @@ private static function userCanManageAnyTenant(User $user): bool
|
|||||||
{
|
{
|
||||||
return $user->tenantMemberships()
|
return $user->tenantMemberships()
|
||||||
->pluck('role')
|
->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
|
private static function userCanDeleteAnyTenant(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->tenantMemberships()
|
return $user->tenantMemberships()
|
||||||
->pluck('role')
|
->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
|
public static function form(Schema $schema): Schema
|
||||||
@ -260,6 +262,11 @@ public static function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
ActionGroup::make([
|
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')
|
Actions\Action::make('view')
|
||||||
->label('View')
|
->label('View')
|
||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
@ -390,7 +397,7 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-pencil-square')
|
->icon('heroicon-o-pencil-square')
|
||||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
@ -410,7 +417,7 @@ public static function table(Table $table): Table
|
|||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_RESTORE)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -427,7 +434,7 @@ public static function table(Table $table): Table
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_RESTORE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
@ -497,7 +504,7 @@ public static function table(Table $table): Table
|
|||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -520,7 +527,7 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)
|
||||||
->apply(),
|
->apply(),
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('forceDelete')
|
Actions\Action::make('forceDelete')
|
||||||
@ -543,7 +550,7 @@ public static function table(Table $table): Table
|
|||||||
/** @var CapabilityResolver $resolver */
|
/** @var CapabilityResolver $resolver */
|
||||||
$resolver = app(CapabilityResolver::class);
|
$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);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -576,7 +583,7 @@ public static function table(Table $table): Table
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_FORCE_DELETE)
|
||||||
->apply(),
|
->apply(),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
|
|||||||
@ -10,6 +10,11 @@ class CreateTenant extends CreateRecord
|
|||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
protected static string $resource = TenantResource::class;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->redirect('/admin/managed-tenants/onboarding');
|
||||||
|
}
|
||||||
|
|
||||||
protected function afterCreate(): void
|
protected function afterCreate(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
@ -28,8 +28,8 @@ protected function getHeaderActions(): array
|
|||||||
$record->delete();
|
$record->delete();
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_DELETE)
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_ARCHIVE)
|
||||||
->tooltip('You do not have permission to archive tenants.')
|
->tooltip('You do not have permission to archive managed tenants.')
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->destructive()
|
->destructive()
|
||||||
->apply(),
|
->apply(),
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\User;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
@ -10,10 +11,24 @@ class ListTenants extends ListRecords
|
|||||||
{
|
{
|
||||||
protected static string $resource = TenantResource::class;
|
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
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
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())
|
->disabled(fn (): bool => ! TenantResource::canCreate())
|
||||||
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
|
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -30,6 +30,14 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ActionGroup::make([
|
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(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('edit')
|
Actions\Action::make('edit')
|
||||||
->label('Edit')
|
->label('Edit')
|
||||||
|
|||||||
@ -33,6 +33,9 @@
|
|||||||
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
|
||||||
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
|
||||||
use App\Services\Intune\WindowsUpdateRingNormalizer;
|
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 Filament\Events\TenantSet;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@ -84,6 +87,28 @@ public function register(): void
|
|||||||
*/
|
*/
|
||||||
public function boot(): 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) {
|
RateLimiter::for('entra-callback', function (Request $request) {
|
||||||
return Limit::perMinute(20)->by((string) $request->ip());
|
return Limit::perMinute(20)->by((string) $request->ip());
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,11 +4,21 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\Auth\Login;
|
use App\Filament\Pages\Auth\Login;
|
||||||
use App\Filament\Pages\ChooseTenant;
|
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\NoAccess;
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
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\Facades\Filament;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
@ -23,6 +33,7 @@
|
|||||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||||
use Illuminate\Session\Middleware\StartSession;
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
@ -39,12 +50,57 @@ public function panel(Panel $panel): Panel
|
|||||||
->authenticatedRoutes(function (Panel $panel): void {
|
->authenticatedRoutes(function (Panel $panel): void {
|
||||||
ChooseTenant::registerRoutes($panel);
|
ChooseTenant::registerRoutes($panel);
|
||||||
NoAccess::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')
|
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||||
->tenantRoutePrefix('t')
|
->tenantRoutePrefix('t')
|
||||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||||
->searchableTenantMenu()
|
->searchableTenantMenu()
|
||||||
->tenantRegistration(RegisterTenant::class)
|
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'primary' => Color::Amber,
|
||||||
])
|
])
|
||||||
@ -79,6 +135,7 @@ public function panel(Panel $panel): Panel
|
|||||||
VerifyCsrfToken::class,
|
VerifyCsrfToken::class,
|
||||||
SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
'ensure-correct-guard:web',
|
'ensure-correct-guard:web',
|
||||||
|
EnsureFilamentTenantSelected::class,
|
||||||
DenyNonMemberTenantAccess::class,
|
DenyNonMemberTenantAccess::class,
|
||||||
DisableBladeIconComponents::class,
|
DisableBladeIconComponents::class,
|
||||||
DispatchServingFilamentEvent::class,
|
DispatchServingFilamentEvent::class,
|
||||||
|
|||||||
@ -19,6 +19,13 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_MANAGE,
|
Capabilities::TENANT_MANAGE,
|
||||||
Capabilities::TENANT_DELETE,
|
Capabilities::TENANT_DELETE,
|
||||||
Capabilities::TENANT_SYNC,
|
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_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
|
||||||
@ -42,6 +49,12 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_MANAGE,
|
Capabilities::TENANT_MANAGE,
|
||||||
Capabilities::TENANT_SYNC,
|
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_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
|
||||||
@ -62,6 +75,8 @@ class RoleCapabilityMap
|
|||||||
TenantRole::Operator->value => [
|
TenantRole::Operator->value => [
|
||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
|
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
|
||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
|
||||||
|
|
||||||
@ -79,6 +94,8 @@ class RoleCapabilityMap
|
|||||||
TenantRole::Readonly->value => [
|
TenantRole::Readonly->value => [
|
||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
|
|
||||||
|
Capabilities::TENANT_MANAGED_TENANTS_VIEW,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,19 @@ class Capabilities
|
|||||||
|
|
||||||
public const TENANT_SYNC = 'tenant.sync';
|
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
|
// Inventory
|
||||||
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
||||||
|
|
||||||
|
|||||||
102
app/Support/ManagedTenants/ManagedTenantContext.php
Normal file
102
app/Support/ManagedTenants/ManagedTenantContext.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Support/Middleware/EnsureFilamentTenantSelected.php
Normal file
75
app/Support/Middleware/EnsureFilamentTenantSelected.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@
|
|||||||
<php>
|
<php>
|
||||||
<ini name="memory_limit" value="512M"/>
|
<ini name="memory_limit" value="512M"/>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
|
<env name="APP_KEY" value="base64:ej4OCgTfaJuqfgkVtofS3hJlmskyw8nM1bRPbLoXEwk="/>
|
||||||
<env name="INTUNE_TENANT_ID" value="" force="true"/>
|
<env name="INTUNE_TENANT_ID" value="" force="true"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
|
|||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
<x-filament::page>
|
||||||
|
{{ $this->table }}
|
||||||
|
</x-filament::page>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
35
specs/068-workspace-foundation-v1/checklists/requirements.md
Normal file
35
specs/068-workspace-foundation-v1/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Workspace Foundation & Managed Tenant Onboarding Unification (v1)
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-01-31
|
||||||
|
**Feature**: [specs/068-workspace-foundation-v1/spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass as of 2026-01-31.
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||||
82
specs/068-workspace-foundation-v1/plan.md
Normal file
82
specs/068-workspace-foundation-v1/plan.md
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# Implementation Plan: Workspace Foundation & Managed Tenant Onboarding Unification (v1)
|
||||||
|
|
||||||
|
**Branch**: `068-workspace-foundation-v1` | **Date**: 2026-02-01 | **Spec**: ./spec.md
|
||||||
|
**Input**: Feature specification from `specs/068-workspace-foundation-v1/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Unify managed tenant onboarding behind a single canonical admin “front door” and ensure managed-tenant management stays tenantless (no tenant-in-tenant URLs).
|
||||||
|
|
||||||
|
Repo alignment note: in this codebase, “Managed tenants” are represented by the existing `App\\Models\\Tenant` model and are managed via `App\\Filament\\Resources\\TenantResource` (which already opts out of tenancy scoping via `protected static bool $isScopedToTenant = false`).
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
specs/068-workspace-foundation-v1/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
├── contracts/ # Phase 1 output
|
||||||
|
└── tasks.md # Phase 2 output (generated by /speckit.tasks)
|
||||||
|
**Project Type**: Laravel web application
|
||||||
|
**Performance Goals**: N/A (routing + RBAC UX change)
|
||||||
|
**Constraints**:
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ ├── ChooseTenant.php
|
||||||
|
│ │ ├── NoAccess.php
|
||||||
|
│ │ └── (new) ManagedTenants/*
|
||||||
|
│ └── Resources/
|
||||||
|
│ └── TenantResource.php
|
||||||
|
├── Models/
|
||||||
|
│ └── Tenant.php
|
||||||
|
├── Providers/
|
||||||
|
│ ├── AuthServiceProvider.php
|
||||||
|
│ └── Filament/AdminPanelProvider.php
|
||||||
|
├── Services/
|
||||||
|
│ └── Auth/RoleCapabilityMap.php
|
||||||
|
└── Support/
|
||||||
|
├── Auth/Capabilities.php
|
||||||
|
└── Middleware/DenyNonMemberTenantAccess.php
|
||||||
|
|
||||||
|
routes/web.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
└── Unit/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Laravel web application, implemented primarily in `app/Filament/*` and `app/Support/Auth/*`.
|
||||||
|
|
||||||
|
## Phase 0 — Outline & Research
|
||||||
|
|
||||||
|
Output: `research.md`
|
||||||
|
|
||||||
|
- Filament tenancy: confirm best-practice approach for tenantless pages and redirects inside a tenancy-enabled panel.
|
||||||
|
- Routing: confirm how to implement `/admin/new` redirect in a way that respects authentication and avoids route conflicts.
|
||||||
|
- RBAC-UX: confirm patterns to preserve 404 vs 403 semantics for managed-tenant actions and pages.
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts
|
||||||
|
|
||||||
|
Outputs: `data-model.md`, `contracts/*`, `quickstart.md`
|
||||||
|
|
||||||
|
- Data model: reuse the existing `Tenant` model to represent a “managed tenant”.
|
||||||
|
- Session state: “Open” stores the selected tenant in session only (no DB persistence).
|
||||||
|
- Contracts: no new external API or OpenAPI contracts expected for v1.
|
||||||
|
|
||||||
|
## Phase 2 — Planning (Implementation Steps)
|
||||||
|
|
||||||
|
This plan is executed via `tasks.md` (generated by `/speckit.tasks`). Implementation sequence:
|
||||||
|
|
||||||
|
1. Add canonical onboarding entry: `/admin/managed-tenants/onboarding`.
|
||||||
|
2. Add legacy redirect: `/admin/new` → canonical onboarding.
|
||||||
|
3. Ensure managed-tenant CRUD remains tenantless (no `/admin/t/{tenant}` required).
|
||||||
|
4. Implement “Open” behavior:
|
||||||
|
- If active: select tenant in session and redirect to a stable tenantless destination (e.g. `/admin/managed-tenants/current`).
|
||||||
|
- If archived: show status screen instead of selecting/redirecting.
|
||||||
|
5. Add/align capability registry entries and role mapping.
|
||||||
|
6. Add/extend Pest tests for redirects and 404/403 semantics.
|
||||||
218
specs/068-workspace-foundation-v1/spec.md
Normal file
218
specs/068-workspace-foundation-v1/spec.md
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
# Feature Specification: Workspace Foundation & Managed Tenant Onboarding Unification (v1)
|
||||||
|
|
||||||
|
**Feature Branch**: `068-workspace-foundation-v1`
|
||||||
|
**Created**: 2026-01-31
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: Consolidate Managed Tenant onboarding and routing into a single admin “front door”, remove tenant-in-tenant patterns, and standardize RBAC UX semantics for Managed Tenant flows.
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-01-31
|
||||||
|
|
||||||
|
- Q: Should `tenant_managed_tenants.open` be a separate capability? → A: No. “Open” is allowed whenever `tenant_managed_tenants.view` is allowed.
|
||||||
|
- Q: What is the canonical destination for “Open” (for an active managed tenant) in v1? → A: A stable tenantless landing page in the admin area (e.g. `/admin/managed-tenants/current`) that shows the selected managed tenant, with the selection stored in session.
|
||||||
|
- Q: Where should the “current managed tenant” selection be stored in v1? → A: Session-only.
|
||||||
|
- Q: What should be the canonical onboarding URL/path in v1? → A: `/admin/managed-tenants/onboarding`.
|
||||||
|
- Q: For archived/deactivated tenants, should “View” always be allowed for viewers? → A: Yes. Archived tenants remain viewable for users with `tenant_managed_tenants.view`.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||||
|
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||||
|
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||||
|
|
||||||
|
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||||
|
Think of each story as a standalone slice of functionality that can be:
|
||||||
|
- Developed independently
|
||||||
|
- Tested independently
|
||||||
|
- Deployed independently
|
||||||
|
- Demonstrated to users independently
|
||||||
|
-->
|
||||||
|
|
||||||
|
### User Story 1 - Add managed tenant via single front door (Priority: P1)
|
||||||
|
|
||||||
|
As an admin user, I can add a new managed tenant through one clearly labeled entry point (“Add managed tenant”), so there is no ambiguity about where onboarding begins.
|
||||||
|
|
||||||
|
**Why this priority**: This removes the current confusion and is the prerequisite for a future wizard-based onboarding experience.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by verifying only one visible “Add managed tenant” entry exists, and legacy direct URLs redirect to the canonical onboarding screen.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** I can access the admin panel, **When** I look for a way to add a managed tenant, **Then** there is exactly one visible entry point labeled “Add managed tenant”.
|
||||||
|
2. **Given** I open a legacy onboarding URL directly (e.g., the previously used “/admin/new”), **When** the page loads, **Then** I am redirected to the canonical onboarding screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Manage tenants without tenant-in-tenant navigation (Priority: P2)
|
||||||
|
|
||||||
|
As an admin user, I can list, view, and manage managed tenants in a tenantless admin area, so the system does not require an already-selected tenant context to manage tenants.
|
||||||
|
|
||||||
|
**Why this priority**: Eliminates tenant-in-tenant logic and removes a large source of inconsistent access/404 semantics.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by verifying there is a tenantless managed-tenant listing and that links/routes do not require any “current tenant” parameter.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** I have access to the admin panel, **When** I navigate to the managed tenants list, **Then** the list loads without requiring a pre-selected tenant context.
|
||||||
|
2. **Given** I open any managed-tenant detail screen via URL, **When** the page loads, **Then** the URL does not include a “current tenant” path prefix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Open an active tenant context and handle archived tenants safely (Priority: P3)
|
||||||
|
|
||||||
|
As an admin user, I can “Open” a managed tenant to work within its context, and if a managed tenant is archived/deactivated I get a clear status screen instead of a broken experience.
|
||||||
|
|
||||||
|
**Why this priority**: Prevents confusing 404s and ensures consistent, safe operations as tenants change lifecycle state.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by verifying “Open” is deterministic for active tenants, and for archived tenants it shows a status screen with only allowed actions.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a managed tenant is active and I am allowed to open it, **When** I click “Open”, **Then** I land on a stable tenantless screen that clearly indicates which managed tenant is selected.
|
||||||
|
2. **Given** a managed tenant is archived/deactivated, **When** I click “Open”, **Then** I see a status screen explaining the tenant is archived (not a 404).
|
||||||
|
3. **Given** a managed tenant is archived/deactivated and I do not have permission to restore or force delete it, **When** I view the status screen, **Then** those actions are not executable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Attempting to access managed-tenant screens as a non-member of the tenant-plane scope.
|
||||||
|
- Attempting to execute a mutation (create/edit/archive/restore/delete) without the necessary capability.
|
||||||
|
- A managed tenant transitions to archived/deactivated while a user has an open browser tab.
|
||||||
|
- Legacy deep links to the old tenant-scoped managed-tenant routes.
|
||||||
|
- A user opens the admin panel with no “current managed tenant” selected yet.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||||
|
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||||
|
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||||
|
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||||
|
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
||||||
|
- ensure any cross-plane access is deny-as-not-found (404),
|
||||||
|
- explicitly define 404 vs 403 semantics:
|
||||||
|
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||||
|
- member but missing capability → 403
|
||||||
|
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||||
|
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||||
|
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||||
|
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||||
|
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||||
|
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||||
|
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
|
Fill them out with the right functional requirements.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
#### Scope, Definitions, Non-goals
|
||||||
|
|
||||||
|
- **FR-001 (Scope / Plane)**: The feature MUST apply only to the admin tenant-plane area and MUST NOT change platform/system-plane behavior.
|
||||||
|
- **FR-002 (Terminology)**: The UI MUST use the term “Managed tenant” (or an equivalent unambiguous label) in places where “Tenant” could be confused with a workspace or platform tenant concept.
|
||||||
|
- **FR-003 (Non-goals enforced)**: This version MUST NOT introduce a wizard/stepper onboarding UI, a new workspace membership model, or a provider/credentials architecture refactor.
|
||||||
|
|
||||||
|
#### Single Front Door
|
||||||
|
|
||||||
|
- **FR-004 (Canonical onboarding entry)**: The system MUST provide exactly one visible UI entry point to start managed-tenant onboarding (“Add managed tenant”), leading to a canonical onboarding screen.
|
||||||
|
- **FR-004a (Canonical onboarding path)**: The canonical onboarding screen MUST be reachable at `/admin/managed-tenants/onboarding`.
|
||||||
|
- **FR-005 (Legacy entry points)**: Legacy onboarding entry points MUST be removed from navigation/buttons, or MUST redirect to the canonical onboarding screen.
|
||||||
|
- **FR-006 (Direct URL behavior)**: Direct requests to the legacy onboarding URL (previously used “/admin/new”) MUST redirect to the canonical onboarding screen.
|
||||||
|
|
||||||
|
#### Tenantless Managed Tenants Area (No tenant-in-tenant)
|
||||||
|
|
||||||
|
- **FR-007 (Tenantless routes)**: Listing, viewing, and editing managed tenants MUST be available without requiring a “current tenant” context in the URL.
|
||||||
|
- **FR-008 (No tenant-in-tenant)**: Managed-tenant management MUST NOT appear under a tenant-scoped URL structure (e.g., MUST NOT require “/t/{currentTenant}/…” style path segments).
|
||||||
|
|
||||||
|
#### “Open” vs “Switch” semantics
|
||||||
|
|
||||||
|
- **FR-009 (Open semantics)**: “Open” on a managed tenant MUST mean “enter this managed tenant’s context within the admin area”, not “switch workspaces”.
|
||||||
|
- **FR-010 (Deterministic destination)**: “Open” MUST take the user to a stable, deterministic destination that clearly indicates the selected managed tenant.
|
||||||
|
- **FR-011 (No tenant-in-tenant link generation)**: Links used for “Open” MUST NOT rely on a tenant-scoped routing prefix.
|
||||||
|
- **FR-011a (Open authorization)**: “Open” MUST be authorized by `tenant_managed_tenants.view` in v1 (no separate “open” capability).
|
||||||
|
- **FR-011b (Open destination)**: In v1, “Open” MUST navigate to a stable tenantless destination in the admin area (e.g. `/admin/managed-tenants/current`), using a session-based “current managed tenant” selection.
|
||||||
|
- **FR-011c (Selection storage)**: In v1, the “current managed tenant” selection MUST be stored in server-side session (no required DB persistence).
|
||||||
|
|
||||||
|
#### Archived / Deactivated Managed Tenants UX
|
||||||
|
|
||||||
|
- **FR-012 (Archived view access)**: Archived/deactivated managed tenants MUST remain viewable (read-only) for users authorized by `tenant_managed_tenants.view`.
|
||||||
|
- **FR-012a (No 404 for viewers)**: Archived/deactivated managed tenants MUST NOT present a “not found” experience solely due to being archived/deactivated when the user is authorized to view managed tenants.
|
||||||
|
- **FR-013 (Archived open behavior)**: “Open” on an archived/deactivated managed tenant MUST NOT result in a “not found” experience; it MUST show a dedicated status screen explaining the tenant is archived/deactivated.
|
||||||
|
- **FR-014 (Archived actions)**: The archived/deactivated status screen MUST present only actions that are allowed for the current user (e.g., restore, force delete), and MUST prevent execution when not allowed.
|
||||||
|
|
||||||
|
#### RBAC UX and Enforcement
|
||||||
|
|
||||||
|
- **FR-015 (404 vs 403 semantics)**: Authorization behavior MUST follow these rules:
|
||||||
|
- Non-member / not entitled to tenant-plane scope → treated as “not found” in user experience.
|
||||||
|
- Member but missing capability → forbidden (action visible but disabled where appropriate; execution prevented).
|
||||||
|
- **FR-016 (Consistent UI affordances)**: In managed-tenant flows, actions MUST consistently follow these UX rules:
|
||||||
|
- Non-member: action not shown and not executable.
|
||||||
|
- Member without capability: action visible but disabled with an explanatory tooltip; not executable.
|
||||||
|
- Member with capability: action enabled.
|
||||||
|
- **FR-017 (Server-side defense-in-depth)**: All mutations and operation-start actions in the managed-tenant area MUST be rejected server-side when the user lacks authorization.
|
||||||
|
|
||||||
|
#### Capabilities (Canonical)
|
||||||
|
|
||||||
|
- **FR-018 (Capability registry)**: Managed-tenant functionality MUST be controlled via canonical capabilities (no ad-hoc permission checks).
|
||||||
|
- **FR-019 (Minimal capabilities set)**: The system MUST define, at minimum, the following capabilities (names are canonical):
|
||||||
|
- `tenant_managed_tenants.view`
|
||||||
|
- `tenant_managed_tenants.create`
|
||||||
|
- `tenant_managed_tenants.manage`
|
||||||
|
- `tenant_managed_tenants.archive`
|
||||||
|
- `tenant_managed_tenants.restore`
|
||||||
|
- `tenant_managed_tenants.force_delete`
|
||||||
|
- **FR-020 (Default role mapping)**: Default role-to-capability mapping MUST align with:
|
||||||
|
- Owner/Manager: all managed-tenant capabilities.
|
||||||
|
- Operator: view (includes “Open”) and tenant-context operations where permitted; not create/manage/archive/force delete.
|
||||||
|
- Readonly: view only.
|
||||||
|
|
||||||
|
#### Backwards Compatibility
|
||||||
|
|
||||||
|
- **FR-021 (Redirect compatibility)**: Legacy managed-tenant entry points and legacy deep links MUST redirect to the new tenantless managed-tenant area when reachable.
|
||||||
|
- **FR-022 (No data loss)**: The feature MUST NOT delete or irreversibly modify existing managed-tenant data.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Summary
|
||||||
|
|
||||||
|
- Exactly one visible “Add managed tenant” entry exists in the admin UI.
|
||||||
|
- Direct requests to the legacy onboarding URL redirect to the canonical onboarding screen.
|
||||||
|
- Managed-tenant management is available without any “current tenant” path prefix.
|
||||||
|
- “Open” leads to a stable tenant-context destination for active managed tenants.
|
||||||
|
- “Open” for archived/deactivated managed tenants shows a dedicated status screen (not “not found”).
|
||||||
|
- Authorization outcomes match the defined 404-vs-403 semantics and actions are never executable without the required capability.
|
||||||
|
- The canonical managed-tenant capabilities exist and role mapping matches the default expectations.
|
||||||
|
- No managed-tenant data is deleted or irreversibly changed by this feature.
|
||||||
|
|
||||||
|
#### Assumptions & Dependencies
|
||||||
|
|
||||||
|
- A managed tenant entity already exists with a lifecycle indicator (active vs archived/deactivated) that can be used to drive UX decisions.
|
||||||
|
- The admin interface can represent a “current managed tenant” selection (even if it is not persisted long-term in v1).
|
||||||
|
- In v1, the selection is session-only (not stored in the database).
|
||||||
|
- A canonical capability registry exists (or can be extended) to hold the managed-tenant capabilities.
|
||||||
|
- Legacy deep links may exist in bookmarks and documentation and therefore must remain reachable via redirects.
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Workspace (v1 minimal)**: An organizational container in the admin interface used to structure managed-tenant management. In v1 it is a routing/UI concept and does not introduce a new membership model.
|
||||||
|
- **Managed Tenant**: A Microsoft Entra/Intune tenant managed by the product, including identity, consent/connection state, and lifecycle state (active vs archived/deactivated).
|
||||||
|
- **Tenant Context (admin)**: The “current selection” of a managed tenant that determines what data/actions the admin interface displays.
|
||||||
|
- **Capability**: A permission unit used to allow/deny actions and UI affordances for managed-tenant flows.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001 (Single entry point)**: 100% of visible “add managed tenant” journeys originate from the single canonical entry point (no second visible onboarding CTA in the admin UI).
|
||||||
|
- **SC-002 (No tenant-in-tenant)**: 0 managed-tenant CRUD routes require a “current tenant” path prefix.
|
||||||
|
- **SC-003 (Archived UX)**: 100% of attempts to “Open” an archived/deactivated managed tenant result in a defined status screen (never a “not found” experience for authorized viewers).
|
||||||
|
- **SC-004 (RBAC semantics)**: For managed-tenant flows, authorization outcomes are consistent and testable: non-members receive “not found” experience, members without capability are blocked from execution.
|
||||||
|
- **SC-005 (Task completion)**: A user with the appropriate capability can start managed-tenant onboarding and reach the onboarding screen successfully on the first attempt in under 30 seconds.
|
||||||
173
specs/068-workspace-foundation-v1/tasks.md
Normal file
173
specs/068-workspace-foundation-v1/tasks.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list for Workspace Foundation & Managed Tenant Onboarding Unification (v1)"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Workspace Foundation & Managed Tenant Onboarding Unification (v1)
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/068-workspace-foundation-v1/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required)
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest) — this feature changes runtime routing + authorization UX.
|
||||||
|
**Operations**: No new long-running/remote/queued/scheduled work expected.
|
||||||
|
**RBAC**: MUST preserve semantics:
|
||||||
|
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||||
|
- member but missing capability → 403
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description with file path`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[US#]**: Which user story this task belongs to
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Confirm local tooling and repo structure for safe iteration.
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Add managed tenant via single front door (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Exactly one visible “Add managed tenant” entry exists, and legacy `/admin/new` redirects to the canonical onboarding page.
|
||||||
|
|
||||||
|
**Independent Test**:
|
||||||
|
- Visiting `/admin/new` redirects to `/admin/managed-tenants/onboarding`.
|
||||||
|
- The managed-tenant list shows a single “Add managed tenant” entry point.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Manage tenants without tenant-in-tenant navigation (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Managed-tenant list/view/edit screens are tenantless (no `/admin/t/{tenant}/...` required).
|
||||||
|
|
||||||
|
**Independent Test**:
|
||||||
|
- Managed-tenant list loads without a tenant route prefix.
|
||||||
|
- Managed-tenant view/edit URLs are tenantless.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Open an active tenant context and handle archived tenants safely (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: “Open” works for active tenants via session-only selection, and archived tenants show a status screen.
|
||||||
|
|
||||||
|
**Independent Test**:
|
||||||
|
- Active tenant “Open” stores selection in session and lands on a deterministic page that shows the selected tenant.
|
||||||
|
- Archived tenant “Open” shows a status page (not 404) for viewers.
|
||||||
|
- Restore/force delete actions require confirmation and are authorization-guarded.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Hardening, consistency, and developer ergonomics.
|
||||||
|
|
||||||
|
- [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/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
- Phase 1 (Setup) → Phase 2 (Foundational) → US1 (MVP) → US2 → US3 → Polish
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- US1 depends on Phase 2 (capabilities available for authorization checks).
|
||||||
|
- US2 depends on Phase 2; can be started after US1, but is logically independent.
|
||||||
|
- US3 depends on Phase 2 and benefits from US2 being in place (tenantless navigation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Execution Examples
|
||||||
|
|
||||||
|
### After Foundational Phase
|
||||||
|
|
||||||
|
- [P] tasks in Phase 2 can be split across registry/mapping/tests.
|
||||||
|
- US1 tests (T009–T011) can be written in parallel.
|
||||||
|
- US3 page/view creation tasks (T029–T032) can be done in parallel with the context helper (T028).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP Scope (recommended)
|
||||||
|
|
||||||
|
- Complete Phase 1 + Phase 2 + US1.
|
||||||
|
- Validate only US1 acceptance scenarios and ship.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
- Add US2 next (tenantless route guarantees).
|
||||||
|
- Add US3 last (session context + archived safe UX).
|
||||||
@ -11,6 +11,8 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('policy sync updates selected policies from graph and updates the operation run', function () {
|
test('policy sync updates selected policies from graph and updates the operation run', function () {
|
||||||
|
config()->set('graph.enabled', true);
|
||||||
|
|
||||||
$tenant = Tenant::factory()->create([
|
$tenant = Tenant::factory()->create([
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
|
use App\Filament\Pages\ManagedTenants\Onboarding;
|
||||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantPermission;
|
use App\Models\TenantPermission;
|
||||||
@ -64,7 +64,7 @@ public function request(string $method, string $path, array $options = []): Grap
|
|||||||
]);
|
]);
|
||||||
Filament::setTenant($contextTenant, true);
|
Filament::setTenant($contextTenant, true);
|
||||||
|
|
||||||
Livewire::test(CreateTenant::class)
|
Livewire::test(Onboarding::class)
|
||||||
->fillForm([
|
->fillForm([
|
||||||
'name' => 'Contoso',
|
'name' => 'Contoso',
|
||||||
'environment' => 'other',
|
'environment' => 'other',
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
25
tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php
Normal file
25
tests/Feature/ManagedTenants/AuthorizationSemanticsTest.php
Normal 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();
|
||||||
|
});
|
||||||
15
tests/Feature/ManagedTenants/OnboardingPageTest.php
Normal file
15
tests/Feature/ManagedTenants/OnboardingPageTest.php
Normal 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');
|
||||||
|
});
|
||||||
14
tests/Feature/ManagedTenants/OnboardingRedirectTest.php
Normal file
14
tests/Feature/ManagedTenants/OnboardingRedirectTest.php
Normal 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');
|
||||||
|
});
|
||||||
23
tests/Feature/ManagedTenants/OpenActiveTenantTest.php
Normal file
23
tests/Feature/ManagedTenants/OpenActiveTenantTest.php
Normal 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);
|
||||||
|
});
|
||||||
26
tests/Feature/ManagedTenants/OpenArchivedTenantTest.php
Normal file
26
tests/Feature/ManagedTenants/OpenArchivedTenantTest.php
Normal 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);
|
||||||
|
});
|
||||||
17
tests/Feature/ManagedTenants/SingleEntryPointTest.php
Normal file
17
tests/Feature/ManagedTenants/SingleEntryPointTest.php
Normal 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');
|
||||||
|
});
|
||||||
32
tests/Feature/ManagedTenants/TenantlessRoutesTest.php
Normal file
32
tests/Feature/ManagedTenants/TenantlessRoutesTest.php
Normal 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();
|
||||||
|
});
|
||||||
@ -9,7 +9,7 @@
|
|||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
describe('Edit tenant archive action UI enforcement', function () {
|
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();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user] = createUserWithTenant(tenant: $tenant, role: 'manager');
|
[$user] = createUserWithTenant(tenant: $tenant, role: 'manager');
|
||||||
|
|
||||||
@ -20,16 +20,13 @@
|
|||||||
|
|
||||||
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionDisabled('archive')
|
->assertActionEnabled('archive')
|
||||||
->assertActionExists('archive', function (Action $action): bool {
|
|
||||||
return $action->getTooltip() === 'You do not have permission to archive tenants.';
|
|
||||||
})
|
|
||||||
->mountAction('archive')
|
->mountAction('archive')
|
||||||
->callMountedAction()
|
->callMountedAction()
|
||||||
->assertSuccessful();
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
$tenant->refresh();
|
$tenant->refresh();
|
||||||
expect($tenant->trashed())->toBeFalse();
|
expect($tenant->trashed())->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows owner members to archive tenant', function () {
|
it('allows owner members to archive tenant', function () {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Pages\Tenancy\RegisterTenant;
|
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
@ -25,6 +25,6 @@
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(CreateTenant::class)
|
->test(Onboarding::class)
|
||||||
->assertStatus(403);
|
->assertStatus(403);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -94,6 +94,10 @@ function createUserWithTenant(?Tenant $tenant = null, ?User $user = null, string
|
|||||||
$tenant->getKey() => ['role' => $role],
|
$tenant->getKey() => ['role' => $role],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (! $tenant->trashed() && $tenant->status === 'active') {
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
return [$user, $tenant];
|
return [$user, $tenant];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -77,7 +77,7 @@
|
|||||||
->action(fn () => null);
|
->action(fn () => null);
|
||||||
|
|
||||||
$enforcement = UiEnforcement::forAction($action)
|
$enforcement = UiEnforcement::forAction($action)
|
||||||
->requireCapability(Capabilities::PROVIDER_MANAGE);
|
->requireCapability(Capabilities::TENANT_MANAGED_TENANTS_VIEW);
|
||||||
|
|
||||||
expect($enforcement)->toBeInstanceOf(UiEnforcement::class);
|
expect($enforcement)->toBeInstanceOf(UiEnforcement::class);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user