Compare commits

..

5 Commits

Author SHA1 Message Date
Lance Pioch
52e5d676a8 Fix PHPStan type errors for custom permission array shapes 2026-02-07 23:49:42 -05:00
Lance Pioch
efe541fb73 Fix PHPStan errors for permission data array shape 2026-02-07 23:40:03 -05:00
Lance Pioch
b183a75c36 Allow plugins to pass custom labels and descriptions for subuser permissions 2026-02-07 23:20:20 -05:00
Lance Pioch
892490176c Support plugin translation namespaces for custom subuser permissions
Adds a translationPrefix parameter to registerCustomPermissions() so
plugins can specify their own namespaced translation keys instead of
being limited to the panel's server/user.permissions.* keys.
2026-02-07 22:31:02 -05:00
Lance Pioch
f95d9b414a Add translatable subuser permission labels with graceful fallbacks (#2069) 2026-02-06 09:20:30 -05:00
9 changed files with 66 additions and 166 deletions

View File

@@ -58,9 +58,6 @@ enum SubuserPermission: string
case SettingsDescription = 'settings.description';
case SettingsReinstall = 'settings.reinstall';
case MountRead = 'mount.read';
case MountUpdate = 'mount.update';
/** @return string[] */
public function split(): array
{
@@ -87,7 +84,6 @@ enum SubuserPermission: string
'schedule' => TablerIcon::Clock,
'settings' => TablerIcon::Settings,
'activity' => TablerIcon::Stack,
'mount' => TablerIcon::LayersLinked,
default => null,
};
}

View File

@@ -143,24 +143,6 @@ class MountResource extends Resource
])
->inline()
->default(false),
ToggleButtons::make('user_mountable')
->label(trans('admin/mount.user_mountable'))
->helperText(trans('admin/mount.user_mountable_help'))
->stateCast(new BooleanStateCast(false, true))
->options([
false => trans('admin/mount.toggles.not_user_mountable'),
true => trans('admin/mount.toggles.user_mountable'),
])
->icons([
false => TablerIcon::Users,
true => TablerIcon::Users,
])
->colors([
false => 'warning',
true => 'success',
])
->inline()
->default(true),
TextInput::make('source')
->label(trans('admin/mount.source'))
->required()

View File

@@ -42,6 +42,7 @@ class CreateMount extends CreateRecord
protected function handleRecordCreation(array $data): Model
{
$data['uuid'] ??= Str::uuid()->toString();
$data['user_mountable'] = 1;
return parent::handleRecordCreation($data);
}

View File

@@ -1,114 +0,0 @@
<?php
namespace App\Filament\Server\Pages;
use App\Enums\SubuserPermission;
use App\Enums\TablerIcon;
use App\Facades\Activity;
use App\Models\Mount;
use App\Models\Server;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class Mounts extends ServerFormPage
{
protected static string|BackedEnum|null $navigationIcon = TablerIcon::LayersLinked;
protected static ?int $navigationSort = 11;
public static function canAccess(): bool
{
return parent::canAccess() && user()?->can(SubuserPermission::MountRead, Filament::getTenant());
}
protected function authorizeAccess(): void
{
abort_unless(user()?->can(SubuserPermission::MountRead, Filament::getTenant()), 403);
}
protected function fillForm(): void
{
$this->form->fill([
'mounts' => $this->getRecord()->mounts->pluck('id')->toArray(),
]);
}
public function form(Schema $schema): Schema
{
/** @var Server $server */
$server = $this->getRecord();
$allowedMounts = Mount::query()
->where('user_mountable', true)
->where(function ($query) use ($server) {
$query->whereDoesntHave('nodes')
->orWhereHas('nodes', fn ($q) => $q->where('nodes.id', $server->node_id));
})
->where(function ($query) use ($server) {
$query->whereDoesntHave('eggs')
->orWhereHas('eggs', fn ($q) => $q->where('eggs.id', $server->egg_id));
})
->get();
return parent::form($schema)
->components([
Section::make(trans('server/mount.description'))
->schema([
CheckboxList::make('mounts')
->hiddenLabel()
->relationship('mounts')
->options(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => $mount->name]))
->descriptions(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => "$mount->source -> $mount->target"]))
->helperText(fn () => $allowedMounts->isEmpty() ? trans('server/mount.no_mounts') : null)
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::MountUpdate, $server))
->bulkToggleable()
->live()
->afterStateUpdated(function ($state) {
$this->save();
})
->columnSpanFull(),
]),
]);
}
public function save(): void
{
/** @var Server $server */
$server = $this->getRecord();
abort_unless(user()?->can(SubuserPermission::MountUpdate, $server), 403);
try {
$this->form->getState();
$this->form->saveRelationships();
Activity::event('server:mount.update')
->log();
Notification::make()
->title(trans('server/mount.notification_updated'))
->success()
->send();
} catch (\Exception $exception) {
Notification::make()
->title(trans('server/mount.notification_failed'))
->body($exception->getMessage())
->danger()
->send();
}
}
public function getTitle(): string
{
return trans('server/mount.title');
}
public static function getNavigationLabel(): string
{
return trans('server/mount.title');
}
}

