This commit is contained in:
2026-02-04 23:31:53 +01:00
commit cd59ccd99b
2401 changed files with 56808 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
<?php
/**
* @var array $values
* @var string|null $formId
* @var bool $detailsOpenAll
* @var bool $isReadOnly
* @var array $tenants
* @var array $selectedTenantIds
*/
use MintyPHP\Session;
$values = $values ?? [];
$formId = $formId ?? 'department-form';
$detailsOpenAll = $detailsOpenAll ?? false;
$isReadOnly = $isReadOnly ?? false;
$tenants = $tenants ?? [];
$selectedTenantIds = $selectedTenantIds ?? [];
$detailsOpenAttr = $detailsOpenAll ? 'open' : '';
$readonlyAttr = $isReadOnly ? 'readonly' : '';
$disabledAttr = $isReadOnly ? 'disabled' : '';
$dataDisabledAttr = $isReadOnly ? 'data-disabled="true"' : '';
?>
<form id="<?php e($formId); ?>" method="post">
<details name="basic-data" open>
<summary>Stammdaten</summary>
<hr>
<label for="description">
<span><?php e(t('Description')); ?></span>
<input required type="text" name="description" id="description" value="<?php e($values['description'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</details>
<?php if ($tenants): ?>
<label>
<span><?php e(t('Assigned tenants')); ?></span>
</label>
<select id="department-tenants" name="tenant_ids" multiple data-multi-select="true" <?php e($dataDisabledAttr); ?> <?php e($disabledAttr); ?>
data-placeholder="<?php e(t('Select tenants')); ?>" data-search="true"
data-search-placeholder="<?php e(t('Search...')); ?>" data-select-all="true"
data-select-all-label="<?php e(t('Select all')); ?>">
<?php foreach ($tenants as $tenant): ?>
<?php
$tenantId = (int) ($tenant['id'] ?? 0);
$selected = in_array($tenantId, $selectedTenantIds ?? [], true) ? 'selected' : '';
?>
<option value="<?php e((string) $tenantId); ?>" <?php e($selected); ?>>
<?php e($tenant['description'] ?? ''); ?>
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
<?php Session::getCsrfInput(); ?>
</form>

View File

@@ -0,0 +1,75 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\DepartmentService;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_CREATE);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$allowedTenantIds = TenantScopeService::getUserTenantIds($currentUserId);
if (TenantScopeService::isStrict() && !$allowedTenantIds) {
Router::redirect('error/forbidden');
return;
}
$errors = [];
$form = [
'description' => '',
];
$tenants = TenantService::list();
if ($allowedTenantIds) {
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool {
$tenantId = (int) ($tenant['id'] ?? 0);
return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true);
}));
} elseif (TenantScopeService::isStrict()) {
$tenants = [];
}
$selectedTenantIds = [];
if (isset($_POST['description'])) {
$result = DepartmentService::createFromAdmin($_POST, $currentUserId);
$form = $result['form'] ?? $form;
$errors = $result['errors'] ?? [];
$tenantIds = $_POST['tenant_ids'] ?? [];
if (!is_array($tenantIds)) {
$tenantIds = [$tenantIds];
}
$selectedTenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$selectedTenantIds = TenantScopeService::filterTenantIdsForUser($selectedTenantIds, $currentUserId);
if (TenantScopeService::isStrict() && !$selectedTenantIds) {
$errors[] = t('Please select at least one tenant');
}
if (($result['ok'] ?? false) && !$errors) {
$departmentId = (int) ($result['id'] ?? 0);
if ($departmentId) {
DepartmentService::syncTenants($departmentId, $selectedTenantIds);
}
$action = (string) ($_POST['action'] ?? 'create');
if ($action === 'create_close') {
Flash::success('Department created', 'admin/departments', 'department_created');
Router::redirect('admin/departments');
} else {
$uuid = (string) ($result['uuid'] ?? '');
if ($uuid !== '') {
$target = "admin/departments/edit/{$uuid}";
Flash::success('Department created', $target, 'department_created');
Router::redirect($target);
} else {
Flash::success('Department created', 'admin/departments', 'department_created');
Router::redirect('admin/departments');
}
}
}
}
Buffer::set('title', t('Create department'));

View File

@@ -0,0 +1,44 @@
<?php
/**
* @var array<int, string> $errors
* @var array $form
*/
?>
<div class="app-details-container">
<section>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
href="/admin/departments"><?php e(t('Departments')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('Create department')); ?>
</div>
<div class="app-details-titlebar">
<h1>
<a href="admin/departments" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
<?php e(t('Create department')); ?>
</h1>
<div class="app-details-titlebar-actions">
<button type="submit" form="department-form" name="action" value="create" class="secondary outline">
<?php e(t('Create')); ?>
</button>
<button type="submit" form="department-form" name="action" value="create_close" class="primary">
<?php e(t('Create & close')); ?>
</button>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="notice" data-variant="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php
$values = $form ?? [];
$detailsOpenAll = true;
require __DIR__ . '/_form.phtml';
?>
</section>
</div>

View File

@@ -0,0 +1,52 @@
<?php
use MintyPHP\Router;
use MintyPHP\Support\Guard;
use MintyPHP\Service\DepartmentService;
use MintyPHP\Service\SettingService;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$tenantIds = TenantScopeService::getUserTenantIds($currentUserId);
$limit = (int) ($_GET['limit'] ?? 10);
$offset = (int) ($_GET['offset'] ?? 0);
$search = trim((string) ($_GET['search'] ?? ''));
$order = (string) ($_GET['order'] ?? 'description');
$dir = (string) ($_GET['dir'] ?? 'asc');
$tenant = trim((string) ($_GET['tenant'] ?? ''));
$result = DepartmentService::listPaged([
'limit' => $limit,
'offset' => $offset,
'search' => $search,
'order' => $order,
'dir' => $dir,
'tenant' => $tenant,
'tenantIds' => $tenantIds,
'tenantUserId' => $currentUserId,
]);
$defaultDepartmentId = SettingService::getDefaultDepartmentId();
$rows = [];
foreach ($result['rows'] as $row) {
$departmentId = (int) ($row['id'] ?? 0);
$rows[] = [
'id' => $row['id'] ?? null,
'uuid' => $row['uuid'] ?? '',
'is_default' => $departmentId > 0 && $defaultDepartmentId === $departmentId,
'description' => $row['description'] ?? '',
'tenants' => $row['tenant_labels'] ?? [],
'created' => dt($row['created'] ?? ''),
'modified' => dt($row['modified'] ?? ''),
];
}
Router::json([
'data' => $rows,
'total' => $result['total'] ?? 0,
]);

View File

@@ -0,0 +1,37 @@
<?php
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\AuthService;
use MintyPHP\Service\DepartmentService;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_DELETE);
$uuid = trim((string) ($id ?? ''));
if ($uuid === '') {
Router::redirect('admin/departments');
}
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$department = $uuid !== '' ? DepartmentService::findByUuid($uuid) : null;
if ($department && !TenantScopeService::canAccess('departments', (int) ($department['id'] ?? 0), $currentUserId)) {
Router::redirect('error/forbidden');
return;
}
$result = DepartmentService::deleteByUuid($uuid);
if (!($result['ok'] ?? false)) {
Flash::error('Department not found', 'admin/departments', 'department_not_found');
Router::redirect('admin/departments');
}
if ($currentUserId > 0) {
AuthService::loadTenantDataIntoSession($currentUserId);
}
Flash::success('Department deleted', 'admin/departments', 'department_deleted');
Router::redirect('admin/departments');

View File

@@ -0,0 +1,111 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\DepartmentService;
use MintyPHP\Repository\TenantDepartmentRepository;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\UserService;
use MintyPHP\Service\SettingService;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId);
}
$allowedTenantIds = TenantScopeService::getUserTenantIds($currentUserId);
$uuid = trim((string) ($id ?? ''));
$department = $uuid !== '' ? DepartmentService::findByUuid($uuid) : null;
if (!$department) {
Flash::error('Department not found', 'admin/departments', 'department_not_found');
Router::redirect('admin/departments');
}
$departmentId = (int) ($department['id'] ?? 0);
if (!TenantScopeService::canAccess('departments', $departmentId, $currentUserId)) {
Router::redirect('error/forbidden');
return;
}
$creatorId = (int) ($department['created_by'] ?? 0);
if ($creatorId > 0) {
$creator = UserService::findById($creatorId);
if ($creator) {
$creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? ''));
$department['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? '');
$department['created_by_uuid'] = $creator['uuid'] ?? null;
}
}
$modifierId = (int) ($department['modified_by'] ?? 0);
if ($modifierId > 0) {
$modifier = UserService::findById($modifierId);
if ($modifier) {
$modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? ''));
$department['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? '');
$department['modified_by_uuid'] = $modifier['uuid'] ?? null;
}
}
$errors = [];
$form = $department;
$tenants = TenantService::list();
$selectedTenantIds = TenantDepartmentRepository::listTenantIdsByDepartmentId($departmentId);
if ($allowedTenantIds) {
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool {
$tenantId = (int) ($tenant['id'] ?? 0);
return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true);
}));
$selectedTenantIds = array_values(array_intersect($selectedTenantIds, $allowedTenantIds));
} elseif (TenantScopeService::isStrict()) {
$tenants = [];
$selectedTenantIds = [];
}
if (isset($_POST['description'])) {
if (!PermissionService::userHas($currentUserId, PermissionService::DEPARTMENTS_UPDATE)) {
Router::redirect('error/forbidden');
return;
}
$result = DepartmentService::updateFromAdmin($departmentId, $_POST, $currentUserId);
$form = $result['form'] ?? $form;
$errors = $result['errors'] ?? [];
$tenantIds = $_POST['tenant_ids'] ?? [];
if (!is_array($tenantIds)) {
$tenantIds = [$tenantIds];
}
$selectedTenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$selectedTenantIds = TenantScopeService::filterTenantIdsForUser($selectedTenantIds, $currentUserId);
if (TenantScopeService::isStrict() && !$selectedTenantIds) {
$errors[] = t('Please select at least one tenant');
}
if (($result['ok'] ?? false) && !$errors) {
$existingTenantIds = TenantDepartmentRepository::listTenantIdsByDepartmentId($departmentId);
$finalTenantIds = TenantScopeService::mergeTenantIdsPreservingOutOfScope(
$selectedTenantIds,
$existingTenantIds,
$allowedTenantIds
);
$cleaned = DepartmentService::syncTenants($departmentId, $finalTenantIds);
$action = (string) ($_POST['action'] ?? 'save');
if ($cleaned > 0) {
Flash::info(t('Department assignments cleaned: %d', $cleaned), "admin/departments/edit/{$uuid}", 'department_assignments_cleaned');
}
if ($action === 'save_close') {
Flash::success('Department updated', 'admin/departments', 'department_updated');
Router::redirect('admin/departments');
} else {
Flash::success('Department updated', "admin/departments/edit/{$uuid}", 'department_updated');
Router::redirect("admin/departments/edit/{$uuid}");
}
}
}
Buffer::set('title', t('Edit department'));

View File

@@ -0,0 +1,167 @@
<?php
/**
* @var array<int, string> $errors
* @var array $form
* @var array $department
*/
use MintyPHP\Session;
$values = $form ?? $department ?? [];
$isReadOnly = !can('departments.update');
?>
<div class="app-details-container">
<section>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
href="/admin/departments"><?php e(t('Departments')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('Edit department')); ?>
</div>
<div class="app-details-titlebar">
<h1>
<a href="admin/departments" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
<?php e(t('Edit department')); ?>
</h1>
<div class="app-details-titlebar-actions">
<?php if (can('departments.delete')): ?>
<details class="dropdown">
<summary role="button" class="outline secondary"><i class="bi bi-three-dots"></i></summary>
<ul dir="rtl">
<li>
<form method="post" action="admin/departments/delete/<?php e($values['uuid'] ?? ''); ?>"
onsubmit="return confirm('<?php e(t('Delete this department?')); ?>');">
<?php Session::getCsrfInput(); ?>
<button type="submit" class="transparent">
<?php e(t('Delete')); ?>
</button>
</form>
</li>
</ul>
</details>
<?php endif; ?>
<?php if (can('departments.update')): ?>
<button type="submit" form="department-form" name="action" value="save" class="secondary outline">
<?php e(t('Save')); ?>
</button>
<button type="submit" form="department-form" name="action" value="save_close" class="primary">
<?php e(t('Save & close')); ?>
</button>
<?php endif; ?>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="app-details-errors">
<div class="notice" data-variant="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
<?php endforeach; ?>
</ul>
</div>
</div>
<?php endif; ?>
<?php
$detailsOpenAll = false;
$isReadOnly = $isReadOnly ?? false;
require __DIR__ . '/_form.phtml';
?>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<h2><?php e($values['description'] ?? ''); ?></h2>
<p><?php e(t('Department')); ?></p>
</hgroup>
<hr>
<div class="grid">
<div>
<small><?php e(t('ID')); ?></small>
<p>
<span class="badge" data-variant="neutral" data-copy="true"
data-copy-value="<?php e($values['id'] ?? ''); ?>">
<?php e($values['id'] ?? '-'); ?>
</span>
</p>
</div>
</div>
<?php
$createdByLabel = $values['created_by_label'] ?? '';
$createdById = $values['created_by'] ?? null;
$createdByUuid = $values['created_by_uuid'] ?? null;
$modifiedByLabel = $values['modified_by_label'] ?? '';
$modifiedById = $values['modified_by'] ?? null;
$modifiedByUuid = $values['modified_by_uuid'] ?? null;
?>
<div class="grid">
<div>
<small><?php e(t('Created')); ?></small>
<p><?php e(dt($values['created'] ?? '') ?: '-'); ?></p>
</div>
<div>
<small>
<?php e(t('Created by')); ?>
</small>
<p>
<?php if ($createdByLabel !== ''): ?>
<?php if ($createdByUuid): ?>
<a href="admin/users/edit/<?php e($createdByUuid); ?>"><?php e($createdByLabel); ?></a>
<?php else: ?>
<?php e($createdByLabel); ?>
<?php endif; ?>
<?php elseif ($createdById): ?>
<?php e('#' . $createdById); ?>
<?php else: ?>
-
<?php endif; ?>
</p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Modified')); ?></small>
<p><?php e(dt($values['modified'] ?? '') ?: '-'); ?></p>
</div>
<div>
<small>
<?php e(t('Modified by')); ?>
</small>
<p>
<?php if ($modifiedByLabel !== ''): ?>
<?php if ($modifiedByUuid): ?>
<a href="admin/users/edit/<?php e($modifiedByUuid); ?>"><?php e($modifiedByLabel); ?></a>
<?php else: ?>
<?php e($modifiedByLabel); ?>
<?php endif; ?>
<?php elseif ($modifiedById): ?>
<?php e('#' . $modifiedById); ?>
<?php else: ?>
-
<?php endif; ?>
</p>
</div>
</div>
<div class="grid">
<div>
<small>
<?php e(t('UUID')); ?>
</small>
<p>
<span class="badge" data-variant="neutral" data-copy="true"
data-copy-value="<?php e($values['uuid'] ?? ''); ?>">
<?php
$uuidValue = (string) ($values['uuid'] ?? '');
$uuidDisplay = $uuidValue !== '' ? substr($uuidValue, 0, 10) : '-';
e($uuidDisplay);
?>
</span>
</p>
</div>
</div>
</div>
</aside>
</div>

View File

@@ -0,0 +1,39 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Guard;
use MintyPHP\Session;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::DEPARTMENTS_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId);
}
$tenants = TenantService::list();
$allowedTenantIds = TenantScopeService::getUserTenantIds($currentUserId);
if ($allowedTenantIds) {
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool {
$tenantId = (int) ($tenant['id'] ?? 0);
return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true);
}));
} elseif (TenantScopeService::isStrict()) {
$tenants = [];
}
usort($tenants, static function ($a, $b) {
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
});
Buffer::set('title', t('Departments'));
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$csrfKey = Session::$csrfSessionKey;
$csrfToken = $_SESSION[$csrfKey] ?? '';
Buffer::set(
'grid_csrf',
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);

View File

@@ -0,0 +1,175 @@
<?php
use MintyPHP\Router;
$canCreateDepartments = can('departments.create');
$canDeleteDepartments = can('departments.delete');
$activeTenant = trim((string) ($_GET['tenant'] ?? ''));
$showTenantTabs = isset($tenants) && is_array($tenants) && count($tenants) > 1;
?>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><?php e(t('Departments')); ?>
</div>
<div class="app-list-titlebar">
<h1><?php e(t('Departments')); ?></h1>
<div class="app-list-titlebar-actions">
<button
class="outline secondary"
type="button"
data-toolbar-toggle
data-toolbar-target="#departments-toolbar"
data-toolbar-text-show="<?php e(t('Show filters')); ?>"
data-toolbar-text-hide="<?php e(t('Hide filters')); ?>"
>
<i class="bi bi-funnel-fill"></i> <span data-toolbar-label><?php e(t('Hide filters')); ?></span>
</button>
<?php if ($canCreateDepartments): ?>
<a role="button" class="primary" href="admin/departments/create">
<i class="bi bi-plus"></i> <?php e(t('Create department')); ?>
</a>
<?php endif; ?>
</div>
</div>
<?php if ($showTenantTabs): ?>
<div class="app-list-tabs" id="departments-tabs">
<a href="admin/departments" class="<?php e($activeTenant === '' ? 'tab-link is-active' : 'tab-link'); ?>">
<?php e(t('All')); ?>
</a>
<?php foreach ($tenants ?? [] as $tenant): ?>
<?php if (!empty($tenant['uuid'])): ?>
<?php $isActive = $activeTenant === $tenant['uuid']; ?>
<a href="admin/departments?tenant=<?php e($tenant['uuid']); ?>" class="<?php e($isActive ? 'tab-link is-active' : 'tab-link'); ?>">
<?php e($tenant['description'] ?? ''); ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="app-list-toolbar" id="departments-toolbar">
<input type="hidden" id="department-tenant-filter" value="<?php e($activeTenant); ?>">
<input type="search" id="department-search" placeholder="<?php e(t('Search...')); ?>">
</div>
<div class="app-list-table">
<div id="departments-grid"></div>
</div>
<?php $gridFactoryVersion = @filemtime('web/js/core/app-grid-factory.js') ?: time(); ?>
<script src="<?php e(asset('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="module">
import { initToolbarToggles } from "<?php e(asset('js/components/app-toggle-toolbar.js')); ?>";
import { createServerGrid } from "<?php e(asset('js/core/app-grid-factory.js?v=' . $gridFactoryVersion)); ?>";
import { buildUrl, postAction, badgeHtml } from "<?php e(asset('js/pages/app-list-utils.js')); ?>";
initToolbarToggles();
const appBase = "<?php e(localeBase()); ?>";
const csrf = <?php MintyPHP\Buffer::get('grid_csrf'); ?>;
const deleteConfirm = "<?php e(t('Delete this department?')); ?>";
const buildActionUrl = (action, id) => buildUrl(appBase, `admin/departments/${action}/${id}`);
const initDepartmentsGrid = () => {
const formatBadge = (value) => badgeHtml(gridjs, value);
const buildBadgeList = (items) => {
if (!Array.isArray(items) || items.length === 0) return '';
const badges = items.map((label) => `<span class="badge" data-variant="neutral">${label}</span>`).join(' ');
return `<div class="badge-list">${badges}</div>`;
};
const gridConfig = createServerGrid({
gridjs: window.gridjs,
container: '#departments-grid',
dataUrl: 'admin/departments/data',
appBase,
columns: [
{ name: "<?php e(t('Description')); ?>", sort: true },
{
name: "<?php e(t('Created')); ?>",
sort: true,
formatter: (cell) => formatBadge(cell)
},
{
name: "<?php e(t('Modified')); ?>",
sort: true,
formatter: (cell) => formatBadge(cell)
},
{
name: "UUID",
hidden: true
},
{
name: "ID",
hidden: true
}
],
sortColumns: ['description', 'created', 'modified', null, null],
paginationLimit: 10,
language: <?php MintyPHP\Buffer::get('grid_lang'); ?>,
mapData: (data) => data.data.map((row) => [
row.description,
row.created,
row.modified,
row.uuid,
row.id
]),
actions: {
enabled: true,
label: "<?php e(t('Actions')); ?>",
getRowId: (row) => row?.cells?.[3]?.data,
items: [
{
key: 'edit',
type: 'link',
label: "<i class=\"bi bi-pencil-fill\"></i>",
href: ({ id }) => buildActionUrl('edit', id)
},
<?php if ($canDeleteDepartments): ?>
{
key: 'delete',
label: "<i class=\"bi bi-trash3-fill\"></i>",
className: 'danger outline',
confirm: deleteConfirm
}
<?php endif; ?>
],
onAction: async ({ action, id, grid }) => {
if (action !== 'delete') return;
const url = buildActionUrl('delete', id);
const response = await postAction(url, csrf);
if (response.ok) {
grid.forceRender();
} else {
window.location.href = new URL('admin/departments', appBase).toString();
}
}
},
search: {
input: '#department-search',
param: 'search',
debounce: 250
},
filters: [
{
input: '#department-tenant-filter',
param: 'tenant'
}
],
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[4]?.data
}),
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[3]?.data;
return uuid ? new URL(`admin/departments/edit/${uuid}`, appBase).toString() : '';
}
}
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Departments grid init failed');
return null;
}
return gridConfig;
};
initDepartmentsGrid();
</script>

View File

@@ -0,0 +1,16 @@
<?php
use MintyPHP\Support\Guard;
use MintyPHP\Buffer;
Guard::requireLogin();
$slug = trim((string) ($slug ?? ''));
if ($slug === '') {
require __DIR__ . '/index().php';
return;
}
http_response_code(404);
Buffer::set('title', t('Page not found'));
$notFound = true;

10
pages/admin/index().php Normal file
View File

@@ -0,0 +1,10 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Guard;
Guard::requireLogin();
$user = $_SESSION['user'] ?? null;
$first_name = $user['first_name'] ?? '';
Buffer::set('title', t('Admin'));

View File

@@ -0,0 +1,25 @@
<?php
/**
* @var array|null $user
* @var string $first_name
*/
if (!empty($notFound)) {
require __DIR__ . '/../error/not_found(error).phtml';
return;
}
?>
<div class="app-breadcrumb">
<a href="/logout">Login</a><i class="bi bi-chevron-right"></i><?php e(t('Home')); ?>
</div>
<div class="app-dashboard-titlebar">
<h1><?php e(t('Welcome back')); ?> <?php e($first_name )?></h1>
<div class="app-dashboard-titlebar-actions">
</div>
</div>
<div class="app-dashboard">
</div>

View File

@@ -0,0 +1,57 @@
<?php
use MintyPHP\Router;
use MintyPHP\Support\Guard;
use MintyPHP\Service\MailLogService;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::MAIL_LOG_VIEW);
$limit = (int) ($_GET['limit'] ?? 10);
$offset = (int) ($_GET['offset'] ?? 0);
$search = trim((string) ($_GET['search'] ?? ''));
$order = (string) ($_GET['order'] ?? 'created_at');
$dir = (string) ($_GET['dir'] ?? 'desc');
$status = trim((string) ($_GET['status'] ?? ''));
$createdFrom = trim((string) ($_GET['created_from'] ?? ''));
$createdTo = trim((string) ($_GET['created_to'] ?? ''));
$result = MailLogService::listPaged([
'limit' => $limit,
'offset' => $offset,
'search' => $search,
'order' => $order,
'dir' => $dir,
'status' => $status,
'created_from' => $createdFrom,
'created_to' => $createdTo,
]);
$rows = [];
foreach ($result['rows'] as $row) {
$status = (string) ($row['status'] ?? '');
$statusBadge = 'neutral';
if ($status === 'sent') {
$statusBadge = 'success';
} elseif ($status === 'failed') {
$statusBadge = 'danger';
}
$rows[] = [
'id' => $row['id'] ?? null,
'to_email' => $row['to_email'] ?? '',
'subject' => $row['subject'] ?? '',
'template' => $row['template'] ?? '',
'status' => $status,
'status_badge' => $statusBadge,
'status_label' => t(ucfirst($status)),
'created_at' => dt($row['created_at'] ?? ''),
'sent_at' => dt($row['sent_at'] ?? ''),
];
}
Router::json([
'data' => $rows,
'total' => $result['total'] ?? 0,
]);

View File

@@ -0,0 +1,23 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Guard;
use MintyPHP\Session;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::MAIL_LOG_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId);
}
Buffer::set('title', t('Mail logs'));
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$csrfKey = Session::$csrfSessionKey;
$csrfToken = $_SESSION[$csrfKey] ?? '';
Buffer::set(
'grid_csrf',
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);

View File

