Compare commits

..

20 Commits

Author SHA1 Message Date
1da1d5d413 Update app.php 2025-06-27 09:32:25 +02:00
f3df6b8673 revert disable 2fa on demo 2025-06-27 08:39:43 +02:00
f532b56abb disable 2fa on demo 2025-06-27 08:39:18 +02:00
194143d7ef Add two factor (#632) 2025-06-27 01:07:33 +02:00
73c836bfe7 add optional force https config 2025-06-26 15:13:00 +02:00
ad5af0cd9e Update SECURITY.md 2025-06-25 23:49:35 +02:00
15d8cb1705 add postgresql 17 (#630) 2025-06-25 22:01:39 +02:00
f4414a931e add custom path to site env (#629) 2025-06-25 21:50:07 +02:00
a593525939 provision lighter servers (#628) 2025-06-25 21:24:45 +02:00
a2841f673b fix DO api connect (#623) 2025-06-23 17:12:47 +02:00
231e90076f fix resource usage chart 2025-06-22 23:08:07 +02:00
dc7fa6b55c Add app command/search (#622) 2025-06-22 22:58:05 +02:00
5689e751af Migrate queues to Horizon (#621) 2025-06-22 11:07:23 +02:00
e59448d30a fix demo 2025-06-21 23:18:24 +02:00
346bd03f4f Merge remote-tracking branch 'origin/3.x' into 3.x 2025-06-21 22:32:56 +02:00
085504605b fix demo 2025-06-21 22:32:45 +02:00
d1a7eb24a7 Update README.md 2025-06-21 22:30:29 +02:00
c10a3ee4bb Update README.md 2025-06-21 22:29:27 +02:00
dd14e69239 Add auto refresh dropdown (#620) 2025-06-21 22:17:49 +02:00
736e27fa4e fix auto upgrade script 2025-06-21 20:56:33 +02:00
74 changed files with 1400 additions and 499 deletions

View File

@ -1,5 +1,5 @@
<p align="center">
<img src="https://github.com/user-attachments/assets/57f77bd5-bd3f-4367-84c0-aff6ecd392b4" alt="VitoDeploy>
<img src="https://github.com/user-attachments/assets/b06531f3-2066-436e-a0e3-0a5f1b7d5118" alt="VitoDeploy>
<p align="center">
<a href="https://github.com/vitodeploy/vito/actions"><img alt="GitHub Workflow Status" src="https://github.com/vitodeploy/vito/workflows/tests/badge.svg"></a>
</p>
@ -14,10 +14,18 @@ ## About Vito
## Quick Start
Version 3 (Alpha):
```sh
bash <(curl -Ls https://raw.githubusercontent.com/vitodeploy/vito/3.x/scripts/install.sh)
```
Version 2:
```sh
bash <(curl -Ls https://raw.githubusercontent.com/vitodeploy/vito/2.x/scripts/install.sh)
```
## Features
- Provisions and Manages the server
@ -30,6 +38,8 @@ ## Features
- Deploy your SSH Keys to the server
- Create and Manage cron jobs on the server
- API
- Plugins
- Export and Import
## Useful Links
@ -46,6 +56,7 @@ ## Credits
- Laravel
- InertiaJS
- ReactJS
- Shadcn UI
- PHPSecLib
- PHPUnit
@ -53,3 +64,5 @@ ## Credits
- Vite
- Prettier
- Spatie
- Opcodesio log viewer
- Tightenco

View File

@ -2,11 +2,12 @@ # Security Policy
## Supported Versions
| Version | New Features | Bug Fixes | Security Fixes |
|---------|--------------|-----------|----------------------|
| 0.x | ❌ | ❌ | ❌ |
| 1.x | ❌ | ❌ | ✅ (Until March 2025) |
| 2.x | | ✅ | ✅ |
| Version | New Features | Bug Fixes | Security Fixes |
|---------|--------------|-----------|----------------|
| 0.x | ❌ | ❌ | ❌ |
| 1.x | ❌ | ❌ | |
| 2.x | | ✅ | ✅ |
| 3.x | ✅ | ✅ | ✅ |
## Reporting a Vulnerability

View File

@ -65,7 +65,7 @@ public function delete(Backup $backup): void
}
$backup->delete();
});
})->onQueue('ssh');
}
/**

View File

@ -30,6 +30,6 @@ public function delete(BackupFile $file): void
dispatch(function () use ($file): void {
$file->deleteFile();
});
})->onQueue('ssh');
}
}

View File

@ -37,7 +37,7 @@ public function restore(BackupFile $backupFile, array $input): void
})->catch(function () use ($backupFile): void {
$backupFile->status = BackupFileStatus::RESTORE_FAILED;
$backupFile->save();
})->onConnection('ssh');
})->onQueue('ssh');
}
/**

View File

@ -39,7 +39,7 @@ public function run(Backup $backup): BackupFile
$backup->save();
$file->status = BackupFileStatus::FAILED;
$file->save();
})->onConnection('ssh');
})->onQueue('ssh');
return $file;
}

View File

@ -34,7 +34,7 @@ public function create(Server $server, array $input): FirewallRule
$rule->save();
dispatch(fn () => $this->applyRule($rule));
dispatch(fn () => $this->applyRule($rule))->onQueue('ssh');
return $rule;
}
@ -58,7 +58,7 @@ public function update(FirewallRule $rule, array $input): FirewallRule
'status' => FirewallRuleStatus::UPDATING,
]);
dispatch(fn () => $this->applyRule($rule));
dispatch(fn () => $this->applyRule($rule))->onQueue('ssh');
return $rule;
}
@ -68,7 +68,7 @@ public function delete(FirewallRule $rule): void
$rule->status = FirewallRuleStatus::DELETING;
$rule->save();
dispatch(fn () => $this->applyRule($rule));
dispatch(fn () => $this->applyRule($rule))->onQueue('ssh');
}
protected function applyRule(FirewallRule $rule): void

View File

@ -44,7 +44,7 @@ function () use ($service, $input): void {
$typeData['extensions'] = array_values(array_diff($typeData['extensions'], [$input['extension']]));
$service->type_data = $typeData;
$service->save();
})->onConnection('ssh');
})->onQueue('ssh-unique');
return $service;
}

View File

@ -43,7 +43,7 @@ public function create(Site $site, array $input): Redirect
$redirect->status = RedirectStatus::FAILED;
$redirect->save();
})
->onConnection('ssh');
->onQueue('ssh-unique');
return $redirect->refresh();
}

View File

@ -27,6 +27,6 @@ public function delete(Site $site, Redirect $redirect): void
})->catch(function () use ($redirect): void {
$redirect->status = RedirectStatus::FAILED;
$redirect->save();
})->onConnection('ssh');
})->onQueue('ssh-unique');
}
}

View File

@ -60,7 +60,7 @@ public function create(Site $site, array $input): void
})->catch(function () use ($ssl): void {
$ssl->status = SslStatus::FAILED;
$ssl->save();
})->onConnection('ssh');
})->onQueue('ssh-unique');
}
/**

View File

@ -62,7 +62,7 @@ public function execute(Script $script, User $user, array $input): ScriptExecuti
})->catch(function () use ($execution): void {
$execution->status = ScriptExecutionStatus::FAILED;
$execution->save();
})->onConnection('ssh');
})->onQueue('ssh');
return $execution;
}

View File

@ -81,7 +81,7 @@ public function create(User $creator, Project $project, array $input): Server
'error' => (string) $e,
]);
})
->onConnection('ssh');
->onQueue('ssh');
return $this->server;
} catch (Exception $e) {
@ -198,30 +198,10 @@ public function createFirewallRules(Server $server): void
private function createServices(): void
{
$this->server->services()->forceDelete();
$this->addSupervisor();
$this->addRedis();
$this->addUfw();
$this->addMonitoring();
}
private function addSupervisor(): void
{
$this->server->services()->create([
'type' => 'process_manager',
'name' => 'supervisor',
'version' => 'latest',
]);
}
private function addRedis(): void
{
$this->server->services()->create([
'type' => 'memory_database',
'name' => 'redis',
'version' => 'latest',
]);
}
private function addUfw(): void
{
$this->server->services()->create([

View File

@ -20,6 +20,6 @@ public function update(Server $server): void
})->catch(function () use ($server): void {
Notifier::send($server, new ServerUpdateFailed($server));
$server->checkConnection();
})->onConnection('ssh');
})->onQueue('ssh-unique');
}
}

View File

@ -46,7 +46,7 @@ public function install(Server $server, array $input): Service
})->catch(function () use ($service): void {
$service->status = ServiceStatus::INSTALLATION_FAILED;
$service->save();
})->onConnection('ssh');
})->onQueue('ssh-unique');
return $service;
}

View File

@ -21,7 +21,7 @@ public function start(Service $service): void
$service->status = ServiceStatus::FAILED;
}
$service->save();
})->onConnection('ssh');
})->onQueue('ssh');
}
public function stop(Service $service): void
@ -37,7 +37,7 @@ public function stop(Service $service): void
$service->status = ServiceStatus::FAILED;
}
$service->save();
})->onConnection('ssh');
})->onQueue('ssh');
}
public function restart(Service $service): void
@ -53,7 +53,7 @@ public function restart(Service $service): void
$service->status = ServiceStatus::FAILED;
}
$service->save();
})->onConnection('ssh');
})->onQueue('ssh');
}
public function enable(Service $service): void
@ -69,7 +69,7 @@ public function enable(Service $service): void
$service->status = ServiceStatus::FAILED;
}
$service->save();
})->onConnection('ssh');
})->onQueue('ssh');
}
public function disable(Service $service): void
@ -85,7 +85,7 @@ public function disable(Service $service): void
$service->status = ServiceStatus::FAILED;
}
$service->save();
})->onConnection('ssh');
})->onQueue('ssh');
}
private function validate(Service $service): void

View File

@ -26,6 +26,6 @@ public function uninstall(Service $service): void
})->catch(function () use ($service): void {
$service->status = ServiceStatus::FAILED;
$service->save();
})->onConnection('ssh');
})->onQueue('ssh-unique');
}
}

View File

@ -92,7 +92,7 @@ public function create(Server $server, array $input): Site
$site->status = SiteStatus::INSTALLATION_FAILED;
$site->save();
Notifier::send($site, new SiteInstallationFailed($site));
})->onConnection('ssh');
})->onQueue('ssh-unique');
DB::commit();

View File

@ -57,7 +57,7 @@ public function run(Site $site): Deployment
$deployment->status = DeploymentStatus::FAILED;
$deployment->save();
Notifier::send($site, new DeploymentCompleted($deployment, $site));
})->onConnection('ssh');
})->onQueue('ssh-unique');
return $deployment;
}

View File

@ -55,7 +55,7 @@ public function execute(Command $command, User $user, array $input): CommandExec
})->catch(function () use ($execution): void {
$execution->status = CommandExecutionStatus::FAILED;
$execution->save();
})->onConnection('ssh');
})->onQueue('ssh');
return $execution;
}

View File

@ -17,12 +17,15 @@ public function update(Site $site, array $input): void
{
Validator::make($input, [
'env' => ['required', 'string'],
'path' => ['required', 'string'],
])->validate();
$site->server->os()->write(
$site->path.'/.env',
$input['path'],
trim((string) $input['env']),
$site->user,
);
$site->jsonUpdate('type_data', 'env_path', $input['path']);
}
}

View File

@ -55,7 +55,7 @@ public function create(Server $server, array $input, ?Site $site = null): void
$worker->save();
})->catch(function () use ($worker): void {
$worker->delete();
})->onConnection('ssh');
})->onQueue('ssh');
}
/**

View File

@ -55,7 +55,7 @@ public function edit(Worker $worker, array $input): void
})->catch(function () use ($worker): void {
$worker->status = WorkerStatus::FAILED;
$worker->save();
})->onConnection('ssh');
})->onQueue('ssh');
}
/**

View File

@ -21,7 +21,7 @@ public function start(Worker $worker): void
$handler->start($worker->id, $worker->site_id);
$worker->status = WorkerStatus::RUNNING;
$worker->save();
})->onConnection('ssh');
})->onQueue('ssh');
}
public function stop(Worker $worker): void
@ -36,7 +36,7 @@ public function stop(Worker $worker): void
$handler->stop($worker->id, $worker->site_id);
$worker->status = WorkerStatus::STOPPED;
$worker->save();
})->onConnection('ssh');
})->onQueue('ssh');
}
public function restart(Worker $worker): void
@ -51,6 +51,6 @@ public function restart(Worker $worker): void
$handler->restart($worker->id, $worker->site_id);
$worker->status = WorkerStatus::RUNNING;
$worker->save();
})->onConnection('ssh');
})->onQueue('ssh');
}
}

View File

@ -27,7 +27,7 @@ public function handle(): void
return;
}
$server->checkConnection();
})->onConnection('ssh');
})->onQueue('ssh');
}
});
}

View File

@ -12,7 +12,7 @@ class Agent extends MobileDetect
*
* @var array<string, string>
*/
protected static $additionalOperatingSystems = [
protected static array $additionalOperatingSystems = [
'Windows' => 'Windows',
'Windows NT' => 'Windows NT',
'OS X' => 'Mac OS X',
@ -29,7 +29,7 @@ class Agent extends MobileDetect
*
* @var array<string, string>
*/
protected static $additionalBrowsers = [
protected static array $additionalBrowsers = [
'Opera Mini' => 'Opera Mini',
'Opera' => 'Opera|OPR',
'Edge' => 'Edge|Edg',
@ -50,14 +50,12 @@ class Agent extends MobileDetect
*
* @var array<string, mixed>
*/
protected $store = [];
protected array $store = [];
/**
* Get the platform name from the User Agent.
*
* @return string|null
*/
public function platform()
public function platform(): ?string
{
return $this->retrieveUsingCacheOrResolve('paymently.platform', fn () => $this->findDetectionRulesAgainstUserAgent(
$this->mergeRules(MobileDetect::getOperatingSystems(), static::$additionalOperatingSystems)

View File

@ -49,6 +49,18 @@ public function store(Request $request): RedirectResponse
RateLimiter::clear($this->throttleKey());
Session::regenerate();
if (user()->two_factor_secret) {
$request->session()->invalidate();
$request->session()->regenerateToken();
$request->session()->put([
'login.id' => user()->id,
'login.remember' => $request->boolean('remember'),
]);
return redirect()->route('two-factor.login');
}
return redirect()->intended(route('servers', absolute: false));
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Models\User;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Routing\Controller;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\FailedTwoFactorLoginResponse;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Laravel\Fortify\Events\RecoveryCodeReplaced;
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
use Laravel\Fortify\Http\Requests\TwoFactorLoginRequest;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Post;
use Symfony\Component\HttpFoundation\Response;
class TwoFactorAuthenticatedSessionController extends Controller
{
protected StatefulGuard $guard;
public function __construct(StatefulGuard $guard)
{
$this->guard = $guard;
}
#[Get('two-factor', name: 'two-factor.login')]
public function create(TwoFactorLoginRequest $request): \Inertia\Response
{
if (! $request->hasChallengedUser()) {
throw new HttpResponseException(redirect()->route('login'));
}
return Inertia::render('auth/two-factor');
}
#[Post('two-factor', name: 'two-factor.store')]
public function store(TwoFactorLoginRequest $request): TwoFactorLoginResponse|Response
{
/** @var User $user */
$user = $request->challengedUser();
if ($code = $request->validRecoveryCode()) {
$user->replaceRecoveryCode($code);
event(new RecoveryCodeReplaced($user, $code));
} elseif (! $request->hasValidCode()) {
event(new TwoFactorAuthenticationFailed($user));
return app(FailedTwoFactorLoginResponse::class)->toResponse($request);
}
event(new ValidTwoFactorAuthenticationCodeProvided($user));
$this->guard->login($user, $request->remember());
$request->session()->regenerate();
return redirect()->intended(route('servers', absolute: false));
}
}

View File

@ -36,6 +36,10 @@ public function index(): Response
#[Post('/install', name: 'plugins.install')]
public function install(Request $request): RedirectResponse
{
if (config('app.demo')) {
return back()->with('error', 'Plugins are disabled in demo mode.');
}
$this->validate($request, [
'url' => 'required|url',
]);
@ -54,8 +58,7 @@ public function install(Request $request): RedirectResponse
}
Plugins::cleanup();
})
->onConnection('default');
})->onQueue('default');
return back()->with('info', 'Plugin is being installed...');
}
@ -63,6 +66,10 @@ public function install(Request $request): RedirectResponse
#[Delete('/uninstall', name: 'plugins.uninstall')]
public function uninstall(Request $request): RedirectResponse
{
if (config('app.demo')) {
return back()->with('error', 'Plugins are disabled in demo mode.');
}
$this->validate($request, [
'name' => 'required|string',
]);
@ -81,8 +88,7 @@ public function uninstall(Request $request): RedirectResponse
}
Plugins::cleanup();
})
->onConnection('default');
})->onQueue('default');
return back()->with('warning', 'Plugin is being uninstalled...');
}

