global storage-providers, notification channels and server providers (#247)

This commit is contained in:
Saeed Vaziry 2024-06-29 12:52:03 +03:30 committed by GitHub
parent ad027eb033
commit 11e3b167cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 678 additions and 77 deletions

View File

@ -19,6 +19,7 @@ public function add(User $user, array $input): void
'user_id' => $user->id, 'user_id' => $user->id,
'provider' => $input['provider'], 'provider' => $input['provider'],
'label' => $input['label'], 'label' => $input['label'],
'project_id' => isset($input['global']) && $input['global'] ? null : $user->current_project_id,
]); ]);
$this->validateType($channel, $input); $this->validateType($channel, $input);
$channel->data = $channel->provider()->createData($input); $channel->data = $channel->provider()->createData($input);

View File

@ -0,0 +1,34 @@
<?php
namespace App\Actions\NotificationChannels;
use App\Models\NotificationChannel;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class EditChannel
{
public function edit(NotificationChannel $notificationChannel, User $user, array $input): void
{
$this->validate($input);
$notificationChannel->label = $input['label'];
$notificationChannel->project_id = isset($input['global']) && $input['global'] ? null : $user->current_project_id;
$notificationChannel->save();
}
/**
* @throws ValidationException
*/
private function validate(array $input): void
{
$rules = [
'label' => [
'required',
],
];
Validator::make($input, $rules)->validate();
}
}

View File

@ -38,6 +38,7 @@ public function create(User $user, array $input): ServerProvider
$serverProvider->profile = $input['name']; $serverProvider->profile = $input['name'];
$serverProvider->provider = $input['provider']; $serverProvider->provider = $input['provider'];
$serverProvider->credentials = $provider->credentialData($input); $serverProvider->credentials = $provider->credentialData($input);
$serverProvider->project_id = isset($input['global']) && $input['global'] ? null : $user->current_project_id;
$serverProvider->save(); $serverProvider->save();
return $serverProvider; return $serverProvider;

View File

@ -0,0 +1,34 @@
<?php
namespace App\Actions\ServerProvider;
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
{
$rules = [
'name' => [
'required',
],
];
Validator::make($input, $rules)->validate();
}
}

View File

@ -14,7 +14,7 @@ public function edit(SourceControl $sourceControl, User $user, array $input): vo
$this->validate($input); $this->validate($input);
$sourceControl->profile = $input['name']; $sourceControl->profile = $input['name'];
$sourceControl->url = isset($input['url']) ? $input['url'] : null; $sourceControl->url = $input['url'] ?? null;
$sourceControl->project_id = isset($input['global']) && $input['global'] ? null : $user->current_project_id; $sourceControl->project_id = isset($input['global']) && $input['global'] ? null : $user->current_project_id;
$this->validateProvider($sourceControl, $input); $this->validateProvider($sourceControl, $input);

View File

@ -21,6 +21,7 @@ public function create(User $user, array $input): void
'user_id' => $user->id, 'user_id' => $user->id,
'provider' => $input['provider'], 'provider' => $input['provider'],
'profile' => $input['name'], 'profile' => $input['name'],
'project_id' => isset($input['global']) && $input['global'] ? null : $user->current_project_id,
]); ]);
$this->validateProvider($input, $storageProvider->provider()->validationRules()); $this->validateProvider($input, $storageProvider->provider()->validationRules());

View File

@ -0,0 +1,34 @@
<?php
namespace App\Actions\StorageProvider;
use App\Models\StorageProvider;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class EditStorageProvider
{
public function edit(StorageProvider $storageProvider, User $user, array $input): void
{
$this->validate($input);
$storageProvider->profile = $input['name'];
$storageProvider->project_id = isset($input['global']) && $input['global'] ? null : $user->current_project_id;
$storageProvider->save();
}
/**
* @throws ValidationException
*/
private function validate(array $input): void
{
$rules = [
'name' => [
'required',
],
];
Validator::make($input, $rules)->validate();
}
}

View File

