edit user account in progress -> requires shell and system user password updating

This commit is contained in:
Alex Crivion
2025-02-24 07:15:30 +00:00
parent 0e22b669c9
commit 6e38bc8100
20 changed files with 432 additions and 16 deletions

View File

@@ -3,10 +3,12 @@
namespace App\Http\Controllers;
use App\Http\Requests\CreateAccountRequest;
use App\Http\Requests\EditAccountRequest;
use App\Models\User;
use App\Services\Accounts\CreateAccountException;
use App\Services\Accounts\CreateAccountService;
use App\Services\Accounts\DeleteAccountService;
use App\Services\Accounts\UpdateAccountService;
use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -39,11 +41,16 @@ class AccountsController extends Controller
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id)
public function update(User $account, EditAccountRequest $request): RedirectResponse
{
//
(new UpdateAccountService($account, $request->validated()))->handle();
session()->flash('success', 'Account updated successfully!');
return redirect()->route('accounts.index');
}
/**
* Remove the specified resource from storage.
*/

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class EditAccountRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return auth()->user()->isAdmin();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'id' => ['required', 'integer', 'exists:users,id'],
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->id)],
'new_password' => ['nullable', Password::defaults()],
'role' => ['required', 'string', 'in:admin,user'],
'domain_limit' => ['nullable', 'integer', 'min:1'],
'database_limit' => ['nullable', 'integer', 'min:1'],
'ssh_access' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Services\Accounts;
use App\Models\PhpVersion;
use App\Models\User;
use App\Services\Laranode\CreatePhpFpmPoolService;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Process;
use Exception;
class UpdateAccountException extends Exception {}
class UpdateAccountService
{
public function __construct(private User $account, private array $validated) {}
public function handle(): void
{
$this->account->update($this->validated);
$this->updateUserShell($this->account->ssh_access);
$this->updatePasswordIfRequested();
}
private function updatePasswordIfRequested(): void
{
if (!empty($this->validated['new_password'])) {
$this->account->password = $this->validated['new_password'];
$this->account->save();
$this->updateSystemUserPasssword();
}
}
private function updateSystemUserPasssword(): void
{
$this->account->refresh();
if ($this->account->ssh_access) {
// TODO: call laranode password update manager
}
}
private function updateUserShell(): void
{
// TODO: call laranode user manager
if ($this->account->ssh_access != $this->validated['ssh_access']) {
if ($this->validated['ssh_access']) {
// add shell access
} else {
// remove shell access
}
}
}
}

