From 7b3c664f3f402418251144d9211ae0928d298ab3 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Wed, 11 Jun 2025 08:20:21 +0545 Subject: [PATCH 01/15] refactor(tests): update middleware reference to SetupSite --- tests/TestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/TestCase.php b/tests/TestCase.php index bbfac22..226977c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -28,7 +28,7 @@ protected function setUp(): void $this->withoutVite(); - // Ensure SetupTenant middleware is applied during tests + // Ensure SetupSite middleware is applied during tests // This is done here since the "withMiddleware" method in workbench/bootstrap/app.php does not seem to work // $this->withMiddleware(SetupTenant::class) also does not work app(Kernel::class)->pushMiddleware(SetupSite::class); From dfd28772400cc97402f45e47ca43f9d074636e51 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Thu, 12 Jun 2025 14:26:09 +0545 Subject: [PATCH 02/15] fix: improve user role and site management --- .../filament-shield/en/filament-shield.php | 2 +- src/EclipseServiceProvider.php | 19 +- src/Filament/Resources/RoleResource.php | 217 ++++++++++++++ .../RoleResource/Pages/CreateRole.php | 47 +++ .../Resources/RoleResource/Pages/EditRole.php | 54 ++++ .../RoleResource/Pages/ListRoles.php | 19 ++ .../Resources/RoleResource/Pages/ViewRole.php | 19 ++ src/Filament/Resources/UserResource.php | 282 +++++++++++++----- src/Models/Site.php | 29 ++ src/Models/User.php | 11 + src/Models/User/Role.php | 10 + src/Providers/AdminPanelProvider.php | 2 +- tests/Feature/AccessTest.php | 2 +- .../Filament/Resources/LocaleResourceTest.php | 5 +- .../Filament/Resources/RoleResourceTest.php | 55 ++++ .../Filament/Resources/UserResourceTest.php | 185 +++++++++++- tests/Feature/UserImpersonationTest.php | 6 + tests/Feature/UserTest.php | 23 ++ tests/Feature/UserTrashRestoreTest.php | 12 + 19 files changed, 920 insertions(+), 79 deletions(-) create mode 100644 src/Filament/Resources/RoleResource.php create mode 100644 src/Filament/Resources/RoleResource/Pages/CreateRole.php create mode 100644 src/Filament/Resources/RoleResource/Pages/EditRole.php create mode 100644 src/Filament/Resources/RoleResource/Pages/ListRoles.php create mode 100644 src/Filament/Resources/RoleResource/Pages/ViewRole.php create mode 100644 tests/Feature/Filament/Resources/RoleResourceTest.php create mode 100644 tests/Feature/UserTest.php diff --git a/resources/lang/vendor/filament-shield/en/filament-shield.php b/resources/lang/vendor/filament-shield/en/filament-shield.php index a1c1c2c..3f044bd 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', diff --git a/src/EclipseServiceProvider.php b/src/EclipseServiceProvider.php index fdb7ec9..7711279 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; @@ -59,7 +61,7 @@ public function register(): self { parent::register(); - require_once __DIR__.'/Helpers/helpers.php'; + require_once __DIR__ . '/Helpers/helpers.php'; Event::listen(Login::class, function ($event) { if ($event->user instanceof User) { @@ -94,7 +96,7 @@ public function boot(): void } // Enable Model strictness when not in production - Model::shouldBeStrict(! app()->isProduction()); + Model::shouldBeStrict(!app()->isProduction()); // Do not allow destructive DB commands in production DB::prohibitDestructiveCommands(app()->isProduction()); @@ -110,9 +112,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..d8acde8 --- /dev/null +++ b/src/Filament/Resources/RoleResource.php @@ -0,0 +1,217 @@ +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\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]) + ->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..7c5e724 --- /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..fdd33be --- /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'), - 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'), + ]), + + Forms\Components\Section::make(__('Access Control')) + ->compact() + ->schema([ + Forms\Components\Select::make('sites') + ->relationship('sites', 'name') + ->getOptionLabelFromRecordUsing(fn(Model $record): string => + "{$record->name} ({$record->domain})") + ->multiple() + ->preload(), + + Forms\Components\Select::make('roles') + ->relationship('roles', 'name') + ->getOptionLabelFromRecordUsing(function ($record): string { + $suffix = $record->site_id ? ' (Site-Specific)' : ' (Global)'; + return "{$record->name}{$suffix}"; + }) + ->saveRelationshipsUsing(function (User $record, $state) { + $record->roles()->syncWithPivotValues($state, [config('permission.column_names.team_foreign_key') => getPermissionsTeamId()]); + }) + ->multiple() + ->preload() + ->searchable(), + ]) ]); } @@ -76,7 +108,7 @@ public static function table(Table $table): Table ->toggleable() ->size(50) ->circular() - ->defaultImageUrl(fn (User $user) => 'https://ui-avatars.com/api/?name='.urlencode($user->name)), + ->defaultImageUrl(fn(User $user) => 'https://ui-avatars.com/api/?name=' . urlencode($user->name)), Tables\Columns\TextColumn::make('first_name') ->searchable() ->sortable() @@ -99,7 +131,7 @@ public static function table(Table $table): Table ->label('Total Logins') ->sortable() ->numeric() - ->formatStateUsing(fn (?int $state) => $state ?? 0), + ->formatStateUsing(fn(?int $state) => $state ?? 0), ]; if (config('eclipse.email_verification')) { @@ -107,9 +139,9 @@ public static function table(Table $table): Table ->searchable() ->sortable() ->width(150) - ->icon(fn (User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle') - ->iconColor(fn (User $user) => $user->email_verified_at ? Color::Green : Color::Red) - ->tooltip(fn (User $user) => $user->email_verified_at ? 'Verified' : 'Not verified'); + ->icon(fn(User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle') + ->iconColor(fn(User $user) => $user->email_verified_at ? Color::Green : Color::Red) + ->tooltip(fn(User $user) => $user->email_verified_at ? 'Verified' : 'Not verified'); } else { $columns[] = Tables\Columns\TextColumn::make('email') ->searchable() @@ -126,6 +158,37 @@ 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') + ) + ->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'); + }) + ->sortable(false) + ->placeholder('No site roles') + ->toggleable(); + $columns[] = Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable() @@ -138,38 +201,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(), @@ -178,10 +213,10 @@ public static function table(Table $table): Table ->grouped() ->redirectTo(route('filament.admin.tenant')), Tables\Actions\DeleteAction::make() - ->authorize(fn (User $record) => auth()->user()->can('delete_user') && auth()->id() !== $record->id) + ->authorize(fn(User $record) => auth()->user()->can('delete_user') && auth()->id() !== $record->id) ->requiresConfirmation(), Tables\Actions\RestoreAction::make() - ->visible(fn (User $user) => $user->trashed() && auth()->user()->can('restore_user')) + ->visible(fn(User $user) => $user->trashed() && auth()->user()->can('restore_user')) ->requiresConfirmation(), ]), ]) @@ -207,36 +242,139 @@ 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') - ->defaultImageUrl(fn (User $user) => 'https://ui-avatars.com/api/?name='.urlencode($user->name)) + ->defaultImageUrl(fn(User $user) => 'https://ui-avatars.com/api/?name=' . urlencode($user->name)) ->circular(), Group::make() ->schema([ TextEntry::make('name') ->label('Full name'), TextEntry::make('email') - ->icon(config('eclipse.email_verification') ? fn (User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle' : null) - ->iconColor(fn (User $user) => $user->email_verified_at ? Color::Green : Color::Red), + ->icon(config('eclipse.email_verification') ? fn(User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle' : null) + ->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)'; + return "✓ {$state->name}{$suffix}"; + }) + + ]) ]); } + public static function getRelations(): array { return [ @@ -290,10 +428,12 @@ public static function getGloballySearchableAttributes(): array public static function getEloquentQuery(): Builder { - return parent::getEloquentQuery() - ->withoutGlobalScopes([ - SoftDeletingScope::class, - ]); + + $query = parent::getEloquentQuery()->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + + return $query; } public static function getPermissionPrefixes(): array diff --git a/src/Models/Site.php b/src/Models/Site.php index f824a6a..9898c5d 100644 --- a/src/Models/Site.php +++ b/src/Models/Site.php @@ -3,8 +3,11 @@ 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; class Site extends Model { @@ -32,6 +35,32 @@ 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(); diff --git a/src/Models/User.php b/src/Models/User.php index 5d99bd5..dd46aec 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -2,6 +2,7 @@ namespace Eclipse\Core\Models; +use Eclipse\Core\Models\User\Role; use Eclipse\Core\Database\Factories\UserFactory; use Exception; use Filament\Models\Contracts\FilamentUser; @@ -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); + } + }); } /** diff --git a/src/Models/User/Role.php b/src/Models/User/Role.php index 87cc2ac..fe12727 100644 --- a/src/Models/User/Role.php +++ b/src/Models/User/Role.php @@ -4,7 +4,9 @@ use Eclipse\Core\Database\Factories\RoleFactory; use Eclipse\Core\Models\Site; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Support\Str; use Spatie\Permission\Models\Role as SpatieRole; class Role extends SpatieRole @@ -16,6 +18,14 @@ public function site() return $this->belongsTo(Site::class); } + protected function name(): Attribute { + + + return Attribute::make( + get: fn (string $value) => Str::headline($value) + ); + } + protected static function newFactory() { return RoleFactory::new(); diff --git a/src/Providers/AdminPanelProvider.php b/src/Providers/AdminPanelProvider.php index de83609..537834d 100644 --- a/src/Providers/AdminPanelProvider.php +++ b/src/Providers/AdminPanelProvider.php @@ -68,7 +68,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/AccessTest.php b/tests/Feature/AccessTest.php index 9ff5cee..e557808 100644 --- a/tests/Feature/AccessTest.php +++ b/tests/Feature/AccessTest.php @@ -71,4 +71,4 @@ // Test access $this->actingAs($user); $this->get(config('log-viewer.route_path', 'log-viewer'))->assertStatus(200); -}); +}); \ No newline at end of file diff --git a/tests/Feature/Filament/Resources/LocaleResourceTest.php b/tests/Feature/Filament/Resources/LocaleResourceTest.php index 09fd8b7..e6df018 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(); @@ -79,7 +82,7 @@ expect($locale)->toBeObject(); foreach ($data as $key => $val) { - expect($locale->$key)->toEqual($val, "Failed asserting that attribute $key value ".$locale->$key.' is equal to '.$val); + expect($locale->$key)->toEqual($val, "Failed asserting that attribute $key value " . $locale->$key . ' is equal to ' . $val); } }); diff --git a/tests/Feature/Filament/Resources/RoleResourceTest.php b/tests/Feature/Filament/Resources/RoleResourceTest.php new file mode 100644 index 0000000..9174999 --- /dev/null +++ b/tests/Feature/Filament/Resources/RoleResourceTest.php @@ -0,0 +1,55 @@ +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, + ]); +}); \ No newline at end of file diff --git a/tests/Feature/Filament/Resources/UserResourceTest.php b/tests/Feature/Filament/Resources/UserResourceTest.php index 5dd19c9..9fb0c3e 100644 --- a/tests/Feature/Filament/Resources/UserResourceTest.php +++ b/tests/Feature/Filament/Resources/UserResourceTest.php @@ -2,8 +2,12 @@ use Eclipse\Core\Filament\Resources\UserResource; use Eclipse\Core\Filament\Resources\UserResource\Pages\CreateUser; +use Eclipse\Core\Filament\Resources\UserResource\Pages\EditUser; 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,17 +127,24 @@ }); 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 () { $superAdmin = User::withTrashed()->find($this->superAdmin->id); @@ -152,3 +163,173 @@ $this->assertModelExists($user); } }); + + +test('user can be created with sites multi-select', function () { + $site1 = Site::factory()->create(); + $site2 = Site::factory()->create(); + + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + + $this->actingAs($admin); + Filament::setTenant($site1); + + $email = fake()->unique()->safeEmail(); + + livewire(CreateUser::class, ['tenant' => $site1]) + ->fillForm([ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'email' => $email, + 'password' => 'password123', + 'sites' => [$site1->id, $site2->id], + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $user = User::where('email', $email)->first(); + + expect($user->sites)->toHaveCount(2); + expect($user->sites->pluck('id'))->toContain($site1->id, $site2->id); +}); + +test('user sites can be updated via multi-select in edit', function () { + $site1 = Site::factory()->create(); + $site2 = Site::factory()->create(); + $site3 = Site::factory()->create(); + $user = User::factory()->create(); + + $user->sites()->attach($site1); + + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + + $this->actingAs($admin); + Filament::setTenant($site1); + + livewire(EditUser::class, ['record' => $user->id, 'tenant' => $site1]) + ->fillForm([ + 'sites' => [$site2->id, $site3->id], + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $user->refresh(); + + expect($user->sites)->toHaveCount(2); + expect($user->sites->pluck('id'))->toContain($site2->id, $site3->id); + expect($user->sites->pluck('id'))->not->toContain($site1->id); +}); + +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]); +}); \ No newline at end of file diff --git a/tests/Feature/UserImpersonationTest.php b/tests/Feature/UserImpersonationTest.php index 6f1d6e7..05c8dcd 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..ba627b5 --- /dev/null +++ b/tests/Feature/UserTest.php @@ -0,0 +1,23 @@ +create(); + $site2 = Site::factory()->create(); + $user = User::factory()->create(); + + $user->sites()->attach($site1); + + expect($user->canAccessTenant($site1))->toBeTrue(); + expect($user->canAccessTenant($site2))->toBeFalse(); +}); \ No newline at end of file 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)); From 4bfefec16cfb3003b16708a2608c7558f8be6c89 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Fri, 13 Jun 2025 16:36:47 +0545 Subject: [PATCH 03/15] fix: resolving formatting issue --- src/EclipseServiceProvider.php | 4 +- src/Filament/Resources/RoleResource.php | 2 +- .../RoleResource/Pages/CreateRole.php | 2 +- .../Resources/RoleResource/Pages/EditRole.php | 2 +- src/Filament/Resources/UserResource.php | 67 +++++++++---------- src/Models/User.php | 2 +- src/Models/User/Role.php | 4 +- .../Filament/Resources/LocaleResourceTest.php | 2 +- .../Filament/Resources/RoleResourceTest.php | 17 ++--- .../Filament/Resources/UserResourceTest.php | 13 ++-- tests/Feature/UserImpersonationTest.php | 4 +- tests/Feature/UserTest.php | 10 +-- 12 files changed, 61 insertions(+), 68 deletions(-) diff --git a/src/EclipseServiceProvider.php b/src/EclipseServiceProvider.php index efa8557..db1237b 100644 --- a/src/EclipseServiceProvider.php +++ b/src/EclipseServiceProvider.php @@ -72,7 +72,7 @@ public function register(): self { parent::register(); - require_once __DIR__ . '/Helpers/helpers.php'; + require_once __DIR__.'/Helpers/helpers.php'; Event::listen(Login::class, function ($event) { if ($event->user instanceof User) { @@ -107,7 +107,7 @@ public function boot(): void } // Enable Model strictness when not in production - Model::shouldBeStrict(!app()->isProduction()); + Model::shouldBeStrict(! app()->isProduction()); // Do not allow destructive DB commands in production DB::prohibitDestructiveCommands(app()->isProduction()); diff --git a/src/Filament/Resources/RoleResource.php b/src/Filament/Resources/RoleResource.php index d8acde8..98ea07f 100644 --- a/src/Filament/Resources/RoleResource.php +++ b/src/Filament/Resources/RoleResource.php @@ -4,9 +4,9 @@ use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions; use BezhanSalleh\FilamentShield\Forms\ShieldSelectAllToggle; -use Eclipse\Core\Filament\Resources\RoleResource\Pages; use BezhanSalleh\FilamentShield\Support\Utils; use BezhanSalleh\FilamentShield\Traits\HasShieldFormComponents; +use Eclipse\Core\Filament\Resources\RoleResource\Pages; use Filament\Facades\Filament; use Filament\Forms; use Filament\Forms\Form; diff --git a/src/Filament/Resources/RoleResource/Pages/CreateRole.php b/src/Filament/Resources/RoleResource/Pages/CreateRole.php index 7c5e724..8cd0749 100644 --- a/src/Filament/Resources/RoleResource/Pages/CreateRole.php +++ b/src/Filament/Resources/RoleResource/Pages/CreateRole.php @@ -2,8 +2,8 @@ namespace Eclipse\Core\Filament\Resources\RoleResource\Pages; -use Eclipse\Core\Filament\Resources\RoleResource; use BezhanSalleh\FilamentShield\Support\Utils; +use Eclipse\Core\Filament\Resources\RoleResource; use Filament\Resources\Pages\CreateRecord; use Illuminate\Support\Arr; use Illuminate\Support\Collection; diff --git a/src/Filament/Resources/RoleResource/Pages/EditRole.php b/src/Filament/Resources/RoleResource/Pages/EditRole.php index fdd33be..e26c846 100644 --- a/src/Filament/Resources/RoleResource/Pages/EditRole.php +++ b/src/Filament/Resources/RoleResource/Pages/EditRole.php @@ -2,8 +2,8 @@ namespace Eclipse\Core\Filament\Resources\RoleResource\Pages; -use Eclipse\Core\Filament\Resources\RoleResource; use BezhanSalleh\FilamentShield\Support\Utils; +use Eclipse\Core\Filament\Resources\RoleResource; use Filament\Actions; use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Arr; diff --git a/src/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index 022012a..958e37b 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -3,7 +3,6 @@ namespace Eclipse\Core\Filament\Resources; use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions; -use Blade; use Eclipse\Core\Filament\Exports\TableExport; use Eclipse\Core\Filament\Resources; use Eclipse\Core\Models\User; @@ -68,10 +67,10 @@ public static function form(Form $form): Form 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'), + ->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'), ]), Forms\Components\Section::make(__('Access Control')) @@ -79,8 +78,7 @@ public static function form(Form $form): Form ->schema([ Forms\Components\Select::make('sites') ->relationship('sites', 'name') - ->getOptionLabelFromRecordUsing(fn(Model $record): string => - "{$record->name} ({$record->domain})") + ->getOptionLabelFromRecordUsing(fn (Model $record): string => "{$record->name} ({$record->domain})") ->multiple() ->preload(), @@ -88,6 +86,7 @@ public static function form(Form $form): Form ->relationship('roles', 'name') ->getOptionLabelFromRecordUsing(function ($record): string { $suffix = $record->site_id ? ' (Site-Specific)' : ' (Global)'; + return "{$record->name}{$suffix}"; }) ->saveRelationshipsUsing(function (User $record, $state) { @@ -96,7 +95,7 @@ public static function form(Form $form): Form ->multiple() ->preload() ->searchable(), - ]) + ]), ]); } @@ -108,7 +107,7 @@ public static function table(Table $table): Table ->toggleable() ->size(50) ->circular() - ->defaultImageUrl(fn(User $user) => 'https://ui-avatars.com/api/?name=' . urlencode($user->name)), + ->defaultImageUrl(fn (User $user) => 'https://ui-avatars.com/api/?name='.urlencode($user->name)), Tables\Columns\TextColumn::make('first_name') ->searchable() ->sortable() @@ -131,7 +130,7 @@ public static function table(Table $table): Table ->label('Total Logins') ->sortable() ->numeric() - ->formatStateUsing(fn(?int $state) => $state ?? 0), + ->formatStateUsing(fn (?int $state) => $state ?? 0), ]; if (config('eclipse.email_verification')) { @@ -139,9 +138,9 @@ public static function table(Table $table): Table ->searchable() ->sortable() ->width(150) - ->icon(fn(User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle') - ->iconColor(fn(User $user) => $user->email_verified_at ? Color::Green : Color::Red) - ->tooltip(fn(User $user) => $user->email_verified_at ? 'Verified' : 'Not verified'); + ->icon(fn (User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle') + ->iconColor(fn (User $user) => $user->email_verified_at ? Color::Green : Color::Red) + ->tooltip(fn (User $user) => $user->email_verified_at ? 'Verified' : 'Not verified'); } else { $columns[] = Tables\Columns\TextColumn::make('email') ->searchable() @@ -163,9 +162,9 @@ public static function table(Table $table): Table ->translateLabel() ->badge() ->getStateUsing( - fn(User $record): Collection => $record + fn (User $record): Collection => $record ->roles() - ->whereNull('roles.' . config('permission.column_names.team_foreign_key')) + ->whereNull('roles.'.config('permission.column_names.team_foreign_key')) ->pluck('name') ) ->sortable(false) @@ -178,11 +177,12 @@ public static function table(Table $table): Table ->badge() ->color('warning') ->getStateUsing(function (User $record) { - if (!Filament::getTenant()) + if (! Filament::getTenant()) { return 'No site context'; + } return $record->roles() - ->where('roles.' . config('permission.column_names.team_foreign_key'), Filament::getTenant()->id) + ->where('roles.'.config('permission.column_names.team_foreign_key'), Filament::getTenant()->id) ->pluck('name'); }) ->sortable(false) @@ -213,10 +213,10 @@ public static function table(Table $table): Table ->grouped() ->redirectTo(route('filament.admin.tenant')), Tables\Actions\DeleteAction::make() - ->authorize(fn(User $record) => auth()->user()->can('delete_user') && auth()->id() !== $record->id) + ->authorize(fn (User $record) => auth()->user()->can('delete_user') && auth()->id() !== $record->id) ->requiresConfirmation(), Tables\Actions\RestoreAction::make() - ->visible(fn(User $user) => $user->trashed() && auth()->user()->can('restore_user')) + ->visible(fn (User $user) => $user->trashed() && auth()->user()->can('restore_user')) ->requiresConfirmation(), ]), ]) @@ -251,8 +251,7 @@ private static function getTableFilters(): array ->trueLabel(__('All accessible sites')) ->falseLabel(__('Current site only')) ->queries( - true: function (Builder $query): void { - }, + true: function (Builder $query): void {}, false: function (Builder $query): void { if (Filament::getTenant()) { $query->whereHas('sites', function ($subQuery) { @@ -273,7 +272,7 @@ private static function getTableFilters(): array ->label('Global Roles') ->relationship('roles', 'name', function (Builder $query): void { $query - ->whereNull('roles.' . config('permission.column_names.team_foreign_key')); + ->whereNull('roles.'.config('permission.column_names.team_foreign_key')); }) ->multiple() ->searchable() @@ -283,13 +282,13 @@ private static function getTableFilters(): array ->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); + $query->where('roles.'.config('permission.column_names.team_foreign_key'), Filament::getTenant()->id); } }) ->multiple() ->searchable() ->preload() - ->visible(fn() => Filament::getTenant() !== null), + ->visible(fn () => Filament::getTenant() !== null), Tables\Filters\TernaryFilter::make('email_verified_at') ->label('Email verification') @@ -298,9 +297,9 @@ private static function getTableFilters(): array ->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, + 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() @@ -339,15 +338,15 @@ public static function infolist(Infolist $infolist): Infolist ->schema([ SpatieMediaLibraryImageEntry::make('avatar') ->collection('avatars') - ->defaultImageUrl(fn(User $user) => 'https://ui-avatars.com/api/?name=' . urlencode($user->name)) + ->defaultImageUrl(fn (User $user) => 'https://ui-avatars.com/api/?name='.urlencode($user->name)) ->circular(), Group::make() ->schema([ TextEntry::make('name') ->label('Full name'), TextEntry::make('email') - ->icon(config('eclipse.email_verification') ? fn(User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle' : null) - ->iconColor(fn(User $user) => $user->email_verified_at ? Color::Green : Color::Red), + ->icon(config('eclipse.email_verification') ? fn (User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle' : null) + ->iconColor(fn (User $user) => $user->email_verified_at ? Color::Green : Color::Red), ]), ]), Section::make(__('Access Information')) @@ -359,7 +358,7 @@ public static function infolist(Infolist $infolist): Infolist ->weight(FontWeight::Medium) ->listWithLineBreaks() ->placeholder(__(' No sites accessible')) - ->formatStateUsing(fn($state) => "✓ {$state->name} ({$state->domain})"), + ->formatStateUsing(fn ($state) => "✓ {$state->name} ({$state->domain})"), TextEntry::make('roles') ->listWithLineBreaks() @@ -367,14 +366,14 @@ public static function infolist(Infolist $infolist): Infolist ->placeholder(__('No roles assigned')) ->formatStateUsing(function ($state): string { $suffix = $state->site_id ? ' (Site-Specific)' : ' (Global)'; + return "✓ {$state->name}{$suffix}"; - }) + }), - ]) + ]), ]); } - public static function getRelations(): array { return [ diff --git a/src/Models/User.php b/src/Models/User.php index dd46aec..71172ec 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -2,8 +2,8 @@ namespace Eclipse\Core\Models; -use Eclipse\Core\Models\User\Role; use Eclipse\Core\Database\Factories\UserFactory; +use Eclipse\Core\Models\User\Role; use Exception; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasAvatar; diff --git a/src/Models/User/Role.php b/src/Models/User/Role.php index fe12727..f2705be 100644 --- a/src/Models/User/Role.php +++ b/src/Models/User/Role.php @@ -18,8 +18,8 @@ public function site() return $this->belongsTo(Site::class); } - protected function name(): Attribute { - + protected function name(): Attribute + { return Attribute::make( get: fn (string $value) => Str::headline($value) diff --git a/tests/Feature/Filament/Resources/LocaleResourceTest.php b/tests/Feature/Filament/Resources/LocaleResourceTest.php index e6df018..94dd4e4 100644 --- a/tests/Feature/Filament/Resources/LocaleResourceTest.php +++ b/tests/Feature/Filament/Resources/LocaleResourceTest.php @@ -82,7 +82,7 @@ expect($locale)->toBeObject(); foreach ($data as $key => $val) { - expect($locale->$key)->toEqual($val, "Failed asserting that attribute $key value " . $locale->$key . ' is equal to ' . $val); + expect($locale->$key)->toEqual($val, "Failed asserting that attribute $key value ".$locale->$key.' is equal to '.$val); } }); diff --git a/tests/Feature/Filament/Resources/RoleResourceTest.php b/tests/Feature/Filament/Resources/RoleResourceTest.php index 9174999..1f71120 100644 --- a/tests/Feature/Filament/Resources/RoleResourceTest.php +++ b/tests/Feature/Filament/Resources/RoleResourceTest.php @@ -6,16 +6,17 @@ use Eclipse\Core\Models\User; use Eclipse\Core\Models\User\Role; use Filament\Facades\Filament; + use function Pest\Livewire\livewire; test('role can be created with site assignment', function () { $site = Site::factory()->create(); $admin = User::factory()->create(); $admin->assignRole('super_admin'); - + $this->actingAs($admin); Filament::setTenant($site); - + livewire(CreateRole::class, ['tenant' => $site]) ->fillForm([ 'name' => 'Site Manager', @@ -23,7 +24,7 @@ ]) ->call('create') ->assertHasNoFormErrors(); - + $this->assertDatabaseHas('roles', [ 'name' => 'Site Manager', 'site_id' => $site->id, @@ -34,22 +35,22 @@ $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, ]); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Filament/Resources/UserResourceTest.php b/tests/Feature/Filament/Resources/UserResourceTest.php index 9fb0c3e..8315403 100644 --- a/tests/Feature/Filament/Resources/UserResourceTest.php +++ b/tests/Feature/Filament/Resources/UserResourceTest.php @@ -144,7 +144,6 @@ expect($user->trashed())->toBeTrue(); }); - test('authed user cannot delete himself', function () { $superAdmin = User::withTrashed()->find($this->superAdmin->id); @@ -164,7 +163,6 @@ } }); - test('user can be created with sites multi-select', function () { $site1 = Site::factory()->create(); $site2 = Site::factory()->create(); @@ -253,13 +251,13 @@ $globalRole = Role::create([ 'name' => 'global_admin', 'guard_name' => 'web', - config('permission.column_names.team_foreign_key') => null // Global role + 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 + config('permission.column_names.team_foreign_key') => $site->id, // Site-specific role ]); $user->sites()->attach($site); @@ -278,7 +276,6 @@ ->assertTableColumnExists('site_roles'); }); - test('filter shows users from all accessible sites when enabled', function () { $site1 = Site::factory()->create(); $site2 = Site::factory()->create(); @@ -311,12 +308,12 @@ $globalRole = Role::create([ 'name' => 'global_admin', - config('permission.column_names.team_foreign_key') => null // Global role + 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 + config('permission.column_names.team_foreign_key') => $site->id, // Site-specific role ]); $user1->assignRole($globalRole); @@ -332,4 +329,4 @@ livewire(ListUsers::class, ['tenant' => $site]) ->assertSuccessful() ->assertCanSeeTableRecords([$user1, $user2]); -}); \ No newline at end of file +}); diff --git a/tests/Feature/UserImpersonationTest.php b/tests/Feature/UserImpersonationTest.php index 05c8dcd..ae01cf0 100644 --- a/tests/Feature/UserImpersonationTest.php +++ b/tests/Feature/UserImpersonationTest.php @@ -36,7 +36,7 @@ test('non-authorized user cannot impersonate other users', function () { $this->unauthorizedUser->syncRoles([]); $this->unauthorizedUser->syncPermissions([]); - + // Login as unauthorized user Auth::login($this->unauthorizedUser); @@ -53,7 +53,7 @@ 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 index ba627b5..bc5d944 100644 --- a/tests/Feature/UserTest.php +++ b/tests/Feature/UserTest.php @@ -3,13 +3,9 @@ use Eclipse\Core\Models\Site; use Eclipse\Core\Models\User; -test('new user automatically gets panel_user role', function () { - -}); - -test('user from seeder gets panel_user role', function () { +test('new user automatically gets panel_user role', function () {}); -}); +test('user from seeder gets panel_user role', function () {}); test('user can only access sites they belong to', function () { $site1 = Site::factory()->create(); @@ -20,4 +16,4 @@ expect($user->canAccessTenant($site1))->toBeTrue(); expect($user->canAccessTenant($site2))->toBeFalse(); -}); \ No newline at end of file +}); From 1dece7aa8f17712a89e2bdf29c15b21787f36d44 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Fri, 13 Jun 2025 17:06:16 +0545 Subject: [PATCH 04/15] fix: removing commented codes & improving site selector in role-resource --- .../lang/vendor/filament-shield/en/filament-shield.php | 2 +- src/Filament/Resources/RoleResource.php | 6 ++++++ src/Filament/Resources/RoleResource/Pages/EditRole.php | 2 +- src/Filament/Resources/UserResource.php | 7 +------ 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/resources/lang/vendor/filament-shield/en/filament-shield.php b/resources/lang/vendor/filament-shield/en/filament-shield.php index 3f044bd..e67e86c 100644 --- a/resources/lang/vendor/filament-shield/en/filament-shield.php +++ b/resources/lang/vendor/filament-shield/en/filament-shield.php @@ -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/Filament/Resources/RoleResource.php b/src/Filament/Resources/RoleResource.php index 98ea07f..cc5ba31 100644 --- a/src/Filament/Resources/RoleResource.php +++ b/src/Filament/Resources/RoleResource.php @@ -60,11 +60,17 @@ public static function form(Form $form): Form ->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')) + ->visibleOn('view'), + 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]) + ->visibleOn(['create', 'edit']) ->options(fn (): Arrayable => Utils::getTenantModel() ? Utils::getTenantModel()::pluck('name', 'id') : collect()) ->dehydrated(fn (): bool => ! (static::shield()->isCentralApp() && Utils::isTenancyEnabled())), ShieldSelectAllToggle::make('select_all') diff --git a/src/Filament/Resources/RoleResource/Pages/EditRole.php b/src/Filament/Resources/RoleResource/Pages/EditRole.php index e26c846..f461190 100644 --- a/src/Filament/Resources/RoleResource/Pages/EditRole.php +++ b/src/Filament/Resources/RoleResource/Pages/EditRole.php @@ -18,7 +18,7 @@ class EditRole extends EditRecord protected function getActions(): array { return [ - Actions\DeleteAction::make(), + Actions\ViewAction::make(), ]; } diff --git a/src/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index 958e37b..ede949e 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -33,8 +33,6 @@ class UserResource extends Resource implements HasShieldPermissions { - // protected static bool $isScopedToTenant = false; - protected static ?string $tenantOwnershipRelationshipName = 'sites'; protected static ?string $model = User::class; @@ -427,12 +425,9 @@ public static function getGloballySearchableAttributes(): array public static function getEloquentQuery(): Builder { - - $query = parent::getEloquentQuery()->withoutGlobalScopes([ + return parent::getEloquentQuery()->withoutGlobalScopes([ SoftDeletingScope::class, ]); - - return $query; } public static function getPermissionPrefixes(): array From 01fbf73c779a8e977dd15514983516a9dc4ba204 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Fri, 13 Jun 2025 19:14:55 +0545 Subject: [PATCH 05/15] fix: site id showing in role view page --- src/Filament/Resources/RoleResource.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Filament/Resources/RoleResource.php b/src/Filament/Resources/RoleResource.php index cc5ba31..4820181 100644 --- a/src/Filament/Resources/RoleResource.php +++ b/src/Filament/Resources/RoleResource.php @@ -63,14 +63,26 @@ public static function form(Form $form): Form 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')) - ->visibleOn('view'), + ->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]) - ->visibleOn(['create', 'edit']) + ->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') From 4073a068e541daa41e65cfd63022bc7bc22e9685 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Fri, 13 Jun 2025 19:17:14 +0545 Subject: [PATCH 06/15] fix: completing UserTest --- src/Models/User.php | 21 ++++++++++++++++++--- tests/Feature/UserTest.php | 6 ++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Models/User.php b/src/Models/User.php index 71172ec..9382e06 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -15,6 +15,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Permission\Traits\HasRoles; @@ -162,9 +163,23 @@ public function delete(): ?bool return parent::delete(); } - /** - * Determine if the user can impersonate other users. - */ + public function hasRoleGlobally($roles): bool + { + $roles = is_array($roles) ? $roles : [$roles]; + + $modelHasRolesTable = config('permission.table_names.model_has_roles', 'model_has_roles'); + $rolesTable = config('permission.table_names.roles', 'roles'); + $modelMorphKey = config('permission.column_names.model_morph_key', 'model_id'); + $rolePivotKey = config('permission.column_names.role_pivot_key', 'role_id') ?: 'role_id'; + + return DB::table($modelHasRolesTable) + ->join($rolesTable, "{$rolesTable}.id", '=', "{$modelHasRolesTable}.{$rolePivotKey}") + ->where("{$modelHasRolesTable}.{$modelMorphKey}", $this->id) + ->where("{$modelHasRolesTable}.model_type", static::class) + ->whereIn("{$rolesTable}.name", $roles) + ->exists(); + } + public function canImpersonate(): bool { return $this->can('impersonate', User::class); diff --git a/tests/Feature/UserTest.php b/tests/Feature/UserTest.php index bc5d944..41c4d25 100644 --- a/tests/Feature/UserTest.php +++ b/tests/Feature/UserTest.php @@ -3,9 +3,11 @@ use Eclipse\Core\Models\Site; use Eclipse\Core\Models\User; -test('new user automatically gets panel_user role', function () {}); +test('new user automatically gets panel_user role', function () { + $user = User::factory()->create(); -test('user from seeder gets panel_user role', function () {}); + expect($user->hasRoleGlobally('panel_user'))->toBeTrue(); +}); test('user can only access sites they belong to', function () { $site1 = Site::factory()->create(); From 6e149067cef799e45addea9203784d2b2e4429f6 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Fri, 13 Jun 2025 19:39:54 +0545 Subject: [PATCH 07/15] fix: failing test --- src/Models/Site.php | 7 +++++++ tests/Feature/AccessTest.php | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Models/Site.php b/src/Models/Site.php index 9898c5d..b82de95 100644 --- a/src/Models/Site.php +++ b/src/Models/Site.php @@ -8,6 +8,7 @@ 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 { @@ -65,4 +66,10 @@ 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/tests/Feature/AccessTest.php b/tests/Feature/AccessTest.php index a9de4f2..e3fc7ba 100644 --- a/tests/Feature/AccessTest.php +++ b/tests/Feature/AccessTest.php @@ -66,7 +66,7 @@ $user->assignRole('super_admin'); // Assert the user has super_admin role - $this->assertTrue($user->hasRole('super_admin')); + $this->assertTrue($user->hasRoleGlobally('super_admin')); // Test access $this->actingAs($user); From 124f622e94a7c980df8c63f3b60d8f3b2822e98a Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Tue, 17 Jun 2025 00:53:55 +0545 Subject: [PATCH 08/15] fix: removing sites form field from UserResource --- src/Filament/Resources/UserResource.php | 16 ++--- .../Filament/Resources/UserResourceTest.php | 58 ------------------- 2 files changed, 9 insertions(+), 65 deletions(-) diff --git a/src/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index ede949e..dca7f22 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -6,6 +6,7 @@ use Eclipse\Core\Filament\Exports\TableExport; use Eclipse\Core\Filament\Resources; use Eclipse\Core\Models\User; +use Eclipse\Core\Models\User\Role; use Filament\Facades\Filament; use Filament\Forms; use Filament\Forms\Form; @@ -24,7 +25,6 @@ use Filament\Tables\Filters\QueryBuilder\Constraints\TextConstraint; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Hash; @@ -74,13 +74,8 @@ public static function form(Form $form): Form Forms\Components\Section::make(__('Access Control')) ->compact() ->schema([ - Forms\Components\Select::make('sites') - ->relationship('sites', 'name') - ->getOptionLabelFromRecordUsing(fn (Model $record): string => "{$record->name} ({$record->domain})") - ->multiple() - ->preload(), - Forms\Components\Select::make('roles') + ->hiddenLabel() ->relationship('roles', 'name') ->getOptionLabelFromRecordUsing(function ($record): string { $suffix = $record->site_id ? ' (Site-Specific)' : ' (Global)'; @@ -88,6 +83,13 @@ public static function form(Form $form): Form 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() diff --git a/tests/Feature/Filament/Resources/UserResourceTest.php b/tests/Feature/Filament/Resources/UserResourceTest.php index 8315403..07678ee 100644 --- a/tests/Feature/Filament/Resources/UserResourceTest.php +++ b/tests/Feature/Filament/Resources/UserResourceTest.php @@ -2,7 +2,6 @@ use Eclipse\Core\Filament\Resources\UserResource; use Eclipse\Core\Filament\Resources\UserResource\Pages\CreateUser; -use Eclipse\Core\Filament\Resources\UserResource\Pages\EditUser; use Eclipse\Core\Filament\Resources\UserResource\Pages\ListUsers; use Eclipse\Core\Models\Site; use Eclipse\Core\Models\User; @@ -163,63 +162,6 @@ } }); -test('user can be created with sites multi-select', function () { - $site1 = Site::factory()->create(); - $site2 = Site::factory()->create(); - - $admin = User::factory()->create(); - $admin->assignRole('super_admin'); - - $this->actingAs($admin); - Filament::setTenant($site1); - - $email = fake()->unique()->safeEmail(); - - livewire(CreateUser::class, ['tenant' => $site1]) - ->fillForm([ - 'first_name' => fake()->firstName(), - 'last_name' => fake()->lastName(), - 'email' => $email, - 'password' => 'password123', - 'sites' => [$site1->id, $site2->id], - ]) - ->call('create') - ->assertHasNoFormErrors(); - - $user = User::where('email', $email)->first(); - - expect($user->sites)->toHaveCount(2); - expect($user->sites->pluck('id'))->toContain($site1->id, $site2->id); -}); - -test('user sites can be updated via multi-select in edit', function () { - $site1 = Site::factory()->create(); - $site2 = Site::factory()->create(); - $site3 = Site::factory()->create(); - $user = User::factory()->create(); - - $user->sites()->attach($site1); - - $admin = User::factory()->create(); - $admin->assignRole('super_admin'); - - $this->actingAs($admin); - Filament::setTenant($site1); - - livewire(EditUser::class, ['record' => $user->id, 'tenant' => $site1]) - ->fillForm([ - 'sites' => [$site2->id, $site3->id], - ]) - ->call('save') - ->assertHasNoFormErrors(); - - $user->refresh(); - - expect($user->sites)->toHaveCount(2); - expect($user->sites->pluck('id'))->toContain($site2->id, $site3->id); - expect($user->sites->pluck('id'))->not->toContain($site1->id); -}); - test('user list shows only current site users by default', function () { $site1 = Site::factory()->create(); $site2 = Site::factory()->create(); From 9848e202fd4596763c4b846ea88d0fb190e200f3 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Tue, 17 Jun 2025 13:19:21 +0545 Subject: [PATCH 09/15] fix: error in testcases --- src/Filament/Resources/UserResource.php | 8 ++++++-- src/Models/User.php | 18 ------------------ src/Models/User/Role.php | 10 ---------- tests/Feature/AccessTest.php | 2 +- tests/Feature/UserTest.php | 4 +++- 5 files changed, 10 insertions(+), 32 deletions(-) diff --git a/src/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index dca7f22..4dbc499 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -28,6 +28,7 @@ use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; use pxlrbt\FilamentExcel\Actions\Tables\ExportBulkAction; use STS\FilamentImpersonate\Tables\Actions\Impersonate; @@ -166,6 +167,7 @@ public static function table(Table $table): Table ->roles() ->whereNull('roles.'.config('permission.column_names.team_foreign_key')) ->pluck('name') + ->map(fn ($roleName) => Str::headline($roleName)) ) ->sortable(false) ->placeholder('No global roles') @@ -183,7 +185,8 @@ public static function table(Table $table): Table return $record->roles() ->where('roles.'.config('permission.column_names.team_foreign_key'), Filament::getTenant()->id) - ->pluck('name'); + ->pluck('name') + ->map(fn ($roleName) => Str::headline($roleName)); }) ->sortable(false) ->placeholder('No site roles') @@ -366,8 +369,9 @@ public static function infolist(Infolist $infolist): Infolist ->placeholder(__('No roles assigned')) ->formatStateUsing(function ($state): string { $suffix = $state->site_id ? ' (Site-Specific)' : ' (Global)'; + $roleName = Str::headline($state->name); - return "✓ {$state->name}{$suffix}"; + return "✓ {$roleName}{$suffix}"; }), ]), diff --git a/src/Models/User.php b/src/Models/User.php index 9382e06..2184453 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -15,7 +15,6 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Permission\Traits\HasRoles; @@ -163,23 +162,6 @@ public function delete(): ?bool return parent::delete(); } - public function hasRoleGlobally($roles): bool - { - $roles = is_array($roles) ? $roles : [$roles]; - - $modelHasRolesTable = config('permission.table_names.model_has_roles', 'model_has_roles'); - $rolesTable = config('permission.table_names.roles', 'roles'); - $modelMorphKey = config('permission.column_names.model_morph_key', 'model_id'); - $rolePivotKey = config('permission.column_names.role_pivot_key', 'role_id') ?: 'role_id'; - - return DB::table($modelHasRolesTable) - ->join($rolesTable, "{$rolesTable}.id", '=', "{$modelHasRolesTable}.{$rolePivotKey}") - ->where("{$modelHasRolesTable}.{$modelMorphKey}", $this->id) - ->where("{$modelHasRolesTable}.model_type", static::class) - ->whereIn("{$rolesTable}.name", $roles) - ->exists(); - } - public function canImpersonate(): bool { return $this->can('impersonate', User::class); diff --git a/src/Models/User/Role.php b/src/Models/User/Role.php index f2705be..87cc2ac 100644 --- a/src/Models/User/Role.php +++ b/src/Models/User/Role.php @@ -4,9 +4,7 @@ use Eclipse\Core\Database\Factories\RoleFactory; use Eclipse\Core\Models\Site; -use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Support\Str; use Spatie\Permission\Models\Role as SpatieRole; class Role extends SpatieRole @@ -18,14 +16,6 @@ public function site() return $this->belongsTo(Site::class); } - protected function name(): Attribute - { - - return Attribute::make( - get: fn (string $value) => Str::headline($value) - ); - } - protected static function newFactory() { return RoleFactory::new(); diff --git a/tests/Feature/AccessTest.php b/tests/Feature/AccessTest.php index e3fc7ba..a9de4f2 100644 --- a/tests/Feature/AccessTest.php +++ b/tests/Feature/AccessTest.php @@ -66,7 +66,7 @@ $user->assignRole('super_admin'); // Assert the user has super_admin role - $this->assertTrue($user->hasRoleGlobally('super_admin')); + $this->assertTrue($user->hasRole('super_admin')); // Test access $this->actingAs($user); diff --git a/tests/Feature/UserTest.php b/tests/Feature/UserTest.php index 41c4d25..54fee6f 100644 --- a/tests/Feature/UserTest.php +++ b/tests/Feature/UserTest.php @@ -6,7 +6,9 @@ test('new user automatically gets panel_user role', function () { $user = User::factory()->create(); - expect($user->hasRoleGlobally('panel_user'))->toBeTrue(); + $this->actingAs($user); + + expect($user->hasRole('panel_user'))->toBeTrue(); }); test('user can only access sites they belong to', function () { From de8ce4cfebbc33c37eb9a2ad9d8209ed64269710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omer=20=C5=A0abi=C4=87?= Date: Tue, 17 Jun 2025 11:30:36 +0200 Subject: [PATCH 10/15] test: add more user tests --- .../Filament/Resources/RoleResourceTest.php | 19 ++---- tests/Feature/UserTest.php | 68 +++++++++++++++++++ 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/tests/Feature/Filament/Resources/RoleResourceTest.php b/tests/Feature/Filament/Resources/RoleResourceTest.php index 1f71120..c973e46 100644 --- a/tests/Feature/Filament/Resources/RoleResourceTest.php +++ b/tests/Feature/Filament/Resources/RoleResourceTest.php @@ -3,19 +3,14 @@ use Eclipse\Core\Filament\Resources\RoleResource\Pages\CreateRole; use Eclipse\Core\Filament\Resources\RoleResource\Pages\EditRole; use Eclipse\Core\Models\Site; -use Eclipse\Core\Models\User; use Eclipse\Core\Models\User\Role; use Filament\Facades\Filament; use function Pest\Livewire\livewire; test('role can be created with site assignment', function () { - $site = Site::factory()->create(); - $admin = User::factory()->create(); - $admin->assignRole('super_admin'); - - $this->actingAs($admin); - Filament::setTenant($site); + $this->set_up_super_admin_and_tenant(); + $site = Filament::getTenant(); livewire(CreateRole::class, ['tenant' => $site]) ->fillForm([ @@ -32,16 +27,12 @@ }); test('role can be edited to change site assignment', function () { - $site1 = Site::factory()->create(); + $this->set_up_super_admin_and_tenant(); + $site1 = Filament::getTenant(); + $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, diff --git a/tests/Feature/UserTest.php b/tests/Feature/UserTest.php index 54fee6f..8f6e075 100644 --- a/tests/Feature/UserTest.php +++ b/tests/Feature/UserTest.php @@ -2,6 +2,8 @@ use Eclipse\Core\Models\Site; use Eclipse\Core\Models\User; +use Filament\Facades\Filament; +use Spatie\Permission\Models\Role; test('new user automatically gets panel_user role', function () { $user = User::factory()->create(); @@ -21,3 +23,69 @@ expect($user->canAccessTenant($site1))->toBeTrue(); expect($user->canAccessTenant($site2))->toBeFalse(); }); + +test('user is correctly given global role', function () { + Role::create(['name' => 'global_admin', 'guard_name' => 'web']); + + $user = User::factory()->create(); + $user->assignRole('global_admin'); + $this->actingAs($user); + + expect($user->hasRole('global_admin'))->toBeTrue(); + + $site = Site::factory()->create(); + Filament::setTenant($site); + + expect($user->hasRole('global_admin'))->toBeTrue(); + + $site2 = Site::factory()->create(); + Filament::setTenant($site2); + + expect($user->hasRole('global_admin'))->toBeTrue(); +}); + +test('user is correctly given site role', function () { + $site = Site::factory()->create(); + $siteRole = Role::create(['name' => 'site_admin', 'guard_name' => 'web', 'site_id' => $site->id]); + + $user = User::factory()->create(); + $user->assignRole($siteRole); + $this->actingAs($user); + + Filament::setTenant($site); + + expect($user->hasRole('site_admin'))->toBeTrue(); +}); + +test('users with global roles are added to new sites', function () { + $globalRole = Role::create(['name' => 'global_admin', 'guard_name' => 'web']); + $user = User::factory()->create(); + $user->assignRole($globalRole); + $this->actingAs($user); + + $site = Site::factory()->create(); + Filament::setTenant($site); + + expect($user->hasRole('global_admin'))->toBeTrue(); +}); + +test('users with site roles are not added to new sites', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $site = Site::factory()->create(); + $siteRole = Role::create(['name' => 'site_admin', 'guard_name' => 'web', 'site_id' => $site->id]); + Filament::setTenant($site); + + $user->assignRole($siteRole); + + // Check on first site + expect($user->hasRole('site_admin'))->toBeTrue(); + + // Create extra site and re-check + $site2 = Site::factory()->create(); + Filament::setTenant($site2); + + expect($user->hasRole('site_admin'))->toBeFalse(); +}); + From b01fc2c2baaa810fffbad2432037a3de4115e0d0 Mon Sep 17 00:00:00 2001 From: ankitcodes4u Date: Tue, 24 Jun 2025 23:33:35 +0000 Subject: [PATCH 11/15] feat: adding role and permission in user resource --- ...0_add_is_global_model_has_roles_tables.php | 22 ++ src/Filament/Resources/RoleResource.php | 235 ------------------ .../RoleResource/Pages/CreateRole.php | 47 ---- .../Resources/RoleResource/Pages/EditRole.php | 54 ---- .../RoleResource/Pages/ListRoles.php | 19 -- .../Resources/RoleResource/Pages/ViewRole.php | 19 -- src/Filament/Resources/UserResource.php | 211 +++++++++++----- .../Resources/UserResource/Pages/EditUser.php | 66 +++++ src/Models/Site.php | 28 +-- src/Models/User.php | 10 + .../Filament/Resources/RoleResourceTest.php | 47 ---- .../Filament/Resources/UserResourceTest.php | 2 + tests/Feature/UserTest.php | 36 +-- 13 files changed, 263 insertions(+), 533 deletions(-) create mode 100644 database/migrations/2025_06_24_000000_add_is_global_model_has_roles_tables.php delete mode 100644 src/Filament/Resources/RoleResource.php delete mode 100644 src/Filament/Resources/RoleResource/Pages/CreateRole.php delete mode 100644 src/Filament/Resources/RoleResource/Pages/EditRole.php delete mode 100644 src/Filament/Resources/RoleResource/Pages/ListRoles.php delete mode 100644 src/Filament/Resources/RoleResource/Pages/ViewRole.php delete mode 100644 tests/Feature/Filament/Resources/RoleResourceTest.php diff --git a/database/migrations/2025_06_24_000000_add_is_global_model_has_roles_tables.php b/database/migrations/2025_06_24_000000_add_is_global_model_has_roles_tables.php new file mode 100644 index 0000000..c1d273e --- /dev/null +++ b/database/migrations/2025_06_24_000000_add_is_global_model_has_roles_tables.php @@ -0,0 +1,22 @@ +boolean('is_global')->default(false); + }); + } + + public function down() + { + Schema::table('model_has_roles', function (Blueprint $table) { + $table->dropColumn('is_global'); + }); + } +}; diff --git a/src/Filament/Resources/RoleResource.php b/src/Filament/Resources/RoleResource.php deleted file mode 100644 index 4820181..0000000 --- a/src/Filament/Resources/RoleResource.php +++ /dev/null @@ -1,235 +0,0 @@ -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 deleted file mode 100644 index 8cd0749..0000000 --- a/src/Filament/Resources/RoleResource/Pages/CreateRole.php +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index f461190..0000000 --- a/src/Filament/Resources/RoleResource/Pages/EditRole.php +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 70f068f..0000000 --- a/src/Filament/Resources/RoleResource/Pages/ListRoles.php +++ /dev/null @@ -1,19 +0,0 @@ -schema([ - Forms\Components\Section::make(__('Personal Information')) + Forms\Components\Section::make(__('Personal Info.')) ->columns(2) - ->compact() ->schema([ Forms\Components\SpatieMediaLibraryFileUpload::make('avatar') ->collection('avatars') @@ -68,46 +70,83 @@ public static function form(Form $form): Form 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') + ->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)) + fn(Set $set) => $set('password', Str::password(16)) ) ), ]), - - Forms\Components\Section::make(__('Access Control')) - ->compact() + Forms\Components\Section::make(__('Global Roles')) ->schema([ - Forms\Components\Select::make('roles') + Forms\Components\CheckboxList::make('global_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(); + ->columnSpanFull() + ->columns([ + 'sm' => 2, + 'md' => 3, + 'lg' => 4, + 'xl' => 5, + ]) + ->options(fn() => Role::pluck('name', 'id')->mapWithKeys( + fn($name, $key) => [$key => Str::headline($name)] + )) + ->afterStateHydrated(function ($component, $record) { + if ($record) { + $roles = DB::table('model_has_roles') + ->where('model_id', $record->id) + ->where('model_type', get_class($record)) + ->where('is_global', true) + ->pluck('role_id') + ->toArray(); + $component->state($roles); + } + }), + ]), - $record->sites()->sync($siteIDs); + Forms\Components\Tabs::make() + ->columnSpanFull() + ->tabs(function (): array { + $tabs = []; + foreach (Site::all() as $site) { + $tabs[] = Forms\Components\Tabs\Tab::make($site->name) + ->schema([ + Forms\Components\CheckboxList::make("site_{$site->id}") + ->label('Roles') + ->columns(3) + ->options(fn() => Role::pluck('name', 'id')->mapWithKeys( + fn($name, $key) => [$key => Str::headline($name)] + )) + ->afterStateHydrated(function ($component, $record) use ($site) { + if ($record) { + $roles = DB::table('model_has_roles') + ->where('model_id', $record->id) + ->where('model_type', get_class($record)) + ->where(function ($query) use ($site) { + $query->where('is_global', true) + ->orWhere(function ($q) use ($site) { + $q->where('is_global', false) + ->where(config('permission.column_names.team_foreign_key'), $site->id); + }); + }) + ->pluck('role_id') + ->unique() + ->toArray(); + $component->state($roles); + } + }), + ]); + } - $record->roles()->syncWithPivotValues($state, [config('permission.column_names.team_foreign_key') => getPermissionsTeamId()]); - }) - ->multiple() - ->preload() - ->searchable(), - ]), + return $tabs; + }), ]); } @@ -119,7 +158,7 @@ public static function table(Table $table): Table ->toggleable() ->size(50) ->circular() - ->defaultImageUrl(fn (User $user) => 'https://ui-avatars.com/api/?name='.urlencode($user->name)), + ->defaultImageUrl(fn(User $user) => 'https://ui-avatars.com/api/?name=' . urlencode($user->name)), Tables\Columns\TextColumn::make('first_name') ->searchable() ->sortable() @@ -142,7 +181,7 @@ public static function table(Table $table): Table ->label('Total Logins') ->sortable() ->numeric() - ->formatStateUsing(fn (?int $state) => $state ?? 0), + ->formatStateUsing(fn(?int $state) => $state ?? 0), ]; if (config('eclipse.email_verification')) { @@ -150,9 +189,9 @@ public static function table(Table $table): Table ->searchable() ->sortable() ->width(150) - ->icon(fn (User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle') - ->iconColor(fn (User $user) => $user->email_verified_at ? Color::Green : Color::Red) - ->tooltip(fn (User $user) => $user->email_verified_at ? 'Verified' : 'Not verified'); + ->icon(fn(User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle') + ->iconColor(fn(User $user) => $user->email_verified_at ? Color::Green : Color::Red) + ->tooltip(fn(User $user) => $user->email_verified_at ? 'Verified' : 'Not verified'); } else { $columns[] = Tables\Columns\TextColumn::make('email') ->searchable() @@ -174,11 +213,11 @@ public static function table(Table $table): Table ->translateLabel() ->badge() ->getStateUsing( - fn (User $record): Collection => $record + fn(User $record): Collection => $record ->roles() - ->whereNull('roles.'.config('permission.column_names.team_foreign_key')) + ->whereNull('roles.' . config('permission.column_names.team_foreign_key')) ->pluck('name') - ->map(fn ($roleName) => Str::headline($roleName)) + ->map(fn($roleName) => Str::headline($roleName)) ) ->sortable(false) ->placeholder('No global roles') @@ -190,14 +229,14 @@ public static function table(Table $table): Table ->badge() ->color('warning') ->getStateUsing(function (User $record) { - if (! Filament::getTenant()) { + if (!Filament::getTenant()) { return 'No site context'; } return $record->roles() - ->where('roles.'.config('permission.column_names.team_foreign_key'), Filament::getTenant()->id) + ->where('roles.' . config('permission.column_names.team_foreign_key'), Filament::getTenant()->id) ->pluck('name') - ->map(fn ($roleName) => Str::headline($roleName)); + ->map(fn($roleName) => Str::headline($roleName)); }) ->sortable(false) ->placeholder('No site roles') @@ -227,10 +266,10 @@ public static function table(Table $table): Table ->grouped() ->redirectTo(route('filament.admin.tenant')), Tables\Actions\DeleteAction::make() - ->authorize(fn (User $record) => auth()->user()->can('delete_user') && auth()->id() !== $record->id) + ->authorize(fn(User $record) => auth()->user()->can('delete_user') && auth()->id() !== $record->id) ->requiresConfirmation(), Tables\Actions\RestoreAction::make() - ->visible(fn (User $user) => $user->trashed() && auth()->user()->can('restore_user')) + ->visible(fn(User $user) => $user->trashed() && auth()->user()->can('restore_user')) ->requiresConfirmation(), ]), ]) @@ -286,7 +325,7 @@ private static function getTableFilters(): array ->label('Global Roles') ->relationship('roles', 'name', function (Builder $query): void { $query - ->whereNull('roles.'.config('permission.column_names.team_foreign_key')); + ->whereNull('roles.' . config('permission.column_names.team_foreign_key')); }) ->multiple() ->searchable() @@ -296,13 +335,13 @@ private static function getTableFilters(): array ->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); + $query->where('roles.' . config('permission.column_names.team_foreign_key'), Filament::getTenant()->id); } }) ->multiple() ->searchable() ->preload() - ->visible(fn () => Filament::getTenant() !== null), + ->visible(fn() => Filament::getTenant() !== null), Tables\Filters\TernaryFilter::make('email_verified_at') ->label('Email verification') @@ -311,9 +350,9 @@ private static function getTableFilters(): array ->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, + 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() @@ -352,39 +391,80 @@ public static function infolist(Infolist $infolist): Infolist ->schema([ SpatieMediaLibraryImageEntry::make('avatar') ->collection('avatars') - ->defaultImageUrl(fn (User $user) => 'https://ui-avatars.com/api/?name='.urlencode($user->name)) + ->defaultImageUrl(fn(User $user) => 'https://ui-avatars.com/api/?name=' . urlencode($user->name)) ->circular(), Group::make() ->schema([ TextEntry::make('name') ->label('Full name'), TextEntry::make('email') - ->icon(config('eclipse.email_verification') ? fn (User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle' : null) - ->iconColor(fn (User $user) => $user->email_verified_at ? Color::Green : Color::Red), + ->icon(config('eclipse.email_verification') ? fn(User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle' : null) + ->iconColor(fn(User $user) => $user->email_verified_at ? Color::Green : Color::Red), ]), ]), - Section::make(__('Access Information')) + Section::make() ->compact() ->columns(2) ->schema([ TextEntry::make('sites') - ->label('Accessable Sites') + ->label('Accessible Sites') ->weight(FontWeight::Medium) ->listWithLineBreaks() - ->placeholder(__(' No sites accessible')) - ->formatStateUsing(fn ($state) => "✓ {$state->name} ({$state->domain})"), + ->placeholder(__('No sites accessible')) + ->formatStateUsing(fn($state) => "✓ {$state->name} ({$state->domain})"), - TextEntry::make('roles') - ->listWithLineBreaks() + TextEntry::make('global_roles') + ->label('Global Roles') ->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}"; + ->listWithLineBreaks() + ->placeholder(__('No global roles assigned')) + ->getStateUsing(function ($record): Collection { + if (!$record) + return collect(); + + $globalRoles = DB::table('model_has_roles') + ->join('roles', 'roles.id', '=', 'model_has_roles.role_id') + ->where('model_has_roles.model_id', $record->id) + ->where('model_has_roles.model_type', get_class($record)) + ->where('model_has_roles.is_global', true) + ->pluck('roles.name'); + + return $globalRoles->map(fn($name) => "✓ " . Str::headline($name)); }), + TextEntry::make('site_roles_breakdown') + ->label('Site Roles') + ->columnSpanFull() + ->getStateUsing(function ($record): string { + if (!$record) + return 'No site roles assigned'; + + $breakdown = []; + + foreach (Site::all() as $site) { + $roles = DB::table('model_has_roles') + ->join('roles', 'roles.id', '=', 'model_has_roles.role_id') + ->where('model_has_roles.model_id', $record->id) + ->where('model_has_roles.model_type', get_class($record)) + ->where(function ($query) use ($site) { + $query->where('model_has_roles.is_global', true) + ->orWhere(function ($q) use ($site) { + $q->where('model_has_roles.is_global', false) + ->where('model_has_roles.' . config('permission.column_names.team_foreign_key'), $site->id); + }); + }) + ->pluck('roles.name') + ->unique(); + + if ($roles->isNotEmpty()) { + $roleList = $roles->map(fn($name) => "✓ " . Str::headline($name))->join(', '); + $breakdown[] = "{$site->name}: {$roleList}"; + } + } + + return $breakdown ? implode('
', $breakdown) : 'No site access'; + }) + ->formatStateUsing(fn($state) => new HtmlString($state)), ]), ]); } @@ -447,6 +527,11 @@ public static function getEloquentQuery(): Builder ]); } + private static function getSites(): Collection + { + return Site::get(); + } + public static function getPermissionPrefixes(): array { return [ diff --git a/src/Filament/Resources/UserResource/Pages/EditUser.php b/src/Filament/Resources/UserResource/Pages/EditUser.php index b40ffdf..0e507ff 100644 --- a/src/Filament/Resources/UserResource/Pages/EditUser.php +++ b/src/Filament/Resources/UserResource/Pages/EditUser.php @@ -3,8 +3,11 @@ namespace Eclipse\Core\Filament\Resources\UserResource\Pages; use Eclipse\Core\Filament\Resources\UserResource; +use Eclipse\Core\Models\Site; use Filament\Actions; use Filament\Resources\Pages\EditRecord; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; class EditUser extends EditRecord { @@ -17,4 +20,67 @@ protected function getHeaderActions(): array Actions\DeleteAction::make(), ]; } + + protected function handleRecordCreation(array $data): Model + { + $record = static::getModel()::create(collect($data)->except($this->getRoleFieldNames())->toArray()); + $this->saveUserRoles($record, $data); + + return $record; + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + $record->update(collect($data)->except($this->getRoleFieldNames())->toArray()); + $this->saveUserRoles($record, $data); + + return $record; + } + + protected function getRoleFieldNames(): array + { + $siteFields = Site::all()->map(fn ($site) => "site_{$site->id}")->toArray(); + + return array_merge(['global_roles'], $siteFields); + } + + protected function saveUserRoles(Model $record, array $data): void + { + DB::table('model_has_roles') + ->where('model_id', $record->id) + ->where('model_type', get_class($record)) + ->delete(); + + $globalRoleIds = ! empty($data['global_roles']) ? collect($data['global_roles'])->map(fn ($id) => (int) $id)->toArray() : []; + + if (! empty($globalRoleIds)) { + foreach ($globalRoleIds as $roleId) { + DB::table('model_has_roles')->insert([ + 'model_id' => $record->id, + 'model_type' => get_class($record), + 'role_id' => $roleId, + config('permission.column_names.team_foreign_key') => getPermissionsTeamId(), + 'is_global' => true, + ]); + } + } + + foreach (Site::all() as $site) { + if (! empty($data["site_{$site->id}"])) { + foreach ($data["site_{$site->id}"] as $roleId) { + $roleId = (int) $roleId; + + if (! in_array($roleId, $globalRoleIds)) { + DB::table('model_has_roles')->insert([ + 'model_id' => $record->id, + 'model_type' => get_class($record), + 'role_id' => $roleId, + config('permission.column_names.team_foreign_key') => $site->id, + 'is_global' => false, + ]); + } + } + } + } + } } diff --git a/src/Models/Site.php b/src/Models/Site.php index b82de95..8c03aeb 100644 --- a/src/Models/Site.php +++ b/src/Models/Site.php @@ -46,21 +46,21 @@ 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(); + // 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); - } - }); - } + // if ($allUserIDs->isNotEmpty()) { + // $site->users()->syncWithoutDetaching($allUserIDs); + // } + // }); + // } protected static function newFactory(): SiteFactory { diff --git a/src/Models/User.php b/src/Models/User.php index 2184453..c0438d3 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -166,4 +166,14 @@ public function canImpersonate(): bool { return $this->can('impersonate', User::class); } + + public function hasSiteRole(Site $site, string $role): bool + { + return $this->roles() + ->where('name', $role) + ->where(function ($query) use ($site) { + $query->where('model_has_roles.' . config('permission.column_names.team_foreign_key'), $site->id); + }) + ->exists(); + } } diff --git a/tests/Feature/Filament/Resources/RoleResourceTest.php b/tests/Feature/Filament/Resources/RoleResourceTest.php deleted file mode 100644 index c973e46..0000000 --- a/tests/Feature/Filament/Resources/RoleResourceTest.php +++ /dev/null @@ -1,47 +0,0 @@ -set_up_super_admin_and_tenant(); - $site = Filament::getTenant(); - - 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 () { - $this->set_up_super_admin_and_tenant(); - $site1 = Filament::getTenant(); - - $site2 = Site::factory()->create(); - $role = Role::factory()->create(['site_id' => $site1->id]); - - 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 07678ee..cbfd65d 100644 --- a/tests/Feature/Filament/Resources/UserResourceTest.php +++ b/tests/Feature/Filament/Resources/UserResourceTest.php @@ -47,6 +47,8 @@ 'last_name' => 'Doe', 'email' => 'john@doe.com', 'password' => 'password', + 'global_roles' => [], + 'site_1' => [] ])->call('create') ->assertHasNoFormErrors(); }); diff --git a/tests/Feature/UserTest.php b/tests/Feature/UserTest.php index 8f6e075..9ae8149 100644 --- a/tests/Feature/UserTest.php +++ b/tests/Feature/UserTest.php @@ -44,19 +44,6 @@ expect($user->hasRole('global_admin'))->toBeTrue(); }); -test('user is correctly given site role', function () { - $site = Site::factory()->create(); - $siteRole = Role::create(['name' => 'site_admin', 'guard_name' => 'web', 'site_id' => $site->id]); - - $user = User::factory()->create(); - $user->assignRole($siteRole); - $this->actingAs($user); - - Filament::setTenant($site); - - expect($user->hasRole('site_admin'))->toBeTrue(); -}); - test('users with global roles are added to new sites', function () { $globalRole = Role::create(['name' => 'global_admin', 'guard_name' => 'web']); $user = User::factory()->create(); @@ -67,25 +54,4 @@ Filament::setTenant($site); expect($user->hasRole('global_admin'))->toBeTrue(); -}); - -test('users with site roles are not added to new sites', function () { - $user = User::factory()->create(); - $this->actingAs($user); - - $site = Site::factory()->create(); - $siteRole = Role::create(['name' => 'site_admin', 'guard_name' => 'web', 'site_id' => $site->id]); - Filament::setTenant($site); - - $user->assignRole($siteRole); - - // Check on first site - expect($user->hasRole('site_admin'))->toBeTrue(); - - // Create extra site and re-check - $site2 = Site::factory()->create(); - Filament::setTenant($site2); - - expect($user->hasRole('site_admin'))->toBeFalse(); -}); - +}); \ No newline at end of file From 16ac22f41659b0613dc50bd57c50becc351c076f Mon Sep 17 00:00:00 2001 From: ankitcodes4u Date: Wed, 6 Aug 2025 07:18:25 +0545 Subject: [PATCH 12/15] fix: resolving conflict --- .junie/guidelines.md | 138 +++++++++++++ config/settings.php | 6 + database/factories/UserFactory.php | 4 + ...2025_06_23_110245_create_user_settings.php | 47 +++++ ..._phone_country_columns_to_users_tables.php | 49 +++++ ...2025_06_23_115758_create_user_settings.php | 14 ++ .../views/filament/components/brand.blade.php | 15 ++ .../filament/components/my-settings.blade.php | 17 ++ .../filament/components/tenant-menu.blade.php | 33 +++ src/EclipseServiceProvider.php | 1 + src/Filament/Pages/Dashboard.php | 10 + src/Filament/Pages/ManageUserSettings.php | 42 ++++ src/Filament/Resources/UserResource.php | 191 +++++++++++++----- src/Foundation/Settings/IsUserSiteScoped.php | 56 +++++ src/Models/Site.php | 16 +- src/Models/User.php | 20 +- src/Providers/AdminPanelProvider.php | 39 +++- .../UserSiteSettingsRepository.php | 83 ++++++++ src/Settings/UserSettings.php | 20 ++ .../Filament/Resources/UserResourceTest.php | 2 +- tests/Feature/UserTest.php | 2 +- .../UserSettingsRepositoryTest.php | 173 ++++++++++++++++ 22 files changed, 917 insertions(+), 61 deletions(-) create mode 100644 .junie/guidelines.md create mode 100644 database/migrations/2025_06_23_110245_create_user_settings.php create mode 100644 database/migrations/2025_06_24_000000_add_dob_phone_country_columns_to_users_tables.php create mode 100644 database/settings/2025_06_23_115758_create_user_settings.php create mode 100644 resources/views/filament/components/brand.blade.php create mode 100644 resources/views/filament/components/my-settings.blade.php create mode 100644 resources/views/filament/components/tenant-menu.blade.php create mode 100644 src/Filament/Pages/Dashboard.php create mode 100644 src/Filament/Pages/ManageUserSettings.php create mode 100644 src/Foundation/Settings/IsUserSiteScoped.php create mode 100644 src/Settings/Repositories/UserSiteSettingsRepository.php create mode 100644 src/Settings/UserSettings.php create mode 100644 tests/Unit/Settings/Repositories/UserSettingsRepositoryTest.php diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..0b6645a --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,138 @@ +# Eclipse Core Development Guidelines + +This document provides essential information for developers working on the Eclipse Core package. + +## Build/Configuration Instructions + +### Requirements +- PHP 8.3 +- Composer +- Node.js and npm (for asset compilation) + +### Local Development Setup + +1. **Clone the repository** + +2. **Install dependencies** + ```bash + composer install + npm install + ``` + +3. **Setup using Lando (recommended)** + + The project includes a `.lando.dist.yml` configuration file for local development using Lando. + + 1. Copy `.lando.dist.yml` to `.lando.yml` and customize as needed + 2. Start the Lando environment: + ```bash + lando start + ``` + 3. Run setup commands: + ```bash + lando composer install + lando npm install + ``` + +4. **Manual Setup (without Lando)** + + If you're not using Lando, you'll need to: + + 1. Run the setup script: + ```bash + composer setup + ``` + 2. This will: + - Install npm dependencies + - Publish Eclipse configuration + - Sync the package skeleton + +## Testing Information + +### Test Configuration + +The project uses Pest PHP (built on PHPUnit) for testing with Orchestra Testbench for Laravel package testing. + +Configuration files: +- `phpunit.xml.dist`: PHPUnit configuration +- `testbench.yaml`: Orchestra Testbench configuration + +### Running Tests + +When running inside the container (after `lando ssh`): +```bash +# Run all tests +composer test + +# Run specific tests +composer test -- --filter=TestName + +``` + +When running outside the container: +```bash +lando test + +# Run specific tests +lando test -- --filter=TestName + +``` + +### Adding New Tests + +1. Create a new test file in the `tests/Feature` or `tests/Unit` directory +2. Use the Pest PHP syntax for writing tests: + +```php +assertTrue(true); +}); + +test('example assertion', function () { + // This test demonstrates basic assertions + $value = 'Eclipse'; + $this->assertEquals('Eclipse', $value); + $this->assertNotEquals('Other', $value); + $this->assertStringContains('clip', $value); +}); +``` + +3. Run the tests to verify they work + +### Testing Environment + +Tests use: +- SQLite in-memory database +- Array cache driver +- Sync queue connection + +## Code Style & Development Guidelines + +### Code Style + +- The project follows PSR-4 autoloading standards +- Uses Laravel Pint for code formatting +- 4-space indentation (2-space for YAML files) +- UTF-8 encoding and LF line endings + +### Formatting Code + +```bash +# Format code using Laravel Pint +composer format + +# Using Lando +lando format +``` + +### Debugging Tools + +The project includes several debugging tools: +- Laravel Telescope for request/query debugging +- Laravel Horizon for queue monitoring +- Log Viewer for log file inspection + +Access to these tools is restricted based on user roles and permissions, as shown in the access tests. diff --git a/config/settings.php b/config/settings.php index 1b30f8a..4fabd51 100644 --- a/config/settings.php +++ b/config/settings.php @@ -40,6 +40,12 @@ 'table' => null, 'connection' => null, ], + 'user_tenant' => [ + 'type' => \Eclipse\Core\Settings\Repositories\UserSiteSettingsRepository::class, + 'model' => null, + 'table' => 'user_site_settings', + 'connection' => null, + ], 'redis' => [ 'type' => Spatie\LaravelSettings\SettingsRepositories\RedisSettingsRepository::class, 'connection' => null, diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 9cf74b3..1091ec2 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -3,6 +3,7 @@ namespace Eclipse\Core\Database\Factories; use Eclipse\Core\Models\User; +use Eclipse\World\Models\Country; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -35,8 +36,11 @@ public function definition(): array 'first_name' => fake()->firstName(), 'last_name' => fake()->lastName(), 'email' => fake()->unique()->safeEmail(), + 'phone_number' => fake()->phoneNumber(), 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), + 'country_id' => Country::inRandomOrder()->first()?->id ?? Country::factory()->create()->id, + 'date_of_birth' => now()->subYears(rand(20, 40)), 'remember_token' => Str::random(10), 'login_count' => 0, ]; diff --git a/database/migrations/2025_06_23_110245_create_user_settings.php b/database/migrations/2025_06_23_110245_create_user_settings.php new file mode 100644 index 0000000..05ba30b --- /dev/null +++ b/database/migrations/2025_06_23_110245_create_user_settings.php @@ -0,0 +1,47 @@ +id(); + + $table->foreignId('user_id') + ->nullable() + ->constrained('users') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + + $table->foreignId('site_id') + ->nullable() + ->constrained('sites') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + + $table->string('group'); + $table->string('name'); + $table->boolean('locked')->default(false); + $table->json('payload'); + + $table->timestamps(); + + $table->unique(['group', 'name', 'user_id', 'site_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_site_settings'); + } +}; diff --git a/database/migrations/2025_06_24_000000_add_dob_phone_country_columns_to_users_tables.php b/database/migrations/2025_06_24_000000_add_dob_phone_country_columns_to_users_tables.php new file mode 100644 index 0000000..93b6929 --- /dev/null +++ b/database/migrations/2025_06_24_000000_add_dob_phone_country_columns_to_users_tables.php @@ -0,0 +1,49 @@ +string('phone_number', 20) + ->after('email') + ->nullable(); + $table->string('country_id', 2) + ->after('password') + ->nullable(); + $table->date('date_of_birth') + ->after('country_id') + ->nullable(); + + $table->index('phone_number'); + $table->index('country_id'); + $table->index('date_of_birth'); + + $table->foreign('country_id') + ->references('id') + ->on('world_countries') + ->onDelete('set null'); + }); + } + + public function down() + { + Schema::table('users', function (Blueprint $table) { + $table->dropForeign(['country_id']); + + $table->dropIndex(['phone_number']); + $table->dropIndex(['country_id']); + $table->dropIndex(['date_of_birth']); + + $table->dropColumn([ + 'date_of_birth', + 'phone_number', + 'country_id', + ]); + }); + } +}; diff --git a/database/settings/2025_06_23_115758_create_user_settings.php b/database/settings/2025_06_23_115758_create_user_settings.php new file mode 100644 index 0000000..a0d9bbc --- /dev/null +++ b/database/settings/2025_06_23_115758_create_user_settings.php @@ -0,0 +1,14 @@ +migrator->repository('user_tenant'); + + $this->migrator->add('site.outgoing_email_address', ''); + $this->migrator->add('site.outgoing_email_signature', ''); + } +}; diff --git a/resources/views/filament/components/brand.blade.php b/resources/views/filament/components/brand.blade.php new file mode 100644 index 0000000..513bed6 --- /dev/null +++ b/resources/views/filament/components/brand.blade.php @@ -0,0 +1,15 @@ +@php + use Eclipse\Core\Services\Registry; + use Filament\Facades\Filament; + + $appName = Registry::getSite()->name ?? config('app.name'); + $hasSpaMode = Filament::getCurrentPanel()->hasSpaMode(); + + $dashboardUrl = '/' . trim(Filament::getCurrentPanel()->getPath(), '/'); +@endphp + + + + \ No newline at end of file diff --git a/resources/views/filament/components/my-settings.blade.php b/resources/views/filament/components/my-settings.blade.php new file mode 100644 index 0000000..7805951 --- /dev/null +++ b/resources/views/filament/components/my-settings.blade.php @@ -0,0 +1,17 @@ +@php + use Eclipse\Core\Filament\Pages\ManageUserSettings; + use Filament\Facades\Filament; + + $navigationIcon = ManageUserSettings::getNavigationIcon(); + $navigationLabel = ManageUserSettings::getNavigationLabel(); + + $hasSpaMode = Filament::getCurrentPanel()->hasSpaMode(); +@endphp + + diff --git a/resources/views/filament/components/tenant-menu.blade.php b/resources/views/filament/components/tenant-menu.blade.php new file mode 100644 index 0000000..2df3b0f --- /dev/null +++ b/resources/views/filament/components/tenant-menu.blade.php @@ -0,0 +1,33 @@ +@php + $currentTenant = filament()->getTenant(); + $currentTenantName = filament()->getTenantName($currentTenant); + + $canSwitchTenants = count( + $tenants = array_filter( + filament()->getUserTenants(filament()->auth()->user()), + fn(\Illuminate\Database\Eloquent\Model $tenant): bool => !$tenant->is($currentTenant), + ), + ); +@endphp + + + + + + @if ($canSwitchTenants) + + @foreach ($tenants as $tenant) + + {{ filament()->getTenantName($tenant) }} + + @endforeach + + @endif + + diff --git a/src/EclipseServiceProvider.php b/src/EclipseServiceProvider.php index db1237b..beb6e60 100644 --- a/src/EclipseServiceProvider.php +++ b/src/EclipseServiceProvider.php @@ -61,6 +61,7 @@ public function configurePackage(SpatiePackage|Package $package): void 'telescope', 'themes', ]) + ->hasViews() ->hasSettings() ->discoversMigrations() ->runsMigrations() diff --git a/src/Filament/Pages/Dashboard.php b/src/Filament/Pages/Dashboard.php new file mode 100644 index 0000000..370ec88 --- /dev/null +++ b/src/Filament/Pages/Dashboard.php @@ -0,0 +1,10 @@ +schema([ + Components\Section::make('Email settings') + ->schema([ + Components\TextInput::make('outgoing_email_address') + ->email() + ->label('Outgoing email address'), + Components\RichEditor::make('outgoing_email_signature') + ->label('Outgoing email signature'), + ]), + ]); + } + + public static function getNavigationGroup(): ?string + { + return 'Configuration'; + } + + public static function getNavigationLabel(): string + { + return 'My settings'; + } +} diff --git a/src/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index d64f6f3..b15fbc0 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -70,17 +70,17 @@ public static function form(Form $form): Form 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') + ->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)) + fn (Set $set) => $set('password', Str::password(16)) ) ), ]), @@ -95,8 +95,8 @@ public static function form(Form $form): Form 'lg' => 4, 'xl' => 5, ]) - ->options(fn() => Role::pluck('name', 'id')->mapWithKeys( - fn($name, $key) => [$key => Str::headline($name)] + ->options(fn () => Role::pluck('name', 'id')->mapWithKeys( + fn ($name, $key) => [$key => Str::headline($name)] )) ->afterStateHydrated(function ($component, $record) { if ($record) { @@ -121,8 +121,8 @@ public static function form(Form $form): Form Forms\Components\CheckboxList::make("site_{$site->id}") ->label('Roles') ->columns(3) - ->options(fn() => Role::pluck('name', 'id')->mapWithKeys( - fn($name, $key) => [$key => Str::headline($name)] + ->options(fn () => Role::pluck('name', 'id')->mapWithKeys( + fn ($name, $key) => [$key => Str::headline($name)] )) ->afterStateHydrated(function ($component, $record) use ($site) { if ($record) { @@ -147,6 +147,53 @@ public static function form(Form $form): Form return $tabs; }), + Forms\Components\SpatieMediaLibraryFileUpload::make('avatar') + ->collection('avatars') + ->avatar() + ->imageEditor() + ->maxSize(1024 * 2), + self::getFirstNameFormComponent(), + self::getLastNameFormComponent(), + self::getEmailFormComponent(), + Forms\Components\TextInput::make('phone_number') + ->label('Phone') + ->tel(), + 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('country_id') + ->relationship('country', 'name') + ->preload() + ->optionsLimit(20) + ->searchable(), + Forms\Components\DatePicker::make('date_of_birth') + ->native(false) + ->minDate(now()->subYears(80)) + ->maxDate(now()), + 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(), ]); } @@ -158,7 +205,7 @@ public static function table(Table $table): Table ->toggleable() ->size(50) ->circular() - ->defaultImageUrl(fn(User $user) => 'https://ui-avatars.com/api/?name=' . urlencode($user->name)), + ->defaultImageUrl(fn (User $user) => 'https://ui-avatars.com/api/?name='.urlencode($user->name)), Tables\Columns\TextColumn::make('first_name') ->searchable() ->sortable() @@ -181,7 +228,7 @@ public static function table(Table $table): Table ->label('Total Logins') ->sortable() ->numeric() - ->formatStateUsing(fn(?int $state) => $state ?? 0), + ->formatStateUsing(fn (?int $state) => $state ?? 0), ]; if (config('eclipse.email_verification')) { @@ -189,9 +236,9 @@ public static function table(Table $table): Table ->searchable() ->sortable() ->width(150) - ->icon(fn(User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle') - ->iconColor(fn(User $user) => $user->email_verified_at ? Color::Green : Color::Red) - ->tooltip(fn(User $user) => $user->email_verified_at ? 'Verified' : 'Not verified'); + ->icon(fn (User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle') + ->iconColor(fn (User $user) => $user->email_verified_at ? Color::Green : Color::Red) + ->tooltip(fn (User $user) => $user->email_verified_at ? 'Verified' : 'Not verified'); } else { $columns[] = Tables\Columns\TextColumn::make('email') ->searchable() @@ -199,6 +246,9 @@ public static function table(Table $table): Table ->width(150); } + $columns[] = Tables\Columns\TextColumn::make('phone_number') + ->label('Phone'); + $columns[] = Tables\Columns\TextColumn::make('email_verified_at') ->label('Verified email') ->placeholder('Not verified') @@ -213,11 +263,11 @@ public static function table(Table $table): Table ->translateLabel() ->badge() ->getStateUsing( - fn(User $record): Collection => $record + fn (User $record): Collection => $record ->roles() - ->whereNull('roles.' . config('permission.column_names.team_foreign_key')) + ->whereNull('roles.'.config('permission.column_names.team_foreign_key')) ->pluck('name') - ->map(fn($roleName) => Str::headline($roleName)) + ->map(fn ($roleName) => Str::headline($roleName)) ) ->sortable(false) ->placeholder('No global roles') @@ -229,18 +279,23 @@ public static function table(Table $table): Table ->badge() ->color('warning') ->getStateUsing(function (User $record) { - if (!Filament::getTenant()) { + if (! Filament::getTenant()) { return 'No site context'; } return $record->roles() - ->where('roles.' . config('permission.column_names.team_foreign_key'), Filament::getTenant()->id) + ->where('roles.'.config('permission.column_names.team_foreign_key'), Filament::getTenant()->id) ->pluck('name') - ->map(fn($roleName) => Str::headline($roleName)); + ->map(fn ($roleName) => Str::headline($roleName)); }) ->sortable(false) ->placeholder('No site roles') ->toggleable(); + $columns[] = Tables\Columns\TextColumn::make('country.name') + ->badge(); + + $columns[] = Tables\Columns\TextColumn::make('date_of_birth') + ->date('M d, Y'); $columns[] = Tables\Columns\TextColumn::make('created_at') ->dateTime() @@ -254,6 +309,41 @@ 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\SelectFilter::make('country_id') + ->label('Country') + ->multiple() + ->relationship('country', 'name', fn (Builder $query): Builder => $query->distinct()) + ->preload() + ->optionsLimit(20), + 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(self::getTableFilters()) @@ -266,10 +356,10 @@ public static function table(Table $table): Table ->grouped() ->redirectTo(route('filament.admin.tenant')), Tables\Actions\DeleteAction::make() - ->authorize(fn(User $record) => auth()->user()->can('delete_user') && auth()->id() !== $record->id) + ->authorize(fn (User $record) => auth()->user()->can('delete_user') && auth()->id() !== $record->id) ->requiresConfirmation(), Tables\Actions\RestoreAction::make() - ->visible(fn(User $user) => $user->trashed() && auth()->user()->can('restore_user')) + ->visible(fn (User $user) => $user->trashed() && auth()->user()->can('restore_user')) ->requiresConfirmation(), ]), ]) @@ -325,7 +415,7 @@ private static function getTableFilters(): array ->label('Global Roles') ->relationship('roles', 'name', function (Builder $query): void { $query - ->whereNull('roles.' . config('permission.column_names.team_foreign_key')); + ->whereNull('roles.'.config('permission.column_names.team_foreign_key')); }) ->multiple() ->searchable() @@ -335,13 +425,13 @@ private static function getTableFilters(): array ->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); + $query->where('roles.'.config('permission.column_names.team_foreign_key'), Filament::getTenant()->id); } }) ->multiple() ->searchable() ->preload() - ->visible(fn() => Filament::getTenant() !== null), + ->visible(fn () => Filament::getTenant() !== null), Tables\Filters\TernaryFilter::make('email_verified_at') ->label('Email verification') @@ -350,9 +440,9 @@ private static function getTableFilters(): array ->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, + 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() @@ -385,22 +475,29 @@ public static function infolist(Infolist $infolist): Infolist TextEntry::make('updated_at') ->dateTime(), ]), - Section::make(__('Personal information')) - ->columns(3) - ->compact() + Section::make('Personal information') + ->columns(4) ->schema([ SpatieMediaLibraryImageEntry::make('avatar') ->collection('avatars') - ->defaultImageUrl(fn(User $user) => 'https://ui-avatars.com/api/?name=' . urlencode($user->name)) + ->defaultImageUrl(fn (User $user) => 'https://ui-avatars.com/api/?name='.urlencode($user->name)) ->circular(), Group::make() ->schema([ TextEntry::make('name') ->label('Full name'), TextEntry::make('email') - ->icon(config('eclipse.email_verification') ? fn(User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle' : null) - ->iconColor(fn(User $user) => $user->email_verified_at ? Color::Green : Color::Red), + ->icon(config('eclipse.email_verification') ? fn (User $user) => $user->email_verified_at ? 'heroicon-s-check-circle' : 'heroicon-s-x-circle' : null) + ->iconColor(fn (User $user) => $user->email_verified_at ? Color::Green : Color::Red), ]), + Group::make() + ->schema([ + TextEntry::make('phone_number')->placeholder('-'), + TextEntry::make('country.name') + ->badge() + ->placeholder('-'), + ]), + TextEntry::make('date_of_birth')->date('M d, Y')->placeholder('-'), ]), Section::make() ->compact() @@ -411,7 +508,7 @@ public static function infolist(Infolist $infolist): Infolist ->weight(FontWeight::Medium) ->listWithLineBreaks() ->placeholder(__('No sites accessible')) - ->formatStateUsing(fn($state) => "✓ {$state->name} ({$state->domain})"), + ->formatStateUsing(fn ($state) => "✓ {$state->name} ({$state->domain})"), TextEntry::make('global_roles') ->label('Global Roles') @@ -419,8 +516,9 @@ public static function infolist(Infolist $infolist): Infolist ->listWithLineBreaks() ->placeholder(__('No global roles assigned')) ->getStateUsing(function ($record): Collection { - if (!$record) + if (! $record) { return collect(); + } $globalRoles = DB::table('model_has_roles') ->join('roles', 'roles.id', '=', 'model_has_roles.role_id') @@ -429,15 +527,16 @@ public static function infolist(Infolist $infolist): Infolist ->where('model_has_roles.is_global', true) ->pluck('roles.name'); - return $globalRoles->map(fn($name) => "✓ " . Str::headline($name)); + return $globalRoles->map(fn ($name) => '✓ '.Str::headline($name)); }), TextEntry::make('site_roles_breakdown') ->label('Site Roles') ->columnSpanFull() ->getStateUsing(function ($record): string { - if (!$record) + if (! $record) { return 'No site roles assigned'; + } $breakdown = []; @@ -447,24 +546,24 @@ public static function infolist(Infolist $infolist): Infolist ->where('model_has_roles.model_id', $record->id) ->where('model_has_roles.model_type', get_class($record)) ->where(function ($query) use ($site) { - $query->where('model_has_roles.is_global', true) - ->orWhere(function ($q) use ($site) { - $q->where('model_has_roles.is_global', false) - ->where('model_has_roles.' . config('permission.column_names.team_foreign_key'), $site->id); - }); - }) + $query->where('model_has_roles.is_global', true) + ->orWhere(function ($q) use ($site) { + $q->where('model_has_roles.is_global', false) + ->where('model_has_roles.'.config('permission.column_names.team_foreign_key'), $site->id); + }); + }) ->pluck('roles.name') ->unique(); if ($roles->isNotEmpty()) { - $roleList = $roles->map(fn($name) => "✓ " . Str::headline($name))->join(', '); + $roleList = $roles->map(fn ($name) => '✓ '.Str::headline($name))->join(', '); $breakdown[] = "{$site->name}: {$roleList}"; } } return $breakdown ? implode('
', $breakdown) : 'No site access'; }) - ->formatStateUsing(fn($state) => new HtmlString($state)), + ->formatStateUsing(fn ($state) => new HtmlString($state)), ]), ]); } diff --git a/src/Foundation/Settings/IsUserSiteScoped.php b/src/Foundation/Settings/IsUserSiteScoped.php new file mode 100644 index 0000000..bba10d9 --- /dev/null +++ b/src/Foundation/Settings/IsUserSiteScoped.php @@ -0,0 +1,56 @@ +getRepository(); + + // Make sure it's a UserSettingsRepository + if (! $repository instanceof UserSiteSettingsRepository) { + throw new RuntimeException('Repository must be an instance of UserSiteSettingsRepository'); + } + + // Configure the repository to use the specified user + $userRepository = $repository->forUser($userId); + + // Get the properties directly from the repository + $properties = collect($userRepository->getPropertiesInGroup(static::group())); + + // Process the properties (decrypt, cast, etc.) + $reflectionProperties = collect((new ReflectionClass(static::class))->getProperties(ReflectionProperty::IS_PUBLIC)) + ->mapWithKeys(fn (ReflectionProperty $property) => [$property->getName() => $property]); + + // Set the properties on the settings instance + foreach ($reflectionProperties as $name => $property) { + if (isset($properties[$name])) { + $settings->$name = $properties[$name]; + } elseif ($property->hasDefaultValue()) { + $settings->$name = $property->getDefaultValue(); + } + } + + return $settings; + } +} diff --git a/src/Models/Site.php b/src/Models/Site.php index 8c03aeb..7da46d4 100644 --- a/src/Models/Site.php +++ b/src/Models/Site.php @@ -67,9 +67,21 @@ protected static function newFactory(): SiteFactory return SiteFactory::new(); } - /** @return HasMany<\Eclipse\Core\Models\User\Role, self> */ + /** @return HasMany */ public function roles(): HasMany { - return $this->hasMany(\Eclipse\Core\Models\User\Role::class); + return $this->hasMany(Role::class); + } + + /** @return HasMany<\Eclipse\Cms\Models\Section, self> */ + public function sections(): HasMany + { + return $this->hasMany(\Eclipse\Cms\Models\Section::class); + } + + /** @return HasMany<\Eclipse\Cms\Models\Page, self> */ + public function pages(): HasMany + { + return $this->hasMany(\Eclipse\Cms\Models\Page::class); } } diff --git a/src/Models/User.php b/src/Models/User.php index c0438d3..1318448 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -4,6 +4,8 @@ use Eclipse\Core\Database\Factories\UserFactory; use Eclipse\Core\Models\User\Role; +use Eclipse\Core\Settings\UserSettings; +use Eclipse\World\Models\Country; use Exception; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasAvatar; @@ -11,10 +13,12 @@ use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; +use Spatie\LaravelSettings\Settings; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Permission\Traits\HasRoles; @@ -49,7 +53,10 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasMedia, 'first_name', 'last_name', 'email', + 'phone_number', 'password', + 'country_id', + 'date_of_birth', 'last_login_at', 'login_count', ]; @@ -74,6 +81,7 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'date_of_birth' => 'date', 'last_login_at' => 'datetime', ]; } @@ -88,6 +96,11 @@ public function sites() return $this->belongsToMany(Site::class, 'site_has_user'); } + public function country(): BelongsTo + { + return $this->belongsTo(Country::class); + } + public function getFilamentAvatarUrl(): ?string { return $this->getMedia('avatars')->first()?->getUrl(); @@ -172,8 +185,13 @@ public function hasSiteRole(Site $site, string $role): bool return $this->roles() ->where('name', $role) ->where(function ($query) use ($site) { - $query->where('model_has_roles.' . config('permission.column_names.team_foreign_key'), $site->id); + $query->where('model_has_roles.'.config('permission.column_names.team_foreign_key'), $site->id); }) ->exists(); } + + public function getSettings(string $settingsClass = UserSettings::class): Settings + { + return $settingsClass::forUser($this->id); + } } diff --git a/src/Providers/AdminPanelProvider.php b/src/Providers/AdminPanelProvider.php index 62aec21..00071a8 100644 --- a/src/Providers/AdminPanelProvider.php +++ b/src/Providers/AdminPanelProvider.php @@ -7,6 +7,7 @@ use BezhanSalleh\FilamentShield\Middleware\SyncShieldTenant; use DutchCodingCompany\FilamentDeveloperLogins\FilamentDeveloperLoginsPlugin; use Eclipse\Common\Providers\GlobalSearchProvider; +use Eclipse\Core\Filament\Pages\Dashboard; use Eclipse\Core\Filament\Pages\EditProfile; use Eclipse\Core\Filament\Pages\Tools\HealthCheckResults; use Eclipse\Core\Models\Locale; @@ -20,7 +21,6 @@ use Filament\Navigation\NavigationGroup; use Filament\Navigation\NavigationItem; use Filament\Notifications\Livewire\Notifications; -use Filament\Pages; use Filament\Panel; use Filament\PanelProvider; use Filament\SpatieLaravelTranslatablePlugin; @@ -29,6 +29,7 @@ use Filament\Support\Enums\Platform; use Filament\Support\Enums\VerticalAlignment; use Filament\Support\Facades\FilamentView; +use Filament\View\PanelsRenderHook; use Filament\Widgets; use Hasnayeen\Themes\Http\Middleware\SetTheme; use Hasnayeen\Themes\ThemesPlugin; @@ -41,6 +42,7 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Schema; use Illuminate\View\Middleware\ShareErrorsFromSession; +use Illuminate\View\View; use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin; use pxlrbt\FilamentSpotlight\SpotlightPlugin; use ShuvroRoy\FilamentSpatieLaravelHealth\FilamentSpatieLaravelHealthPlugin; @@ -58,6 +60,8 @@ public function panel(Panel $panel): Panel $localeIds = [config('app.locale', 'en')]; } + $hasTenantMenu = config('eclipse.multi_site', false); + $panel ->default() ->id('admin') @@ -71,7 +75,9 @@ public function panel(Panel $panel): Panel 'gray' => Color::Slate, ]) ->topNavigation() - ->brandName(fn () => Registry::getSite()?->name) + ->brandLogo( + fn (): View => view('eclipse::filament.components.brand') + ) ->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') @@ -79,7 +85,7 @@ public function panel(Panel $panel): Panel ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') ->discoverClusters(in: $package_src.'Filament/Clusters', for: 'Eclipse\\Core\\Filament\\Clusters') ->pages([ - Pages\Dashboard::class, + Dashboard::class, ]) ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->globalSearch(GlobalSearchProvider::class) @@ -95,7 +101,8 @@ public function panel(Panel $panel): Panel SyncShieldTenant::class, SetTheme::class, ], isPersistent: true) - ->tenantMenu(config('eclipse.multi_site', false)) + // ->tenantMenu(config('eclipse.multi_site', false)) + ->tenantMenu(false) ->widgets([ Widgets\AccountWidget::class, Widgets\FilamentInfoWidget::class, @@ -156,16 +163,28 @@ public function panel(Panel $panel): Panel ->hidden(fn (): bool => ! config('log-viewer.enabled', false) || ! auth()->user()->hasRole('super_admin')), ]) ->databaseNotifications() - ->unsavedChangesAlerts(); + ->unsavedChangesAlerts() + ->renderHook( + PanelsRenderHook::USER_MENU_PROFILE_AFTER, + fn () => view('eclipse::filament.components.my-settings') + ); + + if ($hasTenantMenu) { + $panel->renderHook( + PanelsRenderHook::GLOBAL_SEARCH_END, + fn () => view('eclipse::filament.components.tenant-menu') + ); + } // If the Pro version of the Spotlight plugin is installed, use that, otherwise use the free version if (class_exists(\pxlrbt\FilamentSpotlightPro\SpotlightPlugin::class)) { /** @noinspection PhpFullyQualifiedNameUsageInspection */ - $panel->plugin(\pxlrbt\FilamentSpotlightPro\SpotlightPlugin::make() - ->registerItems([ - \pxlrbt\FilamentSpotlightPro\SpotlightProviders\RegisterResources::make(), - ]) - ->hotkeys(['¸']) + $panel->plugin( + \pxlrbt\FilamentSpotlightPro\SpotlightPlugin::make() + ->registerItems([ + \pxlrbt\FilamentSpotlightPro\SpotlightProviders\RegisterResources::make(), + ]) + ->hotkeys(['¸']) ); } else { $panel->plugin(SpotlightPlugin::make()); diff --git a/src/Settings/Repositories/UserSiteSettingsRepository.php b/src/Settings/Repositories/UserSiteSettingsRepository.php new file mode 100644 index 0000000..c3061c3 --- /dev/null +++ b/src/Settings/Repositories/UserSiteSettingsRepository.php @@ -0,0 +1,83 @@ +map(function ($payload, $name) use ($group) { + return [ + 'group' => $group, + 'name' => $name, + 'payload' => $this->encode($payload), + 'site_id' => Filament::getTenant()?->id, + 'user_id' => auth()->user()?->id, + ]; + })->values()->toArray(); + + $this->getBuilder(false) + ->where('group', $group) + ->upsert($propertiesInBatch, ['group', 'name', 'site_id', 'user_id'], ['payload']); + } + + public function forUser(int $userId): self + { + $clone = clone $this; + $clone->userId = $userId; + + return $clone; + } + + public function getBuilder(bool $fallback = true): Builder + { + $builder = parent::getBuilder(); + $userId = $this->userId ?? auth()->user()?->id; + + if ($fallback) { + // Use default fallback + $table = $this->table ?? (new SettingsProperty)->getTable(); + $builder + ->where(function (Builder $query) use ($table, $userId) { + $query + ->where(function (Builder $query) use ($userId) { + $query + // ... where site_id matches + ->where('site_id', Filament::getTenant()?->id) + // ... where user_id matches + ->where('user_id', $userId); + }) + // ... or where site_id is null and a record with a matching site_id does not exist + ->orWhere(function (Builder $query) use ($table, $userId) { + $query + ->whereNull('site_id') + ->whereNull('user_id') + ->whereNotExists(function (QueryBuilder $query) use ($table, $userId) { + $query->select(DB::raw(1)) + ->from($table, 't2') + ->where('site_id', Filament::getTenant()?->id) + ->where('user_id', $userId) + ->whereColumn('t2.group', $table.'.group') + ->whereColumn('t2.name', $table.'.name'); + }); + }); + }); + } else { + // Don't use fallback, get only settings with the exact site/user match + $builder + ->where('site_id', Filament::getTenant()?->id) + ->where('user_id', $userId); + } + + return $builder; + } +} diff --git a/src/Settings/UserSettings.php b/src/Settings/UserSettings.php new file mode 100644 index 0000000..159fbc7 --- /dev/null +++ b/src/Settings/UserSettings.php @@ -0,0 +1,20 @@ + 'john@doe.com', 'password' => 'password', 'global_roles' => [], - 'site_1' => [] + 'site_1' => [], ])->call('create') ->assertHasNoFormErrors(); }); diff --git a/tests/Feature/UserTest.php b/tests/Feature/UserTest.php index 9ae8149..44273ef 100644 --- a/tests/Feature/UserTest.php +++ b/tests/Feature/UserTest.php @@ -54,4 +54,4 @@ Filament::setTenant($site); expect($user->hasRole('global_admin'))->toBeTrue(); -}); \ No newline at end of file +}); diff --git a/tests/Unit/Settings/Repositories/UserSettingsRepositoryTest.php b/tests/Unit/Settings/Repositories/UserSettingsRepositoryTest.php new file mode 100644 index 0000000..42e90db --- /dev/null +++ b/tests/Unit/Settings/Repositories/UserSettingsRepositoryTest.php @@ -0,0 +1,173 @@ +set_up_common_user_and_tenant(); +}); + +test('default user setting value is used when user has no settings yet', function () { + // Get the default settings (where user_id and site_id are null) + $defaultSettings = app(UserSettings::class); + + // Verify that the settings are loaded from the default values + $this->assertEquals('', $defaultSettings->outgoing_email_address); + $this->assertEquals('', $defaultSettings->outgoing_email_signature); + + // Verify that no user-specific settings exist in the database + $userSettings = DB::table('user_site_settings') + ->where('user_id', $this->user->id) + ->where('site_id', Filament::getTenant()->id) + ->get(); + + $this->assertCount(0, $userSettings); +}); + +test('user settings are saved with correct user_id and site_id', function () { + // Get the settings instance + $settings = app(UserSettings::class); + + // Update the settings + $settings->outgoing_email_address = 'test@example.com'; + $settings->outgoing_email_signature = '

Test Signature

'; + $settings->save(); + + // Verify that the settings were saved with the correct user_id and site_id + $savedSettings = DB::table('user_site_settings') + ->where('user_id', $this->user->id) + ->where('site_id', Filament::getTenant()->id) + ->get(); + + $this->assertCount(2, $savedSettings); // Two settings: email address and signature + + // Verify the values were saved correctly + $emailSetting = $savedSettings->where('name', 'outgoing_email_address')->first(); + $signatureSetting = $savedSettings->where('name', 'outgoing_email_signature')->first(); + + $this->assertNotNull($emailSetting); + $this->assertNotNull($signatureSetting); + $this->assertEquals(json_encode('test@example.com'), $emailSetting->payload); + $this->assertEquals(json_encode('

Test Signature

'), $signatureSetting->payload); +}); + +test('user-specific settings are loaded instead of defaults', function () { + // First, save user-specific settings + $settings = app(UserSettings::class); + $settings->outgoing_email_address = 'user@example.com'; + $settings->outgoing_email_signature = '

User Signature

'; + $settings->save(); + + // Now, get a fresh instance of the settings + $freshSettings = app(UserSettings::class); + + // Verify that the user-specific settings are loaded + $this->assertEquals('user@example.com', $freshSettings->outgoing_email_address); + $this->assertEquals('

User Signature

', $freshSettings->outgoing_email_signature); +}); + +test('site-specific settings are not used on other sites', function () { + // Create a second site + $secondSite = Site::factory()->create(); + + // Save settings for the current site + $settings = app(UserSettings::class); + $settings->outgoing_email_address = 'site1@example.com'; + $settings->outgoing_email_signature = '

Site 1 Signature

'; + $settings->save(); + + // Switch to the second site + $originalSite = Filament::getTenant(); + Filament::setTenant($secondSite); + + // Get settings for the second site + $secondSiteSettings = app(UserSettings::class); + + // Verify that the second site uses default settings, not the first site's settings + $this->assertEquals('', $secondSiteSettings->outgoing_email_address); + $this->assertEquals('', $secondSiteSettings->outgoing_email_signature); + + // Restore the original site + Filament::setTenant($originalSite); + + // Test settings again for the first site + $settings->refresh(); + $this->assertEquals('site1@example.com', $settings->outgoing_email_address); + $this->assertEquals('

Site 1 Signature

', $settings->outgoing_email_signature); +}); + +test('user-specific settings are not used for other users', function () { + // Save settings for the current user + $settings = app(UserSettings::class); + $settings->outgoing_email_address = 'user1@example.com'; + $settings->outgoing_email_signature = '

User 1 Signature

'; + $settings->save(); + + // Create and switch to a second user + $secondUser = User::factory()->create(); + $secondUser->sites()->attach(Filament::getTenant()); + + Auth::login($secondUser); + + // Get settings for the second user + $secondUserSettings = app(UserSettings::class); + + // Verify that the second user uses default settings, not the first user's settings + $this->assertEquals('', $secondUserSettings->outgoing_email_address); + $this->assertEquals('', $secondUserSettings->outgoing_email_signature); +}); + +test('forUser method fetches settings for a specific user', function () { + // Save settings for the current user + $settings = app(UserSettings::class); + $settings->outgoing_email_address = 'user1@example.com'; + $settings->outgoing_email_signature = '

User 1 Signature

'; + $settings->save(); + + // Create a second user + $secondUser = User::factory()->create(); + $secondUser->sites()->attach(Filament::getTenant()); + + // Save settings for the second user + Auth::login($secondUser); + $secondUserSettings = app(UserSettings::class); + $secondUserSettings->outgoing_email_address = 'user2@example.com'; + $secondUserSettings->outgoing_email_signature = '

User 2 Signature

'; + $secondUserSettings->save(); + + // Switch back to the first user + Auth::login($this->user); + + // Use forUser to get settings for the second user while authenticated as the first user + $fetchedSecondUserSettings = UserSettings::forUser($secondUser->id); + + // Verify that the fetched settings match the second user's settings + $this->assertEquals('user2@example.com', $fetchedSecondUserSettings->outgoing_email_address); + $this->assertEquals('

User 2 Signature

', $fetchedSecondUserSettings->outgoing_email_signature); + + // Verify that the current user's settings are still accessible + $currentUserSettings = app(UserSettings::class); + $this->assertEquals('user1@example.com', $currentUserSettings->outgoing_email_address); + $this->assertEquals('

User 1 Signature

', $currentUserSettings->outgoing_email_signature); +}); + +test('forUser method works in non-user context', function () { + // Set settings for the current user + $userSettings = app(UserSettings::class); + $userSettings->outgoing_email_address = 'user@example.com'; + $userSettings->outgoing_email_signature = '

User Signature

'; + $userSettings->save(); + + // Log out and fetch the settings + Auth::logout(); + $settings = $this->user->getSettings(); + $this->assertInstanceOf(UserSettings::class, $settings); + + // Assert values + $this->assertEquals('user@example.com', $settings->outgoing_email_address); + $this->assertEquals('

User Signature

', $settings->outgoing_email_signature); +}); From 755946e399f4f72e0c3b3ec39908444a11bc0873 Mon Sep 17 00:00:00 2001 From: ankitcodes4u Date: Thu, 7 Aug 2025 08:28:48 +0545 Subject: [PATCH 13/15] fix: revert something back --- src/Models/User.php | 3 +++ src/Models/User/Role.php | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Models/User.php b/src/Models/User.php index 7e7710c..57dc41a 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -183,6 +183,9 @@ 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/Models/User/Role.php b/src/Models/User/Role.php index 54c8563..bb9bd70 100644 --- a/src/Models/User/Role.php +++ b/src/Models/User/Role.php @@ -12,14 +12,14 @@ class Role extends SpatieRole { use HasFactory; - protected static function newFactory() - { - return RoleFactory::new(); - } - /** @return BelongsTo */ public function site(): BelongsTo { return $this->belongsTo(Site::class); } + + protected static function newFactory() + { + return RoleFactory::new(); + } } From 694fbc274c408b1e07bddaffdf43a5c3eae8e86b Mon Sep 17 00:00:00 2001 From: ankitcodes4u Date: Fri, 8 Aug 2025 03:24:33 +0545 Subject: [PATCH 14/15] fix: resolving the failing tests --- tests/Feature/UserEmailTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/UserEmailTest.php b/tests/Feature/UserEmailTest.php index ccd7fcb..f3351d8 100644 --- a/tests/Feature/UserEmailTest.php +++ b/tests/Feature/UserEmailTest.php @@ -26,11 +26,11 @@ $site = \Eclipse\Core\Models\Site::first(); $this->authorizedUser = User::factory()->create(); - $this->authorizedUser->assignRole($this->emailRole); + $this->authorizedUser->syncRoles([$this->emailRole]); // Use syncRoles to replace all roles $this->authorizedUser->sites()->attach($site); $this->unauthorizedUser = User::factory()->create(); - $this->unauthorizedUser->assignRole($this->regularRole); + $this->unauthorizedUser->syncRoles([$this->regularRole]); // Use syncRoles to replace all roles $this->unauthorizedUser->sites()->attach($site); $this->recipientUser = User::factory()->create(); From 4a4309ac3d090274af3159dcdbdc2d1c635bb426 Mon Sep 17 00:00:00 2001 From: ankitcodes4u Date: Fri, 8 Aug 2025 03:34:04 +0545 Subject: [PATCH 15/15] fix: clean comments --- tests/Feature/UserEmailTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/UserEmailTest.php b/tests/Feature/UserEmailTest.php index f3351d8..f0198a4 100644 --- a/tests/Feature/UserEmailTest.php +++ b/tests/Feature/UserEmailTest.php @@ -26,11 +26,11 @@ $site = \Eclipse\Core\Models\Site::first(); $this->authorizedUser = User::factory()->create(); - $this->authorizedUser->syncRoles([$this->emailRole]); // Use syncRoles to replace all roles + $this->authorizedUser->syncRoles([$this->emailRole]); $this->authorizedUser->sites()->attach($site); $this->unauthorizedUser = User::factory()->create(); - $this->unauthorizedUser->syncRoles([$this->regularRole]); // Use syncRoles to replace all roles + $this->unauthorizedUser->syncRoles([$this->regularRole]); $this->unauthorizedUser->sites()->attach($site); $this->recipientUser = User::factory()->create();