diff --git a/public/static/style.css b/public/static/style.css index f4cf7bd..4424a71 100644 --- a/public/static/style.css +++ b/public/static/style.css @@ -14,7 +14,8 @@ body { /* --- Header --- */ header { - margin: 1em; + margin: 1rem; + padding: 0 1rem; display: flex; justify-content: space-between; align-content: center; @@ -22,7 +23,7 @@ header { } header h1 { - margin: 1em; + margin: 1rem; } /* --- Navigation bar --- */ @@ -76,14 +77,15 @@ nav li.nav_current_page a { /* --- Main section --- */ main { - margin: 2em; + margin: 2rem; } /* --- Login page --- */ main.login_page { - margin: 2em; - padding: 1em; + margin: 2rem; + padding: 1rem; border: 1px solid #666666; + width: 40rem; } /* --- Text and other styling --- */ @@ -101,11 +103,18 @@ a:hover, a:focus { text-decoration: underline; } +.error_box, .success_box { + max-width: 50rem; + margin: 1rem 0; + padding: 1rem; +} + .error_box { background: #ff4444; - max-width: 50em; - margin: 1em 0; - padding: 1em; +} + +.success_box { + background: #00cc00; } .gray { @@ -131,7 +140,7 @@ button { /* --- Tables --- */ table td, table th { - padding: 0.25em 0.5em; + padding: 0.25rem 0.5rem; } table.bordered_table, @@ -140,7 +149,7 @@ table.bordered_table td, table.bordered_table th { border: 1px solid #999999; border-collapse: collapse; - padding: 0.25em 0.5em; + padding: 0.25rem 0.5rem; } .vertical_table_headers th { @@ -152,13 +161,13 @@ table.bordered_table th { .edit_box, .confirmation_box { border: 1px solid #999999; - padding: 1em; - margin: 1em 0; + padding: 1rem; + margin: 1rem 0; } /* --- Detail columns --- */ input#show_details_checkbox { - margin-bottom: 1em; + margin-bottom: 1rem; } input#show_details_checkbox:not(:checked) ~ table .detail_column { diff --git a/src/Dependencies.php b/src/Dependencies.php index 50c82e1..fc3d767 100644 --- a/src/Dependencies.php +++ b/src/Dependencies.php @@ -16,6 +16,7 @@ use MailAccountAdmin\Repositories\DomainRepository; use PDO; use Psr\Container\ContainerInterface; use Slim\Views\Twig; +use Twig\Extension\CoreExtension as TwigCoreExtension; class Dependencies { @@ -36,7 +37,17 @@ class Dependencies /** @var Settings $settings */ $settings = $c->get(self::SETTINGS); - return Twig::create(self::TWIG_TEMPLATE_DIR, $settings->getTwigSettings()); + // Create Twig view + $twig = Twig::create(self::TWIG_TEMPLATE_DIR, $settings->getTwigSettings()); + + // Set default date format + /** @var TwigCoreExtension $coreExtension */ + $coreExtension = $twig->getEnvironment()->getExtension(TwigCoreExtension::class); + $coreExtension->setDateFormat($settings->getDateFormat()); + $coreExtension->setTimezone($settings->getTimezone()); + + // Return Twig view + return $twig; }); // Database connection diff --git a/src/Exceptions/InformationUnknownException.php b/src/Exceptions/InformationUnknownException.php new file mode 100644 index 0000000..0889af8 --- /dev/null +++ b/src/Exceptions/InformationUnknownException.php @@ -0,0 +1,8 @@ +aliasRepository = $aliasRepository; } + + // -- /accounts - List all accounts + public function showAccounts(Request $request, Response $response): Response { // Parse query parameters for filters @@ -39,21 +43,22 @@ class AccountController extends BaseController return $this->view->render($response, 'accounts.html.twig', $renderData); } + + // -- /accounts/{id} - Show account details + public function showAccountDetails(Request $request, Response $response, array $args): Response { // Parse URL arguments $accountId = (int)$args['id']; // Get account data from database - $accountData = $this->accountRepository->fetchAccountById($accountId); - $accountUsername = $accountData['username']; - $accountData['domain'] = explode('@', $accountUsername, 2)[1]; + $account = $this->accountRepository->fetchAccountById($accountId); // Don't display the password hash, but at least the type of hash (used hash algorithm) - if ($accountData['password'] === '') { + if ($account->getPasswordHash() === '') { $passwordHashType = 'empty'; } else { - $passwordHashInfo = password_get_info($accountData['password']); + $passwordHashInfo = password_get_info($account->getPasswordHash()); $passwordHashType = $passwordHashInfo['algoName'] ?? 'unknown'; } @@ -62,8 +67,8 @@ class AccountController extends BaseController $renderData = [ 'id' => $accountId, - 'accountUsername' => $accountUsername, - 'accountData' => $accountData, + 'accountUsername' => $account->getUsername(), + 'account' => $account, 'passwordHashType' => $passwordHashType, 'aliases' => $aliases, ]; @@ -71,26 +76,27 @@ class AccountController extends BaseController return $this->view->render($response, 'account_details.html.twig', $renderData); } + + // -- /accounts/new - Create new account + public function showAccountCreate(Request $request, Response $response): Response { return $this->showAccounts($request, $response); } + + // -- /accounts/{id}/edit - Edit account + public function showAccountEdit(Request $request, Response $response, array $args): Response { // Parse URL arguments $accountId = (int)$args['id']; // Get account data from database - $accountData = $this->accountRepository->fetchAccountById($accountId); + $account = $this->accountRepository->fetchAccountById($accountId); - $renderData = [ - 'id' => $accountId, - 'accountUsername' => $accountData['username'], - 'accountData' => $accountData, - ]; - - return $this->view->render($response, 'account_edit.html.twig', $renderData); + // Render page + return $this->renderEditPage($response, $account); } public function editAccount(Request $request, Response $response, array $args): Response @@ -100,18 +106,31 @@ class AccountController extends BaseController return $this->showAccountEdit($request, $response, $args); } + private function renderEditPage(Response $response, Account $account, array $extraRenderData = []): Response + { + $renderData = [ + 'id' => $account->getId(), + 'accountUsername' => $account->getUsername(), + 'account' => $account, + ]; + + return $this->view->render($response, 'account_edit.html.twig', array_merge($renderData, $extraRenderData)); + } + + // -- /accounts/{id}/delete - Delete account + public function showAccountDelete(Request $request, Response $response, array $args): Response { // Parse URL arguments $accountId = (int)$args['id']; // Get account data and list of aliases from database - $accountData = $this->accountRepository->fetchAccountById($accountId); + $account = $this->accountRepository->fetchAccountById($accountId); $aliases = $this->aliasRepository->fetchAliasesForUserId($accountId); $renderData = [ 'id' => $accountId, - 'accountUsername' => $accountData['username'], + 'accountUsername' => $account->getUsername(), 'aliases' => $aliases, ]; diff --git a/src/Models/Account.php b/src/Models/Account.php new file mode 100644 index 0000000..472e576 --- /dev/null +++ b/src/Models/Account.php @@ -0,0 +1,138 @@ +id = $id; + $this->username = $username; + $this->passwordHash = $passwordHash; + $this->active = $active; + $this->homeDir = $homeDir; + $this->memo = $memo; + $this->createdAt = $createdAt; + $this->modifiedAt = $modifiedAt; + + // Extract domain from username + $this->domain = explode('@', $this->username, 2)[1]; + } + + public static function createFromArray(array $data): self + { + $account = new self( + (int)$data['user_id'], + $data['username'], + $data['password'], + $data['is_active'] === '1', + $data['home_dir'], + $data['memo'] !== null ? $data['memo'] : '', + new DateTimeImmutable($data['created_at']), + new DateTimeImmutable($data['modified_at']), + ); + + if (isset($data['alias_count'])) { + $account->setAliasCount((int)$data['alias_count']); + } + return $account; + } + + public function getId(): int + { + return $this->id; + } + + public function getUsername(): string + { + return $this->username; + } + + public function getPasswordHash(): string + { + return $this->passwordHash; + } + + public function isActive(): bool + { + return $this->active; + } + + public function getHomeDir(): string + { + return $this->homeDir; + } + + public function getMemo(): string + { + return $this->memo; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getModifiedAt(): DateTimeImmutable + { + return $this->modifiedAt; + } + + + // Getters and setters for extra data that is not part of the data model + + public function getDomain(): string + { + return $this->domain; + } + + public function hasAliasCount(): bool + { + return $this->aliasCount !== null; + } + + public function getAliasCount(): int + { + if ($this->aliasCount === null) { + throw new InformationUnknownException('Alias count is not set on this object.'); + } + return $this->aliasCount; + } + + public function setAliasCount(int $newAliasCount): void + { + $this->aliasCount = $newAliasCount; + } +} diff --git a/src/Repositories/AccountRepository.php b/src/Repositories/AccountRepository.php index a506447..03a6066 100644 --- a/src/Repositories/AccountRepository.php +++ b/src/Repositories/AccountRepository.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace MailAccountAdmin\Repositories; use MailAccountAdmin\Exceptions\AccountNotFoundException; +use MailAccountAdmin\Models\Account; use PDO; class AccountRepository extends BaseRepository @@ -31,10 +32,17 @@ class AccountRepository extends BaseRepository $statement = $this->pdo->prepare($query); $statement->execute($queryParams); - return $statement->fetchAll(PDO::FETCH_ASSOC); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + + // Create Account models from rows + $accountList = []; + foreach ($rows as $row) { + $accountList[] = Account::createFromArray($row); + } + return $accountList; } - public function fetchAccountById(int $accountId): array + public function fetchAccountById(int $accountId): Account { $statement = $this->pdo->prepare('SELECT * FROM mail_users WHERE user_id = :user_id LIMIT 1'); $statement->execute(['user_id' => $accountId]); @@ -43,6 +51,7 @@ class AccountRepository extends BaseRepository throw new AccountNotFoundException("Account with user ID '$accountId' was not found."); } - return $statement->fetch(PDO::FETCH_ASSOC); + $row = $statement->fetch(PDO::FETCH_ASSOC); + return Account::createFromArray($row); } } diff --git a/src/Settings.php b/src/Settings.php index ca111a0..1aecf4b 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -10,10 +10,23 @@ class Settings return getenv('APP_DEBUG') === 'true'; } + public function getTimezone(): string + { + return 'Europe/Berlin'; + } + + public function getDateFormat(): string + { + // Default date format (https://www.php.net/manual/en/datetime.format.php) + // RFC 2822 formatted date (Thu, 21 Dec 2000 16:01:07 +0200) + return 'r'; + } + public function getTwigSettings(): array { return [ 'cache' => getenv('TWIG_CACHE_DIR') ?: false, + 'debug' => $this->isDebugMode(), 'strict_variables' => getenv('TWIG_STRICT') === 'true', ]; } diff --git a/templates/account_details.html.twig b/templates/account_details.html.twig index ec702fe..642f3aa 100644 --- a/templates/account_details.html.twig +++ b/templates/account_details.html.twig @@ -18,15 +18,15 @@ - + - + - + @@ -42,23 +42,23 @@ - + - + - + - + - +
User ID{{ accountData['user_id'] }}{{ account.getId() }}
Username{{ accountData['username'] }}{{ account.getUsername() }}
Domain{{ accountData['domain'] }}{{ account.getDomain() }}
Password
Status{{ accountData['is_active'] == '1' ? 'Active' : 'Inactive' }}{{ account.isActive() ? 'Active' : 'Inactive' }}
Home directory/srv/vmail/{{ accountData['home_dir'] }}/srv/vmail/{{ account.getHomeDir() }}
Admin memo{{ accountData['memo'] | nl2br }}{{ account.getMemo() | nl2br }}
Created at{{ accountData['created_at'] }}{{ account.getCreatedAt() | date }}
Last modified at{{ accountData['modified_at'] }}{{ account.getModifiedAt() | date }}
diff --git a/templates/account_edit.html.twig b/templates/account_edit.html.twig index 4667eef..3d48be6 100644 --- a/templates/account_edit.html.twig +++ b/templates/account_edit.html.twig @@ -28,7 +28,7 @@ - + @@ -67,13 +67,13 @@
Current username:{{ accountData['username'] }}{{ account.getUsername() }}
- + @@ -87,7 +87,7 @@
Current status:{{ accountData['is_active'] == '1' ? 'Active' : 'Inactive' }}{{ account.isActive() ? 'Active' : 'Inactive' }}
New status:
- + @@ -104,7 +104,7 @@
Current home directory:/srv/vmail/{{ accountData['home_dir'] }}/srv/vmail/{{ account.getHomeDir() }}
- +
diff --git a/templates/accounts.html.twig b/templates/accounts.html.twig index 3c9ae7f..caa2883 100644 --- a/templates/accounts.html.twig +++ b/templates/accounts.html.twig @@ -37,15 +37,15 @@ {% if accountList %} {% for account in accountList -%} - - {{ account['username'] }} - {{ account['domain'] }} - {{ account['alias_count'] }} - {{ account['is_active'] == 1 ? 'Yes' : 'No' }} - vmail/{{ account['home_dir'] }} - {{ account['memo'] }} - {{ account['created_at'] }} - {{ account['modified_at'] }} + + {{ account.getUsername() }} + {{ account.getDomain() }} + {{ account.getAliasCount() }} + {{ account.isActive() ? 'Yes' : 'No' }} + vmail/{{ account.getHomeDir() }} + {{ account.getMemo() }} + {{ account.getCreatedAt() | date }} + {{ account.getModifiedAt() | date }} {% endfor %} {% else %}