Implement Account model; small style changes; Twig debug setting

This commit is contained in:
Lexi / Zoe 2021-08-14 23:37:23 +02:00
parent 7859ef77ee
commit 32add30c9d
Signed by: binaryDiv
GPG Key ID: F8D4956E224DA232
10 changed files with 263 additions and 56 deletions

View File

@ -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 {

View File

@ -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

View File

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Exceptions;
class InformationUnknownException extends AppException
{
}

View File

@ -5,6 +5,7 @@ namespace MailAccountAdmin\Frontend\Accounts;
use MailAccountAdmin\Common\UserHelper;
use MailAccountAdmin\Frontend\BaseController;
use MailAccountAdmin\Models\Account;
use MailAccountAdmin\Repositories\AccountRepository;
use MailAccountAdmin\Repositories\AliasRepository;
use Psr\Http\Message\ResponseInterface as Response;
@ -25,6 +26,9 @@ class AccountController extends BaseController
$this->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,
];

138
src/Models/Account.php Normal file
View File

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace MailAccountAdmin\Models;
use DateTimeImmutable;
use MailAccountAdmin\Exceptions\InformationUnknownException;
class Account
{
/** @var int */
private $id;
/** @var string */
private $username;
/** @var string */
private $passwordHash;
/** @var bool */
private $active;
/** @var string */
private $homeDir;
/** @var string */
private $memo;
/** @var DateTimeImmutable */
private $createdAt;
/** @var DateTimeImmutable */
private $modifiedAt;
// Extra data that is not part of the data model
// Domain (extracted from username)
/** @var string */
private $domain;
// Number of aliases (not always available)
/** @var int|null */
private $aliasCount;
private function __construct(int $id, string $username, string $passwordHash, bool $active, string $homeDir, string $memo,
DateTimeImmutable $createdAt, DateTimeImmutable $modifiedAt)
{
$this->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;
}
}

View File

@ -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);
}
}

View File

@ -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',
];
}

View File

@ -18,15 +18,15 @@
<table class="bordered_table vertical_table_headers">
<tr>
<th style="min-width: 10em">User ID</th>
<td style="min-width: 20em">{{ accountData['user_id'] }}</td>
<td style="min-width: 20em">{{ account.getId() }}</td>
</tr>
<tr>
<th>Username</th>
<td>{{ accountData['username'] }}</td>
<td>{{ account.getUsername() }}</td>
</tr>
<tr>
<th>Domain</th>
<td><a href="/accounts?domain={{ accountData['domain'] | escape('url') }}">{{ accountData['domain'] }}</a></td>
<td><a href="/accounts?domain={{ account.getDomain() | escape('url') }}">{{ account.getDomain() }}</a></td>
</tr>
<tr>
<th>Password</th>
@ -42,23 +42,23 @@
</tr>
<tr>
<th>Status</th>
<td>{{ accountData['is_active'] == '1' ? '<span class="green">Active</span>' : '<span class="red">Inactive</span>' }}</td>
<td>{{ account.isActive() ? '<span class="green">Active</span>' : '<span class="red">Inactive</span>' }}</td>
</tr>
<tr>
<th>Home directory</th>
<td><span class="gray">/srv/vmail/</span>{{ accountData['home_dir'] }}</td>
<td><span class="gray">/srv/vmail/</span>{{ account.getHomeDir() }}</td>
</tr>
<tr>
<th>Admin memo</th>
<td>{{ accountData['memo'] | nl2br }}</td>
<td>{{ account.getMemo() | nl2br }}</td>
</tr>
<tr>
<th>Created at</th>
<td>{{ accountData['created_at'] }}</td>
<td>{{ account.getCreatedAt() | date }}</td>
</tr>
<tr>
<th>Last modified at</th>
<td>{{ accountData['modified_at'] }}</td>
<td>{{ account.getModifiedAt() | date }}</td>
</tr>
</table>

View File

@ -28,7 +28,7 @@
<table>
<tr>
<td>Current username:</td>
<td>{{ accountData['username'] }}</td>
<td>{{ account.getUsername() }}</td>
</tr>
<tr>
<td><label for="edit_username">New username:</label></td>
@ -67,13 +67,13 @@
<table>
<tr>
<td>Current status:</td>
<td>{{ accountData['is_active'] == '1' ? '<span class="green">Active</span>' : '<span class="red">Inactive</span>' }}</td>
<td>{{ account.isActive() ? '<span class="green">Active</span>' : '<span class="red">Inactive</span>' }}</td>
</tr>
<tr>
<td>New status:</td>
<td>
<label>
<input type="checkbox" name="is_active" {{ accountData['is_active'] == '1' ? 'checked' : '' }}/> Active
<input type="checkbox" name="is_active" {{ account.isActive() ? 'checked' : '' }}/> Active
</label>
</td>
</tr>
@ -87,7 +87,7 @@
<table>
<tr>
<td>Current home directory:</td>
<td><span class="gray">/srv/vmail/</span>{{ accountData['home_dir'] }}</td>
<td><span class="gray">/srv/vmail/</span>{{ account.getHomeDir() }}</td>
</tr>
<tr>
<td><label for="edit_home_dir">New home directory:</label></td>
@ -104,7 +104,7 @@
<table>
<tr>
<td><label for="edit_memo">Admin memo:</label></td>
<td><textarea id="edit_memo" name="memo" style="min-width: 40em;">{{ accountData['memo'] }}</textarea></td>
<td><textarea id="edit_memo" name="memo" style="min-width: 40em;">{{ account.getMemo() }}</textarea></td>
</tr>
</table>
</div>

View File

@ -37,15 +37,15 @@
</tr>
{% if accountList %}
{% for account in accountList -%}
<tr{% if account['is_active'] != 1 %} class="inactive"{% endif %}>
<td><a href="/accounts/{{ account['user_id'] }}">{{ account['username'] }}</a></td>
<td><a href="/accounts?domain={{ account['domain'] }}">{{ account['domain'] }}</a></td>
<td>{{ account['alias_count'] }}</td>
<td>{{ account['is_active'] == 1 ? 'Yes' : 'No' }}</td>
<td class="detail_column"><span class="gray">vmail/</span>{{ account['home_dir'] }}</td>
<td class="detail_column">{{ account['memo'] }}</td>
<td>{{ account['created_at'] }}</td>
<td class="detail_column">{{ account['modified_at'] }}</td>
<tr{% if not account.isActive() %} class="inactive"{% endif %}>
<td><a href="/accounts/{{ account.getId() }}">{{ account.getUsername() }}</a></td>
<td><a href="/accounts?domain={{ account.getDomain() }}">{{ account.getDomain() }}</a></td>
<td>{{ account.getAliasCount() }}</td>
<td>{{ account.isActive() ? 'Yes' : 'No' }}</td>
<td class="detail_column"><span class="gray">vmail/</span>{{ account.getHomeDir() }}</td>
<td class="detail_column">{{ account.getMemo() }}</td>
<td>{{ account.getCreatedAt() | date }}</td>
<td class="detail_column">{{ account.getModifiedAt() | date }}</td>
</tr>
{% endfor %}
{% else %}