View File

@@ -77,17 +77,30 @@ class SubuserResource extends Resource
$options = [];
$descriptions = [];
$translationPrefix = $data['translationPrefix'] ?? 'server/user.permissions.';
$customDescriptions = $data['descriptions'] ?? [];
foreach ($data['permissions'] as $permission) {
$options[$permission] = str($permission)->headline();
$descriptions[$permission] = trans('server/user.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
if (isset($customDescriptions[$permission])) {
$descriptions[$permission] = $customDescriptions[$permission];
} else {
$descKey = $translationPrefix . $data['name'] . '_' . str($permission)->replace('-', '_');
$descriptions[$permission] = trans()->has($descKey) ? trans($descKey) : null;
}
$permissionsArray[$data['name']][] = $permission;
}
$tabLabelKey = $translationPrefix . $data['name'];
$groupDescKey = $translationPrefix . $data['name'] . '_desc';
$tabs[] = Tab::make($data['name'])
->label(str($data['name'])->headline())
->label($data['label'] ?? (trans()->has($tabLabelKey) ? trans($tabLabelKey) : str($data['name'])->headline()))
->schema([
Section::make()
->description(trans('server/user.permissions.' . $data['name'] . '_desc'))
->description($data['description'] ?? (trans()->has($groupDescKey) ? trans($groupDescKey) : null))
->icon($data['icon'])
->contained(false)
->schema([

View File

@@ -33,11 +33,17 @@ class Subuser extends Model implements Validatable
*/
public const RESOURCE_NAME = 'server_subuser';
/** @var array<string, array{name: string, hidden: ?bool, icon: ?string, permissions: string[]}> */
/** @var array<string, array{name: string, hidden?: ?bool, icon?: ?string, permissions: string[], translationPrefix?: string, label?: string, description?: string, descriptions?: array<string, string>}> */
protected static array $customPermissions = [];
/** @param string[] $permissions */
public static function registerCustomPermissions(string $name, array $permissions, ?string $icon = null, ?bool $hidden = null): void
/**
* @param string[] $permissions
* @param ?string $label Custom label for the permission tab (overrides translation lookup)
* @param ?string $description Custom description for the permission group (overrides translation lookup)
* @param ?array<string, string> $descriptions Custom descriptions keyed by permission name (overrides translation lookup)
* @param ?string $translationPrefix Translation prefix for looking up labels/descriptions (e.g. 'my-plugin::permissions.')
*/
public static function registerCustomPermissions(string $name, array $permissions, ?string $icon = null, ?bool $hidden = null, ?string $translationPrefix = null, ?string $label = null, ?string $description = null, ?array $descriptions = null): void
{
$customPermission = static::$customPermissions[$name] ?? [];
@@ -52,6 +58,22 @@ class Subuser extends Model implements Validatable
$customPermission['hidden'] = $hidden;
}
if (!is_null($translationPrefix)) {
$customPermission['translationPrefix'] = $translationPrefix;
}
if (!is_null($label)) {
$customPermission['label'] = $label;
}
if (!is_null($description)) {
$customPermission['description'] = $description;
}
if (!is_null($descriptions)) {
$customPermission['descriptions'] = array_merge($customPermission['descriptions'] ?? [], $descriptions);
}
static::$customPermissions[$name] = $customPermission;
}
@@ -93,7 +115,7 @@ class Subuser extends Model implements Validatable
return $this->belongsTo(User::class);
}
/** @return array<array{name: string, hidden: bool, icon: string, permissions: string[]}> */
/** @return array<array{name: string, hidden: bool, icon: string, permissions: string[], translationPrefix?: ?string, label?: ?string, description?: ?string, descriptions?: array<string, string>}> */
public static function allPermissionData(): array
{
$allPermissions = [];
@@ -106,22 +128,28 @@ class Subuser extends Model implements Validatable
'hidden' => $subuserPermission->isHidden(),
'icon' => $subuserPermission->getIcon(),
'permissions' => array_merge($allPermissions[$group]['permissions'] ?? [], [$permission]),
'translationPrefix' => null,
'label' => null,
'description' => null,
'descriptions' => [],
];
}
foreach (static::$customPermissions as $customPermission) {
$name = $customPermission['name'];
$groupData = $allPermissions[$name] ?? [];
$existing = $allPermissions[$name] ?? null;
$groupData = [
$allPermissions[$name] = [
'name' => $name,
'hidden' => $customPermission['hidden'] ?? $groupData['hidden'] ?? false,
'icon' => $customPermission['icon'] ?? $groupData['icon'],
'permissions' => array_unique(array_merge($groupData['permissions'] ?? [], $customPermission['permissions'])),
'hidden' => $customPermission['hidden'] ?? ($existing !== null ? $existing['hidden'] : false),
'icon' => $customPermission['icon'] ?? ($existing !== null ? $existing['icon'] : null),
'permissions' => array_unique(array_merge($existing !== null ? $existing['permissions'] : [], $customPermission['permissions'])),
'translationPrefix' => ($customPermission['translationPrefix'] ?? null) ?? ($existing !== null ? $existing['translationPrefix'] : null),
'label' => ($customPermission['label'] ?? null) ?? ($existing !== null ? $existing['label'] : null),
'description' => ($customPermission['description'] ?? null) ?? ($existing !== null ? $existing['description'] : null),
'descriptions' => array_merge($existing !== null ? $existing['descriptions'] : [], $customPermission['descriptions'] ?? []),
];
$allPermissions[$name] = $groupData;
}
return array_values($allPermissions);

View File

@@ -17,13 +17,9 @@ return [
'no_mounts' => 'No Mounts',
'eggs' => 'Eggs',
'nodes' => 'Nodes',
'user_mountable' => 'User Mountable?',
'user_mountable_help' => 'Allow users to toggle this mount on or off for their servers.',
'toggles' => [
'writable' => 'Writable',
'read_only' => 'Read Only',
'user_mountable' => 'User Mountable',
'not_user_mountable' => 'Admin Only',
],
'table' => [
'name' => 'Name',

View File

@@ -1,9 +0,0 @@
<?php
return [
'title' => 'Mounts',
'description' => 'Manage the mounts attached to your server.',
'no_mounts' => 'There are no user-mountable mounts available for this server.',
'notification_updated' => 'Mounts updated successfully.',
'notification_failed' => 'Failed to update mounts.',
];

View File

@@ -17,6 +17,16 @@ return [
'notification_failed' => 'Failed to invite user!',
'permissions' => [
'title' => 'Permissions',
'control' => 'Control',
'user' => 'User',
'file' => 'File',
'backup' => 'Backup',
'schedule' => 'Schedule',
'database' => 'Database',
'allocation' => 'Allocation',
'startup' => 'Startup',
'settings' => 'Settings',
'activity' => 'Activity',
'activity_desc' => 'Permissions that control a user\'s access to the server activity logs.',
'startup_desc' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.',
'settings_desc' => 'Permissions that control a user\'s ability to modify this server\'s settings.',
@@ -69,8 +79,5 @@ return [
'backup_delete' => 'Allows a user to remove backups from the system.',
'backup_download' => 'Allows a user to download a backup for the server. Danger: this allows a user to access all files for the server in the backup.',
'backup_restore' => 'Allows a user to restore a backup for the server. Danger: this allows the user to delete all of the server files in the process.',
'mount_desc' => 'Permissions that control a user\'s ability to manage mounts for this server.',
'mount_read' => 'Allows a user to view the mounts page and see available mounts.',
'mount_update' => 'Allows a user to toggle mounts on or off for the server.',
],
];