@ -29,7 +29,7 @@ public function store(Server $server, Request $request): HtmxResponse
{ {
$this->authorize('manage', $server); $this->authorize('manage', $server);
/** @var \App\Models\SshKey $key */ /** @var SshKey $key */
$key = app(CreateSshKey::class)->create( $key = app(CreateSshKey::class)->create(
$request->user(), $request->user(),
$request->input() $request->input()

View File

@ -35,7 +35,9 @@ public function create(Request $request): View
$this->authorize('create', [Server::class, $user->currentProject]); $this->authorize('create', [Server::class, $user->currentProject]);
$provider = $request->query('provider', old('provider', \App\Enums\ServerProvider::CUSTOM)); $provider = $request->query('provider', old('provider', \App\Enums\ServerProvider::CUSTOM));
$serverProviders = ServerProvider::query()->where('provider', $provider)->get(); $serverProviders = ServerProvider::getByProjectId(auth()->user()->current_project_id)
->where('provider', $provider)
->get();
return view('servers.create', [ return view('servers.create', [
'serverProviders' => $serverProviders, 'serverProviders' => $serverProviders,

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Settings; namespace App\Http\Controllers\Settings;
use App\Actions\NotificationChannels\AddChannel; use App\Actions\NotificationChannels\AddChannel;
use App\Actions\NotificationChannels\EditChannel;
use App\Facades\Toast; use App\Facades\Toast;
use App\Helpers\HtmxResponse; use App\Helpers\HtmxResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@ -13,11 +14,17 @@
class NotificationChannelController extends Controller class NotificationChannelController extends Controller
{ {
public function index(): View public function index(Request $request): View
{ {
return view('settings.notification-channels.index', [ $data = [
'channels' => NotificationChannel::query()->latest()->get(), 'channels' => NotificationChannel::getByProjectId(auth()->user()->current_project_id)->get(),
]); ];
if ($request->has('edit')) {
$data['editChannel'] = NotificationChannel::find($request->input('edit'));
}
return view('settings.notification-channels.index', $data);
} }
public function add(Request $request): HtmxResponse public function add(Request $request): HtmxResponse
@ -32,6 +39,19 @@ public function add(Request $request): HtmxResponse
return htmx()->redirect(route('settings.notification-channels')); return htmx()->redirect(route('settings.notification-channels'));
} }
public function update(NotificationChannel $notificationChannel, Request $request): HtmxResponse
{
app(EditChannel::class)->edit(
$notificationChannel,
$request->user(),
$request->input(),
);
Toast::success('Channel updated.');
return htmx()->redirect(route('settings.notification-channels'));
}
public function delete(int $id): RedirectResponse public function delete(int $id): RedirectResponse
{ {
$channel = NotificationChannel::query()->findOrFail($id); $channel = NotificationChannel::query()->findOrFail($id);

View File

@ -68,7 +68,7 @@ public function delete(Project $project): RedirectResponse
return back(); return back();
} }
public function switch($projectId): RedirectResponse public function switch(Request $request, $projectId): RedirectResponse
{ {
/** @var User $user */ /** @var User $user */
$user = auth()->user(); $user = auth()->user();
@ -81,6 +81,11 @@ public function switch($projectId): RedirectResponse
$user->current_project_id = $project->id; $user->current_project_id = $project->id;
$user->save(); $user->save();
// check if the referer is settings/*
if (str_contains($request->headers->get('referer'), 'settings')) {
return redirect()->to($request->headers->get('referer'));
}
return redirect()->route('servers'); return redirect()->route('servers');
} }
} }

View File

@ -4,6 +4,7 @@
use App\Actions\ServerProvider\CreateServerProvider; use App\Actions\ServerProvider\CreateServerProvider;
use App\Actions\ServerProvider\DeleteServerProvider; use App\Actions\ServerProvider\DeleteServerProvider;
use App\Actions\ServerProvider\EditServerProvider;
use App\Facades\Toast; use App\Facades\Toast;
use App\Helpers\HtmxResponse; use App\Helpers\HtmxResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@ -14,11 +15,17 @@
class ServerProviderController extends Controller class ServerProviderController extends Controller
{ {
public function index(): View public function index(Request $request): View
{ {
return view('settings.server-providers.index', [ $data = [
'providers' => auth()->user()->serverProviders, 'providers' => ServerProvider::getByProjectId(auth()->user()->current_project_id)->get(),
]); ];
if ($request->has('edit')) {
$data['editProvider'] = ServerProvider::find($request->input('edit'));
}
return view('settings.server-providers.index', $data);
} }
public function connect(Request $request): HtmxResponse public function connect(Request $request): HtmxResponse
@ -33,6 +40,19 @@ public function connect(Request $request): HtmxResponse
return htmx()->redirect(route('settings.server-providers')); return htmx()->redirect(route('settings.server-providers'));
} }
public function update(ServerProvider $serverProvider, Request $request): HtmxResponse
{
app(EditServerProvider::class)->edit(
$serverProvider,
$request->user(),
$request->input(),
);
Toast::success('Provider updated.');
return htmx()->redirect(route('settings.server-providers'));
}
public function delete(ServerProvider $serverProvider): RedirectResponse public function delete(ServerProvider $serverProvider): RedirectResponse
{ {
try { try {

View File

@ -18,7 +18,7 @@ class SourceControlController extends Controller
public function index(Request $request): View public function index(Request $request): View
{ {
$data = [ $data = [
'sourceControls' => SourceControl::getByCurrentProject(), 'sourceControls' => SourceControl::getByProjectId(auth()->user()->current_project_id)->get(),
]; ];
if ($request->has('edit')) { if ($request->has('edit')) {

View File

@ -4,6 +4,7 @@
use App\Actions\StorageProvider\CreateStorageProvider; use App\Actions\StorageProvider\CreateStorageProvider;
use App\Actions\StorageProvider\DeleteStorageProvider; use App\Actions\StorageProvider\DeleteStorageProvider;
use App\Actions\StorageProvider\EditStorageProvider;
use App\Facades\Toast; use App\Facades\Toast;
use App\Helpers\HtmxResponse; use App\Helpers\HtmxResponse;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@ -14,11 +15,17 @@
class StorageProviderController extends Controller class StorageProviderController extends Controller
{ {
public function index(): View public function index(Request $request): View
{ {
return view('settings.storage-providers.index', [ $data = [
'providers' => auth()->user()->storageProviders, 'providers' => StorageProvider::getByProjectId(auth()->user()->current_project_id)->get(),
]); ];
if ($request->has('edit')) {
$data['editProvider'] = StorageProvider::find($request->input('edit'));
}
return view('settings.storage-providers.index', $data);
} }
public function connect(Request $request): HtmxResponse public function connect(Request $request): HtmxResponse
@ -33,6 +40,19 @@ public function connect(Request $request): HtmxResponse
return htmx()->redirect(route('settings.storage-providers')); return htmx()->redirect(route('settings.storage-providers'));
} }
public function update(StorageProvider $storageProvider, Request $request): HtmxResponse
{
app(EditStorageProvider::class)->edit(
$storageProvider,
$request->user(),
$request->input(),
);
Toast::success('Provider updated.');
return htmx()->redirect(route('settings.storage-providers'));
}
public function delete(StorageProvider $storageProvider): RedirectResponse public function delete(StorageProvider $storageProvider): RedirectResponse
{ {
try { try {

View File

@ -3,7 +3,9 @@
namespace App\Models; namespace App\Models;
use App\Notifications\NotificationInterface; use App\Notifications\NotificationInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
/** /**
@ -12,6 +14,7 @@
* @property array data * @property array data
* @property string label * @property string label
* @property bool connected * @property bool connected
* @property int $project_id
*/ */
class NotificationChannel extends AbstractModel class NotificationChannel extends AbstractModel
{ {
@ -24,6 +27,7 @@ class NotificationChannel extends AbstractModel
'data', 'data',
'connected', 'connected',
'is_default', 'is_default',
'project_id',
]; ];
protected $casts = [ protected $casts = [
@ -47,4 +51,16 @@ public static function notifyAll(NotificationInterface $notification): void
$channel->notify($notification); $channel->notify($notification);
} }
} }
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public static function getByProjectId(int $projectId): Builder
{
return self::query()
->where('project_id', $projectId)
->orWhereNull('project_id');
}
} }

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@ -13,6 +14,7 @@
* @property array $credentials * @property array $credentials
* @property bool $connected * @property bool $connected
* @property User $user * @property User $user
* @property ?int $project_id
*/ */
class ServerProvider extends AbstractModel class ServerProvider extends AbstractModel
{ {
@ -24,12 +26,14 @@ class ServerProvider extends AbstractModel
'provider', 'provider',
'credentials', 'credentials',
'connected', 'connected',
'project_id',
]; ];
protected $casts = [ protected $casts = [
'user_id' => 'integer', 'user_id' => 'integer',
'credentials' => 'encrypted:array', 'credentials' => 'encrypted:array',
'connected' => 'boolean', 'connected' => 'boolean',
'project_id' => 'integer',
]; ];
public function user(): BelongsTo public function user(): BelongsTo
@ -46,4 +50,16 @@ public function servers(): HasMany
{ {
return $this->hasMany(Server::class, 'provider_id'); return $this->hasMany(Server::class, 'provider_id');
} }
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public static function getByProjectId(int $projectId): Builder
{
return self::query()
->where('project_id', $projectId)
->orWhereNull('project_id');
}
} }

View File

@ -3,7 +3,7 @@
namespace App\Models; namespace App\Models;
use App\SourceControlProviders\SourceControlProvider; use App\SourceControlProviders\SourceControlProvider;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@ -57,10 +57,10 @@ public function project(): BelongsTo
return $this->belongsTo(Project::class); return $this->belongsTo(Project::class);
} }
public static function getByCurrentProject(): Collection public static function getByProjectId(int $projectId): Builder
{ {
return self::query() return self::query()
->where('project_id', auth()->user()->current_project_id) ->where('project_id', $projectId)
->orWhereNull('project_id')->get(); ->orWhereNull('project_id');
} }
} }

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@ -12,6 +13,7 @@
* @property string $provider * @property string $provider
* @property array $credentials * @property array $credentials
* @property User $user * @property User $user
* @property int $project_id
*/ */
class StorageProvider extends AbstractModel class StorageProvider extends AbstractModel
{ {
@ -22,11 +24,13 @@ class StorageProvider extends AbstractModel
'profile', 'profile',
'provider', 'provider',
'credentials', 'credentials',
'project_id',
]; ];
protected $casts = [ protected $casts = [
'user_id' => 'integer', 'user_id' => 'integer',
'credentials' => 'encrypted:array', 'credentials' => 'encrypted:array',
'project_id' => 'integer',
]; ];
public function user(): BelongsTo public function user(): BelongsTo
@ -45,4 +49,16 @@ public function backups(): HasMany
{ {
return $this->hasMany(Backup::class, 'storage_id'); return $this->hasMany(Backup::class, 'storage_id');
} }
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public static function getByProjectId(int $projectId): Builder
{
return self::query()
->where('project_id', $projectId)
->orWhereNull('project_id');
}
} }

View File

@ -106,24 +106,6 @@ public function storageProvider(string $provider): HasOne
return $this->hasOne(StorageProvider::class)->where('provider', $provider); return $this->hasOne(StorageProvider::class)->where('provider', $provider);
} }
public function connectedStorageProviders(): HasMany
{
return $this->storageProviders()->where('connected', true);
}
public function connectedSourceControls(): array
{
$connectedSourceControls = [];
$sourceControls = $this->sourceControls()
->where('connected', 1)
->get(['provider']);
foreach ($sourceControls as $sourceControl) {
$connectedSourceControls[] = $sourceControl->provider;
}
return $connectedSourceControls;
}
public function projects(): BelongsToMany public function projects(): BelongsToMany
{ {
return $this->belongsToMany(Project::class, 'user_project')->withTimestamps(); return $this->belongsToMany(Project::class, 'user_project')->withTimestamps();
@ -134,11 +116,6 @@ public function currentProject(): HasOne
return $this->HasOne(Project::class, 'id', 'current_project_id'); return $this->HasOne(Project::class, 'id', 'current_project_id');
} }
public function isMemberOfProject(Project $project): bool
{
return $project->user_id === $this->id;
}
public function createDefaultProject(): Project public function createDefaultProject(): Project
{ {
$project = $this->projects()->first(); $project = $this->projects()->first();

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('notification_channels', function (Blueprint $table) {
$table->unsignedBigInteger('project_id')->nullable();
});
}
public function down(): void
{
Schema::table('notification_channels', function (Blueprint $table) {
$table->dropColumn('project_id');
});
}
};

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('storage_providers', function (Blueprint $table) {
$table->unsignedBigInteger('project_id')->nullable();
});
}
public function down(): void
{
Schema::table('storage_providers', function (Blueprint $table) {
$table->dropColumn('project_id');
});
}
};

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('server_providers', function (Blueprint $table) {
$table->unsignedBigInteger('project_id')->nullable();
});
}
public function down(): void
{
Schema::table('server_providers', function (Blueprint $table) {
$table->dropColumn('project_id');
});
}
};