@@ -0,0 +1,174 @@
<?php
use MintyPHP\Router;
?>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><?php e(t('Mail logs')); ?>
</div>
<div class="app-list-titlebar">
<h1><?php e(t('Mail logs')); ?></h1>
<div class="app-list-titlebar-actions">
<button
class="outline secondary"
type="button"
data-toolbar-toggle
data-toolbar-target="#mail-log-toolbar"
data-toolbar-text-show="<?php e(t('Show filters')); ?>"
data-toolbar-text-hide="<?php e(t('Hide filters')); ?>"
>
<i class="bi bi-funnel-fill"></i> <span data-toolbar-label><?php e(t('Hide filters')); ?></span>
</button>
</div>
</div>
<div class="app-list-toolbar" id="mail-log-toolbar">
<label class="app-field">
<span><?php e(t('Search')); ?></span>
<input type="search" id="mail-log-search" placeholder="<?php e(t('Search...')); ?>">
</label>
<label class="app-field">
<span><?php e(t('Status')); ?></span>
<select id="mail-log-status-filter">
<option value=""><?php e(t('All statuses')); ?></option>
<option value="sent"><?php e(t('Sent')); ?></option>
<option value="queued"><?php e(t('Queued')); ?></option>
<option value="failed"><?php e(t('Failed')); ?></option>
</select>
</label>
<label class="app-field">
<span><?php e(t('Created from')); ?></span>
<input type="date" id="mail-log-created-from">
</label>
<label class="app-field">
<span><?php e(t('Created to')); ?></span>
<input type="date" id="mail-log-created-to">
</label>
</div>
<div class="app-list-table">
<div id="mail-log-grid"></div>
</div>
<?php $gridFactoryVersion = @filemtime('web/js/core/app-grid-factory.js') ?: time(); ?>
<script src="<?php e(asset('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="module">
import { initToolbarToggles } from "<?php e(asset('js/components/app-toggle-toolbar.js')); ?>";
import { createServerGrid } from "<?php e(asset('js/core/app-grid-factory.js?v=' . $gridFactoryVersion)); ?>";
import { buildUrl, badgeHtml } from "<?php e(asset('js/pages/app-list-utils.js')); ?>";
initToolbarToggles();
const appBase = "<?php e(localeBase()); ?>";
const initMailLogGrid = () => {
const formatBadge = (value) => badgeHtml(gridjs, value);
const gridConfig = createServerGrid({
gridjs: window.gridjs,
container: '#mail-log-grid',
dataUrl: 'admin/mail-log/data',
appBase,
columns: [
{
name: "<?php e(t('Created')); ?>",
sort: true,
formatter: (cell) => formatBadge(cell)
},
{
name: "<?php e(t('Recipient')); ?>",
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html(String(cell ?? ''));
}
return gridjs.html(String(cell ?? ''));
}
},
{
name: "<?php e(t('Subject')); ?>",
sort: true
},
{
name: "<?php e(t('Template')); ?>",
sort: false
},
{
name: "<?php e(t('Status')); ?>",
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html('');
}
const variant = cell.variant || 'neutral';
const label = cell.label || '';
return gridjs.html(`<span class="badge" data-variant="${variant}">${label}</span>`);
}
},
{
name: "ID",
hidden: true
}
],
sortColumns: ['created_at', 'to_email', 'subject', null, 'status', null],
paginationLimit: 10,
language: <?php MintyPHP\Buffer::get('grid_lang'); ?>,
mapData: (data) => data.data.map((row) => [
row.created_at,
row.to_email,
row.subject,
row.template || '-',
{
variant: row.status_badge,
label: row.status_label
},
row.id
]),
actions: {
enabled: true,
label: "<?php e(t('Actions')); ?>",
getRowId: (row) => row?.cells?.[5]?.data,
items: [
{
key: 'view',
type: 'link',
label: "<i class=\"bi bi-eye-fill\"></i>",
href: ({ id }) => buildUrl(appBase, `admin/mail-log/view/${id}`)
}
]
},
search: {
input: '#mail-log-search',
param: 'search',
debounce: 250
},
filters: [
{
input: '#mail-log-status-filter',
param: 'status'
},
{
input: '#mail-log-created-from',
param: 'created_from',
normalize: (value) => (value ? value : '')
},
{
input: '#mail-log-created-to',
param: 'created_to',
normalize: (value) => (value ? value : '')
}
],
urlSync: true,
rowDblClick: {
getUrl: (rowData) => {
const id = rowData?.cells?.[5]?.data;
return id ? new URL(`admin/mail-log/view/${id}`, appBase).toString() : '';
}
}
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Mail log grid init failed');
return null;
}
return gridConfig;
};
initMailLogGrid();
</script>

View File

@@ -0,0 +1,25 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\MailLogService;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::MAIL_LOG_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId);
}
$mailLogId = (int) ($id ?? 0);
$mailLog = $mailLogId > 0 ? MailLogService::find($mailLogId) : null;
if (!$mailLog) {
Flash::error('Mail log not found', 'admin/mail-log', 'mail_log_not_found');
Router::redirect('admin/mail-log');
}
Buffer::set('title', t('View mail log'));

View File

@@ -0,0 +1,137 @@
<?php
/**
* @var array $mailLog
*/
$mailLog = $mailLog ?? [];
$status = (string) ($mailLog['status'] ?? '');
$statusBadge = 'neutral';
if ($status === 'sent') {
$statusBadge = 'success';
} elseif ($status === 'failed') {
$statusBadge = 'danger';
}
?>
<div class="app-details-container">
<section>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
href="/admin/mail-log"><?php e(t('Mail logs')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('View')); ?>
</div>
<div class="app-details-titlebar">
<h1>
<a href="admin/mail-log" title="<?php e(t('Back')); ?>"><i class="bi bi-arrow-left"></i></a>
<?php e(t('View mail log')); ?>
</h1>
</div>
<div class="app-details-content">
<details open>
<summary><?php e(t('Email details')); ?></summary>
<hr>
<div class="app-details-section">
<div class="grid">
<div class="app-form-group">
<label><?php e(t('Recipient')); ?></label>
<input type="text" value="<?php e($mailLog['to_email'] ?? ''); ?>" readonly>
</div>
<div class="app-form-group">
<label><?php e(t('Subject')); ?></label>
<input type="text" value="<?php e($mailLog['subject'] ?? ''); ?>" readonly>
</div>
</div>
<div class="app-form-group">
<label><?php e(t('Template')); ?></label>
<input type="text" value="<?php e($mailLog['template'] ?? '-'); ?>" readonly>
</div>
</div>
</details>
<?php if ($status === 'sent'): ?>
<hr>
<details open>
<summary><?php e(t('Delivery information')); ?></summary>
<hr>
<div class="app-details-section">
<div class="app-form-group">
<label><?php e(t('Sent at')); ?></label>
<input type="text" value="<?php e(dt($mailLog['sent_at'] ?? '')); ?>" readonly>
</div>
<?php if (!empty($mailLog['provider_message_id'])): ?>
<div class="app-form-group">
<label><?php e(t('Provider message ID')); ?></label>
<input type="text" value="<?php e($mailLog['provider_message_id'] ?? ''); ?>" readonly>
</div>
<?php endif; ?>
</div>
</details>
<?php endif; ?>
<?php if ($status === 'failed' && !empty($mailLog['error_message'])): ?>
<hr>
<details open>
<summary><?php e(t('Error information')); ?></summary>
<hr>
<div class="app-details-section">
<div class="app-form-group">
<label><?php e(t('Error message')); ?></label>
<textarea readonly rows="6"><?php e($mailLog['error_message'] ?? ''); ?></textarea>
</div>
</div>
</details>
<?php endif; ?>
</div>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<strong><?php e($mailLog['to_email'] ?? ''); ?></strong>
<p><small><?php e(t('Mail log')); ?></small></p>
</hgroup>
<hr>
<div class="grid">
<div>
<small><?php e(t('ID')); ?></small>
<p>
<span class="badge" data-variant="neutral">
<?php e($mailLog['id'] ?? '-'); ?>
</span>
</p>
</div>
<div>
<small>
<?php e(t('Status')); ?>
</small>
<div>
<span class="badge" data-variant="<?php e($statusBadge); ?>">
<?php e(t(ucfirst($status))); ?>
</span>
</div>
</div>
</div>
<div>
<small><?php e(t('Created')); ?></small>
<p><?php e(dt($mailLog['created_at'] ?? '') ?: '-'); ?></p>
</div>
<?php if ($status === 'sent' && !empty($mailLog['sent_at'])): ?>
<div>
<small><?php e(t('Sent')); ?></small>
<p><?php e(dt($mailLog['sent_at'] ?? '') ?: '-'); ?></p>
</div>
<?php endif; ?>
<?php if (!empty($mailLog['provider_message_id'])): ?>
<div class="grid">
<div>
<small><?php e(t('Provider message ID')); ?></small>
<span class="badge" data-copy="true" data-copy-value="<?php e($mailLog['provider_message_id']); ?>"
data-variant="neutral"><?php e(substr($mailLog['provider_message_id'], 0, 20)); ?></span>
</div>
</div>
<?php endif; ?>
</div>
</aside>
</div>

View File

@@ -0,0 +1,38 @@
<?php
/**
* @var array $values
* @var string|null $formId
* @var bool $detailsOpenAll
* @var bool $isReadOnly
*/
use MintyPHP\Session;
$values = $values ?? [];
$formId = $formId ?? 'permission-form';
$detailsOpenAll = $detailsOpenAll ?? false;
$isReadOnly = $isReadOnly ?? false;
$detailsOpenAttr = $detailsOpenAll ? 'open' : '';
$readonlyAttr = $isReadOnly ? 'readonly' : '';
?>
<form id="<?php e($formId); ?>" method="post">
<details name="basic-data" <?php e($detailsOpenAttr); ?>>
<summary>Stammdaten</summary>
<hr>
<div class="grid">
<label for="permission-description">
<span><?php e(t('Description')); ?></span>
<input type="text" name="description" id="permission-description"
value="<?php e($values['description'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
<label for="permission-key">
<span><?php e(t('Permission key')); ?></span>
<input required type="text" name="key" id="permission-key" value="<?php e($values['key'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</div>
</details>
<hr>
<?php Session::getCsrfInput(); ?>
</form>

View File

@@ -0,0 +1,42 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::PERMISSIONS_CREATE);
$errors = [];
$form = [
'key' => '',
'description' => '',
];
if (isset($_POST['key'])) {
$result = PermissionService::createFromAdmin($_POST);
$form = $result['form'] ?? $form;
$errors = $result['errors'] ?? [];
if ($result['ok'] ?? false) {
$action = (string) ($_POST['action'] ?? 'create');
if ($action === 'create_close') {
Flash::success('Permission created', 'admin/permissions', 'permission_created');
Router::redirect('admin/permissions');
} else {
$id = (int) ($result['id'] ?? 0);
if ($id > 0) {
$target = "admin/permissions/edit/{$id}";
Flash::success('Permission created', $target, 'permission_created');
Router::redirect($target);
} else {
Flash::success('Permission created', 'admin/permissions', 'permission_created');
Router::redirect('admin/permissions');
}
}
}
}
Buffer::set('title', t('Create permission'));

View File

@@ -0,0 +1,44 @@
<?php
/**
* @var array<int, string> $errors
* @var array $form
*/
?>
<div class="app-details-container">
<section>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
href="/admin/permissions"><?php e(t('Permissions')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('Create permission')); ?>
</div>
<div class="app-details-titlebar">
<h1>
<a href="admin/permissions" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
<?php e(t('Create permission')); ?>
</h1>
<div class="app-details-titlebar-actions">
<button type="submit" form="permission-form" name="action" value="create" class="secondary outline">
<?php e(t('Create')); ?>
</button>
<button type="submit" form="permission-form" name="action" value="create_close" class="primary">
<?php e(t('Create & close')); ?>
</button>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="notice" data-variant="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php
$values = $form ?? [];
$detailsOpenAll = true;
require __DIR__ . '/_form.phtml';
?>
</section>
</div>

View File

@@ -0,0 +1,41 @@
<?php
use MintyPHP\Router;
use MintyPHP\Support\Guard;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::PERMISSIONS_VIEW);
if (!isset($_SESSION['user'])) {
http_response_code(401);
Router::json(['data' => [], 'total' => 0]);
}
$limit = (int) ($_GET['limit'] ?? 10);
$offset = (int) ($_GET['offset'] ?? 0);
$search = trim((string) ($_GET['search'] ?? ''));
$order = (string) ($_GET['order'] ?? 'key');
$dir = (string) ($_GET['dir'] ?? 'asc');
$result = PermissionService::listPaged([
'limit' => $limit,
'offset' => $offset,
'search' => $search,
'order' => $order,
'dir' => $dir,
]);
$rows = [];
foreach ($result['data'] ?? [] as $row) {
$rows[] = [
'id' => $row['id'] ?? null,
'key' => $row['key'] ?? '',
'description' => $row['description'] ?? '',
'created' => dt($row['created'] ?? ''),
];
}
Router::json([
'data' => $rows,
'total' => $result['total'] ?? 0,
]);

View File

@@ -0,0 +1,23 @@
<?php
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::PERMISSIONS_DELETE);
$id = (int) ($id ?? 0);
if ($id <= 0) {
Router::redirect('admin/permissions');
}
$result = PermissionService::deleteById($id);
if (!($result['ok'] ?? false)) {
Flash::error('Permission not found', 'admin/permissions', 'permission_not_found');
Router::redirect('admin/permissions');
}
Flash::success('Permission deleted', 'admin/permissions', 'permission_deleted');
Router::redirect('admin/permissions');

View File

@@ -0,0 +1,64 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Repository\RolePermissionRepository;
use MintyPHP\Repository\RoleRepository;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::PERMISSIONS_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId);
}
$id = (int) ($id ?? 0);
$permission = $id > 0 ? PermissionService::find($id) : null;
if (!$permission) {
Flash::error('Permission not found', 'admin/permissions', 'permission_not_found');
Router::redirect('admin/permissions');
}
$errors = [];
$form = $permission;
$roles = RoleRepository::list();
$selectedRoleIds = RolePermissionRepository::listRoleIdsByPermissionId($id);
if (isset($_POST['key'])) {
if (!PermissionService::userHas($currentUserId, PermissionService::PERMISSIONS_UPDATE)) {
Flash::error('Permission denied', "admin/permissions/edit/{$id}", 'permission_denied');
Router::redirect("admin/permissions/edit/{$id}");
return;
}
$result = PermissionService::updateFromAdmin($id, $_POST);
$form = $result['form'] ?? $form;
$errors = $result['errors'] ?? [];
$selectedRoleIds = $_POST['role_ids'] ?? $selectedRoleIds;
if (!is_array($selectedRoleIds)) {
$selectedRoleIds = [$selectedRoleIds];
}
$selectedRoleIds = array_values(array_unique(array_map('intval', $selectedRoleIds)));
if ($result['ok'] ?? false) {
$roleIds = $_POST['role_ids'] ?? [];
if (!is_array($roleIds)) {
$roleIds = [$roleIds];
}
$roleIds = array_values(array_unique(array_map('intval', $roleIds)));
RolePermissionRepository::replaceForPermission($id, $roleIds);
$action = (string) ($_POST['action'] ?? 'save');
if ($action === 'save_close') {
Flash::success('Permission updated', 'admin/permissions', 'permission_updated');
Router::redirect('admin/permissions');
} else {
Flash::success('Permission updated', "admin/permissions/edit/{$id}", 'permission_updated');
Router::redirect("admin/permissions/edit/{$id}");
}
}
}
Buffer::set('title', t('Edit permission'));

View File

@@ -0,0 +1,79 @@
<?php
/**
* @var array<int, string> $errors
* @var array $form
* @var array $permission
*/
?>
<div class="app-details-container">
<section>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
href="/admin/permissions"><?php e(t('Permissions')); ?></a><i
class="bi bi-chevron-right"></i><?php e(t('Edit permission')); ?>
</div>
<div class="app-details-titlebar">
<h1>
<a href="admin/permissions" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
<?php e(t('Edit permission')); ?>
</h1>
<div class="app-details-titlebar-actions">
<?php if (can('permissions.delete')): ?>
<details class="dropdown">
<summary role="button" class="outline secondary"><i class="bi bi-three-dots"></i></summary>
<ul dir="rtl">
<li>
<form method="post" action="admin/permissions/delete/<?php e((string) ($permission['id'] ?? '')); ?>"
onsubmit="return confirm('<?php e(t('Delete this permission?')); ?>');">
<?php MintyPHP\Session::getCsrfInput(); ?>
<button type="submit" class="transparent">
<?php e(t('Delete')); ?>
</button>
</form>
</li>
</ul>
</details>
<?php endif; ?>
<?php if (can('permissions.update')): ?>
<button type="submit" form="permission-form" name="action" value="save" class="secondary outline">
<?php e(t('Save')); ?>
</button>
<button type="submit" form="permission-form" name="action" value="save_close" class="primary">
<?php e(t('Save & close')); ?>
</button>
<?php endif; ?>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="notice" data-variant="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php
$values = $form ?? $permission ?? [];
$isReadOnly = !can('permissions.update');
$detailsOpenAll = true;
$isReadOnly = $isReadOnly ?? false;
require __DIR__ . '/_form.phtml';
?>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<h2><?php e($values['key'] ?? ''); ?></h2>
<p><?php e(t('Permission')); ?></p>
</hgroup>
<hr>
<?php if (!empty($roles)): ?>
<?php multiSelectForm('permission-roles', 'role_ids', 'Assigned roles', 'Select roles', $roles, $selectedRoleIds ?? [], $isReadOnly, 'description', 'permission-form'); ?>
<?php endif; ?>
</div>
</aside>
</div>

View File

@@ -0,0 +1,23 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Guard;
use MintyPHP\Session;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::PERMISSIONS_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId);
}
Buffer::set('title', t('Permissions'));
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$csrfKey = Session::$csrfSessionKey;
$csrfToken = $_SESSION[$csrfKey] ?? '';
Buffer::set(
'grid_csrf',
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);

View File

@@ -0,0 +1,121 @@
<?php
use MintyPHP\Router;
$canCreatePermissions = can('permissions.create');
$canUpdatePermissions = can('permissions.update');
$canDeletePermissions = can('permissions.delete');
?>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><?php e(t('Permissions')); ?>
</div>
<div class="app-list-titlebar">
<h1><?php e(t('Permissions')); ?></h1>
<div class="app-list-titlebar-actions">
<button
class="outline secondary"
type="button"
data-toolbar-toggle
data-toolbar-target="#permissions-toolbar"
data-toolbar-text-show="<?php e(t('Show filters')); ?>"
data-toolbar-text-hide="<?php e(t('Hide filters')); ?>"
>
<i class="bi bi-funnel-fill"></i> <span data-toolbar-label><?php e(t('Hide filters')); ?></span>
</button>
<?php if ($canCreatePermissions): ?>
<a role="button" class="primary" href="admin/permissions/create">
<i class="bi bi-plus"></i> <?php e(t('Create permission')); ?>
</a>
<?php endif; ?>
</div>
</div>
<div class="app-list-toolbar" id="permissions-toolbar">
<input type="search" id="permission-search" placeholder="<?php e(t('Search...')); ?>">
</div>
<div class="app-list-table">
<div id="permissions-grid"></div>
</div>
<?php $gridFactoryVersion = @filemtime('web/js/core/app-grid-factory.js') ?: time(); ?>
<script src="<?php e(asset('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="module">
import { initToolbarToggles } from "<?php e(asset('js/components/app-toggle-toolbar.js')); ?>";
import { createServerGrid } from "<?php e(asset('js/core/app-grid-factory.js?v=' . $gridFactoryVersion)); ?>";
import { buildUrl, postAction, badgeHtml } from "<?php e(asset('js/pages/app-list-utils.js')); ?>";
initToolbarToggles();
const appBase = "<?php e(localeBase()); ?>";
const csrf = <?php MintyPHP\Buffer::get('grid_csrf'); ?>;
const deleteConfirm = "<?php e(t('Delete this permission?')); ?>";
const buildActionUrl = (action, id) => buildUrl(appBase, `admin/permissions/${action}/${id}`);
const initPermissionsGrid = () => {
const gridConfig = createServerGrid({
gridjs: window.gridjs,
container: '#permissions-grid',
dataUrl: 'admin/permissions/data',
appBase,
columns: [
{ name: "<?php e(t('Description')); ?>", sort: true },
{ name: "<?php e(t('Permission key')); ?>", sort: true },
{ name: "<?php e(t('Created')); ?>", sort: true, formatter: (cell) => badgeHtml(gridjs, cell) },
{ name: 'ID', hidden: true }
],
sortColumns: ['description', 'key', 'created', null],
paginationLimit: 10,
language: <?php MintyPHP\Buffer::get('grid_lang'); ?>,
mapData: (data) => data.data.map((row) => [
row.description,
row.key,
row.created,
row.id
]),
actions: {
enabled: true,
label: "<?php e(t('Actions')); ?>",
getRowId: (row) => row?.cells?.[3]?.data,
items: [
{
key: 'edit',
type: 'link',
label: "<i class=\"bi bi-pencil-fill\"></i>",
href: ({ id }) => buildActionUrl('edit', id)
},
<?php if ($canDeletePermissions): ?>
{
key: 'delete',
label: "<i class=\"bi bi-trash3-fill\"></i>",
className: 'danger outline',
confirm: deleteConfirm
}
<?php endif; ?>
],
onAction: async ({ action, id, grid }) => {
if (action !== 'delete') return;
const url = buildActionUrl('delete', id);
const response = await postAction(url, csrf);
if (response.ok) {
grid.forceRender();
} else {
window.location.href = new URL('admin/permissions', appBase).toString();
}
}
},
rowDblClick: {
getUrl: (row) => {
const id = row?.cells?.[3]?.data;
return id ? buildActionUrl('edit', id) : '';
}
},
search: {
input: '#permission-search',
param: 'search',
debounce: 250
}
});
return gridConfig;
};
initPermissionsGrid();
</script>

View File

@@ -0,0 +1,39 @@
<?php
/**
* @var array $values
* @var string|null $formId
* @var bool $detailsOpenAll
* @var bool $isReadOnly
*/
use MintyPHP\Session;
$values = $values ?? [];
$formId = $formId ?? 'role-form';
$detailsOpenAll = $detailsOpenAll ?? false;
$isReadOnly = $isReadOnly ?? false;
$detailsOpenAttr = $detailsOpenAll ? 'open' : '';
$readonlyAttr = $isReadOnly ? 'readonly' : '';
?>
<form id="<?php e($formId); ?>" method="post">
<details name="basic-data" open>
<summary>Stammdaten</summary>
<hr>
<label for="description">
<span><?php e(t('Description')); ?></span>
<input required type="text" name="description" id="description" value="<?php e($values['description'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</details>
<hr>
<?php if (!empty($permissions)): ?>
<details name="permissions" <?php e($detailsOpenAttr); ?>>
<summary><?php e(t('Permissions')); ?></summary>
<hr>
<?php multiSelectForm('role-permissions', 'permission_ids', 'Assigned permissions', 'Select permissions', $permissions, $selectedPermissionIds ?? [], $isReadOnly, ['description', 'key']); ?>
</details>
<hr>
<?php endif; ?>
<?php Session::getCsrfInput(); ?>
</form>

View File

@@ -0,0 +1,61 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Repository\PermissionRepository;
use MintyPHP\Repository\RolePermissionRepository;
use MintyPHP\Service\RoleService;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::ROLES_CREATE);
$errors = [];
$form = [
'description' => '',
];
$permissions = PermissionRepository::list();
$selectedPermissionIds = [];
if (isset($_POST['description'])) {
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$result = RoleService::createFromAdmin($_POST, $currentUserId);
$form = $result['form'] ?? $form;
$errors = $result['errors'] ?? [];
$selectedPermissionIds = $_POST['permission_ids'] ?? $selectedPermissionIds;
if (!is_array($selectedPermissionIds)) {
$selectedPermissionIds = [$selectedPermissionIds];
}
$selectedPermissionIds = array_values(array_unique(array_map('intval', $selectedPermissionIds)));
if ($result['ok'] ?? false) {
$permissionIds = $_POST['permission_ids'] ?? [];
if (!is_array($permissionIds)) {
$permissionIds = [$permissionIds];
}
$permissionIds = array_values(array_unique(array_map('intval', $permissionIds)));
$createdId = (int) ($result['id'] ?? 0);
if ($createdId > 0) {
RolePermissionRepository::replaceForRole($createdId, $permissionIds);
}
$action = (string) ($_POST['action'] ?? 'create');
if ($action === 'create_close') {
Flash::success('Role created', 'admin/roles', 'role_created');
Router::redirect('admin/roles');
} else {
$uuid = (string) ($result['uuid'] ?? '');
if ($uuid !== '') {
$target = "admin/roles/edit/{$uuid}";
Flash::success('Role created', $target, 'role_created');
Router::redirect($target);
} else {
Flash::success('Role created', 'admin/roles', 'role_created');
Router::redirect('admin/roles');
}
}
}
}
Buffer::set('title', t('Create role'));

View File

@@ -0,0 +1,44 @@
<?php
/**
* @var array<int, string> $errors
* @var array $form
*/
?>
<div class="app-details-container">
<section>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
href="/admin/roles"><?php e(t('Roles')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('Create role')); ?>
</div>
<div class="app-details-titlebar">
<h1>
<a href="admin/roles" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
<?php e(t('Create role')); ?>
</h1>
<div class="app-details-titlebar-actions">
<button type="submit" form="role-form" name="action" value="create" class="secondary outline">
<?php e(t('Create')); ?>
</button>
<button type="submit" form="role-form" name="action" value="create_close" class="primary">
<?php e(t('Create & close')); ?>
</button>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="notice" data-variant="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php
$values = $form ?? [];
$detailsOpenAll = true;
require __DIR__ . '/_form.phtml';
?>
</section>
</div>

View File

@@ -0,0 +1,43 @@
<?php
use MintyPHP\Router;
use MintyPHP\Support\Guard;
use MintyPHP\Service\RoleService;
use MintyPHP\Service\SettingService;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::ROLES_VIEW);
$limit = (int) ($_GET['limit'] ?? 10);
$offset = (int) ($_GET['offset'] ?? 0);
$search = trim((string) ($_GET['search'] ?? ''));
$order = (string) ($_GET['order'] ?? 'description');
$dir = (string) ($_GET['dir'] ?? 'asc');
$result = RoleService::listPaged([
'limit' => $limit,
'offset' => $offset,
'search' => $search,
'order' => $order,
'dir' => $dir,
]);
$defaultRoleId = SettingService::getDefaultRoleId();
$rows = [];
foreach ($result['rows'] as $row) {
$roleId = (int) ($row['id'] ?? 0);
$rows[] = [
'id' => $row['id'] ?? null,
'uuid' => $row['uuid'] ?? '',
'is_default' => $roleId > 0 && $defaultRoleId === $roleId,
'description' => $row['description'] ?? '',
'created' => dt($row['created'] ?? ''),
'modified' => dt($row['modified'] ?? ''),
];
}
Router::json([
'data' => $rows,
'total' => $result['total'] ?? 0,
]);

View File

@@ -0,0 +1,29 @@
<?php
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\RoleService;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::ROLES_DELETE);
$uuid = trim((string) ($id ?? ''));
if ($uuid === '') {
Router::redirect('admin/roles');
}
$result = RoleService::deleteByUuid($uuid);
if (!($result['ok'] ?? false)) {
$error = $result['error'] ?? 'unknown';
if ($error === 'admin_role_protected') {
Flash::error('The Admin role cannot be deleted', 'admin/roles', 'admin_role_protected');
} else {
Flash::error('Role not found', 'admin/roles', 'role_not_found');
}
Router::redirect('admin/roles');
}
Flash::success('Role deleted', 'admin/roles', 'role_deleted');
Router::redirect('admin/roles');

