diff --git a/public/static/style.css b/public/static/style.css index 6874ca5..4424a71 100644 --- a/public/static/style.css +++ b/public/static/style.css @@ -158,27 +158,13 @@ table.bordered_table th { /* --- Boxes --- */ .filter_options, -.form_box, +.edit_box, .confirmation_box { border: 1px solid #999999; padding: 1rem; margin: 1rem 0; } -/* --- Detail boxes --- */ -details { - margin: 1rem 0; -} - -details > summary { - cursor: pointer; -} - -details > p { - margin: 0.75rem 1rem; - font-family: monospace; -} - /* --- Detail columns --- */ input#show_details_checkbox { margin-bottom: 1rem; @@ -188,8 +174,8 @@ input#show_details_checkbox:not(:checked) ~ table .detail_column { display: none; } -/* --- Form box --- */ -.form_box p:last-child { +/* --- Edit box --- */ +.edit_box p:last-child { margin-bottom: 0; } diff --git a/src/Common/ActionResult.php b/src/Common/ActionResult.php index d9adf77..924c489 100644 --- a/src/Common/ActionResult.php +++ b/src/Common/ActionResult.php @@ -77,18 +77,4 @@ class ActionResult { return $this->inputData; } - - /** - * Returns an array that can be merged with other template render data, containing either a field "success" or "error" with the - * result message and in case of an error result a field "formData" containing the input data. - */ - public function getRenderData(): array - { - $messageKey = $this->isSuccess() ? 'success' : 'error'; - $renderData = [$messageKey => $this->message]; - if (!empty($this->inputData)) { - $renderData['formData'] = $this->inputData; - } - return $renderData; - } } diff --git a/src/Common/FormData.php b/src/Common/FormData.php deleted file mode 100644 index 24eaf68..0000000 --- a/src/Common/FormData.php +++ /dev/null @@ -1,86 +0,0 @@ - 0) { - throw new InputValidationError("$fieldName is required."); - } elseif (strlen($raw) < $minLength) { - throw new InputValidationError("$fieldName is too short (minimum $minLength characters)."); - } elseif (strlen($raw) > 100) { - throw new InputValidationError("$fieldName is too long (maximum $maxLength characters)."); - } - return $raw; - } - - protected static function validateBoolOption(string $raw): bool - { - return $raw !== ''; - } - - - // Input validation - Application specific validators - - protected static function validateUsername(string $username, bool $required = true): ?string - { - if (!$required && $username === '') { - return null; - } - - $username = strtolower( - self::validateString($username, 3, 100, 'Username') - ); - - if (!preg_match('/^[a-z0-9._+-]+@[a-z0-9.-]+$/', $username) || preg_match('/^\\.|\\.\\.|\\.@|@\\.|\\.$/', $username)) { - throw new InputValidationError('Username is not valid (must be a valid mail address).'); - } - return $username; - } - - protected static function validatePassword(string $password, string $passwordRepeat, bool $required = true): ?string - { - if (!$required && $password === '' && $passwordRepeat === '') { - return null; - } - - $password = self::validateString($password, 6, 1000, 'Password'); - - if ($password !== $passwordRepeat) { - throw new InputValidationError('Passwords do not match.'); - } - return $password; - } - - protected static function validateHomeDir(string $homeDir, bool $required = true): ?string - { - if (!$required && $homeDir === '') { - return null; - } - - $homeDir = self::validateString($homeDir, 0, 100, 'Home directory'); - $homeDir = trim($homeDir, '/'); - - if (!preg_match('!^[a-z0-9._+-]+(/[a-z0-9._+-]+)*$!i', $homeDir)) { - throw new InputValidationError('Home directory is not a valid path.'); - } - return $homeDir; - } - - protected static function validateMemo(string $memo): string - { - return self::validateString($memo, 0, 5000, 'Admin memo'); - } -} diff --git a/src/Dependencies.php b/src/Dependencies.php index 654bbed..8d49ed9 100644 --- a/src/Dependencies.php +++ b/src/Dependencies.php @@ -145,13 +145,14 @@ class Dependencies $c->get(SessionHelper::class), $c->get(UserHelper::class), $c->get(AccountHandler::class), + $c->get(AccountRepository::class), + $c->get(AliasRepository::class), ); }); $container->set(AccountHandler::class, function (ContainerInterface $c) { return new AccountHandler( $c->get(AccountRepository::class), $c->get(AliasRepository::class), - $c->get(DomainRepository::class), $c->get(PasswordHelper::class), ); }); diff --git a/src/Frontend/Accounts/AccountController.php b/src/Frontend/Accounts/AccountController.php index 3b46c6e..0617e56 100644 --- a/src/Frontend/Accounts/AccountController.php +++ b/src/Frontend/Accounts/AccountController.php @@ -8,6 +8,8 @@ use MailAccountAdmin\Common\SessionHelper; use MailAccountAdmin\Common\UserHelper; use MailAccountAdmin\Exceptions\InputValidationError; use MailAccountAdmin\Frontend\BaseController; +use MailAccountAdmin\Repositories\AccountRepository; +use MailAccountAdmin\Repositories\AliasRepository; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Views\Twig; @@ -15,11 +17,15 @@ use Slim\Views\Twig; class AccountController extends BaseController { private AccountHandler $accountHandler; + private AccountRepository $accountRepository; + private AliasRepository $aliasRepository; - public function __construct(Twig $view, SessionHelper $sessionHelper, UserHelper $userHelper, AccountHandler $accountHandler) + public function __construct(Twig $view, SessionHelper $sessionHelper, UserHelper $userHelper, AccountHandler $accountHandler, AccountRepository $accountRepository, AliasRepository $aliasRepository) { parent::__construct($view, $sessionHelper, $userHelper); $this->accountHandler = $accountHandler; + $this->accountRepository = $accountRepository; + $this->aliasRepository = $aliasRepository; } @@ -52,39 +58,8 @@ class AccountController extends BaseController public function showAccountCreate(Request $request, Response $response): Response { - $renderData = $this->accountHandler->getPageDataForCreate(); - - // If the form has been submitted, add the result message and form input data to the render data array - $lastActionResult = $this->sessionHelper->getLastActionResult(); - if ($lastActionResult !== null) { - $renderData = array_merge($renderData, $lastActionResult->getRenderData()); - } - - return $this->view->render($response, 'account_create.html.twig', $renderData); - } - - public function createAccount(Request $request, Response $response): Response - { - // Parse form data - $createData = $request->getParsedBody(); - - try { - // Validate input - $validatedCreateData = AccountCreateData::createFromArray($createData); - $newAccountId = $this->accountHandler->createNewAccount($validatedCreateData); - - // Save success result - $newAccountName = $validatedCreateData->getUsername(); - $this->sessionHelper->setLastActionResult(ActionResult::createSuccessResult( - 'Account ' . $newAccountName . ' was created.' - )); - } catch (InputValidationError $e) { - // Save error result - $this->sessionHelper->setLastActionResult(ActionResult::createErrorResult($e->getMessage(), $createData)); - } - - // Redirect to edit form page via GET (PRG) - return $response->withHeader('Location', '/accounts/new')->withStatus(303); + // TODO: just a placeholder + return $this->showAccounts($request, $response); } @@ -96,12 +71,21 @@ class AccountController extends BaseController $accountId = (int)$args['id']; // Get account data from database - $renderData = $this->accountHandler->getAccountDataForEdit($accountId); + $account = $this->accountRepository->fetchAccountById($accountId); + + $renderData = [ + 'id' => $account->getId(), + 'accountUsername' => $account->getUsername(), + 'account' => $account, + ]; - // If the form has been submitted, add the result message and form input data to the render data array $lastActionResult = $this->sessionHelper->getLastActionResult(); if ($lastActionResult !== null) { - $renderData = array_merge($renderData, $lastActionResult->getRenderData()); + $resultData = $lastActionResult->isSuccess() + ? ['success' => $lastActionResult->getMessage()] + : ['error' => $lastActionResult->getMessage()]; + $resultData['editData'] = $lastActionResult->getInputData(); + $renderData = array_merge($renderData, $resultData); } return $this->view->render($response, 'account_edit.html.twig', $renderData); @@ -109,20 +93,25 @@ class AccountController extends BaseController public function editAccount(Request $request, Response $response, array $args): Response { - // Parse URL arguments and form data + // Parse URL arguments $accountId = (int)$args['id']; + + // Parse form data $editData = $request->getParsedBody(); + $errorMessage = null; try { // Validate input $validatedEditData = AccountEditData::createFromArray($editData); $this->accountHandler->editAccountData($accountId, $validatedEditData); - - // Save success result - $this->sessionHelper->setLastActionResult(ActionResult::createSuccessResult('Account data was saved.')); } catch (InputValidationError $e) { - // Save error result - $this->sessionHelper->setLastActionResult(ActionResult::createErrorResult($e->getMessage(), $editData)); + $errorMessage = $e->getMessage(); + } + + if (empty($errorMessage)) { + $this->sessionHelper->setLastActionResult(ActionResult::createSuccessResult('Account data was saved.')); + } else { + $this->sessionHelper->setLastActionResult(ActionResult::createErrorResult($errorMessage, $editData)); } // Redirect to edit form page via GET (PRG) @@ -138,7 +127,14 @@ class AccountController extends BaseController $accountId = (int)$args['id']; // Get account data and list of aliases from database - $renderData = $this->accountHandler->getAccountDataForDelete($accountId); + $account = $this->accountRepository->fetchAccountById($accountId); + $aliases = $this->aliasRepository->fetchAliasesForUserId($accountId); + + $renderData = [ + 'id' => $accountId, + 'accountUsername' => $account->getUsername(), + 'aliases' => $aliases, + ]; return $this->view->render($response, 'account_delete.html.twig', $renderData); } diff --git a/src/Frontend/Accounts/AccountCreateData.php b/src/Frontend/Accounts/AccountCreateData.php deleted file mode 100644 index 5da7bcd..0000000 --- a/src/Frontend/Accounts/AccountCreateData.php +++ /dev/null @@ -1,60 +0,0 @@ -username = $username; - $this->password = $password; - $this->active = $active; - $this->homeDir = $homeDir; - $this->memo = $memo; - } - - public static function createFromArray($raw): self - { - return new self( - self::validateUsername(trim($raw['username'] ?? '')), - self::validatePassword(trim($raw['password'] ?? ''), trim($raw['password_repeat'] ?? '')), - self::validateBoolOption(trim($raw['is_active'] ?? '')), - self::validateHomeDir(trim($raw['home_dir'] ?? ''), false), - self::validateMemo(trim($raw['memo'] ?? '')), - ); - } - - public function getUsername(): string - { - return $this->username; - } - - public function getPassword(): string - { - return $this->password; - } - - public function getActive(): bool - { - return $this->active; - } - - public function getHomeDir(): ?string - { - return $this->homeDir; - } - - public function getMemo(): string - { - return $this->memo; - } -} diff --git a/src/Frontend/Accounts/AccountEditData.php b/src/Frontend/Accounts/AccountEditData.php index 9f5f67d..f32553e 100644 --- a/src/Frontend/Accounts/AccountEditData.php +++ b/src/Frontend/Accounts/AccountEditData.php @@ -3,9 +3,9 @@ declare(strict_types=1); namespace MailAccountAdmin\Frontend\Accounts; -use MailAccountAdmin\Common\FormData; +use MailAccountAdmin\Exceptions\InputValidationError; -class AccountEditData extends FormData +class AccountEditData { private ?string $username; private bool $usernameCreateAlias; @@ -13,10 +13,10 @@ class AccountEditData extends FormData private ?string $password; private bool $active; private ?string $homeDir; - private string $memo; + private ?string $memo; private function __construct(?string $username, bool $usernameCreateAlias, bool $usernameReplaceAlias, ?string $password, bool $active, - ?string $homeDir, string $memo) + ?string $homeDir, ?string $memo) { $this->username = $username; $this->usernameCreateAlias = $usernameCreateAlias; @@ -30,16 +30,70 @@ class AccountEditData extends FormData public static function createFromArray($raw): self { return new self( - self::validateUsername(trim($raw['username'] ?? ''), false), + self::validateUsername(trim($raw['username'] ?? '')), self::validateBoolOption(trim($raw['username_create_alias'] ?? '')), self::validateBoolOption(trim($raw['username_replace_alias'] ?? '')), - self::validatePassword(trim($raw['password'] ?? ''), trim($raw['password_repeat'] ?? ''), false), + self::validatePassword(trim($raw['password'] ?? ''), trim($raw['password_repeat'] ?? '')), self::validateBoolOption(trim($raw['is_active'] ?? '')), - self::validateHomeDir(trim($raw['home_dir'] ?? ''), false), - self::validateMemo(trim($raw['memo'] ?? '')), + self::validateHomeDir(trim($raw['home_dir'] ?? '')), + trim($raw['memo'] ?? '') ); } + + // Input validation + + private static function validateUsername(string $username): ?string + { + if ($username === '') { + return null; + } + if (strlen($username) > 100) { + throw new InputValidationError('Username is too long.'); + } + + $username = strtolower($username); + if (!preg_match('/^[a-z0-9._+-]+@[a-z0-9.-]+$/', $username) || preg_match('/^\\.|\\.\\.|\\.@|@\\.|\\.$/', $username)) { + throw new InputValidationError('Username is not valid (must be a valid mail address).'); + } + return $username; + } + + private static function validatePassword(string $password, string $passwordRepeat): ?string + { + if ($password === '') { + return null; + } + if ($password !== $passwordRepeat) { + throw new InputValidationError('Passwords do not match.'); + } + return $password; + } + + private static function validateHomeDir(string $homeDir): ?string + { + if ($homeDir === '') { + return null; + } + if (strlen($homeDir) > 100) { + throw new InputValidationError('Home directory is too long.'); + } + + $homeDir = trim($homeDir, '/'); + if (!preg_match('!^[a-z0-9._+-]+(/[a-z0-9._+-]+)*$!i', $homeDir)) { + throw new InputValidationError('Home directory is not a valid path.'); + } + return $homeDir; + } + + private static function validateBoolOption(string $raw): bool + { + return $raw !== ''; + } + + + // Getters + public function getUsername(): ?string { return $this->username; diff --git a/src/Frontend/Accounts/AccountHandler.php b/src/Frontend/Accounts/AccountHandler.php index 4d798e0..26de4fd 100644 --- a/src/Frontend/Accounts/AccountHandler.php +++ b/src/Frontend/Accounts/AccountHandler.php @@ -8,21 +8,17 @@ use MailAccountAdmin\Exceptions\AccountNotFoundException; use MailAccountAdmin\Exceptions\InputValidationError; use MailAccountAdmin\Repositories\AccountRepository; use MailAccountAdmin\Repositories\AliasRepository; -use MailAccountAdmin\Repositories\DomainRepository; class AccountHandler { private AccountRepository $accountRepository; private AliasRepository $aliasRepository; - private DomainRepository $domainRepository; private PasswordHelper $passwordHelper; - public function __construct(AccountRepository $accountRepository, AliasRepository $aliasRepository, DomainRepository $domainRepository, - PasswordHelper $passwordHelper) + public function __construct(AccountRepository $accountRepository, AliasRepository $aliasRepository, PasswordHelper $passwordHelper) { $this->accountRepository = $accountRepository; $this->aliasRepository = $aliasRepository; - $this->domainRepository = $domainRepository; $this->passwordHelper = $passwordHelper; } @@ -63,59 +59,8 @@ class AccountHandler } - // -- /accounts/new - Create new account - - public function getPageDataForCreate(): array - { - return [ - 'domainList' => array_keys($this->domainRepository->fetchDomainList()), - ]; - } - - public function createNewAccount(AccountCreateData $createData): int - { - // Check if new username is still available - $username = $createData->getUsername(); - if (!$this->accountRepository->checkUsernameAvailable($username) || !$this->aliasRepository->checkAliasAvailable($username)) { - throw new InputValidationError("Username \"$username\" is not available."); - } - - // Hash new password - $passwordHash = $this->passwordHelper->hashPassword($createData->getPassword()); - - // Construct home directory from username if necessary - if ($createData->getHomeDir() !== null) { - $homeDir = $createData->getHomeDir(); - } else { - [$localPart, $domainPart] = explode('@', $username, 2); - $homeDir = $domainPart . '/' . $localPart; - } - - // Create account in database - return $this->accountRepository->insertAccount( - $username, - $passwordHash, - $createData->getActive(), - $homeDir, - $createData->getMemo() - ); - } - - // -- /accounts/{id}/edit - Edit account data - public function getAccountDataForEdit(int $accountId): array - { - // Get account data from database - $account = $this->accountRepository->fetchAccountById($accountId); - - return [ - 'id' => $accountId, - 'accountUsername' => $account->getUsername(), - 'account' => $account, - ]; - } - public function editAccountData(int $accountId, AccountEditData $editData): void { // Check if account exists @@ -199,22 +144,4 @@ class AccountHandler // Commit database transaction $this->accountRepository->commitTransaction(); } - - - // -- /accounts/{id}/delete - Delete account - - public function getAccountDataForDelete(int $accountId): array - { - // Get account data from database - $account = $this->accountRepository->fetchAccountById($accountId); - - // Get list of aliases for this account - $aliases = $this->aliasRepository->fetchAliasesForUserId($accountId); - - return [ - 'id' => $accountId, - 'accountUsername' => $account->getUsername(), - 'aliases' => $aliases, - ]; - } } diff --git a/src/Repositories/AccountRepository.php b/src/Repositories/AccountRepository.php index 318ada1..7dba5a0 100644 --- a/src/Repositories/AccountRepository.php +++ b/src/Repositories/AccountRepository.php @@ -65,27 +65,6 @@ class AccountRepository extends BaseRepository return $statement->rowCount() === 0; } - public function insertAccount(string $username, string $passwordHash, bool $active, string $homeDir, string $memo): int - { - $query = ' - INSERT INTO mail_users - (username, password, is_active, home_dir, memo) - VALUES - (:username, :password, :is_active, :home_dir, :memo) - '; - - $statement = $this->pdo->prepare($query); - $statement->execute([ - 'username' => $username, - 'password' => $passwordHash, - 'is_active' => $active ? '1' : '0', - 'home_dir' => $homeDir, - 'memo' => $memo, - ]); - - return (int)$this->pdo->lastInsertId(); - } - public function updateAccountWithId(int $accountId, ?string $newUsername, ?string $newPasswordHash, bool $newActive, ?string $newHomeDir, string $newMemo): void { diff --git a/src/Routes.php b/src/Routes.php index 62faebd..728e70e 100644 --- a/src/Routes.php +++ b/src/Routes.php @@ -27,7 +27,6 @@ class Routes // Accounts $app->get('/accounts', AccountController::class . ':showAccounts'); $app->get('/accounts/new', AccountController::class . ':showAccountCreate'); - $app->post('/accounts/new', AccountController::class . ':createAccount'); $app->get('/accounts/{id:[1-9][0-9]*}', AccountController::class . ':showAccountDetails'); $app->get('/accounts/{id:[1-9][0-9]*}/edit', AccountController::class . ':showAccountEdit'); $app->post('/accounts/{id:[1-9][0-9]*}/edit', AccountController::class . ':editAccount'); diff --git a/templates/account_create.html.twig b/templates/account_create.html.twig deleted file mode 100644 index d881f7b..0000000 --- a/templates/account_create.html.twig +++ /dev/null @@ -1,98 +0,0 @@ -{% extends "base.html.twig" %} - -{% block title %}Create account{% endblock %} - -{% block content %} -

Accounts

- -

- Actions: - List accounts | - Create account -

- -

Create new account

- -
- {{ include('includes/form_result_box.html.twig') }} - -
-

Username

-

- This is the primary mail address of the account and the username to use for login. - The domain is part of the username (e.g. "user@example.com"). -

-
- Show list of known domains -

{{ domainList ? domainList | join(', ') : 'No domains exist yet.' }}

-
- - - - - -
-
- -
-

Password

-

The password will be hashed using the current default hash algorithm.

- - - - - - - - - -
-
- -
-

Account status

- - - - - -
New account status: - -
-
- -
-

Home directory

-

Note: By default the home directory will be determined automatically from the username and domain. Only change this if you know what you're doing!

- - - - - - - - - -
Default home directory:/srv/vmail/<domain.tld>/<local_part>
- /srv/vmail/ -
-
- -
-

-

This field is only readable by admins.

- - - - - -
-
- - - -
-{% endblock %} diff --git a/templates/account_edit.html.twig b/templates/account_edit.html.twig index 6b9c71a..ff1ec87 100644 --- a/templates/account_edit.html.twig +++ b/templates/account_edit.html.twig @@ -16,11 +16,21 @@

Edit account data

- {{ include('includes/form_result_box.html.twig') }} + {% if success is defined %} +
+

Success

+ {{ success }} +
+ {% elseif error is defined %} +
+

Error

+ {{ error }} +
+ {% endif %} -
+

Username

@@ -29,12 +39,12 @@ - + @@ -42,7 +52,7 @@ @@ -50,22 +60,22 @@
-
+

Password

The new password will be hashed using the current default hash algorithm.

- + - +
-
+

Account status

@@ -77,8 +87,8 @@
- /srv/vmail/ + /srv/vmail/
-
+

This field is only readable by admins.

- +
diff --git a/templates/accounts.html.twig b/templates/accounts.html.twig index ae99225..caa2883 100644 --- a/templates/accounts.html.twig +++ b/templates/accounts.html.twig @@ -7,7 +7,6 @@

Actions: - List accounts | Create account

diff --git a/templates/includes/form_result_box.html.twig b/templates/includes/form_result_box.html.twig deleted file mode 100644 index 0f010f5..0000000 --- a/templates/includes/form_result_box.html.twig +++ /dev/null @@ -1,11 +0,0 @@ -{% if success is defined %} -
-

Success

- {{ success | raw }} -
-{% elseif error is defined %} -
-

Error

- {{ error | raw }} -
-{% endif %}