View File

@ -16,7 +16,7 @@ class="p-6"
<div class="mt-6"> <div class="mt-6">
<x-input-label for="script" :value="__('Script')" /> <x-input-label for="script" :value="__('Script')" />
<x-textarea id="script" name="script" class="mt-1 min-h-[400px] w-full"> <x-textarea id="script" name="script" class="mt-1 min-h-[400px] w-full font-mono">
{{ old("script", $site->deploymentScript?->content) }} {{ old("script", $site->deploymentScript?->content) }}
</x-textarea> </x-textarea>
@error("script") @error("script")

View File

@ -21,7 +21,7 @@ class="mt-6"
> >
<x-input-label for="env" :value="__('.env')" /> <x-input-label for="env" :value="__('.env')" />
<div id="env-content"> <div id="env-content">
<x-textarea id="env" name="env" rows="10" class="mt-1 block min-h-[400px] w-full"> <x-textarea id="env" name="env" rows="10" class="mt-1 block min-h-[400px] w-full font-mono">
{{ old("env", session()->get("env") ?? "Loading...") }} {{ old("env", session()->get("env") ?? "Loading...") }}
</x-textarea> </x-textarea>
</div> </div>

View File

@ -35,7 +35,7 @@ class="p-6"
<div class="mt-1 flex items-center"> <div class="mt-1 flex items-center">
<x-select-input id="backup_storage" name="backup_storage" class="mt-1 w-full"> <x-select-input id="backup_storage" name="backup_storage" class="mt-1 w-full">
<option value="" selected disabled>{{ __("Select") }}</option> <option value="" selected disabled>{{ __("Select") }}</option>
@foreach (auth()->user()->storageProviders as $st) @foreach (\App\Models\StorageProvider::getByProjectId(auth()->user()->current_project_id)->get() as $st)
<option value="{{ $st->id }}" @if(old('backup_storage') == $st->id) selected @endif> <option value="{{ $st->id }}" @if(old('backup_storage') == $st->id) selected @endif>
{{ $st->profile }} - {{ $st->provider }} {{ $st->profile }} - {{ $st->provider }}
</option> </option>

