Merge branch 'main' into issue/68

# Conflicts:
#	app/Filament/Resources/NodeResource/Pages/CreateNode.php
#	app/Filament/Resources/NodeResource/RelationManagers/AllocationsRelationManager.php
#	app/Filament/Resources/NodeResource/RelationManagers/NodesRelationManager.php
#	app/Filament/Resources/ServerResource/Pages/CreateServer.php
#	app/Filament/Resources/ServerResource/Pages/EditServer.php
#	app/Filament/Resources/ServerResource/Pages/ListServers.php
#	app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php
#	app/Filament/Resources/UserResource/RelationManagers/ServersRelationManager.php
#	app/Transformers/Api/Client/ServerTransformer.php
#	composer.lock
#	config/panel.php
This commit is contained in:
Lance Pioch 2024-10-18 21:18:10 -04:00
commit 5353d38302
152 changed files with 3101 additions and 1295 deletions

10
.dockerignore Normal file
View File

@ -0,0 +1,10 @@
.git
node_modules
vendor
database/database.sqlite
storage/debugbar/*.json
storage/logs/*.log
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/testing
storage/framework/views/*.php

View File

@ -1,33 +1,7 @@
APP_ENV=production APP_ENV=production
APP_DEBUG=false APP_DEBUG=false
APP_KEY= APP_KEY=
APP_TIMEZONE=UTC
APP_URL=http://panel.test APP_URL=http://panel.test
APP_LOCALE=en
APP_INSTALLED=false APP_INSTALLED=false
APP_TIMEZONE=UTC
LOG_CHANNEL=daily APP_LOCALE=en
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
CACHE_STORE=file
QUEUE_CONNECTION=database
SESSION_DRIVER=file
MAIL_MAILER=log
MAIL_HOST=smtp.example.com
MAIL_PORT=25
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=no-reply@example.com
MAIL_FROM_NAME="Pelican Admin"
# Set this to your domain to prevent it defaulting to 'localhost', causing mail servers such as Gmail to reject your mail
# MAIL_EHLO_DOMAIN=panel.example.com
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null

View File

@ -1,81 +1,64 @@
#!/bin/ash -e #!/bin/ash -e
cd /app
mkdir -p /var/log/panel/logs/ /var/log/supervisord/ /var/log/nginx/ /var/log/php8/ \ #mkdir -p /var/log/supervisord/ /var/log/php8/ \
&& chmod 777 /var/log/panel/logs/ \
&& ln -s /app/storage/logs/ /var/log/panel/
## check for .env file and generate app keys if missing ## check for .env file and generate app keys if missing
if [ -f /app/var/.env ]; then if [ -f /pelican-data/.env ]; then
echo "external vars exist." echo "external vars exist."
rm -rf /app/.env rm -rf /var/www/html/.env
ln -s /app/var/.env /app/
else else
echo "external vars don't exist." echo "external vars don't exist."
rm -rf /app/.env rm -rf /var/www/html/.env
touch /app/var/.env touch /pelican-data/.env
## manually generate a key because key generate --force fails ## manually generate a key because key generate --force fails
if [ -z $APP_KEY ]; then if [ -z $APP_KEY ]; then
echo -e "Generating key." echo -e "Generating key."
APP_KEY=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) 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 "Generated app key: $APP_KEY"
echo -e "APP_KEY=$APP_KEY" > /app/var/.env echo -e "APP_KEY=$APP_KEY" > /pelican-data/.env
else else
echo -e "APP_KEY exists in environment, using that." echo -e "APP_KEY exists in environment, using that."
echo -e "APP_KEY=$APP_KEY" > /app/var/.env echo -e "APP_KEY=$APP_KEY" > /pelican-data/.env
fi fi
ln -s /app/var/.env /app/ ## enable installer
echo -e "APP_INSTALLED=false" >> /pelican-data/.env
fi fi
echo "Checking if https is required." mkdir /pelican-data/database
if [ -f /etc/nginx/http.d/panel.conf ]; then ln -s /pelican-data/.env /var/www/html/
echo "Using nginx config already in place." ln -s /pelican-data/database/database.sqlite /var/www/html/database/
if [ $LE_EMAIL ]; then
echo "Checking for cert update" if ! grep -q "APP_KEY=" .env || grep -q "APP_KEY=$" .env; then
certbot certonly -d $(echo $APP_URL | sed 's~http[s]*://~~g') --standalone -m $LE_EMAIL --agree-tos -n echo "Generating APP_KEY..."
else php artisan key:generate --force
echo "No letsencrypt email is set"
fi
else else
echo "Checking if letsencrypt email is set." echo "APP_KEY is already set."
if [ -z $LE_EMAIL ]; then
echo "No letsencrypt email is set using http config."
cp .github/docker/default.conf /etc/nginx/http.d/panel.conf
else
echo "writing ssl config"
cp .github/docker/default_ssl.conf /etc/nginx/http.d/panel.conf
echo "updating ssl config for domain"
sed -i "s|<domain>|$(echo $APP_URL | sed 's~http[s]*://~~g')|g" /etc/nginx/http.d/panel.conf
echo "generating certs"
certbot certonly -d $(echo $APP_URL | sed 's~http[s]*://~~g') --standalone -m $LE_EMAIL --agree-tos -n
fi
echo "Removing the default nginx config"
rm -rf /etc/nginx/http.d/default.conf
fi fi
if [[ -z $DB_PORT ]]; then
echo -e "DB_PORT not specified, defaulting to 3306"
DB_PORT=3306
fi
## check for DB up before starting the panel
echo "Checking database status."
until nc -z -v -w30 $DB_HOST $DB_PORT
do
echo "Waiting for database connection..."
# wait for 1 seconds before check again
sleep 1
done
## make sure the db is set up ## make sure the db is set up
echo -e "Migrating and Seeding D.B" echo -e "Migrating Database"
php artisan migrate --seed --force php artisan migrate --force
echo -e "Optimizing Filament"
php artisan filament:optimize
## start cronjobs for the queue ## start cronjobs for the queue
echo -e "Starting cron jobs." echo -e "Starting cron jobs."
crond -L /var/log/crond -l 5 crond -L /var/log/crond -l 5
echo -e "Starting supervisord." export SUPERVISORD_CADDY=false
## disable caddy if SKIP_CADDY is set
if [[ -z $SKIP_CADDY ]]; then
echo "Starting PHP-FPM and Caddy"
export SUPERVISORD_CADDY=true
else
echo "Starting PHP-FPM only"
fi
chown -R www-data:www-data . /pelican-data/.env /pelican-data/database
echo "Starting Supervisord"
exec "$@" exec "$@"

View File

@ -25,15 +25,15 @@ autostart=true
autorestart=true autorestart=true
[program:queue-worker] [program:queue-worker]
command=/usr/local/bin/php /app/artisan queue:work --queue=high,standard,low --sleep=3 --tries=3 command=/usr/local/bin/php /var/www/html/artisan queue:work --tries=3
user=nginx user=www-data
autostart=true autostart=true
autorestart=true autorestart=true
[program:nginx] [program:caddy]
command=/usr/sbin/nginx -g 'daemon off;' command=caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
autostart=true autostart=%(ENV_SUPERVISORD_CADDY)s
autorestart=true autorestart=%(ENV_SUPERVISORD_CADDY)s
priority=10 priority=10
stdout_events_enabled=true stdout_events_enabled=true
stderr_events_enabled=true stderr_events_enabled=true

82
.github/workflows/docker-publish.yml vendored Normal file
View File

@ -0,0 +1,82 @@
name: Docker
on:
push:
branches:
- main
release:
types:
- published
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# Always run against a tag, even if the commit into the tag has [docker skip] within the commit message.
if: "!contains(github.ref, 'main') || (!contains(github.event.head_commit.message, 'skip docker') && !contains(github.event.head_commit.message, 'docker skip'))"
steps:
- name: Code checkout
uses: actions/checkout@v4
- name: Docker metadata
id: docker_meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/pelican-dev/panel
flavor: |
latest=false
tags: |
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.action == 'published' && github.event.release.prerelease == false }}
type=ref,event=tag
type=ref,event=branch
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get Build Information
id: build_info
run: |
echo "version_tag=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Build and Push (tag)
uses: docker/build-push-action@v5
if: "github.event_name == 'release' && github.event.action == 'published'"
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.build_info.outputs.version_tag }}
labels: ${{ steps.docker_meta.outputs.labels }}
tags: ${{ steps.docker_meta.outputs.tags }}
- name: Build and Push (main)
uses: docker/build-push-action@v5
if: "github.event_name == 'push' && contains(github.ref, 'main')"
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=dev-${{ steps.build_info.outputs.short_sha }}
labels: ${{ steps.docker_meta.outputs.labels }}
tags: ${{ steps.docker_meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

12
Caddyfile Normal file
View File

@ -0,0 +1,12 @@
{
admin off
email {$ADMIN_EMAIL}
}
{$APP_URL} {
root * /var/www/html/public
encode gzip
php_fastcgi 127.0.0.1:9000
file_server
}

View File

@ -1,41 +1,58 @@
# Stage 0: # Pelican Production Dockerfile
# Build the assets that are needed for the frontend. This build stage is then discarded
# since we won't need NodeJS anymore in the future. This Docker image ships a final production FROM node:20-alpine AS yarn
# level distribution #FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine
WORKDIR /app WORKDIR /build
COPY . ./ COPY . ./
RUN yarn install --frozen-lockfile \
RUN yarn config set network-timeout 300000 \
&& yarn install --frozen-lockfile \
&& yarn run build:production && yarn run build:production
# Stage 1: FROM php:8.3-fpm-alpine
# Build the actual container with all of the needed PHP dependencies that will run the application. # FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
WORKDIR /app
COPY . ./
COPY --from=0 /app/public/assets ./public/assets
RUN apk add --no-cache --update ca-certificates dcron curl git supervisor tar unzip nginx libpng-dev libxml2-dev libzip-dev icu-dev certbot certbot-nginx \
&& docker-php-ext-configure zip \
&& docker-php-ext-install bcmath gd intl pdo_mysql zip \
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& cp .env.example .env \
&& mkdir -p bootstrap/cache/ storage/logs storage/framework/sessions storage/framework/views storage/framework/cache \
&& chmod 777 -R bootstrap storage \
&& composer install --no-dev --optimize-autoloader \
&& rm -rf .env bootstrap/cache/*.php \
&& mkdir -p /app/storage/logs/ \
&& chown -R nginx:nginx .
RUN rm /usr/local/etc/php-fpm.conf \ COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
&& echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \
&& echo "0 23 * * * certbot renew --nginx --quiet" >> /var/spool/cron/crontabs/root \
&& sed -i s/ssl_session_cache/#ssl_session_cache/g /etc/nginx/nginx.conf \
&& mkdir -p /var/run/php /var/run/nginx
COPY .github/docker/default.conf /etc/nginx/http.d/default.conf WORKDIR /var/www/html
COPY .github/docker/www.conf /usr/local/etc/php-fpm.conf
COPY .github/docker/supervisord.conf /etc/supervisord.conf # Install dependencies
RUN apk update && apk add --no-cache \
libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev icu-dev \
zip unzip curl \
caddy ca-certificates supervisor \
&& docker-php-ext-install bcmath gd intl zip opcache pcntl posix pdo_mysql
# Copy the Caddyfile to the container
COPY Caddyfile /etc/caddy/Caddyfile
# Copy the application code to the container
COPY . .
COPY --from=yarn /build/public/assets ./public/assets
RUN touch .env
RUN composer install --no-dev --optimize-autoloader
# Set file permissions
RUN chmod -R 755 storage bootstrap/cache
# Add scheduler to cron
RUN echo "* * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1" | crontab -u www-data -
## supervisord config and log dir
RUN cp .github/docker/supervisord.conf /etc/supervisord.conf && \
mkdir /var/log/supervisord/
HEALTHCHECK --interval=5m --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/up || exit 1
EXPOSE 80 443 EXPOSE 80 443
VOLUME /pelican-data
ENTRYPOINT [ "/bin/ash", ".github/docker/entrypoint.sh" ] ENTRYPOINT [ "/bin/ash", ".github/docker/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ] CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

View File

@ -0,0 +1,46 @@
<?php
namespace App\Console\Commands\Egg;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use Exception;
use Illuminate\Console\Command;
class CheckEggUpdatesCommand extends Command
{
protected $signature = 'p:egg:check-updates';
public function handle(): void
{
/** @var EggExporterService $exporterService */
$exporterService = app(EggExporterService::class);
$eggs = Egg::all();
foreach ($eggs as $egg) {
try {
if (is_null($egg->update_url)) {
$this->comment("{$egg->name}: Skipping (no update url set)");
continue;
}
$currentJson = json_decode($exporterService->handle($egg->id));
unset($currentJson->exported_at);
$updatedJson = json_decode(file_get_contents($egg->update_url));
unset($updatedJson->exported_at);
if (md5(json_encode($currentJson)) === md5(json_encode($updatedJson))) {
$this->info("{$egg->name}: Up-to-date");
cache()->put("eggs.{$egg->uuid}.update", false, now()->addHour());
} else {
$this->warn("{$egg->name}: Found update");
cache()->put("eggs.{$egg->uuid}.update", true, now()->addHour());
}
} catch (Exception $exception) {
$this->error("{$egg->name}: Error ({$exception->getMessage()})");
}
}
}
}

View File

@ -2,20 +2,14 @@
namespace App\Console\Commands\Environment; namespace App\Console\Commands\Environment;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
class AppSettingsCommand extends Command class AppSettingsCommand extends Command
{ {
use EnvironmentWriterTrait;
protected $description = 'Configure basic environment settings for the Panel.'; protected $description = 'Configure basic environment settings for the Panel.';
protected $signature = 'p:environment:setup protected $signature = 'p:environment:setup';
{--url= : The URL that this Panel is running on.}';
protected array $variables = [];
public function handle(): void public function handle(): void
{ {
@ -30,21 +24,6 @@ class AppSettingsCommand extends Command
Artisan::call('key:generate'); Artisan::call('key:generate');
} }
$this->variables['APP_TIMEZONE'] = 'UTC'; Artisan::call('filament:optimize');
$this->variables['APP_URL'] = $this->option('url') ?? $this->ask(
'Application URL',
config('app.url', 'https://example.com')
);
// Make sure session cookies are set as "secure" when using HTTPS
if (str_starts_with($this->variables['APP_URL'], 'https://')) {
$this->variables['SESSION_SECURE_COOKIE'] = 'true';
}
$this->comment('Writing variables to .env file');
$this->writeToEnvironment($this->variables);
$this->info("Setup complete. Vist {$this->variables['APP_URL']}/installer to complete the installation");
} }
} }

View File

@ -42,6 +42,13 @@ class DatabaseSettingsCommand extends Command
*/ */
public function handle(): int public function handle(): int
{ {
$this->error('Changing the database driver will NOT move any database data!');
$this->error('Please make sure you made a database backup first!');
$this->error('After changing the driver you will have to manually move the old data to the new database.');
if (!$this->confirm('Do you want to continue?')) {
return 1;
}
$selected = config('database.default', 'sqlite'); $selected = config('database.default', 'sqlite');
$this->variables['DB_CONNECTION'] = $this->option('driver') ?? $this->choice( $this->variables['DB_CONNECTION'] = $this->option('driver') ?? $this->choice(
'Database Driver', 'Database Driver',

View File

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands\Environment;
use App\Traits\Commands\RequestRedisSettingsTrait;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
class RedisSetupCommand extends Command
{
use EnvironmentWriterTrait;
use RequestRedisSettingsTrait;
protected $description = 'Configure the Panel to use Redis as cache, queue and session driver.';
protected $signature = 'p:redis:setup
{--redis-host= : Redis host to use for connections.}
{--redis-user= : User used to connect to redis.}
{--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}';
protected array $variables = [];
/**
* RedisSetupCommand constructor.
*/
public function __construct(private Kernel $console)
{
parent::__construct();
}
/**
* Handle command execution.
*/
public function handle(): int
{
$this->variables['CACHE_STORE'] = 'redis';
$this->variables['QUEUE_CONNECTION'] = 'redis';
$this->variables['SESSION_DRIVERS'] = 'redis';
$this->requestRedisSettings();
$this->call('p:environment:queue-service', [
'--overwrite' => true,
]);
$this->writeToEnvironment($this->variables);
$this->info($this->console->output());
return 0;
}
}

View File

@ -52,7 +52,7 @@ class MakeUserCommand extends Command
['UUID', $user->uuid], ['UUID', $user->uuid],
['Email', $user->email], ['Email', $user->email],
['Username', $user->username], ['Username', $user->username],
['Admin', $user->root_admin ? 'Yes' : 'No'], ['Admin', $user->isRootAdmin() ? 'Yes' : 'No'],
]); ]);
return 0; return 0;

View File