View File

@@ -0,0 +1,87 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Repository\PermissionRepository;
use MintyPHP\Repository\RolePermissionRepository;
use MintyPHP\Service\RoleService;
use MintyPHP\Service\UserService;
use MintyPHP\Service\SettingService;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::ROLES_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId);
}
$uuid = trim((string) ($id ?? ''));
$role = $uuid !== '' ? RoleService::findByUuid($uuid) : null;
if (!$role) {
Flash::error('Role not found', 'admin/roles', 'role_not_found');
Router::redirect('admin/roles');
}
$roleId = (int) ($role['id'] ?? 0);
$creatorId = (int) ($role['created_by'] ?? 0);
if ($creatorId > 0) {
$creator = UserService::findById($creatorId);
if ($creator) {
$creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? ''));
$role['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? '');
$role['created_by_uuid'] = $creator['uuid'] ?? null;
}
}
$modifierId = (int) ($role['modified_by'] ?? 0);
if ($modifierId > 0) {
$modifier = UserService::findById($modifierId);
if ($modifier) {
$modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? ''));
$role['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? '');
$role['modified_by_uuid'] = $modifier['uuid'] ?? null;
}
}
$errors = [];
$form = $role;
$permissions = PermissionRepository::list();
$selectedPermissionIds = RolePermissionRepository::listPermissionIdsByRoleId($roleId);
if (isset($_POST['description'])) {
if (!PermissionService::userHas($currentUserId, PermissionService::ROLES_UPDATE)) {
Flash::error('Permission denied', "admin/roles/edit/{$uuid}", 'permission_denied');
Router::redirect("admin/roles/edit/{$uuid}");
return;
}
$result = RoleService::updateFromAdmin($roleId, $_POST, $currentUserId);
$form = $result['form'] ?? $form;
$errors = $result['errors'] ?? [];
$selectedPermissionIds = $_POST['permission_ids'] ?? $selectedPermissionIds;
if (!is_array($selectedPermissionIds)) {
$selectedPermissionIds = [$selectedPermissionIds];
}
$selectedPermissionIds = array_values(array_unique(array_map('intval', $selectedPermissionIds)));
if ($result['ok'] ?? false) {
$permissionIds = $_POST['permission_ids'] ?? [];
if (!is_array($permissionIds)) {
$permissionIds = [$permissionIds];
}
$permissionIds = array_values(array_unique(array_map('intval', $permissionIds)));
RolePermissionRepository::replaceForRole($roleId, $permissionIds);
$action = (string) ($_POST['action'] ?? 'save');
if ($action === 'save_close') {
Flash::success('Role updated', 'admin/roles', 'role_updated');
Router::redirect('admin/roles');
} else {
Flash::success('Role updated', "admin/roles/edit/{$uuid}", 'role_updated');
Router::redirect("admin/roles/edit/{$uuid}");
}
}
}
Buffer::set('title', t('Edit role'));

View File

@@ -0,0 +1,167 @@
<?php
/**
* @var array<int, string> $errors
* @var array $form
* @var array $role
*/
use MintyPHP\Session;
$values = $form ?? $role ?? [];
$isReadOnly = !can('roles.update');
?>
<div class="app-details-container">
<section>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
href="/admin/roles"><?php e(t('Roles')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('Edit role')); ?>
</div>
<div class="app-details-titlebar">
<h1>
<a href="admin/roles" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
<?php e(t('Edit role')); ?>
</h1>
<div class="app-details-titlebar-actions">
<?php if (can('roles.delete')): ?>
<details class="dropdown">
<summary role="button" class="outline secondary"><i class="bi bi-three-dots"></i></summary>
<ul dir="rtl">
<li>
<form method="post" action="admin/roles/delete/<?php e($values['uuid'] ?? ''); ?>"
onsubmit="return confirm('<?php e(t('Delete this role?')); ?>');">
<?php Session::getCsrfInput(); ?>
<button type="submit" class="transparent">
<?php e(t('Delete')); ?>
</button>
</form>
</li>
</ul>
</details>
<?php endif; ?>
<?php if (can('roles.update')): ?>
<button type="submit" form="role-form" name="action" value="save" class="secondary outline">
<?php e(t('Save')); ?>
</button>
<button type="submit" form="role-form" name="action" value="save_close" class="primary">
<?php e(t('Save & close')); ?>
</button>
<?php endif; ?>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="app-details-errors">
<div class="notice" data-variant="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
<?php endforeach; ?>
</ul>
</div>
</div>
<?php endif; ?>
<?php
$detailsOpenAll = true;
$isReadOnly = $isReadOnly ?? false;
require __DIR__ . '/_form.phtml';
?>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<hgroup>
<h2><?php e($values['description'] ?? ''); ?></h2>
<p><?php e(t('Role')); ?></p>
</hgroup>
<hr>
<div class="grid">
<div>
<small><?php e(t('ID')); ?></small>
<p>
<span class="badge" data-variant="neutral" data-copy="true"
data-copy-value="<?php e($values['id'] ?? ''); ?>">
<?php e($values['id'] ?? '-'); ?>
</span>
</p>
</div>
</div>
<?php
$createdByLabel = $values['created_by_label'] ?? '';
$createdById = $values['created_by'] ?? null;
$createdByUuid = $values['created_by_uuid'] ?? null;
$modifiedByLabel = $values['modified_by_label'] ?? '';
$modifiedById = $values['modified_by'] ?? null;
$modifiedByUuid = $values['modified_by_uuid'] ?? null;
?>
<div class="grid">
<div>
<small><?php e(t('Created')); ?></small>
<p><?php e(dt($values['created'] ?? '') ?: '-'); ?></p>
</div>
<div>
<small>
<?php e(t('Created by')); ?>
</small>
<p>
<?php if ($createdByLabel !== ''): ?>
<?php if ($createdByUuid): ?>
<a href="admin/users/edit/<?php e($createdByUuid); ?>"><?php e($createdByLabel); ?></a>
<?php else: ?>
<?php e($createdByLabel); ?>
<?php endif; ?>
<?php elseif ($createdById): ?>
<?php e('#' . $createdById); ?>
<?php else: ?>
-
<?php endif; ?>
</p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Modified')); ?></small>
<p><?php e(dt($values['modified'] ?? '') ?: '-'); ?></p>
</div>
<div>
<small>
<?php e(t('Modified by')); ?>
</small>
<p>
<?php if ($modifiedByLabel !== ''): ?>
<?php if ($modifiedByUuid): ?>
<a href="admin/users/edit/<?php e($modifiedByUuid); ?>"><?php e($modifiedByLabel); ?></a>
<?php else: ?>
<?php e($modifiedByLabel); ?>
<?php endif; ?>
<?php elseif ($modifiedById): ?>
<?php e('#' . $modifiedById); ?>
<?php else: ?>
-
<?php endif; ?>
</p>
</div>
</div>
<div class="grid">
<div>
<small>
<?php e(t('UUID')); ?>
</small>
<p>
<span class="badge" data-variant="neutral" data-copy="true"
data-copy-value="<?php e($values['uuid'] ?? ''); ?>">
<?php
$uuidValue = (string) ($values['uuid'] ?? '');
$uuidDisplay = $uuidValue !== '' ? substr($uuidValue, 0, 10) : '-';
e($uuidDisplay);
?>
</span>
</p>
</div>
</div>
</div>
</aside>
</div>

View File

@@ -0,0 +1,23 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Guard;
use MintyPHP\Session;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::ROLES_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId);
}
Buffer::set('title', t('Roles'));
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$csrfKey = Session::$csrfSessionKey;
$csrfToken = $_SESSION[$csrfKey] ?? '';
Buffer::set(
'grid_csrf',
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);

View File

@@ -0,0 +1,159 @@
<?php
use MintyPHP\Router;
$canCreateRoles = can('roles.create');
$canDeleteRoles = can('roles.delete');
?>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><?php e(t('Roles')); ?>
</div>
<div class="app-list-titlebar">
<h1><?php e(t('Roles')); ?></h1>
<div class="app-list-titlebar-actions">
<button
class="outline secondary"
type="button"
data-toolbar-toggle
data-toolbar-target="#roles-toolbar"
data-toolbar-text-show="<?php e(t('Show filters')); ?>"
data-toolbar-text-hide="<?php e(t('Hide filters')); ?>"
>
<i class="bi bi-funnel-fill"></i> <span data-toolbar-label><?php e(t('Hide filters')); ?></span>
</button>
<?php if ($canCreateRoles): ?>
<a role="button" class="primary" href="admin/roles/create">
<i class="bi bi-plus"></i> <?php e(t('Create role')); ?>
</a>
<?php endif; ?>
</div>
</div>
<div class="app-list-toolbar" id="roles-toolbar">
<input type="search" id="role-search" placeholder="<?php e(t('Search...')); ?>">
</div>
<div class="app-list-table">
<div id="roles-grid"></div>
</div>
<?php $gridFactoryVersion = @filemtime('web/js/core/app-grid-factory.js') ?: time(); ?>
<script src="<?php e(asset('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="module">
import { initToolbarToggles } from "<?php e(asset('js/components/app-toggle-toolbar.js')); ?>";
import { createServerGrid } from "<?php e(asset('js/core/app-grid-factory.js?v=' . $gridFactoryVersion)); ?>";
import { buildUrl, postAction, badgeHtml } from "<?php e(asset('js/pages/app-list-utils.js')); ?>";
initToolbarToggles();
const appBase = "<?php e(localeBase()); ?>";
const csrf = <?php MintyPHP\Buffer::get('grid_csrf'); ?>;
const deleteConfirm = "<?php e(t('Delete this role?')); ?>";
const buildActionUrl = (action, id) => buildUrl(appBase, `admin/roles/${action}/${id}`);
const initRolesGrid = () => {
const formatBadge = (value) => badgeHtml(gridjs, value);
const gridConfig = createServerGrid({
gridjs: window.gridjs,
container: '#roles-grid',
dataUrl: 'admin/roles/data',
appBase,
columns: [
{
name: "<?php e(t('Description')); ?>",
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html(String(cell ?? ''));
}
const label = cell.label ?? '';
return gridjs.html(`${label}`);
}
},
{
name: "<?php e(t('Created')); ?>",
sort: true,
formatter: (cell) => formatBadge(cell)
},
{
name: "<?php e(t('Modified')); ?>",
sort: true,
formatter: (cell) => formatBadge(cell)
},
{
name: "UUID",
hidden: true
},
{
name: "ID",
hidden: true
}
],
sortColumns: ['description', 'created', 'modified', null, null],
paginationLimit: 10,
language: <?php MintyPHP\Buffer::get('grid_lang'); ?>,
mapData: (data) => data.data.map((row) => [
{
label: row.description,
is_default: row.is_default ? 1 : 0
},
row.created,
row.modified,
row.uuid,
row.id
]),
actions: {
enabled: true,
label: "<?php e(t('Actions')); ?>",
getRowId: (row) => row?.cells?.[3]?.data,
items: [
{
key: 'edit',
type: 'link',
label: "<i class=\"bi bi-pencil-fill\"></i>",
href: ({ id }) => buildActionUrl('edit', id)
},
<?php if ($canDeleteRoles): ?>
{
key: 'delete',
label: "<i class=\"bi bi-trash3-fill\"></i>",
className: 'danger outline',
confirm: deleteConfirm
}
<?php endif; ?>
],
onAction: async ({ action, id, grid }) => {
if (action !== 'delete') return;
const url = buildActionUrl('delete', id);
const response = await postAction(url, csrf);
if (response.ok) {
grid.forceRender();
} else {
window.location.href = new URL('admin/roles', appBase).toString();
}
}
},
search: {
input: '#role-search',
param: 'search',
debounce: 250
},
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[3]?.data
}),
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[3]?.data;
return uuid ? new URL(`admin/roles/edit/${uuid}`, appBase).toString() : '';
}
}
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Roles grid init failed');
return null;
}
return gridConfig;
};
initRolesGrid();
</script>

View File

@@ -0,0 +1,92 @@
<?php
use MintyPHP\Router;
use MintyPHP\Support\Guard;
use MintyPHP\Support\SearchConfig;
use MintyPHP\Service\PermissionService;
use MintyPHP\DB;
use MintyPHP\I18n;
use MintyPHP\Repository\UserTenantRepository;
Guard::requireLogin();
$userId = (int) ($_SESSION['user']['id'] ?? 0);
if ($userId > 0) {
PermissionService::getUserPermissions($userId);
}
$query = trim((string) ($_GET['q'] ?? ''));
if ($query === '') {
Router::json([]);
}
$previewLimit = 5;
$locale = I18n::$locale ?? (I18n::$defaultLocale ?? 'de');
$tenantIds = $userId > 0 ? UserTenantRepository::listTenantIdsByUserId($userId) : [];
$tenantScoped = !empty($tenantIds);
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
$resources = SearchConfig::resources($query, $locale);
$results = [];
foreach ($resources as $resource) {
$key = (string) ($resource['key'] ?? '');
if ($key === '') {
continue;
}
$tenantFilter = $tenantScopeFilters[$key] ?? '';
$isTenantScoped = $tenantFilter !== '';
if ($isTenantScoped && !$tenantScoped) {
continue;
}
$perm = (string) ($resource['permission'] ?? '');
if ($perm !== '' && !PermissionService::userHas($userId, $perm)) {
continue;
}
$countSql = str_replace('{{tenantFilter}}', $isTenantScoped ? $tenantFilter : '', $resource['countSql'] ?? '');
$countParams = $resource['countParams'] ?? [];
if ($isTenantScoped && strpos($countSql, '???') !== false) {
$countParams[] = $tenantIds;
}
$count = $countSql !== '' ? (int) DB::selectValue($countSql, ...$countParams) : 0;
$items = [];
$previewSql = $resource['previewSql'] ?? null;
if ($previewSql) {
$previewSql = str_replace('{{tenantFilter}}', $isTenantScoped ? $tenantFilter : '', $previewSql);
$previewParams = $resource['previewParams'] ?? [];
if ($isTenantScoped && strpos($previewSql, '???') !== false) {
$previewParams[] = $tenantIds;
}
$previewParams[] = $previewLimit;
$rows = DB::select($previewSql, ...$previewParams);
foreach ($rows as $row) {
$data = $row;
if (is_array($row) && count($row) === 1) {
$data = reset($row);
}
$data = is_array($data) ? $data : [];
$mapped = SearchConfig::mapPreviewItem($key, $data, $query);
if ($mapped !== null) {
$items[] = $mapped;
}
}
}
$result = [
'key' => $key,
'label' => $resource['label'] ?? '',
'count' => $count,
'url' => SearchConfig::listUrl($key, $query),
'items' => $items,
];
if ($key === 'pages' && !empty($items[0]['url'])) {
$result['url'] = $items[0]['url'];
}
$results[] = $result;
}
Router::json($results);

View File

@@ -0,0 +1,26 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\BrandingFaviconService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/settings');
return;
}
$result = BrandingFaviconService::saveUpload($_FILES['favicon'] ?? []);
if (!($result['ok'] ?? false)) {
$error = $result['error'] ?? t('Upload failed');
Flash::error($error, 'admin/settings', 'favicon_upload_failed');
Router::redirect('admin/settings');
return;
}
Flash::success('Favicon updated', 'admin/settings', 'favicon_updated');
Router::redirect('admin/settings');

View File

@@ -0,0 +1,19 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\BrandingFaviconService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/settings');
return;
}
BrandingFaviconService::delete();
Flash::success('Favicon removed', 'admin/settings', 'favicon_removed');
Router::redirect('admin/settings');

View File

@@ -0,0 +1,130 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\SettingService;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\RoleService;
use MintyPHP\Service\DepartmentService;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_VIEW);
$tenants = TenantService::list();
$roles = RoleService::list();
$departments = DepartmentService::list();
$sortByDescription = static function ($a, $b) {
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
};
usort($tenants, $sortByDescription);
usort($roles, $sortByDescription);
usort($departments, $sortByDescription);
$values = [
'default_tenant_id' => SettingService::getDefaultTenantId(),
'default_role_id' => SettingService::getDefaultRoleId(),
'default_department_id' => SettingService::getDefaultDepartmentId(),
'app_title' => SettingService::getAppTitle(),
'app_locale' => SettingService::getAppLocale(),
'app_theme' => SettingService::getAppTheme(),
'app_theme_user' => SettingService::isUserThemeAllowed(),
'app_primary_color' => SettingService::getAppPrimaryColor(),
'smtp_host' => SettingService::getSmtpHost(),
'smtp_port' => SettingService::getSmtpPort(),
'smtp_user' => SettingService::getSmtpUser(),
'smtp_secure' => SettingService::getSmtpSecure(),
'smtp_from' => SettingService::getSmtpFrom(),
'smtp_from_name' => SettingService::getSmtpFromName(),
];
$settings = [
'default_tenant' => SettingService::get(SettingService::DEFAULT_TENANT_KEY),
'default_role' => SettingService::get(SettingService::DEFAULT_ROLE_KEY),
'default_department' => SettingService::get(SettingService::DEFAULT_DEPARTMENT_KEY),
'app_title' => SettingService::get(SettingService::APP_TITLE_KEY),
'app_locale' => SettingService::get(SettingService::APP_LOCALE_KEY),
'app_theme' => SettingService::get(SettingService::APP_THEME_KEY),
'app_theme_user' => SettingService::get(SettingService::APP_THEME_USER_KEY),
'app_primary_color' => SettingService::get(SettingService::APP_PRIMARY_COLOR_KEY),
'smtp_host' => SettingService::get(SettingService::SMTP_HOST_KEY),
'smtp_port' => SettingService::get(SettingService::SMTP_PORT_KEY),
'smtp_user' => SettingService::get(SettingService::SMTP_USER_KEY),
'smtp_password' => SettingService::get(SettingService::SMTP_PASSWORD_KEY),
'smtp_secure' => SettingService::get(SettingService::SMTP_SECURE_KEY),
'smtp_from' => SettingService::get(SettingService::SMTP_FROM_KEY),
'smtp_from_name' => SettingService::get(SettingService::SMTP_FROM_NAME_KEY),
];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!isset($_POST['settings_submit'])) {
Router::redirect('admin/settings');
}
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
$defaultTenantId = (int) ($_POST['default_tenant_id'] ?? 0);
$defaultRoleId = (int) ($_POST['default_role_id'] ?? 0);
$defaultDepartmentId = (int) ($_POST['default_department_id'] ?? 0);
$appTitle = trim((string) ($_POST['app_title'] ?? ''));
$appLocale = trim((string) ($_POST['app_locale'] ?? ''));
$appTheme = trim((string) ($_POST['app_theme'] ?? ''));
$appThemeUser = isset($_POST['app_theme_user']);
$rawPrimaryColor = $_POST['app_primary_color'] ?? '';
if (is_array($rawPrimaryColor)) {
$rawPrimaryColor = '';
}
$appPrimaryColor = trim((string) $rawPrimaryColor);
if (in_array(strtolower($appPrimaryColor), ['null', 'undefined'], true)) {
$appPrimaryColor = '';
}
$smtpHost = trim((string) ($_POST['smtp_host'] ?? ''));
$smtpPort = (int) ($_POST['smtp_port'] ?? 0);
$smtpUser = trim((string) ($_POST['smtp_user'] ?? ''));
$smtpPassword = (string) ($_POST['smtp_password'] ?? '');
$smtpSecure = trim((string) ($_POST['smtp_secure'] ?? ''));
$smtpFrom = trim((string) ($_POST['smtp_from'] ?? ''));
$smtpFromName = trim((string) ($_POST['smtp_from_name'] ?? ''));
SettingService::setDefaultTenantId($defaultTenantId > 0 ? $defaultTenantId : null);
SettingService::setDefaultRoleId($defaultRoleId > 0 ? $defaultRoleId : null);
SettingService::setDefaultDepartmentId($defaultDepartmentId > 0 ? $defaultDepartmentId : null);
SettingService::setAppTitle($appTitle);
SettingService::setAppLocale($appLocale);
SettingService::setAppTheme($appTheme);
SettingService::setUserThemeAllowed($appThemeUser);
$primaryOk = SettingService::setAppPrimaryColor($appPrimaryColor);
if (!$primaryOk) {
$appPrimaryColor = '';
Flash::error('Primary color is invalid', 'admin/settings', 'app_primary_invalid');
}
SettingService::setSmtpHost($smtpHost);
SettingService::setSmtpPort($smtpPort > 0 ? $smtpPort : null);
SettingService::setSmtpUser($smtpUser);
if (trim($smtpPassword) !== '') {
SettingService::setSmtpPassword($smtpPassword);
}
SettingService::setSmtpSecure($smtpSecure);
SettingService::setSmtpFrom($smtpFrom);
SettingService::setSmtpFromName($smtpFromName);
$cacheFile = __DIR__ . '/../../../config/settings.php';
$cacheData = [];
if (is_file($cacheFile)) {
$existing = include $cacheFile;
if (is_array($existing)) {
$cacheData = $existing;
}
}
$cacheData['app_title'] = $appTitle !== '' ? $appTitle : null;
$cacheData['app_locale'] = $appLocale !== '' ? $appLocale : null;
$cacheData['app_theme'] = $appTheme !== '' ? $appTheme : null;
$cacheData['app_theme_user'] = $appThemeUser ? '1' : '0';
$cacheData['app_primary_color'] = $appPrimaryColor !== '' ? $appPrimaryColor : null;
file_put_contents($cacheFile, "<?php\nreturn " . var_export($cacheData, true) . ";\n");
Flash::success('Settings updated', 'admin/settings', 'settings_updated');
Router::redirect('admin/settings');
}
Buffer::set('title', t('Settings'));

View File

