first commit

This commit is contained in:
Mr Sleeps
2026-06-18 16:56:13 +01:00
commit 6e375cbea7
11 changed files with 578 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
{
"name": "mrsleeps/vexim-groups",
"description": "Groups management for VExim Web UI",
"type": "filament-plugin",
"license": "MIT",
"authors": [
{
"name": "Your Name",
"email": "your@email.com"
}
],
"require": {
"php": "^8.4",
"filament/filament": "^3.2",
"illuminate/support": "^11.0"
},
"autoload": {
"psr-4": {
"Vexim\\Groups\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Vexim\\Groups\\GroupServiceProvider"
]
}
},
"minimum-stability": "dev",
"prefer-stable": true
}
+145
View File
@@ -0,0 +1,145 @@
<?php
namespace Vexim\Groups\Filament\Resources;
use App\Filament\Resources\Groups\Pages\CreateGroup;
use App\Filament\Resources\Groups\Pages\EditGroup;
use App\Filament\Resources\Groups\Pages\ListGroups;
use App\Filament\Resources\Groups\Schemas\GroupForm;
use App\Filament\Resources\Groups\Tables\GroupsTable;
use Illuminate\Database\Eloquent\Builder;
use Vexim\Groups\Models\Group;
use App\Models\Domain;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
class GroupResource extends Resource
{
protected static ?string $model = Group::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
protected static string|\UnitEnum|null $navigationGroup = 'Account Management';
protected static ?string $navigationLabel = 'Groups';
protected static ?int $navigationSort = 2;
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return GroupForm::configure($schema);
}
public static function table(Table $table): Table
{
return GroupsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery()->with('domain');
$user = auth()->user();
if (!$user) {
return $query->whereRaw('1 = 0');
}
if ($user->isSystemAdmin()) {
return $query;
}
if ($user->isDomainAdmin()) {
$domainIds = Domain::whereHas('administrators', function ($q) use ($user) {
$q->where('user_id', $user->id)
->where('role', 'domain_admin');
})->pluck('domain_id');
return $query->whereIn('domain_id', $domainIds);
}
return $query->whereRaw('1 = 0');
}
public static function getNavigationBadge(): ?string
{
$user = auth()->user();
if (!$user) {
return null;
}
if ($user->isSystemAdmin()) {
return (string) Group::count();
}
if ($user->isDomainAdmin()) {
$domainIds = Domain::whereHas('administrators', function ($q) use ($user) {
$q->where('user_id', $user->id)
->where('role', 'domain_admin');
})->pluck('domain_id');
$count = Group::whereIn('domain_id', $domainIds)->count();
return (string) $count;
}
// Domain-user sees nothing
return null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'primary';
}
public static function getNavigationBadgeTooltip(): ?string
{
$user = auth()->user();
if (!$user) {
return null;
}
if ($user->isSystemAdmin()) {
return 'Total number of groups in the system';
}
if ($user->isDomainAdmin()) {
return 'Total number of groups in domains you administer';
}
return null;
}
// Hide navigation item for domain-users
public static function shouldRegisterNavigation(): bool
{
$user = auth()->user();
if (!$user || $user->isDomainUser()) {
return false;
}
return true;
}
public static function getPages(): array
{
return [
'index' => ListGroups::route('/'),
'create' => CreateGroup::route('/create'),
'edit' => EditGroup::route('/{record}/edit'),
];
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Vexim\Groups;
use Filament\Panel;
use Illuminate\Support\ServiceProvider;
use Vexim\Groups\Console\InstallGroupModule;
class GroupServiceProvider extends ServiceProvider
{
public function register(): void
{
// Register the plugin with Filament
$this->app->resolving(Panel::class, function (Panel $panel) {
$panel->plugin(new GroupPlugin());
});
}
public function boot(): void
{
// Load migrations
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// Load views if you have any
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'groups');
// Load routes if you have any
$this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
// Publish config
$this->publishes([
__DIR__ . '/../config/groups.php' => config_path('groups.php'),
], 'groups-config');
// Register console commands
if ($this->app->runningInConsole()) {
$this->commands([
InstallGroupModule::class,
]);
}
// Merge config
$this->mergeConfigFrom(
__DIR__ . '/../config/groups.php',
'groups'
);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace Vexim\Groups\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use App\Models\EximUser;
class Group extends Model
{
use HasFactory;
protected $table = 'groups';
protected $fillable = [
'domain_id',
'name',
'is_public',
'enabled'
];
protected $casts = [
'domain_id' => 'integer',
'enabled' => 'boolean',
'is_public' => 'boolean',
];
/**
* Get the domain that owns this group.
*/
public function domain()
{
return $this->belongsTo(Domain::class, 'domain_id');
}
/**
* Get the members (users/aliases) in this group.
* Assuming member_id references another table like users or aliases.
*/
public function members(): BelongsToMany
{
return $this->belongsToMany(
EximUser::class,
'group_contents',
'group_id',
'member_id'
);
}
/**
* Scope for enabled groups only.
*/
public function scopeEnabled($query)
{
return $query->where('enabled', 1);
}
/**
* Scope for public groups only.
*/
public function scopePublic($query)
{
return $query->where('is_public', '1');
}
/**
* Check if group is public.
*/
public function isPublic(): bool
{
return $this->is_public === '1';
}
/**
* Get all administrators assigned to this domain.
*
* Administrators have domain_admin privileges and can manage this domain's
* settings, users, and blocklists.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function administrators()
{
return $this->belongsToMany(User::class, 'vw_domain_user', 'domain_id', 'user_id')
->withPivot('role')
->withTimestamps()
->wherePivot('role', 'domain_admin');
}
// If you need to access the pivot directly
public function groupContents()
{
return $this->hasMany(GroupContent::class, 'group_id');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Vexim\Groups\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\Pivot;
use App\Models\EximUser;
use App\Models\Group;
class GroupContent extends Pivot
{
use HasFactory;
protected $table = 'group_contents';
protected $fillable = [
'group_id',
'member_id'
];
protected $primaryKey = ['group_id', 'member_id'];
public $incrementing = false;
public function member()
{
return $this->belongsTo(EximUser::class, 'member_id');
}
public function group()
{
return $this->belongsTo(Group::class);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Vexim\Groups\Filament\Resources\Pages;
use App\Filament\Resources\Groups\GroupResource;
use Filament\Resources\Pages\CreateRecord;
class CreateGroup extends CreateRecord
{
protected static string $resource = GroupResource::class;
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Vexim\Groups\Filament\Resources\Pages;
use App\Filament\Resources\Groups\GroupResource;
use App\Filament\Resources\Groups\RelationManagers\MembersRelationManager;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditGroup extends EditRecord
{
protected static string $resource = GroupResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
public function getRelationManagers(): array
{
return [
MembersRelationManager::class,
];
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Vexim\Groups\Filament\Resources\Pages;
use App\Filament\Resources\Groups\GroupResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListGroups extends ListRecords
{
protected static string $resource = GroupResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,65 @@
<?php
namespace Vexim\Groups\Filament\Resources\RelationManagers;
use Filament\Forms\Components\Select;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Actions\AttachAction;
use Filament\Actions\DetachAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Schemas\Schema;
use App\Models\EximUser;
class MembersRelationManager extends RelationManager
{
protected static string $relationship = 'members';
protected static ?string $recordTitleAttribute = 'username';
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('username')
->label('Username')
->searchable()
->sortable(),
])
->headerActions([
AttachAction::make()
->label('Add Member')
->model(EximUser::class)
->recordSelectOptionsQuery(function ($query) {
$user = auth()->user();
$group = $this->getOwnerRecord();
$query = $query->whereIn('type', ['alias', 'local']);
if ($user->isSystemAdmin()) {
return $query;
}
if ($user->isDomainAdmin()) {
$domainIds = $user->domains()->pluck('domain_id');
if ($group && $group->domain_id) {
$domainIds->push($group->domain_id);
}
return $query->whereIn('domain_id', $domainIds);
}
return $query->whereRaw('1 = 0');
})
->recordTitleAttribute('username')
->preloadRecordSelect(),
])
->actions([
DetachAction::make()
->label('Remove'),
])
->emptyStateHeading('No group members')
->emptyStateDescription('Add members to this group by clicking the "Add Member" button above.');
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Vexim\Groups\Filament\Resources\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
use App\Models\Domain;
class GroupForm
{
public static function configure(Schema $schema): Schema
{
$record = $schema->getRecord();
$isCreating = $record === null;
return $schema
->components([
Select::make('domain_id')
->label('Domain')
->placeholder('Select a domain')
->options(function () use ($isCreating) {
$user = auth()->user();
if ($isCreating) {
$domainsQuery = $user->isSystemAdmin() ? Domain::query() : $user->domains();
$domainsQuery->whereNotExists(function ($query) {
$query->select('users.domain_id')
->from('users')
->whereColumn('domains.domain_id', 'users.domain_id')
->where('users.type', 'catch');
});
return $domainsQuery->pluck('domain', 'domains.domain_id');
} else {
$domainsQuery = $user->isSystemAdmin() ? Domain::query() : $user->domains();
return $domainsQuery->pluck('domain', 'domains.domain_id');
}
})
->required()
->searchable()
->preload(),
TextInput::make('name')
->helperText('This forms part of the email, the part before the @')
->regex('/^[a-zA-Z0-9._-]+$/')
->validationMessages([
'regex' => 'Only letters, numbers, dots, underscores, and hyphens are allowed.',
])
->required(),
Toggle::make('is_public')
->label('Public group')
->helperText('Anyone can email the group')
->required()
->default(true),
Toggle::make('enabled')
->required(),
]);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Vexim\Groups\Filament\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class GroupsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('domain.domain')
->label('Domain')
->searchable()
->sortable(),
TextColumn::make('name')
->searchable()
->sortable(),
IconColumn::make('is_public')
->label('Public')
->boolean(),
IconColumn::make('enabled')
->boolean(),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}