@ -2,15 +2,16 @@
namespace App\Console; namespace App\Console;
use App\Console\Commands\Egg\CheckEggUpdatesCommand;
use App\Console\Commands\Maintenance\CleanServiceBackupFilesCommand;
use App\Console\Commands\Maintenance\PruneImagesCommand;
use App\Console\Commands\Maintenance\PruneOrphanedBackupsCommand;
use App\Console\Commands\Schedule\ProcessRunnableCommand;
use App\Jobs\NodeStatistics; use App\Jobs\NodeStatistics;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use Illuminate\Console\Scheduling\Schedule; use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Database\Console\PruneCommand; use Illuminate\Database\Console\PruneCommand;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use App\Console\Commands\Schedule\ProcessRunnableCommand;
use App\Console\Commands\Maintenance\PruneOrphanedBackupsCommand;
use App\Console\Commands\Maintenance\CleanServiceBackupFilesCommand;
use App\Console\Commands\Maintenance\PruneImagesCommand;
class Kernel extends ConsoleKernel class Kernel extends ConsoleKernel
{ {
@ -35,6 +36,7 @@ class Kernel extends ConsoleKernel
$schedule->command(CleanServiceBackupFilesCommand::class)->daily(); $schedule->command(CleanServiceBackupFilesCommand::class)->daily();
$schedule->command(PruneImagesCommand::class)->daily(); $schedule->command(PruneImagesCommand::class)->daily();
$schedule->command(CheckEggUpdatesCommand::class)->hourly();
$schedule->job(new NodeStatistics())->everyFiveSeconds()->withoutOverlapping(); $schedule->job(new NodeStatistics())->everyFiveSeconds()->withoutOverlapping();

View File

@ -0,0 +1,16 @@
<?php
namespace App\Enums;
enum RolePermissionModels: string
{
case ApiKey = 'apiKey';
case DatabaseHost = 'databaseHost';
case Database = 'database';
case Egg = 'egg';
case Mount = 'mount';
case Node = 'node';
case Role = 'role';
case Server = 'server';
case User = 'user';
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum RolePermissionPrefixes: string
{
case ViewAny = 'viewList';
case View = 'view';
case Create = 'create';
case Update = 'update';
case Delete = 'delete';
}

View File

@ -3,24 +3,27 @@
namespace App\Filament\Pages\Installer; namespace App\Filament\Pages\Installer;
use App\Filament\Pages\Installer\Steps\AdminUserStep; use App\Filament\Pages\Installer\Steps\AdminUserStep;
use App\Filament\Pages\Installer\Steps\CompletedStep;
use App\Filament\Pages\Installer\Steps\DatabaseStep; use App\Filament\Pages\Installer\Steps\DatabaseStep;
use App\Filament\Pages\Installer\Steps\EnvironmentStep; use App\Filament\Pages\Installer\Steps\EnvironmentStep;
use App\Filament\Pages\Installer\Steps\RedisStep; use App\Filament\Pages\Installer\Steps\RedisStep;
use App\Filament\Pages\Installer\Steps\RequirementsStep; use App\Filament\Pages\Installer\Steps\RequirementsStep;
use App\Models\User;
use App\Services\Users\UserCreationService; use App\Services\Users\UserCreationService;
use App\Traits\CheckMigrationsTrait; use App\Traits\CheckMigrationsTrait;
use App\Traits\EnvironmentWriterTrait; use App\Traits\EnvironmentWriterTrait;
use Exception; use Exception;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Wizard; use Filament\Forms\Components\Wizard;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Forms\Get; use Filament\Forms\Get;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Concerns\HasUnsavedDataChangesAlert;
use Filament\Pages\SimplePage; use Filament\Pages\SimplePage;
use Filament\Support\Enums\MaxWidth; use Filament\Support\Enums\MaxWidth;
use Filament\Support\Exceptions\Halt;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
@ -32,31 +35,30 @@ class PanelInstaller extends SimplePage implements HasForms
{ {
use CheckMigrationsTrait; use CheckMigrationsTrait;
use EnvironmentWriterTrait; use EnvironmentWriterTrait;
use HasUnsavedDataChangesAlert;
use InteractsWithForms; use InteractsWithForms;
public $data = []; public $data = [];
protected static string $view = 'filament.pages.installer'; protected static string $view = 'filament.pages.installer';
private User $user;
public function getMaxWidth(): MaxWidth|string public function getMaxWidth(): MaxWidth|string
{ {
return MaxWidth::SevenExtraLarge; return MaxWidth::SevenExtraLarge;
} }
public function mount() public static function isInstalled(): bool
{ {
if (is_installed()) { // This defaults to true so existing panels count as "installed"
abort(404); return env('APP_INSTALLED', true);
}
$this->form->fill();
} }
public function dehydrate(): void public function mount()
{ {
Artisan::call('config:clear'); abort_if(self::isInstalled(), 404);
Artisan::call('cache:clear');
$this->form->fill();
} }
protected function getFormSchema(): array protected function getFormSchema(): array
@ -64,13 +66,15 @@ class PanelInstaller extends SimplePage implements HasForms
return [ return [
Wizard::make([ Wizard::make([
RequirementsStep::make(), RequirementsStep::make(),
EnvironmentStep::make(), EnvironmentStep::make($this),
DatabaseStep::make(), DatabaseStep::make($this),
RedisStep::make() RedisStep::make($this)
->hidden(fn (Get $get) => $get('env.SESSION_DRIVER') != 'redis' && $get('env.QUEUE_CONNECTION') != 'redis' && $get('env.CACHE_STORE') != 'redis'), ->hidden(fn (Get $get) => $get('env_general.SESSION_DRIVER') != 'redis' && $get('env_general.QUEUE_CONNECTION') != 'redis' && $get('env_general.CACHE_STORE') != 'redis'),
AdminUserStep::make(), AdminUserStep::make($this),
CompletedStep::make(),
]) ])
->persistStepInQueryString() ->persistStepInQueryString()
->nextAction(fn (Action $action) => $action->keyBindings('enter'))
->submitAction(new HtmlString(Blade::render(<<<'BLADE' ->submitAction(new HtmlString(Blade::render(<<<'BLADE'
<x-filament::button <x-filament::button
type="submit" type="submit"
@ -89,59 +93,89 @@ class PanelInstaller extends SimplePage implements HasForms
return 'data'; return 'data';
} }
protected function hasUnsavedDataChangesAlert(): bool
{
return true;
}
public function submit() public function submit()
{
// Disable installer
$this->writeToEnvironment(['APP_INSTALLED' => 'true']);
// Login user
$this->user ??= User::all()->filter(fn ($user) => $user->isRootAdmin())->first();
auth()->guard()->login($this->user, true);
// Redirect to admin panel
return redirect(Filament::getPanel('admin')->getUrl());
}
public function writeToEnv(string $key): void
{ {
try { try {
$inputs = $this->form->getState(); $variables = array_get($this->data, $key);
// Write variables to .env file
$variables = array_get($inputs, 'env');
$this->writeToEnvironment($variables); $this->writeToEnvironment($variables);
// Clear config cache
Artisan::call('config:clear');
// Run migrations
Artisan::call('migrate', [
'--force' => true,
'--seed' => true,
'--database' => $variables['DB_CONNECTION'],
]);
if (!$this->hasCompletedMigrations()) {
throw new Exception('Migrations didn\'t run successfully. Double check your database configuration.');
}
// Create first admin user
$userData = array_get($inputs, 'user');
$userData['root_admin'] = true;
app(UserCreationService::class)->handle($userData);
// Install setup complete
$this->writeToEnvironment(['APP_INSTALLED' => 'true']);
$this->rememberData();
Notification::make()
->title('Successfully Installed')
->success()
->send();
redirect()->intended(Filament::getUrl());
} catch (Exception $exception) { } catch (Exception $exception) {
report($exception); report($exception);
Notification::make() Notification::make()
->title('Installation Failed') ->title('Could not write to .env file')
->body($exception->getMessage()) ->body($exception->getMessage())
->danger() ->danger()
->persistent() ->persistent()
->send(); ->send();
throw new Halt('Error while writing .env file');
}
Artisan::call('config:clear');
}
public function runMigrations(string $driver): void
{
try {
Artisan::call('migrate', [
'--force' => true,
'--seed' => true,
'--database' => $driver,
]);
} catch (Exception $exception) {
report($exception);
Notification::make()
->title('Migrations failed')
->body($exception->getMessage())
->danger()
->persistent()
->send();
throw new Halt('Error while running migrations');
}
if (!$this->hasCompletedMigrations()) {
Notification::make()
->title('Migrations failed')
->danger()
->persistent()
->send();
throw new Halt('Migrations failed');
}
}
public function createAdminUser(): void
{
try {
$userData = array_get($this->data, 'user');
$userData['root_admin'] = true;
$this->user = app(UserCreationService::class)->handle($userData);
} catch (Exception $exception) {
report($exception);
Notification::make()
->title('Could not create admin user')
->body($exception->getMessage())
->danger()
->persistent()
->send();
throw new Halt('Error while creating admin user');
} }
} }
} }

View File

@ -2,12 +2,13 @@
namespace App\Filament\Pages\Installer\Steps; namespace App\Filament\Pages\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step; use Filament\Forms\Components\Wizard\Step;
class AdminUserStep class AdminUserStep
{ {
public static function make(): Step public static function make(PanelInstaller $installer): Step
{ {
return Step::make('user') return Step::make('user')
->label('Admin User') ->label('Admin User')
@ -16,16 +17,17 @@ class AdminUserStep
->label('Admin E-Mail') ->label('Admin E-Mail')
->required() ->required()
->email() ->email()
->default('admin@example.com'), ->placeholder('admin@example.com'),
TextInput::make('user.username') TextInput::make('user.username')
->label('Admin Username') ->label('Admin Username')
->required() ->required()
->default('admin'), ->placeholder('admin'),
TextInput::make('user.password') TextInput::make('user.password')
->label('Admin Password') ->label('Admin Password')
->required() ->required()
->password() ->password()
->revealable(), ->revealable(),
]); ])
->afterValidation(fn () => $installer->createAdminUser());
} }
} }

View File

@ -0,0 +1,34 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Support\HtmlString;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class CompletedStep
{
public static function make(): Step
{
return Step::make('complete')
->label('Setup complete')
->schema([
Placeholder::make('')
->content(new HtmlString('The setup is nearly complete!<br>As last step you need to create a new cronjob that runs every minute to process specific tasks, such as session cleanup and scheduled tasks, and also create a queue worker.')),
TextInput::make('crontab')
->label(new HtmlString('Run the following command to setup your crontab. Note that <code>www-data</code> is your webserver user. On some systems this username might be different!'))
->disabled()
->hintAction(CopyAction::make())
->default('(crontab -l -u www-data 2>/dev/null; echo "* * * * * php ' . base_path() . '/artisan schedule:run >> /dev/null 2>&1") | crontab -u www-data -'),
TextInput::make('queueService')
->label(new HtmlString('To setup the queue worker service you simply have to run the following command.'))
->disabled()
->hintAction(CopyAction::make())
->default('sudo php ' . base_path() . '/artisan p:environment:queue-service'),
Placeholder::make('')
->content('After you finished these two last tasks you can click on "Finish" and use your new panel! Have fun!'),
]);
}
}

View File

@ -2,91 +2,108 @@
namespace App\Filament\Pages\Installer\Steps; namespace App\Filament\Pages\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use Exception;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step; use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Get; use Filament\Forms\Get;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Support\Exceptions\Halt; use Filament\Support\Exceptions\Halt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use PDOException;
class DatabaseStep class DatabaseStep
{ {
public static function make(): Step public static function make(PanelInstaller $installer): Step
{ {
return Step::make('database') return Step::make('database')
->label('Database') ->label('Database')
->columns() ->columns()
->schema([ ->schema([
TextInput::make('env.DB_DATABASE') TextInput::make('env_database.DB_DATABASE')
->label(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite' ? 'Database Path' : 'Database Name') ->label(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite' ? 'Database Path' : 'Database Name')
->columnSpanFull() ->columnSpanFull()
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite' ? 'The path of your .sqlite file relative to the database folder.' : 'The name of the panel database.') ->hintIconTooltip(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite' ? 'The path of your .sqlite file relative to the database folder.' : 'The name of the panel database.')
->required() ->required()
->default(fn (Get $get) => env('DB_DATABASE', $get('env.DB_CONNECTION') === 'sqlite' ? 'database.sqlite' : 'panel')), ->default(fn (Get $get) => env('DB_DATABASE', $get('env_general.DB_CONNECTION') === 'sqlite' ? 'database.sqlite' : 'panel')),
TextInput::make('env.DB_HOST') TextInput::make('env_database.DB_HOST')
->label('Database Host') ->label('Database Host')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The host of your database. Make sure it is reachable.') ->hintIconTooltip('The host of your database. Make sure it is reachable.')
->required() ->required(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite')
->default(env('DB_HOST', '127.0.0.1')) ->default(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite' ? env('DB_HOST', '127.0.0.1') : null)
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'), ->hidden(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite'),
TextInput::make('env.DB_PORT') TextInput::make('env_database.DB_PORT')
->label('Database Port') ->label('Database Port')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The port of your database.') ->hintIconTooltip('The port of your database.')
->required() ->required(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite')
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->maxValue(65535) ->maxValue(65535)
->default(env('DB_PORT', 3306)) ->default(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite' ? env('DB_PORT', 3306) : null)
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'), ->hidden(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite'),
TextInput::make('env.DB_USERNAME') TextInput::make('env_database.DB_USERNAME')
->label('Database Username') ->label('Database Username')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The name of your database user.') ->hintIconTooltip('The name of your database user.')
->required() ->required(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite')
->default(env('DB_USERNAME', 'pelican')) ->default(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite' ? env('DB_USERNAME', 'pelican') : null)
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'), ->hidden(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite'),
TextInput::make('env.DB_PASSWORD') TextInput::make('env_database.DB_PASSWORD')
->label('Database Password') ->label('Database Password')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The password of your database user. Can be empty.') ->hintIconTooltip('The password of your database user. Can be empty.')
->password() ->password()
->revealable() ->revealable()
->default(env('DB_PASSWORD')) ->default(fn (Get $get) => $get('env_general.DB_CONNECTION') !== 'sqlite' ? env('DB_PASSWORD') : null)
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'), ->hidden(fn (Get $get) => $get('env_general.DB_CONNECTION') === 'sqlite'),
]) ])
->afterValidation(function (Get $get) { ->afterValidation(function (Get $get) use ($installer) {
$driver = $get('env.DB_CONNECTION'); $driver = $get('env_general.DB_CONNECTION');
if ($driver !== 'sqlite') {
try {
config()->set('database.connections._panel_install_test', [
'driver' => $driver,
'host' => $get('env.DB_HOST'),
'port' => $get('env.DB_PORT'),
'database' => $get('env.DB_DATABASE'),
'username' => $get('env.DB_USERNAME'),
'password' => $get('env.DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'strict' => true,
]);
DB::connection('_panel_install_test')->getPdo(); if (!self::testConnection($driver, $get('env_database.DB_HOST'), $get('env_database.DB_PORT'), $get('env_database.DB_DATABASE'), $get('env_database.DB_USERNAME'), $get('env_database.DB_PASSWORD'))) {
} catch (PDOException $exception) { throw new Halt('Database connection failed');
Notification::make()
->title('Database connection failed')
->body($exception->getMessage())
->danger()
->send();
DB::disconnect('_panel_install_test');
throw new Halt('Database connection failed');
}
} }
$installer->writeToEnv('env_database');
$installer->runMigrations($driver);
}); });
} }
private static function testConnection(string $driver, $host, $port, $database, $username, $password): bool
{
if ($driver === 'sqlite') {
return true;
}
try {
config()->set('database.connections._panel_install_test', [
'driver' => $driver,
'host' => $host,
'port' => $port,
'database' => $database,
'username' => $username,
'password' => $password,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'strict' => true,
]);
DB::connection('_panel_install_test')->getPdo();
} catch (Exception $exception) {
DB::disconnect('_panel_install_test');
Notification::make()
->title('Database connection failed')
->body($exception->getMessage())
->danger()
->send();
return false;
}
return true;
}
} }

View File

@ -2,14 +2,17 @@
namespace App\Filament\Pages\Installer\Steps; namespace App\Filament\Pages\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use App\Traits\EnvironmentWriterTrait;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons; use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Components\Wizard\Step; use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Set; use Filament\Forms\Set;
class EnvironmentStep class EnvironmentStep
{ {
use EnvironmentWriterTrait;
public const CACHE_DRIVERS = [ public const CACHE_DRIVERS = [
'file' => 'Filesystem', 'file' => 'Filesystem',
'redis' => 'Redis', 'redis' => 'Redis',
@ -17,15 +20,15 @@ class EnvironmentStep
public const SESSION_DRIVERS = [ public const SESSION_DRIVERS = [
'file' => 'Filesystem', 'file' => 'Filesystem',
'redis' => 'Redis',
'database' => 'Database', 'database' => 'Database',
'cookie' => 'Cookie', 'cookie' => 'Cookie',
'redis' => 'Redis',
]; ];
public const QUEUE_DRIVERS = [ public const QUEUE_DRIVERS = [
'database' => 'Database', 'database' => 'Database',
'sync' => 'Sync',
'redis' => 'Redis', 'redis' => 'Redis',
'sync' => 'Synchronous',
]; ];
public const DATABASE_DRIVERS = [ public const DATABASE_DRIVERS = [
@ -34,30 +37,30 @@ class EnvironmentStep
'mysql' => 'MySQL', 'mysql' => 'MySQL',
]; ];
public static function make(): Step public static function make(PanelInstaller $installer): Step
{ {
return Step::make('environment') return Step::make('environment')
->label('Environment') ->label('Environment')
->columns() ->columns()
->schema([ ->schema([
TextInput::make('env.APP_NAME') TextInput::make('env_general.APP_NAME')
->label('App Name') ->label('App Name')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('This will be the Name of your Panel.') ->hintIconTooltip('This will be the Name of your Panel.')
->required() ->required()
->default(config('app.name')), ->default(config('app.name')),
TextInput::make('env.APP_URL') TextInput::make('env_general.APP_URL')
->label('App URL') ->label('App URL')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('This will be the URL you access your Panel from.') ->hintIconTooltip('This will be the URL you access your Panel from.')
->required() ->required()
->default(config('app.url')) ->default(url(''))
->live() ->live()
->afterStateUpdated(fn ($state, Set $set) => $set('env.SESSION_SECURE_COOKIE', str_starts_with($state, 'https://'))), ->afterStateUpdated(fn ($state, Set $set) => $set('env_general.SESSION_SECURE_COOKIE', str_starts_with($state, 'https://') ? 'true' : 'false')),
Toggle::make('env.SESSION_SECURE_COOKIE') TextInput::make('env_general.SESSION_SECURE_COOKIE')
->hidden() ->hidden()
->default(env('SESSION_SECURE_COOKIE')), ->default(str_starts_with(url(''), 'https://') ? 'true' : 'false'),
ToggleButtons::make('env.CACHE_STORE') ToggleButtons::make('env_general.CACHE_STORE')
->label('Cache Driver') ->label('Cache Driver')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for caching. We recommend "Filesystem".') ->hintIconTooltip('The driver used for caching. We recommend "Filesystem".')
@ -65,7 +68,7 @@ class EnvironmentStep
->inline() ->inline()
->options(self::CACHE_DRIVERS) ->options(self::CACHE_DRIVERS)
->default(config('cache.default', 'file')), ->default(config('cache.default', 'file')),
ToggleButtons::make('env.SESSION_DRIVER') ToggleButtons::make('env_general.SESSION_DRIVER')
->label('Session Driver') ->label('Session Driver')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for storing sessions. We recommend "Filesystem" or "Database".') ->hintIconTooltip('The driver used for storing sessions. We recommend "Filesystem" or "Database".')
@ -73,7 +76,7 @@ class EnvironmentStep
->inline() ->inline()
->options(self::SESSION_DRIVERS) ->options(self::SESSION_DRIVERS)
->default(config('session.driver', 'file')), ->default(config('session.driver', 'file')),
ToggleButtons::make('env.QUEUE_CONNECTION') ToggleButtons::make('env_general.QUEUE_CONNECTION')
->label('Queue Driver') ->label('Queue Driver')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for handling queues. We recommend "Database".') ->hintIconTooltip('The driver used for handling queues. We recommend "Database".')
@ -81,7 +84,7 @@ class EnvironmentStep
->inline() ->inline()
->options(self::QUEUE_DRIVERS) ->options(self::QUEUE_DRIVERS)
->default(config('queue.default', 'database')), ->default(config('queue.default', 'database')),
ToggleButtons::make('env.DB_CONNECTION') ToggleButtons::make('env_general.DB_CONNECTION')
->label('Database Driver') ->label('Database Driver')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for the panel database. We recommend "SQLite".') ->hintIconTooltip('The driver used for the panel database. We recommend "SQLite".')
@ -89,6 +92,7 @@ class EnvironmentStep
->inline() ->inline()
->options(self::DATABASE_DRIVERS) ->options(self::DATABASE_DRIVERS)
->default(config('database.default', 'sqlite')), ->default(config('database.default', 'sqlite')),
]); ])
->afterValidation(fn () => $installer->writeToEnv('env_general'));
} }
} }

View File

@ -2,6 +2,8 @@
namespace App\Filament\Pages\Installer\Steps; namespace App\Filament\Pages\Installer\Steps;
use App\Filament\Pages\Installer\PanelInstaller;
use App\Traits\EnvironmentWriterTrait;
use Exception; use Exception;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step; use Filament\Forms\Components\Wizard\Step;
@ -12,30 +14,32 @@ use Illuminate\Support\Facades\Redis;
class RedisStep class RedisStep
{ {
public static function make(): Step use EnvironmentWriterTrait;
public static function make(PanelInstaller $installer): Step
{ {
return Step::make('redis') return Step::make('redis')
->label('Redis') ->label('Redis')
->columns() ->columns()
->schema([ ->schema([
TextInput::make('env.REDIS_HOST') TextInput::make('env_redis.REDIS_HOST')
->label('Redis Host') ->label('Redis Host')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The host of your redis server. Make sure it is reachable.') ->hintIconTooltip('The host of your redis server. Make sure it is reachable.')
->required() ->required()
->default(config('database.redis.default.host')), ->default(config('database.redis.default.host')),
TextInput::make('env.REDIS_PORT') TextInput::make('env_redis.REDIS_PORT')
->label('Redis Port') ->label('Redis Port')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The port of your redis server.') ->hintIconTooltip('The port of your redis server.')
->required() ->required()
->default(config('database.redis.default.port')), ->default(config('database.redis.default.port')),
TextInput::make('env.REDIS_USERNAME') TextInput::make('env_redis.REDIS_USERNAME')
->label('Redis Username') ->label('Redis Username')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The name of your redis user. Can be empty') ->hintIconTooltip('The name of your redis user. Can be empty')
->default(config('database.redis.default.username')), ->default(config('database.redis.default.username')),
TextInput::make('env.REDIS_PASSWORD') TextInput::make('env_redis.REDIS_PASSWORD')
->label('Redis Password') ->label('Redis Password')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('The password for your redis user. Can be empty.') ->hintIconTooltip('The password for your redis user. Can be empty.')
@ -43,25 +47,36 @@ class RedisStep
->revealable() ->revealable()
->default(config('database.redis.default.password')), ->default(config('database.redis.default.password')),
]) ])
->afterValidation(function (Get $get) { ->afterValidation(function (Get $get) use ($installer) {
try { if (!self::testConnection($get('env_redis.REDIS_HOST'), $get('env_redis.REDIS_PORT'), $get('env_redis.REDIS_USERNAME'), $get('env_redis.REDIS_PASSWORD'))) {
config()->set('database.redis._panel_install_test', [
'host' => $get('env.REDIS_HOST'),
'username' => $get('env.REDIS_USERNAME'),
'password' => $get('env.REDIS_PASSWORD'),
'port' => $get('env.REDIS_PORT'),
]);
Redis::connection('_panel_install_test')->command('ping');
} catch (Exception $exception) {
Notification::make()
->title('Redis connection failed')
->body($exception->getMessage())
->danger()
->send();
throw new Halt('Redis connection failed'); throw new Halt('Redis connection failed');
} }
$installer->writeToEnv('env_redis');
}); });
} }
private static function testConnection($host, $port, $username, $password): bool
{
try {
config()->set('database.redis._panel_install_test', [
'host' => $host,
'port' => $port,
'username' => $username,
'password' => $password,
]);
Redis::connection('_panel_install_test')->command('ping');
} catch (Exception $exception) {
Notification::make()
->title('Redis connection failed')
->body($exception->getMessage())
->danger()
->send();
return false;
}
return true;
}
} }

View File

@ -10,18 +10,20 @@ use Filament\Support\Exceptions\Halt;
class RequirementsStep class RequirementsStep
{ {
public const MIN_PHP_VERSION = '8.2';
public static function make(): Step public static function make(): Step
{ {
$correctPhpVersion = version_compare(PHP_VERSION, '8.2.0') >= 0; $correctPhpVersion = version_compare(PHP_VERSION, self::MIN_PHP_VERSION) >= 0;
$fields = [ $fields = [
Section::make('PHP Version') Section::make('PHP Version')
->description('8.2 or newer') ->description(self::MIN_PHP_VERSION . ' or newer')
->icon($correctPhpVersion ? 'tabler-check' : 'tabler-x') ->icon($correctPhpVersion ? 'tabler-check' : 'tabler-x')
->iconColor($correctPhpVersion ? 'success' : 'danger') ->iconColor($correctPhpVersion ? 'success' : 'danger')
->schema([ ->schema([
Placeholder::make('') Placeholder::make('')
->content('Your PHP Version ' . ($correctPhpVersion ? 'is' : 'needs to be') .' 8.2 or newer.'), ->content('Your PHP Version is ' . PHP_VERSION . '.'),
]), ]),
]; ];
@ -80,7 +82,7 @@ class RequirementsStep
->danger() ->danger()
->send(); ->send();
throw new Halt(); throw new Halt('Some requirements are missing');
} }
}); });
} }

View File

@ -49,12 +49,18 @@ class Settings extends Page implements HasForms
$this->form->fill(); $this->form->fill();
} }
public static function canAccess(): bool
{
return auth()->user()->can('view settings');
}
protected function getFormSchema(): array protected function getFormSchema(): array
{ {
return [ return [
Tabs::make('Tabs') Tabs::make('Tabs')
->columns() ->columns()
->persistTabInQueryString() ->persistTabInQueryString()
->disabled(fn () => !auth()->user()->can('update settings'))
->tabs([ ->tabs([
Tab::make('general') Tab::make('general')
->label('General') ->label('General')
@ -86,7 +92,6 @@ class Settings extends Page implements HasForms
TextInput::make('APP_NAME') TextInput::make('APP_NAME')
->label('App Name') ->label('App Name')
->required() ->required()
->alphaNum()
->default(env('APP_NAME', 'Pelican')), ->default(env('APP_NAME', 'Pelican')),
TextInput::make('APP_FAVICON') TextInput::make('APP_FAVICON')
->label('App Favicon') ->label('App Favicon')
@ -147,10 +152,12 @@ class Settings extends Page implements HasForms
->color('danger') ->color('danger')
->icon('tabler-trash') ->icon('tabler-trash')
->requiresConfirmation() ->requiresConfirmation()
->authorize(fn () => auth()->user()->can('update settings'))
->action(fn (Set $set) => $set('TRUSTED_PROXIES', [])), ->action(fn (Set $set) => $set('TRUSTED_PROXIES', [])),
FormAction::make('cloudflare') FormAction::make('cloudflare')
->label('Set to Cloudflare IPs') ->label('Set to Cloudflare IPs')
->icon('tabler-brand-cloudflare') ->icon('tabler-brand-cloudflare')
->authorize(fn () => auth()->user()->can('update settings'))
->action(fn (Set $set) => $set('TRUSTED_PROXIES', [ ->action(fn (Set $set) => $set('TRUSTED_PROXIES', [
'173.245.48.0/20', '173.245.48.0/20',
'103.21.244.0/22', '103.21.244.0/22',
@ -226,6 +233,7 @@ class Settings extends Page implements HasForms
->label('Send Test Mail') ->label('Send Test Mail')
->icon('tabler-send') ->icon('tabler-send')
->hidden(fn (Get $get) => $get('MAIL_MAILER') === 'log') ->hidden(fn (Get $get) => $get('MAIL_MAILER') === 'log')
->authorize(fn () => auth()->user()->can('update settings'))
->action(function () { ->action(function () {
try { try {
MailNotification::route('mail', auth()->user()->email) MailNotification::route('mail', auth()->user()->email)
@ -513,6 +521,25 @@ class Settings extends Page implements HasForms
->suffix('Requests Per Minute') ->suffix('Requests Per Minute')
->default(env('APP_API_APPLICATION_RATELIMIT', config('http.rate_limit.application'))), ->default(env('APP_API_APPLICATION_RATELIMIT', config('http.rate_limit.application'))),
]), ]),
Section::make('Server')
->description('Settings for Servers.')
->columns()
->collapsible()
->collapsed()
->schema([
Toggle::make('PANEL_EDITABLE_SERVER_DESCRIPTIONS')
->label('Allow Users to edit Server Descriptions?')
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->live()
->columnSpanFull()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_EDITABLE_SERVER_DESCRIPTIONS', (bool) $state))
->default(env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', config('panel.editable_server_descriptions'))),
]),
]; ];
} }
@ -561,12 +588,9 @@ class Settings extends Page implements HasForms
return [ return [
Action::make('save') Action::make('save')
->action('save') ->action('save')
->authorize(fn () => auth()->user()->can('update settings'))
->keyBindings(['mod+s']), ->keyBindings(['mod+s']),
]; ];
} }
protected function getFormActions(): array
{
return [];
}
} }

View File

@ -23,13 +23,6 @@ class ApiKeyResource extends Resource
return false; return false;
} }
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [

View File

@ -6,6 +6,7 @@ use App\Filament\Resources\ApiKeyResource;
use App\Models\ApiKey; use App\Models\ApiKey;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@ -51,13 +52,23 @@ class ListApiKeys extends ListRecords
]) ])
->actions([ ->actions([
DeleteAction::make(), DeleteAction::make(),
])
->emptyStateIcon('tabler-key')
->emptyStateDescription('')
->emptyStateHeading('No API Keys')
->emptyStateActions([
CreateAction::make('create')
->label('Create API Key')
->button(),
]); ]);
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->label('Create API Key')
->hidden(fn () => ApiKey::where('key_type', ApiKey::TYPE_APPLICATION)->count() <= 0),
]; ];
} }
} }

View File

@ -10,7 +10,7 @@ class DatabaseHostResource extends Resource
{ {
protected static ?string $model = DatabaseHost::class; protected static ?string $model = DatabaseHost::class;
protected static ?string $label = 'Databases'; protected static ?string $label = 'Database Host';
protected static ?string $navigationIcon = 'tabler-database'; protected static ?string $navigationIcon = 'tabler-database';
protected static ?string $navigationGroup = 'Advanced'; protected static ?string $navigationGroup = 'Advanced';
@ -20,13 +20,6 @@ class DatabaseHostResource extends Resource
return static::getModel()::count() ?: null; return static::getModel()::count() ?: null;
} }
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [

View File

@ -5,13 +5,13 @@ namespace App\Filament\Resources\DatabaseHostResource\Pages;
use App\Filament\Resources\DatabaseHostResource; use App\Filament\Resources\DatabaseHostResource;
use App\Models\Objects\Endpoint; use App\Models\Objects\Endpoint;
use App\Services\Databases\Hosts\HostCreationService; use App\Services\Databases\Hosts\HostCreationService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Pages\CreateRecord;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use PDOException; use PDOException;

View File

@ -8,13 +8,13 @@ use App\Models\DatabaseHost;
use App\Models\Objects\Endpoint; use App\Models\Objects\Endpoint;
use App\Services\Databases\Hosts\HostUpdateService; use App\Services\Databases\Hosts\HostUpdateService;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Pages\EditRecord;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use PDOException; use PDOException;

View File

@ -3,9 +3,11 @@
namespace App\Filament\Resources\DatabaseHostResource\Pages; namespace App\Filament\Resources\DatabaseHostResource\Pages;
use App\Filament\Resources\DatabaseHostResource; use App\Filament\Resources\DatabaseHostResource;
use App\Models\DatabaseHost;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction; use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
@ -42,15 +44,26 @@ class ListDatabaseHosts extends ListRecords
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete databasehost')),
]), ]),
])
->emptyStateIcon('tabler-database')
->emptyStateDescription('')
->emptyStateHeading('No Database Hosts')
->emptyStateActions([
CreateAction::make('create')
->label('Create Database Host')
->button(),
]); ]);
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make('create')->label('New Database Host'), Actions\CreateAction::make('create')
->label('Create Database Host')
->hidden(fn () => DatabaseHost::count() <= 0),
]; ];
} }
} }

