diff --git a/app/Actions/SshKey/CreateSshKey.php b/app/Actions/SshKey/CreateSshKey.php index d21d462f..06419b12 100644 --- a/app/Actions/SshKey/CreateSshKey.php +++ b/app/Actions/SshKey/CreateSshKey.php @@ -5,6 +5,7 @@ use App\Models\SshKey; use App\Models\User; use App\ValidationRules\SshKeyRule; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; class CreateSshKey @@ -16,6 +17,8 @@ class CreateSshKey */ public function create(User $user, array $input): SshKey { + Validator::make($input, self::rules())->validate(); + $key = new SshKey([ 'user_id' => $user->id, 'name' => $input['name'], diff --git a/app/Http/Controllers/API/ServerSSHKeyController.php b/app/Http/Controllers/API/ServerSSHKeyController.php index ceae773b..35123a8f 100644 --- a/app/Http/Controllers/API/ServerSSHKeyController.php +++ b/app/Http/Controllers/API/ServerSSHKeyController.php @@ -65,7 +65,6 @@ public function create(Request $request, Project $project, Server $server): SshK } if (! $sshKey) { - $this->validate($request, CreateSshKey::rules()); /** @var SshKey $sshKey */ $sshKey = app(CreateSshKey::class)->create($user, $request->all()); } diff --git a/app/Http/Controllers/SSHKeyController.php b/app/Http/Controllers/SSHKeyController.php new file mode 100644 index 00000000..9bf88d9d --- /dev/null +++ b/app/Http/Controllers/SSHKeyController.php @@ -0,0 +1,51 @@ +authorize('viewAny', SshKey::class); + + return Inertia::render('ssh-keys/index', [ + 'sshKeys' => SshKeyResource::collection(user()->sshKeys()->simplePaginate(config('web.pagination_size'))), + ]); + } + + #[Post('/', name: 'ssh-keys.store')] + public function store(Request $request): RedirectResponse + { + $this->authorize('create', SshKey::class); + + app(CreateSshKey::class)->create(user(), $request->input()); + + return back()->with('success', 'SSH key created.'); + } + + #[Delete('/{sshKey}', name: 'ssh-keys.destroy')] + public function destroy(SshKey $sshKey): RedirectResponse + { + $this->authorize('delete', $sshKey); + + $sshKey->delete(); + + return back()->with('success', 'SSH key deleted.'); + } +} diff --git a/resources/js/components/ui/textarea.tsx b/resources/js/components/ui/textarea.tsx new file mode 100644 index 00000000..5f558e74 --- /dev/null +++ b/resources/js/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) { + return ( + + ); +} + +export { Textarea }; diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index e0d33bce..32094a8f 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 { BellIcon, CloudIcon, CodeIcon, DatabaseIcon, ListIcon, UserIcon, UsersIcon } from 'lucide-react'; +import { BellIcon, CloudIcon, CodeIcon, DatabaseIcon, KeyIcon, ListIcon, UserIcon, UsersIcon } from 'lucide-react'; import { ReactNode } from 'react'; import Layout from '@/layouts/app/layout'; @@ -39,6 +39,11 @@ const sidebarNavItems: NavItem[] = [ href: route('notification-channels'), icon: BellIcon, }, + { + title: 'SSH Keys', + href: route('ssh-keys'), + icon: KeyIcon, + }, ]; export default function SettingsLayout({ children, breadcrumbs }: { children: ReactNode; breadcrumbs?: BreadcrumbItem[] }) { diff --git a/resources/js/pages/ssh-keys/components/add-ssh-key.tsx b/resources/js/pages/ssh-keys/components/add-ssh-key.tsx new file mode 100644 index 00000000..669b95a5 --- /dev/null +++ b/resources/js/pages/ssh-keys/components/add-ssh-key.tsx @@ -0,0 +1,79 @@ +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 } from '@inertiajs/react'; +import { FormEventHandler, ReactNode, useState } from 'react'; +import { Label } from '@/components/ui/label'; +import InputError from '@/components/ui/input-error'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; + +type SshKeyForm = { + name: string; + public_key: string; +}; + +export default function AddSshKey({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + + const form = useForm>({ + name: '', + public_key: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + form.post(route('ssh-keys.store'), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + + return ( + + {children} + + + Add ssh key + Add new ssh key + + + + + Name + form.setData('name', e.target.value)} /> + + + + Public key + form.setData('public_key', e.target.value)} /> + + + + + + + + Cancel + + + + {form.processing && } + Connect + + + + + ); +} diff --git a/resources/js/pages/ssh-keys/components/columns.tsx b/resources/js/pages/ssh-keys/components/columns.tsx new file mode 100644 index 00000000..33b73309 --- /dev/null +++ b/resources/js/pages/ssh-keys/components/columns.tsx @@ -0,0 +1,105 @@ +import { ColumnDef } from '@tanstack/react-table'; +import DateTime from '@/components/date-time'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, 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 { useState } from 'react'; +import { SSHKey } from '@/types/ssh-key'; + +function Delete({ sshKey }: { sshKey: SSHKey }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete(route('ssh-keys.destroy', sshKey.id), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete {sshKey.name} + Delete ssh key + + Are you sure you want to delete this key? + + + Cancel + + + {form.processing && } + + Delete + + + + + ); +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: 'ID', + enableColumnFilter: true, + enableSorting: true, + enableHiding: true, + }, + { + accessorKey: 'name', + header: 'Name', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'created_at', + header: 'Created at', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ; + }, + }, + { + id: 'actions', + enableColumnFilter: false, + enableSorting: false, + cell: ({ row }) => { + return ( + + + + + Open menu + + + + + + + + + ); + }, + }, +]; diff --git a/resources/js/pages/ssh-keys/index.tsx b/resources/js/pages/ssh-keys/index.tsx new file mode 100644 index 00000000..d1cab3ea --- /dev/null +++ b/resources/js/pages/ssh-keys/index.tsx @@ -0,0 +1,38 @@ +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 { DataTable } from '@/components/data-table'; +import React from 'react'; +import { SSHKey } from '@/types/ssh-key'; +import { columns } from '@/pages/ssh-keys/components/columns'; +import AddSshKey from '@/pages/ssh-keys/components/add-ssh-key'; + +type Page = { + sshKeys: { + data: SSHKey[]; + }; +}; + +export default function SshKeys() { + const page = usePage(); + + return ( + + + + + + + + Add + + + + + + + + ); +} diff --git a/resources/js/types/ssh-key.d.ts b/resources/js/types/ssh-key.d.ts new file mode 100644 index 00000000..ed56991b --- /dev/null +++ b/resources/js/types/ssh-key.d.ts @@ -0,0 +1,11 @@ +import { User } from '@/types/user'; + +export interface SSHKey { + id: number; + user?: User; + name: string; + created_at: string; + updated_at: string; + + [key: string]: unknown; +} diff --git a/tests/Feature/SshKeysTest.php b/tests/Feature/SshKeysTest.php index 256a91a8..2f1b94b6 100644 --- a/tests/Feature/SshKeysTest.php +++ b/tests/Feature/SshKeysTest.php @@ -3,10 +3,8 @@ namespace Tests\Feature; use App\Models\SshKey; -use App\Web\Pages\Settings\SSHKeys\Index; -use App\Web\Pages\Settings\SSHKeys\Widgets\SshKeysList; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Livewire; +use Inertia\Testing\AssertableInertia; use Tests\TestCase; class SshKeysTest extends TestCase @@ -17,25 +15,28 @@ public function test_create_ssh_key(): void { $this->actingAs($this->user); - Livewire::test(Index::class) - ->callAction('add', [ - 'name' => 'test', - 'public_key' => 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== test@test.local', - ]) - ->assertSuccessful(); + $this->post(route('ssh-keys.store'), [ + 'name' => 'test', + 'public_key' => 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSUGPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XAt3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/EnmZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbxNrRFi9wrf+M7Q== test@test.local', + ]) + ->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseHas('ssh_keys', [ + 'name' => 'test', + ]); } public function test_get_public_keys_list(): void { $this->actingAs($this->user); - $key = SshKey::factory()->create([ + SshKey::factory()->create([ 'user_id' => $this->user->id, ]); - $this->get(Index::getUrl()) + $this->get(route('ssh-keys')) ->assertSuccessful() - ->assertSee($key->name); + ->assertInertia(fn (AssertableInertia $page) => $page->component('ssh-keys/index')); } public function test_delete_key(): void @@ -46,9 +47,7 @@ public function test_delete_key(): void 'user_id' => $this->user->id, ]); - Livewire::test(SshKeysList::class) - ->callTableAction('delete', $key->id) - ->assertSuccessful(); + $this->delete(route('ssh-keys.destroy', ['sshKey' => $key->id])); $this->assertSoftDeleted('ssh_keys', [ 'id' => $key->id, @@ -56,6 +55,8 @@ public function test_delete_key(): void } /** + * @param array $postBody + * * @dataProvider ssh_key_data_provider */ public function test_create_ssh_key_handles_invalid_or_partial_keys(array $postBody, bool $expectedToSucceed): void @@ -69,16 +70,18 @@ public function test_create_ssh_key_handles_invalid_or_partial_keys(array $postB 'public_key' => 'public-key-content', ]); - $response = Livewire::test(Index::class) - ->callAction('add', $postBody); + $response = $this->post(route('ssh-keys.store'), $postBody); if ($expectedToSucceed) { - $response->assertHasNoActionErrors(); + $response->assertSessionDoesntHaveErrors(); } else { - $response->assertHasActionErrors(); + $response->assertSessionHasErrors(); } } + /** + * @return array, 1: bool}> + */ public static function ssh_key_data_provider(): array { return [
Are you sure you want to delete this key?