View File

@ -11,9 +11,12 @@
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Patch;
use Spatie\RouteAttributes\Attributes\Post;
use Spatie\RouteAttributes\Attributes\Prefix;
use Spatie\RouteAttributes\Attributes\Put;
@ -65,4 +68,30 @@ public function password(Request $request): RedirectResponse
return to_route('profile');
}
#[Post('/enable-two-factor', name: 'profile.enable-two-factor')]
public function enableTwoFactor(): RedirectResponse
{
$user = user();
app(EnableTwoFactorAuthentication::class)($user);
return back()
->with('success', 'Two factor authentication enabled.')
->with('data', [
'qr_code' => $user->twoFactorQrCodeSvg(),
'qr_code_url' => $user->twoFactorQrCodeUrl(),
'recovery_codes' => $user->recoveryCodes(),
]);
}
#[Post('/disable-two-factor', name: 'profile.disable-two-factor')]
public function disableTwoFactor(): RedirectResponse
{
$user = user();
app(DisableTwoFactorAuthentication::class)($user);
return back()->with('success', 'Two factor authentication disabled.');
}
}

View File

@ -2,8 +2,10 @@
namespace App\Http\Controllers;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Spatie\RouteAttributes\Attributes\Get;
use Spatie\RouteAttributes\Attributes\Middleware;
use Spatie\RouteAttributes\Attributes\Prefix;
@ -21,8 +23,65 @@ public function search(Request $request): JsonResponse
$query = $request->input('query');
$projects = DB::table('projects')
->select(
DB::raw('projects.id as id'),
DB::raw('null as parent_id'),
DB::raw('projects.name as label'),
DB::raw('"project" as type')
)
->where(function (Builder $query) {
if (! user()->isAdmin()) {
$query
->join('user_project', 'projects.id', '=', 'user_project.project_id')
->where('user_project.user_id', user()->id);
}
})
->where('projects.name', 'like', "%{$query}%");
$servers = DB::table('servers')
->select(
DB::raw('servers.id as id'),
DB::raw('null as parent_id'),
DB::raw('servers.name as label'),
DB::raw('"server" as type')
)
->join('projects', 'servers.project_id', '=', 'projects.id')
->where(function (Builder $query) {
if (! user()->isAdmin()) {
$query
->join('user_project', 'projects.id', '=', 'user_project.project_id')
->where('user_project.user_id', user()->id);
}
})
->where('servers.name', 'like', "%{$query}%");
$sites = DB::table('sites')
->select(
DB::raw('sites.id as id'),
DB::raw('sites.server_id as parent_id'),
DB::raw('sites.domain as label'),
DB::raw('"site" as type')
)
->join('servers', 'sites.server_id', '=', 'servers.id')
->join('projects', 'servers.project_id', '=', 'projects.id')
->where(function (Builder $query) {
if (! user()->isAdmin()) {
$query
->join('user_project', 'projects.id', '=', 'user_project.project_id')
->where('user_project.user_id', user()->id);
}
})
->where('sites.domain', 'like', "%{$query}%");
// Combine with unionAll
$results = $projects
->unionAll($servers)
->unionAll($sites)
->get();
$results = [
'data' => [], // Replace with actual search results
'data' => $results, // Replace with actual search results
];
return response()->json($results);

