This commit is contained in:
Saeed Vaziry 2024-09-27 20:36:03 +02:00 committed by GitHub
parent b62c40c97d
commit f6bc04763b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
122 changed files with 6609 additions and 807 deletions

View File

@ -7,6 +7,7 @@ indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
ij_any_block_comment_at_first_column = false
[*.md]
trim_trailing_whitespace = false

1
.gitignore vendored
View File

@ -21,3 +21,4 @@ yarn-error.log
/.fleet
/.idea
/.vscode
laradumps.yaml

View File

@ -0,0 +1,33 @@
<?php
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\User;
use Illuminate\Database\Query\Builder;
use Illuminate\Validation\Rule;
class AddUser
{
public function add(Project $project, array $input): void
{
/** @var User $user */
$user = User::query()->findOrFail($input['user']);
$project->users()->detach($user);
$project->users()->attach($user);
}
public static function rules(Project $project): array
{
return [
'user' => [
'required',
Rule::exists('users', 'id'),
Rule::unique('user_project', 'user_id')->where(function (Builder $query) use ($project) {
$query->where('project_id', $project->id);
}),
],
];
}
}

View File

@ -27,15 +27,21 @@ public function create(User $user, array $input): Project
return $project;
}
private function validate(array $input): void
public static function rules(): array
{
Validator::make($input, [
return [
'name' => [
'required',
'string',
'max:255',
'unique:projects,name',
'lowercase:projects,name',
],
])->validate();
];
}
private function validate(array $input): void
{
Validator::make($input, self::rules())->validate();
}
}

View File

@ -23,15 +23,21 @@ public function update(Project $project, array $input): Project
return $project;
}
private function validate(Project $project, array $input): void
public static function rules(Project $project): array
{
Validator::make($input, [
return [
'name' => [
'required',
'string',
'max:255',
Rule::unique('projects')->where('user_id', $project->user_id)->ignore($project->id),
Rule::unique('projects', 'name')->ignore($project->id),
'lowercase:projects,name',
],
])->validate();
];
}
private function validate(Project $project, array $input): void
{
Validator::make($input, self::rules($project))->validate();
}
}

View File

@ -5,7 +5,6 @@
use App\Enums\FirewallRuleStatus;
use App\Enums\ServerProvider;
use App\Enums\ServerStatus;
use App\Exceptions\ServerProviderError;
use App\Facades\Notifier;
use App\Models\Server;
use App\Models\User;
@ -16,10 +15,8 @@
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Throwable;
class CreateServer
@ -29,8 +26,6 @@ class CreateServer
*/
public function create(User $creator, array $input): Server
{
$this->validateInputs($input);
$server = new Server([
'project_id' => $creator->currentProject->id,
'user_id' => $creator->id,
@ -56,12 +51,8 @@ public function create(User $creator, array $input): Server
$server->provider_id = $input['server_provider'];
}
// validate type
$this->validateType($server, $input);
$server->type_data = $server->type()->data($input);
// validate provider
$this->validateProvider($server, $input);
$server->provider_data = $server->provider()->data($input);
// save
@ -85,11 +76,6 @@ public function create(User $creator, array $input): Server
} catch (Exception $e) {
$server->provider()->delete();
DB::rollBack();
if ($e instanceof ServerProviderError) {
throw ValidationException::withMessages([
'provider' => __('Provider Error: ').$e->getMessage(),
]);
}
throw $e;
}
}
@ -126,59 +112,82 @@ function () use ($server) {
$bus->onConnection('ssh')->dispatch();
}
/**
* @throws ValidationException
*/
private function validateInputs(array $input): void
public static function rules(array $input): array
{
$rules = [
'provider' => 'required|in:'.implode(',', config('core.server_providers')),
'name' => 'required',
'os' => 'required|in:'.implode(',', config('core.operating_systems')),
'provider' => [
'required',
Rule::in(config('core.server_providers')),
],
'name' => [
'required',
],
'os' => [
'required',
Rule::in(config('core.operating_systems')),
],
'type' => [
'required',
Rule::in(config('core.server_types')),
],
'server_provider' => [
Rule::when(function () use ($input) {
return $input['provider'] != ServerProvider::CUSTOM;
}, [
'required',
'exists:server_providers,id,user_id,'.auth()->user()->id,
]),
],
'ip' => [
Rule::when(function () use ($input) {
return $input['provider'] == ServerProvider::CUSTOM;
}, [
'required',
new RestrictedIPAddressesRule,
]),
],
'port' => [
Rule::when(function () use ($input) {
return $input['provider'] == ServerProvider::CUSTOM;
}, [
'required',
'numeric',
'min:1',
'max:65535',
]),
],
];
Validator::make($input, $rules)->validate();
if ($input['provider'] != 'custom') {
$rules['server_provider'] = 'required|exists:server_providers,id,user_id,'.auth()->user()->id;
}
if ($input['provider'] == 'custom') {
$rules['ip'] = [
'required',
new RestrictedIPAddressesRule(),
];
$rules['port'] = [
'required',
'numeric',
'min:1',
'max:65535',
];
}
Validator::make($input, $rules)->validate();
return array_merge($rules, self::typeRules($input), self::providerRules($input));
}
/**
* @throws ValidationException
*/
private function validateType(Server $server, array $input): void
private static function typeRules(array $input): array
{
Validator::make($input, $server->type()->createRules($input))
->validate();
if (! isset($input['type']) || ! in_array($input['type'], config('core.server_types'))) {
return [];
}
$server = new Server(['type' => $input['type']]);
return $server->type()->createRules($input);
}
/**
* @throws ValidationException
*/
private function validateProvider(Server $server, array $input): void
private static function providerRules(array $input): array
{
Validator::make($input, $server->provider()->createRules($input))
->validate();
if (
! isset($input['provider']) ||
! in_array($input['provider'], config('core.server_providers')) ||
$input['provider'] == ServerProvider::CUSTOM
) {
return [];
}
$server = new Server([
'provider' => $input['provider'],
'provider_id' => $input['server_provider'],
]);
return $server->provider()->createRules($input);
}
private function createFirewallRules(Server $server): void

View File

@ -4,7 +4,7 @@
use App\Models\Server;
use App\ValidationRules\RestrictedIPAddressesRule;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class EditServer
@ -14,8 +14,6 @@ class EditServer
*/
public function edit(Server $server, array $input): Server
{
$this->validate($input);
$checkConnection = false;
if (isset($input['name'])) {
$server->name = $input['name'];
@ -41,15 +39,24 @@ public function edit(Server $server, array $input): Server
return $server;
}
/**
* @throws ValidationException
*/
protected function validate(array $input): void
public static function rules(Server $server): array
{
Validator::make($input, [
'ip' => [
new RestrictedIPAddressesRule(),
return [
'name' => [
'string',
'max:255',
Rule::unique('servers')->where('project_id', $server->project_id)->ignore($server->id),
],
])->validateWithBag('editServer');
'ip' => [
'string',
new RestrictedIPAddressesRule,
Rule::unique('servers')->where('project_id', $server->project_id)->ignore($server->id),
],
'port' => [
'integer',
'min:1',
'max:65535',
],
];
}
}

View File

@ -6,7 +6,6 @@
use App\Models\User;
use App\ServerProviders\ServerProvider as ServerProviderContract;
use Exception;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
@ -17,11 +16,7 @@ class CreateServerProvider
*/
public function create(User $user, array $input): ServerProvider
{
$this->validateInput($input);
$provider = $this->getProvider($input['provider']);
$this->validateProvider($provider, $input);
$provider = static::getProvider($input['provider']);
try {
$provider->connect($input);
@ -33,7 +28,7 @@ public function create(User $user, array $input): ServerProvider
]);
}
$serverProvider = new ServerProvider();
$serverProvider = new ServerProvider;
$serverProvider->user_id = $user->id;
$serverProvider->profile = $input['name'];
$serverProvider->provider = $input['provider'];
@ -44,19 +39,16 @@ public function create(User $user, array $input): ServerProvider
return $serverProvider;
}
private function getProvider($name): ServerProviderContract
private static function getProvider($name): ServerProviderContract
{
$providerClass = config('core.server_providers_class.'.$name);
return new $providerClass();
return new $providerClass;
}
/**
* @throws ValidationException
*/
private function validateInput(array $input): void
public static function rules(): array
{
Validator::make($input, [
return [
'name' => [
'required',
],
@ -65,14 +57,11 @@ private function validateInput(array $input): void
Rule::in(config('core.server_providers')),
Rule::notIn('custom'),
],
])->validate();
];
}
/**
* @throws ValidationException
*/
private function validateProvider(ServerProviderContract $provider, array $input): void
public static function providerRules(array $input): array
{
Validator::make($input, $provider->credentialValidationRules($input))->validate();
return static::getProvider($input['provider'])->credentialValidationRules($input);
}
}

View File

@ -4,31 +4,23 @@
use App\Models\ServerProvider;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class EditServerProvider
{
public function edit(ServerProvider $serverProvider, User $user, array $input): void
{
$this->validate($input);
$serverProvider->profile = $input['name'];
$serverProvider->project_id = isset($input['global']) && $input['global'] ? null : $user->current_project_id;
$serverProvider->save();
}
/**
* @throws ValidationException
*/
private function validate(array $input): void
public static function rules(): array
{
$rules = [
return [
'name' => [
'required',
],
];
Validator::make($input, $rules)->validate();
}
}

View File

@ -101,22 +101,26 @@ public function create(Server $server, array $input): Site
/**
* @throws ValidationException
*/
private function validateInputs(Server $server, array $input): void
public static function rules(array $input): void
{
$rules = [
'server_id' => [
'required',
'exists:servers,id',
],
'type' => [
'required',
Rule::in(config('core.site_types')),
],
'domain' => [
'required',
new DomainRule(),
new DomainRule,
Rule::unique('sites', 'domain')->where(function ($query) use ($server) {
return $query->where('server_id', $server->id);
}),
],
'aliases.*' => [
new DomainRule(),
new DomainRule,
],
];

View File

@ -27,7 +27,12 @@ public function create(array $input): User
private function validate(array $input): void
{
Validator::make($input, [
Validator::make($input, self::rules())->validate();
}
public static function rules(): array
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8',
@ -35,6 +40,6 @@ private function validate(array $input): void
'required',
Rule::in([UserRole::ADMIN, UserRole::USER]),
],
])->validate();
];
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Actions\User;
use App\Models\Project;
use App\Models\User;
use Illuminate\Validation\Rule;
class UpdateProjects
{
public function update(User $user, array $input): void
{
$this->validate($input);
$user->projects()->sync($input['projects']);
if ($user->currentProject && !$user->projects->contains($user->currentProject)) {
$user->current_project_id = null;
$user->save();
}
/** @var Project $firstProject */
$firstProject = $user->projects->first();
if (!$user->currentProject && $firstProject) {
$user->current_project_id = $firstProject->id;
$user->save();
}
}
private function validate(array $input): void
{
validator($input, self::rules())->validate();
}
public static function rules(): array
{
return [
'projects.*' => [
'required',
Rule::exists('projects', 'id'),
],
];
}
}

View File

@ -9,7 +9,7 @@
class UpdateUser
{
public function update(User $user, array $input): void
public function update(User $user, array $input): User
{
$this->validate($user, $input);
@ -18,18 +18,29 @@ public function update(User $user, array $input): void
$user->timezone = $input['timezone'];
$user->role = $input['role'];
if (isset($input['password']) && $input['password'] !== null) {
if (isset($input['password'])) {
$user->password = bcrypt($input['password']);
}
$user->save();
return $user;
}
private function validate(User $user, array $input): void
{
Validator::make($input, [
Validator::make($input, self::rules($user))->validate();
}
public static function rules(User $user): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'email' => [
'required',
'email', 'max:255',
Rule::unique('users', 'email')->ignore($user->id)
],
'timezone' => [
'required',
Rule::in(timezone_identifiers_list()),
@ -37,12 +48,7 @@ private function validate(User $user, array $input): void
'role' => [
'required',
Rule::in([UserRole::ADMIN, UserRole::USER]),
function ($attribute, $value, $fail) use ($user) {
if ($user->is(auth()->user()) && $value !== $user->role) {
$fail('You cannot change your own role');
}
},
],
])->validate();
];
}
}

View File

