diff --git a/app/Actions/StorageProvider/CreateStorageProvider.php b/app/Actions/StorageProvider/CreateStorageProvider.php index 6e9c2729..f39752d3 100644 --- a/app/Actions/StorageProvider/CreateStorageProvider.php +++ b/app/Actions/StorageProvider/CreateStorageProvider.php @@ -5,6 +5,7 @@ use App\Models\Project; use App\Models\StorageProvider; use App\Models\User; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -17,6 +18,8 @@ class CreateStorageProvider */ public function create(User $user, Project $project, array $input): StorageProvider { + Validator::make($input, self::rules($input))->validate(); + $storageProvider = new StorageProvider([ 'user_id' => $user->id, 'provider' => $input['provider'], diff --git a/app/Actions/StorageProvider/EditStorageProvider.php b/app/Actions/StorageProvider/EditStorageProvider.php index 11300add..45216741 100644 --- a/app/Actions/StorageProvider/EditStorageProvider.php +++ b/app/Actions/StorageProvider/EditStorageProvider.php @@ -4,6 +4,7 @@ use App\Models\Project; use App\Models\StorageProvider; +use Illuminate\Support\Facades\Validator; class EditStorageProvider { @@ -12,6 +13,8 @@ class EditStorageProvider */ public function edit(StorageProvider $storageProvider, Project $project, array $input): StorageProvider { + Validator::make($input, self::rules())->validate(); + $storageProvider->profile = $input['name']; $storageProvider->project_id = isset($input['global']) && $input['global'] ? null : $project->id; diff --git a/app/Http/Controllers/API/StorageProviderController.php b/app/Http/Controllers/API/StorageProviderController.php index 2e91bbed..d50c9372 100644 --- a/app/Http/Controllers/API/StorageProviderController.php +++ b/app/Http/Controllers/API/StorageProviderController.php @@ -53,8 +53,6 @@ public function create(Request $request, Project $project): StorageProviderResou { $this->authorize('create', StorageProvider::class); - $this->validate($request, CreateStorageProvider::rules($request->all())); - /** @var User $user */ $user = auth()->user(); $storageProvider = app(CreateStorageProvider::class)->create($user, $project, $request->all()); @@ -85,8 +83,6 @@ public function update(Request $request, Project $project, StorageProvider $stor $this->validateRoute($project, $storageProvider); - $this->validate($request, EditStorageProvider::rules()); - $storageProvider = app(EditStorageProvider::class)->edit($storageProvider, $project, $request->all()); return new StorageProviderResource($storageProvider); diff --git a/app/Http/Controllers/StorageProviderController.php b/app/Http/Controllers/StorageProviderController.php new file mode 100644 index 00000000..2c621a14 --- /dev/null +++ b/app/Http/Controllers/StorageProviderController.php @@ -0,0 +1,73 @@ +authorize('viewAny', StorageProvider::class); + + return Inertia::render('storage-providers/index', [ + 'storageProviders' => StorageProviderResource::collection(StorageProvider::getByProjectId(user()->current_project_id)->simplePaginate(config('web.pagination_size'))), + ]); + } + + #[Get('/json', name: 'storage-providers.json')] + public function json(): ResourceCollection + { + $this->authorize('viewAny', StorageProvider::class); + + return StorageProviderResource::collection(StorageProvider::getByProjectId(user()->current_project_id)->get()); + } + + #[Post('/', name: 'storage-providers.store')] + public function store(Request $request): RedirectResponse + { + $this->authorize('create', StorageProvider::class); + + app(CreateStorageProvider::class)->create(user(), user()->currentProject, $request->all()); + + return back()->with('success', 'Storage provider created.'); + } + + #[Patch('/{storageProvider}', name: 'storage-providers.update')] + public function update(Request $request, StorageProvider $storageProvider): RedirectResponse + { + $this->authorize('update', $storageProvider); + + app(EditStorageProvider::class)->edit($storageProvider, user()->currentProject, $request->all()); + + return back()->with('success', 'Storage provider updated.'); + } + + #[Delete('{storageProvider}', name: 'storage-providers.destroy')] + public function destroy(StorageProvider $storageProvider): RedirectResponse + { + $this->authorize('delete', $storageProvider); + + app(DeleteStorageProvider::class)->delete($storageProvider); + + return to_route('storage-providers')->with('success', 'Storage provider deleted.'); + } +} diff --git a/app/Models/StorageProvider.php b/app/Models/StorageProvider.php index 6bfedf71..fb834153 100644 --- a/app/Models/StorageProvider.php +++ b/app/Models/StorageProvider.php @@ -2,6 +2,7 @@ namespace App\Models; +use Database\Factories\StorageProviderFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -17,7 +18,7 @@ */ class StorageProvider extends AbstractModel { - /** @use HasFactory<\Database\Factories\StorageProviderFactory> */ + /** @use HasFactory */ use HasFactory; protected $fillable = [ diff --git a/config/core.php b/config/core.php index 1e78bdca..6724089c 100755 --- a/config/core.php +++ b/config/core.php @@ -536,6 +536,27 @@ \App\Enums\StorageProvider::LOCAL => \App\StorageProviders\Local::class, \App\Enums\StorageProvider::S3 => \App\StorageProviders\S3::class, ], + 'storage_providers_custom_fields' => [ + \App\Enums\StorageProvider::DROPBOX => ['token'], + \App\Enums\StorageProvider::FTP => [ + 'host', + 'port', + 'path', + 'username', + 'password', + 'ssl', + 'passive', + ], + \App\Enums\StorageProvider::S3 => [ + 'api_url', + 'key', + 'secret', + 'region', + 'bucket', + 'path', + ], + \App\Enums\StorageProvider::LOCAL => ['path'], + ], 'ssl_types' => [ \App\Enums\SslType::LETSENCRYPT, diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 60105e42..e73046d5 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -1,5 +1,5 @@ import { type BreadcrumbItem, type NavItem } from '@/types'; -import { CloudIcon, CodeIcon, ListIcon, UserIcon, UsersIcon } from 'lucide-react'; +import { CloudIcon, CodeIcon, DatabaseIcon, ListIcon, UserIcon, UsersIcon } from 'lucide-react'; import { ReactNode } from 'react'; import Layout from '@/layouts/app/layout'; @@ -29,6 +29,11 @@ const sidebarNavItems: NavItem[] = [ href: route('source-controls'), icon: CodeIcon, }, + { + title: 'Storage Providers', + href: route('storage-providers'), + icon: DatabaseIcon, + }, ]; export default function SettingsLayout({ children, breadcrumbs }: { children: ReactNode; breadcrumbs?: BreadcrumbItem[] }) { diff --git a/resources/js/pages/server-providers/components/connect-server-provider.tsx b/resources/js/pages/server-providers/components/connect-server-provider.tsx index 4d6b6d91..01a3b28f 100644 --- a/resources/js/pages/server-providers/components/connect-server-provider.tsx +++ b/resources/js/pages/server-providers/components/connect-server-provider.tsx @@ -111,12 +111,13 @@ export default function ConnectServerProvider({ {page.props.configs.server_providers_custom_fields[form.data.provider]?.map((item: string) => ( - + form.setData(item as keyof ServerProviderForm, e.target.value)} /> diff --git a/resources/js/pages/storage-providers/components/columns.tsx b/resources/js/pages/storage-providers/components/columns.tsx new file mode 100644 index 00000000..08a75191 --- /dev/null +++ b/resources/js/pages/storage-providers/components/columns.tsx @@ -0,0 +1,185 @@ +import { ColumnDef } from '@tanstack/react-table'; +import DateTime from '@/components/date-time'; +import { StorageProvider } from '@/types/storage-provider'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import { useForm } from '@inertiajs/react'; +import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react'; +import FormSuccessful from '@/components/form-successful'; +import { FormEvent, useState } from 'react'; +import InputError from '@/components/ui/input-error'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; + +function Edit({ storageProvider }: { storageProvider: StorageProvider }) { + const [open, setOpen] = useState(false); + const form = useForm({ + name: storageProvider.name, + global: storageProvider.global, + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.patch(route('storage-providers.update', storageProvider.id)); + }; + return ( + + + e.preventDefault()}>Edit + + + + Edit {storageProvider.name} + Edit storage provider + +
+ + + + form.setData('name', e.target.value)} /> + + + +
+ form.setData('global', !form.data.global)} /> + +
+ +
+
+
+ + + + + + +
+
+ ); +} + +function Delete({ storageProvider }: { storageProvider: StorageProvider }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete(route('storage-providers.destroy', storageProvider.id), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete {storageProvider.name} + Delete storage provider + +
+

+ Are you sure you want to delete {storageProvider.name}? +

+ +
+ + + + + + +
+
+ ); +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: 'ID', + enableColumnFilter: true, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: 'provider', + header: 'Provider', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'name', + header: 'Name', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'global', + header: 'Global', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return
{row.original.global ? yes : no}
; + }, + }, + { + accessorKey: 'created_at', + header: 'Created at', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ; + }, + }, + { + id: 'actions', + enableColumnFilter: false, + enableSorting: false, + cell: ({ row }) => { + return ( +
+ + + + + + + + + + +
+ ); + }, + }, +]; diff --git a/resources/js/pages/storage-providers/components/connect-storage-provider.tsx b/resources/js/pages/storage-providers/components/connect-storage-provider.tsx new file mode 100644 index 00000000..f522bae3 --- /dev/null +++ b/resources/js/pages/storage-providers/components/connect-storage-provider.tsx @@ -0,0 +1,150 @@ +import { LoaderCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useForm, usePage } from '@inertiajs/react'; +import { FormEventHandler, ReactNode, 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'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { SharedData } from '@/types'; +import { Checkbox } from '@/components/ui/checkbox'; + +type StorageProviderForm = { + provider: string; + name: string; + global: boolean; +}; + +export default function ConnectStorageProvider({ + providers, + defaultProvider, + onProviderAdded, + children, +}: { + providers: string[]; + defaultProvider?: string; + onProviderAdded?: () => void; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + + const page = usePage(); + + const form = useForm>({ + provider: 's3', + name: '', + global: false, + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + form.post(route('storage-providers.store'), { + onSuccess: () => { + setOpen(false); + if (onProviderAdded) { + onProviderAdded(); + } + }, + }); + }; + + useEffect(() => { + form.setData('provider', defaultProvider ?? 's3'); + }, [defaultProvider]); + + return ( + + {children} + + + Connect to storage provider + Connect to a new storage provider + +
+ + + + + + + + + form.setData('name', e.target.value)} + /> + + + {page.props.configs.storage_providers_custom_fields[form.data.provider]?.map((item: string) => ( + + + form.setData(item as keyof StorageProviderForm, e.target.value)} + /> + + + ))} + +
+ form.setData('global', !form.data.global)} /> + +
+ +
+
+
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/storage-providers/index.tsx b/resources/js/pages/storage-providers/index.tsx new file mode 100644 index 00000000..98273cda --- /dev/null +++ b/resources/js/pages/storage-providers/index.tsx @@ -0,0 +1,41 @@ +import SettingsLayout from '@/layouts/settings/layout'; +import { Head, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import React from 'react'; +import ConnectStorageProvider from '@/pages/storage-providers/components/connect-storage-provider'; +import { DataTable } from '@/components/data-table'; +import { columns } from '@/pages/storage-providers/components/columns'; +import { StorageProvider } from '@/types/storage-provider'; + +type Page = { + storageProviders: { + data: StorageProvider[]; + }; + configs: { + storage_providers: string[]; + }; +}; + +export default function StorageProviders() { + const page = usePage(); + + return ( + + + +
+ +
+ + + +
+
+ + +
+
+ ); +} diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 2aec771a..294a925b 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -37,6 +37,10 @@ export interface Configs { source_control_providers_custom_fields: { [provider: string]: string[]; }; + storage_providers: string[]; + storage_providers_custom_fields: { + [provider: string]: string[]; + }; operating_systems: string[]; service_versions: { [service: string]: string[]; diff --git a/resources/js/types/storage-provider.d.ts b/resources/js/types/storage-provider.d.ts new file mode 100644 index 00000000..7afe2649 --- /dev/null +++ b/resources/js/types/storage-provider.d.ts @@ -0,0 +1,11 @@ +export interface StorageProvider { + id: number; + project_id?: number; + global: boolean; + name: string; + provider: string; + created_at: string; + updated_at: string; + + [key: string]: unknown; +} diff --git a/tests/Feature/SourceControlsTest.php b/tests/Feature/SourceControlsTest.php index 523e6e1e..d81c2910 100644 --- a/tests/Feature/SourceControlsTest.php +++ b/tests/Feature/SourceControlsTest.php @@ -132,7 +132,7 @@ public function test_edit_source_control(string $provider, ?string $url, array $ } /** - * @return array> + * @return array */ public static function data(): array { diff --git a/tests/Feature/StorageProvidersTest.php b/tests/Feature/StorageProvidersTest.php index 7d1ebeb1..a81e3b86 100644 --- a/tests/Feature/StorageProvidersTest.php +++ b/tests/Feature/StorageProvidersTest.php @@ -7,11 +7,9 @@ use App\Models\Backup; use App\Models\Database; use App\Models\StorageProvider as StorageProviderModel; -use App\Web\Pages\Settings\StorageProviders\Index; -use App\Web\Pages\Settings\StorageProviders\Widgets\StorageProvidersList; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; -use Livewire\Livewire; +use Inertia\Testing\AssertableInertia; use Tests\TestCase; class StorageProvidersTest extends TestCase @@ -19,6 +17,8 @@ class StorageProvidersTest extends TestCase use RefreshDatabase; /** + * @param array $input + * * @dataProvider createData */ public function test_create(array $input): void @@ -33,9 +33,7 @@ public function test_create(array $input): void FTP::fake(); } - Livewire::test(Index::class) - ->callAction('connect', $input) - ->assertSuccessful(); + $this->post(route('storage-providers.store'), $input); if ($input['provider'] === StorageProvider::FTP) { FTP::assertConnected($input['host']); @@ -52,29 +50,14 @@ public function test_see_providers_list(): void { $this->actingAs($this->user); - /** @var StorageProviderModel $provider */ - $provider = StorageProviderModel::factory()->create([ + StorageProviderModel::factory()->create([ 'user_id' => $this->user->id, 'provider' => StorageProvider::DROPBOX, ]); - $this->get(Index::getUrl()) + $this->get(route('storage-providers')) ->assertSuccessful() - ->assertSee($provider->profile); - - /** @var StorageProviderModel $provider */ - $provider = StorageProviderModel::factory()->create([ - 'user_id' => $this->user->id, - 'provider' => StorageProvider::S3, - ]); - - $this->get(Index::getUrl()) - ->assertSuccessful() - ->assertSee($provider->profile); - - $this->get(Index::getUrl()) - ->assertSuccessful() - ->assertSee($provider->profile); + ->assertInertia(fn (AssertableInertia $page) => $page->component('storage-providers/index')); } public function test_delete_provider(): void @@ -85,9 +68,7 @@ public function test_delete_provider(): void 'user_id' => $this->user->id, ]); - Livewire::test(StorageProvidersList::class) - ->callTableAction('delete', $provider->id) - ->assertSuccessful(); + $this->delete(route('storage-providers.destroy', ['storageProvider' => $provider->id])); $this->assertDatabaseMissing('storage_providers', [ 'id' => $provider->id, @@ -112,9 +93,10 @@ public function test_cannot_delete_provider(): void 'storage_id' => $provider->id, ]); - Livewire::test(StorageProvidersList::class) - ->callTableAction('delete', $provider->id) - ->assertNotified('This storage provider is being used by a backup.'); + $this->delete(route('storage-providers.destroy', ['storageProvider' => $provider->id])) + ->assertSessionHasErrors([ + 'provider' => 'This storage provider is being used by a backup.', + ]); $this->assertDatabaseHas('storage_providers', [ 'id' => $provider->id, @@ -123,6 +105,8 @@ public function test_cannot_delete_provider(): void /** * @TODO: complete FTP tests + * + * @return array */ public static function createData(): array {