View File

@ -20,13 +20,6 @@ class DatabaseResource extends Resource
return static::getModel()::count() ?: null; return static::getModel()::count() ?: null;
} }
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [

View File

@ -4,10 +4,10 @@ namespace App\Filament\Resources\DatabaseResource\Pages;
use App\Filament\Resources\DatabaseResource; use App\Filament\Resources\DatabaseResource;
use Filament\Actions; use Filament\Actions;
use Filament\Tables\Actions\EditAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@ -48,7 +48,8 @@ class ListDatabases extends ListRecords
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete database')),
]), ]),
]); ]);
} }

View File

@ -21,13 +21,6 @@ class EggResource extends Resource
return static::getModel()::count() ?: null; return static::getModel()::count() ?: null;
} }
public static function getRelations(): array
{
return [
//
];
}
public static function getGloballySearchableAttributes(): array public static function getGloballySearchableAttributes(): array
{ {
return ['name', 'tags', 'uuid', 'id']; return ['name', 'tags', 'uuid', 'id'];

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\EggResource\Pages; namespace App\Filament\Resources\EggResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Filament\Resources\EggResource; use App\Filament\Resources\EggResource;
use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\Fieldset;
@ -15,10 +16,9 @@ use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Resources\Pages\CreateRecord;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use Filament\Forms;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -155,7 +155,7 @@ class CreateEgg extends CreateRecord
->debounce(750) ->debounce(750)
->maxLength(255) ->maxLength(255)
->columnSpanFull() ->columnSpanFull()
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString()) ->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())
) )
->required(), ->required(),
Textarea::make('description')->columnSpanFull(), Textarea::make('description')->columnSpanFull(),

View File

@ -2,9 +2,11 @@
namespace App\Filament\Resources\EggResource\Pages; namespace App\Filament\Resources\EggResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Filament\Resources\EggResource; use App\Filament\Resources\EggResource;
use App\Filament\Resources\EggResource\RelationManagers\ServersRelationManager; use App\Filament\Resources\EggResource\RelationManagers\ServersRelationManager;
use App\Models\Egg; use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService; use App\Services\Eggs\Sharing\EggImporterService;
use Exception; use Exception;
use Filament\Actions; use Filament\Actions;
@ -22,12 +24,10 @@ use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Services\Eggs\Sharing\EggExporterService;
use Filament\Forms;
use Filament\Forms\Form;
class EditEgg extends EditRecord class EditEgg extends EditRecord
{ {
@ -40,6 +40,7 @@ class EditEgg extends EditRecord
Tabs::make()->tabs([ Tabs::make()->tabs([
Tab::make('Configuration') Tab::make('Configuration')
->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4]) ->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4])
->icon('tabler-egg')
->schema([ ->schema([
TextInput::make('name') TextInput::make('name')
->required() ->required()
@ -80,6 +81,7 @@ class EditEgg extends EditRecord
->helperText('') ->helperText('')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]), ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
Toggle::make('force_outgoing_ip') Toggle::make('force_outgoing_ip')
->inline(false)
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip("Forces all outgoing network traffic to have its Source IP NATed to the IP of the server's endpoint. ->hintIconTooltip("Forces all outgoing network traffic to have its Source IP NATed to the IP of the server's endpoint.
Required for certain games to work properly when the Node has multiple public IP addresses. Required for certain games to work properly when the Node has multiple public IP addresses.
@ -105,9 +107,9 @@ class EditEgg extends EditRecord
->valueLabel('Image URI') ->valueLabel('Image URI')
->helperText('The docker images available to servers using this egg.'), ->helperText('The docker images available to servers using this egg.'),
]), ]),
Tab::make('Process Management') Tab::make('Process Management')
->columns() ->columns()
->icon('tabler-server-cog')
->schema([ ->schema([
Select::make('config_from') Select::make('config_from')
->label('Copy Settings From') ->label('Copy Settings From')
@ -130,6 +132,7 @@ class EditEgg extends EditRecord
]), ]),
Tab::make('Egg Variables') Tab::make('Egg Variables')
->columnSpanFull() ->columnSpanFull()
->icon('tabler-variable')
->schema([ ->schema([
Repeater::make('variables') Repeater::make('variables')
->label('') ->label('')
@ -165,7 +168,7 @@ class EditEgg extends EditRecord
->debounce(750) ->debounce(750)
->maxLength(255) ->maxLength(255)
->columnSpanFull() ->columnSpanFull()
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString()) ->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())
) )
->required(), ->required(),
Textarea::make('description')->columnSpanFull(), Textarea::make('description')->columnSpanFull(),
@ -211,22 +214,19 @@ class EditEgg extends EditRecord
]), ]),
Tab::make('Install Script') Tab::make('Install Script')
->columns(3) ->columns(3)
->icon('tabler-file-download')
->schema([ ->schema([
Select::make('copy_script_from') Select::make('copy_script_from')
->placeholder('None') ->placeholder('None')
->relationship('scriptFrom', 'name', ignoreRecord: true), ->relationship('scriptFrom', 'name', ignoreRecord: true),
TextInput::make('script_container') TextInput::make('script_container')
->required() ->required()
->maxLength(255) ->maxLength(255)
->default('alpine:3.4'), ->default('alpine:3.4'),
TextInput::make('script_entry') TextInput::make('script_entry')
->required() ->required()
->maxLength(255) ->maxLength(255)
->default('ash'), ->default('ash'),
MonacoEditor::make('script_install') MonacoEditor::make('script_install')
->label('Install Script') ->label('Install Script')
->columnSpanFull() ->columnSpanFull()
@ -234,7 +234,6 @@ class EditEgg extends EditRecord
->language('shell') ->language('shell')
->view('filament.plugins.monaco-editor'), ->view('filament.plugins.monaco-editor'),
]), ]),
])->columnSpanFull()->persistTabInQueryString(), ])->columnSpanFull()->persistTabInQueryString(),
]); ]);
} }
@ -245,14 +244,13 @@ class EditEgg extends EditRecord
Actions\DeleteAction::make('deleteEgg') Actions\DeleteAction::make('deleteEgg')
->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0) ->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0)
->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? 'Delete' : 'In Use'), ->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? 'Delete' : 'In Use'),
Actions\Action::make('exportEgg') Actions\Action::make('exportEgg')
->label('Export') ->label('Export')
->color('primary') ->color('primary')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) { ->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id); echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')), }, 'egg-' . $egg->getKebabName() . '.json'))
->authorize(fn () => auth()->user()->can('export egg')),
Actions\Action::make('importEgg') Actions\Action::make('importEgg')
->label('Import') ->label('Import')
->form([ ->form([
@ -321,8 +319,8 @@ class EditEgg extends EditRecord
->title('Import Success') ->title('Import Success')
->success() ->success()
->send(); ->send();
}), })
->authorize(fn () => auth()->user()->can('import egg')),
$this->getSaveFormAction()->formId('form'), $this->getSaveFormAction()->formId('form'),
]; ];
} }

View File

@ -14,13 +14,13 @@ use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction; use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Filament\Tables;
class ListEggs extends ListRecords class ListEggs extends ListRecords
{ {
@ -49,17 +49,51 @@ class ListEggs extends ListRecords
]) ])
->actions([ ->actions([
EditAction::make(), EditAction::make(),
Tables\Actions\Action::make('export') Action::make('export')
->icon('tabler-download') ->icon('tabler-download')
->label('Export') ->label('Export')
->color('primary') ->color('primary')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) { ->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id); echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')), }, 'egg-' . $egg->getKebabName() . '.json'))
->authorize(fn () => auth()->user()->can('export egg')),
Action::make('update')
->icon('tabler-cloud-download')
->label('Update')
->color('success')
->requiresConfirmation()
->modalHeading('Are you sure you want to update this egg?')
->modalDescription('If you made any changes to the egg they will be overwritten!')
->modalIconColor('danger')
->modalSubmitAction(fn (Actions\StaticAction $action) => $action->color('danger'))
->action(function (Egg $egg) {
try {
app(EggImporterService::class)->fromUrl($egg->update_url, $egg);
cache()->forget("eggs.{$egg->uuid}.update");
} catch (Exception $exception) {
Notification::make()
->title('Egg Update failed')
->body($exception->getMessage())
->danger()
->send();
report($exception);
return;
}
Notification::make()
->title('Egg updated')
->success()
->send();
})
->authorize(fn () => auth()->user()->can('import egg'))
->visible(fn (Egg $egg) => cache()->get("eggs.{$egg->uuid}.update", false)),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete egg')),
]), ]),
]); ]);
} }
@ -138,7 +172,8 @@ class ListEggs extends ListRecords
->title('Import Success') ->title('Import Success')
->success() ->success()
->send(); ->send();
}), })
->authorize(fn () => auth()->user()->can('import egg')),
]; ];
} }
} }

View File

@ -15,7 +15,7 @@ class ServersRelationManager extends RelationManager
{ {
return $table return $table
->recordTitleAttribute('servers') ->recordTitleAttribute('servers')
->emptyStateDescription('No Servers')->emptyStateHeading('No servers are assigned this egg.') ->emptyStateDescription('No Servers')->emptyStateHeading('No servers are assigned to this Egg.')
->searchable(false) ->searchable(false)
->columns([ ->columns([
TextColumn::make('user.username') TextColumn::make('user.username')

View File

@ -18,13 +18,6 @@ class MountResource extends Resource
return static::getModel()::count() ?: null; return static::getModel()::count() ?: null;
} }
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [

View File

@ -4,14 +4,14 @@ namespace App\Filament\Resources\MountResource\Pages;
use App\Filament\Resources\MountResource; use App\Filament\Resources\MountResource;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Resources\Pages\EditRecord;
use Filament\Forms\Components\Group; use Filament\Forms\Components\Group;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Resources\Pages\EditRecord;
class EditMount extends EditRecord class EditMount extends EditRecord
{ {

View File

@ -43,7 +43,8 @@ class ListMounts extends ListRecords
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete mount')),
]), ]),
]) ])
->emptyStateIcon('tabler-layers-linked') ->emptyStateIcon('tabler-layers-linked')

View File

@ -4,8 +4,8 @@ namespace App\Filament\Resources\NodeResource\Pages;
use App\Filament\Resources\NodeResource; use App\Filament\Resources\NodeResource;
use App\Models\Objects\Endpoint; use App\Models\Objects\Endpoint;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Grid; use Filament\Forms\Components\Grid;
use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@ -154,7 +154,6 @@ class CreateNode extends CreateRecord
'lg' => 2, 'lg' => 2,
]) ])
->required() ->required()
->regex('/[a-zA-Z0-9_\.\- ]+/')
->helperText('This name is for display only and can be changed later.') ->helperText('This name is for display only and can be changed later.')
->maxLength(100), ->maxLength(100),
@ -221,7 +220,7 @@ class CreateNode extends CreateRecord
ToggleButtons::make('public') ToggleButtons::make('public')
->default(true) ->default(true)
->columnSpan(1) ->columnSpan(1)
->label('Automatic Allocation')->inline() ->label('Use Node for deployment?')->inline()
->options([ ->options([
true => 'Yes', true => 'Yes',
false => 'No', false => 'No',
@ -231,11 +230,7 @@ class CreateNode extends CreateRecord
false => 'danger', false => 'danger',
]), ]),
TagsInput::make('tags') TagsInput::make('tags')
->label('Tags') ->placeholder('Add Tags')
->disabled()
->placeholder('Not Implemented')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Not Implemented')
->columnSpan(2), ->columnSpan(2),
TextInput::make('upload_size') TextInput::make('upload_size')
->label('Upload Limit') ->label('Upload Limit')

View File

@ -84,7 +84,7 @@ class EditNode extends EditRecord
if (request()->isSecure()) { if (request()->isSecure()) {
return ' return '
Your panel is currently secured via an SSL certificate and that means your nodes require one too. Your panel is currently secured via an SSL certificate and that means your nodes require one too.
You must use a domain name, because you cannot get SSL certificates for IP Addresses You must use a domain name, because you cannot get SSL certificates for IP Addresses.
'; ';
} }
@ -99,7 +99,7 @@ class EditNode extends EditRecord
->hintColor('danger') ->hintColor('danger')
->hint(function ($state) { ->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) { if (is_ip($state) && request()->isSecure()) {
return 'You cannot connect to an IP Address over SSL'; return 'You cannot connect to an IP Address over SSL!';
} }
return ''; return '';
@ -131,11 +131,9 @@ class EditNode extends EditRecord
$set('dns', false); $set('dns', false);
}) })
->maxLength(255), ->maxLength(255),
TextInput::make('ip') TextInput::make('ip')
->disabled() ->disabled()
->hidden(), ->hidden(),
ToggleButtons::make('dns') ToggleButtons::make('dns')
->label('DNS Record Check') ->label('DNS Record Check')
->helperText('This lets you know if your DNS record correctly points to an IP Address.') ->helperText('This lets you know if your DNS record correctly points to an IP Address.')
@ -158,7 +156,6 @@ class EditNode extends EditRecord
'md' => 1, 'md' => 1,
'lg' => 1, 'lg' => 1,
]), ]),
TextInput::make('daemon_listen') TextInput::make('daemon_listen')
->columnSpan([ ->columnSpan([
'default' => 1, 'default' => 1,
@ -173,7 +170,6 @@ class EditNode extends EditRecord
->default(8080) ->default(8080)
->required() ->required()
->integer(), ->integer(),
TextInput::make('name') TextInput::make('name')
->label('Display Name') ->label('Display Name')
->columnSpan([ ->columnSpan([
@ -183,10 +179,8 @@ class EditNode extends EditRecord
'lg' => 2, 'lg' => 2,
]) ])
->required() ->required()
->regex('/[a-zA-Z0-9_\.\- ]+/')
->helperText('This name is for display only and can be changed later.') ->helperText('This name is for display only and can be changed later.')
->maxLength(100), ->maxLength(100),
ToggleButtons::make('scheme') ToggleButtons::make('scheme')
->label('Communicate over SSL') ->label('Communicate over SSL')
->columnSpan([ ->columnSpan([
@ -236,11 +230,7 @@ class EditNode extends EditRecord
->disabled(), ->disabled(),
TagsInput::make('tags') TagsInput::make('tags')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->label('Tags') ->placeholder('Add Tags'),
->disabled()
->placeholder('Not Implemented')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Not Implemented'),
TextInput::make('upload_size') TextInput::make('upload_size')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1]) ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 1])
->label('Upload Limit') ->label('Upload Limit')
@ -264,7 +254,7 @@ class EditNode extends EditRecord
->helperText('Display alias for the SFTP address. Leave empty to use the Node FQDN.'), ->helperText('Display alias for the SFTP address. Leave empty to use the Node FQDN.'),
ToggleButtons::make('public') ToggleButtons::make('public')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3]) ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3])
->label('Automatic Allocation')->inline() ->label('Use Node for deployment?')->inline()
->options([ ->options([
true => 'Yes', true => 'Yes',
false => 'No', false => 'No',
@ -447,6 +437,16 @@ class EditNode extends EditRecord
$data['config'] = $node->getYamlConfiguration(); $data['config'] = $node->getYamlConfiguration();
if (!is_ip($node->fqdn)) {
$validRecords = gethostbynamel($node->fqdn);
if ($validRecords) {
$data['dns'] = true;
$data['ip'] = collect($validRecords)->first();
} else {
$data['dns'] = false;
}
}
return $data; return $data;
} }
@ -454,6 +454,7 @@ class EditNode extends EditRecord
{ {
return []; return [];
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
@ -473,6 +474,7 @@ class EditNode extends EditRecord
{ {
return null; return null;
} }
protected function getColumnStart() protected function getColumnStart()
{ {
return null; return null;

View File

@ -13,6 +13,7 @@ use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Number;
class ListNodes extends ListRecords class ListNodes extends ListRecords
{ {
@ -47,18 +48,18 @@ class ListNodes extends ListRecords
->icon('tabler-device-desktop-analytics') ->icon('tabler-device-desktop-analytics')
->numeric() ->numeric()
->suffix(config('panel.use_binary_prefix') ? ' GiB' : ' GB') ->suffix(config('panel.use_binary_prefix') ? ' GiB' : ' GB')
->formatStateUsing(fn ($state) => number_format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), 2)) ->formatStateUsing(fn ($state) => Number::format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), maxPrecision: 2, locale: auth()->user()->language))
->sortable(), ->sortable(),
TextColumn::make('disk') TextColumn::make('disk')
->visibleFrom('sm') ->visibleFrom('sm')
->icon('tabler-file') ->icon('tabler-file')
->numeric() ->numeric()
->suffix(config('panel.use_binary_prefix') ? ' GiB' : ' GB') ->suffix(config('panel.use_binary_prefix') ? ' GiB' : ' GB')
->formatStateUsing(fn ($state) => number_format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), 2)) ->formatStateUsing(fn ($state) => Number::format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), maxPrecision: 2, locale: auth()->user()->language))
->sortable(), ->sortable(),
TextColumn::make('cpu') TextColumn::make('cpu')
->visibleFrom('sm') ->visibleFrom('sm')
->icon('tabler-file') ->icon('tabler-cpu')
->numeric() ->numeric()
->suffix(' %') ->suffix(' %')
->sortable(), ->sortable(),
@ -84,7 +85,8 @@ class ListNodes extends ListRecords
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete node')),
]), ]),
]) ])
->emptyStateIcon('tabler-server-2') ->emptyStateIcon('tabler-server-2')

View File