@ -3,23 +3,22 @@
namespace App\Actions\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class UpdateUserPassword
{
/**
* Validate and update the user's password.
*/
public function update($user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current-password'],
'password' => ['required', 'string'],
'password_confirmation' => ['required', 'same:password'],
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
public static function rules(): array
{
return [
'current_password' => ['required', 'string', 'current-password'],
'password' => ['required', 'string'],
'password_confirmation' => ['required', 'same:password'],
];
}
}

View File

@ -3,28 +3,12 @@
namespace App\Actions\User;
use App\Models\User;
use Exception;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class UpdateUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @throws Exception
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'timezone' => [
'required',
Rule::in(timezone_identifiers_list()),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email) {
$this->updateVerifiedUser($user, $input);
} else {
@ -36,6 +20,18 @@ public function update(User $user, array $input): void
}
}
public static function rules(User $user): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'timezone' => [
'required',
Rule::in(timezone_identifiers_list()),
],
];
}
/**
* Update the given verified user's profile information.
*/

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Settings;
use App\Actions\User\CreateUser;
use App\Actions\User\UpdateProjects;
use App\Actions\User\UpdateUser;
use App\Facades\Toast;
use App\Helpers\HtmxResponse;
@ -48,26 +49,7 @@ public function update(User $user, Request $request): RedirectResponse
public function updateProjects(User $user, Request $request): HtmxResponse
{
$this->validate($request, [
'projects.*' => [
'required',
Rule::exists('projects', 'id'),
],
]);
$user->projects()->sync($request->projects);
if ($user->currentProject && ! $user->projects->contains($user->currentProject)) {
$user->current_project_id = null;
$user->save();
}
/** @var Project $firstProject */
$firstProject = $user->projects->first();
if (! $user->currentProject && $firstProject) {
$user->current_project_id = $firstProject->id;
$user->save();
}
app(UpdateProjects::class)->update($user, $request->input());
Toast::success('Projects updated successfully');

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\HasTimezoneTimestamps;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
@ -12,6 +13,8 @@
*/
abstract class AbstractModel extends Model
{
use HasTimezoneTimestamps;
public function jsonUpdate(string $field, string $key, mixed $value, bool $save = true): void
{
$current = $this->{$field};

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Traits\HasTimezoneTimestamps;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -18,12 +19,14 @@
* @property Carbon $updated_at
* @property User $user
* @property Collection<Server> $servers
* @property Collection<User> $users
* @property Collection<NotificationChannel> $notificationChannels
* @property Collection<SourceControl> $sourceControls
*/
class Project extends Model
{
use HasFactory;
use HasTimezoneTimestamps;
protected $fillable = [
'user_id',

View File

@ -3,12 +3,14 @@
namespace App\Models;
use App\Actions\Server\CheckConnection;
use App\Enums\ServerStatus;
use App\Enums\ServiceStatus;
use App\Facades\SSH;
use App\ServerTypes\ServerType;
use App\SSH\Cron\Cron;
use App\SSH\OS\OS;
use App\SSH\Systemd\Systemd;
use App\Support\Testing\SSHFake;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -56,6 +58,7 @@
* @property Backup[] $backups
* @property Queue[] $daemons
* @property SshKey[] $sshKeys
* @property Tag[] $tags
* @property string $hostname
* @property int $updates
* @property Carbon $last_update_check
@ -138,6 +141,29 @@ public static function boot(): void
});
}
public static array $statusColors = [
ServerStatus::READY => 'success',
ServerStatus::INSTALLING => 'warning',
ServerStatus::DISCONNECTED => 'gray',
ServerStatus::INSTALLATION_FAILED => 'danger',
ServerStatus::UPDATING => 'warning',
];
public function isReady(): bool
{
return $this->status === ServerStatus::READY;
}
public function isInstalling(): bool
{
return in_array($this->status, [ServerStatus::INSTALLING, ServerStatus::INSTALLATION_FAILED]);
}
public function isInstallationFailed(): bool
{
return $this->status === ServerStatus::INSTALLATION_FAILED;
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class, 'project_id');
@ -268,7 +294,7 @@ public function defaultService($type): ?Service
return $service;
}
public function ssh(?string $user = null): mixed
public function ssh(?string $user = null): \App\Helpers\SSH|SSHFake
{
return SSH::init($this, $user);
}
@ -295,7 +321,7 @@ public function provider(): \App\ServerProviders\ServerProvider
{
$providerClass = config('core.server_providers_class')[$this->provider];
return new $providerClass($this);
return new $providerClass($this->serverProvider, $this);
}
public function webserver(?string $version = null): ?Service
@ -404,4 +430,13 @@ public function checkForUpdates(): void
$this->last_update_check = now();
$this->save();
}
public function getAvailableUpdatesAttribute(?int $value): int
{
if (! $value) {
return 0;
}
return $value;
}
}

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Cache;
/**
* @property int $user_id
@ -15,6 +16,9 @@
* @property bool $connected
* @property User $user
* @property ?int $project_id
* @property Server[] $servers
* @property Project $project
* @property string $image_url
*/
class ServerProvider extends AbstractModel
{
@ -51,6 +55,13 @@ public function servers(): HasMany
return $this->hasMany(Server::class, 'provider_id');
}
public function provider(): \App\ServerProviders\ServerProvider
{
$providerClass = config('core.server_providers_class')[$this->provider];
return new $providerClass($this);
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
@ -59,7 +70,54 @@ public function project(): BelongsTo
public static function getByProjectId(int $projectId): Builder
{
return self::query()
->where('project_id', $projectId)
->orWhereNull('project_id');
->where(function (Builder $query) use ($projectId) {
$query->where('project_id', $projectId)
->orWhereNull('project_id');
});
}
public function getImageUrlAttribute(): string
{
return url('/static/images/'.$this->provider.'.svg');
}
public static function regions(?int $id): array
{
if (! $id) {
return [];
}
$profile = self::find($id);
if (! $profile) {
return [];
}
if (Cache::get('regions-'.$id)) {
return Cache::get('regions-'.$id);
}
$regions = $profile->provider()->regions();
Cache::put('regions-'.$id, $regions, 600);
return $regions;
}
public static function plans(?int $id, ?string $region): array
{
if (! $id) {
return [];
}
$profile = self::find($id);
if (! $profile) {
return [];
}
if (Cache::get('plans-'.$id.'-'.$region)) {
return Cache::get('plans-'.$id.'-'.$region);
}
$plans = $profile->provider()->plans($region);
Cache::put('plans-'.$id.'-'.$region, $plans, 600);
return $plans;
}
}

View File