@@ -0,0 +1,353 @@
<?php
/**
* @var array $tenants
* @var array $roles
* @var array $departments
* @var array $values
* @var array $settings
*/
use MintyPHP\Session;
use MintyPHP\Service\BrandingLogoService;
use MintyPHP\Service\BrandingFaviconService;
$values = $values ?? [];
$settings = $settings ?? [];
$defaultTenantId = (int) ($values['default_tenant_id'] ?? 0);
$defaultRoleId = (int) ($values['default_role_id'] ?? 0);
$defaultDepartmentId = (int) ($values['default_department_id'] ?? 0);
$appTitle = (string) ($values['app_title'] ?? '');
$appLocale = (string) ($values['app_locale'] ?? '');
$appTheme = (string) ($values['app_theme'] ?? '');
$appThemeUser = !empty($values['app_theme_user']);
$appPrimaryColor = (string) ($values['app_primary_color'] ?? '');
$smtpHost = (string) ($values['smtp_host'] ?? '');
$smtpPort = (string) ($values['smtp_port'] ?? '');
$smtpUser = (string) ($values['smtp_user'] ?? '');
$smtpSecure = (string) ($values['smtp_secure'] ?? '');
$smtpFrom = (string) ($values['smtp_from'] ?? '');
$smtpFromName = (string) ($values['smtp_from_name'] ?? '');
$defaultTenantDesc = $settings['default_tenant']['description'] ?? 'setting.default_tenant';
$defaultRoleDesc = $settings['default_role']['description'] ?? 'setting.default_role';
$defaultDepartmentDesc = $settings['default_department']['description'] ?? 'setting.default_department';
$appTitleDesc = $settings['app_title']['description'] ?? 'setting.app_title';
$appLocaleDesc = $settings['app_locale']['description'] ?? 'setting.app_locale';
$appThemeDesc = $settings['app_theme']['description'] ?? 'setting.app_theme';
$appThemeUserDesc = $settings['app_theme_user']['description'] ?? 'setting.app_theme_user';
$appPrimaryColorDesc = $settings['app_primary_color']['description'] ?? 'setting.app_primary_color';
$smtpHostDesc = $settings['smtp_host']['description'] ?? 'setting.smtp_host';
$smtpPortDesc = $settings['smtp_port']['description'] ?? 'setting.smtp_port';
$smtpUserDesc = $settings['smtp_user']['description'] ?? 'setting.smtp_user';
$smtpPasswordDesc = $settings['smtp_password']['description'] ?? 'setting.smtp_password';
$smtpSecureDesc = $settings['smtp_secure']['description'] ?? 'setting.smtp_secure';
$smtpFromDesc = $settings['smtp_from']['description'] ?? 'setting.smtp_from';
$smtpFromNameDesc = $settings['smtp_from_name']['description'] ?? 'setting.smtp_from_name';
$locales = defined('APP_LOCALES') ? APP_LOCALES : [];
$hasLogo = BrandingLogoService::hasLogo();
$hasFavicon = BrandingFaviconService::hasFavicon();
$canUpdateSettings = can('settings.update');
$isReadOnly = !$canUpdateSettings;
$readonlyAttr = $isReadOnly ? 'readonly' : '';
$disabledAttr = $isReadOnly ? 'disabled' : '';
?>
<div class="app-details-container">
<section>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><?php e(t('Settings')); ?>
</div>
<div class="app-details-titlebar">
<h1>
<?php e(t('Settings')); ?>
</h1>
<div class="app-details-titlebar-actions">
<?php if ($canUpdateSettings): ?>
<button type="submit" form="settings-form" class="primary">
<?php e(t('Save')); ?>
</button>
<?php endif; ?>
</div>
</div>
<form id="settings-form" method="post">
<input type="hidden" name="settings_submit" value="1">
<details name="general" open>
<summary><?php e(t('General')); ?></summary>
<hr>
<label class="app-field">
<span><?php e(t('App title')); ?></span>
<input type="text" name="app_title" value="<?php e($appTitle); ?>" placeholder="<?php e(APP_NAME); ?>" <?php e($readonlyAttr); ?>>
<small><?php e(t($appTitleDesc)); ?></small>
</label>
</details>
<hr>
<details name="appearance">
<summary><?php e(t('Appearance')); ?></summary>
<hr>
<div class="grid">
<fieldset>
<legend><small><?php e(t('Default theme')); ?></small></legend>
<select name="app_theme" <?php e($disabledAttr); ?>>
<option value=""><?php e(t('None')); ?></option>
<option value="light" <?php e($appTheme === 'light' ? 'selected' : ''); ?>>
<?php e(t('Light')); ?>
</option>
<option value="dark" <?php e($appTheme === 'dark' ? 'selected' : ''); ?>>
<?php e(t('Dark')); ?>
</option>
</select>
<small><?php e(t($appThemeDesc)); ?></small>
</fieldset>
<fieldset>
<legend><small><?php e(t('Primary color')); ?></small></legend>
<label class="app-field">
<input type="color" name="app_primary_color"
value="<?php e($appPrimaryColor !== '' ? $appPrimaryColor : '#2fa4a4'); ?>" <?php e($disabledAttr); ?>>
<div>
<div>
<?php e(t('Choose color')); ?>
</div>
</div>
</label>
<small class="muted"><?php e(t($appPrimaryColorDesc)); ?></small>
</fieldset>
</div>
<blockquote data-variant="info">
<?php e(t('Tenants can override this color in their appearance settings.')); ?>
</blockquote>
<fieldset>
<legend>
<small>
<?php e(t($appThemeUserDesc)); ?>
</small>
</legend>
<label class="app-field">
<input type="checkbox" name="app_theme_user" value="1" <?php e($appThemeUser ? 'checked' : ''); ?> <?php e($disabledAttr); ?>>
<span><?php e(t('Allow user theme')); ?></span>
</label>
</fieldset>
</details>
<hr>
<details name="smtp">
<summary><?php e(t('Email settings')); ?></summary>
<hr>
<div class="grid">
<label class="app-field">
<span><?php e(t('SMTP host')); ?></span>
<input type="text" name="smtp_host" value="<?php e($smtpHost); ?>" <?php e($readonlyAttr); ?>>
<small><?php e(t($smtpHostDesc)); ?></small>
</label>
<label class="app-field">
<span><?php e(t('SMTP port')); ?></span>
<input type="number" name="smtp_port" value="<?php e($smtpPort); ?>" min="1" max="65535" <?php e($readonlyAttr); ?>>
<small><?php e(t($smtpPortDesc)); ?></small>
</label>
</div>
<div class="grid">
<label class="app-field">
<span><?php e(t('SMTP user')); ?></span>
<input type="text" name="smtp_user" value="<?php e($smtpUser); ?>" autocomplete="username" <?php e($readonlyAttr); ?>>
<small><?php e(t($smtpUserDesc)); ?></small>
</label>
<label class="app-field">
<span><?php e(t('SMTP password')); ?></span>
<input type="password" name="smtp_password" value="" autocomplete="new-password" <?php e($readonlyAttr); ?>>
<small><?php e(t($smtpPasswordDesc)); ?></small>
</label>
</div>
<div class="grid">
<label class="app-field">
<span><?php e(t('SMTP security')); ?></span>
<select name="smtp_secure" <?php e($disabledAttr); ?>>
<option value=""><?php e(t('None')); ?></option>
<option value="tls" <?php e($smtpSecure === 'tls' ? 'selected' : ''); ?>>TLS</option>
<option value="ssl" <?php e($smtpSecure === 'ssl' ? 'selected' : ''); ?>>SSL</option>
</select>
<small><?php e(t($smtpSecureDesc)); ?></small>
</label>
<label class="app-field">
<span><?php e(t('SMTP from address')); ?></span>
<input type="email" name="smtp_from" value="<?php e($smtpFrom); ?>" <?php e($readonlyAttr); ?>>
<small><?php e(t($smtpFromDesc)); ?></small>
</label>
</div>
<label class="app-field">
<span><?php e(t('SMTP from name')); ?></span>
<input type="text" name="smtp_from_name" value="<?php e($smtpFromName); ?>" <?php e($readonlyAttr); ?>>
<small><?php e(t($smtpFromNameDesc)); ?></small>
</label>
</details>
<hr>
<details name="internationalization">
<summary><?php e(t('Internationalization')); ?></summary>
<hr>
<label class="app-field">
<span><?php e(t('Default language')); ?></span>
<select name="app_locale" <?php e($disabledAttr); ?>>
<option value=""><?php e(t('None')); ?></option>
<?php foreach ($locales as $locale): ?>
<?php
$label = strtoupper($locale);
if ($locale === 'de') {
$label = t('German');
} elseif ($locale === 'en') {
$label = t('English');
}
?>
<option value="<?php e($locale); ?>" <?php e($appLocale === $locale ? 'selected' : ''); ?>>
<?php e($label); ?>
</option>
<?php endforeach; ?>
</select>
<small><?php e(t($appLocaleDesc)); ?></small>
</label>
</details>
<hr>
<details name="user-creation">
<summary><?php e(t('User creation rules')); ?></summary>
<hr>
<div class="grid">
<label class="app-field">
<span>
<?php e(t('Default tenant')); ?>
</span>
<select name="default_tenant_id" <?php e($disabledAttr); ?>>
<option value="">
<?php e(t('None')); ?>
</option>
<?php foreach ($tenants ?? [] as $tenant): ?>
<?php $tenantId = (int) ($tenant['id'] ?? 0); ?>
<?php if ($tenantId > 0): ?>
<option value="<?php e((string) $tenantId); ?>" <?php e($tenantId === $defaultTenantId ? 'selected' : ''); ?>>
<?php e($tenant['description'] ?? ''); ?>
</option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<small>
<?php e(t($defaultTenantDesc)); ?>
</small>
</label>
<label class="app-field">
<span>
<?php e(t('Default department')); ?>
</span>
<select name="default_department_id" <?php e($disabledAttr); ?>>
<option value="">
<?php e(t('None')); ?>
</option>
<?php foreach ($departments ?? [] as $department): ?>
<?php $departmentId = (int) ($department['id'] ?? 0); ?>
<?php if ($departmentId > 0): ?>
<option value="<?php e((string) $departmentId); ?>" <?php e($departmentId === $defaultDepartmentId ? 'selected' : ''); ?>>
<?php e($department['description'] ?? ''); ?>
</option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<small>
<?php e(t($defaultDepartmentDesc)); ?>
</small>
</label>
</div>
<label class="app-field">
<span><?php e(t('Default role')); ?></span>
<select name="default_role_id" <?php e($disabledAttr); ?>>
<option value=""><?php e(t('None')); ?></option>
<?php foreach ($roles ?? [] as $role): ?>
<?php $roleId = (int) ($role['id'] ?? 0); ?>
<?php if ($roleId > 0): ?>
<option value="<?php e((string) $roleId); ?>" <?php e($roleId === $defaultRoleId ? 'selected' : ''); ?>>
<?php e($role['description'] ?? ''); ?>
</option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<small><?php e(t($defaultRoleDesc)); ?></small>
</label>
</details>
<hr>
<?php Session::getCsrfInput(); ?>
</form>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<div class="entity-avatar-block avatar-square avatar-ratio-16-9 avatar-borderless">
<a data-fslightbox="app-logo" href="<?php e(appLogoUrl(256)); ?>">
<img class="entity-avatar-image" src="<?php e(appLogoUrl(256)); ?>" alt="<?php e(t('App logo')); ?>">
</a>
</div>
<hgroup>
<h2><?php e(appTitle()); ?></h2>
<p><?php e(t('Settings')); ?></p>
</hgroup>
<hr>
<?php if ($canUpdateSettings): ?>
<details name="app-logo">
<summary>
<?php e(t('Upload logo')); ?>
</summary>
<hr>
<small>
<?php e(t('Allowed file types: SVG, PNG, JPG, WEBP')); ?>
</small>
<hr>
<form class="user-avatar-form" method="post" action="admin/settings/logo" enctype="multipart/form-data">
<label class="user-avatar-upload">
<input type="file" name="logo" accept="image/svg+xml,image/png,image/jpeg,image/webp">
</label>
<div class="grid">
<button type="submit" class="primary">
<?php e(t('Save')); ?>
</button>
<?php if ($hasLogo): ?>
<button type="submit" class="danger" formaction="admin/settings/logo-delete" formmethod="post">
<?php e(t('Remove logo')); ?>
</button>
<?php endif; ?>
</div>
<?php Session::getCsrfInput(); ?>
</form>
</details>
<?php endif; ?>
<hr>
<?php if ($canUpdateSettings): ?>
<details name="app-favicon">
<summary>
<?php e(t('Upload favicon')); ?>
</summary>
<hr>
<?php if ($hasFavicon): ?>
<div class="entity-avatar-block avatar-square avatar-borderless" style="--avatar-size: 48px;">
<img class="entity-avatar-image" src="<?php e(asset('favicon/favicon-32x32.png')); ?>"
alt="<?php e(t('Favicon')); ?>">
</div>
<small><?php e(t('Favicon preview')); ?></small>
<hr>
<?php endif; ?>
<small><?php e(t('Allowed file types: PNG')); ?></small>
<small><?php e(t('Square images are recommended (icons are center-cropped).')); ?></small>
<hr>
<form class="user-avatar-form" method="post" action="admin/settings/favicon" enctype="multipart/form-data">
<label class="user-avatar-upload">
<input type="file" name="favicon" accept="image/png">
</label>
<div class="grid">
<button type="submit" class="primary">
<?php e(t('Save')); ?>
</button>
<?php if ($hasFavicon): ?>
<button type="submit" class="danger" formaction="admin/settings/favicon-delete" formmethod="post">
<?php e(t('Remove favicon')); ?>
</button>
<?php endif; ?>
</div>
<?php Session::getCsrfInput(); ?>
</form>
</details>
<?php endif; ?>
</div>
</aside>
</div>

View File

@@ -0,0 +1,26 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\BrandingLogoService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/settings');
return;
}
$result = BrandingLogoService::saveUpload($_FILES['logo'] ?? []);
if (!($result['ok'] ?? false)) {
$error = $result['error'] ?? t('Upload failed');
Flash::error($error, 'admin/settings', 'logo_upload_failed');
Router::redirect('admin/settings');
return;
}
Flash::success('Logo updated', 'admin/settings', 'logo_updated');
Router::redirect('admin/settings');

View File

@@ -0,0 +1,19 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\BrandingLogoService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermission(PermissionService::SETTINGS_UPDATE);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/settings');
return;
}
BrandingLogoService::delete();
Flash::success('Logo removed', 'admin/settings', 'logo_removed');
Router::redirect('admin/settings');

View File

@@ -0,0 +1,166 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Guard;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\DepartmentService;
use MintyPHP\Service\RoleService;
use MintyPHP\Service\UserService;
use MintyPHP\Service\PermissionService;
use MintyPHP\Repository\PermissionRepository;
use MintyPHP\Repository\MailLogRepository;
use MintyPHP\DB;
Guard::requirePermissionOrForbidden(PermissionService::STATS_VIEW);
// Basic counts
$tenants = TenantService::list();
$tenantCount = count($tenants);
$departments = DepartmentService::list();
$departmentCount = count($departments);
$roles = RoleService::list();
$roleCount = count($roles);
$users = UserService::list();
$userCount = count($users);
$permissions = PermissionRepository::list();
$permissionCount = count($permissions);
// User stats (active/inactive)
$activeUserCount = 0;
$inactiveUserCount = 0;
foreach ($users as $user) {
$isActive = (int) ($user['active'] ?? 1);
if ($isActive === 1) {
$activeUserCount++;
} else {
$inactiveUserCount++;
}
}
// Organization breakdown (per tenant)
$tenantBreakdown = [];
// Load user-tenant assignments
$userTenantAssignments = [];
$userTenantRows = DB::select('select user_id, tenant_id from user_tenants');
if (is_array($userTenantRows)) {
foreach ($userTenantRows as $row) {
$data = $row['user_tenants'] ?? $row;
$userId = (int) ($data['user_id'] ?? 0);
$tenantId = (int) ($data['tenant_id'] ?? 0);
if ($userId > 0 && $tenantId > 0) {
if (!isset($userTenantAssignments[$userId])) {
$userTenantAssignments[$userId] = [];
}
$userTenantAssignments[$userId][] = $tenantId;
}
}
}
// Load tenant-department assignments
$tenantDepartmentAssignments = [];
$tenantDepartmentRows = DB::select('select tenant_id, department_id from tenant_departments');
if (is_array($tenantDepartmentRows)) {
foreach ($tenantDepartmentRows as $row) {
$data = $row['tenant_departments'] ?? $row;
$tenantId = (int) ($data['tenant_id'] ?? 0);
$departmentId = (int) ($data['department_id'] ?? 0);
if ($tenantId > 0 && $departmentId > 0) {
if (!isset($tenantDepartmentAssignments[$tenantId])) {
$tenantDepartmentAssignments[$tenantId] = [];
}
$tenantDepartmentAssignments[$tenantId][] = $departmentId;
}
}
}
foreach ($tenants as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId === 0) {
continue;
}
// Count departments for this tenant
$deptCount = 0;
$tenantDepartmentIds = $tenantDepartmentAssignments[$tenantId] ?? [];
foreach ($departments as $dept) {
$deptId = (int) ($dept['id'] ?? 0);
if ($deptId > 0 && in_array($deptId, $tenantDepartmentIds, true)) {
$deptCount++;
}
}
// Count users for this tenant
$totalUsers = 0;
$activeUsers = 0;
$inactiveUsers = 0;
foreach ($users as $user) {
$userId = (int) ($user['id'] ?? 0);
if ($userId === 0) {
continue;
}
// Check if user is assigned to this tenant
$userTenantIds = $userTenantAssignments[$userId] ?? [];
if (in_array($tenantId, $userTenantIds, true)) {
$totalUsers++;
$isActive = (int) ($user['active'] ?? 1);
if ($isActive === 1) {
$activeUsers++;
} else {
$inactiveUsers++;
}
}
}
$tenantBreakdown[] = [
'id' => $tenantId,
'uuid' => $tenant['uuid'] ?? '',
'description' => $tenant['description'] ?? '',
'departments' => $deptCount,
'total_users' => $totalUsers,
'active_users' => $activeUsers,
'inactive_users' => $inactiveUsers,
];
}
// Sort by description
usort($tenantBreakdown, function ($a, $b) {
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
});
// Mail log stats
$mailLogTotalCount = DB::selectValue('select count(*) from mail_log');
$mailLogTotalCount = $mailLogTotalCount ? (int) $mailLogTotalCount : 0;
$mailLogSentCount = DB::selectValue("select count(*) from mail_log where status = 'sent'");
$mailLogSentCount = $mailLogSentCount ? (int) $mailLogSentCount : 0;
$mailLogFailedCount = DB::selectValue("select count(*) from mail_log where status = 'failed'");
$mailLogFailedCount = $mailLogFailedCount ? (int) $mailLogFailedCount : 0;
$mailLogQueuedCount = DB::selectValue("select count(*) from mail_log where status = 'queued'");
$mailLogQueuedCount = $mailLogQueuedCount ? (int) $mailLogQueuedCount : 0;
// Mail log stats (last 24h)
$mailLogTotal24hCount = DB::selectValue("select count(*) from mail_log where created_at >= NOW() - INTERVAL 24 HOUR");
$mailLogTotal24hCount = $mailLogTotal24hCount ? (int) $mailLogTotal24hCount : 0;
$mailLogSent24hCount = DB::selectValue("select count(*) from mail_log where status = 'sent' and created_at >= NOW() - INTERVAL 24 HOUR");
$mailLogSent24hCount = $mailLogSent24hCount ? (int) $mailLogSent24hCount : 0;
$mailLogFailed24hCount = DB::selectValue("select count(*) from mail_log where status = 'failed' and created_at >= NOW() - INTERVAL 24 HOUR");
$mailLogFailed24hCount = $mailLogFailed24hCount ? (int) $mailLogFailed24hCount : 0;
$mailLogQueued24hCount = DB::selectValue("select count(*) from mail_log where status = 'queued' and created_at >= NOW() - INTERVAL 24 HOUR");
$mailLogQueued24hCount = $mailLogQueued24hCount ? (int) $mailLogQueued24hCount : 0;
// Calculate date range for last 24 hours
$date24hAgo = gmdate('Y-m-d', strtotime('-24 hours'));
$dateToday = gmdate('Y-m-d');
Buffer::set('title', t('Statistics'));

View File

@@ -0,0 +1,283 @@
<?php
/**
* @var int $tenantCount
* @var int $departmentCount
* @var int $roleCount
* @var int $userCount
* @var int $permissionCount
* @var int $activeUserCount
* @var int $inactiveUserCount
* @var array $tenantBreakdown
* @var int $mailLogTotalCount
* @var int $mailLogSentCount
* @var int $mailLogFailedCount
* @var int $mailLogQueuedCount
* @var int $mailLogTotal24hCount
* @var int $mailLogSent24hCount
* @var int $mailLogFailed24hCount
* @var int $mailLogQueued24hCount
*/
?>
<div class="app-breadcrumb">
<a href="/admin"><?php e(t('Home')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('Statistics')); ?>
</div>
<div class="app-dashboard-titlebar">
<h1><?php e(t('Statistics')); ?></h1>
<div class="app-dashboard-titlebar-actions">
<!-- Optional actions -->
</div>
</div>
<hr>
<div class="app-dashboard">
<div class="app-tabs" data-tabs id="stats-tabs">
<!-- Tab Navigation -->
<div class="app-tabs-nav">
<button data-tab="organization" data-tab-default class="transparent tab-button">
<?php e(t('Organization')); ?>
</button>
<button data-tab="users-roles" class="transparent tab-button">
<?php e(t('Users & roles')); ?>
</button>
<?php if (can('mail_log.view')): ?>
<button data-tab="system" class="transparent tab-button">
<?php e(t('System')); ?>
</button>
<?php endif; ?>
</div>
<!-- Tab Panel: Organization -->
<div data-tab-panel="organization">
<div class="app-tiles">
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/tenants',
'label' => t('Tenants'),
'count' => (string) $tenantCount,
'icon' => 'bi bi-building',
'iconBg' => '#e9f0ff',
'iconColor' => '#264db3',
]);
?>
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/departments',
'label' => t('Departments'),
'count' => (string) $departmentCount,
'icon' => 'bi bi-diagram-3-fill',
'iconBg' => '#fff2d9',
'iconColor' => '#9a5a00',
]);
?>
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/users',
'label' => t('Users'),
'count' => (string) $userCount,
'icon' => 'bi bi-people-fill',
'iconBg' => '#d9f2e6',
'iconColor' => '#1f6a3a',
]);
?>
</div>
<?php if (!empty($tenantBreakdown)): ?>
<div class="app-stats-table">
<table>
<thead>
<tr>
<th>
<?php e(t('Tenant')); ?>
</th>
<th>
<?php e(t('Departments')); ?>
</th>
<th>
<?php e(t('Assigned users (Active/Inactive)')); ?>
</th>
</tr>
</thead>
<tbody>
<?php foreach ($tenantBreakdown as $item): ?>
<tr>
<td>
<?php if (can('tenants.view') && !empty($item['uuid'])): ?>
<a href="admin/tenants/edit/<?php e($item['uuid']); ?>">
<?php e($item['description']); ?>
</a>
<?php else: ?>
<?php e($item['description']); ?>
<?php endif; ?>
</td>
<td>
<?php e($item['departments']); ?>
</td>
<td>
<?php e($item['total_users']); ?> <small>(
<?php e($item['active_users']); ?> /
<?php e($item['inactive_users']); ?>)
</small>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<!-- Tab Panel: Users & Roles -->
<div data-tab-panel="users-roles">
<div class="grid">
<div>
<div class="app-tiles">
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/users',
'label' => t('Active users'),
'count' => (string) $activeUserCount,
'icon' => 'bi bi-person-check-fill',
'iconBg' => '#d9f2e6',
'iconColor' => '#1f6a3a',
]);
?>
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/users',
'label' => t('Inactive users'),
'count' => (string) $inactiveUserCount,
'icon' => 'bi bi-person-x-fill',
'iconBg' => '#ffe9e9',
'iconColor' => '#a32e2e',
]);
?>
</div>
</div>
<div>
<div class="app-tiles">
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/roles',
'label' => t('Roles'),
'count' => (string) $roleCount,
'icon' => 'bi bi-shield-lock-fill',
'iconBg' => '#f3e9ff',
'iconColor' => '#5b2c83',
]);
?>
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/permissions',
'label' => t('Permissions'),
'count' => (string) $permissionCount,
'icon' => 'bi bi-key-fill',
'iconBg' => '#fff2d9',
'iconColor' => '#9a5a00',
]);
?>
</div>
</div>
</div>
</div>
<!-- Tab Panel: System -->
<?php if (can('mail_log.view')): ?>
<div data-tab-panel="system">
<div class="grid">
<div>
<h4><?php e(t('Mail logs')); ?></h4>
<div class="app-tiles">
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/mail-log',
'label' => t('Total emails'),
'count' => (string) $mailLogTotalCount,
'icon' => 'bi bi-envelope-fill',
'iconBg' => '#e9f0ff',
'iconColor' => '#264db3',
]);
?>
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/mail-log?status=sent',
'label' => t('Sent emails'),
'count' => (string) $mailLogSentCount,
'icon' => 'bi bi-envelope-check-fill',
'iconBg' => '#d9f2e6',
'iconColor' => '#1f6a3a',
]);
?>
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/mail-log?status=failed',
'label' => t('Failed emails'),
'count' => (string) $mailLogFailedCount,
'icon' => 'bi bi-envelope-x-fill',
'iconBg' => '#ffe9e9',
'iconColor' => '#a32e2e',
]);
?>
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/mail-log',
'label' => t('Queued emails'),
'count' => (string) $mailLogQueuedCount,
'icon' => 'bi bi-envelope-paper-fill',
'iconBg' => '#fff2d9',
'iconColor' => '#9a5a00',
]);
?>
</div>
</div>
<div>
<h4>
<?php e(t('Last 24 hours')); ?>
</h4>
<div class="app-tiles">
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/mail-log?created_from=' . urlencode($date24hAgo ?? '') . '&created_to=' . urlencode($dateToday ?? ''),
'label' => t('Total (24h)'),
'count' => (string) $mailLogTotal24hCount,
'icon' => 'bi bi-envelope-fill',
'iconBg' => '#e9f0ff',
'iconColor' => '#264db3',
]);
?>
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/mail-log?status=sent&created_from=' . urlencode($date24hAgo ?? '') . '&created_to=' . urlencode($dateToday ?? ''),
'label' => t('Sent (24h)'),
'count' => (string) $mailLogSent24hCount,
'icon' => 'bi bi-envelope-check-fill',
'iconBg' => '#d9f2e6',
'iconColor' => '#1f6a3a',
]);
?>
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/mail-log?status=failed&created_from=' . urlencode($date24hAgo ?? '') . '&created_to=' . urlencode($dateToday ?? ''),
'label' => t('Failed (24h)'),
'count' => (string) $mailLogFailed24hCount,
'icon' => 'bi bi-envelope-x-fill',
'iconBg' => '#ffe9e9',
'iconColor' => '#a32e2e',
]);
?>
<?php
MintyPHP\Support\Tile::render([
'href' => 'admin/mail-log?status=queued&created_from=' . urlencode($date24hAgo ?? '') . '&created_to=' . urlencode($dateToday ?? ''),
'label' => t('Queued (24h)'),
'count' => (string) $mailLogQueued24hCount,
'icon' => 'bi bi-envelope-paper-fill',
'iconBg' => '#fff2d9',
'iconColor' => '#9a5a00',
]);
?>
</div>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>

View File