View File

@ -14,7 +14,7 @@ class="p-6"
<div class="mt-6"> <div class="mt-6">
<x-input-label for="ini" value="php.ini" /> <x-input-label for="ini" value="php.ini" />
<x-textarea id="ini" name="ini" class="mt-1 w-full" rows="15"> <x-textarea id="ini" name="ini" class="mt-1 w-full font-mono" rows="15">
{{ old("ini", session()->get("ini")) }} {{ old("ini", session()->get("ini")) }}
</x-textarea> </x-textarea>
@error("ini") @error("ini")

View File

@ -1,5 +1,5 @@
<x-input-label for="content" :value="__('Content')" /> <x-input-label for="content" :value="__('Content')" />
<x-textarea id="content" name="content" class="mt-1 min-h-[400px] w-full"> <x-textarea id="content" name="content" class="mt-1 min-h-[400px] w-full font-mono">
{{ $value }} {{ $value }}
</x-textarea> </x-textarea>
@error("content") @error("content")

View File

@ -100,6 +100,15 @@ class="mt-1 w-full"
@enderror @enderror
</div> </div>
<div class="mt-6">
<x-checkbox id="global" name="global" :checked="old('global')" value="1">
Is Global (Accessible in all projects)
</x-checkbox>
@error("global")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<x-secondary-button type="button" x-on:click="$dispatch('close')"> <x-secondary-button type="button" x-on:click="$dispatch('close')">
{{ __("Cancel") }} {{ __("Cancel") }}

