diff --git a/resources/lang/vendor/filament-shield/en/filament-shield.php b/resources/lang/vendor/filament-shield/en/filament-shield.php index a1c1c2c..e67e86c 100644 --- a/resources/lang/vendor/filament-shield/en/filament-shield.php +++ b/resources/lang/vendor/filament-shield/en/filament-shield.php @@ -9,7 +9,7 @@ 'column.name' => 'Name', 'column.guard_name' => 'Guard Name', - 'column.team' => 'Team', + 'column.team' => 'Site', 'column.roles' => 'Roles', 'column.permissions' => 'Permissions', 'column.updated_at' => 'Updated At', @@ -24,7 +24,7 @@ 'field.guard_name' => 'Guard Name', 'field.permissions' => 'Permissions', 'field.team' => 'Site', - 'field.team.placeholder' => 'Select a site ...', + 'field.team.placeholder' => 'Global (all sites)', 'field.select_all.name' => 'Select All', 'field.select_all.message' => 'Enables/Disables all Permissions for this role', diff --git a/src/EclipseServiceProvider.php b/src/EclipseServiceProvider.php index 1570b56..db1237b 100644 --- a/src/EclipseServiceProvider.php +++ b/src/EclipseServiceProvider.php @@ -17,6 +17,8 @@ use Eclipse\Core\Providers\HorizonServiceProvider; use Eclipse\Core\Providers\TelescopeServiceProvider; use Eclipse\Core\Services\Registry; +use Filament\Forms\Components\Field; +use Filament\Infolists\Components\Entry; use Filament\Resources\Resource; use Filament\Tables\Columns\Column; use Illuminate\Auth\Events\Login; @@ -121,9 +123,22 @@ public function boot(): void // Register policies for classes that can't be guessed automatically Gate::policy(Role::class, RolePolicy::class); + // Set common settings for Filament form + Field::configureUsing(function (Field $field) { + $field + ->translateLabel(); + }); + + // Set common settings for Filament infolist + Entry::configureUsing(function (Entry $entry) { + $entry + ->translateLabel(); + }); + // Set common settings for Filament table columns Column::configureUsing(function (Column $column) { $column + ->translateLabel() ->toggleable() ->sortable(); }); diff --git a/src/Filament/Resources/RoleResource.php b/src/Filament/Resources/RoleResource.php new file mode 100644 index 0000000..4820181 --- /dev/null +++ b/src/Filament/Resources/RoleResource.php @@ -0,0 +1,235 @@ +schema([ + Forms\Components\Grid::make() + ->schema([ + Forms\Components\Section::make() + ->schema([ + Forms\Components\TextInput::make('name') + ->label(__('filament-shield::filament-shield.field.name')) + ->unique( + ignoreRecord: true, /** @phpstan-ignore-next-line */ + modifyRuleUsing: fn (Unique $rule) => ! Utils::isTenancyEnabled() ? $rule : $rule->where(Utils::getTenantModelForeignKey(), Filament::getTenant()?->id) + ) + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('guard_name') + ->label(__('filament-shield::filament-shield.field.guard_name')) + ->default(Utils::getFilamentAuthGuard()) + ->nullable() + ->maxLength(255), + + Forms\Components\TextInput::make(config('permission.column_names.team_foreign_key')) + ->label(__('filament-shield::filament-shield.field.team')) + ->placeholder(__('filament-shield::filament-shield.field.team.placeholder')) + ->visible(function ($state, $context): bool { + if (empty($state) && $context === 'view') { + return true; + } + + return false; + }), + + Forms\Components\Select::make(config('permission.column_names.team_foreign_key')) + ->label(__('filament-shield::filament-shield.field.team')) + ->placeholder(__('filament-shield::filament-shield.field.team.placeholder')) + /** @phpstan-ignore-next-line */ + ->default([Filament::getTenant()?->id]) + ->visible(function ($state, $context): bool { + if (empty($state) && $context === 'view') { + return false; + } + + return true; + }) + ->options(fn (): Arrayable => Utils::getTenantModel() ? Utils::getTenantModel()::pluck('name', 'id') : collect()) + ->dehydrated(fn (): bool => ! (static::shield()->isCentralApp() && Utils::isTenancyEnabled())), + ShieldSelectAllToggle::make('select_all') + ->onIcon('heroicon-s-shield-check') + ->offIcon('heroicon-s-shield-exclamation') + ->label(__('filament-shield::filament-shield.field.select_all.name')) + ->helperText(fn (): HtmlString => new HtmlString(__('filament-shield::filament-shield.field.select_all.message'))) + ->dehydrated(fn (bool $state): bool => $state), + + ]) + ->columns([ + 'sm' => 2, + 'lg' => 3, + ]), + ]), + static::getShieldFormComponents(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->weight('font-medium') + ->label(__('filament-shield::filament-shield.column.name')) + ->formatStateUsing(fn ($state): string => Str::headline($state)) + ->searchable(), + Tables\Columns\TextColumn::make('guard_name') + ->badge() + ->color('warning') + ->label(__('filament-shield::filament-shield.column.guard_name')), + Tables\Columns\TextColumn::make('site.name') + ->default('Global') + ->badge() + ->color(fn (mixed $state): string => str($state)->contains('Global') ? 'gray' : 'primary') + ->label(__('filament-shield::filament-shield.column.team')) + ->searchable(), + Tables\Columns\TextColumn::make('permissions_count') + ->badge() + ->label(__('filament-shield::filament-shield.column.permissions')) + ->counts('permissions') + ->colors(['success']), + Tables\Columns\TextColumn::make('updated_at') + ->label(__('filament-shield::filament-shield.column.updated_at')) + ->dateTime(), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\DeleteBulkAction::make(), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListRoles::route('/'), + 'create' => Pages\CreateRole::route('/create'), + 'view' => Pages\ViewRole::route('/{record}'), + 'edit' => Pages\EditRole::route('/{record}/edit'), + ]; + } + + public static function getCluster(): ?string + { + return Utils::getResourceCluster() ?? static::$cluster; + } + + public static function getModel(): string + { + return Utils::getRoleModel(); + } + + public static function getModelLabel(): string + { + return __('filament-shield::filament-shield.resource.label.role'); + } + + public static function getPluralModelLabel(): string + { + return __('filament-shield::filament-shield.resource.label.roles'); + } + + public static function shouldRegisterNavigation(): bool + { + return Utils::isResourceNavigationRegistered(); + } + + public static function getNavigationGroup(): ?string + { + return Utils::isResourceNavigationGroupEnabled() + ? __('filament-shield::filament-shield.nav.group') + : ''; + } + + public static function getNavigationLabel(): string + { + return __('filament-shield::filament-shield.nav.role.label'); + } + + public static function getNavigationIcon(): string + { + return __('filament-shield::filament-shield.nav.role.icon'); + } + + public static function getNavigationSort(): ?int + { + return Utils::getResourceNavigationSort(); + } + + public static function getSubNavigationPosition(): SubNavigationPosition + { + return Utils::getSubNavigationPosition() ?? static::$subNavigationPosition; + } + + public static function getSlug(): string + { + return Utils::getResourceSlug(); + } + + public static function getNavigationBadge(): ?string + { + return Utils::isResourceNavigationBadgeEnabled() + ? strval(static::getEloquentQuery()->count()) + : null; + } + + public static function isScopedToTenant(): bool + { + return Utils::isScopedToTenant(); + } + + public static function canGloballySearch(): bool + { + return Utils::isResourceGloballySearchable() && count(static::getGloballySearchableAttributes()) && static::canViewAny(); + } +} diff --git a/src/Filament/Resources/RoleResource/Pages/CreateRole.php b/src/Filament/Resources/RoleResource/Pages/CreateRole.php new file mode 100644 index 0000000..8cd0749 --- /dev/null +++ b/src/Filament/Resources/RoleResource/Pages/CreateRole.php @@ -0,0 +1,47 @@ +permissions = collect($data) + ->filter(function ($permission, $key) { + return ! in_array($key, ['name', 'guard_name', 'select_all', Utils::getTenantModelForeignKey()]); + }) + ->values() + ->flatten() + ->unique(); + + if (Arr::has($data, Utils::getTenantModelForeignKey())) { + return Arr::only($data, ['name', 'guard_name', Utils::getTenantModelForeignKey()]); + } + + return Arr::only($data, ['name', 'guard_name']); + } + + protected function afterCreate(): void + { + $permissionModels = collect(); + $this->permissions->each(function ($permission) use ($permissionModels) { + $permissionModels->push(Utils::getPermissionModel()::firstOrCreate([ + /** @phpstan-ignore-next-line */ + 'name' => $permission, + 'guard_name' => $this->data['guard_name'], + ])); + }); + + $this->record->syncPermissions($permissionModels); + } +} diff --git a/src/Filament/Resources/RoleResource/Pages/EditRole.php b/src/Filament/Resources/RoleResource/Pages/EditRole.php new file mode 100644 index 0000000..f461190 --- /dev/null +++ b/src/Filament/Resources/RoleResource/Pages/EditRole.php @@ -0,0 +1,54 @@ +permissions = collect($data) + ->filter(function ($permission, $key) { + return ! in_array($key, ['name', 'guard_name', 'select_all', Utils::getTenantModelForeignKey()]); + }) + ->values() + ->flatten() + ->unique(); + + if (Arr::has($data, Utils::getTenantModelForeignKey())) { + return Arr::only($data, ['name', 'guard_name', Utils::getTenantModelForeignKey()]); + } + + return Arr::only($data, ['name', 'guard_name']); + } + + protected function afterSave(): void + { + $permissionModels = collect(); + $this->permissions->each(function ($permission) use ($permissionModels) { + $permissionModels->push(Utils::getPermissionModel()::firstOrCreate([ + 'name' => $permission, + 'guard_name' => $this->data['guard_name'], + ])); + }); + + $this->record->syncPermissions($permissionModels); + } +} diff --git a/src/Filament/Resources/RoleResource/Pages/ListRoles.php b/src/Filament/Resources/RoleResource/Pages/ListRoles.php new file mode 100644 index 0000000..70f068f --- /dev/null +++ b/src/Filament/Resources/RoleResource/Pages/ListRoles.php @@ -0,0 +1,19 @@ +schema([ - Forms\Components\SpatieMediaLibraryFileUpload::make('avatar') - ->collection('avatars') - ->avatar() - ->imageEditor() - ->maxSize(1024 * 2), - self::getFirstNameFormComponent(), - self::getLastNameFormComponent(), - self::getEmailFormComponent(), - Forms\Components\DateTimePicker::make('email_verified_at') - ->visible(config('eclipse.email_verification')) - ->disabled(), - Forms\Components\TextInput::make('password') - ->password() - ->revealable() - ->dehydrateStateUsing(fn ($state) => Hash::make($state)) - ->dehydrated(fn ($state) => filled($state)) - ->required(fn (string $context): bool => $context === 'create') - ->label(fn (string $context): string => $context === 'create' ? 'Password' : 'Set new password') - ->suffixAction( - Action::make('randomPassword') - ->icon('heroicon-s-arrow-path') - ->tooltip(__('Random password generator')) - ->color('gray') - ->action( - fn (Set $set) => $set('password', Str::password(16)) - ) - ), - Forms\Components\Select::make('roles') - ->relationship('roles', 'name') - ->saveRelationshipsUsing(function (User $record, $state) { - $record->roles()->syncWithPivotValues($state, [config('permission.column_names.team_foreign_key') => getPermissionsTeamId()]); - }) - ->multiple() - ->preload() - ->searchable(), + Forms\Components\Section::make(__('Personal Information')) + ->columns(2) + ->compact() + ->schema([ + Forms\Components\SpatieMediaLibraryFileUpload::make('avatar') + ->collection('avatars') + ->avatar() + ->imageEditor() + ->columnSpanFull() + ->maxSize(1024 * 2), + self::getFirstNameFormComponent(), + self::getLastNameFormComponent(), + self::getEmailFormComponent(), + Forms\Components\DateTimePicker::make('email_verified_at') + ->visible(config('eclipse.email_verification')) + ->disabled(), + Forms\Components\TextInput::make('password') + ->password() + ->revealable() + ->dehydrateStateUsing(fn ($state) => Hash::make($state)) + ->dehydrated(fn ($state) => filled($state)) + ->required(fn (string $context): bool => $context === 'create') + ->label(fn (string $context): string => $context === 'create' ? 'Password' : 'Set new password') + ->suffixAction( + Action::make('randomPassword') + ->icon('heroicon-s-arrow-path') + ->tooltip(__('Random password generator')) + ->color('gray') + ->action( + fn (Set $set) => $set('password', Str::password(16)) + ) + ), + ]), + + Forms\Components\Section::make(__('Access Control')) + ->compact() + ->schema([ + Forms\Components\Select::make('roles') + ->hiddenLabel() + ->relationship('roles', 'name') + ->getOptionLabelFromRecordUsing(function ($record): string { + $suffix = $record->site_id ? ' (Site-Specific)' : ' (Global)'; + + return "{$record->name}{$suffix}"; + }) + ->saveRelationshipsUsing(function (User $record, $state) { + $siteIDs = Role::whereIn('id', $state) + ->whereNotNull('site_id') + ->pluck('site_id') + ->toArray(); + + $record->sites()->sync($siteIDs); + + $record->roles()->syncWithPivotValues($state, [config('permission.column_names.team_foreign_key') => getPermissionsTeamId()]); + }) + ->multiple() + ->preload() + ->searchable(), + ]), ]); } @@ -138,6 +169,40 @@ public static function table(Table $table): Table ->visible(config('eclipse.email_verification')) ->width(150); + $columns[] = Tables\Columns\TextColumn::make('global_roles') + ->label('Global Roles') + ->translateLabel() + ->badge() + ->getStateUsing( + fn (User $record): Collection => $record + ->roles() + ->whereNull('roles.'.config('permission.column_names.team_foreign_key')) + ->pluck('name') + ->map(fn ($roleName) => Str::headline($roleName)) + ) + ->sortable(false) + ->placeholder('No global roles') + ->toggleable(); + + $columns[] = Tables\Columns\TextColumn::make('site_roles') + ->label('Site Roles (current)') + ->translateLabel() + ->badge() + ->color('warning') + ->getStateUsing(function (User $record) { + if (! Filament::getTenant()) { + return 'No site context'; + } + + return $record->roles() + ->where('roles.'.config('permission.column_names.team_foreign_key'), Filament::getTenant()->id) + ->pluck('name') + ->map(fn ($roleName) => Str::headline($roleName)); + }) + ->sortable(false) + ->placeholder('No site roles') + ->toggleable(); + $columns[] = Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable() @@ -150,38 +215,10 @@ public static function table(Table $table): Table ->toggleable(isToggledHiddenByDefault: true) ->width(150); - $filters = [ - Tables\Filters\TernaryFilter::make('email_verified_at') - ->label('Email verification') - ->nullable() - ->placeholder('All users') - ->trueLabel('Verified') - ->falseLabel('Not verified') - ->queries( - true: fn (Builder $query) => $query->whereNotNull('email_verified_at'), - false: fn (Builder $query) => $query->whereNull('email_verified_at'), - blank: fn (Builder $query) => $query, - ) - ->visible(config('eclipse.email_verification')), - Tables\Filters\QueryBuilder::make() - ->constraints([ - TextConstraint::make('first_name') - ->label('First name'), - TextConstraint::make('last_name') - ->label('Last name'), - TextConstraint::make('name') - ->label('Full name'), - TextConstraint::make('last_login_at') - ->label('Last login Date'), - TextConstraint::make('login_count') - ->label('Total Logins'), - ]), - Tables\Filters\TrashedFilter::make(), - ]; - return $table ->columns($columns) - ->filters($filters) + ->filters(self::getTableFilters()) + ->filtersFormWidth(MaxWidth::Large) ->actions([ Tables\Actions\ActionGroup::make([ Tables\Actions\ViewAction::make(), @@ -219,19 +256,99 @@ public static function table(Table $table): Table ]); } + private static function getTableFilters(): array + { + return [ + Tables\Filters\TernaryFilter::make('user_visibility') + ->label('Show Users From') + ->placeholder(__('Current site (default)')) + ->trueLabel(__('All accessible sites')) + ->falseLabel(__('Current site only')) + ->queries( + true: function (Builder $query): void {}, + false: function (Builder $query): void { + if (Filament::getTenant()) { + $query->whereHas('sites', function ($subQuery) { + $subQuery->where('sites.id', Filament::getTenant()->id); + }); + } + }, + blank: function (Builder $query): void { + if (Filament::getTenant()) { + $query->whereHas('sites', function ($subQuery) { + $subQuery->where('sites.id', Filament::getTenant()->id); + }); + } + } + ), + + Tables\Filters\SelectFilter::make('global_roles') + ->label('Global Roles') + ->relationship('roles', 'name', function (Builder $query): void { + $query + ->whereNull('roles.'.config('permission.column_names.team_foreign_key')); + }) + ->multiple() + ->searchable() + ->preload(), + + Tables\Filters\SelectFilter::make('site_roles') + ->label('Site Roles') + ->relationship('roles', 'name', function (Builder $query): void { + if (Filament::getTenant()) { + $query->where('roles.'.config('permission.column_names.team_foreign_key'), Filament::getTenant()->id); + } + }) + ->multiple() + ->searchable() + ->preload() + ->visible(fn () => Filament::getTenant() !== null), + + Tables\Filters\TernaryFilter::make('email_verified_at') + ->label('Email verification') + ->nullable() + ->placeholder('All users') + ->trueLabel('Verified') + ->falseLabel('Not verified') + ->queries( + true: fn (Builder $query) => $query->whereNotNull('email_verified_at'), + false: fn (Builder $query) => $query->whereNull('email_verified_at'), + blank: fn (Builder $query) => $query, + ) + ->visible(config('eclipse.email_verification')), + Tables\Filters\QueryBuilder::make() + ->constraints([ + TextConstraint::make('first_name') + ->label('First name'), + TextConstraint::make('last_name') + ->label('Last name'), + TextConstraint::make('name') + ->label('Full name'), + TextConstraint::make('last_login_at') + ->label('Last login Date'), + TextConstraint::make('login_count') + ->label('Total Logins'), + ]), + + Tables\Filters\TrashedFilter::make(), + ]; + } + public static function infolist(Infolist $infolist): Infolist { return $infolist->schema([ Section::make() ->columns(2) + ->compact() ->schema([ TextEntry::make('created_at') ->dateTime(), TextEntry::make('updated_at') ->dateTime(), ]), - Section::make('Personal information') + Section::make(__('Personal information')) ->columns(3) + ->compact() ->schema([ SpatieMediaLibraryImageEntry::make('avatar') ->collection('avatars') @@ -246,6 +363,29 @@ public static function infolist(Infolist $infolist): Infolist ->iconColor(fn (User $user) => $user->email_verified_at ? Color::Green : Color::Red), ]), ]), + Section::make(__('Access Information')) + ->compact() + ->columns(2) + ->schema([ + TextEntry::make('sites') + ->label('Accessable Sites') + ->weight(FontWeight::Medium) + ->listWithLineBreaks() + ->placeholder(__(' No sites accessible')) + ->formatStateUsing(fn ($state) => "✓ {$state->name} ({$state->domain})"), + + TextEntry::make('roles') + ->listWithLineBreaks() + ->weight(FontWeight::Medium) + ->placeholder(__('No roles assigned')) + ->formatStateUsing(function ($state): string { + $suffix = $state->site_id ? ' (Site-Specific)' : ' (Global)'; + $roleName = Str::headline($state->name); + + return "✓ {$roleName}{$suffix}"; + }), + + ]), ]); } @@ -302,10 +442,9 @@ public static function getGloballySearchableAttributes(): array public static function getEloquentQuery(): Builder { - return parent::getEloquentQuery() - ->withoutGlobalScopes([ - SoftDeletingScope::class, - ]); + return parent::getEloquentQuery()->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); } public static function getPermissionPrefixes(): array diff --git a/src/Models/Site.php b/src/Models/Site.php index f824a6a..b82de95 100644 --- a/src/Models/Site.php +++ b/src/Models/Site.php @@ -3,8 +3,12 @@ namespace Eclipse\Core\Models; use Eclipse\Core\Database\Factories\SiteFactory; +use Eclipse\Core\Models\User\Role; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; class Site extends Model { @@ -32,8 +36,40 @@ protected function casts(): array ]; } + public function site(): BelongsTo + { + return $this->belongsTo(Site::class); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'site_has_user'); + } + + protected static function booted(): void + { + static::created(function ($site): void { + $allUserIDs = Role::whereNull('site_id') + ->with('users') + ->get() + ->pluck('users.*.id') + ->flatten() + ->unique(); + + if ($allUserIDs->isNotEmpty()) { + $site->users()->syncWithoutDetaching($allUserIDs); + } + }); + } + protected static function newFactory(): SiteFactory { return SiteFactory::new(); } + + /** @return HasMany<\Eclipse\Core\Models\User\Role, self> */ + public function roles(): HasMany + { + return $this->hasMany(\Eclipse\Core\Models\User\Role::class); + } } diff --git a/src/Models/User.php b/src/Models/User.php index 5d99bd5..2184453 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -3,6 +3,7 @@ namespace Eclipse\Core\Models; use Eclipse\Core\Database\Factories\UserFactory; +use Eclipse\Core\Models\User\Role; use Exception; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasAvatar; @@ -123,6 +124,16 @@ protected static function booted() throw new Exception('This account has been deactivated.'); } }); + + static::created(function (self $user) { + $panelUserRole = Role::firstOrCreate(['name' => 'panel_user']); + if (app()->bound('filament') && filament()->getTenant()) { + $tenant = filament()->getTenant(); + $user->assignRole($panelUserRole, $tenant->getKey()); + } else { + $user->assignRole($panelUserRole); + } + }); } /** @@ -151,9 +162,6 @@ public function delete(): ?bool return parent::delete(); } - /** - * Determine if the user can impersonate other users. - */ public function canImpersonate(): bool { return $this->can('impersonate', User::class); diff --git a/src/Providers/AdminPanelProvider.php b/src/Providers/AdminPanelProvider.php index 9df4a72..490c9c7 100644 --- a/src/Providers/AdminPanelProvider.php +++ b/src/Providers/AdminPanelProvider.php @@ -71,7 +71,7 @@ public function panel(Panel $panel): Panel 'gray' => Color::Slate, ]) ->topNavigation() - ->brandName(fn () => Registry::getSite()->name) + ->brandName(fn () => Registry::getSite()?->name) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->discoverResources(in: $package_src.'Filament/Resources', for: 'Eclipse\\Core\\Filament\\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') diff --git a/tests/Feature/Filament/Resources/LocaleResourceTest.php b/tests/Feature/Filament/Resources/LocaleResourceTest.php index 09fd8b7..94dd4e4 100644 --- a/tests/Feature/Filament/Resources/LocaleResourceTest.php +++ b/tests/Feature/Filament/Resources/LocaleResourceTest.php @@ -14,6 +14,9 @@ // Create regular user with no permissions $this->set_up_common_user_and_tenant(); + $this->user->syncRoles([]); + $this->user->syncPermissions([]); + // Create test locale $locale = Locale::factory()->create(); diff --git a/tests/Feature/Filament/Resources/RoleResourceTest.php b/tests/Feature/Filament/Resources/RoleResourceTest.php new file mode 100644 index 0000000..1f71120 --- /dev/null +++ b/tests/Feature/Filament/Resources/RoleResourceTest.php @@ -0,0 +1,56 @@ +create(); + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + + $this->actingAs($admin); + Filament::setTenant($site); + + livewire(CreateRole::class, ['tenant' => $site]) + ->fillForm([ + 'name' => 'Site Manager', + 'site_id' => $site->id, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas('roles', [ + 'name' => 'Site Manager', + 'site_id' => $site->id, + ]); +}); + +test('role can be edited to change site assignment', function () { + $site1 = Site::factory()->create(); + $site2 = Site::factory()->create(); + $role = Role::factory()->create(['site_id' => $site1->id]); + + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + + $this->actingAs($admin); + Filament::setTenant($site1); + + livewire(EditRole::class, ['record' => $role->id, 'tenant' => $site1]) + ->fillForm([ + 'site_id' => $site2->id, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas('roles', [ + 'id' => $role->id, + 'site_id' => $site2->id, + ]); +}); diff --git a/tests/Feature/Filament/Resources/UserResourceTest.php b/tests/Feature/Filament/Resources/UserResourceTest.php index 5dd19c9..07678ee 100644 --- a/tests/Feature/Filament/Resources/UserResourceTest.php +++ b/tests/Feature/Filament/Resources/UserResourceTest.php @@ -3,7 +3,10 @@ use Eclipse\Core\Filament\Resources\UserResource; use Eclipse\Core\Filament\Resources\UserResource\Pages\CreateUser; use Eclipse\Core\Filament\Resources\UserResource\Pages\ListUsers; +use Eclipse\Core\Models\Site; use Eclipse\Core\Models\User; +use Eclipse\Core\Models\User\Role; +use Filament\Facades\Filament; use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteBulkAction; use Illuminate\Support\Facades\Hash; @@ -123,15 +126,21 @@ }); test('user can be deleted', function () { + $site = Site::factory()->create(); + $user = User::factory()->create(); + $user->sites()->attach($site); - livewire(ListUsers::class) + Filament::setTenant($site); + + livewire(ListUsers::class, ['tenant' => $site]) ->assertSuccessful() ->assertTableActionExists(DeleteAction::class) ->assertTableActionEnabled(DeleteAction::class, $user) ->callTableAction(DeleteAction::class, $user); - $this->assertSoftDeleted('users', ['id' => $user->id]); + $user->refresh(); + expect($user->trashed())->toBeTrue(); }); test('authed user cannot delete himself', function () { @@ -152,3 +161,114 @@ $this->assertModelExists($user); } }); + +test('user list shows only current site users by default', function () { + $site1 = Site::factory()->create(); + $site2 = Site::factory()->create(); + + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $user3 = User::factory()->create(); + + $user1->sites()->attach($site1); + $user2->sites()->attach($site2); + $user3->sites()->attach([$site1, $site2]); + + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + + $this->actingAs($admin); + Filament::setTenant($site1); + + livewire(ListUsers::class, ['tenant' => $site1]) + ->assertSuccessful() + ->assertCanSeeTableRecords([$user1, $user3]) + ->assertCanNotSeeTableRecords([$user2]); +}); + +test('user list shows global and site role columns', function () { + $site = Site::factory()->create(); + $user = User::factory()->create(); + + $globalRole = Role::create([ + 'name' => 'global_admin', + 'guard_name' => 'web', + config('permission.column_names.team_foreign_key') => null, // Global role + ]); + + $siteRole = Role::create([ + 'name' => 'site_editor', + 'guard_name' => 'web', + config('permission.column_names.team_foreign_key') => $site->id, // Site-specific role + ]); + + $user->sites()->attach($site); + $user->assignRole($globalRole); + $user->assignRole($siteRole); + + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + + $this->actingAs($admin); + Filament::setTenant($site); + + livewire(ListUsers::class, ['tenant' => $site]) + ->assertSuccessful() + ->assertTableColumnExists('global_roles') + ->assertTableColumnExists('site_roles'); +}); + +test('filter shows users from all accessible sites when enabled', function () { + $site1 = Site::factory()->create(); + $site2 = Site::factory()->create(); + + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $user1->sites()->attach($site1); + $user2->sites()->attach($site2); + + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + $admin->sites()->attach([$site1, $site2]); + $this->actingAs($admin); + Filament::setTenant($site1); + + livewire(ListUsers::class, ['tenant' => $site1]) + ->filterTable('user_visibility', true) + ->assertCanSeeTableRecords([$user1, $user2]); +}); + +test('role filters work for global and site roles', function () { + $site = Site::factory()->create(); + + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $user1->sites()->attach($site); + $user2->sites()->attach($site); + + $globalRole = Role::create([ + 'name' => 'global_admin', + config('permission.column_names.team_foreign_key') => null, // Global role + ]); + + $siteRole = Role::create([ + 'name' => 'site_editor', + config('permission.column_names.team_foreign_key') => $site->id, // Site-specific role + ]); + + $user1->assignRole($globalRole); + $user2->assignRole($siteRole); + + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + $admin->sites()->attach($site); + + $this->actingAs($admin); + Filament::setTenant($site); + + livewire(ListUsers::class, ['tenant' => $site]) + ->assertSuccessful() + ->assertCanSeeTableRecords([$user1, $user2]); +}); diff --git a/tests/Feature/UserImpersonationTest.php b/tests/Feature/UserImpersonationTest.php index 6f1d6e7..ae01cf0 100644 --- a/tests/Feature/UserImpersonationTest.php +++ b/tests/Feature/UserImpersonationTest.php @@ -34,6 +34,9 @@ }); test('non-authorized user cannot impersonate other users', function () { + $this->unauthorizedUser->syncRoles([]); + $this->unauthorizedUser->syncPermissions([]); + // Login as unauthorized user Auth::login($this->unauthorizedUser); @@ -48,6 +51,9 @@ }); test('non-authorized user cannot see and trigger the impersonate table and page action', function () { + $this->unauthorizedUser->syncRoles([]); + $this->unauthorizedUser->syncPermissions([]); + // Login as unauthorized user Auth::login($this->unauthorizedUser); diff --git a/tests/Feature/UserTest.php b/tests/Feature/UserTest.php new file mode 100644 index 0000000..54fee6f --- /dev/null +++ b/tests/Feature/UserTest.php @@ -0,0 +1,23 @@ +create(); + + $this->actingAs($user); + + expect($user->hasRole('panel_user'))->toBeTrue(); +}); + +test('user can only access sites they belong to', function () { + $site1 = Site::factory()->create(); + $site2 = Site::factory()->create(); + $user = User::factory()->create(); + + $user->sites()->attach($site1); + + expect($user->canAccessTenant($site1))->toBeTrue(); + expect($user->canAccessTenant($site2))->toBeFalse(); +}); diff --git a/tests/Feature/UserTrashRestoreTest.php b/tests/Feature/UserTrashRestoreTest.php index 556d1c3..47958dd 100644 --- a/tests/Feature/UserTrashRestoreTest.php +++ b/tests/Feature/UserTrashRestoreTest.php @@ -21,6 +21,10 @@ test('non-authorized user cannot trash another user', function () { $user = User::factory()->create(); $targetUser = User::factory()->create(); + + $user->syncRoles([]); + $user->syncPermissions([]); + Auth::login($user); $this->assertFalse($user->hasPermissionTo('delete_user')); $this->assertFalse($user->can('delete', $targetUser)); @@ -66,6 +70,10 @@ $userToTrash = User::factory()->create(); $userToTrash->delete(); $nonAuthorizedUser = User::factory()->create(); + + $nonAuthorizedUser->syncRoles([]); + $nonAuthorizedUser->syncPermissions([]); + Auth::login($nonAuthorizedUser); $this->assertFalse($nonAuthorizedUser->hasPermissionTo('restore_user')); $this->assertFalse($nonAuthorizedUser->can('restore', $userToTrash)); @@ -101,6 +109,10 @@ $userToTrash = User::factory()->create(); $userToTrash->delete(); $nonAuthorizedUser = User::factory()->create(); + + $nonAuthorizedUser->syncRoles([]); + $nonAuthorizedUser->syncPermissions([]); + Auth::login($nonAuthorizedUser); $this->assertFalse($nonAuthorizedUser->hasPermissionTo('force_delete_user')); $this->assertFalse($nonAuthorizedUser->can('forceDelete', $userToTrash));