Compare commits

...

62 Commits

Author SHA1 Message Date
notCharles
3435756075 update 2026-02-23 15:52:11 -05:00
notCharles
d37505a04b Merge branch 'main' into charles/filament-v5 2026-02-23 15:44:27 -05:00
Boy132
e35ce1e79d Use folder name as id when having id mismatch to allow deletion (#2263) 2026-02-23 20:25:19 +01:00
Charles
42c127c004 Fix invisible force delete button (#2262) 2026-02-23 13:41:15 -05:00
Boy132
593f209142 Fix stock egg UUID migration (#2259) 2026-02-22 17:23:51 +01:00
Boy132
9bf5b2cf0a Do not throw error when checking for egg updates (#2256) 2026-02-22 17:23:09 +01:00
Lance Pioch
f76e864a30 Laravel 12.52.0 Shift (#2247)
Co-authored-by: Shift <shift@laravelshift.com>
2026-02-22 11:11:18 -05:00
Boy132
01cfa31ee1 Limit activity logs on profile page (#2253) 2026-02-19 14:57:48 +01:00
Boy132
dead664e4d Make plugin id in artisan commands also case insensitive (#2252) 2026-02-19 14:57:19 +01:00
Charles
1bbbcd0e25 Update server/egg icon url supported file types (#2249) 2026-02-18 16:30:24 -05:00
Michael (Parker) Parker
677d2f742c docker env fixes (#2234)
Co-authored-by: Charles <charles@pelican.dev>
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2026-02-18 14:40:46 -05:00
Charles
38ffc9fe3e Filament v5 2026-02-18 06:22:10 -05:00
Charles
650fb16d2d exclude bulk actions in list eggs (#2239) 2026-02-17 06:17:28 -05:00
Boy132
58814ea782 Allow to delete errored plugins (#2246) 2026-02-17 11:52:25 +01:00
hallo123wert
0918ed308b exclude bulk actions (#2240) 2026-02-16 06:34:14 -05:00
Lance Pioch
85d5f2ec3f Fix alert banners (#1492) (#2177) 2026-02-15 15:34:09 -05:00
Lance Pioch
810f237547 Remove ignition, just use new default error handler (#2241) 2026-02-15 15:03:55 -05:00
Lance Pioch
8f191890a1 Fix null backup limit exception (#2242) 2026-02-15 15:03:37 -05:00
Boy132
160e0e54f5 More action fixes (#2237)
Co-authored-by: notCharles <charles@pelican.dev>
2026-02-15 17:31:50 +01:00
Charles
fdcfbb00ca Revert "Fix UTF double encoding in file editor" (#2236) 2026-02-15 11:08:01 -05:00
Lance Pioch
44f6cf8928 Fix UTF double encoding in file editor (#2199) 2026-02-14 10:33:35 -05:00
Boy132
cf2a26bbf0 Improve plugin loading (#2233) 2026-02-14 14:06:50 +01:00
gOOvER
adb6678eee Convert YAML boolean defaults to strings (#2232) 2026-02-13 18:12:44 -05:00
Lance Pioch
9539e21b39 Handle invalid svg icon in generic-oidc provider gracefully (#2216) 2026-02-13 10:06:07 -05:00
Charles
33660f635f Fix yet another action (#2230) 2026-02-12 21:00:26 -05:00
Lance Pioch
8c475ed95f Add “reachable” column for Client -> Wings connections for Nodes (#2200) 2026-02-12 17:06:38 -05:00
Lance Pioch
d43cb1d180 Fix empty egg config_files causing fatal 500 error (#2195) (#2197) 2026-02-12 17:06:19 -05:00
Lance Pioch
fe55dbd200 Ignore oauth redirects for spa (#2224) 2026-02-12 16:31:35 -05:00
Charles
0bb4503c2b fix unsuspend button (#2227) 2026-02-12 16:31:20 -05:00
Lance Pioch
3c1168beb5 Update job runners (#2225) 2026-02-12 10:29:33 -05:00
Quinten
1a092aedc8 Docker: chown composer.json and composer.lock for plugins (#2220) 2026-02-12 10:02:12 -05:00
Charles
8c99a8030f Fix more actions (#2208) 2026-02-12 09:56:56 -05:00
Lance Pioch
6e53b1cd7d Allow custom ips to still be entered (#2223) 2026-02-12 09:49:54 -05:00
Charles
4042e0416b Revert toolbar change (#2218) 2026-02-11 17:54:58 -05:00
Lance Pioch
cc8973cf00 Laravel 12.51.0 Shift (#2213)
Co-authored-by: Shift <shift@laravelshift.com>
2026-02-11 11:18:33 -05:00
Quinten
8ebe75b947 fix: composer not been installed in the docker image (#2211) 2026-02-10 16:00:18 -05:00
Hythera
f8144407d1 fix: composer content hash (#2209) 2026-02-09 22:26:01 -05:00
Charles
e431ccb66a add rounding to list-files header (#2207) 2026-02-09 22:19:10 -05:00
Lance Pioch
9291bb4477 Fix webhook processing for event objects (#2198) 2026-02-09 08:47:09 -05:00
Lance Pioch
e8c80ae420 Fix SFTP access denied for subuser when view role is assigned (#2196) 2026-02-09 08:46:47 -05:00
stdpi
f1be003276 Change file browser sticky header again... (#2203) 2026-02-08 15:17:48 -05:00
Charles
e532a9a180 composer upgrade (#2167)
Co-authored-by: Lance Pioch <git@lance.sh>
2026-02-07 14:33:51 -05:00
Charles
41fdd7bc8e Exclude create client api key button (#2169) 2026-02-07 14:33:32 -05:00
Lance Pioch
9b01203b7c Add per-user toggle to redirect admins to /admin after login (#2191) 2026-02-07 13:21:14 -05:00
Lance Pioch
ab4eadec32 Fix Exporting an egg in yaml format (#2172) 2026-02-07 08:10:00 -05:00
Lance Pioch
789c4c7284 Localize email notifications (#2043) (#2178) 2026-02-06 10:37:15 -05:00
Lance Pioch
b1a39f1724 Handle X-Forwarded-Proto in .htaccess for SSL-terminating proxies (#2171) 2026-02-06 09:54:24 -05:00
Lance Pioch
6a548c09a0 Clarify OAuth error when provider account has no linked email (#2179) 2026-02-06 07:50:37 -05:00
Lance Pioch
55bda569cc Implement flexible caching for node statuses (#2174) 2026-02-06 07:50:20 -05:00
Lance Pioch
adf1249086 Fix Egg Feature modals not working (#2175) 2026-02-06 07:49:56 -05:00
Lance Pioch
dbf77bf146 Implement single file move to support Unix mv semantics (#1984) (#2176) 2026-02-06 07:49:40 -05:00
Frogperson
a34bf9fd49 Add Daemon Base Directory field (#2151)
Co-authored-by: Boy132 <mail@boy132.de>
2026-02-05 08:00:25 -05:00
Boy132
7a9deba0e1 Fix notifications for DeleteAction on EditEgg page (#2165) 2026-02-04 22:20:22 +01:00
Charles
159bfe2210 exclude node actions (#2164) 2026-02-04 06:48:13 -05:00
stdpi
a821db8aae Improve file browser UI (#2086) 2026-02-04 05:40:45 -05:00
Lance Pioch
1556f8efb8 Allow all permissions to be toggled at once for api tokens (#2154) 2026-02-02 08:41:10 -05:00
Lance Pioch
57c2aa6f21 Fix the tabbing for username to password on login (#2155)
Co-authored-by: notCharles <charles@pelican.dev>
2026-02-02 08:40:55 -05:00
Charles
36de4c3786 composer update (#2161) 2026-01-31 19:37:54 -05:00
Boy132
26312e3897 Add basic api for plugins (#2146) 2026-02-01 00:10:57 +01:00
Boy132
a477c89025 Move username sanitization to model and make it less strict (#2150) 2026-02-01 00:07:26 +01:00
Boy132
93e81c26a9 Send server id to wings (#2157) 2026-01-31 22:54:06 +01:00
Boy132
23e91e8df3 Fix "visit website" button being invisible (#2158) 2026-01-31 22:53:56 +01:00
112 changed files with 2138 additions and 1560 deletions

View File

@@ -26,7 +26,7 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4, 8.5]
php: [8.3, 8.4, 8.5]
env:
DB_CONNECTION: sqlite
DB_DATABASE: testing.sqlite
@@ -79,8 +79,8 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4, 8.5]
database: ["mysql:8"]
php: [8.5]
database: ["mysql:8.4", "mysql:9.6"]
services:
database:
image: ${{ matrix.database }}
@@ -147,8 +147,8 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4, 8.5]
database: ["mariadb:10.6", "mariadb:10.11", "mariadb:11.4"]
php: [8.5]
database: ["mariadb:10.11", "mariadb:11.4"]
services:
database:
image: ${{ matrix.database }}
@@ -215,8 +215,8 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4, 8.5]
database: ["postgres:14"]
php: [8.5]
database: ["postgres:17", "postgres:18"]
services:
database:
image: ${{ matrix.database }}

View File

@@ -68,6 +68,8 @@ RUN apk add --no-cache \
# required for installing plugins. Pulled from https://github.com/pelican-dev/panel/pull/2034
zip unzip 7zip bzip2-dev yarn git
# Copy composer binary for runtime plugin dependency management
COPY --from=composer /usr/local/bin/composer /usr/local/bin/composer
COPY --chown=root:www-data --chmod=770 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=770 --from=yarnbuild /build/public ./public
@@ -83,8 +85,7 @@ RUN mkdir -p /pelican-data/storage /pelican-data/plugins /var/run/supervisord \
# Allow www-data write permissions where necessary
&& chown -R www-data: /pelican-data .env ./storage ./plugins ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R 770 /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/ /var/www/html/composer.json /var/www/html/composer.lock
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile

View File

@@ -73,6 +73,8 @@ RUN apk add --no-cache \
# required for installing plugins. Pulled from https://github.com/pelican-dev/panel/pull/2034
zip unzip 7zip bzip2-dev yarn git
# Copy composer binary for runtime plugin dependency management
COPY --from=composer /usr/local/bin/composer /usr/local/bin/composer
COPY --chown=root:www-data --chmod=770 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=770 --from=yarnbuild /build/public ./public
@@ -88,7 +90,7 @@ RUN mkdir -p /pelican-data/storage /pelican-data/plugins /var/run/supervisord \
# Allow www-data write permissions where necessary
&& chown -R www-data: /pelican-data .env ./storage ./plugins ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R 770 /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/ /var/www/html/composer.json /var/www/html/composer.lock
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf

View File

@@ -8,7 +8,6 @@ use App\Services\Eggs\Sharing\EggExporterService;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use JsonException;
use Symfony\Component\Yaml\Yaml;
class CheckEggUpdatesCommand extends Command
@@ -22,14 +21,12 @@ class CheckEggUpdatesCommand extends Command
try {
$this->check($egg, $exporterService);
} catch (Exception $exception) {
$this->error("{$egg->name}: Error ({$exception->getMessage()})");
$this->error("$egg->name: Error ({$exception->getMessage()})");
}
}
}
/**
* @throws JsonException
*/
/** @throws Exception */
private function check(Egg $egg, EggExporterService $exporterService): void
{
if (is_null($egg->update_url)) {
@@ -45,7 +42,13 @@ class CheckEggUpdatesCommand extends Command
? Yaml::parse($exporterService->handle($egg->id, EggFormat::YAML))
: json_decode($exporterService->handle($egg->id, EggFormat::JSON), true);
$remote = Http::timeout(5)->connectTimeout(1)->get($egg->update_url)->throw()->body();
$remote = Http::timeout(5)->connectTimeout(1)->get($egg->update_url);
if ($remote->failed()) {
throw new Exception("HTTP request returned status code {$remote->status()}");
}
$remote = $remote->body();
$remote = $isYaml ? Yaml::parse($remote) : json_decode($remote, true);
unset($local['exported_at'], $remote['exported_at']);

View File

@@ -16,7 +16,7 @@ class DisablePluginCommand extends Command
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
$plugin = Plugin::find(str($id)->lower()->toString());
if (!$plugin) {
$this->error('Plugin does not exist!');

View File

@@ -18,7 +18,7 @@ class InstallPluginCommand extends Command
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
$plugin = Plugin::find(str($id)->lower()->toString());
if (!$plugin) {
$this->error('Plugin does not exist!');

View File

@@ -18,7 +18,7 @@ class UninstallPluginCommand extends Command
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
$plugin = Plugin::find(str($id)->lower()->toString());
if (!$plugin) {
$this->error('Plugin does not exist!');

View File

@@ -17,7 +17,7 @@ class UpdatePluginCommand extends Command
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
$plugin = Plugin::find(str($id)->lower()->toString());
if (!$plugin) {
$this->error('Plugin does not exist!');

View File

@@ -12,6 +12,7 @@ enum CustomizationKey: string
case DashboardLayout = 'dashboard_layout';
case ButtonStyle = 'button_style';
case RedirectToAdmin = 'redirect_to_admin';
public function getDefaultValue(): string|int|bool
{
@@ -23,6 +24,7 @@ enum CustomizationKey: string
self::TopNavigation => config('panel.filament.default-navigation', 'sidebar'),
self::DashboardLayout => 'grid',
self::ButtonStyle => true,
self::RedirectToAdmin => false,
};
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Exceptions;
use App\Exceptions\Solutions\ManifestDoesNotExistSolution;
use Exception;
use Spatie\Ignition\Contracts\ProvidesSolution;
use Spatie\Ignition\Contracts\Solution;
class ManifestDoesNotExistException extends Exception implements ProvidesSolution
{
public function getSolution(): Solution
{
return new ManifestDoesNotExistSolution();
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Exceptions;
use Exception;
class PluginIdMismatchException extends Exception {}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Exceptions\Solutions;
use Spatie\Ignition\Contracts\Solution;
class ManifestDoesNotExistSolution implements Solution
{
public function getSolutionTitle(): string
{
return "The manifest.json file hasn't been generated yet";
}
public function getSolutionDescription(): string
{
return 'Run yarn run build:production to build the frontend first.';
}
public function getDocumentationLinks(): array
{
return [
'Docs' => 'https://github.com/pelican/panel/blob/master/package.json',
];
}
}

View File

@@ -13,6 +13,8 @@ use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use App\Traits\Filament\CanCustomizeTabs;
use BackedEnum;
use BladeUI\Icons\Exceptions\SvgNotFound;
use BladeUI\Icons\Factory as IconFactory;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@@ -68,6 +70,8 @@ class Settings extends Page implements HasSchemas
protected CaptchaService $captchaService;
protected IconFactory $iconFactory;
/** @var array<mixed>|null */
public ?array $data = [];
@@ -76,11 +80,12 @@ class Settings extends Page implements HasSchemas
$this->form->fill();
}
public function boot(OAuthService $oauthService, AvatarService $avatarService, CaptchaService $captchaService): void
public function boot(OAuthService $oauthService, AvatarService $avatarService, CaptchaService $captchaService, IconFactory $iconFactory): void
{
$this->oauthService = $oauthService;
$this->avatarService = $avatarService;
$this->captchaService = $captchaService;
$this->iconFactory = $iconFactory;
}
public static function canAccess(): bool
@@ -565,9 +570,18 @@ class Settings extends Page implements HasSchemas
foreach ($oauthSchemas as $schema) {
$key = $schema->getConfigKey();
$icon = $schema->getIcon();
if (is_string($icon)) {
try {
$this->iconFactory->svg($icon);
} catch (SvgNotFound) {
$icon = null;
}
}
$formFields[] = Section::make($schema->getName())
->columns(5)
->icon($schema->getIcon() ?? TablerIcon::BrandOauth)
->icon($icon ?? TablerIcon::BrandOauth)
->collapsed(fn () => !$schema->isEnabled())
->collapsible()
->schema([

View File

@@ -22,6 +22,7 @@ use Filament\Forms\Components\ToggleButtons;
use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -113,12 +114,44 @@ class ApiKeyResource extends Resource
*/
public static function defaultForm(Schema $schema): Schema
{
$permissionList = ApiKey::getPermissionList();
return $schema
->components([
Section::make(trans('admin/apikey.permissions.all'))
->description(trans('admin/apikey.permissions.all_description'))
->columnSpanFull()
->schema([
ToggleButtons::make('permissions_all')
->hiddenLabel()
->inline()
->options([
0 => trans('admin/apikey.permissions.none'),
1 => trans('admin/apikey.permissions.read'),
3 => trans('admin/apikey.permissions.read_write'),
])
->icons([
0 => TablerIcon::BookOff,
1 => TablerIcon::Book,
3 => TablerIcon::Writing,
])
->colors([
0 => 'success',
1 => 'warning',
3 => 'danger',
])
->live()
->afterStateUpdated(function ($state, callable $set) use ($permissionList) {
foreach ($permissionList as $resource) {
$set('permissions_' . $resource, $state);
}
})
->default(0),
]),
Fieldset::make('Permissions')
->columnSpanFull()
->schema(
collect(ApiKey::getPermissionList())->map(fn ($resource) => ToggleButtons::make('permissions_' . $resource)
collect($permissionList)->map(fn ($resource) => ToggleButtons::make('permissions_' . $resource)
->label(str($resource)->replace('_', ' ')->title())->inline()
->options([
0 => trans('admin/apikey.permissions.none'),

View File

@@ -101,7 +101,7 @@ class DatabaseHostResource extends Resource
->toolbarActions([
CreateAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make('exclude_bulk_delete'),
]),
])
->emptyStateIcon(TablerIcon::Database)

View File

@@ -8,6 +8,7 @@ use App\Services\Databases\Hosts\HostCreationService;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
@@ -45,6 +46,17 @@ class CreateDatabaseHost extends CreateRecord
$this->service = $service;
}
protected function getCreateFormAction(): Action
{
$hasFormWrapper = $this->hasFormWrapper();
return Action::make('exclude_create')
->label(trans('filament-panels::resources/pages/create-record.form.actions.create.label'))
->submit($hasFormWrapper ? $this->getSubmitFormLivewireMethodName() : null)
->action($hasFormWrapper ? null : $this->getSubmitFormLivewireMethodName())
->keyBindings(['mod+s']);
}
/** @return Step[]
* @throws Exception
*/

View File

@@ -175,7 +175,8 @@ class CreateEgg extends CreateRecord
->addActionLabel(trans('admin/egg.add_new_variable'))
->grid()
->relationship('variables')
->reorderable()->orderColumn()
->orderColumn()
->reorderAction(fn (Action $action) => $action->hiddenLabel()->tooltip(fn () => $action->getLabel()))
->collapsible()->collapsed()
->columnSpan(2)
->defaultItems(0)

View File

@@ -318,6 +318,7 @@ class EditEgg extends EditRecord
->helperText(trans('admin/egg.start_config_help')),
Textarea::make('config_files')->rows(10)->json()
->label(trans('admin/egg.config_files'))
->dehydrateStateUsing(fn ($state) => blank($state) ? '{}' : $state)
->helperText(trans('admin/egg.config_files_help')),
Textarea::make('config_logs')->rows(10)->json()
->label(trans('admin/egg.log_config'))
@@ -332,9 +333,9 @@ class EditEgg extends EditRecord
->hiddenLabel()
->grid()
->relationship('variables')
->reorderable()
->collapsible()->collapsed()
->orderColumn()
->reorderAction(fn (Action $action) => $action->hiddenLabel()->tooltip(fn () => $action->getLabel()))
->collapsible()->collapsed()
->addActionLabel(trans('admin/egg.add_new_variable'))
->itemLabel(fn (array $state) => $state['name'])
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
@@ -450,17 +451,7 @@ class EditEgg extends EditRecord
return [
DeleteAction::make()
->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0)
->tooltip(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? trans('filament-actions::delete.single.label') : trans('admin/egg.in_use'))
->successNotification(fn (Egg $egg) => Notification::make()
->success()
->title(trans('admin/egg.delete_success'))
->body(trans('admin/egg.deleted', ['egg' => $egg->name]))
)
->failureNotification(fn (Egg $egg) => Notification::make()
->danger()
->title(trans('admin/egg.delete_failed'))
->body(trans('admin/egg.could_not_delete', ['egg' => $egg->name]))
),
->tooltip(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? trans('filament-actions::delete.single.label') : trans('admin/egg.in_use')),
ExportEggAction::make(),
ImportEggAction::make()
->multiple(false),
@@ -494,18 +485,20 @@ class EditEgg extends EditRecord
],
]);
$normalizedExtension = match ($extension) {
'svg+xml', 'svg' => 'svg',
'jpeg', 'jpg' => 'jpg',
'png' => 'png',
'webp' => 'webp',
default => throw new Exception(trans('admin/egg.import.unknown_extension')),
};
$data = @file_get_contents($imageUrl, false, $context, 0, 1048576); // 1024KB
if (empty($data)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$normalizedExtension = match ($extension) {
'svg+xml' => 'svg',
'jpeg' => 'jpg',
default => $extension,
};
Storage::disk('public')->put(Egg::ICON_STORAGE_PATH . "/$egg->uuid.$normalizedExtension", $data);
}

View File

@@ -86,7 +86,7 @@ class ListEggs extends ListRecords
->multiple(),
CreateAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make()
DeleteBulkAction::make('exclude_bulk_delete')
->before(function (Collection &$records) {
$eggsWithServers = $records->filter(fn (Egg $egg) => $egg->servers_count > 0);
@@ -106,7 +106,7 @@ class ListEggs extends ListRecords
$this->halt();
}
}),
UpdateEggBulkAction::make()
UpdateEggBulkAction::make('exclude_bulk_update')
->before(function (Collection &$records) {
$eggsWithoutUpdateUrl = $records->filter(fn (Egg $egg) => $egg->update_url === null);

View File

@@ -104,7 +104,7 @@ class MountResource extends Resource
->toolbarActions([
CreateAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make('exclude_bulk_delete'),
]),
])
->emptyStateIcon(TablerIcon::LayersLinked)

View File

@@ -278,6 +278,14 @@ class CreateNode extends CreateRecord
->default(256)
->minValue(1)
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
TextInput::make('daemon_base')
->label(trans('admin/node.daemon_base'))
->placeholder('/var/lib/pelican/volumes')
->hintIcon(TablerIcon::QuestionMark, trans('admin/node.daemon_base_help'))
->columnSpan(1)
->required()
->default('/var/lib/pelican/volumes')
->rule('regex:/^([\/][\d\w.\-\/]+)$/'),
TextInput::make('daemon_sftp')
->columnSpan(1)
->label(trans('admin/node.sftp_port'))
@@ -287,7 +295,7 @@ class CreateNode extends CreateRecord
->required()
->integer(),
TextInput::make('daemon_sftp_alias')
->columnSpan(2)
->columnSpan(1)
->label(trans('admin/node.sftp_alias'))
->helperText(trans('admin/node.sftp_alias_help')),
Grid::make()

View File

@@ -314,7 +314,7 @@ class EditNode extends EditRecord
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 2,
'lg' => 3,
]),
TextInput::make('upload_size')
->columnSpan([
@@ -329,12 +329,24 @@ class EditNode extends EditRecord
->required()
->minValue(1)
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
TextInput::make('daemon_base')
->label(trans('admin/node.daemon_base'))
->placeholder('/var/lib/pelican/volumes')
->hintIcon(TablerIcon::QuestionMark, trans('admin/node.daemon_base_help'))
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 2,
'lg' => 2,
])
->required()
->rule('regex:/^([\/][\d\w.\-\/]+)$/'),
TextInput::make('daemon_sftp')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 3,
'md' => 2,
'lg' => 1,
])
->label(trans('admin/node.sftp_port'))
->minValue(1)
@@ -346,8 +358,8 @@ class EditNode extends EditRecord
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 3,
'md' => 2,
'lg' => 2,
])
->label(trans('admin/node.sftp_alias'))
->helperText(trans('admin/node.sftp_alias_help')),
@@ -356,7 +368,7 @@ class EditNode extends EditRecord
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 3,
'lg' => 2,
])
->label(trans('admin/node.use_for_deploy'))
->inline()
@@ -374,7 +386,7 @@ class EditNode extends EditRecord
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 3,
'lg' => 2,
])
->label(trans('admin/node.maintenance_mode'))
->inline()
@@ -572,7 +584,7 @@ class EditNode extends EditRecord
->columnSpanFull()
->schema([
Actions::make([
Action::make('autoDeploy')
Action::make('exclude_autoDeploy')
->label(trans('admin/node.auto_deploy'))
->color('primary')
->modalHeading(trans('admin/node.auto_deploy'))
@@ -610,7 +622,7 @@ class EditNode extends EditRecord
}),
])->fullWidth(),
Actions::make([
Action::make('resetKey')
Action::make('exclude_resetKey')
->label(trans('admin/node.reset_token'))
->color('danger')
->requiresConfirmation()

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Admin\Resources\Nodes\Pages;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Nodes\NodeResource;
use App\Filament\Components\Tables\Columns\NodeClientHealthColumn;
use App\Filament\Components\Tables\Columns\NodeHealthColumn;
use App\Filament\Components\Tables\Filters\TagsFilter;
use App\Models\Node;
@@ -34,6 +35,7 @@ class ListNodes extends ListRecords
->searchable()
->hidden(),
NodeHealthColumn::make('health'),
NodeClientHealthColumn::make('reachable'),
TextColumn::make('name')
->label(trans('admin/node.table.name'))
->sortable()

View File

@@ -91,7 +91,10 @@ class AllocationsRelationManager extends RelationManager
->icon(TablerIcon::WorldPlus)
->schema(fn () => [
Select::make('allocation_ip')
->options(fn () => collect($this->getOwnerRecord()->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->options(fn (Get $get) => collect($this->getOwnerRecord()->ipAddresses())
->when($get('allocation_ip'), fn ($ips, $current) => $ips->push($current))
->unique()
->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/node.ip_address'))
->inlineLabel()
->ip()
@@ -100,12 +103,25 @@ class AllocationsRelationManager extends RelationManager
->live()
->hintAction(
Action::make('hint_refresh')
->hiddenLabel()
->icon(TablerIcon::Refresh)
->tooltip(trans('admin/node.refresh'))
->action(function () {
cache()->forget("nodes.{$this->getOwnerRecord()->id}.ips");
})
)
->suffixAction(
Action::make('custom_ip')
->icon(TablerIcon::Keyboard)
->tooltip(trans('admin/node.custom_ip'))
->schema([
TextInput::make('custom_ip')
->label(trans('admin/node.ip_address'))
->ip()
->required(),
])
->action(fn (array $data, Set $set) => $set('allocation_ip', $data['custom_ip']))
)
->required(),
TextInput::make('allocation_alias')
->label(trans('admin/node.table.alias'))

View File

@@ -91,7 +91,7 @@ class PluginResource extends Resource
->url(fn (Plugin $plugin) => !$plugin->getReadme() ? $plugin->url : null, true)
->slideOver(true)
->modalHeading('Readme')
->modalSubmitAction(fn (Plugin $plugin) => Action::make('visit_website')
->modalSubmitAction(fn (Plugin $plugin) => Action::make('exclude_visit_website')
->label(trans('admin/plugin.visit_website'))
->visible(!is_null($plugin->url))
->url($plugin->url, true)
@@ -202,7 +202,7 @@ class PluginResource extends Resource
->icon(TablerIcon::Trash)
->color('danger')
->requiresConfirmation()
->visible(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled)
->visible(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled || $plugin->status === PluginStatus::Errored)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
$pluginService->deletePlugin($plugin);

View File

@@ -106,7 +106,7 @@ class RoleResource extends Resource
->toolbarActions([
CreateAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make('exclude_bulk_delete'),
]),
])
->checkIfRecordIsSelectableUsing(fn (Role $role) => !$role->isRootAdmin() && $role->users_count <= 0);

View File

@@ -251,7 +251,10 @@ class CreateServer extends CreateRecord
return [
Select::make('allocation_ip')
->options(fn () => collect(Node::find($get('node_id'))?->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->options(fn (Get $get) => collect(Node::find($getPage('node_id'))?->ipAddresses())
->when($get('allocation_ip'), fn ($ips, $current) => $ips->push($current))
->unique()
->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/server.ip_address'))->inlineLabel()
->helperText(trans('admin/server.ip_address_helper'))
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
@@ -266,6 +269,18 @@ class CreateServer extends CreateRecord
cache()->forget("nodes.{$get('node_id')}.ips");
})
)
->suffixAction(
Action::make('custom_ip')
->icon(TablerIcon::Keyboard)
->tooltip(trans('admin/node.custom_ip'))
->schema([
TextInput::make('custom_ip')
->label(trans('admin/node.ip_address'))
->ip()
->required(),
])
->action(fn (array $data, Set $set) => $set('allocation_ip', $data['custom_ip']))
)
->required(),
TextInput::make('allocation_alias')
->label(trans('admin/server.alias'))->inlineLabel()

View File

@@ -121,6 +121,7 @@ class EditServer extends EditRecord
->columnSpan(2)
->alignJustify(),
Action::make('uploadIcon')
->hiddenLabel()
->icon(TablerIcon::PhotoUp)
->tooltip(trans('admin/server.import_image'))
->modal()
@@ -935,7 +936,7 @@ class EditServer extends EditRecord
->send();
}
}),
Action::make('toggleUnsuspend')
Action::make('exclude_toggle_unsuspend')
->label(trans('admin/server.unsuspend'))
->color('success')
->hidden(fn (Server $server) => !$server->isSuspended())
@@ -1126,7 +1127,7 @@ class EditServer extends EditRecord
->hidden(fn () => $canForceDelete)
->authorize(fn (Server $server) => user()?->can('delete server', $server))
->icon(TablerIcon::Trash),
Action::make('ForceDelete')
Action::make('exclude_force_delete')
->color('danger')
->label(trans('filament-actions::force-delete.single.label'))
->modalHeading(trans('filament-actions::force-delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
@@ -1218,18 +1219,20 @@ class EditServer extends EditRecord
],
]);
$normalizedExtension = match ($extension) {
'svg+xml', 'svg' => 'svg',
'jpeg', 'jpg' => 'jpg',
'png' => 'png',
'webp' => 'webp',
default => throw new Exception(trans('admin/egg.import.unknown_extension')),
};
$data = @file_get_contents($imageUrl, false, $context, 0, 262144); //256KB
if (empty($data)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$normalizedExtension = match ($extension) {
'svg+xml' => 'svg',
'jpeg' => 'jpg',
default => $extension,
};
Storage::disk('public')->put(Server::ICON_STORAGE_PATH . "/$server->uuid.$normalizedExtension", $data);
}
}

View File

@@ -113,7 +113,10 @@ class AllocationsRelationManager extends RelationManager
->createAnother(false)
->schema(fn () => [
Select::make('allocation_ip')
->options(fn () => collect($this->getOwnerRecord()->node->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->options(fn (Get $get) => collect($this->getOwnerRecord()->node->ipAddresses())
->when($get('allocation_ip'), fn ($ips, $current) => $ips->push($current))
->unique()
->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/server.ip_address'))
->inlineLabel()
->ip()
@@ -126,6 +129,18 @@ class AllocationsRelationManager extends RelationManager
cache()->forget("nodes.{$this->getOwnerRecord()->node->id}.ips");
})
)
->suffixAction(
Action::make('custom_ip')
->icon(TablerIcon::Keyboard)
->tooltip(trans('admin/node.custom_ip'))
->schema([
TextInput::make('custom_ip')
->label(trans('admin/node.ip_address'))
->ip()
->required(),
])
->action(fn (array $data, Set $set) => $set('allocation_ip', $data['custom_ip']))
)
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->required(),
TextInput::make('allocation_alias')

View File

@@ -142,7 +142,7 @@ class UserResource extends Resource
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make('exclude_bulk_delete'),
]),
CreateAction::make()
->hiddenLabel()

View File

@@ -114,7 +114,7 @@ class WebhookResource extends Resource
->toolbarActions([
CreateAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make('exclude_bulk_delete'),
]),
])
->emptyStateIcon(TablerIcon::Webhook)

View File

@@ -16,13 +16,15 @@ class RotateDatabasePasswordAction extends Action
{
public static function getDefaultName(): ?string
{
return 'hint_rotate';
return 'exclude_hint_rotate';
}
protected function setUp(): void
{
parent::setUp();
$this->hiddenLabel();
$this->tooltip(trans('admin/databasehost.rotate'));
$this->icon(TablerIcon::Refresh);

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Filament\Components\Tables\Columns;
use Filament\Support\Enums\Alignment;
use Filament\Tables\Columns\IconColumn;
use Illuminate\Support\Facades\Blade;
class NodeClientHealthColumn extends IconColumn
{
protected function setUp(): void
{
parent::setUp();
$this->label(trans('admin/node.table.reachable'));
$this->alignCenter();
}
public function toEmbeddedHtml(): string
{
$alignment = $this->getAlignment();
$attributes = $this->getExtraAttributeBag()
->class([
'fi-ta-icon',
'fi-inline' => $this->isInline(),
'fi-ta-icon-has-line-breaks' => $this->isListWithLineBreaks(),
'fi-wrapped' => $this->canWrap(),
($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : ''),
])
->toHtml();
return Blade::render(<<<'BLADE'
<div <?= $attributes ?>>
@livewire('node-client-connectivity', ['node' => $record, 'lazy' => true])
</div>
BLADE, [
'attributes' => $attributes,
'record' => $this->getRecord(),
]);
}
}

View File

@@ -254,7 +254,7 @@ class EditProfile extends BaseEditProfile
->columnSpanFull(),
])
->headerActions([
Action::make('create_api_key')
Action::make('exclude_create_api_key')
->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => empty($get('description')))
->successRedirectUrl(self::getUrl(['tab' => 'api-keys::data::tab'], panel: 'app'))
@@ -343,7 +343,7 @@ class EditProfile extends BaseEditProfile
->live(),
])
->headerActions([
Action::make('create_ssh_key')
Action::make('exclude_create_ssh_key')
->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => empty($get('name')) || empty($get('public_key')))
->successRedirectUrl(self::getUrl(['tab' => 'ssh-keys::data::tab'], panel: 'app'))
@@ -426,13 +426,13 @@ class EditProfile extends BaseEditProfile
->label(trans('profile.tabs.activity'))
->icon(TablerIcon::History)
->schema([
Repeater::make('activity')
->hiddenLabel()
Repeater::make('activity') // TODO: move to a table
->label(trans('profile.activity_info'))
->inlineLabel(false)
->deletable(false)
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderBy('timestamp', 'desc');
$query->orderBy('timestamp', 'desc')->limit(50);
})
->schema([
TextEntry::make('log')
@@ -474,6 +474,17 @@ class EditProfile extends BaseEditProfile
false => 'Icon Button',
]),
]),
Section::make(trans('profile.admin'))
->collapsible()
->icon(TablerIcon::Shield)
->visible(fn (User $user) => $user->isAdmin())
->schema([
ToggleButtons::make('redirect_to_admin')
->label(trans('profile.redirect_to_admin'))
->helperText(trans('profile.redirect_to_admin_help'))
->inline()
->boolean(),
]),
Section::make(trans('profile.console'))
->collapsible()
->icon(TablerIcon::Terminal2)
@@ -599,6 +610,7 @@ class EditProfile extends BaseEditProfile
'dashboard_layout' => $data['dashboard_layout'],
'top_navigation' => $data['top_navigation'],
'button_style' => $data['button_style'],
'redirect_to_admin' => $data['redirect_to_admin'] ?? $this->getUser()->getCustomization(CustomizationKey::RedirectToAdmin),
];
unset(
@@ -608,6 +620,7 @@ class EditProfile extends BaseEditProfile
$data['dashboard_layout'],
$data['top_navigation'],
$data['button_style'],
$data['redirect_to_admin'],
);
$data['customization'] = json_encode($customization);
@@ -623,6 +636,7 @@ class EditProfile extends BaseEditProfile
$data['console_graph_period'] = (int) $this->getUser()->getCustomization(CustomizationKey::ConsoleGraphPeriod);
$data['dashboard_layout'] = $this->getUser()->getCustomization(CustomizationKey::DashboardLayout);
$data['button_style'] = $this->getUser()->getCustomization(CustomizationKey::ButtonStyle);
$data['redirect_to_admin'] = $this->getUser()->getCustomization(CustomizationKey::RedirectToAdmin);
// Handle migration from boolean to string navigation types
$topNavigation = $this->getUser()->getCustomization(CustomizationKey::TopNavigation);

View File

@@ -4,6 +4,8 @@ namespace App\Filament\Pages\Auth;
use App\Extensions\Captcha\CaptchaService;
use App\Extensions\OAuth\OAuthService;
use BladeUI\Icons\Exceptions\SvgNotFound;
use BladeUI\Icons\Factory as IconFactory;
use Filament\Actions\Action;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Forms\Components\TextInput;
@@ -19,10 +21,13 @@ class Login extends BaseLogin
protected CaptchaService $captchaService;
public function boot(OAuthService $oauthService, CaptchaService $captchaService): void
protected IconFactory $iconFactory;
public function boot(OAuthService $oauthService, CaptchaService $captchaService, IconFactory $iconFactory): void
{
$this->oauthService = $oauthService;
$this->captchaService = $captchaService;
$this->iconFactory = $iconFactory;
}
public function form(Schema $schema): Schema
@@ -66,6 +71,14 @@ class Login extends BaseLogin
->extraInputAttributes(['tabindex' => 1]);
}
protected function getPasswordFormComponent(): Component
{
/** @var TextInput $component */
$component = parent::getPasswordFormComponent();
return $component->extraInputAttributes(['tabindex' => 2]);
}
protected function getOAuthFormComponent(): Component
{
$actions = [];
@@ -79,9 +92,18 @@ class Login extends BaseLogin
$color = $schema->getHexColor();
$color = is_string($color) ? Color::hex($color) : null;
$icon = $schema->getIcon();
if (is_string($icon)) {
try {
$this->iconFactory->svg($icon);
} catch (SvgNotFound) {
$icon = null;
}
}
$actions[] = Action::make("oauth_$id")
->label($schema->getName())
->icon($schema->getIcon())
->icon($icon)
->color($color)
->url(route('auth.oauth.redirect', ['driver' => $id], false));
}

View File

@@ -78,11 +78,15 @@ class Console extends Page
$feature = data_get($data, 'key');
$feature = $this->featureService->get($feature);
if (!$feature || $this->getMountedAction()) {
if (!$feature) {
return;
}
$this->mountAction($feature->getId());
sleep(2); // TODO find a better way
if ($this->getMountedAction()) {
$this->replaceMountedAction($feature->getId());
} else {
$this->mountAction($feature->getId());
}
}
public function getWidgetData(): array

View File

@@ -462,18 +462,20 @@ class Settings extends ServerFormPage
],
]);
$normalizedExtension = match ($extension) {
'svg+xml', 'svg' => 'svg',
'jpeg', 'jpg' => 'jpg',
'png' => 'png',
'webp' => 'webp',
default => throw new Exception(trans('admin/egg.import.unknown_extension')),
};
$data = @file_get_contents($imageUrl, false, $context, 0, 262144); //256KB
if (empty($data)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$normalizedExtension = match ($extension) {
'svg+xml' => 'svg',
'jpeg' => 'jpg',
default => $extension,
};
Storage::disk('public')->put(Server::ICON_STORAGE_PATH . "/$server->uuid.$normalizedExtension", $data);
}

View File

@@ -27,7 +27,6 @@ use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Textarea;
@@ -77,7 +76,7 @@ class BackupResource extends Resource
/** @var Server $server */
$server = Filament::getTenant();
return $server->backup_limit;
return $server->backup_limit ?? 0;
}
public static function defaultForm(Schema $schema): Schema
@@ -128,7 +127,7 @@ class BackupResource extends Resource
])
->recordActions([
ActionGroup::make([
Action::make('rename')
Action::make('exclude_rename')
->icon(TablerIcon::Pencil)
->authorize(fn () => user()?->can(SubuserPermission::BackupDelete, $server))
->label(trans('server/backup.actions.rename.title'))
@@ -158,14 +157,14 @@ class BackupResource extends Resource
->send();
})
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('lock')
Action::make('exclude_lock')
->iconSize(IconSize::Large)
->icon(fn (Backup $backup) => !$backup->is_locked ? TablerIcon::Lock : TablerIcon::LockOpen)
->authorize(fn () => user()?->can(SubuserPermission::BackupDelete, $server))
->label(fn (Backup $backup) => !$backup->is_locked ? trans('server/backup.actions.lock.lock') : trans('server/backup.actions.lock.unlock'))
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('download')
Action::make('exclude_download')
->label(trans('server/backup.actions.download'))
->iconSize(IconSize::Large)
->color('primary')
@@ -173,7 +172,7 @@ class BackupResource extends Resource
->authorize(fn () => user()?->can(SubuserPermission::BackupDownload, $server))
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true)
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('restore')
Action::make('exclude_restore')
->label(trans('server/backup.actions.restore.title'))
->iconSize(IconSize::Large)
->color('success')
@@ -226,7 +225,12 @@ class BackupResource extends Resource
->send();
})
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
DeleteAction::make('delete')
Action::make('exclude_delete')
->icon(TablerIcon::Trash)
->color('danger')
->requiresConfirmation()
->authorize(fn () => user()?->can(SubuserPermission::BackupDelete, $server))
->label(trans('filament-actions::delete.single.label'))
->iconSize(IconSize::Large)
->disabled(fn (Backup $backup) => $backup->is_locked && $backup->status !== BackupStatus::Failed)
->modalDescription(fn (Backup $backup) => trans('server/backup.actions.delete.description', ['backup' => $backup->name]))

View File

@@ -62,7 +62,7 @@ class DatabaseResource extends Resource
/** @var Server $server */
$server = Filament::getTenant();
return $server->database_limit;
return $server->database_limit ?? 0;
}
/**

View File

@@ -209,16 +209,18 @@ class ListFiles extends ListRecords
->required()
->live(),
TextEntry::make('new_location')
->state(fn (Get $get, File $file) => resolve_path(join_paths($this->path, $get('location') ?? '/', $file->name))),
->state(fn (Get $get, File $file) => resolve_path(join_paths($this->path, str_ends_with($get('location') ?? '/', '/') ? join_paths($get('location') ?? '/', $file->name) : $get('location') ?? '/'))),
])
->action(function ($data, File $file) {
$location = $data['location'];
$files = [['to' => join_paths($location, $file->name), 'from' => $file->name]];
$endsWithSlash = str_ends_with($location, '/');
$to = $endsWithSlash ? join_paths($location, $file->name) : $location;
$files = [['to' => $to, 'from' => $file->name]];
$this->getDaemonFileRepository()->renameFiles($this->path, $files);
$oldLocation = join_paths($this->path, $file->name);
$newLocation = resolve_path(join_paths($this->path, $location, $file->name));
$newLocation = resolve_path(join_paths($this->path, $to));
Activity::event('server:file.rename')
->property('directory', $this->path)

View File

@@ -8,6 +8,7 @@ use App\Models\Schedule;
use App\Models\Server;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Resources\Pages\CreateRecord;
@@ -20,6 +21,17 @@ class CreateSchedule extends CreateRecord
protected static bool $canCreateAnother = false;
protected function getCreateFormAction(): Action
{
$hasFormWrapper = $this->hasFormWrapper();
return Action::make('exclude_create')
->label(__('filament-panels::resources/pages/create-record.form.actions.create.label'))
->submit($hasFormWrapper ? $this->getSubmitFormLivewireMethodName() : null)
->action($hasFormWrapper ? null : $this->getSubmitFormLivewireMethodName())
->keyBindings(['mod+s']);
}
protected function afterCreate(): void
{
/** @var Schedule $schedule */

View File

@@ -7,6 +7,7 @@ use App\Facades\Activity;
use App\Models\Schedule;
use App\Models\Task;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
@@ -73,6 +74,7 @@ class TasksRelationManager extends RelationManager
return $table
->reorderable('sequence_id')
->defaultSort('sequence_id')
->reorderRecordsTriggerAction(fn (Action $action, bool $isReordering) => $action->hiddenLabel()->tooltip(fn () => $action->getLabel()))
->columns([
TextColumn::make('action')
->label(trans('server/schedule.tasks.actions.title'))

View File

@@ -97,8 +97,7 @@ class ScheduleResource extends Resource
->formatStateUsing(fn (?Schedule $schedule) => $schedule?->status->value ?? 'new')
->options(fn (?Schedule $schedule) => [$schedule?->status->value ?? 'new' => $schedule?->status->getLabel() ?? 'New'])
->visibleOn('view'),
Section::make('Cron')
->label(trans('server/schedule.cron'))
Section::make(trans('server/schedule.cron'))
->description(function (Get $get) {
try {
$nextRun = Utilities::getScheduleNextRunDate($get('cron_minute'), $get('cron_hour'), $get('cron_day_of_month'), $get('cron_month'), $get('cron_day_of_week'))->timezone(user()->timezone ?? 'UTC');
@@ -110,22 +109,22 @@ class ScheduleResource extends Resource
})
->schema([
Actions::make([
CronPresetAction::make('hourly')
CronPresetAction::make('exclude_hourly')
->label(trans('server/schedule.time.hourly'))
->cron('0', '*', '*', '*', '*'),
CronPresetAction::make('daily')
CronPresetAction::make('exclude_daily')
->label(trans('server/schedule.time.daily'))
->cron('0', '0', '*', '*', '*'),
CronPresetAction::make('weekly_monday')
CronPresetAction::make('exclude_weekly_monday')
->label(trans('server/schedule.time.weekly_mon'))
->cron('0', '0', '*', '*', '1'),
CronPresetAction::make('weekly_sunday')
CronPresetAction::make('exclude_weekly_sunday')
->label(trans('server/schedule.time.weekly_sun'))
->cron('0', '0', '*', '*', '0'),
CronPresetAction::make('monthly')
CronPresetAction::make('exclude_monthly')
->label(trans('server/schedule.time.monthly'))
->cron('0', '0', '1', '*', '*'),
CronPresetAction::make('every_x_minutes')
CronPresetAction::make('exclude_every_x_minutes')
->label(trans('server/schedule.time.every_min'))
->color(fn (Get $get) => str($get('cron_minute'))->startsWith('*/')
&& $get('cron_hour') == '*'
@@ -148,7 +147,7 @@ class ScheduleResource extends Resource
$set('cron_month', '*');
$set('cron_day_of_week', '*');
}),
CronPresetAction::make('every_x_hours')
CronPresetAction::make('exclude_every_x_hours')
->color(fn (Get $get) => $get('cron_minute') == '0'
&& str($get('cron_hour'))->startsWith('*/')
&& $get('cron_day_of_month') == '*'
@@ -170,7 +169,7 @@ class ScheduleResource extends Resource
$set('cron_month', '*');
$set('cron_day_of_week', '*');
}),
CronPresetAction::make('every_x_days')
CronPresetAction::make('exclude_every_x_days')
->color(fn (Get $get) => $get('cron_minute') == '0'
&& $get('cron_hour') == '0'
&& str($get('cron_day_of_month'))->startsWith('*/')
@@ -192,7 +191,7 @@ class ScheduleResource extends Resource
$set('cron_month', '*');
$set('cron_day_of_week', '*');
}),
CronPresetAction::make('every_x_months')
CronPresetAction::make('exclude_every_x_months')
->color(fn (Get $get) => $get('cron_minute') == '0'
&& $get('cron_hour') == '0'
&& $get('cron_day_of_month') == '1'
@@ -214,7 +213,7 @@ class ScheduleResource extends Resource
$set('cron_month', '*/' . $data['x']);
$set('cron_day_of_week', '*');
}),
CronPresetAction::make('every_x_day_of_week')
CronPresetAction::make('exclude_every_x_day_of_week')
->color(fn (Get $get) => $get('cron_minute') == '0'
&& $get('cron_hour') == '0'
&& $get('cron_day_of_month') == '*'

View File

@@ -123,18 +123,6 @@ class SubuserResource extends Resource
),
])
->recordActions([
DeleteAction::make()
->label(trans('server/user.delete'))
->hidden(fn (Subuser $subuser) => user()?->id === $subuser->user->id)
->successNotificationTitle(null)
->action(function (Subuser $subuser, SubuserDeletionService $subuserDeletionService) use ($server) {
$subuserDeletionService->handle($subuser, $server);
Notification::make()
->title(trans('server/user.notification_delete'))
->success()
->send();
}),
EditAction::make()
->label(trans('server/user.edit'))
->hidden(fn (Subuser $subuser) => user()?->id === $subuser->user->id)
@@ -179,7 +167,7 @@ class SubuserResource extends Resource
])
->formatStateUsing(fn (Subuser $subuser) => $subuser->user->email),
Actions::make([
Action::make('assignAll')
Action::make('exclude_assignAll')
->label(trans('server/user.assign_all'))
->action(function (Set $set) use ($permissionsArray) {
$permissions = $permissionsArray;
@@ -214,6 +202,19 @@ class SubuserResource extends Resource
return $data;
}),
DeleteAction::make()
->label(trans('server/user.delete'))
->hidden(fn (Subuser $subuser) => user()?->id === $subuser->user->id)
->authorize(fn () => user()?->can(SubuserPermission::UserDelete, $server))
->successNotificationTitle(null)
->action(function (Subuser $subuser, SubuserDeletionService $subuserDeletionService) use ($server) {
$subuserDeletionService->handle($subuser, $server);
Notification::make()
->title(trans('server/user.notification_delete'))
->success()
->send();
}),
])
->toolbarActions([
CreateAction::make('invite')
@@ -243,7 +244,7 @@ class SubuserResource extends Resource
])
->required(),
Actions::make([
Action::make('assignAll')
Action::make('exclude_assignAll')
->label(trans('server/user.assign_all'))
->action(function (Set $set, Get $get) use ($permissionsArray) {
$permissions = $permissionsArray;

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Http\Controllers\Api\Application\Plugins;
use App\Enums\PluginStatus;
use App\Exceptions\PanelException;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Plugins\ImportFilePluginRequest;
use App\Http\Requests\Api\Application\Plugins\ReadPluginRequest;
use App\Http\Requests\Api\Application\Plugins\UninstallPluginRequest;
use App\Http\Requests\Api\Application\Plugins\WritePluginRequest;
use App\Models\Plugin;
use App\Services\Helpers\PluginService;
use App\Transformers\Api\Application\PluginTransformer;
use Exception;
use Illuminate\Http\Response;
use Spatie\QueryBuilder\QueryBuilder;
class PluginController extends ApplicationApiController
{
/**
* PluginController constructor.
*/
public function __construct(private readonly PluginService $pluginService)
{
parent::__construct();
}
/**
* List plugins
*
* Return all plugins on the Panel.
*
* @return array<array-key, mixed>
*/
public function index(ReadPluginRequest $request): array
{
$plugins = QueryBuilder::for(Plugin::class)
->allowedFilters(['id', 'name', 'author', 'category'])
->allowedSorts(['id', 'name', 'author', 'category'])
->paginate($request->query('per_page') ?? 10);
return $this->fractal->collection($plugins)
->transformWith($this->getTransformer(PluginTransformer::class))
->toArray();
}
/**
* View plugin
*
* Return a single plugin.
*
* @return array<array-key, mixed>
*/
public function view(ReadPluginRequest $request, Plugin $plugin): array
{
return $this->fractal->item($plugin)
->transformWith($this->getTransformer(PluginTransformer::class))
->toArray();
}
/**
* Import plugin (file)
*
* Imports a new plugin file.
*
* @throws Exception
*/
public function importFile(WritePluginRequest $request): Response
{
if (!$request->hasFile('plugin')) {
throw new PanelException("No 'plugin' file in request");
}
$this->pluginService->downloadPluginFromFile($request->file('plugin'));
return new Response('', Response::HTTP_CREATED);
}
/**
* Import plugin (url)
*
* Imports a new plugin from an url.
*
* @throws Exception
*/
public function importUrl(ImportFilePluginRequest $request): Response
{
$this->pluginService->downloadPluginFromUrl($request->input('url'));
return new Response('', Response::HTTP_CREATED);
}
/**
* Install plugin
*
* Installs and enables a plugin.
*
* @return array<array-key, mixed>
*
* @throws Exception
*/
public function install(WritePluginRequest $request, Plugin $plugin): array
{
if ($plugin->status !== PluginStatus::NotInstalled) {
throw new PanelException('Plugin is already installed');
}
$this->pluginService->installPlugin($plugin);
return $this->fractal->item($plugin)
->transformWith($this->getTransformer(PluginTransformer::class))
->toArray();
}
/**
* Update plugin
*
* Downloads and installs an update for a plugin. Will throw if no update is available.
*
* @return array<array-key, mixed>
*
* @throws Exception
*/
public function update(WritePluginRequest $request, Plugin $plugin): array
{
if (!$plugin->isUpdateAvailable()) {
throw new PanelException("Plugin doesn't need updating");
}
$this->pluginService->updatePlugin($plugin);
return $this->fractal->item($plugin)
->transformWith($this->getTransformer(PluginTransformer::class))
->toArray();
}
/**
* Uninstall plugin
*
* Uninstalls a plugin. Optionally it will delete the plugin folder too.
*
* @return array<array-key, mixed>
*
* @throws Exception
*/
public function uninstall(UninstallPluginRequest $request, Plugin $plugin): array
{
if ($plugin->status === PluginStatus::NotInstalled) {
throw new PanelException('Plugin is not installed');
}
$this->pluginService->uninstallPlugin($plugin, $request->boolean('delete'));
return $this->fractal->item($plugin)
->transformWith($this->getTransformer(PluginTransformer::class))
->toArray();
}
/**
* Enable plugin
*
* Enables a plugin.
*
* @return array<array-key, mixed>
*
* @throws Exception
*/
public function enable(WritePluginRequest $request, Plugin $plugin): array
{
if (!$plugin->canEnable()) {
throw new PanelException("Plugin can't be enabled");
}
$this->pluginService->enablePlugin($plugin);
return $this->fractal->item($plugin)
->transformWith($this->getTransformer(PluginTransformer::class))
->toArray();
}
/**
* Disable plugin
*
* Disables a plugin.
*
* @return array<array-key, mixed>
*
* @throws Exception
*/
public function disable(WritePluginRequest $request, Plugin $plugin): array
{
if (!$plugin->canDisable()) {
throw new PanelException("Plugin can't be disabled");
}
$this->pluginService->disablePlugin($plugin);
return $this->fractal->item($plugin)
->transformWith($this->getTransformer(PluginTransformer::class))
->toArray();
}
}

View File

@@ -74,7 +74,7 @@ class OAuthController extends Controller
$email = $oauthUser->getEmail();
if (!$email) {
return $this->errorRedirect();
return $this->errorRedirect('No email was linked to your account on the OAuth provider.');
}
$user = User::whereEmail($email)->first();

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Plugins;
class ImportFilePluginRequest extends WritePluginRequest
{
public function rules(): array
{
return [
'url' => 'required|string',
];
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Http\Requests\Api\Application\Plugins;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
use App\Models\Plugin;
use App\Services\Acl\Api\AdminAcl;
class ReadPluginRequest extends ApplicationApiRequest
{
protected ?string $resource = Plugin::RESOURCE_NAME;
protected int $permission = AdminAcl::READ;
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Requests\Api\Application\Plugins;
class UninstallPluginRequest extends WritePluginRequest
{
/**
* @param array<array-key, string|string[]>|null $rules
* @return array<array-key, string|string[]>
*/
public function rules(?array $rules = null): array
{
return [
'delete' => 'boolean',
];
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Http\Requests\Api\Application\Plugins;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
use App\Models\Plugin;
use App\Services\Acl\Api\AdminAcl;
class WritePluginRequest extends ApplicationApiRequest
{
protected ?string $resource = Plugin::RESOURCE_NAME;
protected int $permission = AdminAcl::WRITE;
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Responses;
use App\Enums\CustomizationKey;
use App\Models\User;
use Filament\Auth\Http\Responses\Contracts\LoginResponse as LoginResponseContract;
use Filament\Facades\Filament;
use Illuminate\Http\RedirectResponse;
use Livewire\Features\SupportRedirects\Redirector;
class LoginResponse implements LoginResponseContract
{
public function toResponse($request): RedirectResponse|Redirector
{
/** @var User|null $user */
$user = Filament::auth()->user();
if ($user?->getCustomization(CustomizationKey::RedirectToAdmin) && $user->canAccessPanel(Filament::getPanel('admin'))) {
return redirect()->intended(Filament::getPanel('admin')->getUrl());
}
return redirect()->intended(Filament::getUrl());
}
}

View File

@@ -34,6 +34,10 @@ class ProcessWebhook implements ShouldQueue
$data = reset($data);
}
if (is_object($data)) {
$data = get_object_vars($data);
}
if (is_string($data)) {
$data = Arr::wrap(json_decode($data, true) ?? []);
}

View File

@@ -10,6 +10,7 @@ use Filament\Notifications\Concerns\HasStatus;
use Filament\Notifications\Concerns\HasTitle;
use Filament\Support\Components\ViewComponent;
use Illuminate\Contracts\Support\Arrayable;
use Livewire\Livewire;
final class AlertBanner extends ViewComponent implements Arrayable
{
@@ -83,7 +84,13 @@ final class AlertBanner extends ViewComponent implements Arrayable
public function send(): AlertBanner
{
session()->push('alert-banners', $this->toArray());
$data = $this->toArray();
if (Livewire::isLivewireRequest()) {
$data['from_livewire'] = true;
}
session()->push('alert-banners', $data);
return $this;
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Livewire;
use Filament\Notifications\Collection;
class AlertBannerCollection extends Collection
{
public static function fromLivewire($value): static
{
return (new static($value))->transform(
fn (array $alertBanner): AlertBanner => AlertBanner::fromArray($alertBanner),
);
}
}

View File

@@ -2,25 +2,35 @@
namespace App\Livewire;
use Filament\Notifications\Collection;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
class AlertBannerContainer extends Component
{
public Collection $alertBanners;
public AlertBannerCollection $alertBanners;
public function mount(): void
{
$this->alertBanners = new Collection();
$this->pullFromSession();
$this->alertBanners = new AlertBannerCollection();
foreach (session()->pull('alert-banners', []) as $alertBanner) {
// Alerts created during Livewire requests should have been consumed by the event handler on the same page.
if (!empty($alertBanner['from_livewire'])) {
// If they weren't, then discard them instead of showing on the wrong page.
continue;
}
$alertBanner = AlertBanner::fromArray($alertBanner);
$this->alertBanners->put($alertBanner->getId(), $alertBanner);
}
}
#[On('alertBannerSent')]
public function pullFromSession(): void
{
foreach (session()->pull('alert-banners', []) as $alertBanner) {
unset($alertBanner['from_livewire']);
$alertBanner = AlertBanner::fromArray($alertBanner);
$this->alertBanners->put($alertBanner->getId(), $alertBanner);
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Livewire;
use App\Enums\TablerIcon;
use App\Models\Node;
use App\Services\Nodes\NodeJWTService;
use App\Services\Servers\GetUserPermissionsService;
use Filament\Support\Enums\IconSize;
use Filament\Tables\View\Components\Columns\IconColumnComponent\IconComponent;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\ComponentAttributeBag;
use Livewire\Attributes\Locked;
use Livewire\Component;
use function Filament\Support\generate_icon_html;
class NodeClientConnectivity extends Component
{
#[Locked]
public Node $node;
private GetUserPermissionsService $getUserPermissionsService;
private NodeJWTService $nodeJWTService;
public function boot(GetUserPermissionsService $getUserPermissionsService, NodeJWTService $nodeJWTService): void
{
$this->getUserPermissionsService = $getUserPermissionsService;
$this->nodeJWTService = $nodeJWTService;
}
public function render(): \Illuminate\Contracts\View\View
{
$httpUrl = $this->node->getConnectionAddress();
$wsUrl = null;
$wsToken = null;
$server = $this->node->servers()->first();
if ($server) {
$user = Auth::user();
$permissions = $this->getUserPermissionsService->handle($server, $user);
$wsToken = $this->nodeJWTService
->setExpiresAt(now()->addMinute()->toImmutable())
->setUser($user)
->setClaims([
'server_uuid' => $server->uuid,
'permissions' => $permissions,
])
->handle($this->node, $user->id . $server->uuid)->toString();
$wsUrl = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $this->node->getConnectionAddress());
$wsUrl .= sprintf('/api/servers/%s/ws', $server->uuid);
}
return view('livewire.node-client-connectivity', [
'httpUrl' => $httpUrl,
'wsUrl' => $wsUrl,
'wsToken' => $wsToken,
'loadingIcon' => $this->makeIcon(TablerIcon::WorldQuestion, 'warning', 'Checking...'),
'offlineIcon' => $this->makeIcon(TablerIcon::WorldX, 'danger', 'Node is not reachable from your browser'),
'onlineIcon' => $this->makeIcon(TablerIcon::WorldCheck, 'success', 'Node is reachable'),
'warningIcon' => $this->makeIcon(TablerIcon::WorldExclamation, 'warning', 'Node is reachable, but WebSocket failed. Check reverse proxy config.'),
'onlineNoWsIcon' => $this->makeIcon(TablerIcon::WorldCheck, 'success', 'Node is reachable (WebSocket not tested — no servers)'),
]);
}
private function makeIcon(TablerIcon $icon, string $color, string $tooltip): string
{
return generate_icon_html($icon, attributes: (new ComponentAttributeBag())
->merge([
'x-tooltip' => '{
content: "' . $tooltip . '",
theme: $store.theme,
allowHTML: true,
placement: "bottom",
}',
'style' => 'color: var(--dark-text, var(--text))',
], escape: false)
->color(IconComponent::class, $color), size: IconSize::Large)
->toHtml();
}
public function placeholder(): string
{
return generate_icon_html(TablerIcon::WorldQuestion, attributes: (new ComponentAttributeBag())
->color(IconComponent::class, 'warning'), size: IconSize::Large)
->toHtml();
}
}

View File

@@ -174,6 +174,7 @@ class ApiKey extends PersonalAccessToken
Database::RESOURCE_NAME,
Mount::RESOURCE_NAME,
Role::RESOURCE_NAME,
Plugin::RESOURCE_NAME,
];
/** @var string[] */

View File

@@ -255,6 +255,8 @@ class Node extends Model implements Validatable
/**
* Gets the servers associated with a node.
*
* @return HasMany<Server, $this>
*/
public function servers(): HasMany
{
@@ -358,7 +360,7 @@ class Node extends Model implements Validatable
'disk_used' => 0,
];
return cache()->remember("nodes.$this->id.statistics", now()->addSeconds(360), function () use ($default) {
return cache()->flexible("nodes.$this->id.statistics", [5, 30], function () use ($default) {
try {
$data = Http::daemon($this)

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Contracts\Plugins\HasPluginSettings;
use App\Enums\PluginCategory;
use App\Enums\PluginStatus;
use App\Exceptions\PluginIdMismatchException;
use App\Facades\Plugins;
use Exception;
use Filament\Schemas\Components\Component;
@@ -38,6 +39,8 @@ class Plugin extends Model implements HasPluginSettings
{
use Sushi;
public const RESOURCE_NAME = 'plugin';
protected $primaryKey = 'id';
protected $keyType = 'string';
@@ -108,9 +111,10 @@ class Plugin extends Model implements HasPluginSettings
try {
$data = File::json($path, JSON_THROW_ON_ERROR);
$data['id'] = Str::lower($data['id']);
if ($data['id'] !== $plugin) {
throw new Exception("Plugin id mismatch for folder name ($plugin) and id in plugin.json ({$data['id']})!");
if ($data['id'] !== Str::lower($plugin)) {
throw new PluginIdMismatchException("Plugin id mismatch for folder name ($plugin) and id in plugin.json ({$data['id']})!");
}
$panels = null;
@@ -155,13 +159,13 @@ class Plugin extends Model implements HasPluginSettings
if (!$exception instanceof JsonException) {
$plugins[] = [
'id' => $data['id'] ?? Str::uuid(),
'name' => $data['name'] ?? $plugin,
'id' => $exception instanceof PluginIdMismatchException ? $plugin : ($data['id'] ?? Str::uuid()),
'name' => $data['name'] ?? Str::headline($plugin),
'author' => $data['author'] ?? 'Unknown',
'version' => '0.0.0',
'description' => 'Plugin.json is invalid!',
'version' => $data['version'] ?? '0.0.0',
'description' => $exception instanceof PluginIdMismatchException ? $exception->getMessage() : 'Plugin.json is invalid!',
'category' => PluginCategory::Plugin->value,
'url' => null,
'url' => $data['url'] ?? null,
'update_url' => null,
'namespace' => 'Error',
'class' => 'Error',
@@ -195,7 +199,7 @@ class Plugin extends Model implements HasPluginSettings
public function shouldLoad(?string $panelId = null): bool
{
return ($this->status === PluginStatus::Enabled || $this->status === PluginStatus::Errored) && (is_null($panelId) || !$this->panels || in_array($panelId, explode(',', $this->panels)));
return $this->fullClass() !== '\\Error\\Error' && ($this->status === PluginStatus::Enabled || $this->status === PluginStatus::Errored) && (is_null($panelId) || !$this->panels || in_array($panelId, explode(',', $this->panels)));
}
public function canEnable(): bool

View File

@@ -198,7 +198,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
});
static::saving(function (self $user) {
$user->email = mb_strtolower($user->email);
$user->username = str($user->username)->lower()->toString();
$user->email = str($user->email)->lower()->toString();
});
static::deleting(function (self $user) {

View File

@@ -23,14 +23,16 @@ class AccountCreated extends Notification implements ShouldQueue
public function toMail(User $notifiable): MailMessage
{
$locale = $notifiable->language ?? 'en';
$message = (new MailMessage())
->greeting('Hello ' . $notifiable->username . '!')
->line('You are receiving this email because an account has been created for you on ' . config('app.name') . '.')
->line('Username: ' . $notifiable->username)
->line('Email: ' . $notifiable->email);
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
->line(trans('mail.account_created.body', ['app' => config('app.name')], $locale))
->line(trans('mail.account_created.username', ['username' => $notifiable->username], $locale))
->line(trans('mail.account_created.email', ['email' => $notifiable->email], $locale));
if (!is_null($this->token)) {
return $message->action('Setup Your Account', Filament::getPanel('app')->getResetPasswordUrl($this->token, $notifiable));
return $message->action(trans('mail.account_created.action', locale: $locale), Filament::getPanel('app')->getResetPasswordUrl($this->token, $notifiable));
}
return $message;

View File

@@ -24,10 +24,12 @@ class AddedToServer extends Notification implements ShouldQueue
public function toMail(User $notifiable): MailMessage
{
$locale = $notifiable->language ?? 'en';
return (new MailMessage())
->greeting('Hello ' . $notifiable->username . '!')
->line('You have been added as a subuser for the following server, allowing you certain control over the server.')
->line('Server Name: ' . $this->server->name)
->action('Visit Server', Console::getUrl(panel: 'server', tenant: $this->server));
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
->line(trans('mail.added_to_server.body', locale: $locale))
->line(trans('mail.added_to_server.server_name', ['name' => $this->server->name], $locale))
->action(trans('mail.added_to_server.action', locale: $locale), Console::getUrl(panel: 'server', tenant: $this->server));
}
}

View File

@@ -20,9 +20,11 @@ class MailTested extends Notification
public function toMail(): MailMessage
{
$locale = $this->user->language ?? 'en';
return (new MailMessage())
->subject('Panel Test Message')
->greeting('Hello ' . $this->user->username . '!')
->line('This is a test of the Panel mail system. You\'re good to go!');
->subject(trans('mail.mail_tested.subject', locale: $locale))
->greeting(trans('mail.greeting', ['name' => $this->user->username], $locale))
->line(trans('mail.mail_tested.body', locale: $locale));
}
}

View File

@@ -23,11 +23,13 @@ class RemovedFromServer extends Notification implements ShouldQueue
public function toMail(User $notifiable): MailMessage
{
$locale = $notifiable->language ?? 'en';
return (new MailMessage())
->error()
->greeting('Hello ' . $notifiable->username . '.')
->line('You have been removed as a subuser for the following server.')
->line('Server Name: ' . $this->server->name)
->action('Visit Panel', url(''));
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
->line(trans('mail.removed_from_server.body', locale: $locale))
->line(trans('mail.removed_from_server.server_name', ['name' => $this->server->name], $locale))
->action(trans('mail.removed_from_server.action', locale: $locale), url(''));
}
}

View File

@@ -24,10 +24,12 @@ class ServerInstalled extends Notification implements ShouldQueue
public function toMail(User $notifiable): MailMessage
{
$locale = $notifiable->language ?? 'en';
return (new MailMessage())
->greeting('Hello ' . $notifiable->username . '.')
->line('Your server has finished installing and is now ready for you to use.')
->line('Server Name: ' . $this->server->name)
->action('Login and Begin Using', Console::getUrl(panel: 'server', tenant: $this->server));
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
->line(trans('mail.server_installed.body', locale: $locale))
->line(trans('mail.server_installed.server_name', ['name' => $this->server->name], $locale))
->action(trans('mail.server_installed.action', locale: $locale), Console::getUrl(panel: 'server', tenant: $this->server));
}
}

View File

@@ -27,6 +27,7 @@ use App\Services\Helpers\SoftwareVersionService;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Filament\Auth\Http\Responses\Contracts\LoginResponse as LoginResponseContract;
use Illuminate\Config\Repository;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application;
@@ -125,6 +126,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
$this->app->bind(LoginResponseContract::class, \App\Http\Responses\LoginResponse::class);
Scramble::ignoreDefaultRoutes();
/** @var PluginService $pluginService */

View File

@@ -34,7 +34,7 @@ use Illuminate\Support\ServiceProvider;
use Livewire\Component;
use Livewire\Livewire;
use function Livewire\on;
use function Livewire\before;
use function Livewire\store;
class FilamentServiceProvider extends ServiceProvider
@@ -74,7 +74,7 @@ class FilamentServiceProvider extends ServiceProvider
fn () => Blade::render("@vite(['resources/js/app.js'])"),
);
on('dehydrate', function (Component $component) {
before('dehydrate', function (Component $component) {
if (!Livewire::isLivewireRequest()) {
return;
}

View File

@@ -29,6 +29,9 @@ abstract class PanelProvider extends BasePanelProvider
{
return $panel
->spa(fn () => !request()->routeIs('filament.server.pages.console'))
->spaUrlExceptions([
'*/oauth/redirect/*',
])
->databaseNotifications()
->brandName(config('app.name', 'Pelican'))
->brandLogo(config('app.logo'))

View File

@@ -32,10 +32,10 @@ class EggConfigurationService
*/
public function handle(Server $server): array
{
$configs = $this->replacePlaceholders(
$server,
json_decode($server->egg->inherit_config_files)
);
$configFiles = json_decode($server->egg->inherit_config_files ?? '{}');
$configs = is_object($configFiles) || is_array($configFiles)
? $this->replacePlaceholders($server, $configFiles)
: [];
return [
'startup' => $this->convertStartupToNewFormat(json_decode($server->egg->inherit_config_startup, true)),

View File

@@ -92,7 +92,7 @@ class EggExporterService
return $this->yamlExport($decoded);
}
return str_replace(["\r\n", '\\r\\n', '\\n'], "\n", $data);
return str_replace("\r\n", "\n", $data);
}
if (is_array($data)) {

View File

@@ -189,6 +189,18 @@ class EggImporterService
}
}
// Convert YAML booleans to strings to prevent Laravel from converting them to 1/0
// when saving to TEXT field. Required for validation rules like "in:true,false".
if (isset($parsed['variables'])) {
$parsed['variables'] = array_map(function ($variable) {
if (isset($variable['default_value']) && is_bool($variable['default_value'])) {
$variable['default_value'] = $variable['default_value'] ? 'true' : 'false';
}
return $variable;
}, $parsed['variables']);
}
// Reserved env var name handling
[$forbidden, $allowed] = collect($parsed['variables'])
->map(fn ($variable) => array_merge(

View File

@@ -39,7 +39,7 @@ class PluginService
/** @var ClassLoader $classLoader */
$classLoader = File::getRequire(base_path('vendor/autoload.php'));
$plugins = Plugin::query()->orderBy('load_order')->get();
$plugins = Plugin::orderBy('load_order')->get();
foreach ($plugins as $plugin) {
try {
// Filter out plugins that are not compatible with the current panel version
@@ -54,6 +54,10 @@ class PluginService
}
}
if ($plugin->namespace === 'Error') {
continue;
}
// Always autoload src directory to make sure all class names can be resolved (e.g. in migrations)
$namespace = $plugin->namespace . '\\';
if (!array_key_exists($namespace, $classLoader->getPrefixesPsr4())) {
@@ -134,7 +138,7 @@ class PluginService
return;
}
$plugins = Plugin::query()->orderBy('load_order')->get();
$plugins = Plugin::orderBy('load_order')->get();
foreach ($plugins as $plugin) {
try {
if (!$plugin->shouldLoad($panel->getId())) {
@@ -168,7 +172,7 @@ class PluginService
{
$newPackages ??= [];
$plugins = Plugin::query()->orderBy('load_order')->get();
$plugins = Plugin::orderBy('load_order')->get();
foreach ($plugins as $plugin) {
if (!$plugin->composer_packages) {
continue;
@@ -430,7 +434,7 @@ class PluginService
/** @param array<string, mixed> $data */
private function setMetaData(string|Plugin $plugin, array $data): void
{
$path = plugin_path($plugin instanceof Plugin ? $plugin->id : $plugin, 'plugin.json');
$path = plugin_path($plugin->id, 'plugin.json');
if (File::exists($path)) {
$pluginData = File::json($path, JSON_THROW_ON_ERROR);
@@ -439,7 +443,6 @@ class PluginService
File::put($path, json_encode($pluginData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
$plugin = $plugin instanceof Plugin ? $plugin : Plugin::findOrFail($plugin);
$plugin->update($metaData);
}
}
@@ -460,6 +463,8 @@ class PluginService
public function updateLoadOrder(array $order): void
{
foreach ($order as $i => $plugin) {
$plugin = Plugin::firstOrFail(str($plugin)->lower()->toString());
$this->setMetaData($plugin, [
'load_order' => $i,
]);
@@ -468,7 +473,7 @@ class PluginService
public function hasThemePluginEnabled(): bool
{
$plugins = Plugin::query()->orderBy('load_order')->get();
$plugins = Plugin::orderBy('load_order')->get();
foreach ($plugins as $plugin) {
if ($plugin->isTheme() && $plugin->status === PluginStatus::Enabled) {
return true;
@@ -483,7 +488,7 @@ class PluginService
{
$languages = [];
$plugins = Plugin::query()->orderBy('load_order')->get();
$plugins = Plugin::orderBy('load_order')->get();
foreach ($plugins as $plugin) {
if ($plugin->status !== PluginStatus::Enabled || !$plugin->isLanguage()) {
continue;
@@ -500,7 +505,7 @@ class PluginService
return config('panel.plugin.dev_mode', false);
}
private function handlePluginException(string|Plugin $plugin, Exception $exception): void
private function handlePluginException(Plugin $plugin, Exception $exception): void
{
if ($this->isDevModeActive()) {
throw ($exception);

View File

@@ -31,13 +31,22 @@ class GetUserPermissionsService
'admin.websocket.transfer',
];
if ($isAdmin) {
return $isOwner || $user->can('update', $server) ? array_merge(['*'], $adminPermissions) : array_merge([SubuserPermission::WebsocketConnect->value], $adminPermissions);
if ($isAdmin && ($isOwner || $user->can('update', $server))) {
return array_merge(['*'], $adminPermissions);
}
/** @var Subuser|null $subuser */
$subuser = $server->subusers()->where('user_id', $user->id)->first();
$subuserPermissions = $subuser !== null ? $subuser->permissions : [];
return $subuser->permissions ?? [];
if ($isAdmin) {
return array_unique(array_merge(
[SubuserPermission::WebsocketConnect->value],
$adminPermissions,
$subuserPermissions,
));
}
return $subuserPermissions;
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Services\Servers;
use App\Extensions\Features\FeatureService;
use App\Models\Egg;
use App\Models\Mount;
use App\Models\Server;
@@ -39,6 +38,7 @@ class ServerConfigurationStructureService
* Returns the data format used for the daemon.
*
* @return array{
* id: int,
* uuid: string,
* meta: array{name: string, description: string},
* suspended: bool,
@@ -70,6 +70,7 @@ class ServerConfigurationStructureService
protected function returnFormat(Server $server): array
{
$response = [
'id' => $server->id,
'uuid' => $server->uuid,
'meta' => [
'name' => $server->name,

View File

@@ -49,12 +49,6 @@ class UserCreationService
$data['username'] = str($data['email'])->before('@')->toString() . Str::random(3);
}
$data['username'] = str($data['username'])
->replace(['.', '-'], '')
->ascii()
->substr(0, 64)
->toString();
/** @var User $user */
$user = User::query()->forceCreate(array_merge($data, [
'uuid' => Uuid::uuid4()->toString(),

View File

@@ -36,7 +36,7 @@ class EggTransformer extends BaseTransformer
{
$model->loadMissing('configFrom');
$files = json_decode($model->inherit_config_files, true, 512, JSON_THROW_ON_ERROR);
$files = json_decode($model->inherit_config_files ?: '{}', true, 512, JSON_THROW_ON_ERROR);
$model->loadMissing('scriptFrom');
@@ -53,9 +53,9 @@ class EggTransformer extends BaseTransformer
'docker_images' => $model->docker_images,
'config' => [
'files' => $files,
'startup' => json_decode($model->inherit_config_startup, true),
'startup' => json_decode($model->inherit_config_startup ?: '{}', true),
'stop' => $model->inherit_config_stop,
'logs' => json_decode($model->inherit_config_logs, true),
'logs' => json_decode($model->inherit_config_logs ?: '{}', true),
'file_denylist' => $model->inherit_file_denylist,
'extends' => $model->config_from,
],

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Transformers\Api\Application;
use App\Models\Plugin;
class PluginTransformer extends BaseTransformer
{
/**
* Return the resource name for the JSONAPI output.
*/
public function getResourceName(): string
{
return Plugin::RESOURCE_NAME;
}
/**
* @param Plugin $model
*/
public function transform($model): array
{
return [
'id' => $model->id,
'name' => $model->name,
'author' => $model->author,
'version' => $model->version,
'description' => $model->description,
'category' => $model->category,
'url' => $model->url,
'update_url' => $model->update_url,
'namespace' => $model->namespace,
'class' => $model->class,
'panels' => $model->panels ? explode(',', $model->panels) : null,
'panel_version' => $model->panel_version,
'composer_packages' => $model->composer_packages ? json_decode($model->composer_packages, true, 512, JSON_THROW_ON_ERROR) : null,
'meta' => [
'status' => $model->status,
'status_message' => $model->status_message,
'load_order' => $model->load_order,
'is_compatible' => $model->isCompatible(),
'update_available' => $model->isUpdateAvailable(),
'can_enable' => $model->canEnable(),
'can_disable' => $model->canDisable(),
],
];
}
}

View File

@@ -12,18 +12,19 @@
"aws/aws-sdk-php": "^3.369",
"calebporzio/sushi": "^2.5",
"dedoc/scramble": "^0.13",
"filament/filament": "^4.5",
"gboquizosanchez/filament-log-viewer": "^2.1",
"filament/filament": "~5.0",
"gboquizosanchez/filament-log-viewer": "^2.2",
"guzzlehttp/guzzle": "^7.10",
"laravel/framework": "^12.49",
"laravel/framework": "^12.52",
"laravel/helpers": "^1.8",
"laravel/sanctum": "^4.2",
"laravel/sanctum": "^4.3",
"laravel/socialite": "^5.24",
"laravel/tinker": "^2.10.1",
"laravel/ui": "^4.6",
"lcobucci/jwt": "^5.6",
"league/flysystem-aws-s3-v3": "^3.31",
"league/flysystem-memory": "^3.31",
"livewire/livewire": "~4.1",
"phiki/phiki": "^2.0",
"phpseclib/phpseclib": "~3.0.18",
"predis/predis": "^2.3",
@@ -34,7 +35,7 @@
"socialiteproviders/steam": "^4.3",
"spatie/laravel-data": "^4.19",
"spatie/laravel-fractal": "^6.3",
"spatie/laravel-health": "^1.34",
"spatie/laravel-health": "^1.37",
"spatie/laravel-permission": "^6.24",
"spatie/laravel-query-builder": "^6.4",
"spatie/temporary-directory": "^2.3",
@@ -53,10 +54,9 @@
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6.11",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.7",
"pestphp/pest-plugin-faker": "^3.0",
"pestphp/pest-plugin-livewire": "^3.0",
"spatie/laravel-ignition": "^2.9"
"pestphp/pest": "~4.4",
"pestphp/pest-plugin-faker": "~4",
"pestphp/pest-plugin-livewire": "~4.1"
},
"autoload": {
"files": [
@@ -93,7 +93,7 @@
"pestphp/pest-plugin": true
},
"platform": {
"php": "8.2"
"php": "8.3"
}
},
"minimum-stability": "stable",

2081
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,29 +8,35 @@ return new class extends Migration
{
$mappings = [
// Forge Minecraft
'ed072427-f209-4603-875c-f540c6dd5a65' => [
'new_uuid' => 'd6018085-eecc-42bf-bf8c-51ea45a69ace',
'd6018085-eecc-42bf-bf8c-51ea45a69ace' => [
'new_uuid' => 'ed072427-f209-4603-875c-f540c6dd5a65',
'new_update_url' => 'https://raw.githubusercontent.com/pelican-eggs/minecraft/refs/heads/main/java/forge/egg-forge-minecraft.yaml',
],
// Paper
'5da37ef6-58da-4169-90a6-e683e1721247' => [
'new_uuid' => '150956be-4164-4086-9057-631ae95505e9',
'150956be-4164-4086-9057-631ae95505e9' => [
'new_uuid' => '5da37ef6-58da-4169-90a6-e683e1721247',
'new_update_url' => 'https://raw.githubusercontent.com/pelican-eggs/minecraft/refs/heads/main/java/paper/egg-paper.yaml',
],
// Garrys Mod
'60ef81d4-30a2-4d98-ab64-f59c69e2f915' => [
'new_uuid' => 'c0b2f96a-f753-4d82-a73e-6e5be2bbadd5',
'c0b2f96a-f753-4d82-a73e-6e5be2bbadd5' => [
'new_uuid' => '60ef81d4-30a2-4d98-ab64-f59c69e2f915',
'new_update_url' => 'https://raw.githubusercontent.com/pelican-eggs/games-steamcmd/refs/heads/main/gmod/egg-garrys-mod.yaml',
],
];
foreach ($mappings as $oldUuid => $newData) {
DB::table('eggs')->where('uuid', $oldUuid)->update([
'uuid' => $newData['new_uuid'],
'update_url' => $newData['new_update_url'],
]);
if (DB::table('eggs')->where('uuid', $newData['new_uuid'])->exists()) {
DB::table('eggs')->where('uuid', $newData['new_uuid'])->update([
'update_url' => $newData['new_update_url'],
]);
} else {
DB::table('eggs')->where('uuid', $oldUuid)->update([
'uuid' => $newData['new_uuid'],
'update_url' => $newData['new_update_url'],
]);
}
}
}

View File

@@ -5,11 +5,11 @@
{$CADDY_STRICT_PROXIES}
}
admin off
{$PARSED_AUTO_HTTPS}
{$PARSED_LE_EMAIL}
{$CADDY_AUTO_HTTPS}
{$CADDY_LE_EMAIL}
}
{$PARSED_APP_URL} {
{$CADDY_APP_URL} {
root * /var/www/html/public
encode gzip

View File

@@ -1,34 +1,48 @@
#!/bin/ash -e
# shellcheck shell=dash
# check for .env file or symlink and generate app keys if missing
if [ -f /var/www/html/.env ]; then
echo "external vars exist."
if [ -f /pelican-data/.env ]; then
echo ".env vars exist."
# load specific env vars from .env used in the entrypoint and they are not already set
for VAR in "APP_KEY" "APP_INSTALLED" "DB_CONNECTION" "DB_HOST" "DB_PORT"; do if ! (printenv | grep -q ${VAR}); then export $(grep ${VAR} .env | grep -ve "^#"); fi; done
for VAR in "APP_KEY" "APP_INSTALLED" "DB_CONNECTION" "DB_HOST" "DB_PORT"; do
echo "checking for ${VAR}"
## skip if it looks like it might try to execute code
if (grep "${VAR}" .env | grep -qE "\$\(|=\`|\$#"); then echo "var in .env may be executable or a comment, skipping"; continue; fi
# if the variable is in .env then set it
if (grep -q "${VAR}" .env); then
echo "loading ${VAR} from .env"
export "$(grep "${VAR}" .env | sed 's/"//g')"
continue
fi
## variable wasn't loaded or in the env to set
echo "didn't find variable to set"
done
else
echo "external vars don't exist."
echo ".env vars don't exist."
# webroot .env is symlinked to this path
touch /pelican-data/.env
# manually generate a key because key generate --force fails
if [ -z ${APP_KEY} ]; then
echo -e "Generating key."
if [ -z "${APP_KEY}" ]; then
echo "No key set, Generating key."
APP_KEY=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
echo -e "Generated app key: $APP_KEY"
echo -e "APP_KEY=$APP_KEY" > /pelican-data/.env
echo "APP_KEY=$APP_KEY" > /pelican-data/.env
echo "Generated app key written to .env file"
else
echo -e "APP_KEY exists in environment, using that."
echo -e "APP_KEY=$APP_KEY" > /pelican-data/.env
echo "APP_KEY exists in environment, using that."
echo "APP_KEY=$APP_KEY" > /pelican-data/.env
fi
# enable installer
echo -e "APP_INSTALLED=false" >> /pelican-data/.env
echo "APP_INSTALLED=false" >> /pelican-data/.env
fi
# create directories for volumes
mkdir -p /pelican-data/database /pelican-data/storage/avatars /pelican-data/storage/fonts /pelican-data/storage/icons /pelican-data/plugins /var/www/html/storage/logs/supervisord 2>/dev/null
# if the app is installed then we need to run migrations on start. New installs will run migrations when you run the installer.
if [ "${APP_INSTALLED}" == "true" ]; then
if [ "${APP_INSTALLED}" = "true" ]; then
#if the db is anything but sqlite wait until it's accepting connections
if [ "${DB_CONNECTION}" != "sqlite" ]; then
# check for DB up before starting the panel
@@ -39,36 +53,44 @@ if [ "${APP_INSTALLED}" == "true" ]; then
# wait for 1 seconds before check again
sleep 1
done
else
echo "using sqlite database"
fi
# run migration
php artisan migrate --force
fi
echo -e "Optimizing Filament"
echo "Optimizing Filament"
php artisan filament:optimize
# default to caddy not starting
export SUPERVISORD_CADDY=false
export PARSED_APP_URL=${APP_URL}
export CADDY_APP_URL="${APP_URL}"
# checking if app url is using https
if echo "${APP_URL}" | grep -qE '^https://'; then
# checking if app url is https
if (echo "${APP_URL}" | grep -qE '^https://'); then
# check lets encrypt email was set without a proxy
if [ -z "${LE_EMAIL}" ] && [ "${BEHIND_PROXY}" != "true" ]; then
echo "when app url is https a lets encrypt email must be set when not behind a proxy"
exit 1
fi
echo "https domain found setting email var"
export PARSED_LE_EMAIL="email ${LE_EMAIL}"
export CADDY_LE_EMAIL="email ${LE_EMAIL}"
fi
# when running behind a proxy
if [ "${BEHIND_PROXY}" == "true" ]; then
if [ "${BEHIND_PROXY}" = "true" ]; then
echo "running behind proxy"
echo "listening on port 80 internally"
export PARSED_LE_EMAIL=""
export PARSED_APP_URL=":80"
export PARSED_AUTO_HTTPS="auto_https off"
export ASSET_URL=${APP_URL}
export CADDY_LE_EMAIL=""
export CADDY_APP_URL=":80"
export CADDY_AUTO_HTTPS="auto_https off"
export ASSET_URL="${APP_URL}"
fi
# disable caddy if SKIP_CADDY is set
if [ "${SKIP_CADDY:-}" == "true" ]; then
if [ "${SKIP_CADDY:-}" = "true" ]; then
echo "Starting PHP-FPM only"
else
echo "Starting PHP-FPM and Caddy"
@@ -76,8 +98,9 @@ else
export SUPERVISORD_CADDY=true
# handle trusted proxies for caddy when variable has data
if [ ! -z ${TRUSTED_PROXIES} ]; then
export CADDY_TRUSTED_PROXIES=$(echo "trusted_proxies static ${TRUSTED_PROXIES}" | sed 's/,/ /g')
if [ -n "${TRUSTED_PROXIES:-}" ]; then
FORMATTED_PROXIES=$(echo "trusted_proxies static ${TRUSTED_PROXIES}" | sed 's/,/ /g')
export CADDY_TRUSTED_PROXIES="${FORMATTED_PROXIES}"
export CADDY_STRICT_PROXIES="trusted_proxies_strict"
fi
fi

View File

@@ -20,6 +20,8 @@ return [
'never_used' => 'Never Used',
],
'permissions' => [
'all' => 'Set All Permissions',
'all_description' => 'Quickly set all permissions below to the same level.',
'none' => 'None',
'read' => 'Read',
'read_write' => 'Read & Write',

View File

@@ -31,6 +31,7 @@ return [
'no_local_ip' => 'Local IP Addresses are not allowed',
'unsupported_format' => 'Unsupported Format. Supported Formats: :formats',
'invalid_url' => 'The provided URL is invalid',
'unknown_extension' => 'Unknown image extension',
'image_deleted' => 'Image Deleted',
'no_image' => 'No Image Provided',
'image_updated' => 'Image Updated',
@@ -115,10 +116,6 @@ return [
'no_update_url' => 'The following eggs do not have a working update URL set: :eggs',
'cannot_delete' => 'Cannot delete :count egg(s)',
'eggs_have_servers' => 'The following eggs have servers and cannot be deleted: :eggs',
'delete_success' => 'Egg deleted successfully',
'deleted' => 'Deleted: :egg',
'delete_failed' => 'Failed to delete egg',
'could_not_delete' => 'Could not delete: :egg',
'updated_from' => 'Successfully updated from: :url',
'update_error' => 'Error: :error',
'updated_eggs' => 'Updated: :eggs',

View File

@@ -14,6 +14,7 @@ return [
],
'table' => [
'health' => 'Health',
'reachable' => 'Reachable',
'name' => 'Name',
'address' => 'Address',
'public' => 'Public',
@@ -39,6 +40,7 @@ return [
'ip_help' => 'Usually your machine\'s public IP unless you are port forwarding.',
'alias_help' => 'Optional display name to help you remember what these are.',
'refresh' => 'Refresh',
'custom_ip' => 'Enter Custom IP',
'domain' => 'Domain Name',
'ssl_ip' => 'You cannot connect to an IP Address over SSL',
'error' => 'This is the domain name that points to your node\'s IP Address. If you\'ve already set up this, you can verify it by checking the next field!',
@@ -65,6 +67,8 @@ return [
'sftp_port' => 'SFTP Port',
'sftp_alias' => 'SFTP Alias',
'sftp_alias_help' => 'Display alias for the SFTP address. Leave empty to use the Node FQDN.',
'daemon_base' => 'Daemon Base Directory',
'daemon_base_help' => 'The directory where server data will be stored.',
'use_for_deploy' => 'Use for Deployments?',
'maintenance_mode' => 'Maintenance Mode',
'maintenance_mode_help' => 'If the node is marked \'Under Maintenance\' users won\'t be able to access servers that are on that node',

35
lang/en/mail.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
return [
'greeting' => 'Hello :name!',
'account_created' => [
'body' => 'You are receiving this email because an account has been created for you on :app.',
'username' => 'Username: :username',
'email' => 'Email: :email',
'action' => 'Setup Your Account',
],
'added_to_server' => [
'body' => 'You have been added as a subuser for the following server, allowing you certain control over the server.',
'server_name' => 'Server Name: :name',
'action' => 'Visit Server',
],
'removed_from_server' => [
'body' => 'You have been removed as a subuser for the following server.',
'server_name' => 'Server Name: :name',
'action' => 'Visit Panel',
],
'server_installed' => [
'body' => 'Your server has finished installing and is now ready for you to use.',
'server_name' => 'Server Name: :name',
'action' => 'Login and Begin Using',
],
'mail_tested' => [
'subject' => 'Panel Test Message',
'body' => 'This is a test of the Panel mail system. You\'re good to go!',
],
];

View File

@@ -64,7 +64,10 @@ return [
'sidebar' => 'Sidebar',
'topbar' => 'Topbar',
'mixed' => 'Mixed',
'redirect_to_admin' => 'Redirect to Admin on Login',
'redirect_to_admin_help' => 'When enabled, you will be redirected to the admin area after logging in instead of the server list.',
'no_oauth' => 'No Accounts Linked',
'no_api_keys' => 'No API Keys',
'no_ssh_keys' => 'No SSH Keys',
'activity_info' => 'Showing last 50 activity logs',
];

View File

@@ -5,6 +5,10 @@
RewriteEngine On
# Handle X-Forwarded-Proto Header
RewriteCond %{HTTP:X-Forwarded-Proto} =https [NC]
RewriteRule .* - [E=HTTPS:on]
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
(()=>{var n=({livewireId:e})=>({actionNestingIndex:null,init(){window.addEventListener("sync-action-modals",t=>{t.detail.id===e&&this.syncActionModals(t.detail.newActionNestingIndex)})},syncActionModals(t){if(this.actionNestingIndex===t){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}if(this.actionNestingIndex!==null&&this.closeModal(),this.actionNestingIndex=t,this.actionNestingIndex!==null){if(!this.$el.querySelector(`#${this.generateModalId(t)}`)){this.$nextTick(()=>this.openModal());return}this.openModal()}},generateModalId(t){return`fi-${e}-action-`+t},openModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:t}}))},closeModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:t}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",n)});})();
(()=>{var n=({livewireId:e})=>({actionNestingIndex:null,init(){window.addEventListener("sync-action-modals",t=>{t.detail.id===e&&this.syncActionModals(t.detail.newActionNestingIndex,t.detail.shouldOverlayParentActions??!1)})},syncActionModals(t,i=!1){if(this.actionNestingIndex===t){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}let s=this.actionNestingIndex!==null&&t!==null&&t>this.actionNestingIndex;if(this.actionNestingIndex!==null&&!(i&&s)&&this.closeModal(),this.actionNestingIndex=t,this.actionNestingIndex!==null){if(!this.$el.querySelector(`#${this.generateModalId(t)}`)){this.$nextTick(()=>this.openModal());return}this.openModal()}},generateModalId(t){return`fi-${e}-action-`+t},openModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:t}}))},closeModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:t}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",n)});})();

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))}}}export{c as default};
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",unsubscribeLivewireHook:null,visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{e.component.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))},destroy(){this.unsubscribeLivewireHook?.()}}}export{c as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
function h({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{h as default};
function a({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{if(!Array.isArray(e))return;let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{a as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),Livewire.hook("commit",({component:n,commit:d,succeed:r,fail:h,respond:u})=>{r(({snapshot:p,effect:i})=>{this.$nextTick(()=>{if(n.id!==g)return;let s=this.getTabs();s.includes(this.tab)||(this.tab=s[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,d,r,h){let u=t.map(i=>Math.ceil(i.clientWidth)),p=t.map(i=>{let s=i.querySelector(".fi-tabs-item-label"),a=i.querySelector(".fi-badge"),o=Math.ceil(s.clientWidth),l=a?Math.ceil(a.clientWidth):0;return{label:o,badge:l,total:o+(l>0?d+l:0)}});for(let i=0;i<t.length;i++){let s=u.slice(0,i+1).reduce((b,y)=>b+y,0),a=i*n,o=p.slice(i+1),l=o.length>0,W=l?Math.max(...o.map(b=>b.total)):0,D=l?r+W+d+h+n:0;if(s+a+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(c,this.tab),history.replaceState(null,document.title,t.toString())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),d=n.map(a=>a.style.display);n.forEach(a=>a.style.display=""),t.offsetHeight;let r=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),p=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),s=this.findOverflowIndex(n,r,h,p,i,u);n.forEach((a,o)=>a.style.display=d[o]),s!==-1&&(this.withinDropdownIndex=s),this.withinDropdownMounted=!0},destroy(){this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:g,livewireId:m,tab:T,tabQueryStringKey:r}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,unsubscribeLivewireHook:null,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);g&&e.has(r)&&t.includes(e.get(r))&&(this.tab=e.get(r)),this.$watch("tab",()=>{this.updateQueryString(),this.autofocusFields()}),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),this.autofocusFields(),this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:n,onSuccess:o})=>{o(()=>{this.$nextTick(()=>{if(n.component.id!==m)return;let l=this.getTabs();l.includes(this.tab)||(this.tab=l[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,o,l,h){let u=t.map(i=>Math.ceil(i.clientWidth)),b=t.map(i=>{let d=i.querySelector(".fi-tabs-item-label"),s=i.querySelector(".fi-badge"),a=Math.ceil(d.clientWidth),c=s?Math.ceil(s.clientWidth):0;return{label:a,badge:c,total:a+(c>0?o+c:0)}});for(let i=0;i<t.length;i++){let d=u.slice(0,i+1).reduce((p,y)=>p+y,0),s=i*n,a=b.slice(i+1),c=a.length>0,W=c?Math.max(...a.map(p=>p.total)):0,D=c?l+W+o+h+n:0;if(d+s+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!g)return;let t=new URL(window.location.href);t.searchParams.set(r,this.tab),history.replaceState(null,document.title,t.toString())},autofocusFields(){this.$nextTick(()=>this.$el.querySelector(".fi-sc-tabs-tab.fi-active [autofocus]")?.focus())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),o=n.map(s=>s.style.display);n.forEach(s=>s.style.display=""),t.offsetHeight;let l=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),b=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),d=this.findOverflowIndex(n,l,h,b,i,u);n.forEach((s,a)=>s.style.display=o[a]),d!==-1&&(this.withinDropdownIndex=d),this.withinDropdownMounted=!0},destroy(){this.unsubscribeLivewireHook?.(),this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};

View File

@@ -1 +1 @@
function o({isSkippable:s,isStepPersistedInQueryString:i,key:r,startStep:h,stepQueryStringKey:n}){return{step:null,init(){this.$watch("step",()=>this.updateQueryString()),this.step=this.getSteps().at(h-1),this.autofocusFields()},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(){this.$nextTick(()=>this.$refs[`step-${this.step}`].querySelector("[autofocus]")?.focus())},getStepIndex(t){let e=this.getSteps().findIndex(p=>p===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return s||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!i)return;let t=new URL(window.location.href);t.searchParams.set(n,this.step),history.replaceState(null,document.title,t.toString())}}}export{o as default};
function o({isSkippable:s,isStepPersistedInQueryString:i,key:r,startStep:h,stepQueryStringKey:n}){return{step:null,init(){this.$watch("step",()=>this.updateQueryString()),this.step=this.getSteps().at(h-1),this.autofocusFields()},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToStep(t){let e=this.getStepIndex(t);e<=-1||!s&&e>this.getStepIndex(this.step)||(this.step=t,this.autofocusFields(),this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(){this.$nextTick(()=>this.$refs[`step-${this.step}`].querySelector("[autofocus]")?.focus())},getStepIndex(t){let e=this.getSteps().findIndex(p=>p===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return s||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!i)return;let t=new URL(window.location.href);t.searchParams.set(n,this.step),history.replaceState(null,document.title,t.toString())}}}export{o as default};

Some files were not shown because too many files have changed in this diff Show More