View File

@ -16,13 +16,29 @@
@include("settings.notification-channels.partials.icons." . $channel->provider) @include("settings.notification-channels.partials.icons." . $channel->provider)
</div> </div>
<div class="ml-3 flex flex-grow flex-col items-start justify-center"> <div class="ml-3 flex flex-grow flex-col items-start justify-center">
<span class="mb-1">{{ $channel->label }}</span> <div class="mb-1 flex items-center">
{{ $channel->label }}
@if (! $channel->project_id)
<x-status status="disabled" class="ml-2">GLOBAL</x-status>
@endif
</div>
<span class="text-sm text-gray-400"> <span class="text-sm text-gray-400">
<x-datetime :value="$channel->created_at" /> <x-datetime :value="$channel->created_at" />
</span> </span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<div class="inline"> <div class="inline">
<x-icon-button
id="edit-{{ $channel->id }}"
hx-get="{{ route('settings.notification-channels', ['edit' => $channel->id]) }}"
hx-replace-url="true"
hx-select="#edit"
hx-target="#edit"
hx-ext="disable-element"
hx-disable-element="#edit-{{ $channel->id }}"
>
<x-heroicon name="o-pencil" class="h-5 w-5" />
</x-icon-button>
<x-icon-button <x-icon-button
x-on:click="deleteAction = '{{ route('settings.notification-channels.delete', $channel->id) }}'; $dispatch('open-modal', 'delete-channel')" x-on:click="deleteAction = '{{ route('settings.notification-channels.delete', $channel->id) }}'; $dispatch('open-modal', 'delete-channel')"
> >
@ -34,6 +50,12 @@
@endforeach @endforeach
@include("settings.notification-channels.partials.delete-channel") @include("settings.notification-channels.partials.delete-channel")
<div id="edit">
@if (isset($editChannel))
@include("settings.notification-channels.partials.edit-notification-channel", ["notificationChannel" => $editChannel])
@endif
</div>
@else @else
<x-simple-card> <x-simple-card>
<div class="text-center"> <div class="text-center">

View File

@ -0,0 +1,59 @@
<x-modal
name="edit-notification-channel"
:show="true"
x-on:modal-edit-notification-channel-closed.window="window.history.pushState('', '', '{{ route('settings.notification-channels') }}');"
>
<form
id="edit-notification-channel-form"
hx-post="{{ route("settings.notification-channels.update", ["notificationChannel" => $notificationChannel->id]) }}"
hx-swap="outerHTML"
hx-select="#edit-notification-channel-form"
hx-ext="disable-element"
hx-disable-element="#btn-edit-notification-channel"
class="p-6"
>
@csrf
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __("Edit Channel") }}
</h2>
<div class="mt-6">
<x-input-label for="edit-label" value="Label" />
<x-text-input
value="{{ $notificationChannel->label }}"
id="edit-label"
name="label"
type="text"
class="mt-1 w-full"
/>
@error("label")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-checkbox
id="edit-global"
name="global"
:checked="old('global', $notificationChannel->project_id === null ? 1 : null)"
value="1"
>
Is Global (Accessible in all projects)
</x-checkbox>
@error("global")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6 flex justify-end">
<x-secondary-button type="button" x-on:click="$dispatch('close')">
{{ __("Cancel") }}
</x-secondary-button>
<x-primary-button id="btn-edit-notification-channel" class="ml-3">
{{ __("Save") }}
</x-primary-button>
</div>
</form>
</x-modal>

View File

@ -83,6 +83,15 @@ class="mt-1 w-full"
@enderror @enderror
</div> </div>
<div class="mt-6">
<x-checkbox id="global" name="global" :checked="old('global')" value="1">
Is Global (Accessible in all projects)
</x-checkbox>
@error("global")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<x-secondary-button type="button" x-on:click="$dispatch('close')"> <x-secondary-button type="button" x-on:click="$dispatch('close')">
{{ __("Cancel") }} {{ __("Cancel") }}

View File

@ -0,0 +1,59 @@
<x-modal
name="edit-server-provider"
:show="true"
x-on:modal-edit-server-provider-closed.window="window.history.pushState('', '', '{{ route('settings.server-providers') }}');"
>
<form
id="edit-server-provider-form"
hx-post="{{ route("settings.server-providers.update", ["serverProvider" => $serverProvider->id]) }}"
hx-swap="outerHTML"
hx-select="#edit-server-provider-form"
hx-ext="disable-element"
hx-disable-element="#btn-edit-server-provider"
class="p-6"
>
@csrf
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __("Edit Provider") }}
</h2>
<div class="mt-6">
<x-input-label for="edit-name" value="Name" />
<x-text-input
value="{{ $serverProvider->profile }}"
id="edit-name"
name="name"
type="text"
class="mt-1 w-full"
/>
@error("name")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-checkbox
id="edit-global"
name="global"
:checked="old('global', $serverProvider->project_id === null ? 1 : null)"
value="1"
>
Is Global (Accessible in all projects)
</x-checkbox>
@error("global")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6 flex justify-end">
<x-secondary-button type="button" x-on:click="$dispatch('close')">
{{ __("Cancel") }}
</x-secondary-button>
<x-primary-button id="btn-edit-server-provider" class="ml-3">
{{ __("Save") }}
</x-primary-button>
</div>
</form>
</x-modal>

