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,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');