From 0fce4dba9c8ded2f38d3e004e76bdf57a52daa89 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Thu, 29 May 2025 21:20:33 +0200 Subject: [PATCH] #591 - server-ssh-keys --- app/Actions/SshKey/DeployKeyToServer.php | 22 +-- .../API/ServerSSHKeyController.php | 10 +- .../Controllers/ServerSshKeyController.php | 63 +++++++ ...KeyController.php => SshKeyController.php} | 11 +- app/Http/Resources/SshKeyResource.php | 2 +- app/Models/SshKey.php | 3 +- resources/js/components/header-container.tsx | 2 +- resources/js/components/log-output.tsx | 11 +- resources/js/components/ui/dialog.tsx | 2 +- resources/js/layouts/server/layout.tsx | 12 +- resources/js/pages/cronjobs/index.tsx | 8 +- resources/js/pages/firewall/index.tsx | 8 +- .../server-ssh-keys/components/columns.tsx | 125 ++++++++++++++ .../server-ssh-keys/components/deploy-key.tsx | 75 +++++++++ resources/js/pages/server-ssh-keys/index.tsx | 47 ++++++ .../pages/ssh-keys/components/add-ssh-key.tsx | 5 +- .../js/pages/ssh-keys/components/columns.tsx | 6 +- .../ssh-keys/components/ssh-key-select.tsx | 50 ++++++ resources/js/pages/ssh-keys/index.tsx | 4 +- resources/js/pages/workers/index.tsx | 8 +- resources/js/types/ssh-key.d.ts | 2 +- tests/Feature/ServerKeysTest.php | 157 ++---------------- 22 files changed, 438 insertions(+), 195 deletions(-) create mode 100644 app/Http/Controllers/ServerSshKeyController.php rename app/Http/Controllers/{SSHKeyController.php => SshKeyController.php} (81%) create mode 100644 resources/js/pages/server-ssh-keys/components/columns.tsx create mode 100644 resources/js/pages/server-ssh-keys/components/deploy-key.tsx create mode 100644 resources/js/pages/server-ssh-keys/index.tsx create mode 100644 resources/js/pages/ssh-keys/components/ssh-key-select.tsx diff --git a/app/Actions/SshKey/DeployKeyToServer.php b/app/Actions/SshKey/DeployKeyToServer.php index a3b02ce0..f548b3bf 100644 --- a/app/Actions/SshKey/DeployKeyToServer.php +++ b/app/Actions/SshKey/DeployKeyToServer.php @@ -6,20 +6,14 @@ use App\Exceptions\SSHError; use App\Models\Server; use App\Models\SshKey; -use App\Models\User; -use Illuminate\Validation\Rule; class DeployKeyToServer { /** - * @param array $input - * * @throws SSHError */ - public function deploy(Server $server, array $input): void + public function deploy(Server $server, SshKey $sshKey): void { - /** @var SshKey $sshKey */ - $sshKey = SshKey::query()->findOrFail($input['key_id']); $server->sshKeys()->attach($sshKey, [ 'status' => SshKeyStatus::ADDING, ]); @@ -28,18 +22,4 @@ public function deploy(Server $server, array $input): void 'status' => SshKeyStatus::ADDED, ]); } - - /** - * @return array> - */ - public static function rules(User $user, Server $server): array - { - return [ - 'key_id' => [ - 'required', - Rule::exists('ssh_keys', 'id')->where('user_id', $user->id), - Rule::unique('server_ssh_keys', 'ssh_key_id')->where('server_id', $server->id), - ], - ]; - } } diff --git a/app/Http/Controllers/API/ServerSSHKeyController.php b/app/Http/Controllers/API/ServerSSHKeyController.php index 35123a8f..3e404f0e 100644 --- a/app/Http/Controllers/API/ServerSSHKeyController.php +++ b/app/Http/Controllers/API/ServerSSHKeyController.php @@ -5,6 +5,7 @@ use App\Actions\SshKey\CreateSshKey; use App\Actions\SshKey\DeleteKeyFromServer; use App\Actions\SshKey\DeployKeyToServer; +use App\Exceptions\SSHError; use App\Http\Controllers\Controller; use App\Http\Resources\SshKeyResource; use App\Models\Project; @@ -41,6 +42,9 @@ public function index(Project $project, Server $server): ResourceCollection return SshKeyResource::collection($server->sshKeys()->simplePaginate(25)); } + /** + * @throws SSHError + */ #[Post('/', name: 'api.projects.servers.ssh-keys.create', middleware: 'ability:write')] #[Endpoint(title: 'create', description: 'Deploy ssh key to server.')] #[BodyParam(name: 'key_id', description: 'The ID of the key.')] @@ -58,9 +62,7 @@ public function create(Request $request, Project $project, Server $server): SshK $sshKey = null; if ($request->has('key_id')) { - $this->validate($request, DeployKeyToServer::rules($user, $server)); - - /** @var ?SshKey $sshKey */ + /** @var SshKey $sshKey */ $sshKey = $user->sshKeys()->findOrFail($request->key_id); } @@ -69,7 +71,7 @@ public function create(Request $request, Project $project, Server $server): SshK $sshKey = app(CreateSshKey::class)->create($user, $request->all()); } - app(DeployKeyToServer::class)->deploy($server, ['key_id' => $sshKey->id]); + app(DeployKeyToServer::class)->deploy($server, $sshKey); return new SshKeyResource($sshKey); } diff --git a/app/Http/Controllers/ServerSshKeyController.php b/app/Http/Controllers/ServerSshKeyController.php new file mode 100644 index 00000000..6b420770 --- /dev/null +++ b/app/Http/Controllers/ServerSshKeyController.php @@ -0,0 +1,63 @@ +authorize('viewAnyServer', [SshKey::class, $server]); + + return Inertia::render('server-ssh-keys/index', [ + 'sshKeys' => SshKeyResource::collection($server->sshKeys()->with('user')->simplePaginate(config('web.pagination_size'))), + ]); + } + + /** + * @throws SSHError + */ + #[Post('/', name: 'server-ssh-keys.store')] + public function store(Request $request, Server $server): RedirectResponse + { + $this->authorize('createServer', [SshKey::class, $server]); + + /** @var SshKey $sshKey */ + $sshKey = user()->sshKeys()->findOrFail($request->input('key')); + + app(DeployKeyToServer::class)->deploy($server, $sshKey); + + return back()->with('success', 'SSH key deployed.'); + } + + /** + * @throws SSHError + */ + #[Delete('/{sshKey}', name: 'server-ssh-keys.destroy')] + public function destroy(Server $server, SshKey $sshKey): RedirectResponse + { + $this->authorize('deleteServer', [SshKey::class, $server]); + + app(DeleteKeyFromServer::class)->delete($server, $sshKey); + + return back()->with('success', 'SSH key deleted.'); + } +} diff --git a/app/Http/Controllers/SSHKeyController.php b/app/Http/Controllers/SshKeyController.php similarity index 81% rename from app/Http/Controllers/SSHKeyController.php rename to app/Http/Controllers/SshKeyController.php index 9bf88d9d..9ea11e02 100644 --- a/app/Http/Controllers/SSHKeyController.php +++ b/app/Http/Controllers/SshKeyController.php @@ -7,6 +7,7 @@ use App\Models\SshKey; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\ResourceCollection; use Inertia\Inertia; use Inertia\Response; use Spatie\RouteAttributes\Attributes\Delete; @@ -17,7 +18,7 @@ #[Prefix('settings/ssh-keys')] #[Middleware(['auth'])] -class SSHKeyController extends Controller +class SshKeyController extends Controller { #[Get('/', name: 'ssh-keys')] public function index(): Response @@ -29,6 +30,14 @@ public function index(): Response ]); } + #[Get('/json', name: 'ssh-keys.json')] + public function json(): ResourceCollection + { + $this->authorize('viewAny', SshKey::class); + + return SshKeyResource::collection(user()->sshKeys()->get()); + } + #[Post('/', name: 'ssh-keys.store')] public function store(Request $request): RedirectResponse { diff --git a/app/Http/Resources/SshKeyResource.php b/app/Http/Resources/SshKeyResource.php index a1ae9eba..a202bab0 100644 --- a/app/Http/Resources/SshKeyResource.php +++ b/app/Http/Resources/SshKeyResource.php @@ -16,7 +16,7 @@ public function toArray(Request $request): array { return [ 'id' => $this->id, - 'user' => $this->user_id ? new UserResource($this->user) : null, + 'user' => new UserResource($this->whenLoaded('user')), 'name' => $this->name, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, diff --git a/app/Models/SshKey.php b/app/Models/SshKey.php index 71c81d02..70af986b 100644 --- a/app/Models/SshKey.php +++ b/app/Models/SshKey.php @@ -2,6 +2,7 @@ namespace App\Models; +use Database\Factories\SshKeyFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -16,7 +17,7 @@ */ class SshKey extends AbstractModel { - /** @use HasFactory<\Database\Factories\SshKeyFactory> */ + /** @use HasFactory */ use HasFactory; use SoftDeletes; diff --git a/resources/js/components/header-container.tsx b/resources/js/components/header-container.tsx index 21452fbf..b0151bfe 100644 --- a/resources/js/components/header-container.tsx +++ b/resources/js/components/header-container.tsx @@ -1,5 +1,5 @@ import { ReactNode } from 'react'; export default function HeaderContainer({ children }: { children: ReactNode }) { - return
{children}
; + return
{children}
; } diff --git a/resources/js/components/log-output.tsx b/resources/js/components/log-output.tsx index aee4ec2b..8cec559f 100644 --- a/resources/js/components/log-output.tsx +++ b/resources/js/components/log-output.tsx @@ -20,9 +20,12 @@ export default function LogOutput({ children }: { children: ReactNode }) { }; return ( -
- - {children} +
+ +
{children}
@@ -37,7 +40,7 @@ export default function LogOutput({ children }: { children: ReactNode }) {
{autoScroll ? : }
- {autoScroll ? 'Turn off auto scroll' : 'Auto scroll down'} + {autoScroll ? 'Turn off auto scroll' : 'Auto scroll down'}
diff --git a/resources/js/components/ui/dialog.tsx b/resources/js/components/ui/dialog.tsx index 2d7d74f3..b2a5b668 100644 --- a/resources/js/components/ui/dialog.tsx +++ b/resources/js/components/ui/dialog.tsx @@ -40,7 +40,7 @@ function DialogContent({ className, children, ...props }: React.ComponentProps
+ + + + + + + + + + ); +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'user', + header: 'User', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ( + + {row.original.user?.name} ({row.original.user?.email}) + + ); + }, + }, + { + 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/server-ssh-keys/components/deploy-key.tsx b/resources/js/pages/server-ssh-keys/components/deploy-key.tsx new file mode 100644 index 00000000..1fd5f25b --- /dev/null +++ b/resources/js/pages/server-ssh-keys/components/deploy-key.tsx @@ -0,0 +1,75 @@ +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, 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 { Server } from '@/types/server'; +import SshKeySelect from '@/pages/ssh-keys/components/ssh-key-select'; + +export default function DeployKey({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const page = usePage<{ + server: Server; + }>(); + + const form = useForm< + Required<{ + key: string; + }> + >({ + key: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + form.post(route('server-ssh-keys.store', { server: page.props.server.id }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + + return ( + + {children} + + + Deploy ssh key + Deploy ssh key + +
+ + + + form.setData('key', value)} /> + + + +
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/server-ssh-keys/index.tsx b/resources/js/pages/server-ssh-keys/index.tsx new file mode 100644 index 00000000..7f8e41b3 --- /dev/null +++ b/resources/js/pages/server-ssh-keys/index.tsx @@ -0,0 +1,47 @@ +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 { SshKey } from '@/types/ssh-key'; +import { columns } from '@/pages/server-ssh-keys/components/columns'; +import { PaginatedData } from '@/types'; +import DeployKey from '@/pages/server-ssh-keys/components/deploy-key'; +import ServerLayout from '@/layouts/server/layout'; +import HeaderContainer from '@/components/header-container'; +import { BookOpenIcon, RocketIcon } from 'lucide-react'; + +type Page = { + sshKeys: PaginatedData; +}; + +export default function SshKeys() { + const page = usePage(); + + return ( + + + + + +
+ + + + + + +
+
+ + +
+
+ ); +} diff --git a/resources/js/pages/ssh-keys/components/add-ssh-key.tsx b/resources/js/pages/ssh-keys/components/add-ssh-key.tsx index 2b98415b..3ca7fe7d 100644 --- a/resources/js/pages/ssh-keys/components/add-ssh-key.tsx +++ b/resources/js/pages/ssh-keys/components/add-ssh-key.tsx @@ -23,7 +23,7 @@ type SshKeyForm = { public_key: string; }; -export default function AddSshKey({ children }: { children: ReactNode }) { +export default function AddSshKey({ children, onKeyAdded }: { children: ReactNode; onKeyAdded?: () => void }) { const [open, setOpen] = useState(false); const form = useForm>({ @@ -36,6 +36,9 @@ export default function AddSshKey({ children }: { children: ReactNode }) { form.post(route('ssh-keys.store'), { onSuccess: () => { setOpen(false); + if (onKeyAdded) { + onKeyAdded(); + } }, }); }; diff --git a/resources/js/pages/ssh-keys/components/columns.tsx b/resources/js/pages/ssh-keys/components/columns.tsx index 33b73309..60b93adb 100644 --- a/resources/js/pages/ssh-keys/components/columns.tsx +++ b/resources/js/pages/ssh-keys/components/columns.tsx @@ -16,9 +16,9 @@ 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'; +import { SshKey } from '@/types/ssh-key'; -function Delete({ sshKey }: { sshKey: SSHKey }) { +function Delete({ sshKey }: { sshKey: SshKey }) { const [open, setOpen] = useState(false); const form = useForm(); @@ -57,7 +57,7 @@ function Delete({ sshKey }: { sshKey: SSHKey }) { ); } -export const columns: ColumnDef[] = [ +export const columns: ColumnDef[] = [ { accessorKey: 'id', header: 'ID', diff --git a/resources/js/pages/ssh-keys/components/ssh-key-select.tsx b/resources/js/pages/ssh-keys/components/ssh-key-select.tsx new file mode 100644 index 00000000..7d59740b --- /dev/null +++ b/resources/js/pages/ssh-keys/components/ssh-key-select.tsx @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import React from 'react'; +import { SelectTriggerProps } from '@radix-ui/react-select'; +import { SshKey } from '@/types/ssh-key'; +import AddSshKey from '@/pages/ssh-keys/components/add-ssh-key'; +import { Button } from '@/components/ui/button'; +import { PlusIcon } from 'lucide-react'; + +export default function SshKeySelect({ + value, + onValueChange, + ...props +}: { + value: string; + onValueChange: (value: string) => void; +} & SelectTriggerProps) { + const query = useQuery({ + queryKey: ['sshKey'], + queryFn: async () => { + return (await axios.get(route('ssh-keys.json'))).data; + }, + }); + + return ( +
+ + query.refetch()}> + + +
+ ); +} diff --git a/resources/js/pages/ssh-keys/index.tsx b/resources/js/pages/ssh-keys/index.tsx index 882637d1..25b09286 100644 --- a/resources/js/pages/ssh-keys/index.tsx +++ b/resources/js/pages/ssh-keys/index.tsx @@ -4,13 +4,13 @@ import Container from '@/components/container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; import { DataTable } from '@/components/data-table'; -import { SSHKey } from '@/types/ssh-key'; +import { SshKey } from '@/types/ssh-key'; import { columns } from '@/pages/ssh-keys/components/columns'; import AddSshKey from '@/pages/ssh-keys/components/add-ssh-key'; import { PaginatedData } from '@/types'; type Page = { - sshKeys: PaginatedData; + sshKeys: PaginatedData; }; export default function SshKeys() { diff --git a/resources/js/pages/workers/index.tsx b/resources/js/pages/workers/index.tsx index 096769f7..7561e424 100644 --- a/resources/js/pages/workers/index.tsx +++ b/resources/js/pages/workers/index.tsx @@ -5,7 +5,7 @@ import ServerLayout from '@/layouts/server/layout'; import HeaderContainer from '@/components/header-container'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; -import { PlusIcon } from 'lucide-react'; +import { BookOpenIcon, PlusIcon } from 'lucide-react'; import Container from '@/components/container'; import { DataTable } from '@/components/data-table'; import { Worker } from '@/types/worker'; @@ -26,6 +26,12 @@ export default function WorkerIndex() {
+ + +