@ -0,0 +1,160 @@
<?php
namespace App\Filament\Resources\NodeResource\RelationManagers;
use App\Models\Allocation;
use App\Models\Node;
use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
/**
* @method Node getOwnerRecord()
*/
class AllocationsRelationManager extends RelationManager
{
protected static string $relationship = 'allocations';
protected static ?string $icon = 'tabler-plug-connected';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('ip')
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('ip')
// Non Primary Allocations
// ->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->id !== $allocation->server?->allocation_id)
// All assigned allocations
->checkIfRecordIsSelectableUsing(fn (Allocation $allocation) => $allocation->server_id === null)
->searchable()
->columns([
TextColumn::make('id'),
TextColumn::make('port')
->searchable()
->label('Port'),
TextColumn::make('server.name')
->label('Server')
->icon('tabler-brand-docker')
->searchable()
->url(fn (Allocation $allocation): string => $allocation->server ? route('filament.admin.resources.servers.edit', ['record' => $allocation->server]) : ''),
TextInputColumn::make('ip_alias')
->searchable()
->label('Alias'),
TextInputColumn::make('ip')
->searchable()
->label('IP'),
])
->filters([
//
])
->actions([
//
])
->headerActions([
Tables\Actions\Action::make('create new allocation')->label('Create Allocations')
->form(fn () => [
TextInput::make('allocation_ip')
->datalist($this->getOwnerRecord()->ipAddresses())
->label('IP Address')
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->inlineLabel()
->default(null)
->helperText('Optional display name to help you remember what these are.')
->required(false),
TagsInput::make('allocation_ports')
->placeholder('Examples: 27015, 27017-27019')
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would have to port forward these on your home network.
'))
->label('Ports')
->inlineLabel()
->live()
->afterStateUpdated(function ($state, Set $set) {
$ports = collect();
$update = false;
foreach ($state as $portEntry) {
if (!str_contains($portEntry, '-')) {
if (is_numeric($portEntry)) {
$ports->push((int) $portEntry);
continue;
}
// Do not add non numerical ports
$update = true;
continue;
}
$update = true;
[$start, $end] = explode('-', $portEntry);
if (!is_numeric($start) || !is_numeric($end)) {
continue;
}
$start = max((int) $start, 0);
$end = min((int) $end, 2 ** 16 - 1);
foreach (range($start, $end) as $i) {
$ports->push($i);
}
}
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$update = true;
$ports = $uniquePorts;
}
$sortedPorts = $ports->sort()->values();
if ($sortedPorts->all() !== $ports->all()) {
$update = true;
$ports = $sortedPorts;
}
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
if ($update) {
$set('allocation_ports', $ports->all());
}
})
->splitKeys(['Tab', ' ', ','])
->required(),
])
->action(fn (array $data) => resolve(AssignmentService::class)->handle($this->getOwnerRecord(), $data)),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete allocation')),
]),
]);
}
}

View File