@@ -0,0 +1,187 @@
<?php
/**
* @var array $values
* @var string|null $formId
* @var bool $detailsOpenAll
* @var bool $isReadOnly
*/
use MintyPHP\Session;
$values = $values ?? [];
$formId = $formId ?? 'tenant-form';
$detailsOpenAll = $detailsOpenAll ?? false;
$isReadOnly = $isReadOnly ?? false;
$readonlyAttr = $isReadOnly ? 'readonly' : '';
$disabledAttr = $isReadOnly ? 'disabled' : '';
$primaryColor = (string) ($values['primary_color'] ?? '');
$defaultPrimaryColor = appSetting('app_primary_color') ?? '#2fa4a4';
$useDefaultPrimaryColor = $primaryColor === '';
?>
<form id="<?php e($formId); ?>" method="post">
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-tenant-form">
<div class="app-tabs-nav">
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
<button type="button" data-tab="accounting"><?php e(t('Accounting info')); ?></button>
<button type="button" data-tab="communication"><?php e(t('Communication')); ?></button>
<button type="button" data-tab="support"><?php e(t('Support')); ?></button>
<button type="button" data-tab="legal"><?php e(t('Legal')); ?></button>
<button type="button" data-tab="appearance"><?php e(t('Appearance')); ?></button>
</div>
<div data-tab-panel="basic">
<label for="description">
<span><?php e(t('Description')); ?></span>
<input required type="text" name="description" id="description" value="<?php e($values['description'] ?? ''); ?>"
<?php e($readonlyAttr); ?> />
</label>
<label for="status">
<span><?php e(t('Status')); ?></span>
<?php $statusValue = $values['status'] ?? 'active'; ?>
<select name="status" id="status" <?php e($disabledAttr); ?>>
<option value="active" <?php if ($statusValue === 'active') { ?>selected<?php } ?>>
<?php e(t('Active')); ?>
</option>
<option value="inactive" <?php if ($statusValue === 'inactive') { ?>selected<?php } ?>>
<?php e(t('Inactive')); ?>
</option>
</select>
</label>
<label for="address">
<span><?php e(t('Address')); ?></span>
<input type="text" name="address" id="address" value="<?php e($values['address'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
<div class="grid">
<label for="postal_code">
<span><?php e(t('Postal code')); ?></span>
<input type="text" name="postal_code" id="postal_code" value="<?php e($values['postal_code'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
<label for="city">
<span><?php e(t('City')); ?></span>
<input type="text" name="city" id="city" value="<?php e($values['city'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</div>
<div class="grid">
<label for="country">
<span><?php e(t('Country')); ?></span>
<input type="text" name="country" id="country" value="<?php e($values['country'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
<label for="region">
<span><?php e(t('Region')); ?></span>
<input type="text" name="region" id="region" value="<?php e($values['region'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</div>
</div>
<div data-tab-panel="accounting">
<div class="grid">
<label for="vat_id">
<span><?php e(t('VAT ID')); ?></span>
<input type="text" name="vat_id" id="vat_id" value="<?php e($values['vat_id'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
<label for="tax_number">
<span><?php e(t('Tax number')); ?></span>
<input type="text" name="tax_number" id="tax_number" value="<?php e($values['tax_number'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</div>
<label for="billing_email">
<span><?php e(t('Billing email')); ?></span>
<input type="email" name="billing_email" id="billing_email" value="<?php e($values['billing_email'] ?? ''); ?>"
<?php e($readonlyAttr); ?> />
</label>
</div>
<div data-tab-panel="communication">
<div class="grid">
<label for="phone">
<span><?php e(t('Phone')); ?></span>
<input type="tel" name="phone" id="phone" value="<?php e($values['phone'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
<label for="fax">
<span><?php e(t('Fax')); ?></span>
<input type="tel" name="fax" id="fax" value="<?php e($values['fax'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</div>
<div class="grid">
<label for="email">
<span><?php e(t('Email')); ?></span>
<input type="email" name="email" id="email" value="<?php e($values['email'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
<label for="website">
<span><?php e(t('Website')); ?></span>
<input type="url" name="website" id="website" value="<?php e($values['website'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</div>
</div>
<div data-tab-panel="support">
<div class="grid">
<label for="support_email">
<span><?php e(t('Support email')); ?></span>
<input type="email" name="support_email" id="support_email" value="<?php e($values['support_email'] ?? ''); ?>"
<?php e($readonlyAttr); ?> />
</label>
<label for="support_phone">
<span><?php e(t('Support phone')); ?></span>
<input type="tel" name="support_phone" id="support_phone" value="<?php e($values['support_phone'] ?? ''); ?>"
<?php e($readonlyAttr); ?> />
</label>
</div>
</div>
<div data-tab-panel="legal">
<div class="grid">
<label for="privacy_url">
<span><?php e(t('Privacy URL')); ?></span>
<input type="url" name="privacy_url" id="privacy_url" value="<?php e($values['privacy_url'] ?? ''); ?>"
<?php e($readonlyAttr); ?> />
</label>
<label for="imprint_url">
<span><?php e(t('Imprint URL')); ?></span>
<input type="url" name="imprint_url" id="imprint_url" value="<?php e($values['imprint_url'] ?? ''); ?>"
<?php e($readonlyAttr); ?> />
</label>
</div>
</div>
<div data-tab-panel="appearance">
<blockquote data-variant="info">
<?php e(t('Using the default color applies the global system appearance.')); ?>
</blockquote>
<div class="grid">
<fieldset>
<legend><small><?php e(t('Primary color')); ?></small></legend>
<label for="primary_color">
<input type="color" name="primary_color" id="primary_color"
value="<?php e($primaryColor !== '' ? $primaryColor : $defaultPrimaryColor); ?>"
<?php e($readonlyAttr); ?> />
<div>
<?php e(t('Choose color')); ?>
</div>
</label>
</fieldset>
<fieldset>
<legend>
<small>
<?php e(t('Use default color')); ?>
</small>
</legend>
<label class="app-field">
<input type="hidden" name="primary_color_use_default" value="0">
<input type="checkbox" name="primary_color_use_default" value="1"
data-color-default-toggle
data-color-target="#primary_color"
data-color-default="<?php e($defaultPrimaryColor); ?>"
<?php e($useDefaultPrimaryColor ? 'checked' : ''); ?>
<?php e($readonlyAttr); ?>>
<span><?php e(t('Use default color')); ?></span>
</label>
<small class="muted"><?php e(t('Be careful - this resets the tenants color')); ?></small>
</fieldset>
</div>
</div>
</div>
<?php Session::getCsrfInput(); ?>
</form>

View File

@@ -0,0 +1,33 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\TenantAvatarService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_UPDATE);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/tenants');
return;
}
$uuid = trim((string) ($id ?? ''));
if (!TenantAvatarService::isValidUuid($uuid)) {
Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found');
Router::redirect('admin/tenants');
return;
}
$result = TenantAvatarService::saveUpload($uuid, $_FILES['avatar'] ?? []);
if (!($result['ok'] ?? false)) {
$error = $result['error'] ?? t('Upload failed');
Flash::error($error, "admin/tenants/edit/{$uuid}", 'tenant_avatar_upload_failed');
Router::redirect("admin/tenants/edit/{$uuid}");
return;
}
Flash::success('Avatar updated', "admin/tenants/edit/{$uuid}", 'tenant_avatar_updated');
Router::redirect("admin/tenants/edit/{$uuid}");

View File

@@ -0,0 +1,26 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\TenantAvatarService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_UPDATE);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/tenants');
return;
}
$uuid = trim((string) ($id ?? ''));
if (!TenantAvatarService::isValidUuid($uuid)) {
Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found');
Router::redirect('admin/tenants');
return;
}
TenantAvatarService::delete($uuid);
Flash::success('Avatar removed', "admin/tenants/edit/{$uuid}", 'tenant_avatar_removed');
Router::redirect("admin/tenants/edit/{$uuid}");

View File

@@ -0,0 +1,28 @@
<?php
use MintyPHP\Service\TenantAvatarService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
$uuid = trim((string) ($_GET['uuid'] ?? ''));
$size = isset($_GET['size']) ? (int) $_GET['size'] : null;
if (!TenantAvatarService::isValidUuid($uuid)) {
http_response_code(404);
return;
}
$path = TenantAvatarService::findAvatarPath($uuid, $size);
if (!$path || !is_file($path)) {
http_response_code(404);
return;
}
$mime = TenantAvatarService::detectMime($path);
header('Content-Type: ' . $mime);
header('X-Content-Type-Options: nosniff');
header("Content-Security-Policy: sandbox");
header('Cache-Control: private, max-age=300');
header('Content-Length: ' . filesize($path));
readfile($path);
return;

View File

@@ -0,0 +1,62 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_CREATE);
$errors = [];
$form = [
'description' => '',
'address' => '',
'postal_code' => '',
'city' => '',
'country' => '',
'region' => '',
'vat_id' => '',
'tax_number' => '',
'phone' => '',
'fax' => '',
'email' => '',
'support_email' => '',
'support_phone' => '',
'billing_email' => '',
'website' => '',
'privacy_url' => '',
'imprint_url' => '',
'primary_color' => '',
'primary_color_use_default' => 0,
'status' => 'active',
];
if (isset($_POST['description'])) {
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$result = TenantService::createFromAdmin($_POST, $currentUserId);
$form = $result['form'] ?? $form;
$errors = $result['errors'] ?? [];
if ($result['ok'] ?? false) {
$action = (string) ($_POST['action'] ?? 'create');
if ($action === 'create_close') {
Flash::success('Tenant created', 'admin/tenants', 'tenant_created');
Router::redirect('admin/tenants');
} else {
$uuid = (string) ($result['uuid'] ?? '');
if ($uuid !== '') {
$target = "admin/tenants/edit/{$uuid}";
Flash::success('Tenant created', $target, 'tenant_created');
Router::redirect($target);
} else {
Flash::success('Tenant created', 'admin/tenants', 'tenant_created');
Router::redirect('admin/tenants');
}
}
}
}
Buffer::set('title', t('Create tenant'));

View File

@@ -0,0 +1,44 @@
<?php
/**
* @var array<int, string> $errors
* @var array $form
*/
?>
<div class="app-details-container">
<section>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
href="/admin/tenants"><?php e(t('Tenants')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('Create tenant')); ?>
</div>
<div class="app-details-titlebar">
<h1>
<a href="admin/tenants" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
<?php e(t('Create tenant')); ?>
</h1>
<div class="app-details-titlebar-actions">
<button type="submit" form="tenant-form" name="action" value="create" class="secondary outline">
<?php e(t('Create')); ?>
</button>
<button type="submit" form="tenant-form" name="action" value="create_close" class="primary">
<?php e(t('Create & close')); ?>
</button>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="notice" data-variant="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php
$values = $form ?? [];
$detailsOpenAll = true;
require __DIR__ . '/_form.phtml';
?>
</section>
</div>

View File

@@ -0,0 +1,43 @@
<?php
use MintyPHP\Router;
use MintyPHP\Support\Guard;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\SettingService;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_VIEW);
$limit = (int) ($_GET['limit'] ?? 10);
$offset = (int) ($_GET['offset'] ?? 0);
$search = trim((string) ($_GET['search'] ?? ''));
$order = (string) ($_GET['order'] ?? 'description');
$dir = (string) ($_GET['dir'] ?? 'asc');
$result = TenantService::listPaged([
'limit' => $limit,
'offset' => $offset,
'search' => $search,
'order' => $order,
'dir' => $dir,
]);
$defaultTenantId = SettingService::getDefaultTenantId();
$rows = [];
foreach ($result['rows'] as $row) {
$tenantId = (int) ($row['id'] ?? 0);
$rows[] = [
'id' => $row['id'] ?? null,
'uuid' => $row['uuid'] ?? '',
'is_default' => $tenantId > 0 && $defaultTenantId === $tenantId,
'description' => $row['description'] ?? '',
'created' => dt($row['created'] ?? ''),
'modified' => dt($row['modified'] ?? ''),
];
}
Router::json([
'data' => $rows,
'total' => $result['total'] ?? 0,
]);

View File

@@ -0,0 +1,31 @@
<?php
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\AuthService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_DELETE);
$uuid = trim((string) ($id ?? ''));
if ($uuid === '') {
Router::redirect('admin/tenants');
}
$result = TenantService::deleteByUuid($uuid);
if (!($result['ok'] ?? false)) {
Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found');
Router::redirect('admin/tenants');
}
// Refresh session tenant data (removes deleted tenant, switches if it was current)
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
AuthService::loadTenantDataIntoSession($currentUserId);
}
Flash::success('Tenant deleted', 'admin/tenants', 'tenant_deleted');
Router::redirect('admin/tenants');

View File

@@ -0,0 +1,82 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\UserService;
use MintyPHP\Service\SettingService;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId);
}
$uuid = trim((string) ($id ?? ''));
$tenant = $uuid !== '' ? TenantService::findByUuid($uuid) : null;
if (!$tenant) {
Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found');
Router::redirect('admin/tenants');
}
$tenantId = (int) ($tenant['id'] ?? 0);
$creatorId = (int) ($tenant['created_by'] ?? 0);
if ($creatorId > 0) {
$creator = UserService::findById($creatorId);
if ($creator) {
$creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? ''));
$tenant['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? '');
$tenant['created_by_uuid'] = $creator['uuid'] ?? null;
}
}
$modifierId = (int) ($tenant['modified_by'] ?? 0);
if ($modifierId > 0) {
$modifier = UserService::findById($modifierId);
if ($modifier) {
$modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? ''));
$tenant['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? '');
$tenant['modified_by_uuid'] = $modifier['uuid'] ?? null;
}
}
$statusChangedById = (int) ($tenant['status_changed_by'] ?? 0);
if ($statusChangedById > 0) {
$statusChanger = UserService::findById($statusChangedById);
if ($statusChanger) {
$statusChangerName = trim(($statusChanger['first_name'] ?? '') . ' ' . ($statusChanger['last_name'] ?? ''));
$tenant['status_changed_by_label'] = $statusChangerName !== '' ? $statusChangerName : ($statusChanger['email'] ?? '');
$tenant['status_changed_by_uuid'] = $statusChanger['uuid'] ?? null;
}
}
$errors = [];
$form = $tenant;
if (isset($_POST['description'])) {
if (!PermissionService::userHas($currentUserId, PermissionService::TENANTS_UPDATE)) {
Router::redirect('error/forbidden');
return;
}
$result = TenantService::updateFromAdmin($tenantId, $_POST, $currentUserId);
$form = $result['form'] ?? $form;
$errors = $result['errors'] ?? [];
if ($result['ok'] ?? false) {
if ($currentUserId > 0) {
\MintyPHP\Service\AuthService::loadTenantDataIntoSession($currentUserId);
}
$action = (string) ($_POST['action'] ?? 'save');
if ($action === 'save_close') {
Flash::success('Tenant updated', 'admin/tenants', 'tenant_updated');
Router::redirect('admin/tenants');
} else {
Flash::success('Tenant updated', "admin/tenants/edit/{$uuid}", 'tenant_updated');
Router::redirect("admin/tenants/edit/{$uuid}");
}
}
}
Buffer::set('title', t('Edit tenant'));

View File

@@ -0,0 +1,371 @@
<?php
/**
* @var array<int, string> $errors
* @var array $form
* @var array $tenant
*/
use MintyPHP\Session;
use MintyPHP\Service\TenantAvatarService;
use MintyPHP\Service\TenantFaviconService;
$values = $form ?? $tenant ?? [];
$isReadOnly = !can('tenants.update');
$avatarUuid = (string) ($values['uuid'] ?? '');
$hasAvatar = $avatarUuid !== '' && TenantAvatarService::hasAvatar($avatarUuid);
$hasFavicon = $avatarUuid !== '' && TenantFaviconService::hasFavicon($avatarUuid);
?>
<div class="app-details-container">
<section>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
href="/admin/tenants"><?php e(t('Tenants')); ?></a><i
class="bi bi-chevron-right"></i><?php e(t('Edit tenant')); ?>
</div>
<div class="app-details-titlebar">
<h1>
<a href="admin/tenants" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
<?php e(t('Edit tenant')); ?>
</h1>
<div class="app-details-titlebar-actions">
<?php if (can('tenants.delete')): ?>
<details class="dropdown">
<summary role="button" class="outline secondary"><i class="bi bi-three-dots"></i></summary>
<ul dir="rtl">
<li>
<form method="post" action="admin/tenants/delete/<?php e($values['uuid'] ?? ''); ?>"
onsubmit="return confirm('<?php e(t('Delete this tenant?')); ?>');">
<?php Session::getCsrfInput(); ?>
<button type="submit" class="transparent">
<?php e(t('Delete')); ?>
</button>
</form>
</li>
</ul>
</details>
<?php endif; ?>
<?php if (can('tenants.update')): ?>
<button type="submit" form="tenant-form" name="action" value="save" class="secondary outline">
<?php e(t('Save')); ?>
</button>
<button type="submit" form="tenant-form" name="action" value="save_close" class="primary">
<?php e(t('Save & close')); ?>
</button>
<?php endif; ?>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="app-details-errors">
<div class="notice" data-variant="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
<?php endforeach; ?>
</ul>
</div>
</div>
<?php endif; ?>
<?php
$detailsOpenAll = false;
$isReadOnly = $isReadOnly ?? false;
require __DIR__ . '/_form.phtml';
?>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<div class="entity-avatar-block avatar-size-auto avatar-borderless">
<?php if ($hasAvatar): ?>
<a data-fslightbox="tenant-avatar" href="admin/tenants/avatar-file?uuid=<?php e($avatarUuid); ?>&size=256">
<img class="entity-avatar-image" src="admin/tenants/avatar-file?uuid=<?php e($avatarUuid); ?>&size=128"
alt="<?php e(t('Tenant image')); ?>">
</a>
<?php endif; ?>
</div>
<hgroup>
<h2><?php e($values['description'] ?? ''); ?></h2>
<p><?php e(t('Tenant')); ?></p>
</hgroup>
<hr>
<?php if (can('tenants.update')): ?>
<details name="tenant-avatar">
<summary>
<?php e(t('Upload image')); ?>
</summary>
<hr>
<form class="user-avatar-form" method="post" action="admin/tenants/avatar/<?php e($avatarUuid); ?>"
enctype="multipart/form-data">
<label class="user-avatar-upload">
<input type="file" name="avatar" accept="image/*">
</label>
<div class="grid">
<button type="submit" class="primary">
<?php e(t('Save')); ?>
</button>
<?php if ($hasAvatar): ?>
<button data-tooltip-pos="top" data-tooltip="<?php e(t('Remove image')); ?>" type="submit" class="danger" formaction="admin/tenants/avatar-delete/<?php e($avatarUuid); ?>"
formmethod="post">
<i class="bi bi-trash3-fill"></i>
</button>
<?php endif; ?>
</div>
<?php Session::getCsrfInput(); ?>
</form>
</details>
<?php endif; ?>
<hr>
<?php if (can('tenants.update')): ?>
<details name="tenant-favicon">
<summary>
<?php e(t('Upload favicon')); ?>
</summary>
<hr>
<?php if ($hasFavicon): ?>
<div class="entity-avatar-block avatar-square avatar-borderless" style="--avatar-size: 48px;">
<img class="entity-avatar-image"
src="<?php e(asset('favicon/tenants/' . $avatarUuid . '/favicon-32x32.png')); ?>"
alt="<?php e(t('Favicon')); ?>">
</div>
<small><?php e(t('Favicon preview')); ?></small>
<hr>
<?php endif; ?>
<small><?php e(t('Allowed file types: PNG')); ?></small>
<small><?php e(t('Square images are recommended (icons are center-cropped).')); ?></small>
<hr>
<form class="user-avatar-form" method="post" action="admin/tenants/favicon/<?php e($avatarUuid); ?>"
enctype="multipart/form-data">
<label class="user-avatar-upload">
<input type="file" name="favicon" accept="image/png">
</label>
<div class="grid">
<button type="submit" class="primary">
<?php e(t('Save')); ?>
</button>
<?php if ($hasFavicon): ?>
<button type="submit" class="danger" formaction="admin/tenants/favicon-delete/<?php e($avatarUuid); ?>" formmethod="post">
<?php e(t('Remove favicon')); ?>
</button>
<?php endif; ?>
</div>
<?php Session::getCsrfInput(); ?>
</form>
</details>
<?php endif; ?>
<hr>
<?php
$phone = trim((string) ($values['phone'] ?? ''));
$fax = trim((string) ($values['fax'] ?? ''));
$email = trim((string) ($values['email'] ?? ''));
$supportEmail = trim((string) ($values['support_email'] ?? ''));
$supportPhone = trim((string) ($values['support_phone'] ?? ''));
$billingEmail = trim((string) ($values['billing_email'] ?? ''));
$website = trim((string) ($values['website'] ?? ''));
$privacyUrl = trim((string) ($values['privacy_url'] ?? ''));
$imprintUrl = trim((string) ($values['imprint_url'] ?? ''));
$statusValue = (string) ($values['status'] ?? 'active');
?>
<details name="tenant-contact">
<summary><?php e(t('Contact')); ?></summary>
<hr>
<?php if ($supportEmail !== '' || $supportPhone !== '' || $billingEmail !== '' || $email !== '' || $phone !== '' || $fax !== '' || $website !== ''): ?>
<?php if ($supportEmail !== ''): ?>
<div>
<small><?php e(t('Support email')); ?></small>
<p><a href="mailto:<?php e($supportEmail); ?>"><?php e($supportEmail); ?></a></p>
</div>
<?php endif; ?>
<?php if ($supportPhone !== ''): ?>
<div>
<small><?php e(t('Support phone')); ?></small>
<p><a href="tel:<?php e($supportPhone); ?>"><?php e($supportPhone); ?></a></p>
</div>
<?php endif; ?>
<?php if ($billingEmail !== ''): ?>
<div>
<small><?php e(t('Billing email')); ?></small>
<p><a href="mailto:<?php e($billingEmail); ?>"><?php e($billingEmail); ?></a></p>
</div>
<?php endif; ?>
<?php if ($email !== ''): ?>
<div>
<small><?php e(t('Email')); ?></small>
<p><a href="mailto:<?php e($email); ?>"><?php e($email); ?></a></p>
</div>
<?php endif; ?>
<?php if ($phone !== ''): ?>
<div>
<small><?php e(t('Phone')); ?></small>
<p><a href="tel:<?php e($phone); ?>"><?php e($phone); ?></a></p>
</div>
<?php endif; ?>
<?php if ($fax !== ''): ?>
<div>
<small><?php e(t('Fax')); ?></small>
<div><?php e($fax); ?></div>
</div>
<?php endif; ?>
<?php if ($website !== ''): ?>
<div>
<small><?php e(t('Website')); ?></small>
<div>
<a href="<?php e($website); ?>" target="_blank" rel="noopener noreferrer">
<?php e($website); ?>
</a>
</div>
</div>
<?php endif; ?>
<?php else: ?>
<p>-</p>
<?php endif; ?>
</details>
<hr>
<details name="tenant-legal">
<summary><?php e(t('Legal')); ?></summary>
<hr>
<?php if ($privacyUrl !== '' || $imprintUrl !== ''): ?>
<?php if ($privacyUrl !== ''): ?>
<div>
<small><?php e(t('Privacy URL')); ?></small>
<p>
<a href="<?php e($privacyUrl); ?>" target="_blank" rel="noopener noreferrer">
<?php e($privacyUrl); ?>
</a>
</p>
</div>
<?php endif; ?>
<?php if ($imprintUrl !== ''): ?>
<div>
<small><?php e(t('Imprint URL')); ?></small>
<p>
<a href="<?php e($imprintUrl); ?>" target="_blank" rel="noopener noreferrer">
<?php e($imprintUrl); ?>
</a>
</p>
</div>
<?php endif; ?>
<?php else: ?>
<p>-</p>
<?php endif; ?>
</details>
<hr>
<?php
$createdByLabel = $values['created_by_label'] ?? '';
$createdById = $values['created_by'] ?? null;
$createdByUuid = $values['created_by_uuid'] ?? null;
$modifiedByLabel = $values['modified_by_label'] ?? '';
$modifiedById = $values['modified_by'] ?? null;
$modifiedByUuid = $values['modified_by_uuid'] ?? null;
$statusChangedByLabel = $values['status_changed_by_label'] ?? '';
$statusChangedById = $values['status_changed_by'] ?? null;
$statusChangedByUuid = $values['status_changed_by_uuid'] ?? null;
?>
<details name="tenant-status">
<summary><?php e(t('Status')); ?></summary>
<hr>
<div>
<small><?php e(t('Status')); ?></small>
<p><?php e($statusValue !== '' ? t(ucfirst($statusValue)) : '-'); ?></p>
</div>
<div>
<small><?php e(t('Status changed')); ?></small>
<p><?php e(dt($values['status_changed_at'] ?? '') ?: '-'); ?></p>
</div>
<div>
<small><?php e(t('Status changed by')); ?></small>
<p>
<?php if ($statusChangedByLabel !== ''): ?>
<?php if ($statusChangedByUuid): ?>
<a href="admin/users/edit/<?php e($statusChangedByUuid); ?>"><?php e($statusChangedByLabel); ?></a>
<?php else: ?>
<?php e($statusChangedByLabel); ?>
<?php endif; ?>
<?php elseif ($statusChangedById): ?>
<?php e('#' . $statusChangedById); ?>
<?php else: ?>
-
<?php endif; ?>
</p>
</div>
</details>
<hr>
<details name="tenant-audit">
<summary><?php e(t('Audit')); ?></summary>
<hr>
<div>
<small><?php e(t('Created')); ?></small>
<p><?php e(dt($values['created'] ?? '') ?: '-'); ?></p>
</div>
<div>
<small><?php e(t('Created by')); ?></small>
<p>
<?php if ($createdByLabel !== ''): ?>
<?php if ($createdByUuid): ?>
<a href="admin/users/edit/<?php e($createdByUuid); ?>"><?php e($createdByLabel); ?></a>
<?php else: ?>
<?php e($createdByLabel); ?>
<?php endif; ?>
<?php elseif ($createdById): ?>
<?php e('#' . $createdById); ?>
<?php else: ?>
-
<?php endif; ?>
</p>
</div>
<div>
<small><?php e(t('Modified')); ?></small>
<p><?php e(dt($values['modified'] ?? '') ?: '-'); ?></p>
</div>
<div>
<small><?php e(t('Modified by')); ?></small>
<p>
<?php if ($modifiedByLabel !== ''): ?>
<?php if ($modifiedByUuid): ?>
<a href="admin/users/edit/<?php e($modifiedByUuid); ?>"><?php e($modifiedByLabel); ?></a>
<?php else: ?>
<?php e($modifiedByLabel); ?>
<?php endif; ?>
<?php elseif ($modifiedById): ?>
<?php e('#' . $modifiedById); ?>
<?php else: ?>
-
<?php endif; ?>
</p>
</div>
</details>
<hr>
<div class="grid">
<div>
<small>
<?php e(t('UUID')); ?>
</small>
<p>
<span class="badge" data-variant="neutral" data-copy="true"
data-copy-value="<?php e($values['uuid'] ?? ''); ?>">
<?php
$uuidValue = (string) ($values['uuid'] ?? '');
$uuidDisplay = $uuidValue !== '' ? substr($uuidValue, 0, 10) : '-';
e($uuidDisplay);
?>
</span>
</p>
</div>
<div>
<small>
<?php e(t('ID')); ?>
</small>
<p>
<span class="badge" data-variant="neutral" data-copy="true"
data-copy-value="<?php e($values['id'] ?? ''); ?>">
<?php e($values['id'] ?? '-'); ?>
</span>
</p>
</div>
</div>
</div>
</aside>
</div>

View File

@@ -0,0 +1,33 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\TenantFaviconService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_UPDATE);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/tenants');
return;
}
$uuid = trim((string) ($id ?? ''));
if (!TenantFaviconService::isValidUuid($uuid)) {
Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found');
Router::redirect('admin/tenants');
return;
}
$result = TenantFaviconService::saveUpload($uuid, $_FILES['favicon'] ?? []);
if (!($result['ok'] ?? false)) {
$error = $result['error'] ?? t('Upload failed');
Flash::error($error, "admin/tenants/edit/{$uuid}", 'tenant_favicon_upload_failed');
Router::redirect("admin/tenants/edit/{$uuid}");
return;
}
Flash::success('Favicon updated', "admin/tenants/edit/{$uuid}", 'tenant_favicon_updated');
Router::redirect("admin/tenants/edit/{$uuid}");

View File

@@ -0,0 +1,26 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\TenantFaviconService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_UPDATE);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/tenants');
return;
}
$uuid = trim((string) ($id ?? ''));
if (!TenantFaviconService::isValidUuid($uuid)) {
Flash::error('Tenant not found', 'admin/tenants', 'tenant_not_found');
Router::redirect('admin/tenants');
return;
}
TenantFaviconService::delete($uuid);
Flash::success('Favicon removed', "admin/tenants/edit/{$uuid}", 'tenant_favicon_removed');
Router::redirect("admin/tenants/edit/{$uuid}");

View File

@@ -0,0 +1,23 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Guard;
use MintyPHP\Session;
use MintyPHP\Service\PermissionService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::TENANTS_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId);
}
Buffer::set('title', t('Tenants'));
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$csrfKey = Session::$csrfSessionKey;
$csrfToken = $_SESSION[$csrfKey] ?? '';
Buffer::set(
'grid_csrf',
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);

