diff --git a/public/static/style.css b/public/static/style.css index 4424a71..6874ca5 100644 --- a/public/static/style.css +++ b/public/static/style.css @@ -158,13 +158,27 @@ table.bordered_table th { /* --- Boxes --- */ .filter_options, -.edit_box, +.form_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; @@ -174,8 +188,8 @@ input#show_details_checkbox:not(:checked) ~ table .detail_column { display: none; } -/* --- Edit box --- */ -.edit_box p:last-child { +/* --- Form box --- */ +.form_box p:last-child { margin-bottom: 0; } diff --git a/src/Common/FormData.php b/src/Common/FormData.php new file mode 100644 index 0000000..24eaf68 --- /dev/null +++ b/src/Common/FormData.php @@ -0,0 +1,86 @@ + 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 8d49ed9..654bbed 100644 --- a/src/Dependencies.php +++ b/src/Dependencies.php @@ -145,14 +145,13 @@ 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 79a8bf4..3b46c6e 100644 --- a/src/Frontend/Accounts/AccountController.php +++ b/src/Frontend/Accounts/AccountController.php @@ -52,8 +52,39 @@ class AccountController extends BaseController public function showAccountCreate(Request $request, Response $response): Response { - // TODO: just a placeholder - return $this->showAccounts($request, $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); } @@ -78,25 +109,20 @@ class AccountController extends BaseController public function editAccount(Request $request, Response $response, array $args): Response { - // Parse URL arguments + // Parse URL arguments and form data $accountId = (int)$args['id']; - - // Parse form data $editData = $request->getParsedBody(); - $errorMessage = null; try { // Validate input $validatedEditData = AccountEditData::createFromArray($editData); $this->accountHandler->editAccountData($accountId, $validatedEditData); - } catch (InputValidationError $e) { - $errorMessage = $e->getMessage(); - } - if (empty($errorMessage)) { + // Save success result $this->sessionHelper->setLastActionResult(ActionResult::createSuccessResult('Account data was saved.')); - } else { - $this->sessionHelper->setLastActionResult(ActionResult::createErrorResult($errorMessage, $editData)); + } catch (InputValidationError $e) { + // Save error result + $this->sessionHelper->setLastActionResult(ActionResult::createErrorResult($e->getMessage(), $editData)); } // Redirect to edit form page via GET (PRG) diff --git a/src/Frontend/Accounts/AccountCreateData.php b/src/Frontend/Accounts/AccountCreateData.php new file mode 100644 index 0000000..5da7bcd --- /dev/null +++ b/src/Frontend/Accounts/AccountCreateData.php @@ -0,0 +1,60 @@ +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 f32553e..9f5f67d 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\Exceptions\InputValidationError; +use MailAccountAdmin\Common\FormData; -class AccountEditData +class AccountEditData extends FormData { private ?string $username; private bool $usernameCreateAlias; @@ -13,10 +13,10 @@ class AccountEditData 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,70 +30,16 @@ class AccountEditData public static function createFromArray($raw): self { return new self( - self::validateUsername(trim($raw['username'] ?? '')), + self::validateUsername(trim($raw['username'] ?? ''), false), self::validateBoolOption(trim($raw['username_create_alias'] ?? '')), self::validateBoolOption(trim($raw['username_replace_alias'] ?? '')), - self::validatePassword(trim($raw['password'] ?? ''), trim($raw['password_repeat'] ?? '')), + self::validatePassword(trim($raw['password'] ?? ''), trim($raw['password_repeat'] ?? ''), false), self::validateBoolOption(trim($raw['is_active'] ?? '')), - self::validateHomeDir(trim($raw['home_dir'] ?? '')), - trim($raw['memo'] ?? '') + self::validateHomeDir(trim($raw['home_dir'] ?? ''), false), + self::validateMemo(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 c9c72d1..4d798e0 100644 --- a/src/Frontend/Accounts/AccountHandler.php +++ b/src/Frontend/Accounts/AccountHandler.php @@ -8,17 +8,21 @@ 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, PasswordHelper $passwordHelper) + public function __construct(AccountRepository $accountRepository, AliasRepository $aliasRepository, DomainRepository $domainRepository, + PasswordHelper $passwordHelper) { $this->accountRepository = $accountRepository; $this->aliasRepository = $aliasRepository; + $this->domainRepository = $domainRepository; $this->passwordHelper = $passwordHelper; } @@ -59,6 +63,45 @@ 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 diff --git a/src/Repositories/AccountRepository.php b/src/Repositories/AccountRepository.php index 7dba5a0..318ada1 100644 --- a/src/Repositories/AccountRepository.php +++ b/src/Repositories/AccountRepository.php @@ -65,6 +65,27 @@ 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 728e70e..62faebd 100644 --- a/src/Routes.php +++ b/src/Routes.php @@ -27,6 +27,7 @@ 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 new file mode 100644 index 0000000..d881f7b --- /dev/null +++ b/templates/account_create.html.twig @@ -0,0 +1,98 @@ +{% extends "base.html.twig" %} + +{% block title %}Create account{% endblock %} + +{% block content %} +
+ Actions: + List accounts | + Create account +
+ +