@ -3,9 +3,9 @@
namespace App\Filament\Resources\NodeResource\RelationManagers; namespace App\Filament\Resources\NodeResource\RelationManagers;
use App\Models\Server; use App\Models\Server;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Filament\Resources\RelationManagers\RelationManager;
class NodesRelationManager extends RelationManager class NodesRelationManager extends RelationManager
{ {

View File

@ -7,6 +7,7 @@ use Carbon\Carbon;
use Filament\Support\RawJs; use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget; use Filament\Widgets\ChartWidget;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Number;
class NodeCpuChart extends ChartWidget class NodeCpuChart extends ChartWidget
{ {
@ -24,7 +25,7 @@ class NodeCpuChart extends ChartWidget
$cpu = collect(cache()->get("nodes.$node->id.cpu_percent")) $cpu = collect(cache()->get("nodes.$node->id.cpu_percent"))
->slice(-10) ->slice(-10)
->map(fn ($value, $key) => [ ->map(fn ($value, $key) => [
'cpu' => number_format($value * $threads, 2), 'cpu' => Number::format($value * $threads, maxPrecision: 2, locale: auth()->user()->language),
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'), 'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
]) ])
->all(); ->all();
@ -73,8 +74,8 @@ class NodeCpuChart extends ChartWidget
$node = $this->record; $node = $this->record;
$threads = $node->systemInformation()['cpu_count'] ?? 0; $threads = $node->systemInformation()['cpu_count'] ?? 0;
$cpu = number_format(collect(cache()->get("nodes.$node->id.cpu_percent"))->last() * $threads, 2); $cpu = Number::format(collect(cache()->get("nodes.$node->id.cpu_percent"))->last() * $threads, maxPrecision: 2, locale: auth()->user()->language);
$max = number_format($threads * 100) . '%'; $max = Number::format($threads * 100, locale: auth()->user()->language) . '%';
return 'CPU - ' . $cpu . '% Of ' . $max; return 'CPU - ' . $cpu . '% Of ' . $max;
} }

View File

@ -7,6 +7,7 @@ use Carbon\Carbon;
use Filament\Support\RawJs; use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget; use Filament\Widgets\ChartWidget;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Number;
class NodeMemoryChart extends ChartWidget class NodeMemoryChart extends ChartWidget
{ {
@ -22,7 +23,7 @@ class NodeMemoryChart extends ChartWidget
$memUsed = collect(cache()->get("nodes.$node->id.memory_used"))->slice(-10) $memUsed = collect(cache()->get("nodes.$node->id.memory_used"))->slice(-10)
->map(fn ($value, $key) => [ ->map(fn ($value, $key) => [
'memory' => config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, 'memory' => Number::format(config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000, maxPrecision: 2, locale: auth()->user()->language),
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'), 'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
]) ])
->all(); ->all();
@ -73,12 +74,12 @@ class NodeMemoryChart extends ChartWidget
$totalMemory = collect(cache()->get("nodes.$node->id.memory_total"))->last(); $totalMemory = collect(cache()->get("nodes.$node->id.memory_total"))->last();
$used = config('panel.use_binary_prefix') $used = config('panel.use_binary_prefix')
? number_format($latestMemoryUsed / 1024 / 1024 / 1024, 2) .' GiB' ? Number::format($latestMemoryUsed / 1024 / 1024 / 1024, maxPrecision: 2, locale: auth()->user()->language) .' GiB'
: number_format($latestMemoryUsed / 1000 / 1000 / 1000, 2) . ' GB'; : Number::format($latestMemoryUsed / 1000 / 1000 / 1000, maxPrecision: 2, locale: auth()->user()->language) . ' GB';
$total = config('panel.use_binary_prefix') $total = config('panel.use_binary_prefix')
? number_format($totalMemory / 1024 / 1024 / 1024, 2) .' GiB' ? Number::format($totalMemory / 1024 / 1024 / 1024, maxPrecision: 2, locale: auth()->user()->language) .' GiB'
: number_format($totalMemory / 1000 / 1000 / 1000, 2) . ' GB'; : Number::format($totalMemory / 1000 / 1000 / 1000, maxPrecision: 2, locale: auth()->user()->language) . ' GB';
return 'Memory - ' . $used . ' Of ' . $total; return 'Memory - ' . $used . ' Of ' . $total;
} }

View File

@ -0,0 +1,146 @@
<?php
namespace App\Filament\Resources;
use App\Enums\RolePermissionModels;
use App\Enums\RolePermissionPrefixes;
use App\Filament\Resources\RoleResource\Pages;
use App\Models\Role;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Illuminate\Support\Str;
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static ?string $navigationIcon = 'tabler-users-group';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function form(Form $form): Form
{
$permissions = [];
foreach (RolePermissionModels::cases() as $model) {
$options = [];
foreach (RolePermissionPrefixes::cases() as $prefix) {
$options[$prefix->value . ' ' . strtolower($model->value)] = Str::headline($prefix->value);
}
if (array_key_exists($model->value, Role::MODEL_SPECIFIC_PERMISSIONS)) {
foreach (Role::MODEL_SPECIFIC_PERMISSIONS[$model->value] as $permission) {
$options[$permission . ' ' . strtolower($model->value)] = Str::headline($permission);
}
}
$permissions[] = self::makeSection($model->value, $options);
}
foreach (Role::SPECIAL_PERMISSIONS as $model => $prefixes) {
$options = [];
foreach ($prefixes as $prefix) {
$options[$prefix . ' ' . strtolower($model)] = Str::headline($prefix);
}
$permissions[] = self::makeSection($model, $options);
}
return $form
->columns(1)
->schema([
TextInput::make('name')
->label('Role Name')
->required()
->disabled(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
TextInput::make('guard_name')
->label('Guard Name')
->default(Filament::getCurrentPanel()?->getAuthGuard() ?? '')
->nullable()
->hidden(),
Fieldset::make('Permissions')
->columns(3)
->schema($permissions)
->hidden(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
Placeholder::make('permissions')
->content('The Root Admin has all permissions.')
->visible(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
]);
}
private static function makeSection(string $model, array $options): Section
{
$icon = null;
if (class_exists('\App\Filament\Resources\\' . $model . 'Resource')) {
$icon = ('\App\Filament\Resources\\' . $model . 'Resource')::getNavigationIcon();
} elseif (class_exists('\App\Filament\Pages\\' . $model)) {
$icon = ('\App\Filament\Pages\\' . $model)::getNavigationIcon();
}
return Section::make(Str::headline(Str::plural($model)))
->columnSpan(1)
->collapsible()
->collapsed()
->icon($icon)
->headerActions([
Action::make('count')
->label(fn (Get $get) => count($get(strtolower($model) . '_list')))
->badge(),
])
->schema([
CheckboxList::make(strtolower($model) . '_list')
->label('')
->options($options)
->columns()
->gridDirection('row')
->bulkToggleable()
->live()
->afterStateHydrated(
function (Component $component, string $operation, ?Role $record) use ($options) {
if (in_array($operation, ['edit', 'view'])) {
if (blank($record)) {
return;
}
if ($component->isVisible()) {
$component->state(
collect($options)
->filter(fn ($value, $key) => $record->checkPermissionTo($key))
->keys()
->toArray()
);
}
}
}
)
->dehydrated(fn ($state) => !blank($state)),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListRoles::route('/'),
'create' => Pages\CreateRole::route('/create'),
'edit' => Pages\EditRole::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Permission;
/**
* @property Role $record
*/
class CreateRole extends CreateRecord
{
protected static string $resource = RoleResource::class;
protected static bool $canCreateAnother = false;
public Collection $permissions;
protected function mutateFormDataBeforeCreate(array $data): array
{
$this->permissions = collect($data)
->filter(function ($permission, $key) {
return !in_array($key, ['name', 'guard_name']);
})
->values()
->flatten()
->unique();
return Arr::only($data, ['name', 'guard_name']);
}
protected function afterCreate(): void
{
$permissionModels = collect();
$this->permissions->each(function ($permission) use ($permissionModels) {
$permissionModels->push(Permission::firstOrCreate([
'name' => $permission,
'guard_name' => $this->data['guard_name'],
]));
});
$this->record->syncPermissions($permissionModels);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Permission;
/**
* @property Role $record
*/
class EditRole extends EditRecord
{
protected static string $resource = RoleResource::class;
public Collection $permissions;
protected function mutateFormDataBeforeSave(array $data): array
{
$this->permissions = collect($data)
->filter(function ($permission, $key) {
return !in_array($key, ['name', 'guard_name']);
})
->values()
->flatten()
->unique();
return Arr::only($data, ['name', 'guard_name']);
}
protected function afterSave(): void
{
$permissionModels = collect();
$this->permissions->each(function ($permission) use ($permissionModels) {
$permissionModels->push(Permission::firstOrCreate([
'name' => $permission,
'guard_name' => $this->data['guard_name'],
]));
});
$this->record->syncPermissions($permissionModels);
}
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
->disabled(fn (Role $role) => $role->isRootAdmin() || $role->users_count >= 1)
->label(fn (Role $role) => $role->isRootAdmin() ? 'Can\'t delete Root Admin' : ($role->users_count >= 1 ? 'In Use' : 'Delete')),
];
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction as CreateActionTable;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ListRoles extends ListRecords
{
protected static string $resource = RoleResource::class;
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->sortable()
->searchable(),
TextColumn::make('guard_name')
->hidden()
->sortable()
->searchable(),
TextColumn::make('permissions_count')
->label('Permissions')
->badge()
->counts('permissions')
->formatStateUsing(fn (Role $role, $state) => $role->isRootAdmin() ? 'All' : $state),
TextColumn::make('users_count')
->label('Users')
->counts('users')
->icon('tabler-users'),
])
->actions([
EditAction::make(),
])
->checkIfRecordIsSelectableUsing(fn (Role $role) => !$role->isRootAdmin() && $role->users_count <= 0)
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete role')),
]),
])
->emptyStateIcon('tabler-users-group')
->emptyStateDescription('')
->emptyStateHeading('No Roles')
->emptyStateActions([
CreateActionTable::make('create')
->label('Create Role')
->button(),
]);
}
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create Role'),
];
}
}

View File

@ -19,13 +19,6 @@ class ServerResource extends Resource
return static::getModel()::count() ?: null; return static::getModel()::count() ?: null;
} }
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [

View File

@ -10,16 +10,34 @@ use App\Models\User;
use App\Services\Servers\RandomWordService; use App\Services\Servers\RandomWordService;
use App\Services\Servers\ServerCreationService; use App\Services\Servers\ServerCreationService;
use App\Services\Users\UserCreationService; use App\Services\Users\UserCreationService;
use Closure;
use Exception;
use Filament\Forms;
use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Components\Wizard;
use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Filament\Forms;
use Filament\Forms\Components\Wizard;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use Closure; use LogicException;
class CreateServer extends CreateRecord class CreateServer extends CreateRecord
{ {
@ -36,7 +54,7 @@ class CreateServer extends CreateRecord
return $form return $form
->schema([ ->schema([
Wizard::make([ Wizard::make([
Wizard\Step::make('Information') Step::make('Information')
->label('Information') ->label('Information')
->icon('tabler-info-circle') ->icon('tabler-info-circle')
->completedIcon('tabler-check') ->completedIcon('tabler-check')
@ -47,12 +65,12 @@ class CreateServer extends CreateRecord
'lg' => 6, 'lg' => 6,
]) ])
->schema([ ->schema([
Forms\Components\TextInput::make('name') TextInput::make('name')
->prefixIcon('tabler-server') ->prefixIcon('tabler-server')
->label('Name') ->label('Name')
->suffixAction(Forms\Components\Actions\Action::make('random') ->suffixAction(Forms\Components\Actions\Action::make('random')
->icon('tabler-dice-' . random_int(1, 6)) ->icon('tabler-dice-' . random_int(1, 6))
->action(function (Forms\Set $set, Forms\Get $get) { ->action(function (Set $set, Get $get) {
$egg = Egg::find($get('egg_id')); $egg = Egg::find($get('egg_id'));
$prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : ''; $prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : '';
@ -69,7 +87,7 @@ class CreateServer extends CreateRecord
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\Select::make('owner_id') Select::make('owner_id')
->preload() ->preload()
->prefixIcon('tabler-user') ->prefixIcon('tabler-user')
->default(auth()->user()->id) ->default(auth()->user()->id)
@ -82,38 +100,23 @@ class CreateServer extends CreateRecord
]) ])
->relationship('user', 'username') ->relationship('user', 'username')
->searchable(['username', 'email']) ->searchable(['username', 'email'])
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->email | $user->username " . ($user->root_admin ? '(admin)' : '')) ->getOptionLabelFromRecordUsing(fn (User $user) => "$user->email | $user->username " . ($user->isRootAdmin() ? '(admin)' : ''))
->createOptionForm([ ->createOptionForm([
Forms\Components\TextInput::make('username') TextInput::make('username')
->alphaNum() ->alphaNum()
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\TextInput::make('email') TextInput::make('email')
->email() ->email()
->required() ->required()
->unique() ->unique()
->maxLength(255), ->maxLength(255),
Forms\Components\TextInput::make('password') TextInput::make('password')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.') ->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
->password(), ->password(),
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->inline()
->required()
->default(false)
->hidden(),
]) ])
->createOptionUsing(function ($data) { ->createOptionUsing(function ($data) {
resolve(UserCreationService::class)->handle($data); resolve(UserCreationService::class)->handle($data);
@ -121,7 +124,7 @@ class CreateServer extends CreateRecord
}) })
->required(), ->required(),
Forms\Components\Select::make('node_id') Select::make('node_id')
->disabledOn('edit') ->disabledOn('edit')
->prefixIcon('tabler-server-2') ->prefixIcon('tabler-server-2')
->default(fn () => ($this->node = Node::query()->latest()->first())?->id) ->default(fn () => ($this->node = Node::query()->latest()->first())?->id)
@ -140,7 +143,7 @@ class CreateServer extends CreateRecord
}) })
->required(), ->required(),
Forms\Components\Textarea::make('description') Textarea::make('description')
->placeholder('Description') ->placeholder('Description')
->rows(3) ->rows(3)
->columnSpan([ ->columnSpan([
@ -149,11 +152,11 @@ class CreateServer extends CreateRecord
'md' => 6, 'md' => 6,
'lg' => 6, 'lg' => 6,
]) ])
->label('Notes'), ->label('Description'),
]), ]),
Wizard\Step::make('Egg') Step::make('Egg Configuration')
->label('Egg') ->label('Egg Configuration')
->icon('tabler-egg') ->icon('tabler-egg')
->completedIcon('tabler-check') ->completedIcon('tabler-check')
->columns([ ->columns([
@ -163,9 +166,7 @@ class CreateServer extends CreateRecord
'lg' => 6, 'lg' => 6,
]) ])
->schema([ ->schema([
Select::make('egg_id')
Forms\Components\Select::make('egg_id')
->disabledOn('edit')
->prefixIcon('tabler-egg') ->prefixIcon('tabler-egg')
->columnSpan([ ->columnSpan([
'default' => 2, 'default' => 2,
@ -177,7 +178,7 @@ class CreateServer extends CreateRecord
->searchable() ->searchable()
->preload() ->preload()
->live() ->live()
->afterStateUpdated(function ($state, Forms\Set $set, Forms\Get $get, $old) { ->afterStateUpdated(function ($state, Set $set, Get $get, $old) {
$this->egg = Egg::query()->find($state); $this->egg = Egg::query()->find($state);
$set('startup', $this->egg?->startup); $set('startup', $this->egg?->startup);
$set('image', ''); $set('image', '');
@ -191,7 +192,7 @@ class CreateServer extends CreateRecord
}) })
->required(), ->required(),
Forms\Components\ToggleButtons::make('skip_scripts') ToggleButtons::make('skip_scripts')
->label('Run Egg Install Script?') ->label('Run Egg Install Script?')
->default(false) ->default(false)
->columnSpan([ ->columnSpan([
@ -215,7 +216,7 @@ class CreateServer extends CreateRecord
->inline() ->inline()
->required(), ->required(),
Forms\Components\ToggleButtons::make('start_on_completion') ToggleButtons::make('start_on_completion')
->label('Start Server After Install?') ->label('Start Server After Install?')
->default(true) ->default(true)
->required() ->required()
@ -239,10 +240,10 @@ class CreateServer extends CreateRecord
]) ])
->inline(), ->inline(),
Forms\Components\Textarea::make('startup') Textarea::make('startup')
->hidden(fn () => !$this->egg)
->hintIcon('tabler-code') ->hintIcon('tabler-code')
->label('Startup Command') ->label('Startup Command')
->hidden(fn () => !$this->egg)
->required() ->required()
->live() ->live()
->disabled(fn (Forms\Get $get) => $this->egg === null) ->disabled(fn (Forms\Get $get) => $this->egg === null)
@ -266,24 +267,24 @@ class CreateServer extends CreateRecord
'lg' => 6, 'lg' => 6,
]), ]),
Forms\Components\Hidden::make('environment')->default([]), Hidden::make('environment')->default([]),
Forms\Components\Section::make('Variables') Section::make('Variables')
->icon('tabler-eggs') ->icon('tabler-eggs')
->iconColor('primary') ->iconColor('primary')
->hidden(fn (Forms\Get $get) => $get('egg_id') === null) ->hidden(fn (Get $get) => $get('egg_id') === null)
->collapsible() ->collapsible()
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\Placeholder::make('Select an egg first to show its variables!') Placeholder::make('Select an egg first to show its variables!')
->hidden(fn (Forms\Get $get) => $get('egg_id')), ->hidden(fn (Get $get) => $get('egg_id')),
Forms\Components\Placeholder::make('The selected egg has no variables!') Placeholder::make('The selected egg has no variables!')
->hidden(fn (Forms\Get $get) => !$get('egg_id') || ->hidden(fn (Get $get) => !$get('egg_id') ||
Egg::query()->find($get('egg_id'))?->variables()?->count() Egg::query()->find($get('egg_id'))?->variables()?->count()
), ),
Forms\Components\Repeater::make('server_variables') Repeater::make('server_variables')
->label('') ->label('')
->relationship('serverVariables') ->relationship('serverVariables')
->saveRelationshipsBeforeChildrenUsing(null) ->saveRelationshipsBeforeChildrenUsing(null)
@ -296,11 +297,11 @@ class CreateServer extends CreateRecord
->hidden(fn ($state) => empty($state)) ->hidden(fn ($state) => empty($state))
->schema(function () { ->schema(function () {
$text = Forms\Components\TextInput::make('variable_value') $text = TextInput::make('variable_value')
->hidden($this->shouldHideComponent(...)) ->hidden($this->shouldHideComponent(...))
->required(fn (Forms\Get $get) => in_array('required', $get('rules'))) ->required(fn (Get $get) => in_array('required', $get('rules')))
->rules( ->rules(
fn (Forms\Get $get): Closure => function (string $attribute, $value, Closure $fail) use ($get) { fn (Get $get): Closure => function (string $attribute, $value, Closure $fail) use ($get) {
$validator = Validator::make(['validatorkey' => $value], [ $validator = Validator::make(['validatorkey' => $value], [
'validatorkey' => $get('rules'), 'validatorkey' => $get('rules'),
]); ]);
@ -313,7 +314,7 @@ class CreateServer extends CreateRecord
}, },
); );
$select = Forms\Components\Select::make('variable_value') $select = Select::make('variable_value')
->hidden($this->shouldHideComponent(...)) ->hidden($this->shouldHideComponent(...))
->options($this->getSelectOptionsFromRules(...)) ->options($this->getSelectOptionsFromRules(...))
->selectablePlaceholder(false); ->selectablePlaceholder(false);
@ -324,11 +325,11 @@ class CreateServer extends CreateRecord
$component = $component $component = $component
->live(onBlur: true) ->live(onBlur: true)
->hintIcon('tabler-code') ->hintIcon('tabler-code')
->label(fn (Forms\Get $get) => $get('name')) ->label(fn (Get $get) => $get('name'))
->hintIconTooltip(fn (Forms\Get $get) => implode('|', $get('rules'))) ->hintIconTooltip(fn (Get $get) => implode('|', $get('rules')))
->prefix(fn (Forms\Get $get) => '{{' . $get('env_variable') . '}}') ->prefix(fn (Get $get) => '{{' . $get('env_variable') . '}}')
->helperText(fn (Forms\Get $get) => empty($get('description')) ? '—' : $get('description')) ->helperText(fn (Get $get) => empty($get('description')) ? '—' : $get('description'))
->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) { ->afterStateUpdated(function (Set $set, Get $get, $state) {
$environment = $get($envPath = '../../environment'); $environment = $get($envPath = '../../environment');
$environment[$get('env_variable')] = $state; $environment[$get('env_variable')] = $state;
$set($envPath, $environment); $set($envPath, $environment);
@ -404,12 +405,12 @@ class CreateServer extends CreateRecord
]), ]),
Wizard\Step::make('Environment') Step::make('Environment Configuration')
->label('Environment') ->label('Environment Configuration')
->icon('tabler-brand-docker') ->icon('tabler-brand-docker')
->completedIcon('tabler-check') ->completedIcon('tabler-check')
->schema([ ->schema([
Forms\Components\Fieldset::make('Resource Limits') Fieldset::make('Resource Limits')
->columnSpan(6) ->columnSpan(6)
->columns([ ->columns([
'default' => 1, 'default' => 1,
@ -418,14 +419,14 @@ class CreateServer extends CreateRecord
'lg' => 3, 'lg' => 3,
]) ])
->schema([ ->schema([
Forms\Components\Grid::make() Grid::make()
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('unlimited_mem') ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline() ->label('Memory')->inlineLabel()->inline()
->default(true) ->default(true)
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0)) ->afterStateUpdated(fn (Set $set) => $set('memory', 0))
->live() ->live()
->options([ ->options([
true => 'Unlimited', true => 'Unlimited',
@ -437,9 +438,9 @@ class CreateServer extends CreateRecord
]) ])
->columnSpan(2), ->columnSpan(2),
Forms\Components\TextInput::make('memory') TextInput::make('memory')
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem')) ->hidden(fn (Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel() ->label('Memory Limit')->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->default(0) ->default(0)
@ -449,15 +450,15 @@ class CreateServer extends CreateRecord
->minValue(0), ->minValue(0),
]), ]),
Forms\Components\Grid::make() Grid::make()
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('unlimited_disk') ToggleButtons::make('unlimited_disk')
->label('Disk Space')->inlineLabel()->inline() ->label('Disk Space')->inlineLabel()->inline()
->default(true) ->default(true)
->live() ->live()
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0)) ->afterStateUpdated(fn (Set $set) => $set('disk', 0))
->options([ ->options([
true => 'Unlimited', true => 'Unlimited',
false => 'Limited', false => 'Limited',
@ -468,9 +469,9 @@ class CreateServer extends CreateRecord
]) ])
->columnSpan(2), ->columnSpan(2),
Forms\Components\TextInput::make('disk') TextInput::make('disk')
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk')) ->hidden(fn (Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel() ->label('Disk Space Limit')->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->default(0) ->default(0)
@ -480,14 +481,14 @@ class CreateServer extends CreateRecord
->minValue(0), ->minValue(0),
]), ]),
Forms\Components\Grid::make() Grid::make()
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('unlimited_cpu') ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline() ->label('CPU')->inlineLabel()->inline()
->default(true) ->default(true)
->afterStateUpdated(fn (Forms\Set $set) => $set('cpu', 0)) ->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->live() ->live()
->options([ ->options([
true => 'Unlimited', true => 'Unlimited',
@ -499,9 +500,9 @@ class CreateServer extends CreateRecord
]) ])
->columnSpan(2), ->columnSpan(2),
Forms\Components\TextInput::make('cpu') TextInput::make('cpu')
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_cpu')) ->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel() ->label('CPU Limit')->inlineLabel()
->suffix('%') ->suffix('%')
->default(0) ->default(0)
@ -512,23 +513,23 @@ class CreateServer extends CreateRecord
->helperText('100% equals one CPU core.'), ->helperText('100% equals one CPU core.'),
]), ]),
Forms\Components\Grid::make() Grid::make()
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('swap_support') ToggleButtons::make('swap_support')
->live() ->live()
->label('Enable Swap Memory') ->label('Enable Swap Memory')
->inlineLabel() ->inlineLabel()
->inline() ->inline()
->columnSpan(2) ->columnSpan(2)
->default('disabled') ->default('disabled')
->afterStateUpdated(function ($state, Forms\Set $set) { ->afterStateUpdated(function ($state, Set $set) {
$value = match ($state) { $value = match ($state) {
'unlimited' => -1, 'unlimited' => -1,
'disabled' => 0, 'disabled' => 0,
'limited' => 128, 'limited' => 128,
default => throw new \LogicException('Invalid state'), default => throw new LogicException('Invalid state'),
}; };
$set('swap', $value); $set('swap', $value);
@ -544,9 +545,9 @@ class CreateServer extends CreateRecord
'disabled' => 'danger', 'disabled' => 'danger',
]), ]),
Forms\Components\TextInput::make('swap') TextInput::make('swap')
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => match ($get('swap_support')) { ->hidden(fn (Get $get) => match ($get('swap_support')) {
'disabled', 'unlimited' => true, 'disabled', 'unlimited' => true,
default => false, default => false,
}) })
@ -560,16 +561,16 @@ class CreateServer extends CreateRecord
->integer(), ->integer(),
]), ]),
Forms\Components\Hidden::make('io') Hidden::make('io')
->helperText('The IO performance relative to other running containers') ->helperText('The IO performance relative to other running containers')
->label('Block IO Proportion') ->label('Block IO Proportion')
->default(config('panel.default_io_weight')), ->default(config('panel.default_io_weight')),
Forms\Components\Grid::make() Grid::make()
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('oom_killer') ToggleButtons::make('oom_killer')
->label('OOM Killer') ->label('OOM Killer')
->inlineLabel()->inline() ->inlineLabel()->inline()
->default(false) ->default(false)
@ -583,12 +584,12 @@ class CreateServer extends CreateRecord
true => 'danger', true => 'danger',
]), ]),
Forms\Components\TextInput::make('oom_disabled_hidden') TextInput::make('oom_disabled_hidden')
->hidden(), ->hidden(),
]), ]),
]), ]),
Forms\Components\Fieldset::make('Feature Limits') Fieldset::make('Feature Limits')
->inlineLabel() ->inlineLabel()
->columnSpan(6) ->columnSpan(6)
->columns([ ->columns([
@ -598,21 +599,21 @@ class CreateServer extends CreateRecord
'lg' => 3, 'lg' => 3,
]) ])
->schema([ ->schema([
Forms\Components\TextInput::make('allocation_limit') TextInput::make('allocation_limit')
->label('Allocations') ->label('Allocations')
->suffixIcon('tabler-network') ->suffixIcon('tabler-network')
->required() ->required()
->numeric() ->numeric()
->minValue(0) ->minValue(0)
->default(0), ->default(0),
Forms\Components\TextInput::make('database_limit') TextInput::make('database_limit')
->label('Databases') ->label('Databases')
->suffixIcon('tabler-database') ->suffixIcon('tabler-database')
->required() ->required()
->numeric() ->numeric()
->minValue(0) ->minValue(0)
->default(0), ->default(0),
Forms\Components\TextInput::make('backup_limit') TextInput::make('backup_limit')
->label('Backups') ->label('Backups')
->suffixIcon('tabler-copy-check') ->suffixIcon('tabler-copy-check')
->required() ->required()
@ -620,7 +621,7 @@ class CreateServer extends CreateRecord
->minValue(0) ->minValue(0)
->default(0), ->default(0),
]), ]),
Forms\Components\Fieldset::make('Docker Settings') Fieldset::make('Docker Settings')
->columns([ ->columns([
'default' => 1, 'default' => 1,
'sm' => 2, 'sm' => 2,
@ -629,10 +630,10 @@ class CreateServer extends CreateRecord
]) ])
->columnSpan(6) ->columnSpan(6)
->schema([ ->schema([
Forms\Components\Select::make('select_image') Select::make('select_image')
->label('Image Name') ->label('Image Name')
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('image', $state)) ->afterStateUpdated(fn (Set $set, $state) => $set('image', $state))
->options(function ($state, Forms\Get $get, Forms\Set $set) { ->options(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id')); $egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? []; $images = $egg->docker_images ?? [];
@ -653,10 +654,10 @@ class CreateServer extends CreateRecord
'lg' => 2, 'lg' => 2,
]), ]),
Forms\Components\TextInput::make('image') TextInput::make('image')
->label('Image') ->label('Image')
->debounce(500) ->debounce(500)
->afterStateUpdated(function ($state, Forms\Get $get, Forms\Set $set) { ->afterStateUpdated(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id')); $egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? []; $images = $egg->docker_images ?? [];
@ -674,13 +675,13 @@ class CreateServer extends CreateRecord
'lg' => 2, 'lg' => 2,
]), ]),
Forms\Components\KeyValue::make('docker_labels') KeyValue::make('docker_labels')
->label('Container Labels') ->label('Container Labels')
->keyLabel('Title') ->keyLabel('Title')
->valueLabel('Description') ->valueLabel('Description')
->columnSpanFull(), ->columnSpanFull(),
Forms\Components\CheckboxList::make('mounts') CheckboxList::make('mounts')
->live() ->live()
->relationship('mounts') ->relationship('mounts')
->options(fn () => $this->node?->mounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]) ?? []) ->options(fn () => $this->node?->mounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]) ?? [])
@ -731,24 +732,24 @@ class CreateServer extends CreateRecord
return $service->handle($data, validateVariables: false); return $service->handle($data, validateVariables: false);
} }
private function shouldHideComponent(Forms\Get $get, Forms\Components\Component $component): bool private function shouldHideComponent(Get $get, Component $component): bool
{ {
$containsRuleIn = collect($get('rules'))->reduce( $containsRuleIn = collect($get('rules'))->reduce(
fn ($result, $value) => $result === true && !str($value)->startsWith('in:'), true fn ($result, $value) => $result === true && !str($value)->startsWith('in:'), true
); );
if ($component instanceof Forms\Components\Select) { if ($component instanceof Select) {
return $containsRuleIn; return $containsRuleIn;
} }
if ($component instanceof Forms\Components\TextInput) { if ($component instanceof TextInput) {
return !$containsRuleIn; return !$containsRuleIn;
} }
throw new \Exception('Component type not supported: ' . $component::class); throw new Exception('Component type not supported: ' . $component::class);
} }
private function getSelectOptionsFromRules(Forms\Get $get): array private function getSelectOptionsFromRules(Get $get): array
{ {
$inRule = collect($get('rules'))->reduce( $inRule = collect($get('rules'))->reduce(
fn ($result, $value) => str($value)->startsWith('in:') ? $value : $result, '' fn ($result, $value) => str($value)->startsWith('in:') ? $value : $result, ''

View File

@ -28,12 +28,20 @@ use App\Models\Egg;
use App\Models\Server; use App\Models\Server;
use App\Models\ServerVariable; use App\Models\ServerVariable;
use App\Services\Servers\ServerDeletionService; use App\Services\Servers\ServerDeletionService;
use Closure;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Tabs; use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Closure;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction; use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class EditServer extends EditRecord class EditServer extends EditRecord
@ -59,7 +67,7 @@ class EditServer extends EditRecord
]) ])
->columnSpanFull() ->columnSpanFull()
->tabs([ ->tabs([
Tabs\Tab::make('Information') Tab::make('Information')
->icon('tabler-info-circle') ->icon('tabler-info-circle')
->schema([ ->schema([
Forms\Components\ToggleButtons::make('condition') Forms\Components\ToggleButtons::make('condition')
@ -81,12 +89,13 @@ class EditServer extends EditRecord
'md' => 1, 'md' => 1,
'lg' => 1, 'lg' => 1,
]), ]),
Forms\Components\TextInput::make('name')
TextInput::make('name')
->prefixIcon('tabler-server') ->prefixIcon('tabler-server')
->label('Display Name') ->label('Display Name')
->suffixAction(Forms\Components\Actions\Action::make('random') ->suffixAction(Action::make('random')
->icon('tabler-dice-' . random_int(1, 6)) ->icon('tabler-dice-' . random_int(1, 6))
->action(function (Forms\Set $set, Forms\Get $get) { ->action(function (Set $set, Get $get) {
$egg = Egg::find($get('egg_id')); $egg = Egg::find($get('egg_id'));
$prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : ''; $prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : '';
@ -103,7 +112,7 @@ class EditServer extends EditRecord
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\Select::make('owner_id') Select::make('owner_id')
->prefixIcon('tabler-user') ->prefixIcon('tabler-user')
->label('Owner') ->label('Owner')
->columnSpan([ ->columnSpan([
@ -117,11 +126,31 @@ class EditServer extends EditRecord
->preload() ->preload()
->required(), ->required(),
Forms\Components\Textarea::make('description') ToggleButtons::make('condition')
->label('Server Status')
->formatStateUsing(fn (Server $server) => $server->condition)
->options(fn ($state) => collect(array_merge(ContainerStatus::cases(), ServerState::cases()))
->filter(fn ($condition) => $condition->value === $state)
->mapWithKeys(fn ($state) => [$state->value => str($state->value)->replace('_', ' ')->ucwords()])
)
->colors(collect(array_merge(ContainerStatus::cases(), ServerState::cases()))->mapWithKeys(
fn ($status) => [$status->value => $status->color()]
))
->icons(collect(array_merge(ContainerStatus::cases(), ServerState::cases()))->mapWithKeys(
fn ($status) => [$status->value => $status->icon()]
))
->columnSpan([
'default' => 2,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
Textarea::make('description')
->label('Notes') ->label('Notes')
->columnSpanFull(), ->columnSpanFull(),
Forms\Components\TextInput::make('uuid') TextInput::make('uuid')
->hintAction(CopyAction::make()) ->hintAction(CopyAction::make())
->columnSpan([ ->columnSpan([
'default' => 2, 'default' => 2,
@ -131,7 +160,7 @@ class EditServer extends EditRecord
]) ])
->readOnly() ->readOnly()
->dehydrated(false), ->dehydrated(false),
Forms\Components\TextInput::make('uuid_short') TextInput::make('uuid_short')
->label('Short UUID') ->label('Short UUID')
->hintAction(CopyAction::make()) ->hintAction(CopyAction::make())
->columnSpan([ ->columnSpan([
@ -142,7 +171,7 @@ class EditServer extends EditRecord
]) ])
->readOnly() ->readOnly()
->dehydrated(false), ->dehydrated(false),
Forms\Components\TextInput::make('external_id') TextInput::make('external_id')
->label('External ID') ->label('External ID')
->columnSpan([ ->columnSpan([
'default' => 2, 'default' => 2,
@ -151,7 +180,7 @@ class EditServer extends EditRecord
'lg' => 3, 'lg' => 3,
]) ])
->maxLength(255), ->maxLength(255),
Forms\Components\Select::make('node_id') Select::make('node_id')
->label('Node') ->label('Node')
->relationship('node', 'name') ->relationship('node', 'name')
->columnSpan([ ->columnSpan([
@ -162,11 +191,11 @@ class EditServer extends EditRecord
]) ])
->disabled(), ->disabled(),
]), ]),
Tabs\Tab::make('env-tab')
->label('Environment') Tab::make('Environment')
->icon('tabler-brand-docker') ->icon('tabler-brand-docker')
->schema([ ->schema([
Forms\Components\Fieldset::make('Resource Limits') Fieldset::make('Resource Limits')
->columns([ ->columns([
'default' => 1, 'default' => 1,
'sm' => 2, 'sm' => 2,
@ -174,14 +203,14 @@ class EditServer extends EditRecord
'lg' => 3, 'lg' => 3,
]) ])
->schema([ ->schema([
Forms\Components\Grid::make() Grid::make()
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('unlimited_mem') ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline() ->label('Memory')->inlineLabel()->inline()
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0)) ->afterStateUpdated(fn (Set $set) => $set('memory', 0))
->formatStateUsing(fn (Forms\Get $get) => $get('memory') == 0) ->formatStateUsing(fn (Get $get) => $get('memory') == 0)
->live() ->live()
->options([ ->options([
true => 'Unlimited', true => 'Unlimited',
@ -193,9 +222,9 @@ class EditServer extends EditRecord
]) ])
->columnSpan(2), ->columnSpan(2),
Forms\Components\TextInput::make('memory') TextInput::make('memory')
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem')) ->hidden(fn (Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel() ->label('Memory Limit')->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required() ->required()
@ -204,15 +233,15 @@ class EditServer extends EditRecord
->minValue(0), ->minValue(0),
]), ]),
Forms\Components\Grid::make() Grid::make()
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('unlimited_disk') ToggleButtons::make('unlimited_disk')
->label('Disk Space')->inlineLabel()->inline() ->label('Disk Space')->inlineLabel()->inline()
->live() ->live()
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0)) ->afterStateUpdated(fn (Set $set) => $set('disk', 0))
->formatStateUsing(fn (Forms\Get $get) => $get('disk') == 0) ->formatStateUsing(fn (Get $get) => $get('disk') == 0)
->options([ ->options([
true => 'Unlimited', true => 'Unlimited',
false => 'Limited', false => 'Limited',
@ -223,9 +252,9 @@ class EditServer extends EditRecord
]) ])
->columnSpan(2), ->columnSpan(2),
Forms\Components\TextInput::make('disk') TextInput::make('disk')
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk')) ->hidden(fn (Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel() ->label('Disk Space Limit')->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required() ->required()
@ -234,14 +263,14 @@ class EditServer extends EditRecord
->minValue(0), ->minValue(0),
]), ]),
Forms\Components\Grid::make() Grid::make()
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('unlimited_cpu') ToggleButtons::make('unlimited_cpu')
->label('CPU')->inlineLabel()->inline() ->label('CPU')->inlineLabel()->inline()
->afterStateUpdated(fn (Forms\Set $set) => $set('cpu', 0)) ->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->formatStateUsing(fn (Forms\Get $get) => $get('cpu') == 0) ->formatStateUsing(fn (Get $get) => $get('cpu') == 0)
->live() ->live()
->options([ ->options([
true => 'Unlimited', true => 'Unlimited',
@ -253,9 +282,9 @@ class EditServer extends EditRecord
]) ])
->columnSpan(2), ->columnSpan(2),
Forms\Components\TextInput::make('cpu') TextInput::make('cpu')
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_cpu')) ->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label('CPU Limit')->inlineLabel() ->label('CPU Limit')->inlineLabel()
->suffix('%') ->suffix('%')
->required() ->required()
@ -264,15 +293,15 @@ class EditServer extends EditRecord
->minValue(0), ->minValue(0),
]), ]),
Forms\Components\Grid::make() Grid::make()
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('swap_support') ToggleButtons::make('swap_support')
->live() ->live()
->label('Enable Swap Memory')->inlineLabel()->inline() ->label('Enable Swap Memory')->inlineLabel()->inline()
->columnSpan(2) ->columnSpan(2)
->afterStateUpdated(function ($state, Forms\Set $set) { ->afterStateUpdated(function ($state, Set $set) {
$value = match ($state) { $value = match ($state) {
'unlimited' => -1, 'unlimited' => -1,
'disabled' => 0, 'disabled' => 0,
@ -282,7 +311,7 @@ class EditServer extends EditRecord
$set('swap', $value); $set('swap', $value);
}) })
->formatStateUsing(function (Forms\Get $get) { ->formatStateUsing(function (Get $get) {
return match (true) { return match (true) {
$get('swap') > 0 => 'limited', $get('swap') > 0 => 'limited',
$get('swap') == 0 => 'disabled', $get('swap') == 0 => 'disabled',
@ -301,9 +330,9 @@ class EditServer extends EditRecord
'disabled' => 'danger', 'disabled' => 'danger',
]), ]),
Forms\Components\TextInput::make('swap') TextInput::make('swap')
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => match ($get('swap_support')) { ->hidden(fn (Get $get) => match ($get('swap_support')) {
'disabled', 'unlimited', true => true, 'disabled', 'unlimited', true => true,
default => false, default => false,
}) })
@ -319,11 +348,11 @@ class EditServer extends EditRecord
->helperText('The IO performance relative to other running containers') ->helperText('The IO performance relative to other running containers')
->label('Block IO Proportion'), ->label('Block IO Proportion'),
Forms\Components\Grid::make() Grid::make()
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('oom_killer') ToggleButtons::make('oom_killer')
->label('OOM Killer')->inlineLabel()->inline() ->label('OOM Killer')->inlineLabel()->inline()
->columnSpan(2) ->columnSpan(2)
->options([ ->options([
@ -335,12 +364,12 @@ class EditServer extends EditRecord
true => 'danger', true => 'danger',
]), ]),
Forms\Components\TextInput::make('oom_disabled_hidden') TextInput::make('oom_disabled_hidden')
->hidden(), ->hidden(),
]), ]),
]), ]),
Forms\Components\Fieldset::make('Feature Limits') Fieldset::make('Feature Limits')
->inlineLabel() ->inlineLabel()
->columns([ ->columns([
'default' => 1, 'default' => 1,
@ -349,23 +378,23 @@ class EditServer extends EditRecord
'lg' => 3, 'lg' => 3,
]) ])
->schema([ ->schema([
Forms\Components\TextInput::make('allocation_limit') TextInput::make('allocation_limit')
->suffixIcon('tabler-network') ->suffixIcon('tabler-network')
->required() ->required()
->minValue(0) ->minValue(0)
->numeric(), ->numeric(),
Forms\Components\TextInput::make('database_limit') TextInput::make('database_limit')
->suffixIcon('tabler-database') ->suffixIcon('tabler-database')
->required() ->required()
->minValue(0) ->minValue(0)
->numeric(), ->numeric(),
Forms\Components\TextInput::make('backup_limit') TextInput::make('backup_limit')
->suffixIcon('tabler-copy-check') ->suffixIcon('tabler-copy-check')
->required() ->required()
->minValue(0) ->minValue(0)
->numeric(), ->numeric(),
]), ]),
Forms\Components\Fieldset::make('Docker Settings') Fieldset::make('Docker Settings')
->columns([ ->columns([
'default' => 1, 'default' => 1,
'sm' => 2, 'sm' => 2,
@ -373,10 +402,10 @@ class EditServer extends EditRecord
'lg' => 3, 'lg' => 3,
]) ])
->schema([ ->schema([
Forms\Components\Select::make('select_image') Select::make('select_image')
->label('Image Name') ->label('Image Name')
->afterStateUpdated(fn (Forms\Set $set, $state) => $set('image', $state)) ->afterStateUpdated(fn (Set $set, $state) => $set('image', $state))
->options(function ($state, Forms\Get $get, Forms\Set $set) { ->options(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id')); $egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? []; $images = $egg->docker_images ?? [];
@ -392,10 +421,10 @@ class EditServer extends EditRecord
->selectablePlaceholder(false) ->selectablePlaceholder(false)
->columnSpan(1), ->columnSpan(1),
Forms\Components\TextInput::make('image') TextInput::make('image')
->label('Image') ->label('Image')
->debounce(500) ->debounce(500)
->afterStateUpdated(function ($state, Forms\Get $get, Forms\Set $set) { ->afterStateUpdated(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id')); $egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? []; $images = $egg->docker_images ?? [];
@ -415,7 +444,7 @@ class EditServer extends EditRecord
->columnSpanFull(), ->columnSpanFull(),
]), ]),
]), ]),
Tabs\Tab::make('Egg') Tab::make('Egg')
->icon('tabler-egg') ->icon('tabler-egg')
->columns([ ->columns([
'default' => 1, 'default' => 1,
@ -424,7 +453,7 @@ class EditServer extends EditRecord
'lg' => 5, 'lg' => 5,
]) ])
->schema([ ->schema([
Forms\Components\Select::make('egg_id') Select::make('egg_id')
->disabledOn('edit') ->disabledOn('edit')
->prefixIcon('tabler-egg') ->prefixIcon('tabler-egg')
->columnSpan([ ->columnSpan([
@ -438,7 +467,7 @@ class EditServer extends EditRecord
->preload() ->preload()
->required(), ->required(),
Forms\Components\ToggleButtons::make('skip_scripts') ToggleButtons::make('skip_scripts')
->label('Run Egg Install Script?')->inline() ->label('Run Egg Install Script?')->inline()
->columnSpan([ ->columnSpan([
'default' => 6, 'default' => 6,
@ -520,7 +549,7 @@ class EditServer extends EditRecord
} }
}), }),
Forms\Components\Textarea::make('startup') Textarea::make('startup')
->label('Startup Command') ->label('Startup Command')
->required() ->required()
->columnSpan(6) ->columnSpan(6)
@ -531,18 +560,18 @@ class EditServer extends EditRecord
); );
}), }),
Forms\Components\Textarea::make('defaultStartup') Textarea::make('defaultStartup')
->hintAction(CopyAction::make()) ->hintAction(CopyAction::make())
->label('Default Startup Command') ->label('Default Startup Command')
->disabled() ->disabled()
->formatStateUsing(function ($state, Get $get, Set $set) { ->formatStateUsing(function ($state, Get $get) {
$egg = Egg::query()->find($get('egg_id')); $egg = Egg::query()->find($get('egg_id'));
return $egg->startup; return $egg->startup;
}) })
->columnSpan(6), ->columnSpan(6),
Forms\Components\Repeater::make('server_variables') Repeater::make('server_variables')
->relationship('serverVariables', function (Builder $query) { ->relationship('serverVariables', function (Builder $query) {
/** @var Server $server */ /** @var Server $server */
$server = $this->getRecord(); $server = $this->getRecord();
@ -571,7 +600,7 @@ class EditServer extends EditRecord
->reorderable(false)->addable(false)->deletable(false) ->reorderable(false)->addable(false)->deletable(false)
->schema(function () { ->schema(function () {
$text = Forms\Components\TextInput::make('variable_value') $text = TextInput::make('variable_value')
->hidden($this->shouldHideComponent(...)) ->hidden($this->shouldHideComponent(...))
->required(fn (ServerVariable $serverVariable) => $serverVariable->variable->getRequiredAttribute()) ->required(fn (ServerVariable $serverVariable) => $serverVariable->variable->getRequiredAttribute())
->rules([ ->rules([
@ -588,7 +617,7 @@ class EditServer extends EditRecord
}, },
]); ]);
$select = Forms\Components\Select::make('variable_value') $select = Select::make('variable_value')
->hidden($this->shouldHideComponent(...)) ->hidden($this->shouldHideComponent(...))
->options($this->getSelectOptionsFromRules(...)) ->options($this->getSelectOptionsFromRules(...))
->selectablePlaceholder(false); ->selectablePlaceholder(false);
@ -609,10 +638,10 @@ class EditServer extends EditRecord
}) })
->columnSpan(6), ->columnSpan(6),
]), ]),
Tabs\Tab::make('Mounts') Tab::make('Mounts')
->icon('tabler-layers-linked') ->icon('tabler-layers-linked')
->schema([ ->schema([
Forms\Components\CheckboxList::make('mounts') CheckboxList::make('mounts')
->relationship('mounts') ->relationship('mounts')
->options(fn (Server $server) => $server->node->mounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name])) ->options(fn (Server $server) => $server->node->mounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]))
->descriptions(fn (Server $server) => $server->node->mounts->mapWithKeys(fn ($mount) => [$mount->id => "$mount->source -> $mount->target"])) ->descriptions(fn (Server $server) => $server->node->mounts->mapWithKeys(fn ($mount) => [$mount->id => "$mount->source -> $mount->target"]))
@ -620,7 +649,7 @@ class EditServer extends EditRecord
->helperText(fn (Server $server) => $server->node->mounts->isNotEmpty() ? '' : 'No Mounts exist for this Node') ->helperText(fn (Server $server) => $server->node->mounts->isNotEmpty() ? '' : 'No Mounts exist for this Node')
->columnSpanFull(), ->columnSpanFull(),
]), ]),
Tabs\Tab::make('Databases') Tab::make('Databases')
->icon('tabler-database') ->icon('tabler-database')
->schema([ ->schema([
Repeater::make('databases') Repeater::make('databases')
@ -628,7 +657,7 @@ class EditServer extends EditRecord
->helperText(fn (Server $server) => $server->databases->isNotEmpty() ? '' : 'No Databases exist for this Server') ->helperText(fn (Server $server) => $server->databases->isNotEmpty() ? '' : 'No Databases exist for this Server')
->columns(2) ->columns(2)
->schema([ ->schema([
Forms\Components\TextInput::make('database') TextInput::make('database')
->columnSpan(2) ->columnSpan(2)
->label('Database Name') ->label('Database Name')
->disabled() ->disabled()
@ -639,11 +668,11 @@ class EditServer extends EditRecord
->icon('tabler-trash') ->icon('tabler-trash')
->action(fn (DatabaseManagementService $databaseManagementService, $record) => $databaseManagementService->delete($record)) ->action(fn (DatabaseManagementService $databaseManagementService, $record) => $databaseManagementService->delete($record))
), ),
Forms\Components\TextInput::make('username') TextInput::make('username')
->disabled() ->disabled()
->formatStateUsing(fn ($record) => $record->username) ->formatStateUsing(fn ($record) => $record->username)
->columnSpan(2), ->columnSpan(2),
Forms\Components\TextInput::make('password') TextInput::make('password')
->disabled() ->disabled()
->hintAction( ->hintAction(
Action::make('rotate') Action::make('rotate')
@ -653,30 +682,30 @@ class EditServer extends EditRecord
) )
->formatStateUsing(fn (Database $database) => $database->password) ->formatStateUsing(fn (Database $database) => $database->password)
->columnSpan(2), ->columnSpan(2),
Forms\Components\TextInput::make('remote') TextInput::make('remote')
->disabled() ->disabled()
->formatStateUsing(fn ($record) => $record->remote) ->formatStateUsing(fn ($record) => $record->remote)
->columnSpan(1) ->columnSpan(1)
->label('Connections From'), ->label('Connections From'),
Forms\Components\TextInput::make('max_connections') TextInput::make('max_connections')
->disabled() ->disabled()
->formatStateUsing(fn ($record) => $record->max_connections) ->formatStateUsing(fn ($record) => $record->max_connections)
->columnSpan(1), ->columnSpan(1),
Forms\Components\TextInput::make('JDBC') TextInput::make('JDBC')
->disabled() ->disabled()
->label('JDBC Connection String') ->label('JDBC Connection String')
->columnSpan(2) ->columnSpan(2)
->formatStateUsing(fn (Forms\Get $get, $record) => 'jdbc:mysql://' . $get('username') . ':' . urlencode($record->password) . '@' . $record->host->host . ':' . $record->host->port . '/' . $get('database')), ->formatStateUsing(fn (Get $get, $record) => 'jdbc:mysql://' . $get('username') . ':' . urlencode($record->password) . '@' . $record->host->host . ':' . $record->host->port . '/' . $get('database')),
]) ])
->relationship('databases') ->relationship('databases')
->deletable(false) ->deletable(false)
->addable(false) ->addable(false)
->columnSpan(4), ->columnSpan(4),
])->columns(4), ])->columns(4),
Tabs\Tab::make('Actions') Tab::make('Actions')
->icon('tabler-settings') ->icon('tabler-settings')
->schema([ ->schema([
Forms\Components\Fieldset::make('Server Actions') Fieldset::make('Server Actions')
->columns([ ->columns([
'default' => 1, 'default' => 1,
'sm' => 2, 'sm' => 2,
@ -684,11 +713,11 @@ class EditServer extends EditRecord
'lg' => 6, 'lg' => 6,
]) ])
->schema([ ->schema([
Forms\Components\Grid::make() Grid::make()
->columnSpan(3) ->columnSpan(3)
->schema([ ->schema([
Forms\Components\Actions::make([ Forms\Components\Actions::make([
Forms\Components\Actions\Action::make('toggleInstall') Action::make('toggleInstall')
->label('Toggle Install Status') ->label('Toggle Install Status')
->disabled(fn (Server $server) => $server->isSuspended()) ->disabled(fn (Server $server) => $server->isSuspended())
->action(function (ServersController $serversController, Server $server) { ->action(function (ServersController $serversController, Server $server) {
@ -697,14 +726,14 @@ class EditServer extends EditRecord
$this->refreshFormData(['status', 'docker']); $this->refreshFormData(['status', 'docker']);
}), }),
])->fullWidth(), ])->fullWidth(),
Forms\Components\ToggleButtons::make('') ToggleButtons::make('')
->hint('If you need to change the install status from uninstalled to installed, or vice versa, you may do so with this button.'), ->hint('If you need to change the install status from uninstalled to installed, or vice versa, you may do so with this button.'),
]), ]),
Forms\Components\Grid::make() Grid::make()
->columnSpan(3) ->columnSpan(3)
->schema([ ->schema([
Forms\Components\Actions::make([ Forms\Components\Actions::make([
Forms\Components\Actions\Action::make('toggleSuspend') Action::make('toggleSuspend')
->label('Suspend') ->label('Suspend')
->color('warning') ->color('warning')
->hidden(fn (Server $server) => $server->isSuspended()) ->hidden(fn (Server $server) => $server->isSuspended())
@ -714,7 +743,7 @@ class EditServer extends EditRecord
$this->refreshFormData(['status', 'docker']); $this->refreshFormData(['status', 'docker']);
}), }),
Forms\Components\Actions\Action::make('toggleUnsuspend') Action::make('toggleUnsuspend')
->label('Unsuspend') ->label('Unsuspend')
->color('success') ->color('success')
->hidden(fn (Server $server) => !$server->isSuspended()) ->hidden(fn (Server $server) => !$server->isSuspended())
@ -725,37 +754,37 @@ class EditServer extends EditRecord
$this->refreshFormData(['status', 'docker']); $this->refreshFormData(['status', 'docker']);
}), }),
])->fullWidth(), ])->fullWidth(),
Forms\Components\ToggleButtons::make('') ToggleButtons::make('')
->hidden(fn (Server $server) => $server->isSuspended()) ->hidden(fn (Server $server) => $server->isSuspended())
->hint('This will suspend the server, stop any running processes, and immediately block the user from being able to access their files or otherwise manage the server through the panel or API.'), ->hint('This will suspend the server, stop any running processes, and immediately block the user from being able to access their files or otherwise manage the server through the panel or API.'),
Forms\Components\ToggleButtons::make('') ToggleButtons::make('')
->hidden(fn (Server $server) => !$server->isSuspended()) ->hidden(fn (Server $server) => !$server->isSuspended())
->hint('This will unsuspend the server and restore normal user access.'), ->hint('This will unsuspend the server and restore normal user access.'),
]), ]),
Forms\Components\Grid::make() Grid::make()
->columnSpan(3) ->columnSpan(3)
->schema([ ->schema([
Forms\Components\Actions::make([ Forms\Components\Actions::make([
Forms\Components\Actions\Action::make('transfer') Action::make('transfer')
->label('Transfer Soon™') ->label('Transfer Soon™')
->action(fn (TransferServerService $transfer, Server $server) => $transfer->handle($server, [])) ->action(fn (TransferServerService $transfer, Server $server) => $transfer->handle($server, []))
->disabled() //TODO! ->disabled() //TODO!
->form([ //TODO! ->form([ //TODO!
Forms\Components\Select::make('newNode') Select::make('newNode')
->label('New Node') ->label('New Node')
->required() ->required()
->options([ ->options([
true => 'on', true => 'on',
false => 'off', false => 'off',
]), ]),
Forms\Components\Select::make('newMainAllocation') Select::make('newMainAllocation')
->label('New Main Allocation') ->label('New Main Allocation')
->required() ->required()
->options([ ->options([
true => 'on', true => 'on',
false => 'off', false => 'off',
]), ]),
Forms\Components\Select::make('newAdditionalAllocation') Select::make('newAdditionalAllocation')
->label('New Additional Allocations') ->label('New Additional Allocations')
->options([ ->options([
true => 'on', true => 'on',
@ -764,14 +793,14 @@ class EditServer extends EditRecord
]) ])
->modalHeading('Transfer'), ->modalHeading('Transfer'),
])->fullWidth(), ])->fullWidth(),
Forms\Components\ToggleButtons::make('') ToggleButtons::make('')
->hint('Transfer this server to another node connected to this panel. Warning! This feature has not been fully tested and may have bugs.'), ->hint('Transfer this server to another node connected to this panel. Warning! This feature has not been fully tested and may have bugs.'),
]), ]),
Forms\Components\Grid::make() Grid::make()
->columnSpan(3) ->columnSpan(3)
->schema([ ->schema([
Forms\Components\Actions::make([ Forms\Components\Actions::make([
Forms\Components\Actions\Action::make('reinstall') Action::make('reinstall')
->label('Reinstall') ->label('Reinstall')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
@ -780,7 +809,7 @@ class EditServer extends EditRecord
->disabled(fn (Server $server) => $server->isSuspended()) ->disabled(fn (Server $server) => $server->isSuspended())
->action(fn (ServersController $serversController, Server $server) => $serversController->reinstallServer($server)), ->action(fn (ServersController $serversController, Server $server) => $serversController->reinstallServer($server)),
])->fullWidth(), ])->fullWidth(),
Forms\Components\ToggleButtons::make('') ToggleButtons::make('')
->hint('This will reinstall the server with the assigned egg install script.'), ->hint('This will reinstall the server with the assigned egg install script.'),
]), ]),
]), ]),
@ -794,9 +823,9 @@ class EditServer extends EditRecord
return $form return $form
->columns() ->columns()
->schema([ ->schema([
Forms\Components\Select::make('toNode') Select::make('toNode')
->label('New Node'), ->label('New Node'),
Forms\Components\TextInput::make('newAllocation') TextInput::make('newAllocation')
->label('Allocation'), ->label('Allocation'),
]); ]);
@ -804,12 +833,17 @@ class EditServer extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make('Delete') Actions\Action::make('Delete')
->successRedirectUrl(route('filament.admin.resources.servers.index')) ->successRedirectUrl(route('filament.admin.resources.servers.index'))
->color('danger') ->color('danger')
->label('Delete') ->label('Delete')
->after(fn (Server $server) => resolve(ServerDeletionService::class)->handle($server)) ->requiresConfirmation()
->requiresConfirmation(), ->action(function (Server $server) {
resolve(ServerDeletionService::class)->handle($server);
return redirect(ListServers::getUrl());
})
->authorize(fn (Server $server) => auth()->user()->can('delete server', $server)),
Actions\Action::make('console') Actions\Action::make('console')
->label('Console') ->label('Console')
->icon('tabler-terminal') ->icon('tabler-terminal')
@ -844,20 +878,19 @@ class EditServer extends EditRecord
return true; return true;
} }
if ($component instanceof Forms\Components\Select) { if ($component instanceof Select) {
return !$containsRuleIn; return !$containsRuleIn;
} }
if ($component instanceof Forms\Components\TextInput) { if ($component instanceof TextInput) {
return $containsRuleIn; return $containsRuleIn;
} }
throw new \Exception('Component type not supported: ' . $component::class); throw new Exception('Component type not supported: ' . $component::class);
} }
private function getSelectOptionsFromRules(ServerVariable $serverVariable): array private function getSelectOptionsFromRules(ServerVariable $serverVariable): array
{ {
$inRule = array_first($serverVariable->variable->rules, fn ($value) => str($value)->startsWith('in:')); $inRule = array_first($serverVariable->variable->rules, fn ($value) => str($value)->startsWith('in:'));
return str($inRule) return str($inRule)

View File

@ -6,10 +6,12 @@ use App\Filament\Resources\ServerResource;
use App\Models\Server; use App\Models\Server;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\CreateAction; use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Grouping\Group; use Filament\Tables\Grouping\Group;
use Filament\Tables\Table; use Filament\Tables\Table;
use Filament\Tables;
class ListServers extends ListRecords class ListServers extends ListRecords
{ {
@ -26,46 +28,55 @@ class ListServers extends ListRecords
Group::make('egg.name')->getDescriptionFromRecordUsing(fn (Server $server): string => str($server->egg->description)->limit(150)), Group::make('egg.name')->getDescriptionFromRecordUsing(fn (Server $server): string => str($server->egg->description)->limit(150)),
]) ])
->columns([ ->columns([
Tables\Columns\TextColumn::make('condition') TextColumn::make('condition')
->default('unknown') ->default('unknown')
->badge() ->badge()
->icon(fn (Server $server) => $server->conditionIcon()) ->icon(fn (Server $server) => $server->conditionIcon())
->color(fn (Server $server) => $server->conditionColor()), ->color(fn (Server $server) => $server->conditionColor()),
Tables\Columns\TextColumn::make('uuid') TextColumn::make('uuid')
->hidden() ->hidden()
->label('UUID') ->label('UUID')
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('name') TextColumn::make('name')
->icon('tabler-brand-docker') ->icon('tabler-brand-docker')
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('node.name') TextColumn::make('node.name')
->icon('tabler-server-2') ->icon('tabler-server-2')
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node])) ->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'node.name') ->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'node.name')
->sortable() ->sortable()
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('egg.name') TextColumn::make('egg.name')
->icon('tabler-egg') ->icon('tabler-egg')
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->egg])) ->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->egg]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'egg.name') ->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'egg.name')
->sortable() ->sortable()
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('user.username') TextColumn::make('user.username')
->icon('tabler-user') ->icon('tabler-user')
->label('Owner') ->label('Owner')
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user])) ->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'user.username') ->hidden(fn (Table $table) => $table->getGrouping()?->getId() === 'user.username')
->sortable()
->searchable(),
TextColumn::make('image')->hidden(),
TextColumn::make('backups_count')
->counts('backups')
->label('Backups')
->icon('tabler-file-download')
->numeric()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('ports') TextColumn::make('ports')
->badge() ->badge()
->separator(), ->separator(),
]) ])
->actions([ ->actions([
Tables\Actions\Action::make('View') Action::make('View')
->icon('tabler-terminal') ->icon('tabler-terminal')
->url(fn (Server $server) => "/server/$server->uuid_short"), ->url(fn (Server $server) => "/server/$server->uuid_short")
Tables\Actions\EditAction::make(), ->authorize(fn () => auth()->user()->can('view server')),
EditAction::make(),
]) ])
->emptyStateIcon('tabler-brand-docker') ->emptyStateIcon('tabler-brand-docker')
->searchable() ->searchable()