View File

@@ -0,0 +1,159 @@
<?php
use MintyPHP\Router;
$canCreateTenants = can('tenants.create');
$canDeleteTenants = can('tenants.delete');
?>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><?php e(t('Tenants')); ?>
</div>
<div class="app-list-titlebar">
<h1><?php e(t('Tenants')); ?></h1>
<div class="app-list-titlebar-actions">
<button
class="outline secondary"
type="button"
data-toolbar-toggle
data-toolbar-target="#tenants-toolbar"
data-toolbar-text-show="<?php e(t('Show filters')); ?>"
data-toolbar-text-hide="<?php e(t('Hide filters')); ?>"
>
<i class="bi bi-funnel-fill"></i> <span data-toolbar-label><?php e(t('Hide filters')); ?></span>
</button>
<?php if ($canCreateTenants): ?>
<a role="button" class="primary" href="admin/tenants/create">
<i class="bi bi-plus"></i> <?php e(t('Create tenant')); ?>
</a>
<?php endif; ?>
</div>
</div>
<div class="app-list-toolbar" id="tenants-toolbar">
<input type="search" id="tenant-search" placeholder="<?php e(t('Search...')); ?>">
</div>
<div class="app-list-table">
<div id="tenants-grid"></div>
</div>
<?php $gridFactoryVersion = @filemtime('web/js/core/app-grid-factory.js') ?: time(); ?>
<script src="<?php e(asset('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="module">
import { initToolbarToggles } from "<?php e(asset('js/components/app-toggle-toolbar.js')); ?>";
import { createServerGrid } from "<?php e(asset('js/core/app-grid-factory.js?v=' . $gridFactoryVersion)); ?>";
import { buildUrl, postAction, badgeHtml } from "<?php e(asset('js/pages/app-list-utils.js')); ?>";
initToolbarToggles();
const appBase = "<?php e(localeBase()); ?>";
const csrf = <?php MintyPHP\Buffer::get('grid_csrf'); ?>;
const deleteConfirm = "<?php e(t('Delete this tenant?')); ?>";
const buildActionUrl = (action, id) => buildUrl(appBase, `admin/tenants/${action}/${id}`);
const initTenantsGrid = () => {
const formatBadge = (value) => badgeHtml(gridjs, value);
const gridConfig = createServerGrid({
gridjs: window.gridjs,
container: '#tenants-grid',
dataUrl: 'admin/tenants/data',
appBase,
columns: [
{
name: "<?php e(t('Description')); ?>",
sort: true,
formatter: (cell) => {
if (!cell || typeof cell !== 'object') {
return gridjs.html(String(cell ?? ''));
}
const label = cell.label ?? '';
return gridjs.html(`${label}`);
}
},
{
name: "<?php e(t('Created')); ?>",
sort: true,
formatter: (cell) => formatBadge(cell)
},
{
name: "<?php e(t('Modified')); ?>",
sort: true,
formatter: (cell) => formatBadge(cell)
},
{
name: "UUID",
hidden: true
},
{
name: "ID",
hidden: true
}
],
sortColumns: ['description', 'created', 'modified', null, null],
paginationLimit: 10,
language: <?php MintyPHP\Buffer::get('grid_lang'); ?>,
mapData: (data) => data.data.map((row) => [
{
label: row.description,
is_default: row.is_default ? 1 : 0
},
row.created,
row.modified,
row.uuid,
row.id
]),
actions: {
enabled: true,
label: "<?php e(t('Actions')); ?>",
getRowId: (row) => row?.cells?.[3]?.data,
items: [
{
key: 'edit',
type: 'link',
label: "<i class=\"bi bi-pencil-fill\"></i>",
href: ({ id }) => buildActionUrl('edit', id)
},
<?php if ($canDeleteTenants): ?>
{
key: 'delete',
label: "<i class=\"bi bi-trash3-fill\"></i>",
className: 'danger outline',
confirm: deleteConfirm
}
<?php endif; ?>
],
onAction: async ({ action, id, grid }) => {
if (action !== 'delete') return;
const url = buildActionUrl('delete', id);
const response = await postAction(url, csrf);
if (response.ok) {
grid.forceRender();
} else {
window.location.href = new URL('admin/tenants', appBase).toString();
}
}
},
search: {
input: '#tenant-search',
param: 'search',
debounce: 250
},
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[3]?.data
}),
rowDblClick: {
getUrl: (rowData) => {
const uuid = rowData?.cells?.[3]?.data;
return uuid ? new URL(`admin/tenants/edit/${uuid}`, appBase).toString() : '';
}
}
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Tenants grid init failed');
return null;
}
return gridConfig;
};
initTenantsGrid();
</script>

View File

@@ -0,0 +1,359 @@
<?php
/**
* @var array $values
* @var bool $passwordRequired
* @var string|null $passwordLabel
* @var string|null $passwordLegend
* @var string|null $passwordConfirmLabel
* @var string|null $formId
* @var bool $detailsOpenAll
* @var bool $showTenants
* @var array $tenants
* @var array $selectedTenantIds
* @var bool $showRoles
* @var array $roles
* @var array $selectedRoleIds
* @var bool $showDepartments
* @var array $departments
* @var array $selectedDepartmentIds
* @var bool $isReadOnly
* @var bool $showPermissions
* @var array $permissionRows
*/
use MintyPHP\Session;
use MintyPHP\Service\UserService;
use MintyPHP\I18n;
$values = $values ?? [];
$isOwnAccount = (int) ($_SESSION['user']['id'] ?? 0) === (int) ($values['id'] ?? 0);
$passwordRequired = $passwordRequired ?? false;
$passwordLabel = $passwordLabel ?? t('Password');
$passwordLegend = $passwordLegend ?? t('Password');
$passwordConfirmLabel = $passwordConfirmLabel ?? t('Password (again)');
$formId = $formId ?? 'user-form';
$detailsOpenAll = $detailsOpenAll ?? false;
$showTenants = $showTenants ?? false;
$tenants = $tenants ?? [];
$selectedTenantIds = $selectedTenantIds ?? [];
$showRoles = $showRoles ?? false;
$roles = $roles ?? [];
$selectedRoleIds = $selectedRoleIds ?? [];
$showDepartments = $showDepartments ?? false;
$departments = $departments ?? [];
$selectedDepartmentIds = $selectedDepartmentIds ?? [];
$primaryTenantId = (int) ($values['primary_tenant_id'] ?? 0);
if (!$primaryTenantId && count($selectedTenantIds) === 1) {
$primaryTenantId = (int) $selectedTenantIds[0];
}
$isReadOnly = $isReadOnly ?? false;
$permissionRows = $permissionRows ?? [];
$showPermissions = $showPermissions ?? false;
$minLength = UserService::passwordMinLength();
$requiredAttr = $passwordRequired ? 'required' : '';
$readonlyAttr = $isReadOnly ? 'readonly' : '';
$disabledAttr = $isReadOnly ? 'disabled' : '';
$dataDisabledAttr = $isReadOnly ? 'data-disabled="true"' : '';
$locales = defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale];
$primaryTenantDisabled = $isReadOnly || count($selectedTenantIds) === 1;
$primaryTenantDisabledAttr = $primaryTenantDisabled ? 'disabled' : '';
$showOrganization = $showTenants || $showDepartments || $showRoles;
?>
<form id="<?php e($formId); ?>" method="post">
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-user-form">
<div class="app-tabs-nav">
<button type="button" data-tab="profile" data-tab-default><?php e(t('Profile')); ?></button>
<button type="button" data-tab="access"><?php e(t('Access & password')); ?></button>
<?php if ($showOrganization): ?>
<button type="button" data-tab="organization"><?php e(t('Organization')); ?></button>
<?php endif; ?>
<button type="button" data-tab="masterdata"><?php e(t('Master data')); ?></button>
<?php if ($showPermissions): ?>
<button type="button" data-tab="permissions"><?php e(t('Permissions')); ?></button>
<?php endif; ?>
</div>
<div data-tab-panel="profile">
<details name="identity-data" open>
<summary><?php e(t('Identity')); ?></summary>
<hr>
<div class="grid">
<label for="first_name">
<span><?php e(t('First name')); ?></span>
<input required type="text" name="first_name" id="first_name" value="<?php e($values['first_name'] ?? ''); ?>"
<?php e($readonlyAttr); ?> />
</label>
<label for="last_name">
<span><?php e(t('Last name')); ?></span>
<input required type="text" name="last_name" id="last_name" value="<?php e($values['last_name'] ?? ''); ?>"
<?php e($readonlyAttr); ?> />
</label>
</div>
<label for="email">
<span><?php e(t('Email')); ?></span>
<input required type="email" name="email" id="email" value="<?php e($values['email'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</details>
<hr>
<details name="profile-data" open>
<summary><?php e(t('Profile')); ?></summary>
<hr>
<label for="job_title">
<span><?php e(t('Job title')); ?></span>
<input type="text" name="job_title" id="job_title" value="<?php e($values['job_title'] ?? ''); ?>"
<?php e($readonlyAttr); ?> />
</label>
<label for="profile_description">
<span><?php e(t('Profile description')); ?></span>
<textarea name="profile_description" id="profile_description" rows="4"
<?php e($readonlyAttr); ?>><?php e($values['profile_description'] ?? ''); ?></textarea>
</label>
</details>
<hr>
<details name="preferences" open>
<summary><?php e(t('Preferences')); ?></summary>
<hr>
<div class="grid">
<?php if (allowUserTheme()): ?>
<label for="theme">
<span><?php e(t('Theme')); ?></span>
<?php $selectedTheme = $values['theme'] ?? 'light'; ?>
<select name="theme" id="theme" <?php e($disabledAttr); ?>>
<option value="light" <?php if ($selectedTheme === 'light') { ?>selected<?php } ?>>
<?php e(t('Light')); ?>
</option>
<option value="dark" <?php if ($selectedTheme === 'dark') { ?>selected<?php } ?>>
<?php e(t('Dark')); ?>
</option>
</select>
</label>
<?php endif; ?>
<label for="locale">
<span><?php e(t('Language')); ?></span>
<?php $selectedLocale = $values['locale'] ?? (I18n::$locale ?? I18n::$defaultLocale); ?>
<select name="locale" id="locale" <?php e($disabledAttr); ?>>
<?php foreach ($locales as $locale): ?>
<?php
$label = strtoupper($locale);
if ($locale === 'de') {
$label = t('German');
} elseif ($locale === 'en') {
$label = t('English');
}
?>
<option value="<?php e($locale); ?>" <?php if ($selectedLocale === $locale) { ?>selected<?php } ?>>
<?php e($label); ?>
</option>
<?php endforeach; ?>
</select>
</label>
</div>
</details>
</div>
<div data-tab-panel="access">
<details name="access-data" open>
<summary><?php e(t('Access & password')); ?></summary>
<hr>
<div class="grid">
<div>
<label for="password">
<span><?php e($passwordLabel); ?></span>
<input <?php e($requiredAttr); ?> type="password" name="password" id="password"
minlength="<?php e($minLength); ?>" autocomplete="new-password" <?php e($disabledAttr); ?> />
</label>
<label for="password2">
<span><?php e($passwordConfirmLabel); ?></span>
<input <?php e($requiredAttr); ?> type="password" name="password2" id="password2"
minlength="<?php e($minLength); ?>" autocomplete="new-password" <?php e($disabledAttr); ?> />
</label>
</div>
<div class="form-hint" data-password-hints data-password-input="#password" data-confirm-input="#password2"
data-email-input="#email" data-min-length="<?php e($minLength); ?>">
<strong><?php e(t('Password requirements')); ?></strong>
<ul class="form-hint-list">
<?php foreach (UserService::passwordHints() as $hint): ?>
<li data-rule="<?php e($hint['rule']); ?>"><?php e($hint['text']); ?></li>
<?php endforeach; ?>
<li data-rule="match"><?php e(t('Passwords must match')); ?></li>
</ul>
</div>
</div>
<?php if (!$isOwnAccount): ?>
<label for="active">
<input type="checkbox" name="active" id="active" value="1" <?php e(($values['active'] ?? 0) ? 'checked' : ''); ?>
<?php e($disabledAttr); ?> />
<span><?php e(t('Active')); ?></span>
</label>
<?php endif; ?>
</details>
</div>
<?php if ($showOrganization): ?>
<div data-tab-panel="organization">
<?php if ($showTenants): ?>
<details name="assignments-tenants" open>
<summary><?php e(t('Tenants')); ?></summary>
<hr>
<label>
<span>
<?php e(t('Primary tenant')); ?>
</span>
</label>
<?php if ($primaryTenantDisabled && $primaryTenantId > 0): ?>
<input type="hidden" name="primary_tenant_id" value="<?php e((string) $primaryTenantId); ?>">
<?php endif; ?>
<select id="user-primary-tenant" name="primary_tenant_id" <?php e($primaryTenantDisabledAttr); ?>
<?php e($disabledAttr); ?>>
<?php if (count($selectedTenantIds) !== 1): ?>
<option value="">
<?php e(t('Select primary tenant')); ?>
</option>
<?php endif; ?>
<?php foreach ($tenants as $tenant): ?>
<?php
$tenantId = (int) ($tenant['id'] ?? 0);
if (!in_array($tenantId, $selectedTenantIds ?? [], true)) {
continue;
}
$selected = $tenantId === $primaryTenantId ? 'selected' : '';
?>
<option value="<?php e((string) $tenantId); ?>" <?php e($selected); ?>>
<?php e($tenant['description'] ?? ''); ?>
</option>
<?php endforeach; ?>
</select>
<?php multiSelectForm('user-tenants', 'tenant_ids', 'Assigned tenants', 'Select tenants', $tenants, $selectedTenantIds, $isReadOnly); ?>
</details>
<hr>
<?php endif; ?>
<?php if ($showDepartments || $showRoles): ?>
<details name="assignments-structure" open>
<summary><?php e(t('Departments & roles')); ?></summary>
<hr>
<div class="grid">
<?php if ($showDepartments): ?>
<div>
<?php multiSelectForm('user-departments', 'department_ids', 'Assigned departments', 'Select departments', $departments, $selectedDepartmentIds, $isReadOnly); ?>
</div>
<?php endif; ?>
<?php if ($showRoles): ?>
<div>
<?php multiSelectForm('user-roles', 'role_ids', 'Assigned roles', 'Select roles', $roles, $selectedRoleIds, $isReadOnly); ?>
</div>
<?php endif; ?>
</div>
</details>
<?php endif; ?>
</div>
<?php endif; ?>
<div data-tab-panel="masterdata">
<details name="contact-data" open>
<summary><?php e(t('Contact')); ?></summary>
<hr>
<div class="grid">
<label for="phone">
<span><?php e(t('Phone')); ?></span>
<input type="tel" name="phone" id="phone" autocomplete="tel" value="<?php e($values['phone'] ?? ''); ?>"
<?php e($readonlyAttr); ?> />
</label>
<label for="mobile">
<span><?php e(t('Mobile')); ?></span>
<input type="tel" name="mobile" id="mobile" autocomplete="tel" value="<?php e($values['mobile'] ?? ''); ?>"
<?php e($readonlyAttr); ?> />
</label>
<label for="short_dial">
<span><?php e(t('Short dial')); ?></span>
<input type="text" name="short_dial" id="short_dial" inputmode="numeric"
value="<?php e($values['short_dial'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</div>
</details>
<hr>
<details name="address-data" open>
<summary><?php e(t('Address')); ?></summary>
<hr>
<label for="address">
<span><?php e(t('Address')); ?></span>
<input type="text" name="address" id="address" value="<?php e($values['address'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
<div class="grid">
<label for="postal_code">
<span><?php e(t('Postal code')); ?></span>
<input type="text" name="postal_code" id="postal_code" value="<?php e($values['postal_code'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
<label for="city">
<span><?php e(t('City')); ?></span>
<input type="text" name="city" id="city" value="<?php e($values['city'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</div>
<div class="grid">
<label for="region">
<span><?php e(t('Region')); ?></span>
<input type="text" name="region" id="region" value="<?php e($values['region'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
<label for="country">
<span><?php e(t('Country')); ?></span>
<input type="text" name="country" id="country" value="<?php e($values['country'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</div>
</details>
<hr>
<details name="employment-data" open>
<summary><?php e(t('Employment')); ?></summary>
<hr>
<label for="hire_date">
<span><?php e(t('Hire date')); ?></span>
<input type="date" name="hire_date" id="hire_date" value="<?php e($values['hire_date'] ?? ''); ?>" <?php e($readonlyAttr); ?> />
</label>
</details>
</div>
<?php if ($showPermissions): ?>
<div data-tab-panel="permissions">
<table>
<thead>
<tr>
<th><?php e(t('Description')); ?></th>
<th><?php e(t('Permission key')); ?></th>
<th><?php e(t('Permission origin')); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($permissionRows as $permissionRow): ?>
<?php
$permissionKey = (string) ($permissionRow['key'] ?? '');
$labelKey = 'perm.' . $permissionKey;
$label = t($labelKey);
if ($label === $labelKey) {
$label = (string) ($permissionRow['description'] ?? '');
if ($label === '') {
$label = $permissionKey;
}
}
$rolesList = [];
if (!empty($permissionRow['roles']) && is_array($permissionRow['roles'])) {
$rolesList = $permissionRow['roles'];
}
?>
<tr>
<td><?php e($label); ?></td>
<td><code><?php e($permissionKey); ?></code></td>
<td>
<?php if ($rolesList): ?>
<?php foreach ($rolesList as $roleLabel): ?>
<span class="badge" data-variant="neutral"><?php e($roleLabel); ?></span>
<?php endforeach; ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php Session::getCsrfInput(); ?>
</form>

View File

@@ -0,0 +1,41 @@
<?php
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\UserService;
use MintyPHP\Http\Request;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/users');
return;
}
$uuid = trim((string) ($id ?? ''));
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
$userId = (int) ($user['id'] ?? 0);
if ($userId > 0 && $currentUserId !== $userId && !TenantScopeService::canAccess('users', $userId, $currentUserId)) {
if (Request::wantsJson()) {
http_response_code(403);
Router::json(['error' => 'permission_denied']);
return;
}
Router::redirect('error/forbidden');
return;
}
$result = UserService::setActiveByUuid($uuid, true, $currentUserId);
if ($result['ok'] ?? false) {
if (Request::wantsJson()) {
http_response_code(204);
return;
}
} elseif (Request::wantsJson()) {
http_response_code((int) ($result['status'] ?? 404));
Router::json(['error' => 'not_found']);
return;
}
Router::redirect('admin/users');

View File

@@ -0,0 +1,41 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\UserAvatarService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\UserService;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/users');
return;
}
$uuid = trim((string) ($id ?? ''));
if (!UserAvatarService::isValidUuid($uuid)) {
Flash::error('User not found', 'admin/users', 'user_not_found');
Router::redirect('admin/users');
return;
}
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
$userId = (int) ($user['id'] ?? 0);
if ($userId > 0 && $currentUserId !== $userId && !TenantScopeService::canAccess('users', $userId, $currentUserId)) {
Router::redirect('error/forbidden');
return;
}
$result = UserAvatarService::saveUpload($uuid, $_FILES['avatar'] ?? []);
if (!($result['ok'] ?? false)) {
$error = $result['error'] ?? t('Upload failed');
Flash::error($error, "admin/users/edit/{$uuid}", 'avatar_upload_failed');
Router::redirect("admin/users/edit/{$uuid}");
return;
}
Flash::success('Avatar updated', "admin/users/edit/{$uuid}", 'avatar_updated');
Router::redirect("admin/users/edit/{$uuid}");

View File

@@ -0,0 +1,34 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\UserAvatarService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Service\UserService;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/users');
return;
}
$uuid = trim((string) ($id ?? ''));
if (!UserAvatarService::isValidUuid($uuid)) {
Flash::error('User not found', 'admin/users', 'user_not_found');
Router::redirect('admin/users');
return;
}
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
$userId = (int) ($user['id'] ?? 0);
if ($userId > 0 && $currentUserId !== $userId && !TenantScopeService::canAccess('users', $userId, $currentUserId)) {
Router::redirect('error/forbidden');
return;
}
UserAvatarService::delete($uuid);
Flash::success('Avatar removed', "admin/users/edit/{$uuid}", 'avatar_removed');
Router::redirect("admin/users/edit/{$uuid}");

View File

@@ -0,0 +1,37 @@
<?php
use MintyPHP\Service\UserAvatarService;
use MintyPHP\Service\UserService;
use MintyPHP\Service\TenantScopeService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
$uuid = trim((string) ($_GET['uuid'] ?? ''));
$size = isset($_GET['size']) ? (int) $_GET['size'] : null;
if (!UserAvatarService::isValidUuid($uuid)) {
http_response_code(404);
return;
}
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
$userId = (int) ($user['id'] ?? 0);
if ($userId > 0 && $currentUserId !== $userId && !TenantScopeService::canAccess('users', $userId, $currentUserId)) {
http_response_code(403);
return;
}
$path = UserAvatarService::findAvatarPath($uuid, $size);
if (!$path || !is_file($path)) {
http_response_code(404);
return;
}
$mime = UserAvatarService::detectMime($path);
header('Content-Type: ' . $mime);
header('X-Content-Type-Options: nosniff');
header('Content-Security-Policy: sandbox');
header('Cache-Control: private, max-age=300');
header('Content-Length: ' . filesize($path));
readfile($path);

View File

@@ -0,0 +1,4 @@
<?php
http_response_code(404);
return;

View File

@@ -0,0 +1,135 @@
<?php
use MintyPHP\Support\Guard;
use MintyPHP\Http\Request;
use MintyPHP\Router;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\UserService;
use MintyPHP\Service\MailService;
use MintyPHP\I18n;
Guard::requireLogin();
$action = strtolower(trim((string) ($action ?? '')));
$handlers = [
'activate' => 'handleActivate',
'deactivate' => 'handleDeactivate',
'delete' => 'handleDelete',
'send-access' => 'handleSendAccess',
];
if (!isset($handlers[$action])) {
Router::json(['ok' => false, 'error' => 'invalid_action']);
}
if ($action === 'delete') {
Guard::requirePermissionOrForbidden(PermissionService::USERS_DELETE);
}
if ($action === 'send-access') {
Guard::requirePermissionOrForbidden(PermissionService::USERS_UPDATE);
}
$raw = $_POST['uuids'] ?? '';
$uuids = [];
if (is_array($raw)) {
$uuids = $raw;
} elseif (is_string($raw)) {
$uuids = array_filter(array_map('trim', explode(',', $raw)));
}
$uuids = array_values(array_unique(array_filter($uuids)));
if (!$uuids) {
Router::json(['ok' => false, 'error' => 'no_selection']);
}
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
call_user_func($handlers[$action], $uuids, $currentUserId);
function handleActivate(array $uuids, int $currentUserId): void
{
$result = UserService::setActiveByUuids($uuids, true, $currentUserId);
if (!($result['ok'] ?? false)) {
Router::json(['ok' => false, 'error' => 'update_failed']);
}
Router::json(['ok' => true, 'count' => (int) ($result['count'] ?? 0)]);
}
function handleDeactivate(array $uuids, int $currentUserId): void
{
if ($currentUserId > 0) {
$currentUser = UserService::findById($currentUserId);
$currentUuid = $currentUser['uuid'] ?? '';
if ($currentUuid !== '') {
$uuids = array_values(array_filter($uuids, static fn ($uuid) => $uuid !== $currentUuid));
}
if (!$uuids) {
Router::json(['ok' => false, 'error' => 'self_deactivate']);
}
}
$result = UserService::setActiveByUuids($uuids, false, $currentUserId);
if (!($result['ok'] ?? false)) {
Router::json(['ok' => false, 'error' => 'update_failed']);
}
Router::json(['ok' => true, 'count' => (int) ($result['count'] ?? 0)]);
}
function handleDelete(array $uuids, int $currentUserId): void
{
$result = UserService::deleteByUuids($uuids, $currentUserId);
if (!($result['ok'] ?? false)) {
Router::json(['ok' => false, 'error' => $result['error'] ?? 'delete_failed']);
}
Router::json(['ok' => true, 'count' => (int) ($result['count'] ?? 0)]);
}
function handleSendAccess(array $uuids, int $currentUserId): void
{
$successCount = 0;
$failedCount = 0;
foreach ($uuids as $uuid) {
$user = UserService::findByUuid($uuid);
if (!$user || empty($user['email'])) {
$failedCount++;
continue;
}
$locale = $user['locale'] ?? (I18n::$locale ?? I18n::$defaultLocale);
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
$isGerman = strpos((string) $locale, 'de') === 0;
$greeting = $isGerman ? 'Hallo' : 'Hello';
if ($name !== '') {
$greeting .= ' ' . $name;
}
$greeting .= ',';
$loginUrl = appUrl(Request::withLocale('login', $locale));
$resetUrl = appUrl(Request::withLocale('password/forgot', $locale));
$previousLocale = I18n::$locale ?? null;
I18n::$locale = $locale;
$subject = t('Your access details');
I18n::$locale = $previousLocale;
$vars = [
'app_name' => appTitle(),
'app_logo_url' => appLogoUrlAbsolute(128),
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
'greeting' => $greeting,
'username' => (string) ($user['email'] ?? ''),
'login_url' => $loginUrl,
'reset_url' => $resetUrl,
];
$result = MailService::sendTemplate('access_info', $vars, (string) $user['email'], $subject, $locale);
if ($result['ok'] ?? false) {
$successCount++;
} else {
$failedCount++;
}
}
Router::json([
'ok' => $successCount > 0,
'count' => $successCount,
'failed' => $failedCount,
]);
}

View File