View File

@ -14,7 +14,6 @@
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Support\Facades\URL;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@ -93,18 +92,6 @@ public function switch(Server $server): RedirectResponse
{
$this->authorize('view', $server);
$previousUrl = URL::previous();
$previousRequest = Request::create($previousUrl);
$previousRoute = app('router')->getRoutes()->match($previousRequest);
if ($previousRoute->hasParameter('server')) {
if (count($previousRoute->parameters()) > 1) {
return redirect()->route('servers.show', ['server' => $server->id]);
}
return redirect()->route($previousRoute->getName(), ['server' => $server]);
}
return redirect()->route('servers.show', ['server' => $server->id]);
}

View File

@ -85,6 +85,10 @@ private function export(string $zipFileName): string
#[Post('/import', name: 'vito-settings.import')]
public function import(Request $request): RedirectResponse
{
if (config('app.demo')) {
return back()->with('error', 'Import is disabled in demo mode.');
}
// set session driver to file
config(['session.driver' => 'file']);

View File

@ -2,8 +2,10 @@
namespace App\Http\Middleware;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\ServerResource;
use App\Http\Resources\SiteResource;
use App\Http\Resources\UserResource;
use App\Models\Server;
use App\Models\Site;
use App\Models\User;
@ -55,12 +57,17 @@ public function share(Request $request): array
$data = [];
if ($request->route('server')) {
$data['server'] = ServerResource::make($request->route('server'));
/** @var Server $server */
$server = $request->route('server');
if ($user && $user->can('view', $server) && $user->current_project_id !== $server->project_id) {
$user->current_project_id = $server->project_id;
$user->save();
}
$data['server'] = ServerResource::make($server);
// sites
$sites = [];
/** @var Server $server */
$server = $request->route('server');
if ($user && $user->can('viewAny', [Site::class, $server])) {
$sites = SiteResource::collection($server->sites);
}
@ -76,12 +83,14 @@ public function share(Request $request): array
...parent::share($request),
...$data,
'name' => config('app.name'),
'version' => config('app.version'),
'demo' => config('app.demo'),
'quote' => ['message' => trim($message), 'author' => trim($author)],
'auth' => [
'user' => $user,
'projects' => $user?->allProjects()->get(),
'currentProject' => $user?->currentProject,
],
'auth' => $user ? [
'user' => UserResource::make($user->load('projects')),
'projects' => ProjectResource::collection($user->allProjects()->get()),
'currentProject' => ProjectResource::make($user->currentProject),
] : null,
'public_key_text' => __('servers.create.public_key_text', ['public_key' => get_public_key_content()]),
'project_servers' => $servers,
'configs' => [

View File

@ -19,6 +19,7 @@ public function toArray(Request $request): array
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'two_factor_enabled' => (bool) $this->two_factor_secret,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'projects' => ProjectResource::collection($this->whenLoaded('projects')),

View File

@ -8,6 +8,7 @@
use App\Models\PersonalAccessToken;
use App\Plugins\Plugins;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;
use Laravel\Sanctum\Sanctum;
@ -33,5 +34,12 @@ public function boot(): void
$this->app->bind('plugins', fn (): Plugins => new Plugins);
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
Fortify::twoFactorChallengeView(function () {
return view('app');
});
if (config('app.force_https')) {
URL::forceHttps();
}
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function (?User $user = null) {
return $user?->isAdmin();
});
}
}

View File