View File

@ -18,13 +18,29 @@ class="h-10 w-10"
/> />
</div> </div>
<div class="ml-3 flex flex-grow flex-col items-start justify-center"> <div class="ml-3 flex flex-grow flex-col items-start justify-center">
<span class="mb-1">{{ $provider->profile }}</span> <div class="mb-1 flex items-center">
{{ $provider->profile }}
@if (! $provider->project_id)
<x-status status="disabled" class="ml-2">GLOBAL</x-status>
@endif
</div>
<span class="text-sm text-gray-400"> <span class="text-sm text-gray-400">
<x-datetime :value="$provider->created_at" /> <x-datetime :value="$provider->created_at" />
</span> </span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<div class="inline"> <div class="inline">
<x-icon-button
id="edit-{{ $provider->id }}"
hx-get="{{ route('settings.server-providers', ['edit' => $provider->id]) }}"
hx-replace-url="true"
hx-select="#edit"
hx-target="#edit"
hx-ext="disable-element"
hx-disable-element="#edit-{{ $provider->id }}"
>
<x-heroicon name="o-pencil" class="h-5 w-5" />
</x-icon-button>
<x-icon-button <x-icon-button
x-on:click="deleteAction = '{{ route('settings.server-providers.delete', $provider->id) }}'; $dispatch('open-modal', 'delete-provider')" x-on:click="deleteAction = '{{ route('settings.server-providers.delete', $provider->id) }}'; $dispatch('open-modal', 'delete-provider')"
> >
@ -36,6 +52,12 @@ class="h-10 w-10"
@endforeach @endforeach
@include("settings.server-providers.partials.delete-provider") @include("settings.server-providers.partials.delete-provider")
<div id="edit">
@if (isset($editProvider))
@include("settings.server-providers.partials.edit-provider", ["serverProvider" => $editProvider])
@endif
</div>
@else @else
<x-simple-card> <x-simple-card>
<div class="text-center"> <div class="text-center">

View File

@ -109,7 +109,7 @@ class="text-primary-500"
<div class="mt-6"> <div class="mt-6">
<x-checkbox <x-checkbox
id="global" id="edit-global"
name="global" name="global"
:checked="old('global', $sourceControl->project_id === null ? 1 : null)" :checked="old('global', $sourceControl->project_id === null ? 1 : null)"
value="1" value="1"

View File

@ -201,6 +201,15 @@ class="mt-1 w-full"
</div> </div>
@endif @endif
<div class="mt-6">
<x-checkbox id="global" name="global" :checked="old('global')" value="1">
Is Global (Accessible in all projects)
</x-checkbox>
@error("global")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<x-secondary-button type="button" x-on:click="$dispatch('close')"> <x-secondary-button type="button" x-on:click="$dispatch('close')">
{{ __("Cancel") }} {{ __("Cancel") }}

View File

@ -0,0 +1,59 @@
<x-modal
name="edit-storage-provider"
:show="true"
x-on:modal-edit-storage-provider-closed.window="window.history.pushState('', '', '{{ route('settings.storage-providers') }}');"
>
<form
id="edit-storage-provider-form"
hx-post="{{ route("settings.storage-providers.update", ["storageProvider" => $storageProvider->id]) }}"
hx-swap="outerHTML"
hx-select="#edit-storage-provider-form"
hx-ext="disable-element"
hx-disable-element="#btn-edit-storage-provider"
class="p-6"
>
@csrf
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __("Edit Provider") }}
</h2>
<div class="mt-6">
<x-input-label for="edit-name" value="Name" />
<x-text-input
value="{{ $storageProvider->profile }}"
id="edit-name"
name="name"
type="text"
class="mt-1 w-full"
/>
@error("name")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-checkbox
id="edit-global"
name="global"
:checked="old('global', $storageProvider->project_id === null ? 1 : null)"
value="1"
>
Is Global (Accessible in all projects)
</x-checkbox>
@error("global")
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6 flex justify-end">
<x-secondary-button type="button" x-on:click="$dispatch('close')">
{{ __("Cancel") }}
</x-secondary-button>
<x-primary-button id="btn-edit-storage-provider" class="ml-3">
{{ __("Save") }}
</x-primary-button>
</div>
</form>
</x-modal>

View File