View File

@ -0,0 +1,161 @@
<?php
namespace App\Filament\Resources\ServerResource\RelationManagers;
use App\Models\Allocation;
use App\Models\Server;
use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AssociateAction;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
/**
* @method Server getOwnerRecord()
*/
class AllocationsRelationManager extends RelationManager
{
protected static string $relationship = 'allocations';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('ip')
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('ip')
->recordTitle(fn (Allocation $allocation) => "$allocation->ip:$allocation->port")
->checkIfRecordIsSelectableUsing(fn (Allocation $record) => $record->id !== $this->getOwnerRecord()->allocation_id)
->inverseRelationship('server')
->columns([
TextColumn::make('ip')->label('IP'),
TextColumn::make('port')->label('Port'),
TextInputColumn::make('ip_alias')->label('Alias'),
IconColumn::make('primary')
->icon(fn ($state) => match ($state) {
true => 'tabler-star-filled',
default => 'tabler-star',
})
->color(fn ($state) => match ($state) {
true => 'warning',
default => 'gray',
})
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]))
->default(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id)
->label('Primary'),
])
->actions([
Action::make('make-primary')
->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]))
->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : 'Make Primary'),
])
->headerActions([
CreateAction::make()->label('Create Allocation')
->createAnother(false)
->form(fn () => [
TextInput::make('allocation_ip')
->datalist($this->getOwnerRecord()->node->ipAddresses())
->label('IP Address')
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->inlineLabel()
->default(null)
->helperText('Optional display name to help you remember what these are.')
->required(false),
TagsInput::make('allocation_ports')
->placeholder('Examples: 27015, 27017-27019')
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would have to port forward these on your home network.
'))
->label('Ports')
->inlineLabel()
->live()
->afterStateUpdated(function ($state, Set $set) {
$ports = collect();
$update = false;
foreach ($state as $portEntry) {
if (!str_contains($portEntry, '-')) {
if (is_numeric($portEntry)) {
$ports->push((int) $portEntry);
continue;
}
// Do not add non numerical ports
$update = true;
continue;
}
$update = true;
[$start, $end] = explode('-', $portEntry);
if (!is_numeric($start) || !is_numeric($end)) {
continue;
}
$start = max((int) $start, 0);
$end = min((int) $end, 2 ** 16 - 1);
foreach (range($start, $end) as $i) {
$ports->push($i);
}
}
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$update = true;
$ports = $uniquePorts;
}
$sortedPorts = $ports->sort()->values();
if ($sortedPorts->all() !== $ports->all()) {
$update = true;
$ports = $sortedPorts;
}
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
if ($update) {
$set('allocation_ports', $ports->all());
}
})
->splitKeys(['Tab', ' ', ','])
->required(),
])
->action(fn (array $data) => resolve(AssignmentService::class)->handle($this->getOwnerRecord()->node, $data, $this->getOwnerRecord())),
AssociateAction::make()
->multiple()
->associateAnother(false)
->preloadRecordSelect()
->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node))
->label('Add Allocation'),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DissociateBulkAction::make(),
]),
]);
}
}