@ -65,6 +65,7 @@ private function databases(): void
->label('PostgreSQL')
->handler(Postgresql::class)
->versions([
'17',
'16',
'15',
'14',

View File

@ -55,7 +55,7 @@ public function data(array $input): array
public function connect(array $credentials): bool
{
try {
$connect = Http::withToken($credentials['token'])->get($this->apiUrl.'/account');
$connect = Http::withToken($credentials['token'])->get($this->apiUrl.'/droplets');
} catch (Exception) {
throw new CouldNotConnectToProvider('DigitalOcean');
}

View File

@ -34,15 +34,12 @@ public function createRules(array $input): array
public function connect(): bool
{
try {
ds($this->getApiUrl());
$res = Http::withToken($this->data()['token'])
->get($this->getApiUrl().'/version');
} catch (Exception) {
return false;
}
ds($res->status());
return $res->successful();
}

View File

@ -13,9 +13,9 @@
"ext-intl": "*",
"aws/aws-sdk-php": "^3.158",
"inertiajs/inertia-laravel": "^2.0",
"laradumps/laradumps": "^4.2",
"laravel/fortify": "^1.17",
"laravel/framework": "^12.0",
"laravel/horizon": "^5.33",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.8",
"mobiledetect/mobiledetectlib": "^4.8",

493
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "fc8ee6c13042c4df2cd39b59c4d80ac1",
"content-hash": "b7e394815fa2fc2447c357d65eda06e7",
"packages": [
{
"name": "aws/aws-crt-php",
@ -1377,147 +1377,6 @@
},
"time": "2025-04-10T15:08:36+00:00"
},
{
"name": "laradumps/laradumps",
"version": "v4.2.1",
"source": {
"type": "git",
"url": "https://github.com/laradumps/laradumps.git",
"reference": "0cdd5fe9e20efc71c280a97a6aca3a05c19469f6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laradumps/laradumps/zipball/0cdd5fe9e20efc71c280a97a6aca3a05c19469f6",
"reference": "0cdd5fe9e20efc71c280a97a6aca3a05c19469f6",
"shasum": ""
},
"require": {
"illuminate/mail": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"laradumps/laradumps-core": "^3.2.2",
"nunomaduro/termwind": "^1.15.1|^2.0.1",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.0|^3.0",
"laravel/framework": "^10.0|^11.0|^12.0",
"laravel/pint": "^1.17.2",
"livewire/livewire": "^3.5.6",
"mockery/mockery": "^1.6.12",
"orchestra/testbench-core": "^8.0|^9.4|^10.0",
"pestphp/pest": "^2.35.1|^3.7.0",
"symfony/var-dumper": "^6.4.0|^7.1.3"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"LaraDumps\\LaraDumps\\LaraDumpsServiceProvider"
]
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"LaraDumps\\LaraDumps\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Luan Freitas",
"email": "luanfreitas10@protonmail.com",
"role": "Developer"
}
],
"description": "LaraDumps is a friendly app designed to boost your Laravel PHP coding and debugging experience.",
"homepage": "https://github.com/laradumps/laradumps",
"support": {
"issues": "https://github.com/laradumps/laradumps/issues",
"source": "https://github.com/laradumps/laradumps/tree/v4.2.1"
},
"funding": [
{
"url": "https://github.com/luanfreitasdev",
"type": "github"
}
],
"time": "2025-06-12T13:38:57+00:00"
},
{
"name": "laradumps/laradumps-core",
"version": "v3.2.4",
"source": {
"type": "git",
"url": "https://github.com/laradumps/laradumps-core.git",
"reference": "eb0e15805ac4061a524c43ed7c1e7796ef3326d8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laradumps/laradumps-core/zipball/eb0e15805ac4061a524c43ed7c1e7796ef3326d8",
"reference": "eb0e15805ac4061a524c43ed7c1e7796ef3326d8",
"shasum": ""
},
"require": {
"ext-curl": "*",
"nunomaduro/termwind": "^1.15|^2.0",
"php": "^8.1",
"ramsey/uuid": "^4.7.5",
"spatie/backtrace": "^1.5",
"symfony/console": "^5.4|^6.4|^7.0",
"symfony/finder": "^5.4|^6.4|^7.0",
"symfony/process": "^5.4|^6.4|^7.0",
"symfony/var-dumper": "^5.4|^6.4|^7.0",
"symfony/yaml": "^5.4|^6.4|^7.0"
},
"require-dev": {
"illuminate/support": "^10.46",
"laravel/pint": "^1.13.7",
"pestphp/pest": "^2.0|^3.0",
"phpstan/phpstan": "^1.10.50"
},
"bin": [
"bin/laradumps"
],
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"LaraDumps\\LaraDumpsCore\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Luan Freitas",
"email": "luanfreitas10@protonmail.com",
"role": "Developer"
}
],
"description": "LaraDumps is a friendly app designed to boost your Laravel / PHP coding and debugging experience.",
"homepage": "https://github.com/laradumps/laradumps-core",
"support": {
"issues": "https://github.com/laradumps/laradumps-core/issues",
"source": "https://github.com/laradumps/laradumps-core/tree/v3.2.4"
},
"funding": [
{
"url": "https://github.com/luanfreitasdev",
"type": "github"
}
],
"time": "2025-05-16T14:48:30+00:00"
},
{
"name": "laravel/fortify",
"version": "v1.27.0",
@ -1798,6 +1657,86 @@
},
"time": "2025-06-18T12:56:23+00:00"
},
{
"name": "laravel/horizon",
"version": "v5.33.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/horizon.git",
"reference": "50057bca1f1dcc9fbd5ff6d65143833babd784b3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/horizon/zipball/50057bca1f1dcc9fbd5ff6d65143833babd784b3",
"reference": "50057bca1f1dcc9fbd5ff6d65143833babd784b3",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-pcntl": "*",
"ext-posix": "*",
"illuminate/contracts": "^9.21|^10.0|^11.0|^12.0",
"illuminate/queue": "^9.21|^10.0|^11.0|^12.0",
"illuminate/support": "^9.21|^10.0|^11.0|^12.0",
"nesbot/carbon": "^2.17|^3.0",
"php": "^8.0",
"ramsey/uuid": "^4.0",
"symfony/console": "^6.0|^7.0",
"symfony/error-handler": "^6.0|^7.0",
"symfony/polyfill-php83": "^1.28",
"symfony/process": "^6.0|^7.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^9.0|^10.4|^11.5",
"predis/predis": "^1.1|^2.0"
},
"suggest": {
"ext-redis": "Required to use the Redis PHP driver.",
"predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0)."
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Horizon": "Laravel\\Horizon\\Horizon"
},
"providers": [
"Laravel\\Horizon\\HorizonServiceProvider"
]
},
"branch-alias": {
"dev-master": "6.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Horizon\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Dashboard and code-driven configuration for Laravel queues.",
"keywords": [
"laravel",
"queue"
],
"support": {
"issues": "https://github.com/laravel/horizon/issues",
"source": "https://github.com/laravel/horizon/tree/v5.33.1"
},
"time": "2025-06-16T13:48:30+00:00"
},
{
"name": "laravel/prompts",
"version": "v0.3.5",
@ -4465,69 +4404,6 @@
},
"time": "2025-06-01T06:28:46+00:00"
},
{
"name": "spatie/backtrace",
"version": "1.7.4",
"source": {
"type": "git",
"url": "https://github.com/spatie/backtrace.git",
"reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/backtrace/zipball/cd37a49fce7137359ac30ecc44ef3e16404cccbe",
"reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe",
"shasum": ""
},
"require": {
"php": "^7.3 || ^8.0"
},
"require-dev": {
"ext-json": "*",
"laravel/serializable-closure": "^1.3 || ^2.0",
"phpunit/phpunit": "^9.3 || ^11.4.3",
"spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6",
"symfony/var-dumper": "^5.1 || ^6.0 || ^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Backtrace\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van de Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "A better backtrace",
"homepage": "https://github.com/spatie/backtrace",
"keywords": [
"Backtrace",
"spatie"
],
"support": {
"source": "https://github.com/spatie/backtrace/tree/1.7.4"
},
"funding": [
{
"url": "https://github.com/sponsors/spatie",
"type": "github"
},
{
"url": "https://spatie.be/open-source/support-us",
"type": "other"
}
],
"time": "2025-05-08T15:41:09+00:00"
},
{
"name": "spatie/laravel-route-attributes",
"version": "1.25.2",
@ -6834,78 +6710,6 @@
],
"time": "2025-04-27T18:39:23+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "cea40a48279d58dc3efee8112634cb90141156c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/cea40a48279d58dc3efee8112634cb90141156c2",
"reference": "cea40a48279d58dc3efee8112634cb90141156c2",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.3.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-04T10:10:33+00:00"
},
{
"name": "tightenco/ziggy",
"version": "v2.5.3",
@ -9854,6 +9658,69 @@
],
"time": "2024-02-20T11:51:46+00:00"
},
{
"name": "spatie/backtrace",
"version": "1.7.4",
"source": {
"type": "git",
"url": "https://github.com/spatie/backtrace.git",
"reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/backtrace/zipball/cd37a49fce7137359ac30ecc44ef3e16404cccbe",
"reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe",
"shasum": ""
},
"require": {
"php": "^7.3 || ^8.0"
},
"require-dev": {
"ext-json": "*",
"laravel/serializable-closure": "^1.3 || ^2.0",
"phpunit/phpunit": "^9.3 || ^11.4.3",
"spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6",
"symfony/var-dumper": "^5.1 || ^6.0 || ^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Backtrace\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van de Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "A better backtrace",
"homepage": "https://github.com/spatie/backtrace",
"keywords": [
"Backtrace",
"spatie"
],
"support": {
"source": "https://github.com/spatie/backtrace/tree/1.7.4"
},
"funding": [
{
"url": "https://github.com/sponsors/spatie",
"type": "github"
},
{
"url": "https://spatie.be/open-source/support-us",
"type": "other"
}
],
"time": "2025-05-08T15:41:09+00:00"
},
{
"name": "spatie/data-transfer-object",
"version": "3.9.1",
@ -10364,6 +10231,78 @@
],
"time": "2025-05-15T09:04:05+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.3.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "cea40a48279d58dc3efee8112634cb90141156c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/cea40a48279d58dc3efee8112634cb90141156c2",
"reference": "cea40a48279d58dc3efee8112634cb90141156c2",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.3.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-04-04T10:10:33+00:00"
},
{
"name": "theseer/tokenizer",
"version": "1.2.3",

View File

@ -200,6 +200,7 @@
App\Providers\SourceControlServiceProvider::class,
App\Providers\NotificationChannelServiceProvider::class,
App\Providers\ServiceTypeServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
],
/*
@ -217,7 +218,9 @@
// 'ExampleClass' => App\Example\ExampleClass::class,
])->toArray(),
'version' => '3.0.0-alpha-1',
'version' => '3.0.0-beta-1',
'demo' => env('APP_DEMO', false),
'force_https' => env('FORCE_HTTPS', false),
];

242
config/horizon.php Normal file
View File

@ -0,0 +1,242 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => env('HORIZON_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Horizon Path
|--------------------------------------------------------------------------
|
| This is the URI path where Horizon will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('HORIZON_PATH', 'horizon'),
/*
|--------------------------------------------------------------------------
| Horizon Redis Connection
|--------------------------------------------------------------------------
|
| This is the name of the Redis connection where Horizon will store the
| meta information required for it to function. It includes the list
| of supervisors, failed jobs, job metrics, and other information.
|
*/
'use' => 'default',
/*
|--------------------------------------------------------------------------
| Horizon Redis Prefix
|--------------------------------------------------------------------------
|
| This prefix will be used when storing all Horizon data in Redis. You
| may modify the prefix when you are running multiple installations
| of Horizon on the same server so that they don't have problems.
|
*/
'prefix' => env(
'HORIZON_PREFIX',
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
),
/*
|--------------------------------------------------------------------------
| Horizon Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will get attached onto each Horizon route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => [
'web',
\App\Http\Middleware\MustBeAdminMiddleware::class,
],
/*
|--------------------------------------------------------------------------
| Queue Wait Time Thresholds
|--------------------------------------------------------------------------
|
| This option allows you to configure when the LongWaitDetected event
| will be fired. Every connection / queue combination may have its
| own, unique threshold (in seconds) before this event is fired.
|
*/
'waits' => [
'redis:default' => 60,
],
/*
|--------------------------------------------------------------------------
| Job Trimming Times
|--------------------------------------------------------------------------
|
| Here you can configure for how long (in minutes) you desire Horizon to
| persist the recent and failed jobs. Typically, recent jobs are kept
| for one hour while all failed jobs are stored for an entire week.
|
*/
'trim' => [
'recent' => 60,
'pending' => 60,
'completed' => 60,
'recent_failed' => 10080,
'failed' => 10080,
'monitored' => 10080,
],
/*
|--------------------------------------------------------------------------
| Silenced Jobs
|--------------------------------------------------------------------------
|
| Silencing a job will instruct Horizon to not place the job in the list
| of completed jobs within the Horizon dashboard. This setting may be
| used to fully remove any noisy jobs from the completed jobs list.
|
*/
'silenced' => [
// App\Jobs\ExampleJob::class,
],
/*
|--------------------------------------------------------------------------
| Metrics
|--------------------------------------------------------------------------
|
| Here you can configure how many snapshots should be kept to display in
| the metrics graph. This will get used in combination with Horizon's
| `horizon:snapshot` schedule to define how long to retain metrics.
|
*/
'metrics' => [
'trim_snapshots' => [
'job' => 24,
'queue' => 24,
],
],
/*
|--------------------------------------------------------------------------
| Fast Termination
|--------------------------------------------------------------------------
|
| When this option is enabled, Horizon's "terminate" command will not
| wait on all of the workers to terminate unless the --wait option
| is provided. Fast termination can shorten deployment delay by
| allowing a new instance of Horizon to start while the last
| instance will continue to terminate each of its workers.
|
*/
'fast_termination' => false,
/*
|--------------------------------------------------------------------------
| Memory Limit (MB)
|--------------------------------------------------------------------------
|
| This value describes the maximum amount of memory the Horizon master
| supervisor may consume before it is terminated and restarted. For
| configuring these limits on your workers, see the next section.
|
*/
'memory_limit' => 64,
/*
|--------------------------------------------------------------------------
| Queue Worker Configuration
|--------------------------------------------------------------------------
|
| Here you may define the queue worker settings used by your application
| in all environments. These supervisors and settings handle all your
| queued jobs and will be provisioned by Horizon during deployment.
|
*/
'defaults' => [
'default' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 90,
'nice' => 0,
],
'ssh' => [
'connection' => 'redis',
'queue' => ['ssh'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 600,
'nice' => 0,
],
'ssh-unique' => [
'connection' => 'redis',
'queue' => ['ssh-unique'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'maxProcesses' => 1,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 1,
'timeout' => 600,
'nice' => 0,
],
],
'environments' => [
'*' => [
'default' => [
'maxProcesses' => 3,
],
'ssh' => [
'maxProcesses' => 3,
],
'ssh-unique' => [
'maxProcesses' => 1,
],
],
],
];