@@ -0,0 +1,93 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\I18n;
use MintyPHP\Router;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\UserService;
use MintyPHP\Service\TenantScopeService;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\RoleService;
use MintyPHP\Service\DepartmentService;
use MintyPHP\Service\SettingService;
Guard::requirePermissionOrForbidden(PermissionService::USERS_CREATE);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$allowedTenantIds = TenantScopeService::getUserTenantIds($currentUserId);
$tenants = TenantService::list();
if ($allowedTenantIds) {
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool {
$tenantId = (int) ($tenant['id'] ?? 0);
return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true);
}));
} elseif (TenantScopeService::isStrict()) {
$tenants = [];
}
$roles = RoleService::list();
$departments = $allowedTenantIds ? DepartmentService::listByTenantIds($allowedTenantIds) : [];
if (!$allowedTenantIds && !TenantScopeService::isStrict()) {
$departments = DepartmentService::list();
}
$errors = [];
$form = [
'first_name' => '',
'last_name' => '',
'email' => '',
'profile_description' => '',
'job_title' => '',
'phone' => '',
'mobile' => '',
'short_dial' => '',
'address' => '',
'postal_code' => '',
'city' => '',
'country' => '',
'region' => '',
'hire_date' => '',
'totp_secret' => '',
'locale' => I18n::$locale ?? I18n::$defaultLocale,
'active' => 1,
];
if (isset($_POST['email'])) {
$input = $_POST;
$tenantIds = UserService::normalizeIdInput($input['tenant_ids'] ?? []);
if ($allowedTenantIds) {
$tenantIds = array_values(array_intersect($tenantIds, $allowedTenantIds));
} elseif (TenantScopeService::isStrict()) {
$tenantIds = [];
}
$defaultTenantId = SettingService::getDefaultTenantId();
if (TenantScopeService::isStrict() && !$tenantIds && !$defaultTenantId) {
$errors[] = t('Please select at least one tenant');
}
$input['tenant_ids'] = $tenantIds;
$result = UserService::createFromAdmin($input, $currentUserId);
$form = $result['form'] ?? $form;
$errors = array_merge($errors, $result['errors'] ?? []);
if (($result['ok'] ?? false) && !$errors) {
$action = (string) ($_POST['action'] ?? 'create');
if ($action === 'create_close') {
Flash::success('User created', 'admin/users', 'user_created');
Router::redirect('admin/users');
} else {
$uuid = (string) ($result['uuid'] ?? '');
if ($uuid !== '') {
$target = "admin/users/edit/{$uuid}";
Flash::success('User created', $target, 'user_created');
Router::redirect($target);
} else {
Flash::success('User created', 'admin/users', 'user_created');
Router::redirect('admin/users');
}
}
}
}
Buffer::set('title', t('Create user'));

View File

@@ -0,0 +1,51 @@
<?php
/**
* @var array<int, string> $errors
* @var array $form
*/
?>
<div class="app-details-container">
<section>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
href="/admin/users"><?php e(t('Users')); ?></a><i class="bi bi-chevron-right"></i><?php e(t('Create user')); ?>
</div>
<div class="app-details-titlebar">
<h1>
<a href="admin/users" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
<?php e(t('Create user')); ?>
</h1>
<div class="app-details-titlebar-actions">
<button type="submit" form="user-form" name="action" value="create" class="secondary outline">
<?php e(t('Create')); ?>
</button>
<button type="submit" form="user-form" name="action" value="create_close" class="primary">
<?php e(t('Create & close')); ?>
</button>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="notice" data-variant="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php
$values = $form ?? [];
$passwordRequired = true;
$detailsOpenAll = true;
$passwordLabel = t('Password');
$passwordLegend = t('Password');
$passwordConfirmLabel = t('Password (again)');
require __DIR__ . '/_form.phtml';
?>
</section>
</div>

View File

@@ -0,0 +1,106 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\UserService;
use MintyPHP\Service\UserAvatarService;
use MintyPHP\Service\TenantScopeService;
if (!isset($_SESSION['user'])) {
http_response_code(401);
Router::json(['data' => [], 'total' => 0]);
}
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$limit = (int) ($_GET['limit'] ?? 10);
$offset = (int) ($_GET['offset'] ?? 0);
$search = trim((string) ($_GET['search'] ?? ''));
$order = (string) ($_GET['order'] ?? 'last_name');
$dir = (string) ($_GET['dir'] ?? 'asc');
$active = $_GET['active'] ?? 'all';
$createdFrom = trim((string) ($_GET['created_from'] ?? ''));
$createdTo = trim((string) ($_GET['created_to'] ?? ''));
$tenant = trim((string) ($_GET['tenant'] ?? ''));
$roles = $_GET['roles'] ?? '';
$departments = $_GET['departments'] ?? '';
$emailVerified = $_GET['email_verified'] ?? 'all';
$result = UserService::listPaged([
'limit' => $limit,
'offset' => $offset,
'search' => $search,
'order' => $order,
'dir' => $dir,
'active' => $active,
'created_from' => $createdFrom,
'created_to' => $createdTo,
'tenant' => $tenant,
'roles' => $roles,
'departments' => $departments,
'email_verified' => $emailVerified,
'tenantUserId' => $currentUserId,
]);
$rows = [];
foreach ($result['rows'] as $row) {
$tenantLabels = $row['tenant_labels'] ?? [];
if (is_string($tenantLabels)) {
$tenantList = $tenantLabels !== ''
? array_values(array_filter(array_map('trim', explode('||', $tenantLabels))))
: [];
} elseif (is_array($tenantLabels)) {
$tenantList = array_values(array_filter(array_map('trim', $tenantLabels)));
} else {
$tenantList = [];
}
$roleLabels = $row['role_labels'] ?? [];
if (is_string($roleLabels)) {
$roleList = $roleLabels !== ''
? array_values(array_filter(array_map('trim', explode('||', $roleLabels))))
: [];
} elseif (is_array($roleLabels)) {
$roleList = array_values(array_filter(array_map('trim', $roleLabels)));
} else {
$roleList = [];
}
$departmentLabels = $row['department_labels'] ?? [];
if (is_string($departmentLabels)) {
$departmentList = $departmentLabels !== ''
? array_values(array_filter(array_map('trim', explode('||', $departmentLabels))))
: [];
} elseif (is_array($departmentLabels)) {
$departmentList = array_values(array_filter(array_map('trim', $departmentLabels)));
} else {
$departmentList = [];
}
$primaryTenantLabel = (string) ($row['primary_tenant_label'] ?? '');
if ($primaryTenantLabel === '' && count($tenantList) === 1) {
$primaryTenantLabel = (string) $tenantList[0];
}
$uuid = (string) ($row['uuid'] ?? '');
$rows[] = [
'id' => $row['id'] ?? null,
'uuid' => $uuid,
'first_name' => $row['first_name'] ?? '',
'last_name' => $row['last_name'] ?? '',
'email' => $row['email'] ?? '',
'phone' => $row['phone'] ?? '',
'mobile' => $row['mobile'] ?? '',
'short_dial' => $row['short_dial'] ?? '',
'tenants' => [
'items' => $tenantList,
'primary' => $primaryTenantLabel,
],
'departments' => $departmentList,
'roles' => $roleList,
'created' => dt($row['created'] ?? ''),
'modified' => dt($row['modified'] ?? ''),
'active' => (int) ($row['active'] ?? 0),
'has_avatar' => $uuid !== '' && UserAvatarService::hasAvatar($uuid),
];
}
Router::json([
'data' => $rows,
'total' => $result['total'] ?? 0,
]);

View File

@@ -0,0 +1,53 @@
<?php
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\UserService;
use MintyPHP\Http\Request;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/users');
return;
}
$uuid = trim((string) ($id ?? ''));
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
$userId = (int) ($user['id'] ?? 0);
if ($userId > 0 && $currentUserId !== $userId && !TenantScopeService::canAccess('users', $userId, $currentUserId)) {
if (Request::wantsJson()) {
http_response_code(403);
Router::json(['error' => 'permission_denied']);
return;
}
Router::redirect('error/forbidden');
return;
}
$result = UserService::setActiveByUuid($uuid, false, $currentUserId);
if (!($result['ok'] ?? false)) {
if (($result['error'] ?? '') === 'self_deactivate') {
Flash::error('You cannot deactivate your own account', 'admin/users', 'user_self_deactivate');
if (Request::wantsJson()) {
http_response_code(400);
Router::json(['error' => 'self_deactivate']);
return;
}
} elseif (Request::wantsJson()) {
http_response_code((int) ($result['status'] ?? 404));
Router::json(['error' => 'not_found']);
return;
}
Router::redirect('admin/users');
return;
}
if (Request::wantsJson()) {
http_response_code(204);
return;
}
Router::redirect('admin/users');

View File

@@ -0,0 +1,61 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\UserService;
use MintyPHP\Service\PermissionService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Http\Request;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::USERS_DELETE);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/users');
return;
}
$uuid = trim((string) ($id ?? ''));
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
$userId = (int) ($user['id'] ?? 0);
if ($userId > 0 && $currentUserId !== $userId && !TenantScopeService::canAccess('users', $userId, $currentUserId)) {
if (Request::wantsJson()) {
http_response_code(403);
Router::json(['error' => 'permission_denied']);
return;
}
Router::redirect('error/forbidden');
return;
}
$result = UserService::deleteByUuid($uuid, $currentUserId);
if (!($result['ok'] ?? false)) {
if (($result['error'] ?? '') === 'self_delete') {
Flash::error('You cannot delete your own account', 'admin/users', 'user_self_delete');
if (Request::wantsJson()) {
http_response_code(400);
Router::json(['error' => 'self_delete']);
return;
}
} else {
Flash::error('User not found', 'admin/users', 'user_not_found');
if (Request::wantsJson()) {
http_response_code(404);
Router::json(['error' => 'not_found']);
return;
}
}
Router::redirect('admin/users');
return;
}
if (Request::wantsJson()) {
http_response_code(204);
Router::json([]);
return;
}
Flash::success('User deleted', 'admin/users', 'user_deleted');
Router::redirect('admin/users');

View File

@@ -0,0 +1,160 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\I18n;
use MintyPHP\Router;
use MintyPHP\Repository\UserRoleRepository;
use MintyPHP\Repository\UserTenantRepository;
use MintyPHP\Repository\UserDepartmentRepository;
use MintyPHP\Repository\RolePermissionRepository;
use MintyPHP\Service\AuthService;
use MintyPHP\Service\DepartmentService;
use MintyPHP\Service\RoleService;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\UserService;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId, true);
}
$uuid = trim((string) ($id ?? ''));
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
if (!$user) {
Flash::error('User not found', 'admin/users', 'user_not_found');
Router::redirect('admin/users');
}
$userId = (int) ($user['id'] ?? 0);
$canViewUsers = PermissionService::userHas($currentUserId, PermissionService::USERS_VIEW);
$canUpdateUser = PermissionService::userHas($currentUserId, PermissionService::USERS_UPDATE);
$canUpdateSelf = PermissionService::userHas($currentUserId, PermissionService::USERS_SELF_UPDATE);
$canUpdateAssignments = PermissionService::userHas($currentUserId, PermissionService::USERS_UPDATE_ASSIGNMENTS);
$isOwnAccount = $currentUserId === $userId;
$canEditUser = $isOwnAccount ? ($canUpdateUser || $canUpdateSelf) : $canUpdateUser;
$canEditAssignments = $canEditUser && $canUpdateAssignments;
if (!$isOwnAccount && !TenantScopeService::canAccess('users', $userId, $currentUserId)) {
Router::redirect('error/forbidden');
return;
}
if (!$canViewUsers && !$canEditUser) {
Router::redirect('error/forbidden');
return;
}
$creatorId = (int) ($user['created_by'] ?? 0);
if ($creatorId > 0) {
$creator = UserService::findById($creatorId);
if ($creator) {
$creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? ''));
$user['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? '');
$user['created_by_uuid'] = $creator['uuid'] ?? null;
}
}
$modifierId = (int) ($user['modified_by'] ?? 0);
if ($modifierId > 0) {
$modifier = UserService::findById($modifierId);
if ($modifier) {
$modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? ''));
$user['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? '');
$user['modified_by_uuid'] = $modifier['uuid'] ?? null;
}
}
$activeChangedById = (int) ($user['active_changed_by'] ?? 0);
if ($activeChangedById > 0) {
$activeChanger = UserService::findById($activeChangedById);
if ($activeChanger) {
$activeChangerName = trim(($activeChanger['first_name'] ?? '') . ' ' . ($activeChanger['last_name'] ?? ''));
$user['active_changed_by_label'] = $activeChangerName !== '' ? $activeChangerName : ($activeChanger['email'] ?? '');
$user['active_changed_by_uuid'] = $activeChanger['uuid'] ?? null;
}
}
$errors = [];
$form = $user;
// Users with tenants.update permission can see ALL tenants (to assign new tenants to users)
$canManageTenants = PermissionService::userHas($currentUserId, PermissionService::TENANTS_UPDATE);
$allowedTenantIds = $canManageTenants ? null : TenantScopeService::getUserTenantIds($currentUserId);
$tenants = TenantService::list();
if ($allowedTenantIds) {
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool {
$tenantId = (int) ($tenant['id'] ?? 0);
return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true);
}));
} elseif (!$canManageTenants && TenantScopeService::isStrict()) {
$tenants = [];
}
$selectedTenantIds = UserTenantRepository::listTenantIdsByUserId($userId);
if ($allowedTenantIds) {
$selectedTenantIds = array_values(array_intersect($selectedTenantIds, $allowedTenantIds));
}
$primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0);
if (!$primaryTenantId && count($selectedTenantIds) === 1) {
$primaryTenantId = (int) $selectedTenantIds[0];
$user['primary_tenant_id'] = $primaryTenantId;
}
$roles = RoleService::list();
$selectedRoleIds = UserRoleRepository::listRoleIdsByUserId($userId);
$permissionRows = RolePermissionRepository::listPermissionsWithRolesByRoleIds($selectedRoleIds);
$departments = DepartmentService::listForUserAssignments($selectedTenantIds, []);
$selectedDepartmentIds = UserDepartmentRepository::listDepartmentIdsByUserId($userId);
if ($selectedDepartmentIds) {
$departments = DepartmentService::listForUserAssignments($selectedTenantIds, $selectedDepartmentIds);
}
// Keep initial $isOwnAccount/$canEditUser values for view permissions.
if (isset($_POST['email'])) {
if (!$canEditUser) {
Router::redirect('error/forbidden');
return;
}
$result = UserService::updateFromAdmin($userId, $_POST, $currentUserId);
$form = $result['form'] ?? $form;
$errors = $result['errors'] ?? [];
$tenantIds = UserService::normalizeIdInput($_POST['tenant_ids'] ?? []);
if ($allowedTenantIds) {
$tenantIds = array_values(array_intersect($tenantIds, $allowedTenantIds));
} elseif (!$canManageTenants && TenantScopeService::isStrict()) {
$tenantIds = [];
}
$selectedTenantIds = $tenantIds;
$primaryTenantId = (int) ($_POST['primary_tenant_id'] ?? 0);
$selectedRoleIds = UserService::normalizeIdInput($_POST['role_ids'] ?? []);
$selectedDepartmentIds = UserService::normalizeIdInput($_POST['department_ids'] ?? []);
$departments = DepartmentService::listForUserAssignments($selectedTenantIds, $selectedDepartmentIds);
if ($result['ok'] ?? false) {
if ($canEditAssignments) {
UserService::syncTenants($userId, $tenantIds);
UserService::syncRoles($userId, $selectedRoleIds);
UserService::syncDepartments($userId, $selectedDepartmentIds);
// Update session if editing own account (tenant assignments changed)
if ($currentUserId === $userId) {
AuthService::loadTenantDataIntoSession($userId);
}
}
if ($currentUserId === $userId && isset($form['theme'])) {
$_SESSION['user']['theme'] = $form['theme'] ?: 'light';
}
if ($currentUserId === $userId && isset($form['locale'])) {
$_SESSION['user']['locale'] = $form['locale'];
I18n::$locale = $form['locale'];
}
$action = (string) ($_POST['action'] ?? 'save');
if ($action === 'save_close') {
Flash::success('User updated', 'admin/users', 'user_updated');
Router::redirect('admin/users');
} else {
Flash::success('User updated', "admin/users/edit/{$uuid}", 'user_updated');
Router::redirect("admin/users/edit/{$uuid}");
}
}
}
Buffer::set('title', $isOwnAccount ? t('My account') : t('Edit user'));

View File

@@ -0,0 +1,312 @@
<?php
/**
* @var array<int, string> $errors
* @var array $form
* @var array $user
*/
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Service\UserAvatarService;
$values = $form ?? $user ?? [];
$avatarUuid = (string) ($values['uuid'] ?? '');
$hasAvatar = $avatarUuid !== '' && UserAvatarService::hasAvatar($avatarUuid);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$isOwnAccount = $currentUserId > 0 && $currentUserId === (int) ($values['id'] ?? 0);
$titleText = $isOwnAccount ? t('My account') : t('Edit user');
$canViewUsers = can('users.view');
$hideNavigation = $isOwnAccount && !$canViewUsers;
?>
<div class="app-details-container">
<section>
<?php if (!$hideNavigation): ?>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><a
href="/admin/users"><?php e(t('Users')); ?></a><i class="bi bi-chevron-right"></i><?php e($titleText); ?>
</div>
<?php endif; ?>
<div class="app-details-titlebar">
<h1>
<?php if (!$hideNavigation): ?>
<a href="admin/users" title="<?php e(t('Cancel')); ?>"><i class="bi bi-arrow-left"></i></a>
<?php endif; ?>
<?php e($titleText); ?>
</h1>
<div class="app-details-titlebar-actions">
<?php if (can('users.delete')): ?>
<details class="dropdown">
<summary role="button" class="outline secondary"><i class="bi bi-three-dots"></i></summary>
<ul dir="rtl">
<li>
<?php if (!empty($canEditUser)): ?>
<form method="post" action="admin/users/send-access/<?php e($values['uuid'] ?? ''); ?>"
onsubmit="return confirm('<?php e(t('Send access email to this user?')); ?>');">
<?php Session::getCsrfInput(); ?>
<button type="submit" class="transparent">
<?php e(t('Send access')); ?>
</button>
</form>
<?php endif; ?>
</li>
<li>
<?php if (!empty($canEditUser)): ?>
<form method="post" action="admin/users/forget-tokens/<?php e($values['uuid'] ?? ''); ?>"
onsubmit="return confirm('<?php e(t('Clear all login tokens for this user?')); ?>');">
<?php Session::getCsrfInput(); ?>
<button type="submit" class="transparent">
<?php e(t('Clear login tokens')); ?>
</button>
</form>
<?php endif; ?>
</li>
<li>
<form method="post" action="admin/users/delete/<?php e($values['uuid'] ?? ''); ?>"
onsubmit="return confirm('<?php e(t('Delete this user?')); ?>');">
<?php Session::getCsrfInput(); ?>
<button type="submit" class="transparent">
<?php e(t('Delete')); ?>
</button>
</form>
</li>
</ul>
</details>
<?php endif; ?>
<?php if (!empty($canEditUser)): ?>
<button type="submit" form="user-form" name="action" value="save" class="secondary outline">
<?php e(t('Save')); ?>
</button>
<?php if (!$hideNavigation): ?>
<button type="submit" form="user-form" name="action" value="save_close" class="primary">
<?php e(t('Save & close')); ?>
</button>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="app-details-errors">
<div class="notice" data-variant="error">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
<?php endforeach; ?>
</ul>
</div>
</div>
<?php endif; ?>
<?php
$passwordRequired = false;
$passwordLabel = t('New password (optional)');
$passwordLegend = t('Password');
$passwordConfirmLabel = t('Password (again)');
$showTenants = !empty($canEditAssignments);
$tenants = $tenants ?? [];
$selectedTenantIds = $selectedTenantIds ?? [];
$showRoles = !empty($canEditAssignments);
$roles = $roles ?? [];
$selectedRoleIds = $selectedRoleIds ?? [];
$showDepartments = !empty($canEditAssignments);
$departments = $departments ?? [];
$selectedDepartmentIds = $selectedDepartmentIds ?? [];
$isReadOnly = !(!empty($canEditUser));
$canEditAssignments = $canEditAssignments ?? false;
$permissionRows = $permissionRows ?? [];
$showPermissions = !empty($permissionRows) && can('permissions.view');
require __DIR__ . '/_form.phtml';
?>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
<div class="user-avatar-block avatar-round">
<?php if ($hasAvatar): ?>
<a data-fslightbox="user-avatar" href="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=256">
<img class="user-avatar-image" src="admin/users/avatar-file?uuid=<?php e($avatarUuid); ?>&size=128"
alt="<?php e(t('Profile image')); ?>">
</a>
<?php else: ?>
<?php
$initials = strtoupper(substr((string) ($values['first_name'] ?? ''), 0, 1) . substr((string) ($values['last_name'] ?? ''), 0, 1));
?>
<div class="user-avatar-placeholder"><?php e($initials ?: '?'); ?></div>
<?php endif; ?>
</div>
<hgroup>
<h2><?php e($values['first_name'] ?? ''); ?> <?php e($values['last_name'] ?? ''); ?></h2>
<p><?php e($values['email'] ?? ''); ?></p>
</hgroup>
<hr>
<?php if (!empty($canEditUser)): ?>
<details name="user-avatar">
<summary>
<?php e(t('Upload image')); ?>
</summary>
<hr>
<form class="user-avatar-form" method="post" action="admin/users/avatar/<?php e($avatarUuid); ?>"
enctype="multipart/form-data">
<label class="user-avatar-upload">
<input type="file" name="avatar" accept="image/*">
</label>
<div class="grid">
<button type="submit" class="primary">
<?php e(t('Save')); ?>
</button>
<?php if ($hasAvatar): ?>
<button data-tooltip-pos="top" data-tooltip="<?php e(t('Remove image')); ?>" type="submit" class="danger" formaction="admin/users/avatar-delete/<?php e($avatarUuid); ?>"
formmethod="post">
<i class="bi bi-trash3-fill"></i>
</button>
<?php endif; ?>
</div>
<?php Session::getCsrfInput(); ?>
</form>
</details>
<?php endif; ?>
<hr>
<details name="user-meta" open>
<summary><?php e(t('Status & meta')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('ID')); ?></small>
<p>
<span class="badge" data-variant="neutral" data-copy="true"
data-copy-value="<?php e($values['id'] ?? ''); ?>">
<?php e($values['id'] ?? '-'); ?>
</span>
</p>
</div>
<div>
<small><?php e(t('Status')); ?></small>
<p>
<?php if (!empty($values['active'])): ?>
<span class="badge" data-variant="success"><?php e(t('Active')); ?></span>
<?php else: ?>
<span class="badge" data-variant="danger"><?php e(t('Inactive')); ?></span>
<?php endif; ?>
</p>
</div>
</div>
<div>
<small><?php e(t('Email')); ?></small>
<p>
<?php if (!empty($values['email_verified_at'])): ?>
<span class="badge" data-variant="success"><?php e(t('Verified')); ?> <?php e(dt($values['email_verified_at'])); ?></span>
<?php else: ?>
<span class="badge" data-variant="warning"><?php e(t('Not verified')); ?></span>
<?php endif; ?>
</p>
</div>
<div>
<small>
<?php e(t('UUID')); ?>
</small>
<p>
<span class="badge" data-variant="neutral" data-copy="true"
data-copy-value="<?php e($values['uuid'] ?? ''); ?>">
<?php
$uuidValue = (string) ($values['uuid'] ?? '');
$uuidDisplay = $uuidValue !== '' ? substr($uuidValue, 0, 10) : '-';
e($uuidDisplay);
?>
</span>
</p>
</div>
</details>
<hr>
<?php
$createdByLabel = $values['created_by_label'] ?? '';
$createdById = $values['created_by'] ?? null;
$createdByUuid = $values['created_by_uuid'] ?? null;
$modifiedByLabel = $values['modified_by_label'] ?? '';
$modifiedById = $values['modified_by'] ?? null;
$modifiedByUuid = $values['modified_by_uuid'] ?? null;
$activeChangedByLabel = $values['active_changed_by_label'] ?? '';
$activeChangedById = $values['active_changed_by'] ?? null;
$activeChangedByUuid = $values['active_changed_by_uuid'] ?? null;
?>
<details name="user-audit">
<summary><?php e(t('Audit')); ?></summary>
<hr>
<div class="grid">
<div>
<small><?php e(t('Created')); ?></small>
<p><?php e(dt($values['created'] ?? '') ?: '-'); ?></p>
</div>
<div>
<small>
<?php e(t('Created by')); ?>
</small>
<p>
<?php if ($createdByLabel !== ''): ?>
<?php if ($createdByUuid): ?>
<a href="admin/users/edit/<?php e($createdByUuid); ?>"><?php e($createdByLabel); ?></a>
<?php else: ?>
<?php e($createdByLabel); ?>
<?php endif; ?>
<?php elseif ($createdById): ?>
<?php e('#' . $createdById); ?>
<?php else: ?>
-
<?php endif; ?>
</p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Modified')); ?></small>
<p><?php e(dt($values['modified'] ?? '') ?: '-'); ?></p>
</div>
<div>
<small>
<?php e(t('Modified by')); ?>
</small>
<p>
<?php if ($modifiedByLabel !== ''): ?>
<?php if ($modifiedByUuid): ?>
<a href="admin/users/edit/<?php e($modifiedByUuid); ?>"><?php e($modifiedByLabel); ?></a>
<?php else: ?>
<?php e($modifiedByLabel); ?>
<?php endif; ?>
<?php elseif ($modifiedById): ?>
<?php e('#' . $modifiedById); ?>
<?php else: ?>
-
<?php endif; ?>
</p>
</div>
</div>
<div class="grid">
<div>
<small><?php e(t('Status changed')); ?></small>
<p><?php e(dt($values['active_changed_at'] ?? '') ?: '-'); ?></p>
</div>
<div>
<small>
<?php e(t('Status changed by')); ?>
</small>
<p>
<?php if ($activeChangedByLabel !== ''): ?>
<?php if ($activeChangedByUuid): ?>
<a href="admin/users/edit/<?php e($activeChangedByUuid); ?>"><?php e($activeChangedByLabel); ?></a>
<?php else: ?>
<?php e($activeChangedByLabel); ?>
<?php endif; ?>
<?php elseif ($activeChangedById): ?>
<?php e('#' . $activeChangedById); ?>
<?php else: ?>
-
<?php endif; ?>
</p>
</div>
</div>
</details>
</div>
</aside>
</div>

View File