View File

@ -21,13 +21,14 @@ use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Tabs; use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Tabs\Tab; use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Get; use Filament\Forms\Get;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
@ -57,7 +58,6 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->maxLength(255) ->maxLength(255)
->unique(ignoreRecord: true) ->unique(ignoreRecord: true)
->autofocus(), ->autofocus(),
TextInput::make('email') TextInput::make('email')
->prefixIcon('tabler-mail') ->prefixIcon('tabler-mail')
->label(trans('strings.email')) ->label(trans('strings.email'))
@ -65,7 +65,6 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->required() ->required()
->maxLength(255) ->maxLength(255)
->unique(ignoreRecord: true), ->unique(ignoreRecord: true),
TextInput::make('password') TextInput::make('password')
->label(trans('strings.password')) ->label(trans('strings.password'))
->password() ->password()
@ -77,7 +76,6 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->dehydrateStateUsing(fn ($state): string => Hash::make($state)) ->dehydrateStateUsing(fn ($state): string => Hash::make($state))
->live(debounce: 500) ->live(debounce: 500)
->same('passwordConfirmation'), ->same('passwordConfirmation'),
TextInput::make('passwordConfirmation') TextInput::make('passwordConfirmation')
->label(trans('strings.password_confirmation')) ->label(trans('strings.password_confirmation'))
->password() ->password()
@ -86,13 +84,11 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->required() ->required()
->visible(fn (Get $get): bool => filled($get('password'))) ->visible(fn (Get $get): bool => filled($get('password')))
->dehydrated(false), ->dehydrated(false),
Select::make('timezone') Select::make('timezone')
->required() ->required()
->prefixIcon('tabler-clock-pin') ->prefixIcon('tabler-clock-pin')
->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz])) ->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz]))
->searchable(), ->searchable(),
Select::make('language') Select::make('language')
->label(trans('strings.language')) ->label(trans('strings.language'))
->required() ->required()
@ -111,7 +107,6 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
Tab::make('2FA') Tab::make('2FA')
->icon('tabler-shield-lock') ->icon('tabler-shield-lock')
->schema(function () { ->schema(function () {
if ($this->getUser()->use_totp) { if ($this->getUser()->use_totp) {
return [ return [
Placeholder::make('2fa-already-enabled') Placeholder::make('2fa-already-enabled')
@ -196,16 +191,13 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->helperText('Enter your current password to verify.'), ->helperText('Enter your current password to verify.'),
]; ];
}), }),
Tab::make('API Keys') Tab::make('API Keys')
->icon('tabler-key') ->icon('tabler-key')
->schema([ ->schema([
Grid::make(5)->schema([ Grid::make(5)->schema([
Section::make('Create API Key')->columnSpan(3)->schema([ Section::make('Create API Key')->columnSpan(3)->schema([
TextInput::make('description') TextInput::make('description')
->live(), ->live(),
TagsInput::make('allowed_ips') TagsInput::make('allowed_ips')
->live() ->live()
->splitKeys([',', ' ', 'Tab']) ->splitKeys([',', ' ', 'Tab'])
@ -222,12 +214,10 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
$get('description'), $get('description'),
$get('allowed_ips'), $get('allowed_ips'),
); );
Activity::event('user:api-key.create') Activity::event('user:api-key.create')
->subject($token->accessToken) ->subject($token->accessToken)
->property('identifier', $token->accessToken->identifier) ->property('identifier', $token->accessToken->identifier)
->log(); ->log();
$action->success(); $action->success();
}), }),
]), ]),
@ -256,13 +246,11 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
]), ]),
]), ]),
]), ]),
Tab::make('SSH Keys') Tab::make('SSH Keys')
->icon('tabler-lock-code') ->icon('tabler-lock-code')
->schema([ ->schema([
Placeholder::make('Coming soon!'), Placeholder::make('Coming soon!'),
]), ]),
Tab::make('Activity') Tab::make('Activity')
->icon('tabler-history') ->icon('tabler-history')
->schema([ ->schema([
@ -286,7 +274,7 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
]; ];
} }
protected function handleRecordUpdate($record, $data): \Illuminate\Database\Eloquent\Model protected function handleRecordUpdate($record, $data): Model
{ {
if ($token = $data['2facode'] ?? null) { if ($token = $data['2facode'] ?? null) {
/** @var ToggleTwoFactorService $service */ /** @var ToggleTwoFactorService $service */

View File

@ -3,13 +3,16 @@
namespace App\Filament\Resources\UserResource\Pages; namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Services\Exceptions\FilamentExceptionHandler; use App\Models\Role;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use App\Models\User; use App\Models\User;
use Filament\Forms; use Filament\Actions\DeleteAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
class EditUser extends EditRecord class EditUser extends EditRecord
@ -20,54 +23,33 @@ class EditUser extends EditRecord
return $form return $form
->schema([ ->schema([
Section::make()->schema([ Section::make()->schema([
Forms\Components\TextInput::make('username')->required()->maxLength(255), TextInput::make('username')->required()->maxLength(255),
Forms\Components\TextInput::make('email')->email()->required()->maxLength(255), TextInput::make('email')->email()->required()->maxLength(255),
TextInput::make('password')
Forms\Components\TextInput::make('password')
->dehydrateStateUsing(fn (string $state): string => Hash::make($state)) ->dehydrateStateUsing(fn (string $state): string => Hash::make($state))
->dehydrated(fn (?string $state): bool => filled($state)) ->dehydrated(fn (?string $state): bool => filled($state))
->required(fn (string $operation): bool => $operation === 'create') ->required(fn (string $operation): bool => $operation === 'create')
->password(), ->password(),
Select::make('language')
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->disableOptionWhen(function (string $operation, $value, User $user) {
if ($operation !== 'edit' || $value) {
return false;
}
return $user->isLastRootAdmin();
})
->hint(fn (User $user) => $user->isLastRootAdmin() ? 'This is the last root administrator!' : '')
->helperText(fn (User $user) => $user->isLastRootAdmin() ? 'You must have at least one root administrator in your system.' : '')
->hintColor('warning')
->inline()
->required()
->default(false),
Forms\Components\Hidden::make('skipValidation')->default(true),
Forms\Components\Select::make('language')
->required() ->required()
->hidden() ->hidden()
->default('en') ->default('en')
->options(fn (User $user) => $user->getAvailableLanguages()), ->options(fn (User $user) => $user->getAvailableLanguages()),
Hidden::make('skipValidation')->default(true),
CheckboxList::make('roles')
->disabled(fn (User $user) => $user->id === auth()->user()->id)
->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id)
->relationship('roles', 'name')
->label('Admin Roles')
->columnSpanFull()
->bulkToggleable(false),
])->columns(), ])->columns(),
]); ]);
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() DeleteAction::make()
->label(fn (User $user) => auth()->user()->id === $user->id ? 'Can\'t Delete Yourself' : ($user->servers()->count() > 0 ? 'User Has Servers' : 'Delete')) ->label(fn (User $user) => auth()->user()->id === $user->id ? 'Can\'t Delete Yourself' : ($user->servers()->count() > 0 ? 'User Has Servers' : 'Delete'))
->disabled(fn (User $user) => auth()->user()->id === $user->id || $user->servers()->count() > 0), ->disabled(fn (User $user) => auth()->user()->id === $user->id || $user->servers()->count() > 0),
$this->getSaveFormAction()->formId('form'), $this->getSaveFormAction()->formId('form'),
@ -78,9 +60,4 @@ class EditUser extends EditRecord
{ {
return []; return [];
} }
public function exception($exception, $stopPropagation): void
{
(new FilamentExceptionHandler())->handle($exception, $stopPropagation);
}
} }

View File

@ -3,14 +3,22 @@
namespace App\Filament\Resources\UserResource\Pages; namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Models\Role;
use App\Models\User; use App\Models\User;
use App\Services\Users\UserCreationService; use App\Services\Users\UserCreationService;
use Filament\Actions; use Filament\Actions\CreateAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Filament\Tables;
use Filament\Forms;
class ListUsers extends ListRecords class ListUsers extends ListRecords
{ {
@ -21,101 +29,102 @@ class ListUsers extends ListRecords
return $table return $table
->searchable(false) ->searchable(false)
->columns([ ->columns([
Tables\Columns\ImageColumn::make('picture') ImageColumn::make('picture')
->visibleFrom('lg') ->visibleFrom('lg')
->label('') ->label('')
->extraImgAttributes(['class' => 'rounded-full']) ->extraImgAttributes(['class' => 'rounded-full'])
->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))), ->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))),
Tables\Columns\TextColumn::make('external_id') TextColumn::make('external_id')
->searchable() ->searchable()
->hidden(), ->hidden(),
Tables\Columns\TextColumn::make('uuid') TextColumn::make('uuid')
->label('UUID') ->label('UUID')
->hidden() ->hidden()
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('username') TextColumn::make('username')
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('email') TextColumn::make('email')
->searchable() ->searchable()
->icon('tabler-mail'), ->icon('tabler-mail'),
Tables\Columns\IconColumn::make('root_admin') IconColumn::make('use_totp')
->visibleFrom('md') ->label('2FA')
->label('Admin')
->boolean()
->trueIcon('tabler-star-filled')
->falseIcon('tabler-star-off')
->sortable(),
Tables\Columns\IconColumn::make('use_totp')->label('2FA')
->visibleFrom('lg') ->visibleFrom('lg')
->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off') ->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off')
->boolean()->sortable(), ->boolean()->sortable(),
Tables\Columns\TextColumn::make('servers_count') TextColumn::make('roles_count')
->counts('roles')
->icon('tabler-users-group')
->label('Roles')
->formatStateUsing(fn (User $user, $state) => $state . ($user->isRootAdmin() ? ' (Root Admin)' : '')),
TextColumn::make('servers_count')
->counts('servers') ->counts('servers')
->icon('tabler-server') ->icon('tabler-server')
->label('Servers'), ->label('Servers'),
Tables\Columns\TextColumn::make('subusers_count') TextColumn::make('subusers_count')
->visibleFrom('sm') ->visibleFrom('sm')
->label('Subusers') ->label('Subusers')
->counts('subusers') ->counts('subusers')
->icon('tabler-users'), ->icon('tabler-users'),
// ->formatStateUsing(fn (string $state, $record): string => (string) ($record->servers_count + $record->subusers_count)) // ->formatStateUsing(fn (string $state, $record): string => (string) ($record->servers_count + $record->subusers_count))
]) ])
->filters([
//
])
->actions([ ->actions([
Tables\Actions\EditAction::make(), EditAction::make(),
]) ])
->checkIfRecordIsSelectableUsing(fn (User $user) => auth()->user()->id !== $user->id && !$user->servers_count) ->checkIfRecordIsSelectableUsing(fn (User $user) => auth()->user()->id !== $user->id && !$user->servers_count)
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([ BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(), DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete user')),
]), ]),
]); ]);
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make('create') CreateAction::make('create')
->label('Create User') ->label('Create User')
->createAnother(false) ->createAnother(false)
->form([ ->form([
Forms\Components\Grid::make() Grid::make()
->schema([ ->schema([
Forms\Components\TextInput::make('username') TextInput::make('username')
->alphaNum() ->alphaNum()
->required() ->required()
->maxLength(255), ->maxLength(255),
Forms\Components\TextInput::make('email') TextInput::make('email')
->email() ->email()
->required() ->required()
->unique() ->unique()
->maxLength(255), ->maxLength(255),
TextInput::make('password')
Forms\Components\TextInput::make('password')
->hintIcon('tabler-question-mark') ->hintIcon('tabler-question-mark')
->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.') ->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
->password(), ->password(),
CheckboxList::make('roles')
Forms\Components\ToggleButtons::make('root_admin') ->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id)
->label('Administrator (Root)') ->relationship('roles', 'name')
->options([ ->dehydrated()
false => 'No', ->label('Admin Roles')
true => 'Admin', ->columnSpanFull()
]) ->bulkToggleable(false),
->colors([
false => 'primary',
true => 'danger',
])
->inline()
->required()
->default(false),
]), ]),
]) ])
->successRedirectUrl(route('filament.admin.resources.users.index')) ->successRedirectUrl(route('filament.admin.resources.users.index'))
->action(function (array $data) { ->action(function (array $data) {
resolve(UserCreationService::class)->handle($data); $roles = $data['roles'];
Notification::make()->title('User Created!')->success()->send(); $roles = collect($roles)->map(fn ($role) => Role::findById($role));
unset($data['roles']);
/** @var UserCreationService $creationService */
$creationService = resolve(UserCreationService::class);
$user = $creationService->handle($data);
$user->syncRoles($roles);
Notification::make()
->title('User Created!')
->success()
->send();
return redirect()->route('filament.admin.resources.users.index'); return redirect()->route('filament.admin.resources.users.index');
}), }),

View File

@ -6,10 +6,10 @@ use App\Enums\ServerState;
use App\Models\Server; use App\Models\Server;
use App\Models\User; use App\Models\User;
use App\Services\Servers\SuspensionService; use App\Services\Servers\SuspensionService;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Actions;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ServersRelationManager extends RelationManager class ServersRelationManager extends RelationManager
{ {
@ -36,7 +36,6 @@ class ServersRelationManager extends RelationManager
resolve(SuspensionService::class)->toggle($server); resolve(SuspensionService::class)->toggle($server);
} }
}), }),
Actions\Action::make('toggleUnsuspend') Actions\Action::make('toggleUnsuspend')
->hidden(fn () => $user->servers()->where('status', ServerState::Suspended)->count() === 0) ->hidden(fn () => $user->servers()->where('status', ServerState::Suspended)->count() === 0)
->label('Unsuspend All Servers') ->label('Unsuspend All Servers')
@ -48,32 +47,32 @@ class ServersRelationManager extends RelationManager
}), }),
]) ])
->columns([ ->columns([
Tables\Columns\TextColumn::make('uuid') TextColumn::make('uuid')
->hidden() ->hidden()
->label('UUID') ->label('UUID')
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('name') TextColumn::make('name')
->icon('tabler-brand-docker') ->icon('tabler-brand-docker')
->label(trans('strings.name')) ->label(trans('strings.name'))
->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server])) ->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server]))
->searchable() ->searchable()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('node.name') TextColumn::make('node.name')
->icon('tabler-server-2') ->icon('tabler-server-2')
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node])) ->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node]))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('egg.name') TextColumn::make('egg.name')
->icon('tabler-egg') ->icon('tabler-egg')
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->egg])) ->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->egg]))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('image')->hidden(), TextColumn::make('image')->hidden(),
Tables\Columns\TextColumn::make('databases_count') TextColumn::make('databases_count')
->counts('databases') ->counts('databases')
->label('Databases') ->label('Databases')
->icon('tabler-database') ->icon('tabler-database')
->numeric() ->numeric()
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('backups_count') TextColumn::make('backups_count')
->counts('backups') ->counts('backups')
->label('Backups') ->label('Backups')
->icon('tabler-file-download') ->icon('tabler-file-download')

View File

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Api\Application\Roles;
use App\Exceptions\PanelException;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use App\Models\Role;
use Spatie\QueryBuilder\QueryBuilder;
use App\Transformers\Api\Application\RoleTransformer;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Roles\GetRoleRequest;
use App\Http\Requests\Api\Application\Roles\StoreRoleRequest;
use App\Http\Requests\Api\Application\Roles\DeleteRoleRequest;
use App\Http\Requests\Api\Application\Roles\UpdateRoleRequest;
class RoleController extends ApplicationApiController
{
/**
* Return all the roles currently registered on the Panel.
*/
public function index(GetRoleRequest $request): array
{
$roles = QueryBuilder::for(Role::query())
->allowedFilters(['id', 'name'])
->allowedSorts(['id', 'name'])
->paginate($request->query('per_page') ?? 10);
return $this->fractal->collection($roles)
->transformWith($this->getTransformer(RoleTransformer::class))
->toArray();
}
/**
* Return a single role.
*/
public function view(GetRoleRequest $request, Role $role): array
{
return $this->fractal->item($role)
->transformWith($this->getTransformer(RoleTransformer::class))
->toArray();
}
/**
* Store a new role on the Panel and return an HTTP/201 response code with the
* new role attached.
*
* @throws \Throwable
*/
public function store(StoreRoleRequest $request): JsonResponse
{
$role = Role::create($request->validated());
return $this->fractal->item($role)
->transformWith($this->getTransformer(RoleTransformer::class))
->addMeta([
'resource' => route('api.application.roles.view', [
'role' => $role->id,
]),
])
->respond(201);
}
/**
* Update a role on the Panel and return the updated record to the user.
*
* @throws \Throwable
*/
public function update(UpdateRoleRequest $request, Role $role): array
{
if ($role->isRootAdmin()) {
throw new PanelException('Can\'t update root admin role!');
}
$role->update($request->validated());
return $this->fractal->item($role)
->transformWith($this->getTransformer(RoleTransformer::class))
->toArray();
}
/**
* Delete a role from the Panel.
*
* @throws \Exception
*/
public function delete(DeleteRoleRequest $request, Role $role): Response
{
if ($role->isRootAdmin()) {
throw new PanelException('Can\'t delete root admin role!');
}
$role->delete();
return $this->returnNoContent();
}
}

View File