@ -18,13 +18,29 @@ class="h-10 w-10"
/> />
</div> </div>
<div class="ml-3 flex flex-grow flex-col items-start justify-center"> <div class="ml-3 flex flex-grow flex-col items-start justify-center">
<span class="mb-1">{{ $provider->profile }}</span> <div class="mb-1 flex items-center">
{{ $provider->profile }}
@if (! $provider->project_id)
<x-status status="disabled" class="ml-2">GLOBAL</x-status>
@endif
</div>
<span class="text-sm text-gray-400"> <span class="text-sm text-gray-400">
<x-datetime :value="$provider->created_at" /> <x-datetime :value="$provider->created_at" />
</span> </span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<div class="inline"> <div class="inline">
<x-icon-button
id="edit-{{ $provider->id }}"
hx-get="{{ route('settings.storage-providers', ['edit' => $provider->id]) }}"
hx-replace-url="true"
hx-select="#edit"
hx-target="#edit"
hx-ext="disable-element"
hx-disable-element="#edit-{{ $provider->id }}"
>
<x-heroicon name="o-pencil" class="h-5 w-5" />
</x-icon-button>
<x-icon-button <x-icon-button
x-on:click="deleteAction = '{{ route('settings.storage-providers.delete', $provider->id) }}'; $dispatch('open-modal', 'delete-provider')" x-on:click="deleteAction = '{{ route('settings.storage-providers.delete', $provider->id) }}'; $dispatch('open-modal', 'delete-provider')"
> >
@ -36,6 +52,12 @@ class="h-10 w-10"
@endforeach @endforeach
@include("settings.storage-providers.partials.delete-storage-provider") @include("settings.storage-providers.partials.delete-storage-provider")
<div id="edit">
@if (isset($editProvider))
@include("settings.storage-providers.partials.edit-provider", ["storageProvider" => $editProvider])
@endif
</div>
@else @else
<x-simple-card> <x-simple-card>
<div class="text-center"> <div class="text-center">

View File

@ -22,7 +22,7 @@ class="space-y-6"
hx-swap="outerHTML" hx-swap="outerHTML"
> >
<div id="vhost-container"> <div id="vhost-container">
<x-textarea id="vhost" name="vhost" rows="10" class="mt-1 block min-h-[400px] w-full"> <x-textarea id="vhost" name="vhost" rows="10" class="mt-1 block min-h-[400px] w-full font-mono">
{{ session()->has("vhost") ? session()->get("vhost") : "Loading..." }} {{ session()->has("vhost") ? session()->get("vhost") : "Loading..." }}
</x-textarea> </x-textarea>
</div> </div>

View File

@ -3,7 +3,7 @@
<div class="mt-1 flex items-center"> <div class="mt-1 flex items-center">
<x-select-input id="source_control" name="source_control" class="mt-1 w-full"> <x-select-input id="source_control" name="source_control" class="mt-1 w-full">
<option value="" selected>{{ __("Select") }}</option> <option value="" selected>{{ __("Select") }}</option>
@foreach (\App\Models\SourceControl::getByCurrentProject() as $sourceControl) @foreach (\App\Models\SourceControl::getByProjectId(auth()->user()->current_project_id)->get() as $sourceControl)
<option <option
value="{{ $sourceControl->id }}" value="{{ $sourceControl->id }}"
@if($sourceControl->id == old('source_control', isset($site) ? $site->source_control_id : null)) selected @endif @if($sourceControl->id == old('source_control', isset($site) ? $site->source_control_id : null)) selected @endif

View File

@ -31,6 +31,7 @@
Route::get('/', [ServerProviderController::class, 'index'])->name('settings.server-providers'); Route::get('/', [ServerProviderController::class, 'index'])->name('settings.server-providers');
Route::post('connect', [ServerProviderController::class, 'connect'])->name('settings.server-providers.connect'); Route::post('connect', [ServerProviderController::class, 'connect'])->name('settings.server-providers.connect');
Route::delete('delete/{serverProvider}', [ServerProviderController::class, 'delete'])->name('settings.server-providers.delete'); Route::delete('delete/{serverProvider}', [ServerProviderController::class, 'delete'])->name('settings.server-providers.delete');
Route::post('edit/{serverProvider}', [ServerProviderController::class, 'update'])->name('settings.server-providers.update');
}); });
// source-controls // source-controls
@ -46,6 +47,7 @@
Route::get('/', [StorageProviderController::class, 'index'])->name('settings.storage-providers'); Route::get('/', [StorageProviderController::class, 'index'])->name('settings.storage-providers');
Route::post('connect', [StorageProviderController::class, 'connect'])->name('settings.storage-providers.connect'); Route::post('connect', [StorageProviderController::class, 'connect'])->name('settings.storage-providers.connect');
Route::delete('delete/{storageProvider}', [StorageProviderController::class, 'delete'])->name('settings.storage-providers.delete'); Route::delete('delete/{storageProvider}', [StorageProviderController::class, 'delete'])->name('settings.storage-providers.delete');
Route::post('edit/{storageProvider}', [StorageProviderController::class, 'update'])->name('settings.storage-providers.update');
}); });
// notification-channels // notification-channels
@ -53,6 +55,7 @@
Route::get('/', [NotificationChannelController::class, 'index'])->name('settings.notification-channels'); Route::get('/', [NotificationChannelController::class, 'index'])->name('settings.notification-channels');
Route::post('add', [NotificationChannelController::class, 'add'])->name('settings.notification-channels.add'); Route::post('add', [NotificationChannelController::class, 'add'])->name('settings.notification-channels.add');
Route::delete('delete/{id}', [NotificationChannelController::class, 'delete'])->name('settings.notification-channels.delete'); Route::delete('delete/{id}', [NotificationChannelController::class, 'delete'])->name('settings.notification-channels.delete');
Route::post('edit/{notificationChannel}', [NotificationChannelController::class, 'update'])->name('settings.notification-channels.update');
}); });
// ssh-keys // ssh-keys