@ -2,10 +2,12 @@
namespace App\Models;
use App\Enums\SiteStatus;
use App\Exceptions\SourceControlIsNotConnected;
use App\Exceptions\SSHError;
use App\SiteTypes\SiteType;
use App\SSH\Services\Webserver\Webserver;
use App\Traits\HasProjectThroughServer;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -44,6 +46,7 @@
class Site extends AbstractModel
{
use HasFactory;
use HasProjectThroughServer;
protected $fillable = [
'server_id',
@ -73,6 +76,13 @@ class Site extends AbstractModel
'source_control_id' => 'integer',
];
public static array $statusColors = [
SiteStatus::READY => 'success',
SiteStatus::INSTALLING => 'warning',
SiteStatus::INSTALLATION_FAILED => 'danger',
SiteStatus::DELETING => 'danger',
];
public static function boot(): void
{
parent::boot();

View File

@ -3,8 +3,13 @@
namespace App\Models;
use App\Enums\UserRole;
use App\Traits\HasTimezoneTimestamps;
use Carbon\Carbon;
use Filament\Models\Contracts\HasTenants;
use Filament\Panel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
@ -34,10 +39,13 @@
* @property Project $currentProject
* @property Collection<Project> $projects
* @property string $role
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class User extends Authenticatable
class User extends Authenticatable implements HasTenants
{
use HasFactory;
use HasTimezoneTimestamps;
use Notifiable;
use TwoFactorAuthenticatable;
@ -111,6 +119,16 @@ public function projects(): BelongsToMany
return $this->belongsToMany(Project::class, 'user_project')->withTimestamps();
}
public function getTenants(Panel $panel): Collection
{
return $this->projects;
}
public function canAccessTenant(Model $tenant): bool
{
return $this->projects()->whereKey($tenant)->exists();
}
public function currentProject(): HasOne
{
return $this->HasOne(Project::class, 'id', 'current_project_id');
@ -121,7 +139,7 @@ public function createDefaultProject(): Project
$project = $this->projects()->first();
if (! $project) {
$project = new Project();
$project = new Project;
$project->name = 'default';
$project->save();

View File

@ -2,7 +2,6 @@
namespace App\Policies;
use App\Enums\UserRole;
use App\Models\Project;
use App\Models\User;
@ -10,26 +9,26 @@ class ProjectPolicy
{
public function viewAny(User $user): bool
{
return $user->role === UserRole::ADMIN;
return $user->isAdmin();
}
public function view(User $user, Project $project): bool
{
return $user->role === UserRole::ADMIN || $project->users->contains($user);
return $user->isAdmin() || $project->users->contains($user);
}
public function create(User $user): bool
{
return $user->role === UserRole::ADMIN;
return $user->isAdmin();
}
public function update(User $user, Project $project): bool
{
return $user->role === UserRole::ADMIN;
return $user->isAdmin();
}
public function delete(User $user, Project $project): bool
{
return $user->role === UserRole::ADMIN;
return $user->isAdmin();
}
}

View File

@ -2,40 +2,38 @@
namespace App\Policies;
use App\Enums\UserRole;
use App\Models\Project;
use App\Models\Server;
use App\Models\User;
class ServerPolicy
{
public function viewAny(User $user, Project $project): bool
public function viewAny(User $user): bool
{
return $user->role === UserRole::ADMIN || $project->users->contains($user);
return $user->isAdmin() || $user->currentProject?->users->contains($user);
}
public function view(User $user, Server $server): bool
{
return $user->role === UserRole::ADMIN || $server->project->users->contains($user);
return $user->isAdmin() || $server->project->users->contains($user);
}
public function create(User $user, Project $project): bool
public function create(User $user): bool
{
return $user->role === UserRole::ADMIN || $project->users->contains($user);
return $user->isAdmin() || $user->currentProject?->users->contains($user);
}
public function update(User $user, Server $server): bool
{
return $user->role === UserRole::ADMIN || $server->project->users->contains($user);
return $user->isAdmin() || $server->project->users->contains($user);
}
public function delete(User $user, Server $server): bool
{
return $user->role === UserRole::ADMIN || $server->project->users->contains($user);
return $user->isAdmin() || $server->project->users->contains($user);
}
public function manage(User $user, Server $server): bool
{
return $user->role === UserRole::ADMIN || $server->project->users->contains($user);
return $user->isAdmin() || $server->project->users->contains($user);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Policies;
use App\Models\ServerProvider;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class ServerProviderPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
return $user->isAdmin();
}
public function view(User $user, ServerProvider $serverProvider): bool
{
return $user->isAdmin();
}
public function create(User $user): bool
{
return $user->isAdmin();
}
public function update(User $user, ServerProvider $serverProvider): bool
{
return $user->isAdmin();
}
public function delete(User $user, ServerProvider $serverProvider): bool
{
return $user->isAdmin();
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Policies;
use App\Models\Server;
use App\Models\Site;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class SitePolicy
{
use HandlesAuthorization;
public function viewAny(User $user, Server $server): bool
{
return ($user->isAdmin() || $server->project->users->contains($user)) && $server->isReady();
}
public function view(User $user, Site $site): bool
{
return ($user->isAdmin() || $site->server->project->users->contains($user)) &&
$site->server->isReady();
}
public function create(User $user, Server $server): bool
{
return ($user->isAdmin() || $server->project->users->contains($user)) && $server->isReady();
}
public function update(User $user, Site $site): bool
{
return ($user->isAdmin() || $site->server->project->users->contains($user)) &&
$site->server->isReady();
}
public function delete(User $user, Site $site): bool
{
return ($user->isAdmin() || $site->server->project->users->contains($user)) &&
$site->server->isReady();
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class UserPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
return $user->isAdmin();
}
public function view(User $user, User $model): bool
{
return $user->isAdmin();
}
public function create(User $user): bool
{
return $user->isAdmin();
}
public function update(User $user, User $model): bool
{
return $user->isAdmin();
}
public function delete(User $user, User $model): bool
{
return $user->isAdmin();
}
}

View File

@ -17,7 +17,7 @@ class RouteServiceProvider extends ServiceProvider
*
* @var string
*/
public const HOME = '/servers';
public const HOME = '/app';
/**
* Define your route model bindings, pattern filters, and other route configuration.

View File

@ -0,0 +1,99 @@
<?php
namespace App\Providers;
use App\Web\Pages\Settings\Projects\Widgets\SelectProject;
use Exception;
use Filament\Facades\Filament;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Panel;
use Filament\Support\Colors\Color;
use Filament\Support\Facades\FilamentColor;
use Filament\Support\Facades\FilamentView;
use Filament\View\PanelsRenderHook;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Livewire\Livewire;
class WebServiceProvider extends ServiceProvider
{
/**
* @throws Exception
*/
public function register(): void
{
Filament::registerPanel($this->panel(Panel::make()));
}
public function boot(): void
{
FilamentView::registerRenderHook(
PanelsRenderHook::SIDEBAR_NAV_START,
fn () => Livewire::mount(SelectProject::class)
);
FilamentColor::register([
'slate' => Color::Slate,
'gray' => Color::Zinc,
'red' => Color::Red,
'orange' => Color::Orange,
'amber' => Color::Amber,
'yellow' => Color::Yellow,
'lime' => Color::Lime,
'green' => Color::Green,
'emerald' => Color::Emerald,
'teal' => Color::Teal,
'cyan' => Color::Cyan,
'sky' => Color::Sky,
'blue' => Color::Blue,
'indigo' => Color::Indigo,
'violet' => Color::Violet,
'purple' => Color::Purple,
'fuchsia' => Color::Fuchsia,
'pink' => Color::Pink,
'rose' => Color::Rose,
]);
}
/**
* @throws Exception
*/
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('app')
->path('app')
->passwordReset()
->colors([
'primary' => Color::Indigo,
])
->viteTheme('resources/css/filament/app/theme.css')
->brandLogo(fn () => view('web.components.brand'))
->brandLogoHeight('30px')
->discoverPages(in: app_path('Web/Pages'), for: 'App\\Web\\Pages')
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
])
->globalSearchKeyBindings(['command+k', 'ctrl+k'])
->globalSearchFieldKeyBindingSuffix();
}
}

View File

@ -10,7 +10,6 @@
use Exception;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Throwable;
class AWS extends AbstractProvider
@ -21,12 +20,7 @@ class AWS extends AbstractProvider
public function createRules(array $input): array
{
$rules = [
'os' => [
'required',
Rule::in(config('core.operating_systems')),
],
];
$rules = [];
// plans
$plans = [];
foreach (config('serverproviders.aws.plans') as $plan) {
@ -82,14 +76,18 @@ public function connect(?array $credentials = null): bool
}
}
public function plans(): array
public function plans(?string $region): array
{
return config('serverproviders.aws.plans');
return collect(config('serverproviders.aws.plans'))
->mapWithKeys(fn ($value) => [$value['value'] => $value['title']])
->toArray();
}
public function regions(): array
{
return config('serverproviders.aws.regions');
return collect(config('serverproviders.aws.regions'))
->mapWithKeys(fn ($value) => [$value['value'] => $value['title']])
->toArray();
}
public function create(): void

View File

@ -3,15 +3,19 @@
namespace App\ServerProviders;
use App\Models\Server;
use App\Models\ServerProvider as Provider;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
abstract class AbstractProvider implements ServerProvider
{
protected ?Provider $serverProvider;
protected ?Server $server;
public function __construct(?Server $server = null)
public function __construct(?Provider $serverProvider = null, ?Server $server = null)
{
$this->serverProvider = $serverProvider;
$this->server = $server;
}

View File

@ -15,7 +15,7 @@ public function createRules(array $input): array
'ip' => [
'required',
Rule::unique('servers', 'ip'),
new RestrictedIPAddressesRule(),
new RestrictedIPAddressesRule,
],
'port' => [
'required',
@ -46,7 +46,7 @@ public function connect(?array $credentials = null): bool
return true;
}
public function plans(): array
public function plans(?string $region): array
{
return [];
}

View File

@ -16,9 +16,7 @@ class DigitalOcean extends AbstractProvider
public function createRules(array $input): array
{
$rules = [
'os' => 'required|in:'.implode(',', config('core.operating_systems')),
];
$rules = [];
// plans
$plans = [];
foreach (config('serverproviders.digitalocean.plans') as $plan) {
@ -70,14 +68,18 @@ public function connect(?array $credentials = null): bool
return true;
}
public function plans(): array
public function plans(?string $region): array
{
return config('serverproviders.digitalocean.plans');
return collect(config('serverproviders.digitalocean.plans'))
->mapWithKeys(fn ($value) => [$value['value'] => $value['title']])
->toArray();
}
public function regions(): array
{
return config('serverproviders.digitalocean.regions');
return collect(config('serverproviders.digitalocean.regions'))
->mapWithKeys(fn ($value) => [$value['value'] => $value['title']])
->toArray();
}
/**

View File

@ -6,6 +6,8 @@
use App\Exceptions\ServerProviderError;
use App\Facades\Notifier;
use App\Notifications\FailedToDeleteServerFromProvider;
use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
@ -16,7 +18,6 @@ class Hetzner extends AbstractProvider
public function createRules(array $input): array
{
return [
'os' => 'required|in:'.implode(',', config('core.operating_systems')),
'plan' => 'required',
'region' => 'required',
];
@ -46,6 +47,7 @@ public function data(array $input): array
/**
* @throws CouldNotConnectToProvider
* @throws ConnectionException
*/
public function connect(?array $credentials = null): bool
{
@ -57,18 +59,53 @@ public function connect(?array $credentials = null): bool
return true;
}
public function plans(): array
public function plans(?string $region): array
{
return config('serverproviders.hetzner.plans');
try {
$plans = Http::withToken($this->serverProvider->credentials['token'])
->get($this->apiUrl.'/server_types', ['per_page' => 50])
->json();
return collect($plans['server_types'])->filter(function ($type) use ($region) {
return collect($type['prices'])->filter(function ($price) use ($region) {
return $price['location'] === $region;
});
})
->mapWithKeys(function ($value) {
return [
$value['name'] => __('server_providers.plan', [
'name' => $value['name'],
'cpu' => $value['cores'],
'architecture' => $value['architecture'],
'memory' => $value['memory'],
'disk' => $value['disk'],
]),
];
})
->toArray();
} catch (Exception) {
return [];
}
}
public function regions(): array
{
return config('serverproviders.hetzner.regions');
try {
$regions = Http::withToken($this->serverProvider->credentials['token'])
->get($this->apiUrl.'/locations', ['per_page' => 50])
->json();
return collect($regions['locations'])
->mapWithKeys(fn ($value) => [$value['name'] => $value['city'].' - '.$value['country']])
->toArray();
} catch (Exception) {
return [];
}
}
/**
* @throws ServerProviderError
* @throws ConnectionException
*/
public function create(): void
{
@ -106,6 +143,9 @@ public function create(): void
$this->server->save();
}
/**
* @throws ConnectionException
*/
public function isRunning(): bool
{
$status = Http::withToken($this->server->serverProvider->credentials['token'])
@ -118,6 +158,9 @@ public function isRunning(): bool
return $status->json()['server']['status'] == 'running';
}
/**
* @throws ConnectionException
*/
public function delete(): void
{
if (isset($this->server->provider_data['hetzner_id'])) {

View File

@ -15,9 +15,7 @@ class Linode extends AbstractProvider
public function createRules($input): array
{
$rules = [
'os' => 'required|in:'.implode(',', config('core.operating_systems')),
];
$rules = [];
// plans
$plans = [];
foreach (config('serverproviders.linode.plans') as $plan) {
@ -69,14 +67,18 @@ public function connect(?array $credentials = null): bool
return true;
}
public function plans(): array
public function plans(?string $region): array
{
return config('serverproviders.linode.plans');
return collect(config('serverproviders.linode.plans'))
->mapWithKeys(fn ($value) => [$value['value'] => $value['title']])
->toArray();
}
public function regions(): array
{
return config('serverproviders.linode.regions');
return collect(config('serverproviders.linode.regions'))
->mapWithKeys(fn ($value) => [$value['value'] => $value['title']])
->toArray();
}
/**

View File

@ -14,7 +14,7 @@ public function data(array $input): array;
public function connect(?array $credentials = null): bool;
public function plans(): array;
public function plans(?string $region): array;
public function regions(): array;

View File

@ -17,9 +17,7 @@ class Vultr extends AbstractProvider
public function createRules($input): array
{
$rules = [
'os' => 'required|in:'.implode(',', config('core.operating_systems')),
];
$rules = [];
// plans
$plans = [];
foreach (config('serverproviders.vultr.plans') as $plan) {
@ -71,14 +69,18 @@ public function connect(?array $credentials = null): bool
return true;
}
public function plans(): array
public function plans(?string $region): array
{
return config('serverproviders.vultr.plans');
return collect(config('serverproviders.vultr.plans'))
->mapWithKeys(fn ($value) => [$value['value'] => $value['title']])
->toArray();
}
public function regions(): array
{
return config('serverproviders.vultr.regions');
return collect(config('serverproviders.vultr.regions'))
->mapWithKeys(fn ($value) => [$value['value'] => $value['title']])
->toArray();
}
/**

View File

@ -27,7 +27,7 @@ function date_with_timezone($date, $timezone): string
function htmx(): HtmxResponse
{
return new HtmxResponse();
return new HtmxResponse;
}
function vito_version(): string
@ -48,3 +48,10 @@ function convert_time_format($string): string
return preg_replace('/(\d+)h/', '$1 hours', $string);
}
function get_public_key_content(): string
{
return str(file_get_contents(storage_path(config('core.ssh_public_key_name'))))
->replace("\n", '')
->toString();
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Traits;
use App\Models\Project;
use App\Models\Server;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
trait HasProjectThroughServer
{
public function project(): HasOneThrough
{
return $this->hasOneThrough(
Project::class,
Server::class,
'id',
'id',
'server_id',
'project_id'
);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Traits;
use Carbon\Carbon;
use Exception;
/**
* @property string $created_at_by_timezone
* @property string $updated_at_by_timezone
*/
trait HasTimezoneTimestamps
{
public function getCreatedAtByTimezoneAttribute(): string
{
return $this->getDateTimeByTimezone($this->created_at);
}
public function getUpdatedAtByTimezoneAttribute(): string
{
return $this->getDateTimeByTimezone($this->updated_at);
}
public function getDateTimeByTimezone(?Carbon $value = null): ?string
{
if ($value && auth()->user() && auth()->user()->timezone) {
try {
return date_with_timezone($value, auth()->user()->timezone);
} catch (Exception) {
}
}
return $value;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Web\Fields;
use Filament\Forms\Components\Field;
class AlertField extends Field
{
protected string $view = 'web.fields.alert';
public string $color = 'blue';
public string $icon = 'heroicon-o-information-circle';
public string $message = '';
public function color(string $color): static
{
$this->color = $color;
return $this;
}
public function icon(string $icon): static
{
$this->icon = $icon;
return $this;
}
public function message(string $message): static
{
$this->message = $message;
return $this;
}
public function success(): static
{
return $this->color('green')->icon('heroicon-o-check-circle');
}
public function warning(): static
{
return $this->color('yellow')->icon('heroicon-o-exclamation-circle');
}
public function danger(): static
{
return $this->color('red')->icon('heroicon-o-x-circle');
}
public function info(): static
{
return $this->color('blue')->icon('heroicon-o-information-circle');
}
public function getColor(): string
{
return $this->color;
}
public function getIcon(): string
{
return $this->icon;
}
public function getMessage(): string
{
return $this->message;
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Web\Fields;
use Filament\Forms\Components\Field;
class ProviderField extends Field
{
protected string $view = 'web.fields.provider';
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Web\Pages;
use Filament\Widgets\AccountWidget;
class Dashboard extends \Filament\Pages\Dashboard
{
public function getWidgets(): array
{
return [
AccountWidget::class,
];
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Web\Pages\Servers;
use App\Models\Server;
use App\Web\Traits\PageHasWidgets;
use Filament\Actions\Action;
use Filament\Pages\Page;
class Create extends Page
{
use PageHasWidgets;
protected static ?string $slug = 'servers/create';
protected static bool $shouldRegisterNavigation = false;
protected static ?string $title = 'Create Server';
public static function canAccess(): bool
{
return auth()->user()?->can('create', Server::class) ?? false;
}
protected function getExtraAttributes(): array
{
return [];
}
public function getWidgets(): array
{
return [
[Widgets\CreateServer::class],
];
}
protected function getHeaderActions(): array
{
return [
Action::make('read-the-docs')
->label('Read the Docs')
->icon('heroicon-o-document-text')
->color('gray')
->url('https://vitodeploy.com/servers/create-server.html')
->openUrlInNewTab(),
];
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Web\Pages\Servers;
use App\Models\Server;
use App\Web\Traits\PageHasWidgets;
use Filament\Actions\Action;
use Filament\Pages\Page;
class Index extends Page
{
use PageHasWidgets;
protected static ?string $slug = 'servers';
protected static ?string $navigationIcon = 'heroicon-o-server-stack';
protected static ?int $navigationSort = 1;
protected static ?string $title = 'Servers';
public static function getNavigationItemActiveRoutePattern(): string
{
return static::getRouteName().'*';
}
public function getWidgets(): array
{
return [
[Widgets\ServersList::class],
];
}
protected function getHeaderActions(): array
{
return [
Action::make('create')
->label('Create a Server')
->url(Create::getUrl())
->authorize('create', Server::class),
];
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Web\Pages\Servers;
use App\Actions\Server\RebootServer;
use App\Models\Server;
use App\Web\Pages\Servers\Widgets\ServerDetails;
use App\Web\Pages\Servers\Widgets\UpdateServerInfo;
use App\Web\Traits\PageHasServer;
use App\Web\Traits\PageHasWidgets;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class Settings extends Page
{
use PageHasServer;
use PageHasWidgets;
protected static ?string $slug = 'servers/{server}/settings';
protected static bool $shouldRegisterNavigation = false;
protected static ?string $title = 'Settings';
protected $listeners = ['$refresh'];
public Server $server;
public static function canAccess(): bool
{
return auth()->user()?->can('update', request()->route('server')) ?? false;
}
public function getWidgets(): array
{
return [
[
ServerDetails::class, ['server' => $this->server],
],
[
UpdateServerInfo::class, ['server' => $this->server],
],
];
}
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
->icon('heroicon-o-trash')
->record($this->server)
->modalHeading('Delete Server')
->modalDescription('Once your server is deleted, all of its resources and data will be permanently deleted and can\'t be restored'),
Action::make('reboot')
->color('gray')
->icon('heroicon-o-arrow-path')
->label('Reboot')
->requiresConfirmation()
->action(function () {
app(RebootServer::class)->reboot($this->server);
$this->dispatch('$refresh');
Notification::make()
->info()
->title('Server is being rebooted')
->send();
}),
];
}
protected function getServer(): ?Server
{
return $this->server;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Web\Pages\Servers\Sites;
use App\Models\Server;
use App\Models\Site;
use App\Web\Pages\Servers\Sites\Widgets\SitesList;
use App\Web\Traits\PageHasServer;
use App\Web\Traits\PageHasWidgets;
use Filament\Actions\CreateAction;
use Filament\Pages\Page;
class Index extends Page
{
use PageHasServer;
use PageHasWidgets;
protected static ?string $slug = 'servers/{server}/sites';
protected static bool $shouldRegisterNavigation = false;
protected static ?string $title = 'Sites';
public Server $server;
public function getWidgets(): array
{
return [
[SitesList::class, ['server' => $this->server]],
];
}
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->authorize(fn () => auth()->user()?->can('create', [Site::class, $this->server]))
->createAnother(false)
->label('Create a Site'),
];
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Web\Pages\Servers\Sites\Widgets;
use App\Models\Server;
use App\Models\Site;
use App\Web\Pages\Servers\View;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as Widget;
use Illuminate\Database\Eloquent\Builder;
class SitesList extends Widget
{
public Server $server;
protected function getTableQuery(): Builder
{
return Site::query()->where('server_id', $this->server->id);
}
protected static ?string $heading = '';
protected function getTableColumns(): array
{
return [
TextColumn::make('id')
->searchable()
->sortable(),
TextColumn::make('server.name')
->searchable()
->sortable(),
TextColumn::make('domain')
->searchable(),
TextColumn::make('status')
->label('Status')
->badge()
->color(fn (Site $site) => Site::$statusColors[$site->status])
->searchable()
->sortable(),
];
}
public function getTable(): Table
{
return $this->table
// ->recordUrl(fn (Server $record) => View::getUrl(parameters: ['server' => $record]))
->actions([
// Action::make('settings')
// ->label('Settings')
// ->icon('heroicon-o-cog-6-tooth')
// ->authorize(fn ($record) => auth()->user()->can('update', $record))
// ->url(fn (Server $record) => '/'),
]);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Web\Pages\Servers;
use App\Models\Server;
use App\Web\Pages\Servers\Widgets\Installing;
use App\Web\Traits\PageHasServer;
use App\Web\Traits\PageHasWidgets;
use Filament\Pages\Page;
use Livewire\Attributes\On;
class View extends Page
{
use PageHasServer;
use PageHasWidgets;
protected static ?string $slug = 'servers/{server}';
protected static bool $shouldRegisterNavigation = false;
protected static ?string $title = 'Overview';
public Server $server;
public string $previousStatus;
public function mount(): void
{
$this->previousStatus = $this->server->status;
}
public static function canAccess(): bool
{
return auth()->user()?->can('view', request()->route('server')) ?? false;
}
#[On('$refresh')]
public function refresh(): void
{
$currentStatus = $this->server->refresh()->status;
if ($this->previousStatus !== $currentStatus) {
$this->redirect(static::getUrl(parameters: ['server' => $this->server]));
}
$this->previousStatus = $currentStatus;
}
public function getWidgets(): array
{
if ($this->server->isInstalling()) {
return [
[Installing::class, ['server' => $this->server]],
];
}
return [];
}
}

View File

@ -0,0 +1,263 @@
<?php
namespace App\Web\Pages\Servers\Widgets;
use App\Actions\Server\CreateServer as CreateServerAction;
use App\Enums\ServerProvider;
use App\Enums\ServerType;
use App\Enums\Webserver;
use App\Models\Server;
use App\Web\Fields\AlertField;
use App\Web\Fields\ProviderField;
use App\Web\Pages\Servers\Index;
use App\Web\Pages\Settings\ServerProviders\Actions\Create;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Support\Enums\MaxWidth;
use Filament\Widgets\Widget;
use Throwable;
class CreateServer extends Widget implements HasForms
{
use InteractsWithForms;
protected static string $view = 'web.components.form';
protected $listeners = ['$refresh'];
protected static bool $isLazy = false;
public ?string $provider = ServerProvider::HETZNER;
public ?string $server_provider = '';
public ?string $region = '';
public ?string $plan = '';
public ?string $public_key = '';
public ?string $name = '';
public ?string $ip = '';
public ?string $port = '';
public ?string $os = '';
public ?string $type = ServerType::REGULAR;
public ?string $webserver = Webserver::NGINX;
public ?string $database = '';
public ?string $php = '';
public function form(Form $form): Form
{
$publicKey = __('servers.create.public_key_text', [
'public_key' => get_public_key_content(),
]);
return $form
->schema([
ProviderField::make('provider')
->label('Select a provider')
->default(ServerProvider::CUSTOM)
->live()
->reactive()
->reactive()
->afterStateUpdated(function (callable $set) {
$set('server_provider', null);
$set('region', null);
$set('plan', null);
})
->rules(fn ($get) => CreateServerAction::rules($this->all())['provider']),
AlertField::make('alert')
->warning()
->message(__('servers.create.public_key_warning'))
->visible(fn ($get) => $this->provider === ServerProvider::CUSTOM),
Select::make('server_provider')
->visible(fn ($get) => $this->provider !== ServerProvider::CUSTOM)
->label('Server provider connection')
->rules(fn ($get) => CreateServerAction::rules($this->all())['server_provider'])
->options(function ($get) {
return \App\Models\ServerProvider::getByProjectId(auth()->user()->current_project_id)
->where('provider', $this->provider)
->pluck('profile', 'id');
})
->live()
->suffixAction(
Action::make('connect')
->form(Create::form())
->modalHeading('Connect to a new server provider')
->modalSubmitActionLabel('Connect')
->icon('heroicon-o-wifi')
->tooltip('Connect to a new server provider')
->modalWidth(MaxWidth::Medium)
->authorize(fn () => auth()->user()->can('create', \App\Models\ServerProvider::class))
// TODO: remove this after filament #14319 is fixed
->url(\App\Web\Pages\Settings\ServerProviders\Index::getUrl())
->action(fn (array $data) => Create::action($data))
)
->placeholder('Select profile')
->native(false)
->selectablePlaceholder(false)
->visible(fn ($get) => $this->provider !== ServerProvider::CUSTOM),
Grid::make()
->schema([
Select::make('region')
->label('Region')
->rules(fn ($get) => CreateServerAction::rules($this->all())['region'])
->live()
->reactive()
->options(function () {
if (! $this->server_provider) {
return [];
}
return \App\Models\ServerProvider::regions($this->server_provider);
})
->loadingMessage('Loading regions...')
->disabled(fn ($get) => ! $this->server_provider)
->placeholder(fn ($get) => $this->server_provider ? 'Select region' : 'Select connection first')
->searchable(),
Select::make('plan')
->label('Plan')
->rules(fn ($get) => CreateServerAction::rules($this->all())['plan'])
->reactive()
->options(function () {
if (! $this->server_provider || ! $this->region) {
return [];
}
return \App\Models\ServerProvider::plans($this->server_provider, $this->region);
})
->loadingMessage('Loading plans...')
->disabled(fn ($get) => ! $this->region)
->placeholder(fn ($get) => $this->region ? 'Select plan' : 'Select plan first')
->searchable(),
])
->visible(fn ($get) => $this->provider !== ServerProvider::CUSTOM),
TextInput::make('public_key')
->label('Public Key')
->default($publicKey)
->suffixAction(
Action::make('copy')
->icon('heroicon-o-clipboard-document-list')
->tooltip('Copy')
->action(function ($livewire, $state) {
$livewire->js(
'window.navigator.clipboard.writeText("'.$state.'");'
);
Notification::make()
->success()
->title('Copied!')
->send();
})
)
->helperText('Run this command on your server as root user')
->disabled()
->visible(fn ($get) => $this->provider === ServerProvider::CUSTOM),
TextInput::make('name')
->label('Name')
->rules(fn ($get) => CreateServerAction::rules($this->all())['name']),
Grid::make()
->schema([
TextInput::make('ip')
->label('SSH IP Address')
->rules(fn ($get) => CreateServerAction::rules($this->all())['ip']),
TextInput::make('port')
->label('SSH Port')
->rules(fn ($get) => CreateServerAction::rules($this->all())['port']),
])
->visible(fn ($get) => $this->provider === ServerProvider::CUSTOM),
Grid::make()
->schema([
Select::make('os')
->label('OS')
->native(false)
->selectablePlaceholder(false)
->rules(fn ($get) => CreateServerAction::rules($this->all())['os'])
->options(function () {
return collect(config('core.operating_systems'))
->mapWithKeys(fn ($value) => [$value => $value]);
}),
Select::make('type')
->label('Server Type')
->native(false)
->selectablePlaceholder(false)
->rules(fn ($get) => CreateServerAction::rules($this->all())['type'])
->options(function () {
return collect(config('core.server_types'))
->mapWithKeys(fn ($value) => [$value => $value]);
})
->default(ServerType::REGULAR),
]),
Grid::make(3)
->schema([
Select::make('webserver')
->label('Webserver')
->native(false)
->selectablePlaceholder(false)
->rules(fn ($get) => CreateServerAction::rules($this->all())['webserver'] ?? [])
->options(function () {
return collect(config('core.webservers'))
->mapWithKeys(fn ($value) => [$value => $value]);
}),
Select::make('database')
->label('Database')
->native(false)
->selectablePlaceholder(false)
->rules(fn ($get) => CreateServerAction::rules($this->all())['database'] ?? [])
->options(function () {
return collect(config('core.databases_name'))
->mapWithKeys(fn ($value, $key) => [
$key => $value.' '.config('core.databases_version')[$key],
]);
}),
Select::make('php')
->label('PHP')
->native(false)
->selectablePlaceholder(false)
->rules(fn ($get) => CreateServerAction::rules($this->all())['php'] ?? [])
->options(function () {
return collect(config('core.php_versions'))
->mapWithKeys(fn ($value) => [$value => $value]);
}),
]),
Actions::make([
Action::make('create')
->label('Create Server')
->button()
->action(fn () => $this->submit()),
]),
])
->columns(1);
}
public function submit(): void
{
$this->authorize('create', Server::class);
$this->validate();
try {
$server = app(CreateServerAction::class)->create(auth()->user(), $this->all()['data']);
$this->redirect(Index::getUrl());
} catch (Throwable $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
}
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Web\Pages\Servers\Widgets;
use App\Models\Server;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\ViewEntry;
use Filament\Infolists\Concerns\InteractsWithInfolists;
use Filament\Infolists\Contracts\HasInfolists;
use Filament\Infolists\Infolist;
use Filament\Widgets\Widget;
use Illuminate\View\ComponentAttributeBag;
class Installing extends Widget implements HasForms, HasInfolists
{
use InteractsWithForms;
use InteractsWithInfolists;
protected $listeners = ['$refresh'];
protected static bool $isLazy = false;
protected static string $view = 'web.components.infolist';
public Server $server;
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Section::make()
->heading('Installing Server')
->icon(function () {
if ($this->server->isInstallationFailed()) {
return 'heroicon-o-x-circle';
}
return view('filament::components.loading-indicator')
->with('attributes', new ComponentAttributeBag([
'class' => 'mr-2 size-[24px] text-primary-400',
]));
})
->iconColor($this->server->isInstallationFailed() ? 'danger' : 'primary')
->schema([
ViewEntry::make('progress')
->hiddenLabel()
->view('components.progress-bar')
->viewData([
'value' => $this->server->progress,
]),
]),
])
->record($this->server->refresh());
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Web\Pages\Servers\Widgets;
use App\Models\Server;
use App\Web\Resources\Server\Pages\CreateServer;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Widgets\Widget;
use Illuminate\Database\Eloquent\Builder;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
class SelectServer extends Widget implements HasForms
{
use InteractsWithForms;
protected static string $view = 'web.components.form';
public int|string|null $server = null;
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function mount(): void
{
$server = Server::query()
->where('project_id', auth()->user()->current_project_id)
->find(session()->get('current_server_id'));
if ($server && auth()->user()->can('view', $server)) {
$this->server = $server->id;
}
}
protected function getFormSchema(): array
{
$options = $this->query()
->limit(10)
->pluck('name', 'id')
->toArray();
return [
Select::make('server')
->name('server')
->model($this->server)
->searchable()
->options($options)
->searchPrompt('Search...')
->getSearchResultsUsing(function ($search) {
return $this->query()
->where('name', 'like', "%{$search}%")
->limit(10)
->pluck('name', 'id')
->toArray();
})
->extraAttributes(['class' => '-mx-2 pointer-choices'])
->live()
->hintIcon('heroicon-o-question-mark-circle')
->hintIconTooltip('Filter resources by default based on a server')
->suffixAction(
Action::make('create')
->icon('heroicon-o-plus')
->tooltip('Create a new server')
->url(CreateServer::getUrl())
),
];
}
private function query(): Builder
{
return Server::query()
->where(function (Builder $query) {
$query->where('project_id', auth()->user()->current_project_id);
});
}
public function updatedServer($value): void
{
if (! $value) {
session()->forget('current_server_id');
$this->redirect(url()->previous());
return;
}
$server = Server::query()->find($value);
if (! $server) {
session()->forget('current_server_id');
return;
}
$this->authorize('view', $server);
session()->put('current_server_id', $value);
$this->redirect(url()->previous());
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Web\Pages\Servers\Widgets;
use App\Actions\Server\Update;
use App\Models\Server;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Infolists\Components\Actions\Action;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Concerns\InteractsWithInfolists;
use Filament\Infolists\Contracts\HasInfolists;
use Filament\Infolists\Infolist;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
class ServerDetails extends Widget implements HasForms, HasInfolists
{
use InteractsWithForms;
use InteractsWithInfolists;
protected $listeners = ['$refresh'];
protected static bool $isLazy = false;
protected static string $view = 'web.components.infolist';
public Server $server;
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Section::make()
->heading('Server Details')
->description('More details about your server')
->columns(1)
->schema([
TextEntry::make('id')
->label('ID')
->inlineLabel()
->hintIcon('heroicon-o-information-circle')
->hintIconTooltip('Server unique identifier to use in the API'),
TextEntry::make('created_at_by_timezone')
->label('Created At')
->inlineLabel(),
TextEntry::make('last_updated_check')
->label('Last Updated Check')
->inlineLabel()
->state(fn (Server $record) => $record->getDateTimeByTimezone($record->last_update_check) ?? '-')
->suffixAction(
Action::make('check-update')
->icon('heroicon-o-arrow-path')
->tooltip('Check Now')
->action(fn (Server $record) => $record->checkForUpdates())
),
TextEntry::make('available_updates')
->label('Available Updates')
->inlineLabel()
->suffixAction(
Action::make('update-server')
->icon('heroicon-o-check-circle')
->tooltip('Update Now')
->action(function (Server $record) {
app(Update::class)->update($record);
$this->dispatch('$refresh');
Notification::make()
->info()
->title('Updating the server...')
->send();
})
),
TextEntry::make('provider')
->label('Provider')
->inlineLabel(),
TextEntry::make('tags')
->label('Tags')
->inlineLabel()
->state(fn (Server $record) => view('web.components.tags', ['tags' => $record->tags]))
->suffixAction(
Action::make('edit-tags')
->icon('heroicon-o-pencil')
->tooltip('Edit Tags')
->action(fn (Server $record) => $this->dispatch('$editTags', $record))
->tooltip('Edit Tags')
),
]),
])
->record($this->server);
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Web\Pages\Servers\Widgets;
use App\Models\Server;
use App\Web\Pages\Servers\View;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Infolists\Components\Actions\Action;
use Filament\Infolists\Components\Fieldset;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Concerns\InteractsWithInfolists;
use Filament\Infolists\Contracts\HasInfolists;
use Filament\Infolists\Infolist;
use Filament\Notifications\Notification;
use Filament\Support\Enums\IconPosition;
use Filament\Widgets\Widget;
class ServerSummary extends Widget implements HasForms, HasInfolists
{
use InteractsWithForms;
use InteractsWithInfolists;
protected $listeners = ['$refresh'];
protected static bool $isLazy = false;
protected static string $view = 'web.components.infolist';
public Server $server;
public function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Fieldset::make('info')
->label('Server Summary')
->schema([
TextEntry::make('name')
->label('Name')
->url(fn (Server $record) => View::getUrl(parameters: ['server' => $record])),
TextEntry::make('ip')
->label('IP Address')
->icon('heroicon-o-clipboard-document')
->iconPosition(IconPosition::After)
->copyable(),
TextEntry::make('status')
->label('Status')
->badge()
->color(static function ($state): string {
return Server::$statusColors[$state];
})
->suffixAction(
Action::make('check-status')
->icon('heroicon-o-arrow-path')
->tooltip('Check Connection')
->action(function (Server $record) {
$record = $record->checkConnection();
$this->dispatch('$refresh');
Notification::make()
->status(Server::$statusColors[$record->status])
->title('Server is '.$record->status)
->send();
})
),
])
->columns(3),
])
->record($this->server->refresh());
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Web\Pages\Servers\Widgets;
use App\Models\Server;
use App\Web\Pages\Servers\View;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ViewColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as Widget;
use Illuminate\Database\Eloquent\Builder;
class ServersList extends Widget
{
protected function getTableQuery(): Builder
{
return Server::query()->where('project_id', auth()->user()->current_project_id);
}
protected static ?string $heading = '';
protected function getTableColumns(): array
{
return [
TextColumn::make('id')
->searchable()
->sortable(),
TextColumn::make('name')
->searchable()
->sortable(),
ViewColumn::make('tags.name')
->label('Tags')
->view('web.components.tags')
->extraCellAttributes(['class' => 'px-3'])
->searchable()
->sortable(),
TextColumn::make('status')
->label('Status')
->badge()
->color(fn (Server $server) => Server::$statusColors[$server->status])
->searchable()
->sortable(),
TextColumn::make('created_at_by_timezone')
->label('Created At')
->searchable()
->sortable(),
];
}
public function getTable(): Table
{
return $this->table
->recordUrl(fn (Server $record) => View::getUrl(parameters: ['server' => $record]))
->actions([
Action::make('settings')
->label('Settings')
->icon('heroicon-o-cog-6-tooth')
->authorize(fn ($record) => auth()->user()->can('update', $record))
->url(fn (Server $record) => '/'),
]);
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Web\Pages\Servers\Widgets;
use App\Actions\Server\EditServer;
use App\Models\Server;
use Filament\Forms\Components\Actions;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
class UpdateServerInfo extends Widget implements HasForms
{
use InteractsWithForms;
protected static bool $isLazy = false;
protected static string $view = 'web.components.form';
public Server $server;
public string $name;
public string $ip;
public string $port;
public function mount(Server $server): void
{
$this->server = $server;
$this->name = $server->name;
$this->ip = $server->ip;
$this->port = $server->port;
}
public function form(Form $form): Form
{
return $form
->schema([
Section::make()
->heading('Update Server')
->description('You can edit your server\'s information here')
->columns(1)
->schema([
TextInput::make('name')
->label('Name')
->rules(EditServer::rules($this->server)['name']),
TextInput::make('ip')
->label('IP Address')
->rules(EditServer::rules($this->server)['ip']),
TextInput::make('port')
->label('Port')
->rules(EditServer::rules($this->server)['port']),
])
->footerActions([
Actions\Action::make('submit')
->label('Save')
->action(fn () => $this->submit()),
]),
]);
}
public function submit(): void
{
$this->authorize('update', $this->server);
$this->validate();
app(EditServer::class)->edit($this->server, $this->all());
$this->dispatch('$refresh');
Notification::make()
->success()
->title('Server updated!')
->send();
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Web\Pages\Settings\Profile;
use App\Web\Pages\Settings\Profile\Widgets\ProfileInformation;
use App\Web\Pages\Settings\Profile\Widgets\TwoFactor;
use App\Web\Pages\Settings\Profile\Widgets\UpdatePassword;
use App\Web\Traits\PageHasWidgets;
use Filament\Pages\Page;
class Index extends Page
{
use PageHasWidgets;
protected static ?string $navigationGroup = 'Settings';
protected static ?string $slug = 'settings/profile';
protected static ?string $title = 'Profile';
protected static ?string $navigationIcon = 'heroicon-o-user-circle';
protected static ?int $navigationSort = 2;
public function getWidgets(): array
{
return [
[ProfileInformation::class],
[UpdatePassword::class],
[TwoFactor::class],
];
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Web\Pages\Settings\Profile\Widgets;
use App\Actions\User\UpdateUserProfileInformation;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
class ProfileInformation extends Widget implements HasForms
{
use InteractsWithForms;
protected static bool $isLazy = false;
protected static string $view = 'web.components.form';
public string $name;
public string $email;
public string $timezone;
public function mount(): void
{
$this->name = auth()->user()->name;
$this->email = auth()->user()->email;
$this->timezone = auth()->user()->timezone;
}
public function getFormSchema(): array
{
$rules = UpdateUserProfileInformation::rules(auth()->user());
return [
Section::make()
->heading('Profile Information')
->description('Update your account\'s profile information and email address.')
->schema([
TextInput::make('name')
->label('Name')
->rules($rules['name']),
TextInput::make('email')
->label('Email')
->rules($rules['email']),
Select::make('timezone')
->label('Timezone')
->searchable()
->options(
collect(timezone_identifiers_list())
->mapWithKeys(fn ($timezone) => [$timezone => $timezone])
)
->rules($rules['timezone']),
])
->footerActions([
Action::make('save')
->label('Save')
->action(fn () => $this->submit()),
]),
];
}
public function submit(): void
{
$this->validate();
app(UpdateUserProfileInformation::class)->update(auth()->user(), $this->all());
Notification::make()
->success()
->title('Profile updated!')
->send();
}
}

View File

@ -0,0 +1,138 @@
<?php
namespace App\Web\Pages\Settings\Profile\Widgets;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Infolists\Components\Actions\Action;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Infolists\Concerns\InteractsWithInfolists;
use Filament\Infolists\Contracts\HasInfolists;
use Filament\Infolists\Infolist;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
class TwoFactor extends Widget implements HasForms, HasInfolists
{
use InteractsWithForms;
use InteractsWithInfolists;
protected $listeners = ['$refresh'];
protected static bool $isLazy = false;
protected static string $view = 'web.components.infolist';
public bool $enabled = false;
public bool $showCodes = false;
public function mount(): void
{
if (auth()->user()->two_factor_secret) {
$this->enabled = true;
}
}
public function infolist(Infolist $infolist): Infolist
{
return $infolist->schema([
Section::make()
->heading('Two Factor Authentication')
->description('Here you can activate 2FA to secure your account')
->schema([
TextEntry::make('disabled')
->hiddenLabel()
->state('Two factor authentication is disabled.')
->visible(! $this->enabled),
ViewEntry::make('qr_code')
->hiddenLabel()
->view('web.components.container', [
'content' => $this->enabled ? auth()->user()->twoFactorQrCodeSvg() : null,
])
->visible($this->enabled && $this->showCodes),
TextEntry::make('qr_code_manual')
->label('If you are unable to scan the QR code, please use the 2FA secret instead.')
->state($this->enabled ? decrypt(auth()->user()->two_factor_secret) : null)
->copyable()
->visible($this->enabled && $this->showCodes),
TextEntry::make('recovery_codes_text')
->hiddenLabel()
->color('warning')
->state('Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.')
->visible($this->enabled),
ViewEntry::make('recovery_codes')
->hiddenLabel()
->extraAttributes(['class' => 'rounded-lg border border-gray-100 p-2 dark:border-gray-700'])
->view('web.components.container', [
'content' => $this->enabled ? implode('</br>', json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true)) : null,
])
->visible($this->enabled),
])
->footerActions([
Action::make('two-factor')
->color($this->enabled ? 'danger' : 'primary')
->label($this->enabled ? 'Disable' : 'Enable')
->action(function () {
if ($this->enabled) {
$this->disableTwoFactor();
} else {
$this->enableTwoFactor();
}
}),
Action::make('regenerate')
->color('gray')
->label('Regenerate Recovery Codes')
->visible($this->enabled)
->action(fn () => $this->regenerateRecoveryCodes()),
]),
]);
}
public function enableTwoFactor(): void
{
app(EnableTwoFactorAuthentication::class)(auth()->user());
$this->enabled = true;
$this->showCodes = true;
Notification::make()
->success()
->title('Two factor authentication enabled')
->send();
$this->dispatch('$refresh');
}
public function disableTwoFactor(): void
{
app(DisableTwoFactorAuthentication::class)(auth()->user());
$this->enabled = false;
$this->showCodes = false;
Notification::make()
->success()
->title('Two factor authentication disabled')
->send();
$this->dispatch('$refresh');
}
public function regenerateRecoveryCodes(): void
{
app(GenerateNewRecoveryCodes::class)(auth()->user());
Notification::make()
->success()
->title('Recovery codes generated')
->send();
$this->dispatch('$refresh');
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Web\Pages\Settings\Profile\Widgets;
use App\Actions\User\UpdateUserPassword;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
class UpdatePassword extends Widget implements HasForms
{
use InteractsWithForms;
protected static bool $isLazy = false;
protected static string $view = 'web.components.form';
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public function getFormSchema(): array
{
$rules = UpdateUserPassword::rules();
return [
Section::make()
->heading('Update Password')
->description('Ensure your account is using a long, random password to stay secure.')
->schema([
TextInput::make('current_password')
->label('Current Password')
->password()
->rules($rules['current_password']),
TextInput::make('password')
->label('New Password')
->password()
->rules($rules['password']),
TextInput::make('password_confirmation')
->label('Confirm Password')
->password()
->rules($rules['password_confirmation']),
])
->footerActions([
Action::make('save')
->label('Save')
->action(fn () => $this->submit()),
]),
];
}
public function submit(): void
{
$this->validate();
app(UpdateUserPassword::class)->update(auth()->user(), $this->all());
$this->current_password = '';
$this->password = '';
$this->password_confirmation = '';
Notification::make()
->success()
->title('Password updated!')
->send();
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Web\Pages\Settings\Projects;
use App\Actions\Projects\CreateProject;
use App\Models\Project;
use App\Web\Traits\PageHasWidgets;
use Filament\Actions\CreateAction;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Pages\Page;
use Filament\Support\Enums\MaxWidth;
class Index extends Page
{
use PageHasWidgets;
protected static ?string $navigationGroup = 'Settings';
protected static ?string $slug = 'settings/projects';
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?int $navigationSort = 4;
protected static ?string $title = 'Projects';
public static function getNavigationItemActiveRoutePattern(): string
{
return static::getRouteName().'*';
}
public static function canAccess(): bool
{
return auth()->user()?->can('viewAny', Project::class) ?? false;
}
public function getWidgets(): array
{
return [
[Widgets\ProjectsList::class],
];
}
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create Project')
->icon('heroicon-o-plus')
->authorize('create', Project::class)
->using(function (array $data) {
return app(CreateProject::class)->create(auth()->user(), $data);
})
->form(function (Form $form) {
return $form->schema([
TextInput::make('name')
->name('name')
->rules(CreateProject::rules()['name']),
])->columns(1);
})
->createAnother(false)
->modalWidth(MaxWidth::Large),
];
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Web\Pages\Settings\Projects;
use App\Actions\Projects\DeleteProject;
use App\Models\Project;
use App\Web\Pages\Settings\Projects\Widgets\AddUser;
use App\Web\Pages\Settings\Projects\Widgets\ProjectUsersList;
use App\Web\Pages\Settings\Projects\Widgets\UpdateProject;
use App\Web\Traits\PageHasWidgets;
use Exception;
use Filament\Actions\DeleteAction;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Contracts\Support\Htmlable;
class Settings extends Page
{
use PageHasWidgets;
protected static ?string $slug = 'settings/projects/{project}';
protected static ?string $title = 'Project Settings';
protected static bool $shouldRegisterNavigation = false;
public static function canAccess(): bool
{
return auth()->user()?->can('update', request()->route('project')) ?? false;
}
public Project $project;
public function getWidgets(): array
{
return [
[
UpdateProject::class,
['project' => $this->project],
],
[
AddUser::class,
['project' => $this->project],
],
[
ProjectUsersList::class,
['project' => $this->project],
],
];
}
public function getTitle(): string|Htmlable
{
return 'Project Settings';
}
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
->record($this->project)
->label('Delete Project')
->modalHeading('Delete Project')
->modalDescription('Are you sure you want to delete this project? This action will delete all associated data and cannot be undone.')
->using(function (Project $record) {
try {
app(DeleteProject::class)->delete(auth()->user(), $record);
$this->redirectRoute('filament.app.resources.projects.index');
} catch (Exception $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
}
}),
];
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Web\Pages\Settings\Projects\Widgets;
use App\Models\Project;
use App\Models\User;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
class AddUser extends Widget implements HasForms
{
use InteractsWithForms;
protected static string $view = 'web.components.form';
public Project $project;
public ?int $user;
public function mount(Project $project): void
{
$this->project = $project;
}
public function getFormSchema(): array
{
return [
Section::make()
->heading('Add User')
->schema([
Select::make('user')
->name('user')
->options(fn () => User::query()->pluck('name', 'id'))
->searchable()
->rules(\App\Actions\Projects\AddUser::rules($this->project)['user']),
])
->footerActions([
Action::make('add')
->label('Add')
->action(fn () => $this->submit()),
]),
];
}
public function submit(): void
{
$this->authorize('update', $this->project);
$this->validate();
app(\App\Actions\Projects\AddUser::class)
->add($this->project, [
'user' => $this->user,
]);
Notification::make()
->title('User added!')
->success()
->send();
$this->user = null;
}
public function updated(): void
{
$this->dispatch('userAdded');
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Web\Pages\Settings\Projects\Widgets;
use App\Models\Project;
use App\Models\User;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as Widget;
use Illuminate\Database\Eloquent\Builder;
class ProjectUsersList extends Widget
{
protected $listeners = ['userAdded' => '$refresh'];
public Project $project;
public function mount(Project $project): void
{
$this->project = $project;
}
protected function getTableQuery(): Builder
{
return User::query()->whereHas('projects', function (Builder $query) {
$query->where('project_id', $this->project->id);
});
}
protected function getTableColumns(): array
{
return [
Tables\Columns\TextColumn::make('id')->width('20%'),
Tables\Columns\TextColumn::make('name')->width('20%'),
Tables\Columns\TextColumn::make('email')->width('20%'),
];
}
public function getTable(): Table
{
return $this->table->actions([
Tables\Actions\DeleteAction::make()
->label('Remove')
->modalHeading('Remove user from project')
->visible(function ($record) {
return $this->authorize('update', $this->project)->allowed() && $record->id !== auth()->id();
})
->using(function ($record) {
$this->project->users()->detach($record);
}),
])->paginated(false);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Web\Pages\Settings\Projects\Widgets;
use App\Models\Project;
use App\Web\Pages\Settings\Projects\Settings;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as Widget;
use Illuminate\Database\Eloquent\Builder;
class ProjectsList extends Widget
{
protected function getTableQuery(): Builder
{
return Project::query();
}
protected static ?string $heading = '';
protected function getTableColumns(): array
{
return [
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('created_at_by_timezone')
->label('Created At')
->searchable()
->sortable(),
];
}
public function getTable(): Table
{
return $this->table
->recordUrl(fn (Project $record) => Settings::getUrl(['project' => $record]))
->actions([
Action::make('settings')
->label('Settings')
->icon('heroicon-o-cog-6-tooth')
->authorize(fn ($record) => auth()->user()->can('update', $record))
->url(fn (Project $record) => Settings::getUrl(['project' => $record])),
]);
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Web\Pages\Settings\Projects\Widgets;
use App\Models\Project;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Widgets\Widget;
use Illuminate\Database\Eloquent\Builder;
class SelectProject extends Widget implements HasForms
{
use InteractsWithForms;
protected static string $view = 'web.components.form';
public int|string|null $project;
protected function getFormSchema(): array
{
$options = Project::query()
->where(function (Builder $query) {
if (auth()->user()->isAdmin()) {
return;
}
$query->where('user_id', auth()->id())
->orWhereHas('users', fn ($query) => $query->where('user_id', auth()->id()));
})
->get()
->mapWithKeys(fn ($project) => [$project->id => $project->name])
->toArray();
return [
Select::make('project')
->name('project')
->model($this->project)
->label('Project')
->searchable()
->options($options)
->searchPrompt('Select a project...')
->extraAttributes(['class' => '-mx-2 pointer-choices'])
->selectablePlaceholder(false)
->live(),
];
}
public function updatedProject($value): void
{
$project = Project::query()->findOrFail($value);
$this->authorize('view', $project);
auth()->user()->update(['current_project_id' => $value]);
$this->redirect('/app');
}
public function mount(): void
{
$this->project = auth()->user()->current_project_id;
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Web\Pages\Settings\Projects\Widgets;
use App\Models\Project;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Widgets\Widget;
class UpdateProject extends Widget implements HasForms
{
use InteractsWithForms;
protected static string $view = 'web.components.form';
public Project $project;
public string $name;
public function mount(Project $project): void
{
$this->project = $project;
$this->name = $project->name;
}
public function getFormSchema(): array
{
return [
Section::make()
->heading('Project Information')
->schema([
TextInput::make('name')
->name('name')
->label('Name')
->rules(\App\Actions\Projects\UpdateProject::rules($this->project)['name'])
->placeholder('Enter the project name'),
])
->footerActions([
Action::make('save')
->label('Save')
->action(fn () => $this->submit()),
]),
];
}
public function submit(): void
{
$this->authorize('update', $this->project);
$this->validate();
app(\App\Actions\Projects\UpdateProject::class)
->update($this->project, [
'name' => $this->name,
]);
Notification::make()
->title('Project updated successfully!')
->success()
->send();
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Web\Pages\Settings\ServerProviders\Actions;
use App\Actions\ServerProvider\CreateServerProvider;
use App\Enums\ServerProvider;
use Exception;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
class Create
{
public static function form(): array
{
return [
Select::make('provider')
->options(
collect(config('core.server_providers'))
->filter(fn ($provider) => $provider != ServerProvider::CUSTOM)
->mapWithKeys(fn ($provider) => [$provider => $provider])
)
->live()
->reactive()
->rules(CreateServerProvider::rules()['provider']),
TextInput::make('name')
->rules(CreateServerProvider::rules()['name']),
TextInput::make('token')
->label('API Key')
->validationAttribute('API Key')
->visible(fn ($get) => in_array($get('provider'), [
ServerProvider::DIGITALOCEAN,
ServerProvider::LINODE,
ServerProvider::VULTR,
ServerProvider::HETZNER,
]))
->rules(fn ($get) => CreateServerProvider::providerRules($get())['token']),
TextInput::make('key')
->label('Access Key')
->visible(function ($get) {
return $get('provider') == ServerProvider::AWS;
})
->rules(fn ($get) => CreateServerProvider::providerRules($get())['key']),
TextInput::make('secret')
->label('Secret')
->visible(fn ($get) => $get('provider') == ServerProvider::AWS)
->rules(fn ($get) => CreateServerProvider::providerRules($get())['secret']),
Checkbox::make('global')
->label('Is Global (Accessible in all projects)'),
];
}
/**
* @throws Exception
*/
public static function action(array $data): void
{
try {
app(CreateServerProvider::class)->create(auth()->user(), $data);
} catch (Exception $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();
throw $e;
}
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Web\Pages\Settings\ServerProviders\Actions;
use App\Actions\ServerProvider\EditServerProvider;
use App\Models\ServerProvider;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\TextInput;
class Edit
{
public static function form(): array
{
return [
TextInput::make('name')
->label('Name')
->rules(EditServerProvider::rules()['name']),
Checkbox::make('global')
->label('Is Global (Accessible in all projects)'),
];
}
public static function action(ServerProvider $provider, array $data): void
{
app(EditServerProvider::class)->edit($provider, auth()->user(), $data);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Web\Pages\Settings\ServerProviders;
use App\Enums\ServerProvider;
use App\Web\Traits\PageHasWidgets;
use Filament\Actions\CreateAction;
use Filament\Pages\Page;
use Filament\Support\Enums\MaxWidth;
class Index extends Page
{
use PageHasWidgets;
protected static ?string $navigationGroup = 'Settings';
protected static ?string $slug = 'settings/server-providers';
protected static ?string $title = 'Server Providers';
protected static ?string $navigationIcon = 'heroicon-o-server-stack';
protected static ?int $navigationSort = 5;
public static function canAccess(): bool
{
return auth()->user()?->can('viewAny', ServerProvider::class) ?? false;
}
public function getWidgets(): array
{
return [
[Widgets\ServerProvidersList::class],
];
}
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Connect')
->modalHeading('Connect to a Server Provider')
->modalSubmitActionLabel('Connect')
->createAnother(false)
->form(Actions\Create::form())
->authorize('create', ServerProvider::class)
->modalWidth(MaxWidth::Medium)
->using(fn (array $data) => Actions\Create::action($data)),
];
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Web\Pages\Settings\ServerProviders\Widgets;
use App\Actions\ServerProvider\DeleteServerProvider;
use App\Models\ServerProvider;
use App\Web\Pages\Settings\ServerProviders\Actions\Edit;
use Filament\Support\Enums\MaxWidth;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as Widget;
use Illuminate\Database\Eloquent\Builder;
class ServerProvidersList extends Widget
{
protected function getTableQuery(): Builder
{
return ServerProvider::getByProjectId(auth()->user()->current_project_id);
}
protected static ?string $heading = '';
protected function getTableColumns(): array
{
return [
ImageColumn::make('image_url')
->label('Provider')
->size(24),
TextColumn::make('name')
->default(fn ($record) => $record->profile)
->label('Name')
->searchable()
->sortable(),
TextColumn::make('id')
->label('Global')
->badge()
->color(fn ($record) => $record->project_id ? 'gray' : 'success')
->formatStateUsing(function (ServerProvider $record) {
return $record->project_id ? 'No' : 'Yes';
}),
TextColumn::make('created_at_by_timezone')
->label('Created At')
->searchable()
->sortable(),
];
}
public function getTable(): Table
{
return $this->table->actions([
EditAction::make('edit')
->label('Edit')
->modalHeading('Edit Server Provider')
->mutateRecordDataUsing(function (array $data, ServerProvider $record) {
return [
'name' => $record->profile,
'global' => $record->project_id === null,
];
})
->form(Edit::form())
->authorize(fn (ServerProvider $record) => auth()->user()->can('update', $record))
->using(fn (array $data, ServerProvider $record) => Edit::action($record, $data))
->modalWidth(MaxWidth::Medium),
DeleteAction::make('delete')
->label('Delete')
->modalHeading('Delete Server Provider')
->authorize(fn (ServerProvider $record) => auth()->user()->can('delete', $record))
->using(function (array $data, ServerProvider $record) {
app(DeleteServerProvider::class)->delete($record);
}),
]);
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Web\Pages\Settings\Users;
use App\Actions\User\CreateUser;
use App\Models\User;
use App\Web\Traits\PageHasWidgets;
use Filament\Actions\CreateAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Pages\Page;
use Filament\Support\Enums\MaxWidth;
class Index extends Page
{
use PageHasWidgets;
protected static ?string $navigationGroup = 'Settings';
protected static ?string $slug = 'users';
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?int $navigationSort = 3;
protected static ?string $title = 'Users';
public static function canAccess(): bool
{
return auth()->user()?->can('viewAny', User::class) ?? false;
}
public function getWidgets(): array
{
return [
[Widgets\UsersList::class],
];
}
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create User')
->authorize('create', User::class)
->action(function (array $data) {
$user = app(CreateUser::class)->create($data);
$this->dispatch('$refresh');
return $user;
})
->form(function (Form $form) {
$rules = CreateUser::rules();
return $form
->schema([
TextInput::make('name')
->rules($rules['name']),
TextInput::make('email')
->rules($rules['email']),
TextInput::make('password')
->rules($rules['password']),
Select::make('role')
->rules($rules['role'])
->options(collect(config('core.user_roles'))->mapWithKeys(fn ($role) => [$role => $role])),
])
->columns(1);
})
->modalWidth(MaxWidth::Large),
];
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace App\Web\Pages\Settings\Users\Widgets;
use App\Actions\User\UpdateProjects;
use App\Actions\User\UpdateUser;
use App\Models\Project;
use App\Models\User;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Support\Enums\MaxWidth;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as Widget;
use Illuminate\Database\Eloquent\Builder;
class UsersList extends Widget
{
protected $listeners = ['$refresh'];
protected function getTableQuery(): Builder
{
return User::query();
}
protected static ?string $heading = '';
protected function getTableColumns(): array
{
return [
TextColumn::make('name')
->searchable()
->sortable(),
TextColumn::make('email')
->searchable()
->sortable(),
TextColumn::make('timezone'),
TextColumn::make('created_at_by_timezone')
->label('Created At')
->searchable()
->sortable(),
TextColumn::make('role'),
];
}
public function getTable(): Table
{
return $this->table
->actions([
EditAction::make('edit')
->authorize(fn ($record) => auth()->user()->can('update', $record))
->using(function ($record, array $data) {
app(UpdateUser::class)->update($record, $data);
})
->form(function (Form $form, $record) {
return $form
->schema([
TextInput::make('name')
->rules(UpdateUser::rules($record)['name']),
TextInput::make('email')
->rules(UpdateUser::rules($record)['email']),
Select::make('timezone')
->searchable()
->options(
collect(timezone_identifiers_list())
->mapWithKeys(fn ($timezone) => [$timezone => $timezone])
)
->rules(UpdateUser::rules($record)['timezone']),
Select::make('role')
->options(
collect(config('core.user_roles'))
->mapWithKeys(fn ($role) => [$role => $role])
)
->rules(UpdateUser::rules($record)['role']),
])
->columns(1);
})
->modalWidth(MaxWidth::Large),
Action::make('update-projects')
->label('Projects')
->icon('heroicon-o-rectangle-stack')
->authorize(fn ($record) => auth()->user()->can('update', $record))
->action(function ($record, array $data) {
app(UpdateProjects::class)->update($record, $data);
Notification::make()
->title('Projects Updated')
->success()
->send();
})
->form(function (Form $form, $record) {
return $form
->schema([
CheckboxList::make('projects')
->options(Project::query()->pluck('name', 'id')->toArray())
->searchable()
->default($record->projects->pluck('id')->toArray())
->rules(UpdateProjects::rules()['projects.*']),
])
->columns(1);
})
->modalSubmitActionLabel('Save')
->modalWidth(MaxWidth::Large),
DeleteAction::make()
->authorize(fn ($record) => auth()->user()->can('delete', $record)),
]);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Web\Traits;
use App\Models\Server;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Route;
trait BelongsToServers
{
public static function getUrl(string $name = 'index', array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null): string
{
if (! isset($parameters['server'])) {
$parameters['server'] = request()->route('server') ?? 0;
}
return parent::getUrl($name, $parameters, $isAbsolute, $panel, $tenant);
}
public static function getServerFromRoute(): Server
{
$server = request()->route('server');
if (! $server) {
$server = Route::getRoutes()->match(Request::create(url()->previous()))->parameter('server');
}
if (! $server instanceof Server) {
$server = Server::query()->find($server);
}
if (! $server) {
$server = new Server;
}
return $server;
}
public static function canViewAny(): bool
{
return static::can('viewAny');
return auth()->user()->can('viewAny', [static::getModel(), static::getServerFromRoute()]);
}
public static function canCreate(): bool
{
return auth()->user()->can('create', [static::getModel(), static::getServerFromRoute()]);
}
public static function authorizeViewAny(): void
{
Gate::authorize('viewAny', [static::getModel(), static::getServerFromRoute()]);
}
public static function authorizeCreate(): void
{
Gate::authorize('create', [static::getModel(), static::getServerFromRoute()]);
}
public static function authorizeEdit(Model $record): void
{
Gate::authorize('update', [$record, static::getServerFromRoute()]);
}
public static function authorizeView(Model $record): void
{
Gate::authorize('view', [$record, static::getServerFromRoute()]);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Web\Traits;
trait PageHasCluster
{
// public function getMaxContentWidth(): ?string
// {
// return 'full';
// }
public function getSubNavigation(): array
{
if (filled($cluster = static::getCluster())) {
return $this->generateNavigationItems($cluster::getClusteredComponents());
}
return [];
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Web\Traits;
use App\Models\Site;
use App\Web\Pages\Servers\Settings;
use App\Web\Pages\Servers\Sites\Index;
use App\Web\Pages\Servers\View;
use App\Web\Pages\Servers\Widgets\ServerSummary;
use Filament\Navigation\NavigationItem;
use Illuminate\Contracts\Support\Htmlable;
trait PageHasServer
{
public function getTitle(): string|Htmlable
{
return static::$title.' - '.$this->server->name;
}
public function getSubNavigation(): array
{
$items = [];
if (auth()->user()?->can('view', $this->server)) {
$items[] = NavigationItem::make('Overview')
->icon('heroicon-o-chart-pie')
->isActiveWhen(fn () => request()->routeIs(View::getRouteName()))
->url(View::getUrl(parameters: ['server' => $this->server]));
}
if (auth()->user()?->can('viewAny', [Site::class, $this->server])) {
$items[] = NavigationItem::make('Sites')
->icon('heroicon-o-globe-alt')
->isActiveWhen(fn () => request()->routeIs(Index::getRouteName().'*'))
->url(Index::getUrl(parameters: ['server' => $this->server]));
}
if (auth()->user()?->can('update', $this->server)) {
$items[] = NavigationItem::make('Settings')
->icon('heroicon-o-cog-6-tooth')
->isActiveWhen(fn () => request()->routeIs(Settings::getRouteName().'*'))
->url(Settings::getUrl(parameters: ['server' => $this->server]));
}
return $items;
}
protected function getHeaderWidgets(): array
{
return [
ServerSummary::make([
'server' => $this->server,
]),
];
}
public function getHeaderWidgetsColumns(): int|string|array
{
return 1;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Web\Traits;
use Illuminate\View\ComponentAttributeBag;
trait PageHasWidgets
{
protected array $extraAttributes = [
'wire:poll.5s' => '$dispatch(\'$refresh\')',
];
protected function getExtraAttributes(): array
{
return $this->extraAttributes;
}
public function getView(): string
{
return 'web.components.page';
}
public function getExtraAttributesBag(): ComponentAttributeBag
{
return new ComponentAttributeBag($this->getExtraAttributes());
}
abstract public function getWidgets(): array;
}

View File

@ -2,15 +2,13 @@
"name": "vitodeploy/vito",
"type": "project",
"description": "The ultimate server management tool",
"keywords": [
"framework",
"laravel"
],
"keywords": ["framework", "laravel"],
"license": "AGPL-3.0",
"require": {
"php": "^8.2",
"ext-ftp": "*",
"aws/aws-sdk-php": "^3.158",
"filament/filament": "^3.2",
"laravel/fortify": "^1.17",
"laravel/framework": "^11.0",
"laravel/tinker": "^2.8",
@ -18,6 +16,7 @@
},
"require-dev": {
"fakerphp/faker": "^1.9.1",
"laradumps/laradumps": "^3.0",
"laravel/pint": "^1.10",
"laravel/sail": "^1.18",
"mockery/mockery": "^1.4.4",
@ -43,7 +42,8 @@
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
"@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"

2957
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -193,6 +193,7 @@
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\WebServiceProvider::class,
],
/*

View File

@ -476,4 +476,9 @@
\App\Models\Server::class,
\App\Models\Site::class,
],
'user_roles' => [
\App\Enums\UserRole::USER,
\App\Enums\UserRole::ADMIN,
]
];

89
config/filament.php Normal file
View File

@ -0,0 +1,89 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Broadcasting
|--------------------------------------------------------------------------
|
| By uncommenting the Laravel Echo configuration, you may connect Filament
| to any Pusher-compatible websockets server.
|
| This will allow your users to receive real-time notifications.
|
*/
'broadcasting' => [
// 'echo' => [
// 'broadcaster' => 'pusher',
// 'key' => env('VITE_PUSHER_APP_KEY'),
// 'cluster' => env('VITE_PUSHER_APP_CLUSTER'),
// 'wsHost' => env('VITE_PUSHER_HOST'),
// 'wsPort' => env('VITE_PUSHER_PORT'),
// 'wssPort' => env('VITE_PUSHER_PORT'),
// 'authEndpoint' => '/broadcasting/auth',
// 'disableStats' => true,
// 'encrypted' => true,
// 'forceTLS' => true,
// ],
],
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| This is the storage disk Filament will use to store files. You may use
| any of the disks defined in the `config/filesystems.php`.
|
*/
'default_filesystem_disk' => env('FILAMENT_FILESYSTEM_DISK', 'public'),
/*
|--------------------------------------------------------------------------
| Assets Path
|--------------------------------------------------------------------------
|
| This is the directory where Filament's assets will be published to. It
| is relative to the `public` directory of your Laravel application.
|
| After changing the path, you should run `php artisan filament:assets`.
|
*/
'assets_path' => null,
/*
|--------------------------------------------------------------------------
| Cache Path
|--------------------------------------------------------------------------
|
| This is the directory that Filament will use to store cache files that
| are used to optimize the registration of components.
|
| After changing the path, you should run `php artisan filament:cache-components`.
|
*/
'cache_path' => base_path('bootstrap/cache/filament'),
/*
|--------------------------------------------------------------------------
| Livewire Loading Delay
|--------------------------------------------------------------------------
|
| This sets the delay before loading indicators appear.
|
| Setting this to 'none' makes indicators appear immediately, which can be
| desirable for high-latency connections. Setting it to 'default' applies
| Livewire's standard 200ms delay.
|
*/
'livewire_loading_delay' => 'default',
];

5
lang/en.json Normal file
View File

@ -0,0 +1,5 @@
{
"servers.create.public_key_text": "mkdir -p /root/.ssh && touch /root/.ssh/authorized_keys && echo ':public_key' >> /root/.ssh/authorized_keys",
"servers.create.public_key_warning": "Your server needs to have a new unused installation of supported operating systems and must have a root user. To get started, add our public key to /root/.ssh/authorized_keys file by running the bellow command on your server as root.",
"server_providers.plan": ":name - :cpu Cores(:architecture) - :memory - :disk Disk"
}

189
package-lock.json generated
View File

@ -5,22 +5,23 @@
"packages": {
"": {
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/typography": "^0.5.9",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"alpinejs": "^3.4.2",
"apexcharts": "^3.44.2",
"autoprefixer": "^10.4.2",
"autoprefixer": "^10.4.20",
"brace": "^0.11.1",
"flowbite": "^2.3.0",
"flowbite-datepicker": "^1.2.6",
"htmx.org": "^1.9.10",
"laravel-echo": "^1.15.0",
"laravel-vite-plugin": "^0.7.2",
"postcss": "^8.4.31",
"postcss": "^8.4.45",
"postcss-nesting": "^13.0.0",
"prettier": "^3.2.5",
"prettier-plugin-blade": "^2.1.6",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.1.0",
"tailwindcss": "^3.4.10",
"tippy.js": "^6.3.7",
"vite": "^4.5.3"
}
@ -510,21 +511,21 @@
}
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz",
"integrity": "sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==",
"version": "0.5.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz",
"integrity": "sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==",
"dev": true,
"dependencies": {
"mini-svg-data-uri": "^1.2.3"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1"
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.13.tgz",
"integrity": "sha512-ADGcJ8dX21dVVHIwTRgzrcunY6YY9uSlAHHGVKvkA+vLc5qLwEszvKts40lx7z0qc4clpjclwLeK5rVCV2P/uw==",
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz",
"integrity": "sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==",
"dev": true,
"dependencies": {
"lodash.castarray": "^4.4.0",
@ -533,7 +534,7 @@
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20"
}
},
"node_modules/@vue/reactivity": {
@ -631,9 +632,9 @@
"dev": true
},
"node_modules/autoprefixer": {
"version": "10.4.19",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
"version": "10.4.20",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
"integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==",
"dev": true,
"funding": [
{
@ -650,11 +651,11 @@
}
],
"dependencies": {
"browserslist": "^4.23.0",
"caniuse-lite": "^1.0.30001599",
"browserslist": "^4.23.3",
"caniuse-lite": "^1.0.30001646",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"picocolors": "^1.0.0",
"picocolors": "^1.0.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
@ -713,9 +714,9 @@
}
},
"node_modules/browserslist": {
"version": "4.23.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz",
"integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==",
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
"integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
"dev": true,
"funding": [
{
@ -732,10 +733,10 @@
}
],
"dependencies": {
"caniuse-lite": "^1.0.30001587",
"electron-to-chromium": "^1.4.668",
"node-releases": "^2.0.14",
"update-browserslist-db": "^1.0.13"
"caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.5.4",
"node-releases": "^2.0.18",
"update-browserslist-db": "^1.1.0"
},
"bin": {
"browserslist": "cli.js"
@ -754,9 +755,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001615",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001615.tgz",
"integrity": "sha512-1IpazM5G3r38meiae0bHRnPhz+CBQ3ZLqbQMtrg+AsTPKAXgW38JNsXkyZ+v8waCsDmPq87lmfun5Q2AGysNEQ==",
"version": "1.0.30001658",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001658.tgz",
"integrity": "sha512-N2YVqWbJELVdrnsW5p+apoQyYt51aBMSsBZki1XZEfeBCexcM/sf4xiAHcXQBkuOwJBXtWF7aW1sYX6tKebPHw==",
"dev": true,
"funding": [
{
@ -881,9 +882,9 @@
"dev": true
},
"node_modules/electron-to-chromium": {
"version": "1.4.754",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.754.tgz",
"integrity": "sha512-7Kr5jUdns5rL/M9wFFmMZAgFDuL2YOnanFH4OI4iFzUqyh3XOL7nAGbSlSMZdzKMIyyTpNSbqZsWG9odwLeKvA==",
"version": "1.5.18",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.18.tgz",
"integrity": "sha512-1OfuVACu+zKlmjsNdcJuVQuVE61sZOLbNM4JAQ1Rvh6EOj0/EUKhMJjRH73InPlXSh8HIJk1cVZ8pyOV/FMdUQ==",
"dev": true
},
"node_modules/emoji-regex": {
@ -930,9 +931,9 @@
}
},
"node_modules/escalade": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
"integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"engines": {
"node": ">=6"
@ -1358,9 +1359,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
"dev": true
},
"node_modules/normalize-path": {
@ -1431,9 +1432,9 @@
}
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
"dev": true
},
"node_modules/picomatch": {
@ -1467,9 +1468,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"version": "8.4.45",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz",
"integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==",
"dev": true,
"funding": [
{
@ -1487,7 +1488,7 @@
],
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"picocolors": "^1.0.1",
"source-map-js": "^1.2.0"
},
"engines": {
@ -1609,6 +1610,90 @@
"node": ">=4"
}
},
"node_modules/postcss-nesting": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-13.0.0.tgz",
"integrity": "sha512-TCGQOizyqvEkdeTPM+t6NYwJ3EJszYE/8t8ILxw/YoeUvz2rz7aM8XTAmBWh9/DJjfaaabL88fWrsVHSPF2zgA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"dependencies": {
"@csstools/selector-resolve-nested": "^2.0.0",
"@csstools/selector-specificity": "^4.0.0",
"postcss-selector-parser": "^6.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"postcss": "^8.4"
}
},
"node_modules/postcss-nesting/node_modules/@csstools/selector-resolve-nested": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-2.0.0.tgz",
"integrity": "sha512-oklSrRvOxNeeOW1yARd4WNCs/D09cQjunGZUgSq6vM8GpzFswN+8rBZyJA29YFZhOTQ6GFzxgLDNtVbt9wPZMA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"postcss-selector-parser": "^6.1.0"
}
},
"node_modules/postcss-nesting/node_modules/@csstools/selector-specificity": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-4.0.0.tgz",
"integrity": "sha512-189nelqtPd8++phaHNwYovKZI0FOzH1vQEE3QhHHkNIGrg5fSs9CbYP3RvfEH5geztnIA9Jwq91wyOIwAW5JIQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"postcss-selector-parser": "^6.1.0"
}
},
"node_modules/postcss-nesting/node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
@ -2100,9 +2185,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz",
"integrity": "sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==",
"version": "3.4.10",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz",
"integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==",
"dev": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@ -2198,9 +2283,9 @@
"dev": true
},
"node_modules/update-browserslist-db": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.14.tgz",
"integrity": "sha512-JixKH8GR2pWYshIPUg/NujK3JO7JiqEEUiNArE86NQyrgUuZeTlZQN3xuS/yiV5Kb48ev9K6RqNkaJjXsdg7Jw==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
"integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==",
"dev": true,
"funding": [
{
@ -2218,7 +2303,7 @@
],
"dependencies": {
"escalade": "^3.1.2",
"picocolors": "^1.0.0"
"picocolors": "^1.0.1"
},
"bin": {
"update-browserslist-db": "cli.js"

View File

@ -7,22 +7,23 @@
"lint:fix": "prettier --write ./resources/views"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/typography": "^0.5.9",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"alpinejs": "^3.4.2",
"apexcharts": "^3.44.2",
"autoprefixer": "^10.4.2",
"autoprefixer": "^10.4.20",
"brace": "^0.11.1",
"flowbite": "^2.3.0",
"flowbite-datepicker": "^1.2.6",
"htmx.org": "^1.9.10",
"laravel-echo": "^1.15.0",
"laravel-vite-plugin": "^0.7.2",
"postcss": "^8.4.31",
"postcss": "^8.4.45",
"postcss-nesting": "^13.0.0",
"prettier": "^3.2.5",
"prettier-plugin-blade": "^2.1.6",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.1.0",
"tailwindcss": "^3.4.10",
"tippy.js": "^6.3.7",
"vite": "^4.5.3"
}

File diff suppressed because one or more lines are too long

View File

@ -1,9 +1,14 @@
{
"resources/css/app.css": {
"file": "assets/app-888ea5fa.css",
"file": "assets/app-852e0e7b.css",
"isEntry": true,
"src": "resources/css/app.css"
},
"resources/css/filament/app/theme.css": {
"file": "assets/theme-98a3afe7.css",
"isEntry": true,
"src": "resources/css/filament/app/theme.css"
},
"resources/js/app.css": {
"file": "assets/app-a1ae07b3.css",
"src": "resources/js/app.css"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
.fi-pagination-items,.fi-pagination-overview,.fi-pagination-records-per-page-select:not(.fi-compact){display:none}@supports (container-type:inline-size){.fi-pagination{container-type:inline-size}@container (min-width: 28rem){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@container (min-width: 56rem){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}@supports not (container-type:inline-size){@media (min-width:640px){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@media (min-width:768px){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{background-color:#333;border-radius:4px;color:#fff;font-size:14px;line-height:1.4;outline:0;position:relative;transition-property:transform,visibility,opacity;white-space:normal}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{border-top-color:initial;border-width:8px 8px 0;bottom:-7px;left:0;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{border-bottom-color:initial;border-width:0 8px 8px;left:0;top:-7px;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-left-color:initial;border-width:8px 0 8px 8px;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{border-right-color:initial;border-width:8px 8px 8px 0;left:-7px;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{color:#333;height:16px;width:16px}.tippy-arrow:before{border-color:transparent;border-style:solid;content:"";position:absolute}.tippy-content{padding:5px 9px;position:relative;z-index:1}.tippy-box[data-theme~=light]{background-color:#fff;box-shadow:0 0 20px 4px #9aa1b126,0 4px 80px -8px #24282f40,0 4px 4px -2px #5b5e6926;color:#26323d}.tippy-box[data-theme~=light][data-placement^=top]>.tippy-arrow:before{border-top-color:#fff}.tippy-box[data-theme~=light][data-placement^=bottom]>.tippy-arrow:before{border-bottom-color:#fff}.tippy-box[data-theme~=light][data-placement^=left]>.tippy-arrow:before{border-left-color:#fff}.tippy-box[data-theme~=light][data-placement^=right]>.tippy-arrow:before{border-right-color:#fff}.tippy-box[data-theme~=light]>.tippy-backdrop{background-color:#fff}.tippy-box[data-theme~=light]>.tippy-svg-arrow{fill:#fff}.fi-sortable-ghost{opacity:.3}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
function i({state:a,splitKeys:n}){return{newTag:"",state:a,createTag:function(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag:function(t){this.state=this.state.filter(e=>e!==t)},reorderTags:function(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{["x-on:blur"]:"createTag()",["x-model"]:"newTag",["x-on:keydown"](t){["Enter",...n].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},["x-on:paste"](){this.$nextTick(()=>{if(n.length===0){this.createTag();return}let t=n.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{i as default};

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