@@ -0,0 +1,49 @@
<?php
use MintyPHP\Service\UserService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$search = trim((string) ($_GET['search'] ?? ''));
$order = (string) ($_GET['order'] ?? 'id');
$dir = strtolower((string) ($_GET['dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
$active = $_GET['active'] ?? 'all';
$createdFrom = trim((string) ($_GET['created_from'] ?? ''));
$createdTo = trim((string) ($_GET['created_to'] ?? ''));
$tenant = trim((string) ($_GET['tenant'] ?? ''));
$roles = $_GET['roles'] ?? '';
$departments = $_GET['departments'] ?? '';
$allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'email', 'locale', 'theme', 'created', 'modified', 'active'];
if (!in_array($order, $allowedOrder, true)) {
$order = 'id';
}
$limit = (int) ($_GET['limit'] ?? 5000);
if ($limit < 1) {
$limit = 5000;
}
if ($limit > 5000) {
$limit = 5000;
}
$result = UserService::listPaged([
'limit' => $limit,
'offset' => 0,
'search' => $search,
'order' => $order,
'dir' => $dir,
'active' => $active,
'created_from' => $createdFrom,
'created_to' => $createdTo,
'tenant' => $tenant,
'roles' => $roles,
'departments' => $departments,
'tenantUserId' => $currentUserId,
]);
$exportRows = $result['rows'] ?? [];
$exportFilename = 'users-' . date('Ymd-His') . '.csv';

View File

@@ -0,0 +1,75 @@
<?php
$filename = $exportFilename ?? ('users-' . date('Ymd-His') . '.csv');
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
$escapeValue = static function ($value, bool $allowSignedNumeric = false) {
if ($value === null) {
return '';
}
$string = (string) $value;
if ($string !== '' && in_array($string[0], ['=', '@'], true)) {
return "'" . $string;
}
if ($string !== '' && in_array($string[0], ['+', '-'], true)) {
if ($allowSignedNumeric && preg_match('/^[+-]?[0-9\\s().-]+$/', $string)) {
return $string;
}
return "'" . $string;
}
return $string;
};
$formatTimestamp = static function ($value) {
if ($value === null || $value === '') {
return '';
}
return dt($value, 'Y-m-d H:i:s');
};
$joinLabels = static function ($value) {
if (is_array($value)) {
return implode(' | ', array_map('strval', $value));
}
if (is_string($value)) {
$value = trim($value);
if ($value === '') {
return '';
}
return str_replace('||', ' | ', $value);
}
return '';
};
$handle = fopen('php://output', 'w');
if ($handle === false) {
return;
}
fputcsv($handle, ['id', 'uuid', 'first_name', 'last_name', 'email', 'phone', 'mobile', 'short_dial', 'locale', 'theme', 'active', 'tenants', 'departments', 'roles', 'created_by', 'modified_by', 'created', 'modified'], ',', '"', '\\');
foreach (($exportRows ?? []) as $row) {
fputcsv($handle, [
$escapeValue($row['id'] ?? ''),
$escapeValue($row['uuid'] ?? ''),
$escapeValue($row['first_name'] ?? ''),
$escapeValue($row['last_name'] ?? ''),
$escapeValue($row['email'] ?? ''),
$escapeValue($row['phone'] ?? '', true),
$escapeValue($row['mobile'] ?? '', true),
$escapeValue($row['short_dial'] ?? '', true),
$escapeValue($row['locale'] ?? ''),
$escapeValue($row['theme'] ?? ''),
(int) ($row['active'] ?? 0),
$escapeValue($joinLabels($row['tenant_labels'] ?? [])),
$escapeValue($joinLabels($row['department_labels'] ?? [])),
$escapeValue($joinLabels($row['role_labels'] ?? [])),
$escapeValue($row['created_by'] ?? ''),
$escapeValue($row['modified_by'] ?? ''),
$formatTimestamp($row['created'] ?? ''),
$formatTimestamp($row['modified'] ?? ''),
], ',', '"', '\\');
}
fclose($handle);

View File

@@ -0,0 +1,39 @@
<?php
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\RememberMeService;
use MintyPHP\Service\TenantScopeService;
use MintyPHP\Service\UserService;
Guard::requireLogin();
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$canUpdateUser = PermissionService::userHas($currentUserId, PermissionService::USERS_UPDATE);
$canUpdateSelf = PermissionService::userHas($currentUserId, PermissionService::USERS_SELF_UPDATE);
$uuid = trim((string) ($id ?? ''));
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
if (!$user) {
Flash::error(t('User not found'), 'admin/users', 'user_not_found');
Router::redirect('admin/users');
}
$userId = (int) ($user['id'] ?? 0);
$isOwnAccount = $currentUserId > 0 && $currentUserId === $userId;
$canEditUser = $isOwnAccount ? ($canUpdateUser || $canUpdateSelf) : $canUpdateUser;
if (!$canEditUser) {
Router::redirect('error/forbidden');
return;
}
if (!$isOwnAccount && !TenantScopeService::canAccess('users', $userId, $currentUserId)) {
Router::redirect('error/forbidden');
return;
}
RememberMeService::forgetAllForUser($userId);
Flash::success(t('Login tokens cleared'), "admin/users/edit/{$uuid}", 'tokens_cleared');
Router::redirect("admin/users/edit/{$uuid}");

View File

@@ -0,0 +1,48 @@
<?php
use MintyPHP\Buffer;
use MintyPHP\Support\Guard;
use MintyPHP\Session;
use MintyPHP\Service\DepartmentService;
use MintyPHP\Service\RoleService;
use MintyPHP\Service\TenantService;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\TenantScopeService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::USERS_VIEW);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
if ($currentUserId > 0) {
PermissionService::getUserPermissions($currentUserId);
}
$allowedTenantIds = TenantScopeService::getUserTenantIds($currentUserId);
Buffer::set('title', t('Users'));
Buffer::set('grid_lang', json_encode(gridLang(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$csrfKey = Session::$csrfSessionKey;
$csrfToken = $_SESSION[$csrfKey] ?? '';
Buffer::set(
'grid_csrf',
json_encode(['key' => $csrfKey, 'token' => $csrfToken], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
$tenants = TenantService::list();
if ($allowedTenantIds) {
$tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool {
$tenantId = (int) ($tenant['id'] ?? 0);
return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true);
}));
} elseif (TenantScopeService::isStrict()) {
$tenants = [];
}
sortByDescription($tenants);
$roles = RoleService::list();
sortByDescription($roles);
$departments = $allowedTenantIds ? DepartmentService::listByTenantIds($allowedTenantIds) : [];
if (!$allowedTenantIds && !TenantScopeService::isStrict()) {
$departments = DepartmentService::list();
}
sortByDescription($departments);

View File

@@ -0,0 +1,389 @@
<?php
use MintyPHP\Router;
$activeTenant = trim((string) ($_GET['tenant'] ?? ''));
$activeRoles = array_filter(array_map('trim', explode(',', (string) ($_GET['roles'] ?? ''))));
$activeDepartments = array_filter(array_map('trim', explode(',', (string) ($_GET['departments'] ?? ''))));
$canDeleteUsers = can('users.delete');
$canUpdateUsers = can('users.update');
$canUpdateSelf = can('users.self_update');
$currentUserUuid = (string) ($_SESSION['user']['uuid'] ?? '');
$showTenantTabs = isset($tenants) && is_array($tenants) && count($tenants) > 1;
?>
<div class="app-breadcrumb">
<a href="/admin">Startseite</a><i class="bi bi-chevron-right"></i><?php e(t('Users')); ?>
</div>
<div class="app-list-titlebar">
<h1><?php e(t('Users')); ?></h1>
<div class="app-list-titlebar-actions">
<button type="button" class="secondary outline" data-users-bulk="activate" hidden>
<i class="bi bi-check-circle"></i> <?php e(t('Activate')); ?>
</button>
<button type="button" class="secondary outline" data-users-bulk="deactivate" hidden>
<i class="bi bi-ban"></i> <?php e(t('Deactivate')); ?>
</button>
<?php if ($canUpdateUsers): ?>
<button type="button" class="secondary outline" data-users-bulk="send-access" hidden>
<i class="bi bi-envelope"></i> <?php e(t('Send access')); ?>
</button>
<?php endif; ?>
<?php if ($canDeleteUsers): ?>
<button type="button" class="danger outline" data-users-bulk="delete" hidden>
<i class="bi bi-trash3-fill"></i> <?php e(t('Delete')); ?>
</button>
<?php endif; ?>
<details class="dropdown" data-tooltip="<?php e(t("Actions"));?>" data-tooltip-pos="top">
<summary role="button" class="outline secondary"><i class="bi bi-three-dots"></i></summary>
<ul dir="rtl">
<li dir="ltr">
<button class="transparent" type="button" data-toolbar-toggle data-toolbar-target="#users-toolbar, #users-tabs"
data-toolbar-text-show="<?php e(t('Show filters')); ?>"
data-toolbar-text-hide="<?php e(t('Hide filters')); ?>">
<span data-toolbar-label>
<?php e(t('Hide filters')); ?>
</span>
</button>
</li>
<li dir="ltr">
<button class="transparent" type="button" data-users-reset>
<?php e(t('Reset filters')); ?>
</button>
</li>
<li dir="ltr">
<button class="transparent" type="button" data-users-export>
<?php e(t('Export CSV')); ?>
</button>
</li>
</ul>
</details>
<?php if (can('users.create')): ?>
<a role="button" class="primary" href="admin/users/create">
<i class="bi bi-plus"></i> <?php e(t('Create user')); ?>
</a>
<?php endif; ?>
</div>
</div>
<?php if ($showTenantTabs): ?>
<div class="app-list-tabs" id="users-tabs">
<a href="admin/users" class="<?php e($activeTenant === '' ? 'tab-link is-active' : 'tab-link'); ?>">
<?php e(t('All')); ?>
</a>
<?php foreach ($tenants ?? [] as $tenant): ?>
<?php if (!empty($tenant['uuid'])): ?>
<?php $isActive = $activeTenant === $tenant['uuid']; ?>
<a href="admin/users?tenant=<?php e($tenant['uuid']); ?>" class="<?php e($isActive ? 'tab-link is-active' : 'tab-link'); ?>">
<?php e($tenant['description'] ?? ''); ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="app-list-toolbar" id="users-toolbar">
<input type="hidden" id="user-tenant-filter" value="">
<label class="app-field">
<span><?php e(t('Search')); ?></span>
<input type="search" id="user-search" placeholder="<?php e(t('Search...')); ?>">
</label>
<label class="app-field">
<span><?php e(t('Status')); ?></span>
<select id="user-status-filter">
<option value="all"><?php e(t('All')); ?></option>
<option value="active"><?php e(t('Active')); ?></option>
<option value="inactive"><?php e(t('Inactive')); ?></option>
</select>
</label>
<label class="app-field">
<span><?php e(t('Email verified')); ?></span>
<select id="user-email-verified-filter">
<option value="all"><?php e(t('All')); ?></option>
<option value="verified"><?php e(t('Verified')); ?></option>
<option value="unverified"><?php e(t('Unverified')); ?></option>
</select>
</label>
<?php multiSelectFilter('user-department-filter', 'Departments', 'Select departments', $departments ?? [], $activeDepartments); ?>
<?php multiSelectFilter('user-role-filter', 'Roles', 'Select roles', $roles ?? [], $activeRoles); ?>
<label class="app-field">
<span><?php e(t('Created from')); ?></span>
<input type="date" id="user-created-from">
</label>
<label class="app-field">
<span><?php e(t('Created to')); ?></span>
<input type="date" id="user-created-to">
</label>
</div>
<div class="app-list-table">
<div id="users-grid"></div>
</div>
<?php $gridFactoryVersion = @filemtime('web/js/core/app-grid-factory.js') ?: time(); ?>
<script src="<?php e(asset('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script src="<?php e(asset('vendor/gridjs/plugins/selection/selection.umd.js')); ?>"></script>
<script type="module">
import { initToolbarToggles } from "<?php e(asset('js/components/app-toggle-toolbar.js')); ?>";
import { createServerGrid } from "<?php e(asset('js/core/app-grid-factory.js?v=' . $gridFactoryVersion)); ?>";
import { initUsersListPage } from "<?php e(asset('js/pages/app-users-list.js')); ?>";
import { bindBulkVisibility } from "<?php e(asset('js/components/app-bulk-selection.js')); ?>";
import { buildUrl, badgeHtml } from "<?php e(asset('js/pages/app-list-utils.js')); ?>";
import { showAsyncFlash, withLoading } from "<?php e(asset('js/components/app-async-flash.js')); ?>";
initToolbarToggles();
const appBase = "<?php e(localeBase()); ?>";
const currentUserUuid = "<?php e($currentUserUuid); ?>";
const canUpdateUsers = <?php e($canUpdateUsers ? 'true' : 'false'); ?>;
const canUpdateSelf = <?php e($canUpdateSelf ? 'true' : 'false'); ?>;
const csrf = <?php MintyPHP\Buffer::get('grid_csrf'); ?>;
const labels = {
active: "<?php e(t('Active')); ?>",
inactive: "<?php e(t('Inactive')); ?>",
bulkActivateConfirm: "<?php e(t('Activate users?')); ?>",
bulkDeactivateConfirm: "<?php e(t('Deactivate users?')); ?>",
bulkDeleteConfirm: "<?php e(t('Delete users?')); ?>",
bulkSendAccessConfirm: "<?php e(t('Send access emails to selected users?')); ?>",
primaryTenant: "<?php e(t('Primary tenant')); ?>",
bulkMessages: {
'activate': {
success: "<?php e(t('%d users activated')); ?>",
error: "<?php e(t('Failed to activate users')); ?>"
},
'deactivate': {
success: "<?php e(t('%d users deactivated')); ?>",
error: "<?php e(t('Failed to deactivate users')); ?>"
},
'delete': {
success: "<?php e(t('%d users deleted')); ?>",
error: "<?php e(t('Failed to delete users')); ?>"
},
'send-access': {
success: "<?php e(t('Access emails sent to %d users')); ?>",
partial: "<?php e(t('Access emails sent to %d users, %f failed')); ?>",
error: "<?php e(t('Failed to send access emails')); ?>"
}
}
};
const buildBulkUrl = (action) => buildUrl(appBase, `admin/users/bulk/${action}`);
const normalizeCommaSeparated = (value) => {
if (Array.isArray(value)) return value;
if (typeof value === 'string') {
return value.split(',').map((item) => item.trim()).filter(Boolean);
}
return [];
};
const initUsersGrid = () => {
const rowSelection = window.gridjs?.plugins?.selection?.RowSelection;
if (!rowSelection) {
throw new Error('Grid.js RowSelection plugin is not available.');
}
const activeIndex = 6;
const uuidIndex = 2;
const canEditRow = (row) => {
const uuid = row?.cells?.[uuidIndex]?.data;
if (!uuid) return false;
if (uuid === currentUserUuid) {
return canUpdateUsers || canUpdateSelf;
}
return canUpdateUsers;
};
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
const buildBadgeList = (items) => {
let list = items;
let primary = '';
if (items && typeof items === 'object' && !Array.isArray(items)) {
list = Array.isArray(items.items) ? items.items : [];
primary = String(items.primary ?? '');
}
if (!Array.isArray(list) || list.length === 0) return '';
const badges = list.map((label) => {
const isPrimary = primary !== '' && label === primary;
const variant = isPrimary ? 'info' : 'neutral';
const tooltip = isPrimary ? ` data-tooltip="${escapeHtml(labels.primaryTenant)}" data-tooltip-pos="top"` : '';
return `<span class="badge" data-variant="${variant}"${tooltip}>${escapeHtml(label)}</span>`;
}).join(' ');
return `<div class="badge-list">${badges}</div>`;
};
const initialsForRow = (row) => {
const first = row?.cells?.[3]?.data ?? '';
const last = row?.cells?.[4]?.data ?? '';
const parts = [first, last]
.map((value) => String(value || '').trim())
.filter(Boolean);
const chars = parts.map((value) => value[0] || '').join('').toUpperCase();
return chars || '?';
};
const columns = [
{
name: "<?php e(t('Avatar')); ?>",
sort: false,
formatter: (cell, row) => {
if (!cell) {
const initials = escapeHtml(initialsForRow(row));
return gridjs.html(`<span class="grid-avatar grid-avatar-placeholder">${initials}</span>`);
}
const src = new URL(`admin/users/avatar-file?uuid=${cell}&size=64`, appBase).toString();
const full = new URL(`admin/users/avatar-file?uuid=${cell}&size=256`, appBase).toString();
return gridjs.html(`<a data-fslightbox="user-avatars" href="${full}"><img class="grid-avatar" src="${src}" alt="" loading="lazy"></a>`);
}
},
{ name: 'UUID', hidden: true },
{ name: "<?php e(t('First name')); ?>", sort: true },
{ name: "<?php e(t('Last name')); ?>", sort: true },
{ name: "<?php e(t('Email')); ?>", sort: true },
{
name: "<?php e(t('State')); ?>",
sort: true,
formatter: (cell) => cell
? gridjs.html(`<span class="badge" data-variant="success">${labels.active}</span>`)
: gridjs.html(`<span class="badge" data-variant="danger">${labels.inactive}</span>`)
},
{ name: "<?php e(t('Tenants')); ?>", sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
{ name: "<?php e(t('Departments')); ?>", sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
{ name: "<?php e(t('Roles')); ?>", sort: false, formatter: (cell) => gridjs.html(buildBadgeList(cell)) },
{ name: "<?php e(t('Phone')); ?>", sort: false },
{ name: "<?php e(t('Mobile')); ?>", sort: false },
{ name: "<?php e(t('Short dial')); ?>", sort: false },
{ name: "<?php e(t('Created')); ?>", sort: true, formatter: (cell) => badgeHtml(gridjs, cell) },
{ name: "<?php e(t('Modified')); ?>", sort: true, formatter: (cell) => badgeHtml(gridjs, cell) },
{ name: 'ID', hidden: true }
];
const gridConfig = createServerGrid({
gridjs: window.gridjs,
container: '#users-grid',
dataUrl: 'admin/users/data',
appBase,
columns,
sortColumns: [null, null, 'first_name', 'last_name', 'email', 'active', null, null, null, null, null, null, 'created', 'modified', null],
paginationLimit: 10,
language: <?php MintyPHP\Buffer::get('grid_lang'); ?>,
mapData: (data) => data.data.map((row) => [
row.has_avatar ? row.uuid : '',
row.uuid,
row.first_name,
row.last_name,
row.email,
row.active,
row.tenants,
row.departments,
row.roles,
row.phone,
row.mobile,
row.short_dial,
row.created,
row.modified,
row.id
]),
selection: {
enabled: true,
id: 'selectRow',
component: rowSelection,
selectAllLabel: "<?php e(t('Select all')); ?>",
props: {
id: (row) => row.cell(uuidIndex).data
},
getSelectedIds: ({ container }) => {
if (!container) return [];
return Array.from(container.querySelectorAll('.gridjs-tr-selected'))
.map((row) => row.getAttribute('data-uuid'))
.filter(Boolean);
}
},
search: {
input: '#user-search',
param: 'search',
debounce: 250
},
filters: [
{
input: '#user-status-filter',
param: 'active',
default: 'all',
normalize: (value) => (value && value !== 'all' ? value : '')
},
{
input: '#user-email-verified-filter',
param: 'email_verified',
default: 'all',
normalize: (value) => (value && value !== 'all' ? value : '')
},
{
input: '#user-department-filter',
param: 'departments',
default: [],
normalize: normalizeCommaSeparated
},
{
input: '#user-role-filter',
param: 'roles',
default: [],
normalize: normalizeCommaSeparated
},
{
input: '#user-tenant-filter',
param: 'tenant',
default: '',
normalize: (value) => value || ''
},
{
input: '#user-created-from',
param: 'created_from',
normalize: (value) => (value ? value : '')
},
{
input: '#user-created-to',
param: 'created_to',
normalize: (value) => (value ? value : '')
}
],
urlSync: true,
rowDataset: (row) => ({
uuid: row?.cells?.[uuidIndex]?.data
}),
rowDblClick: {
getUrl: (rowData) => {
if (!canEditRow(rowData)) {
return '';
}
const uuid = rowData?.cells?.[uuidIndex]?.data;
return uuid ? new URL(`admin/users/edit/${uuid}`, appBase).toString() : '';
}
}
});
if (!gridConfig || !gridConfig.grid) {
console.warn('Users grid init failed');
return null;
}
return { ...gridConfig, indices: { activeIndex, uuidIndex } };
};
const gridConfig = initUsersGrid();
initUsersListPage({
gridConfig,
appBase,
csrf,
labels,
buildBulkUrl,
showFlash: showAsyncFlash,
withLoading
});
bindBulkVisibility({
containerSelector: '#users-grid',
buttonSelector: '[data-users-bulk]'
});
</script>

View File

@@ -0,0 +1,73 @@
<?php
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\TenantScopeService;
use MintyPHP\Service\UserService;
use MintyPHP\Service\MailService;
use MintyPHP\I18n;
use MintyPHP\Http\Request;
Guard::requireLogin();
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$canUpdateUser = PermissionService::userHas($currentUserId, PermissionService::USERS_UPDATE);
$canUpdateSelf = PermissionService::userHas($currentUserId, PermissionService::USERS_SELF_UPDATE);
$uuid = trim((string) ($id ?? ''));
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
if (!$user) {
Flash::error(t('User not found'), 'admin/users', 'user_not_found');
Router::redirect('admin/users');
}
$userId = (int) ($user['id'] ?? 0);
$isOwnAccount = $currentUserId > 0 && $currentUserId === $userId;
$canEditUser = $isOwnAccount ? ($canUpdateUser || $canUpdateSelf) : $canUpdateUser;
if (!$canEditUser) {
Router::redirect('error/forbidden');
return;
}
if (!$isOwnAccount && !TenantScopeService::canAccess('users', $userId, $currentUserId)) {
Router::redirect('error/forbidden');
return;
}
$locale = $user['locale'] ?? (I18n::$locale ?? I18n::$defaultLocale);
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
$isGerman = strpos((string) $locale, 'de') === 0;
$greeting = $isGerman ? 'Hallo' : 'Hello';
if ($name !== '') {
$greeting .= ' ' . $name;
}
$greeting .= ',';
$loginUrl = appUrl(Request::withLocale('login', $locale));
$resetUrl = appUrl(Request::withLocale('password/forgot', $locale));
$previousLocale = I18n::$locale ?? null;
I18n::$locale = $locale;
$subject = t('Your access details');
I18n::$locale = $previousLocale;
$vars = [
'app_name' => appTitle(),
'app_logo_url' => appLogoUrlAbsolute(128),
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
'greeting' => $greeting,
'username' => (string) ($user['email'] ?? ''),
'login_url' => $loginUrl,
'reset_url' => $resetUrl,
];
$result = MailService::sendTemplate('access_info', $vars, (string) $user['email'], $subject, $locale);
if (!($result['ok'] ?? false)) {
Flash::error(t('Access email failed'), "admin/users/edit/{$uuid}", 'access_email_failed');
Router::redirect("admin/users/edit/{$uuid}");
}
Flash::success(t('Access email sent'), "admin/users/edit/{$uuid}", 'access_email_sent');
Router::redirect("admin/users/edit/{$uuid}");

View File

@@ -0,0 +1,83 @@
<?php
use MintyPHP\Http\Request;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Service\UserService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin');
return;
}
if (!Session::checkCsrfToken()) {
if (Request::wantsJson()) {
http_response_code(400);
Router::json(['ok' => false, 'error' => 'csrf']);
return;
}
Router::redirect('admin');
return;
}
$userId = (int) ($_SESSION['user']['id'] ?? 0);
if ($userId <= 0) {
http_response_code(401);
if (Request::wantsJson()) {
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
Router::redirect('login');
return;
}
// Get tenant_id from POST
$tenantId = (int) ($_POST['tenant_id'] ?? 0);
if ($tenantId <= 0) {
http_response_code(400);
if (Request::wantsJson()) {
Router::json(['ok' => false, 'error' => 'invalid_tenant_id']);
return;
}
Router::redirect('admin');
return;
}
$result = UserService::setCurrentTenant($userId, $tenantId);
if (!($result['ok'] ?? false)) {
$error = $result['error'] ?? 'update_failed';
http_response_code(400);
if (Request::wantsJson()) {
Router::json(['ok' => false, 'error' => $error]);
return;
}
Router::redirect('admin');
return;
}
// Update session with new current tenant
if (isset($_SESSION['user'])) {
$_SESSION['user']['current_tenant_id'] = $tenantId;
}
// Update session with full tenant data
$_SESSION['current_tenant'] = $result['tenant'] ?? null;
// Reload available tenants and departments
$_SESSION['available_tenants'] = UserService::getAvailableTenants($userId);
$_SESSION['available_departments_by_tenant'] = UserService::getAvailableDepartmentsByTenant($userId);
if (Request::wantsJson()) {
Router::json([
'ok' => true,
'tenant_id' => $tenantId,
'tenant' => $result['tenant'] ?? null,
]);
return;
}
Router::redirect('admin');

View File

@@ -0,0 +1,66 @@
<?php
use MintyPHP\Http\Request;
use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Service\UserService;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin');
return;
}
if (!Session::checkCsrfToken()) {
if (Request::wantsJson()) {
http_response_code(400);
Router::json(['ok' => false, 'error' => 'csrf']);
return;
}
Router::redirect('admin');
return;
}
$userId = (int) ($_SESSION['user']['id'] ?? 0);
if ($userId <= 0) {
http_response_code(401);
if (Request::wantsJson()) {
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
Router::redirect('login');
return;
}
$theme = trim((string) ($_POST['theme'] ?? ''));
if (!in_array($theme, ['light', 'dark'], true)) {
http_response_code(400);
if (Request::wantsJson()) {
Router::json(['ok' => false, 'error' => 'invalid_theme']);
return;
}
Router::redirect('admin');
return;
}
$updated = UserService::setTheme($userId, $theme);
if (!$updated) {
http_response_code(500);
if (Request::wantsJson()) {
Router::json(['ok' => false, 'error' => 'update_failed']);
return;
}
Router::redirect('admin');
return;
}
$_SESSION['user']['theme'] = $theme;
if (Request::wantsJson()) {
Router::json(['ok' => true, 'theme' => $theme]);
return;
}
Router::redirect('admin');