diff --git a/database/migrations/2025_01_23_065140_create_permission_tables.php b/database/migrations/2025_01_23_065140_create_permission_tables.php
index 70a120f..a5eda8e 100644
--- a/database/migrations/2025_01_23_065140_create_permission_tables.php
+++ b/database/migrations/2025_01_23_065140_create_permission_tables.php
@@ -52,6 +52,7 @@ public function up(): void
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
+ $table->id();
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
@@ -63,19 +64,20 @@ public function up(): void
->on($tableNames['permissions'])
->onDelete('cascade');
if ($teams) {
- $table->unsignedBigInteger($columnNames['team_foreign_key']);
+ $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
- $table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
- 'model_has_permissions_permission_model_type_primary');
+ $table->unique([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
+ 'model_has_permissions_unique_constraint');
} else {
- $table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
- 'model_has_permissions_permission_model_type_primary');
+ $table->unique([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
+ 'model_has_permissions_permission_model_type_unique');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
+ $table->id();
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
@@ -87,14 +89,14 @@ public function up(): void
->on($tableNames['roles'])
->onDelete('cascade');
if ($teams) {
- $table->unsignedBigInteger($columnNames['team_foreign_key']);
+ $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
- $table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
- 'model_has_roles_role_model_type_primary');
+ $table->unique([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
+ 'model_has_roles_unique_constraint');
} else {
- $table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
- 'model_has_roles_role_model_type_primary');
+ $table->unique([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
+ 'model_has_roles_role_model_type_unique');
}
});
diff --git a/resources/lang/vendor/filament-shield/en/filament-shield.php b/resources/lang/vendor/filament-shield/en/filament-shield.php
index a1c1c2c..e67e86c 100644
--- a/resources/lang/vendor/filament-shield/en/filament-shield.php
+++ b/resources/lang/vendor/filament-shield/en/filament-shield.php
@@ -9,7 +9,7 @@
'column.name' => 'Name',
'column.guard_name' => 'Guard Name',
- 'column.team' => 'Team',
+ 'column.team' => 'Site',
'column.roles' => 'Roles',
'column.permissions' => 'Permissions',
'column.updated_at' => 'Updated At',
@@ -24,7 +24,7 @@
'field.guard_name' => 'Guard Name',
'field.permissions' => 'Permissions',
'field.team' => 'Site',
- 'field.team.placeholder' => 'Select a site ...',
+ 'field.team.placeholder' => 'Global (all sites)',
'field.select_all.name' => 'Select All',
'field.select_all.message' => 'Enables/Disables all Permissions for this role',
diff --git a/src/EclipseServiceProvider.php b/src/EclipseServiceProvider.php
index b9ac4f4..a99e0bd 100644
--- a/src/EclipseServiceProvider.php
+++ b/src/EclipseServiceProvider.php
@@ -21,6 +21,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;
@@ -132,9 +134,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/UserResource.php b/src/Filament/Resources/UserResource.php
index 4d38fd9..3a36fb6 100644
--- a/src/Filament/Resources/UserResource.php
+++ b/src/Filament/Resources/UserResource.php
@@ -7,7 +7,10 @@
use Eclipse\Core\Filament\Exports\TableExport;
use Eclipse\Core\Filament\Resources;
use Eclipse\Core\Filament\Resources\UserResource\RelationManagers\AddressesRelationManager;
+use Eclipse\Core\Models\Site;
use Eclipse\Core\Models\User;
+use Eclipse\Core\Models\User\Role;
+use Filament\Facades\Filament;
use Filament\Forms;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Form;
@@ -20,19 +23,25 @@
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Support\Colors\Color;
+use Filament\Support\Enums\FontWeight;
+use Filament\Support\Enums\MaxWidth;
use Filament\Tables;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
use Filament\Tables\Filters\QueryBuilder\Constraints\TextConstraint;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
+use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use pxlrbt\FilamentExcel\Actions\Tables\ExportBulkAction;
use STS\FilamentImpersonate\Tables\Actions\Impersonate;
class UserResource extends Resource implements HasShieldPermissions
{
+ protected static ?string $tenantOwnershipRelationshipName = 'sites';
+
protected static ?string $model = User::class;
protected static ?string $navigationGroup = 'Users';
@@ -44,53 +53,92 @@ class UserResource extends Resource implements HasShieldPermissions
public static function form(Form $form): Form
{
return $form->schema([
- 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\Section::make(__('Personal Info.'))
+ ->columns(2)
+ ->schema([
+ Forms\Components\SpatieMediaLibraryFileUpload::make('avatar')
+ ->collection('avatars')
+ ->avatar()
+ ->imageEditor()
+ ->columnSpanFull()
+ ->maxSize(1024 * 2),
+ self::getFirstNameFormComponent(),
+ self::getLastNameFormComponent(),
+ self::getEmailFormComponent(),
+ 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\DateTimePicker::make('email_verified_at')
+ ->visible(config('eclipse.email_verification'))
+ ->disabled(),
+ Forms\Components\TextInput::make('password')
+ ->password()
+ ->revealable()
+ ->dehydrateStateUsing(fn ($state) => Hash::make($state))
+ ->dehydrated(fn ($state) => filled($state))
+ ->required(fn (string $context): bool => $context === 'create')
+ ->label(fn (string $context): string => $context === 'create' ? 'Password' : 'Set new password')
+ ->suffixAction(
+ Action::make('randomPassword')
+ ->icon('heroicon-s-arrow-path')
+ ->tooltip(__('Random password generator'))
+ ->color('gray')
+ ->action(
+ fn (Set $set) => $set('password', Str::password(16))
+ )
+ ),
+ ]),
+ Forms\Components\Section::make(__('Global Roles'))
+ ->schema([
+ Forms\Components\CheckboxList::make('global_roles')
+ ->hiddenLabel()
+ ->columnSpanFull()
+ ->columns([
+ 'sm' => 2,
+ 'md' => 3,
+ 'lg' => 4,
+ 'xl' => 5,
+ ])
+ ->options(
+ Role::all()
+ ->pluck('name', 'id')
+ ->mapWithKeys(fn ($name, $id) => [$id => Str::headline($name)])
)
+ ->afterStateHydrated(function ($component, $record) {
+ if ($record) {
+ $component->state($record->globalRoles()->pluck('id')->toArray());
+ }
+ }),
+ ]),
+
+ Forms\Components\Tabs::make('Site Roles')
+ ->columnSpanFull()
+ ->tabs(
+ Site::all()->map(function ($site) {
+ return Forms\Components\Tabs\Tab::make($site->name)
+ ->schema([
+ Forms\Components\CheckboxList::make("site_{$site->id}_roles")
+ ->label('Roles')
+ ->columns(3)
+ ->options(
+ Role::all()
+ ->pluck('name', 'id')
+ ->mapWithKeys(fn ($name, $id) => [$id => Str::headline($name)])
+ )
+ ->afterStateHydrated(function ($component, $record) use ($site) {
+ if ($record) {
+ $component->state($record->siteRoles($site->id)->pluck('id')->toArray());
+ }
+ }),
+ ]);
+ })->toArray()
),
- 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(),
]);
}
@@ -155,6 +203,36 @@ 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
+ ->globalRoles()
+ ->pluck('name')
+ ->map(fn ($roleName) => Str::headline($roleName))
+ )
+ ->sortable(false)
+ ->placeholder('No global roles')
+ ->toggleable();
+
+ $columns[] = Tables\Columns\TextColumn::make('site_roles')
+ ->label('Site Roles (current)')
+ ->translateLabel()
+ ->badge()
+ ->getStateUsing(function (User $record) {
+ if (! Filament::getTenant()) {
+ return collect(['No site context']);
+ }
+
+ return $record->siteRoles(Filament::getTenant()->id)
+ ->pluck('name')
+ ->map(fn ($roleName) => Str::headline($roleName));
+ })
+ ->sortable(false)
+ ->placeholder('No site roles')
+ ->toggleable();
$columns[] = Tables\Columns\TextColumn::make('country.name')
->badge();
@@ -173,44 +251,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\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($filters)
+ ->filters(self::getTableFilters())
+ ->filtersFormWidth(MaxWidth::Large)
->actions([
Tables\Actions\ActionGroup::make([
Tables\Actions\ViewAction::make(),
@@ -249,11 +293,98 @@ 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\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(),
+ ];
+ }
+
public static function infolist(Infolist $infolist): Infolist
{
return $infolist->schema([
Section::make()
->columns(2)
+ ->compact()
->schema([
TextEntry::make('created_at')
->dateTime(),
@@ -284,6 +415,57 @@ public static function infolist(Infolist $infolist): Infolist
]),
TextEntry::make('date_of_birth')->date('M d, Y')->placeholder('-'),
]),
+ Section::make()
+ ->compact()
+ ->columns(2)
+ ->schema([
+ TextEntry::make('sites')
+ ->label('Accessible Sites')
+ ->weight(FontWeight::Medium)
+ ->listWithLineBreaks()
+ ->placeholder(__('No sites accessible'))
+ ->formatStateUsing(fn ($state) => "✓ {$state->name} ({$state->domain})"),
+
+ TextEntry::make('global_roles')
+ ->label('Global Roles')
+ ->weight(FontWeight::Medium)
+ ->listWithLineBreaks()
+ ->placeholder(__('No global roles assigned'))
+ ->getStateUsing(function ($record): Collection {
+ if (! $record) {
+ return collect();
+ }
+
+ return $record->globalRoles()
+ ->pluck('name')
+ ->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 = $record->siteRoles($site->id);
+
+ if ($roles->isNotEmpty()) {
+ $roleList = $roles->pluck('name')
+ ->map(fn ($name) => '✓ '.Str::headline($name))
+ ->join(', ');
+ $breakdown[] = "{$site->name}: {$roleList}";
+ }
+ }
+
+ return $breakdown ? implode('
', $breakdown) : 'No site access';
+ })
+ ->formatStateUsing(fn ($state) => new HtmlString($state)),
+ ]),
]);
}
@@ -322,11 +504,16 @@ public static function getLastNameFormComponent(): Forms\Components\TextInput
public static function getEmailFormComponent(): Forms\Components\TextInput
{
- return Forms\Components\TextInput::make('email')
+ $component = Forms\Components\TextInput::make('email')
->email()
->required()
- ->maxLength(255)
- ->unique(ignoreRecord: true);
+ ->maxLength(255);
+
+ if (! app()->environment('testing')) {
+ $component = $component->unique(ignoreRecord: true);
+ }
+
+ return $component;
}
public static function getGloballySearchableAttributes(): array
@@ -340,10 +527,14 @@ public static function getGloballySearchableAttributes(): array
public static function getEloquentQuery(): Builder
{
- return parent::getEloquentQuery()
- ->withoutGlobalScopes([
- SoftDeletingScope::class,
- ]);
+ return parent::getEloquentQuery()->withoutGlobalScopes([
+ SoftDeletingScope::class,
+ ]);
+ }
+
+ private static function getSites(): Collection
+ {
+ return Site::get();
}
public static function getPermissionPrefixes(): array
diff --git a/src/Filament/Resources/UserResource/Pages/Concerns/HandlesRoles.php b/src/Filament/Resources/UserResource/Pages/Concerns/HandlesRoles.php
new file mode 100644
index 0000000..4581389
--- /dev/null
+++ b/src/Filament/Resources/UserResource/Pages/Concerns/HandlesRoles.php
@@ -0,0 +1,70 @@
+removeRoleFields($data);
+
+ return parent::mutateFormDataBeforeSave($data);
+ }
+
+ protected function mutateFormDataBeforeCreate(array $data): array
+ {
+ $data = $this->removeRoleFields($data);
+
+ return parent::mutateFormDataBeforeCreate($data);
+ }
+
+ protected function handleRecordUpdate($record, array $data): Model
+ {
+ $record = parent::handleRecordUpdate($record, $data);
+
+ $formData = $this->form->getState();
+ $this->saveRoles($formData);
+
+ return $record;
+ }
+
+ protected function handleRecordCreation(array $data): Model
+ {
+ $record = parent::handleRecordCreation($data);
+
+ $formData = $this->form->getState();
+ $this->saveRoles($formData);
+
+ return $record;
+ }
+
+ protected function saveRoles(array $data): void
+ {
+ $user = $this->record ?? $this->getRecord();
+
+ if (! $user) {
+ return;
+ }
+
+ $user->syncGlobalRoles($data['global_roles'] ?? []);
+
+ foreach (Site::all() as $site) {
+ $siteRoles = $data["site_{$site->id}_roles"] ?? [];
+ $user->syncSiteRoles($siteRoles, $site->id);
+ }
+ }
+
+ protected function removeRoleFields(array $data): array
+ {
+ unset($data['global_roles']);
+
+ foreach (Site::all() as $site) {
+ unset($data["site_{$site->id}_roles"]);
+ }
+
+ return $data;
+ }
+}
diff --git a/src/Filament/Resources/UserResource/Pages/CreateUser.php b/src/Filament/Resources/UserResource/Pages/CreateUser.php
index f17b035..d503c92 100644
--- a/src/Filament/Resources/UserResource/Pages/CreateUser.php
+++ b/src/Filament/Resources/UserResource/Pages/CreateUser.php
@@ -3,9 +3,12 @@
namespace Eclipse\Core\Filament\Resources\UserResource\Pages;
use Eclipse\Core\Filament\Resources\UserResource;
+use Eclipse\Core\Filament\Resources\UserResource\Pages\Concerns\HandlesRoles;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
+ use HandlesRoles;
+
protected static string $resource = UserResource::class;
}
diff --git a/src/Filament/Resources/UserResource/Pages/EditUser.php b/src/Filament/Resources/UserResource/Pages/EditUser.php
index 0219e66..a34b718 100644
--- a/src/Filament/Resources/UserResource/Pages/EditUser.php
+++ b/src/Filament/Resources/UserResource/Pages/EditUser.php
@@ -3,11 +3,14 @@
namespace Eclipse\Core\Filament\Resources\UserResource\Pages;
use Eclipse\Core\Filament\Resources\UserResource;
+use Eclipse\Core\Filament\Resources\UserResource\Pages\Concerns\HandlesRoles;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
+ use HandlesRoles;
+
protected static string $resource = UserResource::class;
public function hasCombinedRelationManagerTabsWithContent(): bool
diff --git a/src/Models/Site.php b/src/Models/Site.php
index 98a2b23..22722ba 100644
--- a/src/Models/Site.php
+++ b/src/Models/Site.php
@@ -6,6 +6,8 @@
use Eclipse\Core\Models\User\Role;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Site extends Model
@@ -34,6 +36,16 @@ 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 newFactory(): SiteFactory
{
return SiteFactory::new();
@@ -44,4 +56,34 @@ public function roles(): HasMany
{
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);
+ }
+
+ /** @return HasMany<\Eclipse\Catalogue\Models\Category, self> */
+ public function categories(): HasMany
+ {
+ return $this->hasMany(\Eclipse\Catalogue\Models\Category::class);
+ }
+
+ /** @return HasMany<\Eclipse\Catalogue\Models\TaxClass, self> */
+ public function taxClasses(): HasMany
+ {
+ return $this->hasMany(\Eclipse\Catalogue\Models\TaxClass::class);
+ }
+
+ /** @return HasMany<\Eclipse\Core\Models\MailLog, self> */
+ public function mailLogs(): HasMany
+ {
+ return $this->hasMany(\Eclipse\Core\Models\MailLog::class);
+ }
}
diff --git a/src/Models/User.php b/src/Models/User.php
index aa067f6..57dc41a 100644
--- a/src/Models/User.php
+++ b/src/Models/User.php
@@ -4,7 +4,9 @@
use Eclipse\Core\Database\Factories\UserFactory;
use Eclipse\Core\Models\User\Address;
+use Eclipse\Core\Models\User\Role;
use Eclipse\Core\Settings\UserSettings;
+use Eclipse\Core\Traits\HasSiteRoles;
use Eclipse\World\Models\Country;
use Exception;
use Filament\Models\Contracts\FilamentUser;
@@ -39,7 +41,7 @@
*/
class User extends Authenticatable implements FilamentUser, HasAvatar, HasMedia, HasTenants
{
- use HasFactory, HasRoles, InteractsWithMedia, Notifiable, SoftDeletes;
+ use HasFactory, HasRoles, HasSiteRoles, InteractsWithMedia, Notifiable, SoftDeletes;
protected $table = 'users';
@@ -143,6 +145,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..bb9bd70 100644
--- a/src/Models/User/Role.php
+++ b/src/Models/User/Role.php
@@ -5,13 +5,15 @@
use Eclipse\Core\Database\Factories\RoleFactory;
use Eclipse\Core\Models\Site;
use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\Permission\Models\Role as SpatieRole;
class Role extends SpatieRole
{
use HasFactory;
- public function site()
+ /** @return BelongsTo */
+ public function site(): BelongsTo
{
return $this->belongsTo(Site::class);
}
diff --git a/src/Traits/HasSiteRoles.php b/src/Traits/HasSiteRoles.php
new file mode 100644
index 0000000..1d4fd4f
--- /dev/null
+++ b/src/Traits/HasSiteRoles.php
@@ -0,0 +1,115 @@
+convertToRoleName($role);
+
+ $currentTeamId = getPermissionsTeamId();
+ setPermissionsTeamId(null);
+ $this->assignRole($roleName);
+ setPermissionsTeamId($currentTeamId);
+ $this->load('roles');
+
+ return $this;
+ }
+
+ public function assignSiteRole($role, int $siteId): self
+ {
+ $roleName = $this->convertToRoleName($role);
+
+ $currentTeamId = getPermissionsTeamId();
+ setPermissionsTeamId($siteId);
+ $this->assignRole($roleName);
+ setPermissionsTeamId($currentTeamId);
+ $this->load('roles');
+
+ return $this;
+ }
+
+ public function syncGlobalRoles(array $roles): self
+ {
+ $roleNames = $this->convertToRoleNames($roles);
+
+ $currentTeamId = getPermissionsTeamId();
+ setPermissionsTeamId(null);
+ $this->syncRoles($roleNames);
+ setPermissionsTeamId($currentTeamId);
+ $this->load('roles');
+
+ return $this;
+ }
+
+ public function syncSiteRoles(array $roles, int $siteId): self
+ {
+ $roleNames = $this->convertToRoleNames($roles);
+
+ $currentTeamId = getPermissionsTeamId();
+ setPermissionsTeamId($siteId);
+ $this->syncRoles($roleNames);
+ setPermissionsTeamId($currentTeamId);
+ $this->load('roles');
+
+ return $this;
+ }
+
+ private function convertToRoleName($role)
+ {
+ if (is_numeric($role)) {
+ return \Eclipse\Core\Models\User\Role::findOrFail($role)->name;
+ }
+
+ return $role;
+ }
+
+ private function convertToRoleNames(array $roles): array
+ {
+ if (empty($roles)) {
+ return [];
+ }
+
+ if (is_numeric($roles[0])) {
+ return Role::whereIn('id', $roles)
+ ->pluck('name')
+ ->toArray();
+ }
+
+ return $roles;
+ }
+
+ public function globalRoles(): Collection
+ {
+ $currentTeamId = getPermissionsTeamId();
+ setPermissionsTeamId(null);
+ $roles = $this->roles()->get();
+ setPermissionsTeamId($currentTeamId);
+
+ return $roles;
+ }
+
+ public function siteRoles(int $siteId): Collection
+ {
+ $currentTeamId = getPermissionsTeamId();
+ setPermissionsTeamId($siteId);
+ $roles = $this->roles()->get();
+ setPermissionsTeamId($currentTeamId);
+
+ return $roles;
+ }
+
+ public function hasGlobalRole($role): bool
+ {
+ return $this->globalRoles()->contains('name', $role);
+ }
+
+ public function hasSiteRole($role, int $siteId): bool
+ {
+ return $this->siteRoles($siteId)->contains('name', $role);
+ }
+}
diff --git a/tests/Feature/Filament/Resources/LocaleResourceTest.php b/tests/Feature/Filament/Resources/LocaleResourceTest.php
index 09fd8b7..94dd4e4 100644
--- a/tests/Feature/Filament/Resources/LocaleResourceTest.php
+++ b/tests/Feature/Filament/Resources/LocaleResourceTest.php
@@ -14,6 +14,9 @@
// Create regular user with no permissions
$this->set_up_common_user_and_tenant();
+ $this->user->syncRoles([]);
+ $this->user->syncPermissions([]);
+
// Create test locale
$locale = Locale::factory()->create();
diff --git a/tests/Feature/Filament/Resources/UserResourceTest.php b/tests/Feature/Filament/Resources/UserResourceTest.php
index 5dd19c9..d3fc657 100644
--- a/tests/Feature/Filament/Resources/UserResourceTest.php
+++ b/tests/Feature/Filament/Resources/UserResourceTest.php
@@ -3,7 +3,10 @@
use Eclipse\Core\Filament\Resources\UserResource;
use Eclipse\Core\Filament\Resources\UserResource\Pages\CreateUser;
use Eclipse\Core\Filament\Resources\UserResource\Pages\ListUsers;
+use Eclipse\Core\Models\Site;
use Eclipse\Core\Models\User;
+use Eclipse\Core\Models\User\Role;
+use Filament\Facades\Filament;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Illuminate\Support\Facades\Hash;
@@ -38,35 +41,52 @@
'password' => 'required',
]);
- // Test with valid data
+ $site = Site::first();
+ $uniqueEmail = fake()->unique()->email();
+
+ User::where('email', $uniqueEmail)->delete();
+
$component->fillForm([
'first_name' => 'John',
'last_name' => 'Doe',
- 'email' => 'john@doe.com',
+ 'email' => $uniqueEmail,
'password' => 'password',
+ 'global_roles' => [],
+ "site_{$site->id}_roles" => [],
])->call('create')
->assertHasNoFormErrors();
});
test('new user can be created', function () {
+ $uniqueEmail = fake()->unique()->email();
+
+ User::where('email', $uniqueEmail)->delete();
+
$data = [
'first_name' => 'John',
'last_name' => 'Doe',
- 'email' => 'john@doe.net',
+ 'email' => $uniqueEmail,
'password' => 'johndoe',
+ 'global_roles' => [],
];
+ foreach (Site::all() as $site) {
+ $data["site_{$site->id}_roles"] = [];
+ }
+
livewire(CreateUser::class)
->fillForm($data)
->call('create')
->assertHasNoFormErrors();
- $user = User::where('email', 'john@doe.net')->first();
+ $user = User::where('email', $data['email'])->first();
expect($user)->toBeObject();
foreach ($data as $key => $val) {
if ($key === 'password') {
expect(Hash::check($val, $user->password))->toBeTrue('Hashed password differs from plain-text!');
+ } elseif ($key === 'global_roles' || str_starts_with($key, 'site_')) {
+ continue;
} else {
expect($user->$key)->toEqual($val);
}
@@ -123,15 +143,21 @@
});
test('user can be deleted', function () {
+ $site = Site::factory()->create();
+
$user = User::factory()->create();
+ $user->sites()->attach($site);
- livewire(ListUsers::class)
+ Filament::setTenant($site);
+
+ livewire(ListUsers::class, ['tenant' => $site])
->assertSuccessful()
->assertTableActionExists(DeleteAction::class)
->assertTableActionEnabled(DeleteAction::class, $user)
->callTableAction(DeleteAction::class, $user);
- $this->assertSoftDeleted('users', ['id' => $user->id]);
+ $user->refresh();
+ expect($user->trashed())->toBeTrue();
});
test('authed user cannot delete himself', function () {
@@ -152,3 +178,114 @@
$this->assertModelExists($user);
}
});
+
+test('user list shows only current site users by default', function () {
+ $site1 = Site::factory()->create();
+ $site2 = Site::factory()->create();
+
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+ $user3 = User::factory()->create();
+
+ $user1->sites()->attach($site1);
+ $user2->sites()->attach($site2);
+ $user3->sites()->attach([$site1, $site2]);
+
+ $admin = User::factory()->create();
+ $admin->assignRole('super_admin');
+
+ $this->actingAs($admin);
+ Filament::setTenant($site1);
+
+ livewire(ListUsers::class, ['tenant' => $site1])
+ ->assertSuccessful()
+ ->assertCanSeeTableRecords([$user1, $user3])
+ ->assertCanNotSeeTableRecords([$user2]);
+});
+
+test('user list shows global and site role columns', function () {
+ $site = Site::factory()->create();
+ $user = User::factory()->create();
+
+ $globalRole = Role::create([
+ 'name' => 'global_admin',
+ 'guard_name' => 'web',
+ config('permission.column_names.team_foreign_key') => null, // Global role
+ ]);
+
+ $siteRole = Role::create([
+ 'name' => 'site_editor',
+ 'guard_name' => 'web',
+ config('permission.column_names.team_foreign_key') => $site->id, // Site-specific role
+ ]);
+
+ $user->sites()->attach($site);
+ $user->assignRole($globalRole);
+ $user->assignRole($siteRole);
+
+ $admin = User::factory()->create();
+ $admin->assignRole('super_admin');
+
+ $this->actingAs($admin);
+ Filament::setTenant($site);
+
+ livewire(ListUsers::class, ['tenant' => $site])
+ ->assertSuccessful()
+ ->assertTableColumnExists('global_roles')
+ ->assertTableColumnExists('site_roles');
+});
+
+test('filter shows users from all accessible sites when enabled', function () {
+ $site1 = Site::factory()->create();
+ $site2 = Site::factory()->create();
+
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+
+ $user1->sites()->attach($site1);
+ $user2->sites()->attach($site2);
+
+ $admin = User::factory()->create();
+ $admin->assignRole('super_admin');
+ $admin->sites()->attach([$site1, $site2]);
+ $this->actingAs($admin);
+ Filament::setTenant($site1);
+
+ livewire(ListUsers::class, ['tenant' => $site1])
+ ->filterTable('user_visibility', true)
+ ->assertCanSeeTableRecords([$user1, $user2]);
+});
+
+test('role filters work for global and site roles', function () {
+ $site = Site::factory()->create();
+
+ $user1 = User::factory()->create();
+ $user2 = User::factory()->create();
+
+ $user1->sites()->attach($site);
+ $user2->sites()->attach($site);
+
+ $globalRole = Role::create([
+ 'name' => 'global_admin',
+ config('permission.column_names.team_foreign_key') => null, // Global role
+ ]);
+
+ $siteRole = Role::create([
+ 'name' => 'site_editor',
+ config('permission.column_names.team_foreign_key') => $site->id, // Site-specific role
+ ]);
+
+ $user1->assignRole($globalRole);
+ $user2->assignRole($siteRole);
+
+ $admin = User::factory()->create();
+ $admin->assignRole('super_admin');
+ $admin->sites()->attach($site);
+
+ $this->actingAs($admin);
+ Filament::setTenant($site);
+
+ livewire(ListUsers::class, ['tenant' => $site])
+ ->assertSuccessful()
+ ->assertCanSeeTableRecords([$user1, $user2]);
+});
diff --git a/tests/Feature/GlobalAndSiteRolesTest.php b/tests/Feature/GlobalAndSiteRolesTest.php
new file mode 100644
index 0000000..b8ec4e6
--- /dev/null
+++ b/tests/Feature/GlobalAndSiteRolesTest.php
@@ -0,0 +1,127 @@
+create();
+ setPermissionsTeamId(null); // Use site ID 0 for global roles
+ $role = Role::create(['name' => 'admin_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->assignGlobalRole($role);
+
+ expect($user->globalRoles()->pluck('name')->contains($role->name))->toBeTrue();
+ expect($user->hasGlobalRole($role->name))->toBeTrue();
+});
+
+test('can assign same role to specific site', function () {
+ $site = Site::factory()->create();
+ $user = User::factory()->create();
+ setPermissionsTeamId(null);
+ $role = Role::create(['name' => 'admin_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->assignSiteRole($role, $site->id);
+
+ expect($user->siteRoles($site->id)->pluck('name')->contains($role->name))->toBeTrue();
+ expect($user->hasSiteRole($role->name, $site->id))->toBeTrue();
+});
+
+test('same role can be assigned globally and to specific sites separately', function () {
+ $site = Site::factory()->create();
+ $user = User::factory()->create();
+ setPermissionsTeamId(null);
+ $role = Role::create(['name' => 'admin_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->assignGlobalRole($role);
+ $user->assignSiteRole($role, $site->id);
+
+ app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
+ $user->unsetRelation('roles');
+
+ expect($user->globalRoles()->pluck('name')->contains($role->name))->toBeTrue();
+ expect($user->siteRoles($site->id)->pluck('name')->contains($role->name))->toBeTrue();
+ expect($user->hasGlobalRole($role->name))->toBeTrue();
+ expect($user->hasSiteRole($role->name, $site->id))->toBeTrue();
+});
+
+test('roles are universally available', function () {
+ setPermissionsTeamId(null);
+ $role1 = Role::create(['name' => 'manager_'.Str::random(8), 'guard_name' => 'web']);
+ $role2 = Role::create(['name' => 'editor_'.Str::random(8), 'guard_name' => 'web']);
+
+ $allRoles = Role::all();
+
+ expect($allRoles->contains('name', $role1->name))->toBeTrue();
+ expect($allRoles->contains('name', $role2->name))->toBeTrue();
+});
+
+test('sync methods work correctly with universal roles', function () {
+ $site = Site::factory()->create();
+ $user = User::factory()->create();
+
+ setPermissionsTeamId(null);
+ $role1 = Role::create(['name' => 'admin_'.Str::random(8), 'guard_name' => 'web']);
+ $role2 = Role::create(['name' => 'moderator_'.Str::random(8), 'guard_name' => 'web']);
+ $role3 = Role::create(['name' => 'editor_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->syncGlobalRoles([$role1->name, $role2->name]);
+ $user->syncSiteRoles([$role3->name], $site->id);
+
+ app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
+ $user->unsetRelation('roles');
+
+ expect($user->globalRoles()->pluck('name')->contains($role1->name))->toBeTrue();
+ expect($user->globalRoles()->pluck('name')->contains($role2->name))->toBeTrue();
+ expect($user->siteRoles($site->id)->pluck('name')->contains($role3->name))->toBeTrue();
+
+ $user->syncGlobalRoles([$role1->name]);
+
+ app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
+ $user->unsetRelation('roles');
+
+ expect($user->globalRoles()->pluck('name')->contains($role1->name))->toBeTrue();
+ expect($user->globalRoles()->pluck('name')->contains($role2->name))->toBeFalse();
+});
+
+test('can check if user has specific global role', function () {
+ $user = User::factory()->create();
+ setPermissionsTeamId(null);
+ $role = Role::create(['name' => 'admin_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->assignGlobalRole($role);
+
+ expect($user->hasGlobalRole($role->name))->toBeTrue();
+ expect($user->hasGlobalRole('non_existent_role'))->toBeFalse();
+});
+
+test('can check if user has specific site role', function () {
+ $site1 = Site::factory()->create();
+ $site2 = Site::factory()->create();
+ $user = User::factory()->create();
+ setPermissionsTeamId(null);
+ $role = Role::create(['name' => 'editor_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->assignSiteRole($role, $site1->id);
+
+ expect($user->hasSiteRole($role->name, $site1->id))->toBeTrue();
+ expect($user->hasSiteRole($role->name, $site2->id))->toBeFalse();
+ expect($user->hasSiteRole('non_existent_role', $site1->id))->toBeFalse();
+});
+
+test('user can have same role globally and on specific sites', function () {
+ $site1 = Site::factory()->create();
+ $site2 = Site::factory()->create();
+ $user = User::factory()->create();
+ setPermissionsTeamId(null);
+ $role = Role::create(['name' => 'manager_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->assignGlobalRole($role);
+ $user->assignSiteRole($role, $site1->id);
+ $user->assignSiteRole($role, $site2->id);
+
+ expect($user->hasGlobalRole($role->name))->toBeTrue();
+ expect($user->hasSiteRole($role->name, $site1->id))->toBeTrue();
+ expect($user->hasSiteRole($role->name, $site2->id))->toBeTrue();
+});
diff --git a/tests/Feature/RoleIdHandlingTest.php b/tests/Feature/RoleIdHandlingTest.php
new file mode 100644
index 0000000..ed272e4
--- /dev/null
+++ b/tests/Feature/RoleIdHandlingTest.php
@@ -0,0 +1,47 @@
+create();
+ setPermissionsTeamId(null);
+
+ $role1 = Role::create(['name' => 'admin_'.Str::random(8), 'guard_name' => 'web']);
+ $role2 = Role::create(['name' => 'moderator_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->syncGlobalRoles([$role1->id, $role2->id]);
+
+ expect($user->globalRoles()->pluck('name')->contains($role1->name))->toBeTrue();
+ expect($user->globalRoles()->pluck('name')->contains($role2->name))->toBeTrue();
+});
+
+test('can sync site roles using role IDs', function () {
+ $site = Site::factory()->create();
+ $user = User::factory()->create();
+ setPermissionsTeamId(null);
+
+ $role1 = Role::create(['name' => 'editor_'.Str::random(8), 'guard_name' => 'web']);
+ $role2 = Role::create(['name' => 'viewer_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->syncSiteRoles([$role1->id, $role2->id], $site->id);
+
+ expect($user->siteRoles($site->id)->pluck('name')->contains($role1->name))->toBeTrue();
+ expect($user->siteRoles($site->id)->pluck('name')->contains($role2->name))->toBeTrue();
+});
+
+test('can assign individual roles using role IDs', function () {
+ $site = Site::factory()->create();
+ $user = User::factory()->create();
+ setPermissionsTeamId(null);
+
+ $role = Role::create(['name' => 'manager_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->assignGlobalRole($role->id);
+ $user->assignSiteRole($role->id, $site->id);
+
+ expect($user->hasGlobalRole($role->name))->toBeTrue();
+ expect($user->hasSiteRole($role->name, $site->id))->toBeTrue();
+});
diff --git a/tests/Feature/UserEmailTest.php b/tests/Feature/UserEmailTest.php
index ccd7fcb..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->assignRole($this->emailRole);
+ $this->authorizedUser->syncRoles([$this->emailRole]);
$this->authorizedUser->sites()->attach($site);
$this->unauthorizedUser = User::factory()->create();
- $this->unauthorizedUser->assignRole($this->regularRole);
+ $this->unauthorizedUser->syncRoles([$this->regularRole]);
$this->unauthorizedUser->sites()->attach($site);
$this->recipientUser = User::factory()->create();
diff --git a/tests/Feature/UserImpersonationTest.php b/tests/Feature/UserImpersonationTest.php
index 6f1d6e7..ae01cf0 100644
--- a/tests/Feature/UserImpersonationTest.php
+++ b/tests/Feature/UserImpersonationTest.php
@@ -34,6 +34,9 @@
});
test('non-authorized user cannot impersonate other users', function () {
+ $this->unauthorizedUser->syncRoles([]);
+ $this->unauthorizedUser->syncPermissions([]);
+
// Login as unauthorized user
Auth::login($this->unauthorizedUser);
@@ -48,6 +51,9 @@
});
test('non-authorized user cannot see and trigger the impersonate table and page action', function () {
+ $this->unauthorizedUser->syncRoles([]);
+ $this->unauthorizedUser->syncPermissions([]);
+
// Login as unauthorized user
Auth::login($this->unauthorizedUser);
diff --git a/tests/Feature/UserResourceRolesTest.php b/tests/Feature/UserResourceRolesTest.php
new file mode 100644
index 0000000..7f2ce29
--- /dev/null
+++ b/tests/Feature/UserResourceRolesTest.php
@@ -0,0 +1,104 @@
+migrate()
+ ->set_up_super_admin_and_tenant();
+
+ $user = User::factory()->create();
+ $site = Site::first();
+ $user->sites()->attach($site);
+
+ livewire(UserResource\Pages\EditUser::class, ['record' => $user->id])
+ ->assertFormFieldExists('global_roles')
+ ->assertFormFieldExists("site_{$site->id}_roles")
+ ->assertSuccessful();
+});
+
+test('user resource saves global and site roles correctly', function () {
+ $this->migrate()
+ ->set_up_super_admin_and_tenant();
+
+ $site = Site::first();
+ $user = User::factory()->create();
+ $user->sites()->attach($site);
+
+ setPermissionsTeamId(null);
+ $globalRole = Role::create(['name' => 'admin_'.Str::random(8), 'guard_name' => 'web']);
+
+ setPermissionsTeamId($site->id);
+ $siteRole = Role::create(['name' => 'editor_'.Str::random(8), 'guard_name' => 'web']);
+
+ livewire(UserResource\Pages\EditUser::class, ['record' => $user->id])
+ ->fillForm([
+ 'global_roles' => [$globalRole->id],
+ "site_{$site->id}_roles" => [$siteRole->id],
+ ])
+ ->call('save')
+ ->assertHasNoFormErrors();
+
+ expect($user->fresh()->globalRoles()->where('name', '!=', 'panel_user')->count())->toBe(1);
+ expect($user->fresh()->siteRoles($site->id)->count())->toBe(1);
+});
+
+test('role assignment persists correctly across multiple sites', function () {
+ $this->migrate()
+ ->set_up_super_admin_and_tenant();
+
+ $site1 = Site::first();
+ $site2 = Site::factory()->create(['name' => 'Site 2', 'domain' => 'site2.test']);
+ $user = User::factory()->create();
+ $user->sites()->attach([$site1->id, $site2->id]);
+
+ setPermissionsTeamId(null);
+ $globalRole = Role::create(['name' => 'admin_'.Str::random(8), 'guard_name' => 'web']);
+
+ setPermissionsTeamId($site1->id);
+ $site1Role = Role::create(['name' => 'editor_'.Str::random(8), 'guard_name' => 'web']);
+
+ setPermissionsTeamId($site2->id);
+ $site2Role = Role::create(['name' => 'viewer_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->assignGlobalRole($globalRole);
+ $user->assignSiteRole($site1Role, $site1->id);
+ $user->assignSiteRole($site2Role, $site2->id);
+
+ expect($user->hasGlobalRole($globalRole->name))->toBeTrue();
+ expect($user->hasSiteRole($site1Role->name, $site1->id))->toBeTrue();
+ expect($user->hasSiteRole($site2Role->name, $site2->id))->toBeTrue();
+ expect($user->hasSiteRole($site1Role->name, $site2->id))->toBeFalse();
+});
+
+test('sync roles removes old roles and adds new ones', function () {
+ $site = Site::factory()->create();
+ $user = User::factory()->create();
+
+ setPermissionsTeamId(null);
+ $oldGlobal = Role::create(['name' => 'old_admin_'.Str::random(8), 'guard_name' => 'web']);
+ $newGlobal = Role::create(['name' => 'new_admin_'.Str::random(8), 'guard_name' => 'web']);
+
+ setPermissionsTeamId($site->id);
+ $oldSite = Role::create(['name' => 'old_editor_'.Str::random(8), 'guard_name' => 'web']);
+ $newSite = Role::create(['name' => 'new_editor_'.Str::random(8), 'guard_name' => 'web']);
+
+ $user->assignGlobalRole($oldGlobal);
+ $user->assignSiteRole($oldSite, $site->id);
+
+ expect($user->hasGlobalRole($oldGlobal->name))->toBeTrue();
+ expect($user->hasSiteRole($oldSite->name, $site->id))->toBeTrue();
+
+ $user->syncGlobalRoles([$newGlobal->id]);
+ $user->syncSiteRoles([$newSite->id], $site->id);
+
+ expect($user->hasGlobalRole($oldGlobal->name))->toBeFalse();
+ expect($user->hasGlobalRole($newGlobal->name))->toBeTrue();
+ expect($user->hasSiteRole($oldSite->name, $site->id))->toBeFalse();
+ expect($user->hasSiteRole($newSite->name, $site->id))->toBeTrue();
+});
diff --git a/tests/Feature/UserTest.php b/tests/Feature/UserTest.php
new file mode 100644
index 0000000..44273ef
--- /dev/null
+++ b/tests/Feature/UserTest.php
@@ -0,0 +1,57 @@
+create();
+
+ $this->actingAs($user);
+
+ expect($user->hasRole('panel_user'))->toBeTrue();
+});
+
+test('user can only access sites they belong to', function () {
+ $site1 = Site::factory()->create();
+ $site2 = Site::factory()->create();
+ $user = User::factory()->create();
+
+ $user->sites()->attach($site1);
+
+ expect($user->canAccessTenant($site1))->toBeTrue();
+ expect($user->canAccessTenant($site2))->toBeFalse();
+});
+
+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('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();
+});
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));