@ -13,6 +13,8 @@ use App\Http\Requests\Api\Application\Users\StoreUserRequest;
use App\Http\Requests\Api\Application\Users\DeleteUserRequest; use App\Http\Requests\Api\Application\Users\DeleteUserRequest;
use App\Http\Requests\Api\Application\Users\UpdateUserRequest; use App\Http\Requests\Api\Application\Users\UpdateUserRequest;
use App\Http\Controllers\Api\Application\ApplicationApiController; use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Users\AssignUserRolesRequest;
use App\Models\Role;
class UserController extends ApplicationApiController class UserController extends ApplicationApiController
{ {
@ -75,6 +77,44 @@ class UserController extends ApplicationApiController
return $response->toArray(); return $response->toArray();
} }
/**
* Assign roles to a user.
*/
public function assignRoles(AssignUserRolesRequest $request, User $user): array
{
foreach ($request->input('roles') as $role) {
if ($role === Role::getRootAdmin()->id) {
continue;
}
$user->assignRole($role);
}
$response = $this->fractal->item($user)
->transformWith($this->getTransformer(UserTransformer::class));
return $response->toArray();
}
/**
* Removes roles from a user.
*/
public function removeRoles(AssignUserRolesRequest $request, User $user): array
{
foreach ($request->input('roles') as $role) {
if ($role === Role::getRootAdmin()->id) {
continue;
}
$user->removeRole($role);
}
$response = $this->fractal->item($user)
->transformWith($this->getTransformer(UserTransformer::class));
return $response->toArray();
}
/** /**
* Store a new user on the system. Returns the created user and an HTTP/201 * Store a new user on the system. Returns the created user and an HTTP/201
* header on successful creation. * header on successful creation.

View File

@ -48,7 +48,7 @@ class ClientController extends ClientApiController
if (in_array($type, ['admin', 'admin-all'])) { if (in_array($type, ['admin', 'admin-all'])) {
// If they aren't an admin but want all the admin servers don't fail the request, just // If they aren't an admin but want all the admin servers don't fail the request, just
// make it a query that will never return any results back. // make it a query that will never return any results back.
if (!$user->root_admin) { if (!$user->isRootAdmin()) {
$builder->whereRaw('1 = 2'); $builder->whereRaw('1 = 2');
} else { } else {
$builder = $type === 'admin-all' $builder = $type === 'admin-all'

View File

@ -13,6 +13,7 @@ use Illuminate\Database\Query\JoinClause;
use App\Http\Requests\Api\Client\ClientApiRequest; use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Transformers\Api\Client\ActivityLogTransformer; use App\Transformers\Api\Client\ActivityLogTransformer;
use App\Http\Controllers\Api\Client\ClientApiController; use App\Http\Controllers\Api\Client\ClientApiController;
use App\Models\Role;
class ActivityLogController extends ClientApiController class ActivityLogController extends ClientApiController
{ {
@ -32,15 +33,16 @@ class ActivityLogController extends ClientApiController
// We could do this with a query and a lot of joins, but that gets pretty // We could do this with a query and a lot of joins, but that gets pretty
// painful so for now we'll execute a simpler query. // painful so for now we'll execute a simpler query.
$subusers = $server->subusers()->pluck('user_id')->merge([$server->owner_id]); $subusers = $server->subusers()->pluck('user_id')->merge([$server->owner_id]);
$rootAdmins = Role::getRootAdmin()->users()->pluck('id');
$builder->select('activity_logs.*') $builder->select('activity_logs.*')
->leftJoin('users', function (JoinClause $join) { ->leftJoin('users', function (JoinClause $join) {
$join->on('users.id', 'activity_logs.actor_id') $join->on('users.id', 'activity_logs.actor_id')
->where('activity_logs.actor_type', (new User())->getMorphClass()); ->where('activity_logs.actor_type', (new User())->getMorphClass());
}) })
->where(function (Builder $builder) use ($subusers) { ->where(function (Builder $builder) use ($subusers, $rootAdmins) {
$builder->whereNull('users.id') $builder->whereNull('users.id')
->orWhere('users.root_admin', 0) ->orWhereNotIn('users.id', $rootAdmins)
->orWhereIn('users.id', $subusers); ->orWhereIn('users.id', $subusers);
}); });
}) })

View File

@ -2,16 +2,16 @@
namespace App\Http\Controllers\Api\Client\Servers; namespace App\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Response;
use App\Models\Server;
use Illuminate\Http\JsonResponse;
use App\Facades\Activity; use App\Facades\Activity;
use App\Services\Servers\ReinstallServerService;
use App\Http\Controllers\Api\Client\ClientApiController; use App\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use App\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest;
use App\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest; use App\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest;
use App\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest; use App\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest;
use App\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest; use App\Models\Server;
use App\Services\Servers\ReinstallServerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class SettingsController extends ClientApiController class SettingsController extends ClientApiController
{ {
@ -33,7 +33,11 @@ class SettingsController extends ClientApiController
$description = $request->has('description') ? (string) $request->input('description') : $server->description; $description = $request->has('description') ? (string) $request->input('description') : $server->description;
$server->name = $name; $server->name = $name;
$server->description = $description;
if (config('panel.editable_server_descriptions')) {
$server->description = $description;
}
$server->save(); $server->save();
if ($server->name !== $name) { if ($server->name !== $name) {

View File

@ -140,7 +140,7 @@ class SftpAuthenticationController extends Controller
*/ */
protected function validateSftpAccess(User $user, Server $server): void protected function validateSftpAccess(User $user, Server $server): void
{ {
if (!$user->root_admin && $server->owner_id !== $user->id) { if (!$user->isRootAdmin() && $server->owner_id !== $user->id) {
$permissions = $this->permissions->handle($server, $user); $permissions = $this->permissions->handle($server, $user);
if (!in_array(Permission::ACTION_FILE_SFTP, $permissions)) { if (!in_array(Permission::ACTION_FILE_SFTP, $permissions)) {

View File

@ -2,13 +2,13 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Filament\Pages\Installer\PanelInstaller;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\User; use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use App\Facades\Activity; use App\Facades\Activity;
use Illuminate\View\View;
class LoginController extends AbstractLoginController class LoginController extends AbstractLoginController
{ {
@ -17,8 +17,12 @@ class LoginController extends AbstractLoginController
* base authentication view component. React will take over at this point and * base authentication view component. React will take over at this point and
* turn the login area into an SPA. * turn the login area into an SPA.
*/ */
public function index(): View public function index()
{ {
if (!PanelInstaller::isInstalled()) {
return redirect('/installer');
}
return view('templates/auth.core'); return view('templates/auth.core');
} }

View File

@ -14,7 +14,7 @@ class AdminAuthenticate
*/ */
public function handle(Request $request, \Closure $next): mixed public function handle(Request $request, \Closure $next): mixed
{ {
if (!$request->user() || !$request->user()->root_admin) { if (!$request->user() || !$request->user()->isRootAdmin()) {
throw new AccessDeniedHttpException(); throw new AccessDeniedHttpException();
} }

View File

@ -15,7 +15,7 @@ class AuthenticateApplicationUser
{ {
/** @var \App\Models\User|null $user */ /** @var \App\Models\User|null $user */
$user = $request->user(); $user = $request->user();
if (!$user || !$user->root_admin) { if (!$user || !$user->isRootAdmin()) {
throw new AccessDeniedHttpException('This account does not have permission to access the API.'); throw new AccessDeniedHttpException('This account does not have permission to access the API.');
} }

View File

@ -39,7 +39,7 @@ class AuthenticateServerAccess
// At the very least, ensure that the user trying to make this request is the // At the very least, ensure that the user trying to make this request is the
// server owner, a subuser, or a root admin. We'll leave it up to the controllers // server owner, a subuser, or a root admin. We'll leave it up to the controllers
// to authenticate more detailed permissions if needed. // to authenticate more detailed permissions if needed.
if ($user->id !== $server->owner_id && !$user->root_admin) { if ($user->id !== $server->owner_id && !$user->isRootAdmin()) {
// Check for subuser status. // Check for subuser status.
if (!$server->subusers->contains('user_id', $user->id)) { if (!$server->subusers->contains('user_id', $user->id)) {
throw new NotFoundHttpException(trans('exceptions.api.resource_not_found')); throw new NotFoundHttpException(trans('exceptions.api.resource_not_found'));
@ -55,7 +55,7 @@ class AuthenticateServerAccess
if (($server->isSuspended() || $server->node->isUnderMaintenance()) && !$request->routeIs('api:client:server.resources')) { if (($server->isSuspended() || $server->node->isUnderMaintenance()) && !$request->routeIs('api:client:server.resources')) {
throw $exception; throw $exception;
} }
if (!$user->root_admin || !$request->routeIs($this->except)) { if (!$user->isRootAdmin() || !$request->routeIs($this->except)) {
throw $exception; throw $exception;
} }
} }

View File

@ -51,7 +51,7 @@ class RequireTwoFactorAuthentication
// If the level is set as admin and the user is not an admin, pass them through as well. // If the level is set as admin and the user is not an admin, pass them through as well.
if ($level === self::LEVEL_NONE || $user->use_totp) { if ($level === self::LEVEL_NONE || $user->use_totp) {
return $next($request); return $next($request);
} elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) { } elseif ($level === self::LEVEL_ADMIN && !$user->isRootAdmin()) {
return $next($request); return $next($request);
} }

View File

@ -21,7 +21,7 @@ abstract class AdminFormRequest extends FormRequest
return false; return false;
} }
return (bool) $this->user()->root_admin; return $this->user()->isRootAdmin();
} }
/** /**

View File

@ -22,7 +22,6 @@ class NewUserFormRequest extends AdminFormRequest
'name_last', 'name_last',
'password', 'password',
'language', 'language',
'root_admin',
])->toArray(); ])->toArray();
} }
} }

View File

@ -22,7 +22,6 @@ class UserFormRequest extends AdminFormRequest
'name_last', 'name_last',
'password', 'password',
'language', 'language',
'root_admin',
])->toArray(); ])->toArray();
} }
} }

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class DeleteRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::WRITE;
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class GetRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::READ;
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class StoreRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::WRITE;
public function rules(array $rules = null): array
{
return [
'name' => 'required|string',
];
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
class UpdateRoleRequest extends StoreRoleRequest
{
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Http\Requests\Api\Application\Users;
class AssignUserRolesRequest extends StoreUserRequest
{
/**
* Return the validation rules for this request.
*/
public function rules(array $rules = null): array
{
return [
'roles' => 'array',
'roles.*' => 'int',
];
}
}

View File

@ -26,7 +26,6 @@ class StoreUserRequest extends ApplicationApiRequest
'password', 'password',
'language', 'language',
'timezone', 'timezone',
'root_admin',
])->toArray(); ])->toArray();
$response['first_name'] = $rules['name_first']; $response['first_name'] = $rules['name_first'];
@ -56,7 +55,6 @@ class StoreUserRequest extends ApplicationApiRequest
'external_id' => 'Third Party Identifier', 'external_id' => 'Third Party Identifier',
'name_first' => 'First Name', 'name_first' => 'First Name',
'name_last' => 'Last Name', 'name_last' => 'Last Name',
'root_admin' => 'Root Administrator Status',
]; ];
} }
} }

View File

@ -56,7 +56,7 @@ abstract class SubuserRequest extends ClientApiRequest
$server = $this->route()->parameter('server'); $server = $this->route()->parameter('server');
// If we are a root admin or the server owner, no need to perform these checks. // If we are a root admin or the server owner, no need to perform these checks.
if ($user->root_admin || $user->id === $server->owner_id) { if ($user->isRootAdmin() || $user->id === $server->owner_id) {
return; return;
} }

View File

@ -2,8 +2,8 @@
namespace App\Http\ViewComposers; namespace App\Http\ViewComposers;
use Illuminate\View\View;
use App\Services\Helpers\AssetHashService; use App\Services\Helpers\AssetHashService;
use Illuminate\View\View;
class AssetComposer class AssetComposer
{ {
@ -28,6 +28,7 @@ class AssetComposer
'siteKey' => config('recaptcha.website_key') ?? '', 'siteKey' => config('recaptcha.website_key') ?? '',
], ],
'usesSyncDriver' => config('queue.default') === 'sync', 'usesSyncDriver' => config('queue.default') === 'sync',
'serverDescriptionsEditable' => config('panel.editable_server_descriptions'),
]); ]);
} }
} }

View File

@ -5,12 +5,12 @@ namespace App\Models;
use App\Exceptions\Service\HasActiveServersException; use App\Exceptions\Service\HasActiveServersException;
use App\Repositories\Daemon\DaemonConfigurationRepository; use App\Repositories\Daemon\DaemonConfigurationRepository;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
/** /**
* @property int $id * @property int $id
@ -75,11 +75,11 @@ class Node extends Model
'disk_overallocate', 'cpu', 'cpu_overallocate', 'disk_overallocate', 'cpu', 'cpu_overallocate',
'upload_size', 'daemon_base', 'upload_size', 'daemon_base',
'daemon_sftp', 'daemon_sftp_alias', 'daemon_listen', 'daemon_sftp', 'daemon_sftp_alias', 'daemon_listen',
'description', 'maintenance_mode', 'description', 'maintenance_mode', 'tags',
]; ];
public static array $validationRules = [ public static array $validationRules = [
'name' => 'required|regex:/^([\w .-]{1,100})$/', 'name' => 'required|string|min:1|max:100',
'description' => 'string|nullable', 'description' => 'string|nullable',
'public' => 'boolean', 'public' => 'boolean',
'fqdn' => 'required|string', 'fqdn' => 'required|string',

48
app/Models/Role.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Spatie\Permission\Models\Role as BaseRole;
/**
* @property int $id
* @property string $name
* @property string $guard_name
* @property \Illuminate\Database\Eloquent\Collection|\Spatie\Permission\Models\Permission[] $permissions
* @property int|null $permissions_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\User[] $users
* @property int|null $users_count
*/
class Role extends BaseRole
{
public const RESOURCE_NAME = 'role';
public const ROOT_ADMIN = 'Root Admin';
public const MODEL_SPECIFIC_PERMISSIONS = [
'egg' => [
'import',
'export',
],
];
public const SPECIAL_PERMISSIONS = [
'settings' => [
'view',
'update',
],
];
public function isRootAdmin(): bool
{
return $this->name === self::ROOT_ADMIN;
}
public static function getRootAdmin(): self
{
/** @var self $role */
$role = self::findOrCreate(self::ROOT_ADMIN);
return $role;
}
}

View File

@ -25,6 +25,9 @@ use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use App\Notifications\SendPasswordReset as ResetPasswordNotification; use App\Notifications\SendPasswordReset as ResetPasswordNotification;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model as IlluminateModel;
use Spatie\Permission\Traits\HasRoles;
/** /**
* App\Models\User. * App\Models\User.
@ -40,7 +43,6 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
* @property string|null $remember_token * @property string|null $remember_token
* @property string $language * @property string $language
* @property string $timezone * @property string $timezone
* @property bool $root_admin
* @property bool $use_totp * @property bool $use_totp
* @property string|null $totp_secret * @property string|null $totp_secret
* @property \Illuminate\Support\Carbon|null $totp_authenticated_at * @property \Illuminate\Support\Carbon|null $totp_authenticated_at
@ -77,7 +79,6 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
* @method static Builder|User whereNameLast($value) * @method static Builder|User whereNameLast($value)
* @method static Builder|User wherePassword($value) * @method static Builder|User wherePassword($value)
* @method static Builder|User whereRememberToken($value) * @method static Builder|User whereRememberToken($value)
* @method static Builder|User whereRootAdmin($value)
* @method static Builder|User whereTotpAuthenticatedAt($value) * @method static Builder|User whereTotpAuthenticatedAt($value)
* @method static Builder|User whereTotpSecret($value) * @method static Builder|User whereTotpSecret($value)
* @method static Builder|User whereUpdatedAt($value) * @method static Builder|User whereUpdatedAt($value)
@ -94,6 +95,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
use AvailableLanguages; use AvailableLanguages;
use CanResetPassword; use CanResetPassword;
use HasAccessTokens; use HasAccessTokens;
use HasRoles;
use Notifiable; use Notifiable;
public const USER_LEVEL_USER = 0; public const USER_LEVEL_USER = 0;
@ -131,7 +133,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'totp_secret', 'totp_secret',
'totp_authenticated_at', 'totp_authenticated_at',
'gravatar', 'gravatar',
'root_admin',
'oauth', 'oauth',
]; ];
@ -145,7 +146,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/ */
protected $attributes = [ protected $attributes = [
'external_id' => null, 'external_id' => null,
'root_admin' => false,
'language' => 'en', 'language' => 'en',
'timezone' => 'UTC', 'timezone' => 'UTC',
'use_totp' => false, 'use_totp' => false,
@ -166,7 +166,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'name_first' => 'nullable|string|between:0,255', 'name_first' => 'nullable|string|between:0,255',
'name_last' => 'nullable|string|between:0,255', 'name_last' => 'nullable|string|between:0,255',
'password' => 'sometimes|nullable|string', 'password' => 'sometimes|nullable|string',
'root_admin' => 'boolean',
'language' => 'string', 'language' => 'string',
'timezone' => 'string', 'timezone' => 'string',
'use_totp' => 'boolean', 'use_totp' => 'boolean',
@ -177,7 +176,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function casts(): array protected function casts(): array
{ {
return [ return [
'root_admin' => 'boolean',
'use_totp' => 'boolean', 'use_totp' => 'boolean',
'gravatar' => 'boolean', 'gravatar' => 'boolean',
'totp_authenticated_at' => 'datetime', 'totp_authenticated_at' => 'datetime',
@ -226,7 +224,10 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/ */
public function toReactObject(): array public function toReactObject(): array
{ {
return collect($this->toArray())->except(['id', 'external_id'])->toArray(); return array_merge(collect($this->toArray())->except(['id', 'external_id'])->toArray(), [
'root_admin' => $this->isRootAdmin(),
'admin' => $this->canAccessPanel(Filament::getPanel('admin')),
]);
} }
/** /**
@ -315,7 +316,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function checkPermission(Server $server, string $permission = ''): bool protected function checkPermission(Server $server, string $permission = ''): bool
{ {
if ($this->root_admin || $server->owner_id === $this->id) { if ($this->isRootAdmin() || $server->owner_id === $this->id) {
return true; return true;
} }
@ -351,14 +352,23 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
public function isLastRootAdmin(): bool public function isLastRootAdmin(): bool
{ {
$rootAdmins = User::query()->where('root_admin', true)->limit(2)->get(); $rootAdmins = User::all()->filter(fn ($user) => $user->isRootAdmin());
return once(fn () => $rootAdmins->count() === 1 && $rootAdmins->first()->is($this)); return once(fn () => $rootAdmins->count() === 1 && $rootAdmins->first()->is($this));
} }
public function isRootAdmin(): bool
{
return $this->hasRole(Role::ROOT_ADMIN);
}
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
return $this->root_admin; if ($this->isRootAdmin()) {
return true;
}
return $this->roles()->count() >= 1 && $this->getAllPermissions()->count() >= 1;
} }
public function getFilamentName(): string public function getFilamentName(): string
@ -370,4 +380,13 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
{ {
return 'https://gravatar.com/avatar/' . md5(strtolower($this->email)); return 'https://gravatar.com/avatar/' . md5(strtolower($this->email));
} }
public function canTarget(IlluminateModel $user): bool
{
if ($this->isRootAdmin()) {
return true;
}
return $user instanceof User && !$user->isRootAdmin();
}
} }

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class ApiKeyPolicy
{
use DefaultPolicies;
protected string $modelName = 'apikey';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class DatabaseHostPolicy
{
use DefaultPolicies;
protected string $modelName = 'databasehost';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class DatabasePolicy
{
use DefaultPolicies;
protected string $modelName = 'database';
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
trait DefaultPolicies
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->can('viewList ' . $this->modelName);
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Model $model): bool
{
return $user->can('view ' . $this->modelName, $model);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->can('create ' . $this->modelName);
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Model $model): bool
{
return $user->can('update ' . $this->modelName, $model);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Model $model): bool
{
return $user->can('delete ' . $this->modelName, $model);
}
}

View File

@ -2,12 +2,9 @@
namespace App\Policies; namespace App\Policies;
use App\Models\User;
class EggPolicy class EggPolicy
{ {
public function create(User $user): bool use DefaultPolicies;
{
return true; protected string $modelName = 'egg';
}
} }

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class MountPolicy
{
use DefaultPolicies;
protected string $modelName = 'mount';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class NodePolicy
{
use DefaultPolicies;
protected string $modelName = 'node';
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class RolePolicy
{
use DefaultPolicies;
protected string $modelName = 'role';
}

View File

@ -2,34 +2,38 @@
namespace App\Policies; namespace App\Policies;
use App\Models\User;
use App\Models\Server; use App\Models\Server;
use App\Models\User;
class ServerPolicy class ServerPolicy
{ {
use DefaultPolicies;
protected string $modelName = 'server';
/** /**
* Checks if the user has the given permission on/for the server. * Runs before any of the functions are called. Used to determine if the (sub-)user has permissions.
*/ */
protected function checkPermission(User $user, Server $server, string $permission): bool public function before(User $user, string $ability, string|Server $server): ?bool
{ {
$subuser = $server->subusers->where('user_id', $user->id)->first(); // For "viewAny" the $server param is the class name
if (!$subuser || empty($permission)) { if (is_string($server)) {
return false; return null;
} }
return in_array($permission, $subuser->permissions); // Owner has full server permissions
} if ($server->owner_id === $user->id) {
/**
* Runs before any of the functions are called. Used to determine if user is root admin, if so, ignore permissions.
*/
public function before(User $user, string $ability, Server $server): bool
{
if ($user->root_admin || $server->owner_id === $user->id) {
return true; return true;
} }
return $this->checkPermission($user, $server, $ability); $subuser = $server->subusers->where('user_id', $user->id)->first();
// If the user is a subuser check their permissions
if ($subuser) {
return in_array($ability, $subuser->permissions);
}
// Return null to let default policies take over
return null;
} }
/** /**

View File

@ -0,0 +1,26 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
class UserPolicy
{
use DefaultPolicies {
update as defaultUpdate;
delete as defaultDelete;
}
protected string $modelName = 'user';
public function update(User $user, Model $model): bool
{
return $user->canTarget($model) && $this->defaultUpdate($user, $model);
}
public function delete(User $user, Model $model): bool
{
return $user->canTarget($model) && $this->defaultDelete($user, $model);
}
}

View File

@ -7,7 +7,7 @@ use App\Livewire\EndpointSynth;
use App\Models; use App\Models;
use App\Models\ApiKey; use App\Models\ApiKey;
use App\Models\Node; use App\Models\Node;
use App\Services\Helpers\SoftwareVersionService; use App\Models\User;
use Dedoc\Scramble\Scramble; use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi; use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme; use Dedoc\Scramble\Support\Generator\SecurityScheme;
@ -33,9 +33,9 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
$versionData = app(SoftwareVersionService::class)->versionData(); // TODO: remove when old admin area gets yeeted
View::share('appVersion', $versionData['version'] ?? 'undefined'); View::share('appVersion', config('app.version'));
View::share('appIsGit', $versionData['is_git'] ?? false); View::share('appIsGit', false);
Paginator::useBootstrap(); Paginator::useBootstrap();
@ -94,6 +94,10 @@ class AppServiceProvider extends ServiceProvider
'success' => Color::Green, 'success' => Color::Green,
'warning' => Color::Amber, 'warning' => Color::Amber,
]); ]);
Gate::before(function (User $user, $ability) {
return $user->isRootAdmin() ? true : null;
});
} }
/** /**

View File

@ -24,7 +24,7 @@ class AdminPanelProvider extends PanelProvider
public function boot() public function boot()
{ {
FilamentAsset::registerCssVariables([ FilamentAsset::registerCssVariables([
'sidebar-width' => '14rem !important', 'sidebar-width' => '16rem !important',
]); ]);
} }
@ -46,9 +46,7 @@ class AdminPanelProvider extends PanelProvider
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters') ->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\\Filament\\Clusters')
->pages([ ->spa()
// Pages\Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([ ->widgets([
Widgets\AccountWidget::class, Widgets\AccountWidget::class,

View File

@ -32,6 +32,7 @@ class AdminAcl
public const RESOURCE_DATABASE_HOSTS = 'database_hosts'; public const RESOURCE_DATABASE_HOSTS = 'database_hosts';
public const RESOURCE_SERVER_DATABASES = 'server_databases'; public const RESOURCE_SERVER_DATABASES = 'server_databases';
public const RESOURCE_MOUNTS = 'mounts'; public const RESOURCE_MOUNTS = 'mounts';
public const RESOURCE_ROLES = 'roles';
/** /**
* Determine if an API key has permission to perform a specific read/write operation. * Determine if an API key has permission to perform a specific read/write operation.

View File

@ -14,10 +14,10 @@ class GetUserPermissionsService
*/ */
public function handle(Server $server, User $user): array public function handle(Server $server, User $user): array
{ {
if ($user->root_admin || $user->id === $server->owner_id) { if ($user->isRootAdmin() || $user->id === $server->owner_id) {
$permissions = ['*']; $permissions = ['*'];
if ($user->root_admin) { if ($user->isRootAdmin()) {
$permissions[] = 'admin.websocket.errors'; $permissions[] = 'admin.websocket.errors';
$permissions[] = 'admin.websocket.install'; $permissions[] = 'admin.websocket.install';
$permissions[] = 'admin.websocket.transfer'; $permissions[] = 'admin.websocket.transfer';

View File

@ -2,6 +2,7 @@
namespace App\Services\Users; namespace App\Services\Users;
use App\Models\Role;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Contracts\Hashing\Hasher;
@ -39,10 +40,17 @@ class UserCreationService
$data['password'] = $this->hasher->make(str_random(30)); $data['password'] = $this->hasher->make(str_random(30));
} }
$isRootAdmin = array_key_exists('root_admin', $data) && $data['root_admin'];
unset($data['root_admin']);
$user = User::query()->forceCreate(array_merge($data, [ $user = User::query()->forceCreate(array_merge($data, [
'uuid' => Uuid::uuid4()->toString(), 'uuid' => Uuid::uuid4()->toString(),
])); ]));
if ($isRootAdmin) {
$user->syncRoles(Role::getRootAdmin());
}
if (isset($generateResetToken)) { if (isset($generateResetToken)) {
$token = $this->passwordBroker->createToken($user); $token = $this->passwordBroker->createToken($user);
} }

View File

@ -14,7 +14,7 @@ trait EnvironmentWriterTrait
public function escapeEnvironmentValue(string $value): string public function escapeEnvironmentValue(string $value): string
{ {
if (!preg_match('/^\"(.*)\"$/', $value) && preg_match('/([^\w.\-+\/])+/', $value)) { if (!preg_match('/^\"(.*)\"$/', $value) && preg_match('/([^\w.\-+\/])+/', $value)) {
return sprintf('"%s"', addslashes($value)); return sprintf('"%s"', addcslashes($value, '\\"'));
} }
return $value; return $value;

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