View File

@ -21,12 +21,14 @@ public function test_add_email_channel(): void
'provider' => NotificationChannel::EMAIL, 'provider' => NotificationChannel::EMAIL,
'email' => 'email@example.com', 'email' => 'email@example.com',
'label' => 'Email', 'label' => 'Email',
'global' => 1,
])->assertSessionDoesntHaveErrors(); ])->assertSessionDoesntHaveErrors();
/** @var \App\Models\NotificationChannel $channel */ /** @var \App\Models\NotificationChannel $channel */
$channel = \App\Models\NotificationChannel::query() $channel = \App\Models\NotificationChannel::query()
->where('provider', NotificationChannel::EMAIL) ->where('provider', NotificationChannel::EMAIL)
->where('label', 'Email') ->where('label', 'Email')
->whereNull('project_id')
->first(); ->first();
$this->assertEquals('email@example.com', $channel->data['email']); $this->assertEquals('email@example.com', $channel->data['email']);

View File

@ -99,6 +99,8 @@ public function test_add_existing_key()
*/ */
public function test_create_ssh_key_handles_invalid_or_partial_keys(array $postBody, bool $expectedToSucceed): void public function test_create_ssh_key_handles_invalid_or_partial_keys(array $postBody, bool $expectedToSucceed): void
{ {
SSH::fake();
$this->actingAs($this->user); $this->actingAs($this->user);
// Some existing amount of seed data to make the test more realistic // Some existing amount of seed data to make the test more realistic

View File

@ -32,6 +32,7 @@ public function test_connect_provider(string $provider, array $input): void
$this->assertDatabaseHas('server_providers', [ $this->assertDatabaseHas('server_providers', [
'provider' => $provider, 'provider' => $provider,
'profile' => 'profile', 'profile' => 'profile',
'project_id' => isset($input['global']) ? null : $this->user->current_project_id,
]); ]);
} }
@ -136,6 +137,13 @@ public static function data(): array
'token' => 'token', 'token' => 'token',
], ],
], ],
[
ServerProvider::LINODE,
[
'token' => 'token',
'global' => 1,
],
],
[ [
ServerProvider::DIGITALOCEAN, ServerProvider::DIGITALOCEAN,
[ [

View File

@ -13,21 +13,24 @@ class StorageProvidersTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function test_connect_dropbox(): void /**
* @dataProvider createData
*/
public function test_create(array $input): void
{ {
$this->actingAs($this->user); $this->actingAs($this->user);
Http::fake(); if ($input['provider'] === StorageProvider::DROPBOX) {
Http::fake();
}
$this->post(route('settings.storage-providers.connect'), [ $this->post(route('settings.storage-providers.connect'), $input)
'provider' => StorageProvider::DROPBOX, ->assertSessionDoesntHaveErrors();
'name' => 'profile',
'token' => 'token',
])->assertSessionDoesntHaveErrors();
$this->assertDatabaseHas('storage_providers', [ $this->assertDatabaseHas('storage_providers', [
'provider' => StorageProvider::DROPBOX, 'provider' => $input['provider'],
'profile' => 'profile', 'profile' => $input['name'],
'project_id' => isset($input['global']) ? null : $this->user->current_project_id,
]); ]);
} }
@ -89,19 +92,69 @@ public function test_cannot_delete_provider(): void
]); ]);
} }
public function test_create_local_driver(): void /**
* @TODO: complete FTP tests
*/
public static function createData(): array
{ {
$this->actingAs($this->user); return [
[
$this->post(route('settings.storage-providers.connect'), [ [
'provider' => StorageProvider::LOCAL, 'provider' => StorageProvider::LOCAL,
'name' => 'profile', 'name' => 'local-test',
'path' => '/home/vito/backups', 'path' => '/home/vito/backups',
])->assertSessionDoesntHaveErrors(); ],
],
$this->assertDatabaseHas('storage_providers', [ [
'provider' => StorageProvider::LOCAL, [
'profile' => 'profile', 'provider' => StorageProvider::LOCAL,
]); 'name' => 'local-test',
'path' => '/home/vito/backups',
'global' => 1,
],
],
// [
// [
// 'provider' => StorageProvider::FTP,
// 'name' => 'ftp-test',
// 'host' => '1.2.3.4',
// 'port' => '22',
// 'path' => '/home/vito',
// 'username' => 'username',
// 'password' => 'password',
// 'ssl' => 1,
// 'passive' => 1,
// ],
// ],
// [
// [
// 'provider' => StorageProvider::FTP,
// 'name' => 'ftp-test',
// 'host' => '1.2.3.4',
// 'port' => '22',
// 'path' => '/home/vito',
// 'username' => 'username',
// 'password' => 'password',
// 'ssl' => 1,
// 'passive' => 1,
// 'global' => 1,
// ],
// ],
[
[
'provider' => StorageProvider::DROPBOX,
'name' => 'dropbox-test',
'token' => 'token',
],
],
[
[
'provider' => StorageProvider::DROPBOX,
'name' => 'dropbox-test',
'token' => 'token',
'global' => 1,
],
],
];
} }
} }