View File

@ -10,7 +10,7 @@ user=root
autostart=1
autorestart=1
numprocs=1
command=/usr/bin/php /var/www/html/artisan queue:work --sleep=3 --backoff=0 --queue=default,ssh,ssh-long --timeout=3600 --tries=1
command=/usr/bin/php /var/www/html/artisan horizon
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/worker.log
stopwaitsecs=3600

View File

@ -7,38 +7,37 @@ import typescript from 'typescript-eslint';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...typescript.configs.recommended,
{
...react.configs.flat.recommended,
...react.configs.flat['jsx-runtime'], // Required for React 17+
languageOptions: {
globals: {
...globals.browser,
},
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'react/no-unescaped-entities': 'off',
},
settings: {
react: {
version: 'detect',
},
},
js.configs.recommended,
...typescript.configs.recommended,
{
...react.configs.flat.recommended,
...react.configs.flat['jsx-runtime'], // Required for React 17+
languageOptions: {
globals: {
...globals.browser,
},
},
{
plugins: {
'react-hooks': reactHooks,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'react/no-unescaped-entities': 'off',
},
{
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
settings: {
react: {
version: 'detect',
},
},
prettier, // Turn off all rules that might conflict with Prettier
},
{
plugins: {
'react-hooks': reactHooks,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
},
},
{
ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'],
},
prettier, // Turn off all rules that might conflict with Prettier
];

View File

@ -4,7 +4,6 @@ includes:
parameters:
paths:
- app
- config
- bootstrap
- database/factories
level: 7

View File

@ -3,9 +3,25 @@ import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { CommandIcon, SearchIcon } from 'lucide-react';
import CreateServer from '@/pages/servers/components/create-server';
import ProjectForm from '@/pages/projects/components/project-form';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { Badge } from '@/components/ui/badge';
import { router } from '@inertiajs/react';
type SearchResult = {
id: number;
parent_id?: number;
label: string;
type: 'server' | 'project' | 'site';
};
export default function AppCommand() {
const [open, setOpen] = useState(false);
const [openServer, setOpenServer] = useState(false);
const [openProject, setOpenProject] = useState(false);
const [queryText, setQueryText] = useState('');
const [selected, setSelected] = useState<string>('create-server');
useEffect(() => {
const down = (e: KeyboardEvent) => {
@ -19,9 +35,47 @@ export default function AppCommand() {
return () => document.removeEventListener('keydown', down);
}, []);
const handleOpenChange = (open: boolean) => {
setOpen(open);
if (!open) {
setOpenServer(false);
setOpenProject(false);
}
};
const query = useQuery<SearchResult[]>({
queryKey: ['search'],
queryFn: async () => {
const response = await axios.get(route('search', { query: queryText }));
return response.data.data;
},
retry: false,
enabled: false,
refetchInterval: false,
refetchIntervalInBackground: false,
});
useEffect(() => {
if (query.data && query.data.length > 0) {
setSelected(`result-0`);
} else {
setSelected('create-server');
}
}, [query.data]);
useEffect(() => {
if (queryText !== '' && queryText.length >= 3) {
const timeoutId = setTimeout(() => {
query.refetch();
}, 300);
return () => clearTimeout(timeoutId);
}
}, [queryText]);
return (
<div>
<Button className="px-1!" variant="outline" size="sm" onClick={() => setOpen(true)}>
<Button className="hidden px-1! lg:flex" variant="outline" size="sm" onClick={() => setOpen(true)}>
<span className="sr-only">Open command menu</span>
<SearchIcon className="ml-1 size-3" />
Search...
@ -29,15 +83,54 @@ export default function AppCommand() {
<CommandIcon className="mr-1 size-3" /> K
</span>
</Button>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<Button className="lg:hidden" variant="outline" size="sm" onClick={() => setOpen(true)}>
<CommandIcon className="mr-1 size-3" /> K
</Button>
<CommandDialog open={open} onOpenChange={handleOpenChange} shouldFilter={false} value={selected}>
<CommandInput placeholder="Type a command or search..." onValueChange={setQueryText} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CreateServer>
<CommandItem>Create server</CommandItem>
{query.isFetching && <p className="text-muted-foreground p-4 text-sm">Searching...</p>}
{query.data && query.data?.length > 0 && (
<CommandGroup heading="Search Results">
{query.data.map((result, index) => (
<CommandItem
key={`search-result-${result.id}`}
className="flex items-center justify-between"
value={`result-${index}`}
onSelect={() => {
if (result.type === 'server') {
router.post(route('servers.switch', { server: result.id }));
} else if (result.type === 'project') {
router.patch(
route('projects.switch', {
project: result.id,
currentPath: window.location.pathname,
}),
);
} else if (result.type === 'site') {
router.post(route('sites.switch', { server: result.parent_id, site: result.id }));
}
setOpen(false);
}}
>
{result.label}
<Badge variant="outline">{result.type}</Badge>
</CommandItem>
))}
</CommandGroup>
)}
<CommandGroup heading="Commands">
<CreateServer defaultOpen={openServer} onOpenChange={setOpenServer}>
<CommandItem value="create-server" key="cmd-create-server" onSelect={() => setOpenServer(true)}>
Create server
</CommandItem>
</CreateServer>
<CommandItem>Create project</CommandItem>
<ProjectForm defaultOpen={openProject} onOpenChange={setOpenProject}>
<CommandItem value="create-project" key="cmd-create-project" onSelect={() => setOpenProject(true)}>
Create project
</CommandItem>
</ProjectForm>
</CommandGroup>
</CommandList>
</CommandDialog>

View File

@ -6,6 +6,7 @@ import AppCommand from '@/components/app-command';
import { SiteSwitch } from '@/components/site-switch';
import { usePage } from '@inertiajs/react';
import { SharedData } from '@/types';
import Refresh from '@/components/refresh';
export function AppHeader() {
const page = usePage<SharedData>();
@ -18,7 +19,7 @@ export function AppHeader() {
<ProjectSwitch />
<SlashIcon className="size-3" />
<ServerSwitch />
{page.props.server && (
{page.props.server && page.props.server.services['webserver'] && (
<>
<SlashIcon className="size-3" />
<SiteSwitch />
@ -26,7 +27,10 @@ export function AppHeader() {
)}
</div>
</div>
<AppCommand />
<div className="flex items-center gap-2">
<AppCommand />
<Refresh />
</div>
</header>
);
}

View File

@ -12,12 +12,13 @@ import {
SidebarMenuSub,
SidebarMenuSubItem,
} from '@/components/ui/sidebar';
import { type NavItem } from '@/types';
import { Link } from '@inertiajs/react';
import { BookOpen, ChevronRightIcon, CogIcon, Folder, MousePointerClickIcon, ServerIcon, ZapIcon } from 'lucide-react';
import { type NavItem, SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import { BookOpen, ChevronRightIcon, CogIcon, Folder, ListEndIcon, LogsIcon, MousePointerClickIcon, ServerIcon, ZapIcon } from 'lucide-react';
import AppLogo from './app-logo';
import { Icon } from '@/components/icon';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
const mainNavItems: NavItem[] = [
{
@ -43,6 +44,16 @@ const mainNavItems: NavItem[] = [
];
const footerNavItems: NavItem[] = [
{
title: 'Horizon Dashboard',
href: route('horizon.index'),
icon: ListEndIcon,
},
{
title: 'Vito Logs',
href: route('log-viewer.index'),
icon: LogsIcon,
},
{
title: 'Repository',
href: 'https://github.com/vitodeploy/vito',
@ -56,6 +67,8 @@ const footerNavItems: NavItem[] = [
];
export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems?: NavItem[]; secondNavTitle?: string }) {
const page = usePage<SharedData>();
return (
<Sidebar collapsible="icon" className="overflow-hidden [&>[data-sidebar=sidebar]]:flex-row">
{/* This is the first sidebar */}
@ -67,7 +80,12 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems?
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild className="md:h-8 md:p-0">
<Link href={route('servers')} prefetch>
<AppLogo />
<Tooltip>
<TooltipTrigger>
<AppLogo />
</TooltipTrigger>
<TooltipContent side="right">{page.props.version}</TooltipContent>
</Tooltip>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>

View File

@ -0,0 +1,70 @@
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { ChevronDownIcon, RefreshCwIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { router } from '@inertiajs/react';
export default function Refresh() {
const [poll, setPoll] = useState<{
stop: VoidFunction;
start: VoidFunction;
}>();
const [polling, setPolling] = useState(false);
const storedInterval = (localStorage.getItem('refresh_interval') as '5' | '10' | '30' | '60' | '0') || '10';
const [refreshInterval, setRefreshInterval] = useState<5 | 10 | 30 | 60 | 0>(
storedInterval === '0' ? 0 : (parseInt(storedInterval) as 5 | 10 | 30 | 60),
);
const intervalLabels = {
5: '5s',
10: '10s',
30: '30s',
60: '1m',
0: 'OFF',
};
const refresh = () => {
router.reload({
onStart: () => {
setPolling(true);
},
onFinish: () => {
setPolling(false);
},
});
};
useEffect(() => {
poll?.stop();
if (refreshInterval > 0) {
setPoll(router.poll(refreshInterval * 1000));
} else {
poll?.stop();
setPoll(undefined);
}
localStorage.setItem('refresh_interval', refreshInterval.toString());
}, [refreshInterval]);
return (
<div className="flex items-center">
<Button variant="outline" size="sm" className="md:rounded-r-none" onClick={refresh} disabled={polling}>
{polling ? <RefreshCwIcon className="animate-spin" /> : <RefreshCwIcon className="lg:hidden" />}
<span className="hidden md:block">Refresh</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="hidden rounded-l-none border-l-0 md:flex">
{intervalLabels[refreshInterval] || 'Unknown'}
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setRefreshInterval(5)}>5s</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setRefreshInterval(10)}>10s</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setRefreshInterval(30)}>30s</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setRefreshInterval(60)}>1m</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setRefreshInterval(0)}>OFF</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@ -30,6 +30,7 @@ export function SiteSwitch() {
if (storedSite && page.props.server_sites && !page.props.server_sites.find((site) => site.id === storedSite.id)) {
siteHelper.storeSite();
setSelectedSite(null);
}
const handleSiteChange = (site: Site) => {

View File

@ -18,11 +18,15 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
shouldFilter = true,
value,
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
shouldFilter?: boolean;
value?: string;
}) {
return (
<Dialog {...props}>
@ -31,7 +35,11 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
<Command
shouldFilter={shouldFilter}
value={value}
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
{children}
</Command>
</DialogContent>

View File

@ -1,7 +1,7 @@
import { AppSidebar } from '@/components/app-sidebar';
import { AppHeader } from '@/components/app-header';
import { type BreadcrumbItem, NavItem, SharedData } from '@/types';
import { type PropsWithChildren } from 'react';
import { type PropsWithChildren, useEffect } from 'react';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { usePage } from '@inertiajs/react';
import { Toaster } from '@/components/ui/sonner';
@ -20,38 +20,40 @@ export default function Layout({
}>) {
const page = usePage<SharedData>();
if (page.props.flash && page.props.flash.success) {
toast(
<div className="flex items-center gap-2">
<CheckCircle2Icon className="text-success size-5" />
{page.props.flash.success}
</div>,
);
}
if (page.props.flash && page.props.flash.error) {
toast(
<div className="flex items-center gap-2">
<CircleXIcon className="text-destructive size-5" />
{page.props.flash.error}
</div>,
);
}
if (page.props.flash && page.props.flash.warning) {
toast(
<div className="flex items-center gap-2">
<TriangleAlertIcon className="text-warning size-5" />
{page.props.flash.warning}
</div>,
);
}
if (page.props.flash && page.props.flash.info) {
toast(
<div className="flex items-center gap-2">
<InfoIcon className="text-info size-5" />
{page.props.flash.info}
</div>,
);
}
useEffect(() => {
if (page.props.flash && page.props.flash.success) {
toast(
<div className="flex items-center gap-2">
<CheckCircle2Icon className="text-success size-5" />
{page.props.flash.success}
</div>,
);
}
if (page.props.flash && page.props.flash.error) {
toast(
<div className="flex items-center gap-2">
<CircleXIcon className="text-destructive size-5" />
{page.props.flash.error}
</div>,
);
}
if (page.props.flash && page.props.flash.warning) {
toast(
<div className="flex items-center gap-2">
<TriangleAlertIcon className="text-warning size-5" />
{page.props.flash.warning}
</div>,
);
}
if (page.props.flash && page.props.flash.info) {
toast(
<div className="flex items-center gap-2">
<InfoIcon className="text-info size-5" />
{page.props.flash.info}
</div>,
);
}
}, [page.props.flash]);
const queryClient = new QueryClient();

View File

@ -1,6 +1,7 @@
import AppLogoIcon from '@/components/app-logo-icon';
import { Link } from '@inertiajs/react';
import { Link, usePage } from '@inertiajs/react';
import { type PropsWithChildren } from 'react';
import { SharedData } from '@/types';
interface AuthLayoutProps {
name?: string;
@ -9,6 +10,7 @@ interface AuthLayoutProps {
}
export default function AuthLayout({ children, title, description }: PropsWithChildren<AuthLayoutProps>) {
const page = usePage<SharedData>();
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
@ -27,6 +29,16 @@ export default function AuthLayout({ children, title, description }: PropsWithCh
</div>
</div>
{children}
<div className="text-muted-foreground/50 text-center text-xs">
VitoDeploy{' '}
<a
href={`https://github.com/vitodeploy/vito/releases/tag/${page.props.version}`}
className="hover:text-primary cursor-pointer"
target="_blank"
>
{page.props.version}
</a>
</div>
</div>
</div>
</div>

View File

@ -26,15 +26,13 @@ import { ReactNode } from 'react';
import { Server } from '@/types/server';
import ServerHeader from '@/pages/servers/components/header';
import Layout from '@/layouts/app/layout';
import { usePage, usePoll } from '@inertiajs/react';
import { usePage } from '@inertiajs/react';
import { Site } from '@/types/site';
import PHPIcon from '@/icons/php';
import NodeIcon from '@/icons/node';
import siteHelper from '@/lib/site-helper';
export default function ServerLayout({ children }: { children: ReactNode }) {
usePoll(7000);
const page = usePage<{
server: Server;
site?: Site;

View File

@ -1,18 +1,5 @@
import { type BreadcrumbItem, type NavItem } from '@/types';
import {
BellIcon,
CloudIcon,
CodeIcon,
CommandIcon,
DatabaseIcon,
KeyIcon,
ListIcon,
LogsIcon,
PlugIcon,
TagIcon,
UserIcon,
UsersIcon,
} from 'lucide-react';
import { BellIcon, CloudIcon, CodeIcon, CommandIcon, DatabaseIcon, KeyIcon, ListIcon, PlugIcon, TagIcon, UserIcon, UsersIcon } from 'lucide-react';
import { ReactNode } from 'react';
import Layout from '@/layouts/app/layout';
import VitoIcon from '@/icons/vito';
@ -78,12 +65,6 @@ const sidebarNavItems: NavItem[] = [
href: route('vito-settings'),
icon: VitoIcon,
},
{
title: 'Vito Logs',
href: route('log-viewer.index'),
icon: LogsIcon,
external: true,
},
];
export default function SettingsLayout({ children, breadcrumbs }: { children: ReactNode; breadcrumbs?: BreadcrumbItem[] }) {

View File

@ -0,0 +1,16 @@
type Callback = (data: unknown) => void;
const events: Record<string, Callback[]> = {};
export const EventBus = {
on(event: string, callback: Callback) {
if (!events[event]) events[event] = [];
events[event].push(callback);
},
off(event: string, callback: Callback) {
events[event] = events[event]?.filter((cb) => cb !== callback) || [];
},
emit(event: string, data?: unknown) {
events[event]?.forEach((cb) => cb(data));
},
};

View File

@ -11,14 +11,17 @@ import { LoaderCircleIcon } from 'lucide-react';
import { registerDotEnvLanguage } from '@/lib/editor';
import { Site } from '@/types/site';
import { useAppearance } from '@/hooks/use-appearance';
import { Input } from '@/components/ui/input';
export default function Env({ site, children }: { site: Site; children: ReactNode }) {
const { getActualAppearance } = useAppearance();
const [open, setOpen] = useState(false);
const form = useForm<{
env: string;
path: string;
}>({
env: '',
path: site.type_data.env_path || `${site.path}/.env`,
});
const submit = (e: FormEvent) => {
@ -55,7 +58,15 @@ export default function Env({ site, children }: { site: Site; children: ReactNod
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent className="sm:max-w-5xl">
<SheetHeader>
<SheetTitle>Edit .env</SheetTitle>
<SheetTitle>
<Input
name="path"
value={form.data.path}
onChange={(e) => form.setData('path', e.target.value)}
autoFocus={false}
className="max-w-[80%]"
/>
</SheetTitle>
<SheetDescription className="sr-only">Edit .env file</SheetDescription>
</SheetHeader>
<Form id="update-env-form" className="h-full" onSubmit={submit}>

View File

@ -1,4 +1,4 @@
import { Head, useForm } from '@inertiajs/react';
import { Head, useForm, usePage } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
@ -9,6 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AuthLayout from '@/layouts/auth/layout';
import { SharedData } from '@/types';
type LoginForm = {
email: string;
@ -22,9 +23,11 @@ interface LoginProps {
}
export default function Login({ status, canResetPassword }: LoginProps) {
const page = usePage<SharedData>();
const { data, setData, post, processing, errors, reset } = useForm<Required<LoginForm>>({
email: '',
password: '',
email: page.props.demo ? 'demo@vitodeploy.com' : '',
password: page.props.demo ? 'password' : '',
remember: false,
});

View File

@ -0,0 +1,76 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import InputError from '@/components/ui/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AuthLayout from '@/layouts/auth/layout';
import { Form, FormField, FormFields } from '@/components/ui/form';
export default function TwoFactor() {
const form = useForm<Required<{ code: string; recovery_code: string }>>({
code: '',
recovery_code: '',
});
const submit: FormEventHandler = (e) => {
e.preventDefault();
form.post(route('two-factor.store'), {
onFinish: () => form.reset(),
});
};
return (
<AuthLayout title="Two factor challenge" description="Please enter the two-factor authentication code to continue.">
<Head title="Confirm password" />
<Form onSubmit={submit}>
<FormFields>
<FormField>
<Label htmlFor="code">Code</Label>
<Input
id="code"
type="text"
name="code"
placeholder="Two factor code"
value={form.data.code}
autoFocus
onChange={(e) => form.setData('code', e.target.value)}
/>
<InputError message={form.errors.code} />
</FormField>
<FormField>
<Label htmlFor="recovery_code">Recovery Code</Label>
<Input
id="recovery_code"
type="text"
name="recovery_code"
placeholder="Or enter your recovery code"
value={form.data.recovery_code}
onChange={(e) => form.setData('recovery_code', e.target.value)}
/>
<InputError message={form.errors.recovery_code} />
</FormField>
<div className="space-y-2">
<Button className="w-full" disabled={form.processing}>
{form.processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
Confirm
</Button>
<Button variant="ghost" asChild>
<Link className="block w-full" method="post" href={route('logout')}>
Back to login
</Link>
</Button>
</div>
</FormFields>
</Form>
</AuthLayout>
);
}

View File

@ -0,0 +1,139 @@
import { useForm, usePage } from '@inertiajs/react';
import type { SharedData } from '@/types';
import { FormEventHandler, ReactNode, useState } from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { CheckCircle2Icon, LoaderCircleIcon, XCircleIcon } from 'lucide-react';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { FormField, FormFields } from '@/components/ui/form';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
function Disable(): ReactNode {
const [open, setOpen] = useState(false);
const form = useForm();
const submit = () => {
form.post(route('profile.disable-two-factor'), {
preserveScroll: true,
onSuccess: () => setOpen(false),
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive">Disable Two Factor</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Disable two factor</DialogTitle>
<DialogDescription className="sr-only">Disable two factor</DialogDescription>
</DialogHeader>
<p className="p-4">Are you sure you want to enable two factor authentication?</p>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={submit} variant="destructive" disabled={form.processing}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Disable
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Enable() {
const form = useForm();
const submit: FormEventHandler = (e) => {
e.preventDefault();
form.post(route('profile.enable-two-factor'));
};
return (
<Button onClick={submit} disabled={form.processing}>
{form.processing && <LoaderCircleIcon className="animate-spin" />}
Enable Two Factor
</Button>
);
}
export default function TwoFactor() {
const page = usePage<
SharedData & {
flash: {
data?: {
qr_code?: string;
qr_code_url?: string;
recovery_codes?: string[];
};
};
}
>();
return (
<Card>
<CardHeader>
<CardTitle>Two factor authentication</CardTitle>
<CardDescription>Enable or Disable two factor authentication</CardDescription>
</CardHeader>
<CardContent className="space-y-2 p-4">
{page.props.flash.data?.qr_code && (
<FormFields>
<FormField>
<Label htmlFor="qr-code">Scan this QR code with your authenticator app</Label>
<div className="flex max-h-[400px] items-center">
<div dangerouslySetInnerHTML={{ __html: page.props.flash.data.qr_code }}></div>
</div>
</FormField>
<FormField>
<Label htmlFor="qr-code-url">QR Code URL</Label>
<Input id="qr-code-url" value={page.props.flash.data.qr_code_url} disabled />
</FormField>
<FormField>
<Label htmlFor="recovery-codes">Recovery Codes</Label>
<Textarea id="recovery-codes" value={page.props.flash.data.recovery_codes?.join('\n') || ''} disabled rows={5} />
</FormField>
</FormFields>
)}
{page.props.auth.user.two_factor_enabled ? (
<Alert>
<AlertDescription>
<div className="flex items-center gap-2">
<CheckCircle2Icon className="text-success size-4" />
<p>Two factor authentication is enabled</p>
</div>
</AlertDescription>
</Alert>
) : (
<Alert>
<AlertDescription>
<div className="flex items-center gap-2">
<XCircleIcon className="text-danger size-4" />
Two factor authentication is <strong>not</strong> enabled
</div>
</AlertDescription>
</Alert>
)}
</CardContent>
<CardFooter className="gap-2">
{!page.props.auth.user.two_factor_enabled && <Enable />}
{page.props.auth.user.two_factor_enabled && <Disable />}
</CardFooter>
</Card>
);
}

View File

@ -4,15 +4,34 @@ import Container from '@/components/container';
import UpdatePassword from '@/pages/profile/components/update-password';
import UpdateProfile from '@/pages/profile/components/update-profile';
import Heading from '@/components/heading';
import TwoFactor from '@/pages/profile/components/two-factor';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useState } from 'react';
export default function Profile() {
const [tab, setTab] = useState('info');
return (
<SettingsLayout>
<Head title="Profile settings" />
<Container className="max-w-5xl">
<Heading title="Profile settings" description="Manage your profile settings." />
<UpdateProfile />
<UpdatePassword />
<Tabs defaultValue={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="two_factor">Two Factor</TabsTrigger>
</TabsList>
<TabsContent value="info">
<UpdateProfile />
</TabsContent>
<TabsContent value="password">
<UpdatePassword />
</TabsContent>
<TabsContent value="two_factor">
<TwoFactor />
</TabsContent>
</Tabs>
</Container>
</SettingsLayout>
);

View File

@ -8,7 +8,7 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { FormEventHandler, ReactNode, useState } from 'react';
import { FormEventHandler, ReactNode, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { LoaderCircle } from 'lucide-react';
import { useForm } from '@inertiajs/react';
@ -19,8 +19,30 @@ import InputError from '@/components/ui/input-error';
import { Project } from '@/types/project';
import FormSuccessful from '@/components/form-successful';
export default function ProjectForm({ project, children }: { project?: Project; children: ReactNode }) {
const [open, setOpen] = useState(false);
export default function ProjectForm({
project,
defaultOpen,
onOpenChange,
children,
}: {
project?: Project;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen || false);
useEffect(() => {
if (defaultOpen) {
setOpen(defaultOpen);
}
}, [setOpen, defaultOpen]);
const handleOpenChange = (open: boolean) => {
setOpen(open);
if (onOpenChange) {
onOpenChange(open);
}
};
const form = useForm({
name: project?.name || '',
@ -42,7 +64,7 @@ export default function ProjectForm({ project, children }: { project?: Project;
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>

View File

@ -2,7 +2,7 @@ import { ClipboardCheckIcon, ClipboardIcon, LoaderCircle, TriangleAlert, WifiIco
import { Button } from '@/components/ui/button';
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
import { useForm, usePage } from '@inertiajs/react';
import React, { FormEventHandler, useState } from 'react';
import React, { FormEventHandler, useEffect, useState } from 'react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import InputError from '@/components/ui/input-error';
@ -25,9 +25,31 @@ type CreateServerForm = {
plan: string;
};
export default function CreateServer({ children }: { children: React.ReactNode }) {
export default function CreateServer({
defaultOpen,
onOpenChange,
children,
}: {
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}) {
const page = usePage<SharedData>();
const [open, setOpen] = useState(defaultOpen || false);
useEffect(() => {
if (defaultOpen) {
setOpen(defaultOpen);
}
}, [defaultOpen]);
const handleOpenChange = (open: boolean) => {
setOpen(open);
if (onOpenChange) {
onOpenChange(open);
}
};
const form = useForm<Required<CreateServerForm>>({
provider: 'custom',
server_provider: 0,
@ -97,7 +119,7 @@ export default function CreateServer({ children }: { children: React.ReactNode }
};
return (
<Sheet>
<Sheet open={open} onOpenChange={handleOpenChange} modal>
<SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent className="w-full lg:max-w-4xl">
<SheetHeader>

View File

@ -5,7 +5,7 @@ import HeaderContainer from '@/components/header-container';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import ServerLayout from '@/layouts/server/layout';
import { BookOpenIcon, MoreVerticalIcon } from 'lucide-react';
import { MoreVerticalIcon } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardRow, CardTitle } from '@/components/ui/card';
import React from 'react';
import { Site, SiteFeature } from '@/types/site';
@ -29,14 +29,6 @@ export default function SiteFeatures() {
<Container className="max-w-5xl">
<HeaderContainer>
<Heading title="Features" description="Your site has some features enabled by Vito or other plugins" />
<div className="flex items-center gap-2">
<a href="https://vitodeploy.com/docs/sites/features" target="_blank">
<Button variant="outline">
<BookOpenIcon />
<span className="hidden lg:block">Docs</span>
</Button>
</a>
</div>
</HeaderContainer>
<Card>

View File

@ -104,6 +104,8 @@ export interface Configs {
export interface SharedData {
name: string;
version: string;
demo: boolean;
quote: { message: string; author: string };
auth: Auth;
ziggy: Config & { location: string };

View File

@ -9,6 +9,7 @@ export interface Site {
type: string;
type_data: {
method?: 'round-robin' | 'least-connections' | 'ip-hash';
env_path?: string;
[key: string]: unknown;
};
domain: string;

View File

@ -10,6 +10,7 @@ export interface User {
updated_at: string;
timezone: string;
projects?: Project[];
two_factor_enabled: boolean;
role: string;
[key: string]: unknown; // This allows for additional properties...
}

View File

@ -0,0 +1,11 @@
sudo sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo DEBIAN_FRONTEND=noninteractive apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install postgresql-17 -y
systemctl status postgresql
sudo -u postgres psql -c "SELECT version();"

View File

@ -209,7 +209,7 @@ chown -R vito:vito /home/vito
export V_WORKER_CONFIG="
[program:worker]
process_name=%(program_name)s_%(process_num)02d
command=php /home/vito/vito/artisan queue:work --sleep=3 --backoff=0 --queue=default,ssh,ssh-long --timeout=3600 --tries=1
command=php /home/vito/vito/artisan horizon
autostart=1
autorestart=1
user=vito

View File

@ -19,6 +19,7 @@ sudo service php8.4-fpm restart
sudo sed -i "s/memory_limit = .*/memory_limit = 1G/" /etc/php/8.4/fpm/php.ini
sudo sed -i "s/upload_max_filesize = .*/upload_max_filesize = 1G/" /etc/php/8.4/fpm/php.ini
sudo sed -i "s/post_max_size = .*/post_max_size = 1G/" /etc/php/8.4/fpm/php.ini
sudo sed -i '/location ~ \\\.php\$ {/a \ fastcgi_buffers 16 16k;\n fastcgi_buffer_size 32k;' /etc/nginx/sites-available/vito
echo "Installing Redis"
sudo apt install redis-server -y
@ -34,6 +35,10 @@ sudo sed -i "s/php8.2-fpm.sock/php8.4-fpm.sock/g" /etc/nginx/sites-available/vit
sudo sed -i '/server\s*{.*/a \ client_max_body_size 100M;' /etc/nginx/sites-available/vito
sudo service nginx restart
echo "Update supervisor configuration"
sudo sed -i 's/command=php \/home\/vito\/vito\/artisan queue:work --sleep=3 --backoff=0 --queue=default,ssh,ssh-long --timeout=3600 --tries=1/command=php \/home\/vito\/vito\/artisan horizon/' /etc/supervisor/conf.d/worker.conf
sudo service supervisor restart
echo "Fetching the latest release"
git fetch
git checkout 3.x

View File

@ -381,6 +381,7 @@ public function test_update_env(): void
'site' => $site,
]), [
'env' => $envContent,
'path' => '/home/vito/some-path/.env',
])
->assertSuccessful()
->assertJsonFragment([

View File

@ -147,8 +147,13 @@ public function test_update_env_file(): void
'site' => $this->site,
]), [
'env' => 'APP_ENV="production"',
'path' => '/home/vito/some-path/.env',
])
->assertSessionDoesntHaveErrors();
$this->site->refresh();
$this->assertEquals('/home/vito/some-path/.env', data_get($this->site->type_data, 'env_path'));
}
/**