diff --git a/app/Actions/Accounts/CreateAccountAction.php b/app/Actions/Accounts/CreateAccountAction.php
deleted file mode 100644
index 197e871..0000000
--- a/app/Actions/Accounts/CreateAccountAction.php
+++ /dev/null
@@ -1,20 +0,0 @@
-email);
- }
- }
-}
diff --git a/app/Actions/Filemanager/RenameFileAction.php b/app/Actions/Filemanager/RenameFileAction.php
index 86c6140..e5508c0 100644
--- a/app/Actions/Filemanager/RenameFileAction.php
+++ b/app/Actions/Filemanager/RenameFileAction.php
@@ -26,6 +26,11 @@ class RenameFileAction
// get path from currentName
$path = dirname($r->currentName) == "." ? '' : dirname($r->currentName) . '/';
+ $newPath = $path . $r->newName;
+
+ if ($filesystem->fileExists($newPath) || $filesystem->directoryExists($newPath)) {
+ throw new \Exception('Target ' . $newPath . ' already exists!');
+ }
$filesystem->move($r->currentName, $path . $r->newName);
@@ -39,3 +44,4 @@ class RenameFileAction
};
}
}
+
diff --git a/app/Events/SystemStatsEvent.php b/app/Events/SystemStatsEvent.php
index f155141..6d093c8 100755
--- a/app/Events/SystemStatsEvent.php
+++ b/app/Events/SystemStatsEvent.php
@@ -2,7 +2,7 @@
namespace App\Events;
-use App\Services\SystemStatsService;
+use App\Services\Dashboard\SystemStatsService;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
diff --git a/app/Events/TopStatsEvent.php b/app/Events/TopStatsEvent.php
index 0ad1a40..ce8d266 100755
--- a/app/Events/TopStatsEvent.php
+++ b/app/Events/TopStatsEvent.php
@@ -2,7 +2,7 @@
namespace App\Events;
-use App\Services\TopCommandService;
+use App\Services\Dashboard\TopCommandService;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBeUnique;
diff --git a/app/Http/Controllers/AccountsController.php b/app/Http/Controllers/AccountsController.php
index c36d5c7..e37c620 100644
--- a/app/Http/Controllers/AccountsController.php
+++ b/app/Http/Controllers/AccountsController.php
@@ -2,9 +2,13 @@
namespace App\Http\Controllers;
-use App\Actions\Accounts\CreateAccountAction;
use App\Http\Requests\CreateAccountRequest;
use App\Models\User;
+use App\Services\Accounts\CreateAccountException;
+use App\Services\Accounts\CreateAccountService;
+use App\Services\Accounts\DeleteAccountService;
+use Exception;
+use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -13,7 +17,7 @@ class AccountsController extends Controller
/**
* Display a listing of the resource.
*/
- public function index()
+ public function index(): \Inertia\Response
{
$accounts = User::all();
return Inertia::render('Accounts/Index', compact('accounts'));
@@ -22,11 +26,22 @@ class AccountsController extends Controller
/**
* Store a newly created resource in storage.
*/
- public function store(CreateAccountRequest $request, CreateAccountAction $createAccount)
+ public function store(CreateAccountRequest $request): RedirectResponse
{
- $createAccount->execute($request->validated());
+ try {
- return redirect()->route('accounts.index');
+ (new CreateAccountService($request->validated()))->handle();
+
+ session()->flash('success', 'Account created successfully!');
+
+ return redirect()->route('accounts.index');
+ } catch (CreateAccountException $e) {
+ session()->flash('error', $e->getMessage());
+ return back();
+ } catch (Exception $e) {
+ session()->flash('error', $e->getMessage());
+ return back();
+ }
}
@@ -41,9 +56,9 @@ class AccountsController extends Controller
/**
* Remove the specified resource from storage.
*/
- public function destroy($account)
+ public function destroy($account): RedirectResponse
{
- User::findOrFail($account)->delete();
+ (new DeleteAccountService(User::findOrFail($account)))->handle();
return redirect()->route('accounts.index');
}
@@ -51,7 +66,7 @@ class AccountsController extends Controller
/**
* Impersonate a user
*/
- public function impersonate(User $user)
+ public function impersonate(User $user): RedirectResponse
{
auth()->user()->impersonate($user);
return redirect()->route('dashboard');
@@ -60,7 +75,7 @@ class AccountsController extends Controller
/**
* Leave impersonation
*/
- public function leaveImpersonation()
+ public function leaveImpersonation(): RedirectResponse
{
auth()->user()->leaveImpersonation();
return redirect()->route('dashboard');
diff --git a/app/Http/Controllers/StatsHistoryController.php b/app/Http/Controllers/StatsHistoryController.php
index 1c824df..69e61b9 100644
--- a/app/Http/Controllers/StatsHistoryController.php
+++ b/app/Http/Controllers/StatsHistoryController.php
@@ -2,9 +2,9 @@
namespace App\Http\Controllers;
-use App\Services\CPUHistoryService;
-use App\Services\MemoryHistoryService;
-use App\Services\NetworkHistoryService;
+use App\Services\Dashboard\CPUHistoryService;
+use App\Services\Dashboard\MemoryHistoryService;
+use App\Services\Dashboard\NetworkHistoryService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Inertia\Inertia;
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
index dd76841..e5d02c0 100644
--- a/app/Http/Middleware/HandleInertiaRequests.php
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -35,6 +35,10 @@ class HandleInertiaRequests extends Middleware
'user' => $request->user(),
'isImpersonating' => app('impersonate')->isImpersonating(),
],
+ 'flash' => [
+ 'success' => session('success'),
+ 'error' => session('error'),
+ ],
];
}
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 7de56ac..a4d4c84 100755
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -3,6 +3,8 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
+
+use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -49,6 +51,7 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
+ 'ssh_access' => 'boolean',
];
}
@@ -60,8 +63,28 @@ class User extends Authenticatable
return $this->isAdmin();
}
+ /**
+ * @return bool
+ */
public function isAdmin()
{
return $this->role === 'admin';
}
+
+ /**
+ * @return Attribute
+ */
+ public function homedir(): Attribute
+ {
+ return Attribute::make(
+ get: fn() => '/home/' . $this->systemUsername,
+ );
+ }
+
+ public function systemUsername(): Attribute
+ {
+ return Attribute::make(
+ get: fn() => $this->username . '_ln',
+ );
+ }
}
diff --git a/app/Services/Accounts/CreateAccountService.php b/app/Services/Accounts/CreateAccountService.php
new file mode 100644
index 0000000..0d56872
--- /dev/null
+++ b/app/Services/Accounts/CreateAccountService.php
@@ -0,0 +1,62 @@
+laranodeBinPath = '/usr/local/bin/laranode';
+
+ // appends _ln to all users to avoid all sort of issues (conflicts, control, security, files, etc.)
+ $this->systemUsername = $validated['username'] . '_ln';
+ }
+
+ public function handle(): void
+ {
+ // create system user
+ $this->createSystemUser();
+
+ // only after that add the user to the database
+ $user = User::create($this->validated);
+
+ event(new Registered($user));
+
+ // notify user if requested
+ // @todo: implement notification (mail)
+ if (isset($validated['notify']) && $validated['notify']) {
+ \Illuminate\Support\Facades\Log::info('Would notify ' . $user->email);
+ }
+ }
+
+ private function createSystemUser(): void
+ {
+
+ $createUser = Process::run([
+ 'sudo',
+ $this->laranodeBinPath . '/laranode-user-manager.sh',
+ 'create',
+ $this->systemUsername,
+ $this->validated['ssh_access'] ? 'yes' : 'no',
+ $this->validated['ssh_access'] ? $this->validated['password'] : null,
+ ]);
+
+ if ($createUser->failed()) {
+ throw new CreateAccountException('Failed to create system user: ' . $createUser->errorOutput());
+ }
+ }
+
+ // @TODO: implement add user php-fpm pools based on each php version
+ private function addPhpFpmPools(): void {}
+}
diff --git a/app/Services/Accounts/DeleteAccountService.php b/app/Services/Accounts/DeleteAccountService.php
new file mode 100644
index 0000000..52eb3e9
--- /dev/null
+++ b/app/Services/Accounts/DeleteAccountService.php
@@ -0,0 +1,47 @@
+laranodeBinPath = '/usr/local/bin/laranode';
+ }
+
+ public function handle(): void
+ {
+ // delete system user
+ $this->deleteSystemUser();
+
+ User::findOrFail($this->user->id)->delete();
+ }
+
+ private function deleteSystemUser(): void
+ {
+ $deleteUser = Process::run([
+ 'sudo',
+ $this->laranodeBinPath . '/laranode-user-manager.sh',
+ 'delete',
+ $this->user->systemUsername,
+ ]);
+
+ if ($deleteUser->failed()) {
+ throw new DeleteAccountException('Failed to delete system user: ' . $deleteUser->errorOutput());
+ }
+ }
+
+ // @TODO: implement delete all DB's of this user
+ private function deleteDatabases(): void {}
+
+ // @TODO: implement delete user php-fpm pools
+ private function deletePhpFpmPools(): void {}
+}
diff --git a/app/Services/CPUHistoryService.php b/app/Services/Dashboard/CPUHistoryService.php
similarity index 95%
rename from app/Services/CPUHistoryService.php
rename to app/Services/Dashboard/CPUHistoryService.php
index 9a99e8b..3f3c891 100644
--- a/app/Services/CPUHistoryService.php
+++ b/app/Services/Dashboard/CPUHistoryService.php
@@ -1,6 +1,6 @@
{
+ if (flash.success) {
+ toast(flash.success, { type: 'success' });
+ }
+
+ if (flash.error) {
+ toast(flash.error, { type: 'error' });
+ }
+ }, [flash]);
+
+
return (
diff --git a/resources/js/Pages/Accounts/Index.jsx b/resources/js/Pages/Accounts/Index.jsx
index bb4b1fc..3f9a186 100644
--- a/resources/js/Pages/Accounts/Index.jsx
+++ b/resources/js/Pages/Accounts/Index.jsx
@@ -85,7 +85,7 @@ export default function Accounts({ accounts }) {
- {account.ssh_access ? No : Yes}
+ {!account.ssh_access ? No : Yes}
|
{account.role == "admin" ? Admin : User}
diff --git a/resources/js/Pages/Accounts/Partials/CreateAccountForm.jsx b/resources/js/Pages/Accounts/Partials/CreateAccountForm.jsx
index bb4ef6b..c42c688 100644
--- a/resources/js/Pages/Accounts/Partials/CreateAccountForm.jsx
+++ b/resources/js/Pages/Accounts/Partials/CreateAccountForm.jsx
@@ -61,7 +61,7 @@ export default function CreateAccountForm() {
closeModal();
reset();
toast("Account created successfully.");
- }
+ },
});
};
diff --git a/tests/Feature/Filemanager/DeleteFileTest.php b/tests/Feature/Filemanager/DeleteFileTest.php
new file mode 100644
index 0000000..77f25c9
--- /dev/null
+++ b/tests/Feature/Filemanager/DeleteFileTest.php
@@ -0,0 +1,108 @@
+filesystem = new Filesystem(new InMemoryFilesystemAdapter());
+ $this->action = new DeleteFilesAction($this->filesystem);
+
+ // Set up test directory structure
+ $this->filesystem->write('file1.txt', 'content');
+ $this->filesystem->write('file2.txt', 'content');
+ $this->filesystem->createDirectory('folder1');
+ $this->filesystem->write('folder1/inside.txt', 'content');
+ $this->filesystem->createDirectory('folder2');
+ $this->filesystem->write('folder2/test.txt', 'content');
+});
+
+test('it can delete a single file', function () {
+ $request = Request::create('', 'POST', [
+ 'filesToDelete' => ['file1.txt']
+ ]);
+
+ $response = $this->action->execute($request);
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($this->filesystem->fileExists('file1.txt'))->toBeFalse()
+ ->and(json_decode($response->getContent()))->toHaveProperty('message', 'Files deleted successfully!');
+});
+
+test('it can delete multiple files', function () {
+ $request = Request::create('', 'POST', [
+ 'filesToDelete' => ['file1.txt', 'file2.txt']
+ ]);
+
+ $response = $this->action->execute($request);
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($this->filesystem->fileExists('file1.txt'))->toBeFalse()
+ ->and($this->filesystem->fileExists('file2.txt'))->toBeFalse()
+ ->and(json_decode($response->getContent()))->toHaveProperty('message', 'Files deleted successfully!');
+});
+
+test('it can delete a directory and its contents', function () {
+ $request = Request::create('', 'POST', [
+ 'filesToDelete' => ['folder1']
+ ]);
+
+ $response = $this->action->execute($request);
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($this->filesystem->directoryExists('folder1'))->toBeFalse()
+ ->and($this->filesystem->fileExists('folder1/inside.txt'))->toBeFalse()
+ ->and(json_decode($response->getContent()))->toHaveProperty('message', 'Files deleted successfully!');
+});
+
+test('it can delete multiple directories and files together', function () {
+ $request = Request::create('', 'POST', [
+ 'filesToDelete' => ['folder1', 'file1.txt', 'folder2']
+ ]);
+
+ $response = $this->action->execute($request);
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($this->filesystem->directoryExists('folder1'))->toBeFalse()
+ ->and($this->filesystem->fileExists('file1.txt'))->toBeFalse()
+ ->and($this->filesystem->directoryExists('folder2'))->toBeFalse();
+});
+
+test('it fails when file does not exist', function () {
+ $request = Request::create('', 'POST', [
+ 'filesToDelete' => ['non-existent.txt']
+ ]);
+
+ $response = $this->action->execute($request);
+
+ expect($response->getStatusCode())->toBe(500)
+ ->and(json_decode($response->getContent()))->toHaveProperty('error', 'non-existent.txt does not exist!');
+});
+
+test('it fails when directory does not exist', function () {
+ $request = Request::create('', 'POST', [
+ 'filesToDelete' => ['non-existent-folder']
+ ]);
+
+ $response = $this->action->execute($request);
+
+ expect($response->getStatusCode())->toBe(500)
+ ->and(json_decode($response->getContent()))->toHaveProperty('error', 'non-existent-folder does not exist!');
+});
+
+test('it fails validation when filesToDelete is not provided', function () {
+ $request = Request::create('', 'POST', []);
+
+ expect(fn() => $this->action->execute($request))
+ ->toThrow(Illuminate\Validation\ValidationException::class);
+});
+
+test('it fails validation when filesToDelete is not an array', function () {
+ $request = Request::create('', 'POST', [
+ 'filesToDelete' => 'not-an-array'
+ ]);
+
+ expect(fn() => $this->action->execute($request))
+ ->toThrow(Illuminate\Validation\ValidationException::class);
+});
diff --git a/tests/Feature/Filemanager/GetDirectoryContentsTest.php b/tests/Feature/Filemanager/GetDirectoryContentsTest.php
index 5081e94..55e6d05 100644
--- a/tests/Feature/Filemanager/GetDirectoryContentsTest.php
+++ b/tests/Feature/Filemanager/GetDirectoryContentsTest.php
@@ -3,12 +3,12 @@
use App\Actions\Filemanager\GetDirectoryContentsAction;
use League\Flysystem\Filesystem;
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
+use Symfony\Component\HttpFoundation\StreamedResponse;
beforeEach(function () {
$this->filesystem = new Filesystem(new InMemoryFilesystemAdapter());
$this->action = new GetDirectoryContentsAction($this->filesystem);
- // Set up a test directory structure
$this->filesystem->write('file1.txt', 'content');
$this->filesystem->write('file2.txt', 'content');
$this->filesystem->createDirectory('folder1');
@@ -17,53 +17,59 @@ beforeEach(function () {
$this->filesystem->write('folder1/subfolder/deep.txt', 'content');
});
+// Helper function to capture streamed response content
+function captureStreamedContent($response): array
+{
+ if ($response instanceof StreamedResponse) {
+ ob_start();
+ $response->sendContent();
+ $content = ob_get_clean();
+ return json_decode($content, true);
+ }
+ return json_decode($response->getContent(), true);
+}
+
test('it lists contents of root directory', function () {
- $response = $this->action->execute(null);
- $content = json_decode($response->getContent(), true);
+ $response = $this->action->execute('/');
+ $content = captureStreamedContent($response);
expect($response->getStatusCode())->toBe(200)
->and($content)->toHaveKey('files')
->and($content)->toHaveKey('goBack')
- ->and($content['goBack'])->toBe('/')
- ->and(collect($content['files'])->pluck('path')->all())->toContain('file1.txt')
- ->and(collect($content['files'])->pluck('path')->all())->toContain('file2.txt')
- ->and(collect($content['files'])->pluck('path')->all())->toContain('folder1');
+ ->and($content['goBack'])->toBe('')
+ ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('file1.txt')
+ ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('file2.txt')
+ ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('folder1');
});
test('it lists contents of a subdirectory', function () {
$response = $this->action->execute('folder1');
- $content = json_decode($response->getContent(), true);
+ $content = captureStreamedContent($response);
expect($response->getStatusCode())->toBe(200)
->and($content)->toHaveKey('files')
->and($content)->toHaveKey('goBack')
->and($content['goBack'])->toBe('/')
- ->and(collect($content['files'])->pluck('path')->all())->toContain('folder1/inside1.txt')
- ->and(collect($content['files'])->pluck('path')->all())->toContain('folder1/subfolder');
+ ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('folder1/inside1.txt')
+ ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('folder1/subfolder');
});
test('it handles nested directory navigation', function () {
$response = $this->action->execute('folder1/subfolder');
- $content = json_decode($response->getContent(), true);
+ $content = captureStreamedContent($response);
expect($response->getStatusCode())->toBe(200)
->and($content)->toHaveKey('files')
->and($content)->toHaveKey('goBack')
->and($content['goBack'])->toBe('folder1')
- ->and(collect($content['files'])->pluck('path')->all())->toContain('folder1/subfolder/deep.txt');
+ ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('folder1/subfolder/deep.txt');
});
-test('it handles non-existent directory', function () {
- $response = $this->action->execute('non-existent-folder');
-
- expect($response->getStatusCode())->toBe(500)
- ->and(json_decode($response->getContent(), true))->toHaveKey('error');
-});
test('it handles empty directory', function () {
$this->filesystem->createDirectory('empty-folder');
$response = $this->action->execute('empty-folder');
- $content = json_decode($response->getContent(), true);
+ $content = captureStreamedContent($response);
expect($response->getStatusCode())->toBe(200)
->and($content)->toHaveKey('files')
@@ -72,9 +78,9 @@ test('it handles empty directory', function () {
test('it returns non-recursive listing', function () {
$response = $this->action->execute('folder1');
- $content = json_decode($response->getContent(), true);
+ $content = captureStreamedContent($response);
- expect(collect($content['files'])->pluck('path')->all())
+ expect(collect($content['files'])->map(fn($file) => $file['path'])->all())
->not->toContain('folder1/subfolder/deep.txt');
});
@@ -82,6 +88,7 @@ test('it properly handles directory traversal attempts', function () {
$response = $this->action->execute('../some/path');
$content = json_decode($response->getContent(), true);
+ // The action should treat this as a regular path and fail to find it
expect($response->getStatusCode())->toBe(500)
->and($content)->toHaveKey('error');
});
diff --git a/tests/Feature/Filemanager/RenameFileTest.php b/tests/Feature/Filemanager/RenameFileTest.php
new file mode 100644
index 0000000..f3ba7df
--- /dev/null
+++ b/tests/Feature/Filemanager/RenameFileTest.php
@@ -0,0 +1,40 @@
+createDirectory('test-folder');
+ $filesystem->write('test-folder/inside.txt', 'test content');
+
+ $request = Request::create('', 'POST', [
+ 'currentName' => 'test-folder',
+ 'newName' => 'renamed-folder'
+ ]);
+
+ // Let's see what's happening
+ try {
+ $response = $action->execute($request);
+
+ dump($response->getStatusCode());
+
+ // Dump response content if there's an error
+ if ($response->getStatusCode() === 500) {
+ dump(json_decode($response->getContent(), true));
+ }
+ } catch (\Exception $e) {
+ dump($e->getMessage());
+ }
+
+ expect($response->getStatusCode())->toBe(200)
+ ->and($filesystem->directoryExists('test-folder'))->toBeFalse()
+ ->and($filesystem->directoryExists('renamed-folder'))->toBeTrue()
+ ->and($filesystem->fileExists('renamed-folder/inside.txt'))->toBeTrue();
+});
+
diff --git a/tests/Feature/TopCommandServiceTest.php b/tests/Feature/TopCommandServiceTest.php
index fa94205..cb3da2c 100644
--- a/tests/Feature/TopCommandServiceTest.php
+++ b/tests/Feature/TopCommandServiceTest.php
@@ -1,6 +1,6 @@
|