BIN
public/favico.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1,24 +1,32 @@
import { Link, usePage } from "@inertiajs/react";
import { RiDashboard3Fill } from "react-icons/ri";
import { useState } from "react";
import { RiDashboard3Fill, RiMvFill } from "react-icons/ri";
import { ImProfile } from "react-icons/im";
import { FaPhp, FaUsers } from "react-icons/fa6";
import { VscFileSubmodule } from "react-icons/vsc";
import { TbBrandMysql } from "react-icons/tb";
import { MdSecurity } from "react-icons/md";
import { IoLockClosedOutline } from "react-icons/io5";
import { TbWorldWww } from "react-icons/tb";
const SidebarNavi = () => {
const { auth } = usePage().props;
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (<div className="fixed flex flex-col top-14 left-0 w-14 hover:w-64 md:w-64 bg-gray-950 dark:bg-gray-900 h-full text-white transition-all duration-300 border-none z-10 sidebar">
return (<div className={`fixed flex flex-col top-14 left-0 ${isSidebarOpen ? 'w-64' : 'w-14'} md:w-64 bg-gray-950 dark:bg-gray-900 h-full text-white transition-all duration-300 border-none z-10 sidebar`}>
<div className="overflow-y-auto overflow-x-hidden flex flex-col justify-between flex-grow dark:border-gray-800 dark:border-r">
<ul className="flex flex-col py-4 space-y-2">
<li className="hidden md:block">
<li>
<div className="flex flex-row items-center h-8">
<div className="text-sm font-light tracking-wide text-gray-400 uppercase ml-4">
<div className="text-sm font-light tracking-wide text-gray-400 uppercase ml-4 hidden md:block">
Menu
</div>
<div>
<button onClick={() => setIsSidebarOpen(!isSidebarOpen)} className="text-gray-400 ml-4 block md:hidden">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd"></path></svg>
</button>
</div>
</div>
</li>
@@ -38,7 +46,7 @@ const SidebarNavi = () => {
{auth.user.role == 'admin' && (
<li>
<Link
to={route('accounts.index')}
href={route('accounts.index')}
className="relative flex flex-row items-center h-11 focus:outline-none hover:bg-gray-900 text-gray-300 border-l-4 border-transparent hover:border-indigo-900 pr-6"
>
<div>
@@ -49,6 +57,17 @@ const SidebarNavi = () => {
</li>
)}
<li>
<Link
href="/domains"
className="relative flex flex-row items-center h-11 focus:outline-none hover:bg-gray-900 text-gray-300 border-l-4 border-transparent hover:border-indigo-900 pr-6"
>
<div>
<TbWorldWww className="ml-3 w-5 h-5" />
</div>
<span className="ml-2 text-sm tracking-wide truncate">Domains</span>
</Link>
</li>
{auth.user.role == 'admin' && (
<li>
@@ -78,7 +97,7 @@ const SidebarNavi = () => {
<li>
<Link
to="/mysql"
href="/mysql"
className="relative flex flex-row items-center h-11 focus:outline-none hover:bg-gray-900 text-gray-300 border-l-4 border-transparent hover:border-indigo-900 pr-6"
>
<div>
@@ -91,7 +110,7 @@ const SidebarNavi = () => {
{auth.user.role == 'admin' && (
<li>
<Link
to="/php-manager"
href="/php-manager"
className="relative flex flex-row items-center h-11 focus:outline-none hover:bg-gray-900 text-gray-300 border-l-4 border-transparent hover:border-indigo-900 pr-6"
>
<div>

View File

@@ -2,6 +2,7 @@ import { FaUsers } from "react-icons/fa6";
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link } from '@inertiajs/react';
import CreateUserForm from './Partials/CreateAccountForm';
import EditAccountForm from './Partials/EditAccountForm';
import ConfirmationButton from "@/Components/ConfirmationButton";
import { TiDelete } from "react-icons/ti";
import { toast } from "react-toastify";
@@ -9,10 +10,10 @@ import { router } from '@inertiajs/react'
import { TbWorldWww } from "react-icons/tb";
import { RiLoginCircleLine } from "react-icons/ri";
import { FaDatabase, FaEdit } from "react-icons/fa";
import { Tooltip } from 'react-tooltip'
export default function Accounts({ accounts }) {
const deleteUser = (id) => {
router.delete(route('accounts.destroy', { account: id }), {
onBefore: () => {
@@ -74,13 +75,13 @@ export default function Accounts({ accounts }) {
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
<div className='flex items-center'>
<FaDatabase className='w-4 h-4 mr-1' />
<span className="text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 p-1 rounded-full">
<span className="text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 py-1 px-3 rounded-full">
{account.domain_limit ?? '∞'}
</span>
</div>
<div className='flex items-center mt-1.5'>
<TbWorldWww className='w-4 h-4 mr-1' />
<span className="text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 p-1 rounded-full">
<span className="text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 py-1 px-3 rounded-full">
{account.database_limit ?? '∞'}
</span>
</div>
@@ -95,12 +96,18 @@ export default function Accounts({ accounts }) {
whitespace-nowrap dark:text-white">
<div className='flex items-center space-x-2'>
<FaEdit className='w-4 h-4' />
<EditAccountForm account={account} />
<Link href={route('accounts.impersonate', { user: account.id })}>
<Link href={route('accounts.impersonate', { user: account.id })}
data-tooltip-id={`tooltip-impersonate-${account.id}`}
data-tooltip-content="Impersonate User"
data-tooltip-place="top"
>
<RiLoginCircleLine className='w-4 h-4' />
</Link>
<Tooltip id={`tooltip-impersonate-${account.id}`} />
<ConfirmationButton doAction={() => deleteUser(account.id)}>
<TiDelete className='w-6 h-6 text-red-500' />
</ConfirmationButton>

View File

@@ -0,0 +1,280 @@
import InputError from '@/Components/InputError';
import InputLabel from '@/Components/InputLabel';
import Modal from '@/Components/Modal';
import PrimaryButton from '@/Components/PrimaryButton';
import SecondaryButton from '@/Components/SecondaryButton';
import Checkbox from '@/Components/Checkbox';
import TextInput from '@/Components/TextInput';
import { useForm } from '@inertiajs/react';
import { useState } from 'react';
import { FaEdit, FaUserEdit } from 'react-icons/fa';
import InputRadio from '@/Components/InputRadio';
import { router } from '@inertiajs/react'
export default function EditAccountForm({ account }) {
const [showModal, setShowModal] = useState(false);
const randomPassword = () => {
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let result = '';
for (let i = 0; i < 12; i++) {
result += characters.charAt(
Math.floor(Math.random() * charactersLength)
);
}
return result;
};
const {
data,
setData,
patch,
processing,
errors,
clearErrors,
} = useForm({
id: account.id,
name: account.name,
email: account.email,
role: account.role,
domain_limit: account.domain_limit,
database_limit: account.database_limit,
ssh_access: account.ssh_access,
new_password: '',
});
const showEditModal = () => {
setShowModal(true);
};
const updateUser = (e) => {
e.preventDefault();
patch(route('accounts.update', { account: account.id }), {
onSuccess: () => {
router.visit(route('accounts.index'));
},
});
};
const closeModal = () => {
setShowModal(false);
clearErrors();
};
if (!account) return;
return (
<>
<button onClick={showEditModal} className='flex items-center text-gray-700 dark:text-gray-300'>
<FaEdit className='mr-2' />
</button>
<Modal show={showModal} onClose={closeModal}>
<form onSubmit={updateUser} className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-gray-100 flex items-center">
<FaUserEdit className='mr-2' />
Edit Account
</h2>
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-4 max-h-[500px] overflow-scroll">
<div>
<InputLabel
htmlFor="name"
value="Name"
className='my-2'
/>
<TextInput
id="name"
name="name"
value={data.name}
onChange={(e) =>
setData('name', e.target.value)
}
className="mt-1 block w-full"
isFocused
placeholder="Full Name"
/>
<InputError
message={errors.name}
className="mt-2"
/>
</div>
<div>
<InputLabel
htmlFor="email"
value="Email"
className='my-2'
/>
<TextInput
id="email"
name="email"
value={data.email}
onChange={(e) =>
setData('email', e.target.value)
}
className="mt-1 block w-full"
placeholder="Email"
/>
<InputError
message={errors.email}
className="mt-2"
/>
</div>
<div>
<InputLabel
htmlFor="new_password"
value="New Password (leave empty to keep current)"
className='my-2'
/>
<TextInput
id="new_password"
name="new_password"
onChange={(e) =>
setData('new_password', e.target.value)
}
className="mt-1 block w-full"
placeholder="New Password"
/>
<InputError
message={errors.new_password}
className="mt-2"
/>
</div>
<div>
<InputLabel
htmlFor="role"
value="Role"
className='my-2'
/>
<div className='flex items-center space-x-4'>
<InputRadio
id="admin"
name="admin"
checked={data.role == 'admin'}
onChange={(e) =>
setData('role', e.target.checked ? 'admin' : 'user')
}
className="h-4 w-4"
value="admin"
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
Admin
</span>
<InputRadio
id="user"
name="user"
value={'user'}
checked={data.role == 'user'}
onChange={(e) =>
setData('role', e.target.checked ? 'user' : 'admin')
}
className="h-4 w-4"
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
User
</span>
</div>
<InputError
message={errors.role}
className="mt-2"
/>
</div>
<div>
<InputLabel
htmlFor="domain_limit"
value="Domain Limit (leave empty for unlimited)"
className='my-2'
/>
<TextInput
id="domain_limit"
name="domain_limit"
type="number"
value={data.domain_limit}
onChange={(e) =>
setData('domain_limit', e.target.value)
}
className="mt-1 block w-full"
placeholder="Domain Limit"
/>
<InputError
message={errors.domain_limit}
className="mt-2"
/>
</div>
<div>
<InputLabel
htmlFor="database_limit"
value="Database Limit (leave empty for unlimited)"
className='my-2'
/>
<TextInput
id="database_limit"
name="database_limit"
type="number"
value={data.database_limit}
onChange={(e) =>
setData('database_limit', e.target.value)
}
className="mt-1 block w-full"
placeholder="Database Limit"
/>
<InputError
message={errors.database_limit}
className="mt-2"
/>
</div>
<div className='mt-4 flex items-center space-x-4'>
<Checkbox
id="ssh"
name="ssh"
checked={data.ssh_access}
onChange={(e) =>
setData('ssh_access', e.target.checked)
}
className="mr-1"
/>
<div className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Allow SSH/SFTP access
</div>
</div>
<div className="flex justify-end">
<PrimaryButton className="mr-3" disabled={processing}>
Update Account
</PrimaryButton>
<SecondaryButton onClick={closeModal}>
Cancel
</SecondaryButton>
</div>
</div>
</form>
</Modal>
</>
);
}

View File

@@ -20,6 +20,6 @@ createInertiaApp({
root.render(<App {...props} />);
},
progress: {
color: '#4B5563',
color: '#AC825F',
},
});

View File

@@ -10,6 +10,15 @@
<!-- Fonts -->
<link href="{{ asset('fonts/satoshi/css/satoshi.css') }}" rel="stylesheet" />
<!-- Favicon for browsers -->
<link rel="icon" type="image/png" sizes="32x32" href="/favico.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favico.png">
<!-- Favicon for iOS devices -->
<link rel="apple-touch-icon" sizes="180x180" href="/favico.png">
<!-- Favicon for Android devices -->
<link rel="icon" type="image/png" sizes="192x192" href="/favico.png">
<!-- Scripts -->
@routes

0
storage/app/.gitignore vendored Normal file → Executable file
View File

0
storage/app/private/.gitignore vendored Normal file → Executable file
View File

0
storage/app/public/.gitignore vendored Normal file → Executable file
View File

0
storage/framework/.gitignore vendored Normal file → Executable file
View File

0
storage/framework/cache/.gitignore vendored Normal file → Executable file
View File

0
storage/framework/cache/data/.gitignore vendored Normal file → Executable file
View File

0
storage/framework/sessions/.gitignore vendored Normal file → Executable file
View File

0
storage/framework/testing/.gitignore vendored Normal file → Executable file
View File

0
storage/framework/views/.gitignore vendored Normal file → Executable file
View File

0
storage/logs/.gitignore vendored Normal file → Executable file
View File

View File

@@ -1,7 +1,6 @@
import { defineConfig } from "vite";
import laravel from "laravel-vite-plugin";
import react from "@vitejs/plugin-react";
import fs from "fs";
export default defineConfig({
plugins: [