add composer-unused, comprehensive docs, and project restructure

- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 15:27:35 +01:00
parent 3eb9cc0ac4
commit 25370a1a55
389 changed files with 40506 additions and 8071 deletions

View File

@@ -15,7 +15,7 @@
* @var array $roles
* @var array $selectedRoleIds
* @var bool $showDepartments
* @var array $departments
* @var array $departmentOptionsByTenant
* @var array $selectedDepartmentIds
* @var bool $isReadOnly
* @var bool $showPermissions
@@ -24,6 +24,19 @@
* @var array $passwordResets
* @var bool $showRememberTokens
* @var array $rememberTokens
* @var bool $showCustomFieldsTab
* @var bool $canEditCustomFieldValues
* @var array $customFieldDefinitionsByTenant
* @var array $customFieldValueMap
* @var array $customFieldPostedValues
* @var bool $showDangerZone
* @var string|null $dangerZoneDeleteFormId
* @var string|null $dangerZoneWarning
* @var string|null $dangerZoneActionLabel
* @var bool $showTokenDangerAction
* @var string|null $tokenDangerFormId
* @var string|null $tokenDangerWarning
* @var string|null $tokenDangerActionLabel
*/
use MintyPHP\Session;
@@ -45,7 +58,7 @@ $showRoles = $showRoles ?? false;
$roles = $roles ?? [];
$selectedRoleIds = $selectedRoleIds ?? [];
$showDepartments = $showDepartments ?? false;
$departments = $departments ?? [];
$departmentOptionsByTenant = $departmentOptionsByTenant ?? [];
$selectedDepartmentIds = $selectedDepartmentIds ?? [];
$primaryTenantId = (int) ($values['primary_tenant_id'] ?? 0);
if (!$primaryTenantId && count($selectedTenantIds) === 1) {
@@ -58,34 +71,69 @@ $passwordResets = $passwordResets ?? [];
$showPasswordResets = $showPasswordResets ?? false;
$rememberTokens = $rememberTokens ?? [];
$showRememberTokens = $showRememberTokens ?? false;
$apiTokens = $apiTokens ?? [];
$showApiTokens = $showApiTokens ?? false;
$canManageApiTokens = $canManageApiTokens ?? false;
$showCustomFieldsTab = (bool) $showCustomFieldsTab;
$canEditCustomFieldValues = (bool) $canEditCustomFieldValues;
$customFieldDefinitionsByTenant = (array) $customFieldDefinitionsByTenant;
$customFieldValueMap = (array) $customFieldValueMap;
$customFieldPostedValues = (array) $customFieldPostedValues;
$customFieldScalarPosted = is_array($customFieldPostedValues['custom_field_values'] ?? null)
? $customFieldPostedValues['custom_field_values']
: [];
$customFieldMultiPosted = is_array($customFieldPostedValues['custom_field_values_multi'] ?? null)
? $customFieldPostedValues['custom_field_values_multi']
: [];
$showDangerZone = (bool) ($showDangerZone ?? false);
$dangerZoneDeleteFormId = (string) ($dangerZoneDeleteFormId ?? '');
$dangerZoneWarning = (string) ($dangerZoneWarning ?? t('This action cannot be undone.'));
$dangerZoneActionLabel = (string) ($dangerZoneActionLabel ?? t('Delete'));
$showTokenDangerAction = (bool) ($showTokenDangerAction ?? false);
$tokenDangerFormId = (string) ($tokenDangerFormId ?? '');
$tokenDangerWarning = (string) ($tokenDangerWarning ?? t('This action cannot be undone.'));
$tokenDangerActionLabel = (string) ($tokenDangerActionLabel ?? t('Clear login tokens'));
$minLength = UserService::passwordMinLength();
$requiredAttr = $passwordRequired ? 'required' : '';
$readonlyAttr = $isReadOnly ? 'readonly' : '';
$disabledAttr = $isReadOnly ? 'disabled' : '';
$dataDisabledAttr = $isReadOnly ? 'data-disabled="true"' : '';
$visibilityDisabled = $isReadOnly || $isOwnAccount;
$visibilityDisabledAttr = $visibilityDisabled ? 'disabled' : '';
$visibilityHint = $isOwnAccount
? t('You cannot deactivate your own account')
: t('Inactive entries are hidden in lists and selections.');
$locales = defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale];
$primaryTenantDisabled = $isReadOnly || count($selectedTenantIds) === 1;
$primaryTenantDisabledAttr = $primaryTenantDisabled ? 'disabled' : '';
$showOrganization = $showTenants || $showDepartments || $showRoles;
$showOrganizationTab = $showOrganization || $showPermissions;
$customFieldInputsDisabled = $isReadOnly || !$canEditCustomFieldValues;
$customFieldInputsDisabledAttr = $customFieldInputsDisabled ? 'disabled' : '';
$customFieldTenantMap = [];
foreach ($tenants as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0 || !in_array($tenantId, $selectedTenantIds, true)) {
continue;
}
$customFieldTenantMap[$tenantId] = $tenant;
}
?>
<form id="<?php e($formId); ?>" method="post">
<form id="<?php e($formId); ?>" method="post" data-details-storage="admin-user-form-details-v1">
<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="profile" data-tab-default><?php e(t('Master data')); ?></button>
<?php if ($showOrganizationTab): ?>
<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 if ($showCustomFieldsTab): ?>
<button type="button" data-tab="custom_fields"><?php e(t('Custom fields')); ?></button>
<?php endif; ?>
<?php if ($showPasswordResets): ?>
<button type="button" data-tab="password-resets"><?php e(t('Password resets')); ?></button>
<?php endif; ?>
<?php if ($showRememberTokens): ?>
<button type="button" data-tab="login-tokens"><?php e(t('Login tokens')); ?></button>
<button type="button" data-tab="security"><?php e(t('Security')); ?></button>
<button type="button" data-tab="visibility"><?php e(t('Visibility')); ?></button>
<?php if ($showDangerZone || $showTokenDangerAction): ?>
<button type="button" data-tab="danger"><?php e(t('Danger zone')); ?></button>
<?php endif; ?>
</div>
@@ -109,7 +157,7 @@ $showOrganization = $showTenants || $showDepartments || $showRoles;
</label>
<hr>
<details name="profile-data" open>
<details name="profile-data" data-details-key="profile-data" <?php e($detailsOpenAll ? 'open' : ''); ?>>
<summary><?php e(t('Profile')); ?></summary>
<hr>
<label for="job_title">
@@ -124,7 +172,7 @@ $showOrganization = $showTenants || $showDepartments || $showRoles;
</label>
</details>
<hr>
<details name="preferences" open>
<details name="preferences" data-details-key="preferences" <?php e($detailsOpenAll ? 'open' : ''); ?>>
<summary><?php e(t('Preferences')); ?></summary>
<hr>
<div class="grid">
@@ -168,104 +216,8 @@ $showOrganization = $showTenants || $showDepartments || $showRoles;
</label>
</div>
</details>
</div>
<div data-tab-panel="access">
<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; ?>
</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>
<hr>
<details name="profile-contact-data" data-details-key="profile-contact-data" <?php e($detailsOpenAll ? 'open' : ''); ?>>
<summary><?php e(t('Contact')); ?></summary>
<hr>
<div class="grid">
@@ -287,7 +239,7 @@ $showOrganization = $showTenants || $showDepartments || $showRoles;
</div>
</details>
<hr>
<details name="address-data" open>
<details name="profile-address-data" data-details-key="profile-address-data" <?php e($detailsOpenAll ? 'open' : ''); ?>>
<summary><?php e(t('Address')); ?></summary>
<hr>
<label for="address">
@@ -316,7 +268,7 @@ $showOrganization = $showTenants || $showDepartments || $showRoles;
</div>
</details>
<hr>
<details name="employment-data" open>
<details name="profile-employment-data" data-details-key="profile-employment-data" <?php e($detailsOpenAll ? 'open' : ''); ?>>
<summary><?php e(t('Employment')); ?></summary>
<hr>
<label for="hire_date">
@@ -325,154 +277,598 @@ $showOrganization = $showTenants || $showDepartments || $showRoles;
</label>
</details>
</div>
<?php if ($showPermissions): ?>
<div data-tab-panel="permissions">
<div class="overflow-auto white-space-nowrap">
<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>
<div data-tab-panel="security">
<details name="security-password" data-details-key="security-password" 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; ?>
</tbody>
</table>
<li data-rule="match"><?php e(t('Passwords must match')); ?></li>
</ul>
</div>
</div>
</div>
<?php endif; ?>
<?php if ($showPasswordResets): ?>
<div data-tab-panel="password-resets">
<div class="overflow-auto white-space-nowrap">
<table>
<thead>
<tr>
<th><?php e(t('Requested at')); ?></th>
<th><?php e(t('Expires at')); ?></th>
<th><?php e(t('Used at')); ?></th>
<th><?php e(t('Attempts')); ?></th>
<th><?php e(t('Status')); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($passwordResets as $reset): ?>
<?php
$expiresAt = (string) ($reset['expires_at'] ?? '');
$usedAt = (string) ($reset['used_at'] ?? '');
$status = t('Active');
$statusVariant = 'success';
if ($usedAt !== '') {
$status = t('Used');
$statusVariant = 'neutral';
} elseif ($expiresAt !== '') {
try {
$expired = (new \DateTimeImmutable($expiresAt)) <= new \DateTimeImmutable('now');
if ($expired) {
$status = t('Expired');
$statusVariant = 'warning';
}
} catch (\Exception $e) {
// fallback: keep Active
}
}
?>
</details>
<?php if ($showPasswordResets): ?>
<hr>
<details name="security-password-resets" data-details-key="security-password-resets">
<summary><?php e(t('Password resets')); ?></summary>
<hr>
<div class="overflow-auto white-space-nowrap">
<table>
<thead>
<tr>
<td><?php e(dt($reset['created'] ?? '')); ?></td>
<td><?php e($expiresAt !== '' ? dt($expiresAt) : '-'); ?></td>
<td><?php e($usedAt !== '' ? dt($usedAt) : '-'); ?></td>
<td><?php e((string) ($reset['attempts'] ?? 0)); ?></td>
<td>
<span class="badge" data-variant="<?php e($statusVariant); ?>">
<?php e($status); ?>
</span>
</td>
<th><?php e(t('Requested at')); ?></th>
<th><?php e(t('Expires at')); ?></th>
<th><?php e(t('Used at')); ?></th>
<th><?php e(t('Attempts')); ?></th>
<th><?php e(t('Status')); ?></th>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php if ($showRememberTokens): ?>
<div data-tab-panel="login-tokens">
<div class="overflow-auto white-space-nowrap">
<table>
<thead>
<tr>
<th><?php e(t('Created at')); ?></th>
<th><?php e(t('Expires at')); ?></th>
<th><?php e(t('Last used')); ?></th>
<th><?php e(t('Status')); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($rememberTokens as $token): ?>
<?php
$expiresAt = (string) ($token['expires_at'] ?? '');
$expiredByAdminAt = (string) ($token['expired_by_admin_at'] ?? '');
$lastUsed = (string) ($token['last_used'] ?? '');
$status = t('Active');
$statusVariant = 'success';
if ($expiresAt !== '') {
try {
$expired = (new \DateTimeImmutable($expiresAt)) <= new \DateTimeImmutable('now');
if ($expired) {
if ($expiredByAdminAt !== '') {
$status = t('Expired by admin');
$statusVariant = 'warning';
} else {
</thead>
<tbody>
<?php foreach ($passwordResets as $reset): ?>
<?php
$expiresAt = (string) ($reset['expires_at'] ?? '');
$usedAt = (string) ($reset['used_at'] ?? '');
$status = t('Active');
$statusVariant = 'success';
if ($usedAt !== '') {
$status = t('Used');
$statusVariant = 'neutral';
} elseif ($expiresAt !== '') {
try {
$expired = (new \DateTimeImmutable($expiresAt)) <= new \DateTimeImmutable('now');
if ($expired) {
$status = t('Expired');
$statusVariant = 'warning';
}
} catch (\Exception $e) {
// fallback: keep Active
}
} catch (\Exception $e) {
// fallback: keep Active
}
}
?>
?>
<tr>
<td><?php e(dt($reset['created'] ?? '')); ?></td>
<td><?php e($expiresAt !== '' ? dt($expiresAt) : '-'); ?></td>
<td><?php e($usedAt !== '' ? dt($usedAt) : '-'); ?></td>
<td><?php e((string) ($reset['attempts'] ?? 0)); ?></td>
<td>
<span class="badge" data-variant="<?php e($statusVariant); ?>">
<?php e($status); ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</details>
<?php endif; ?>
<?php if ($showRememberTokens): ?>
<hr>
<details name="security-login-tokens" data-details-key="security-login-tokens">
<summary><?php e(t('Login tokens')); ?></summary>
<hr>
<div class="overflow-auto white-space-nowrap">
<table>
<thead>
<tr>
<td><?php e(dt($token['created'] ?? '')); ?></td>
<td><?php e($expiresAt !== '' ? dt($expiresAt) : '-'); ?></td>
<td><?php e($lastUsed !== '' ? dt($lastUsed) : '-'); ?></td>
<td>
<span class="badge" data-variant="<?php e($statusVariant); ?>">
<?php e($status); ?>
</span>
</td>
<th><?php e(t('Created at')); ?></th>
<th><?php e(t('Expires at')); ?></th>
<th><?php e(t('Last used')); ?></th>
<th><?php e(t('Status')); ?></th>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</thead>
<tbody>
<?php foreach ($rememberTokens as $token): ?>
<?php
$expiresAt = (string) ($token['expires_at'] ?? '');
$expiredByAdminAt = (string) ($token['expired_by_admin_at'] ?? '');
$lastUsed = (string) ($token['last_used'] ?? '');
$status = t('Active');
$statusVariant = 'success';
if ($expiresAt !== '') {
try {
$expired = (new \DateTimeImmutable($expiresAt)) <= new \DateTimeImmutable('now');
if ($expired) {
if ($expiredByAdminAt !== '') {
$status = t('Expired by admin');
$statusVariant = 'warning';
} else {
$status = t('Expired');
$statusVariant = 'warning';
}
}
} catch (\Exception $e) {
// fallback: keep Active
}
}
?>
<tr>
<td><?php e(dt($token['created'] ?? '')); ?></td>
<td><?php e($expiresAt !== '' ? dt($expiresAt) : '-'); ?></td>
<td><?php e($lastUsed !== '' ? dt($lastUsed) : '-'); ?></td>
<td>
<span class="badge" data-variant="<?php e($statusVariant); ?>">
<?php e($status); ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</details>
<?php endif; ?>
<?php if ($showApiTokens): ?>
<hr>
<?php
$apiTokenData = $_SESSION['api_token_created'] ?? null;
$apiTokenCreated = null;
if (is_array($apiTokenData) && ($apiTokenData['uuid'] ?? '') === ($values['uuid'] ?? '')) {
$apiTokenCreated = $apiTokenData['token'];
unset($_SESSION['api_token_created']);
}
?>
<details name="security-api-tokens" data-details-key="security-api-tokens"<?php if ($apiTokenCreated): ?> open<?php endif; ?>>
<summary><?php e(t('API tokens')); ?></summary>
<hr>
<?php if ($apiTokenCreated): ?>
<div class="app-flash" data-variant="info" style="margin-bottom: 1rem;">
<p><strong><?php e(t('New API token (copy now — shown only once):')); ?></strong></p>
<span class="badge" data-variant="neutral" data-copy="true"
data-copy-value="<?php e($apiTokenCreated); ?>"
style="font-family: var(--pico-font-family-monospace); font-size: 0.85rem; word-break: break-all;">
<?php e($apiTokenCreated); ?>
</span>
</div>
<?php endif; ?>
<?php if ($canManageApiTokens): ?>
<div>
<label>
<span><?php e(t('Token name')); ?></span>
</label>
<div role="group" class="white-space-nowrap">
<input type="text" name="token_name" id="api-token-name"
form="api-token-create-form" required
placeholder="<?php e(t('e.g. CI Pipeline')); ?>">
<button type="submit"
form="api-token-create-form"
><?php e(t('Create API token')); ?></button>
</div>
</div>
<?php endif; ?>
<?php if (!empty($apiTokens)): ?>
<div class="overflow-auto white-space-nowrap">
<table>
<thead>
<tr>
<th><?php e(t('Name')); ?></th>
<th><?php e(t('Prefix')); ?></th>
<th><?php e(t('Created at')); ?></th>
<th><?php e(t('Last used')); ?></th>
<th><?php e(t('Status')); ?></th>
<?php if ($canManageApiTokens): ?>
<th></th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($apiTokens as $apiToken): ?>
<?php
$isRevoked = !empty($apiToken['revoked_at']);
$isExpired = false;
$expiresAtApi = (string) ($apiToken['expires_at'] ?? '');
if (!$isRevoked && $expiresAtApi !== '') {
try {
$isExpired = (new \DateTimeImmutable($expiresAtApi)) <= new \DateTimeImmutable('now');
} catch (\Exception $e) {}
}
$statusLabel = t('Active');
$statusVariant = 'success';
if ($isRevoked) {
$statusLabel = t('Revoked');
$statusVariant = 'danger';
} elseif ($isExpired) {
$statusLabel = t('Expired');
$statusVariant = 'warning';
}
?>
<tr>
<td><?php e($apiToken['name'] ?? ''); ?></td>
<td><code><?php e(substr($apiToken['selector'] ?? '', 0, 8) . '...'); ?></code></td>
<td><?php e(dt($apiToken['created'] ?? '')); ?></td>
<td><?php e(!empty($apiToken['last_used_at']) ? dt($apiToken['last_used_at']) : '-'); ?></td>
<td>
<span class="badge" data-variant="<?php e($statusVariant); ?>">
<?php e($statusLabel); ?>
</span>
</td>
<?php if ($canManageApiTokens): ?>
<td>
<?php if (!$isRevoked): ?>
<button type="submit"
name="token_id"
value="<?php e((string) ($apiToken['id'] ?? '')); ?>"
class="outline"
style="--pico-color: var(--app-danger);"
formaction="<?php e(lurl("admin/users/api-token-revoke/{$values['uuid']}")); ?>"
onclick="return confirm('<?php e(t('Revoke this API token?')); ?>');"
><?php e(t('Revoke')); ?></button>
<?php endif; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if (empty($apiTokens) && !$canManageApiTokens): ?>
<p class="secondary"><?php e(t('No API tokens')); ?></p>
<?php endif; ?>
</details>
<?php endif; ?>
</div>
<?php if ($showOrganizationTab): ?>
<div data-tab-panel="organization">
<?php if ($showOrganization): ?>
<?php if ($showTenants): ?>
<details name="assignments-tenants" data-details-key="assignments-tenants" open>
<summary><?php e(t('Tenants')); ?></summary>
<hr>
<?php multiSelectForm('user-tenants', 'tenant_ids', 'Assigned tenants', 'Select tenants', $tenants, $selectedTenantIds, $isReadOnly); ?>
<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>
</details>
<hr>
<?php endif; ?>
<?php if ($showDepartments): ?>
<details name="assignments-departments" data-details-key="assignments-departments">
<summary><?php e(t('Departments')); ?></summary>
<hr>
<?php
$selectedTenantMap = [];
foreach ($tenants as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0 || !in_array($tenantId, $selectedTenantIds, true)) {
continue;
}
$selectedTenantMap[$tenantId] = $tenant;
}
?>
<?php if (!$selectedTenantMap): ?>
<p class="muted"><?php e(t('Assign tenants first to select departments')); ?></p>
<?php else: ?>
<?php foreach ($selectedTenantMap as $tenantId => $tenant): ?>
<?php $tenantDepartments = $departmentOptionsByTenant[$tenantId] ?? []; ?>
<fieldset>
<legend>
<small><?php e(t('Departments for tenant')); ?>: <?php e($tenant['description'] ?? ''); ?></small>
</legend>
<?php if ($tenantDepartments): ?>
<?php
multiSelectForm(
'user-departments-tenant-' . $tenantId,
'department_ids',
'Assigned departments',
'Select departments',
$tenantDepartments,
$selectedDepartmentIds,
$isReadOnly
);
?>
<?php else: ?>
<p class="muted"><?php e(t('No departments available for this tenant')); ?></p>
<?php endif; ?>
</fieldset>
<?php endforeach; ?>
<?php endif; ?>
</details>
<?php if ($showRoles): ?><hr><?php endif; ?>
<?php endif; ?>
<?php if ($showRoles): ?>
<details name="assignments-roles" data-details-key="assignments-roles">
<summary><?php e(t('Roles')); ?></summary>
<hr>
<?php multiSelectForm('user-roles', 'role_ids', 'Assigned roles', 'Select roles', $roles, $selectedRoleIds, $isReadOnly); ?>
</details>
<?php endif; ?>
<?php endif; ?>
<?php if ($showPermissions): ?>
<?php if ($showOrganization): ?><hr><?php endif; ?>
<details name="assignments-permissions" data-details-key="assignments-permissions">
<summary><?php e(t('Permissions')); ?></summary>
<hr>
<div class="overflow-auto white-space-nowrap">
<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>
</details>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($showCustomFieldsTab): ?>
<div data-tab-panel="custom_fields">
<?php if (!$customFieldTenantMap): ?>
<p class="muted"><?php e(t('Assign tenants first to edit custom fields')); ?></p>
<?php else: ?>
<?php $hasRenderedCustomFields = false; ?>
<?php foreach ($customFieldTenantMap as $tenantId => $tenant): ?>
<?php $tenantDefinitions = $customFieldDefinitionsByTenant[$tenantId] ?? []; ?>
<details name="custom-fields-tenant" data-details-key="custom-fields-tenant-<?php e((string) $tenantId); ?>">
<summary>
<?php e((string) ($tenant['description'] ?? t('Custom fields for tenant'))); ?>
</summary>
<hr>
<?php if (!$tenantDefinitions): ?>
<p class="muted"><?php e(t('No custom fields configured for this tenant')); ?></p>
<?php else: ?>
<div>
<?php foreach ($tenantDefinitions as $definition): ?>
<?php
$definitionId = (int) ($definition['id'] ?? 0);
$definitionType = strtolower((string) ($definition['type'] ?? 'text'));
$definitionLabel = (string) ($definition['label'] ?? '');
$definitionOptions = is_array($definition['options'] ?? null) ? $definition['options'] : [];
if ($definitionId <= 0 || $definitionLabel === '') {
continue;
}
$hasRenderedCustomFields = true;
$currentValue = $customFieldValueMap[$definitionId] ?? [];
$scalarPostedKey = (string) $definitionId;
$scalarPosted = array_key_exists($scalarPostedKey, $customFieldScalarPosted)
? $customFieldScalarPosted[$scalarPostedKey]
: null;
$multiPostedRaw = $customFieldMultiPosted[$scalarPostedKey] ?? null;
$multiPosted = is_array($multiPostedRaw)
? array_values(array_unique(array_map('intval', $multiPostedRaw)))
: [];
$multiPosted = array_values(array_filter($multiPosted, static fn (int $id): bool => $id > 0));
$currentOptionIds = is_array($currentValue['option_ids'] ?? null)
? array_values(array_unique(array_map('intval', $currentValue['option_ids'])))
: [];
$currentOptionIds = array_values(array_filter($currentOptionIds, static fn (int $id): bool => $id > 0));
$valueText = array_key_exists('value_text', $currentValue) ? (string) ($currentValue['value_text'] ?? '') : '';
$valueBool = array_key_exists('value_bool', $currentValue) && $currentValue['value_bool'] !== null
? (string) ((int) $currentValue['value_bool'])
: '';
$valueDate = array_key_exists('value_date', $currentValue) ? (string) ($currentValue['value_date'] ?? '') : '';
$valueOptionId = array_key_exists('option_id', $currentValue) && $currentValue['option_id'] !== null
? (int) $currentValue['option_id']
: 0;
?>
<div>
<?php if (in_array($definitionType, ['text', 'textarea'], true)): ?>
<?php $fieldValue = $scalarPosted !== null ? trim((string) $scalarPosted) : $valueText; ?>
<label for="custom-field-<?php e((string) $definitionId); ?>">
<span>
<?php e($definitionLabel); ?>
</span>
<?php if ($definitionType === 'textarea'): ?>
<textarea
id="custom-field-<?php e((string) $definitionId); ?>"
name="custom_field_values[<?php e((string) $definitionId); ?>]"
rows="4"
<?php e($customFieldInputsDisabledAttr); ?>
><?php e($fieldValue); ?></textarea>
<?php else: ?>
<input
id="custom-field-<?php e((string) $definitionId); ?>"
type="text"
name="custom_field_values[<?php e((string) $definitionId); ?>]"
value="<?php e($fieldValue); ?>"
<?php e($customFieldInputsDisabledAttr); ?>
>
<?php endif; ?>
</label>
<?php elseif ($definitionType === 'date'): ?>
<?php $fieldValue = $scalarPosted !== null ? trim((string) $scalarPosted) : $valueDate; ?>
<label for="custom-field-<?php e((string) $definitionId); ?>">
<span>
<?php e($definitionLabel); ?>
</span>
<input
id="custom-field-<?php e((string) $definitionId); ?>"
type="date"
name="custom_field_values[<?php e((string) $definitionId); ?>]"
value="<?php e($fieldValue); ?>"
<?php e($customFieldInputsDisabledAttr); ?>
>
</label>
<?php elseif ($definitionType === 'boolean'): ?>
<?php $fieldValue = $scalarPosted !== null ? strtolower(trim((string) $scalarPosted)) : $valueBool; ?>
<label for="custom-field-<?php e((string) $definitionId); ?>">
<span>
<?php e($definitionLabel); ?>
</span>
<select
id="custom-field-<?php e((string) $definitionId); ?>"
name="custom_field_values[<?php e((string) $definitionId); ?>]"
<?php e($customFieldInputsDisabledAttr); ?>
>
<option value=""><?php e(t('Please select')); ?></option>
<option value="1" <?php e($fieldValue === '1' || $fieldValue === 'true' ? 'selected' : ''); ?>>
<?php e(t('Yes')); ?>
</option>
<option value="0" <?php e($fieldValue === '0' || $fieldValue === 'false' ? 'selected' : ''); ?>>
<?php e(t('No')); ?>
</option>
</select>
</label>
<?php elseif ($definitionType === 'select'): ?>
<?php
$fieldValue = $scalarPosted !== null ? (int) $scalarPosted : $valueOptionId;
?>
<label for="custom-field-<?php e((string) $definitionId); ?>">
<span>
<?php e($definitionLabel); ?>
</span>
<select
id="custom-field-<?php e((string) $definitionId); ?>"
name="custom_field_values[<?php e((string) $definitionId); ?>]"
<?php e($customFieldInputsDisabledAttr); ?>
>
<option value=""><?php e(t('Please select')); ?></option>
<?php foreach ($definitionOptions as $option): ?>
<?php
$optionId = (int) ($option['id'] ?? 0);
if ($optionId <= 0) {
continue;
}
?>
<option value="<?php e((string) $optionId); ?>" <?php e($fieldValue === $optionId ? 'selected' : ''); ?>>
<?php e((string) ($option['label'] ?? '')); ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php elseif ($definitionType === 'multiselect'): ?>
<?php
$selectedOptions = $multiPostedRaw !== null ? $multiPosted : $currentOptionIds;
multiSelectForm(
'user-custom-field-' . $definitionId,
'custom_field_values_multi[' . $definitionId . ']',
$definitionLabel,
'Select options',
$definitionOptions,
$selectedOptions,
$customFieldInputsDisabled,
['label', 'description']
);
?>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</details>
<hr>
<?php endforeach; ?>
<?php if (!$hasRenderedCustomFields): ?>
<p class="muted"><?php e(t('No custom fields configured for selected tenants')); ?></p>
<?php endif; ?>
<?php endif; ?>
</div>
<?php endif; ?>
<div data-tab-panel="visibility">
<?php
$statusFieldName = 'active';
$statusFieldId = 'user-active';
$statusFieldValue = (string) (($values['active'] ?? 1) ? '1' : '0');
$statusFieldOptions = [
'1' => t('Active'),
'0' => t('Inactive'),
];
$statusFieldHint = $visibilityHint;
$statusFieldDisabledAttr = $visibilityDisabledAttr;
require templatePath('partials/app-visibility-status-field.phtml');
?>
</div>
<?php if ($showDangerZone || $showTokenDangerAction): ?>
<div data-tab-panel="danger">
<?php if ($showTokenDangerAction && $tokenDangerFormId !== ''): ?>
<fieldset>
<legend><small><?php e(t('Login tokens')); ?></small></legend>
<p class="muted"><?php e($tokenDangerWarning); ?></p>
<button type="submit" class="danger" form="<?php e($tokenDangerFormId); ?>">
<?php e($tokenDangerActionLabel); ?>
</button>
</fieldset>
<?php endif; ?>
<?php if ($showDangerZone && $dangerZoneDeleteFormId !== ''): ?>
<?php
$dangerFormId = $dangerZoneDeleteFormId;
$dangerLegend = t('Delete');
$dangerWarning = $dangerZoneWarning;
$dangerButtonLabel = $dangerZoneActionLabel;
require templatePath('partials/app-danger-zone-delete-field.phtml');
?>
<?php endif; ?>
</div>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,58 @@
<?php
use MintyPHP\Support\Guard;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\User\UserAccessPdfService;
use MintyPHP\Service\User\UserService;
use MintyPHP\Router;
define('MINTY_ALLOW_OUTPUT', true);
// Hard gate: user must be logged in and explicitly allowed to generate access PDFs.
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::USERS_ACCESS_PDF);
// Support both route param and POSTed uuid (POST avoids exposing uuid in URL/history).
$uuid = trim((string) ($id ?? ''));
if ($uuid === '' && strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
$uuid = trim((string) ($_POST['uuid'] ?? ''));
}
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
if (!$user) {
http_response_code(404);
return;
}
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$userId = (int) ($user['id'] ?? 0);
// Enforce tenant scope on the target user before rendering any sensitive document.
if ($userId <= 0 || !TenantScopeService::canAccess('users', $userId, $currentUserId)) {
Router::redirect('error/forbidden');
return;
}
$pdf = UserAccessPdfService::renderUserAccessPdf($user);
if ($pdf === '') {
// Keep error generic; service handles concrete renderer/asset failures internally.
http_response_code(500);
return;
}
$locale = strtolower((string) (\MintyPHP\I18n::$locale ?? \MintyPHP\I18n::$defaultLocale));
$filename = str_starts_with($locale, 'de') ? 'zugangsdaten.pdf' : 'access-details.pdf';
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $filename . '"');
header('Cache-Control: private, no-store, max-age=0');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
header('Content-Length: ' . strlen($pdf));
$output = fopen('php://output', 'wb');
if ($output === false) {
http_response_code(500);
return;
}
fwrite($output, $pdf);
fclose($output);

View File

@@ -0,0 +1,100 @@
<?php
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\User\UserAccessPdfService;
use MintyPHP\Service\User\UserService;
use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
define('MINTY_ALLOW_OUTPUT', true);
// Hard gate: user must be logged in and explicitly allowed to generate access PDFs.
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::USERS_ACCESS_PDF);
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) !== 'POST') {
Router::redirect('admin/users');
return;
}
$raw = $_POST['uuids'] ?? '';
$uuids = [];
if (is_array($raw)) {
$uuids = $raw;
} elseif (is_string($raw)) {
$uuids = explode(',', $raw);
}
$uuids = array_values(array_unique(array_filter(array_map('trim', $uuids))));
if (!$uuids) {
Flash::error(t('No valid users selected'), 'admin/users', 'users_access_pdf_no_selection');
Router::redirect('admin/users');
return;
}
if (count($uuids) > 100) {
// Server-side hard limit to keep synchronous ZIP generation predictable.
Flash::error(t('Too many users selected'), 'admin/users', 'users_access_pdf_too_many');
Router::redirect('admin/users');
return;
}
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$users = [];
foreach ($uuids as $uuid) {
$user = UserService::findByUuid($uuid);
if (!$user) {
continue;
}
$userId = (int) ($user['id'] ?? 0);
if ($userId <= 0) {
continue;
}
// Scope check per user prevents cross-tenant exports in mixed selections.
if (!TenantScopeService::canAccess('users', $userId, $currentUserId)) {
continue;
}
$users[] = $user;
}
if (!$users) {
Flash::error(t('No valid users selected'), 'admin/users', 'users_access_pdf_not_allowed');
Router::redirect('admin/users');
return;
}
if (!class_exists(ZipArchive::class)) {
// Explicit runtime dependency check for environments missing ext-zip.
Flash::error(t('ZIP support missing (ext-zip)'), 'admin/users', 'users_access_pdf_zip_missing');
Router::redirect('admin/users');
return;
}
$zip = UserAccessPdfService::renderUsersAccessZip($users);
$zipPath = (string) ($zip['path'] ?? '');
$zipFilename = trim((string) ($zip['filename'] ?? ''));
$zipCount = (int) ($zip['count'] ?? 0);
if ($zipPath === '' || !is_file($zipPath) || $zipCount <= 0) {
Flash::error(t('Failed to generate access PDF'), 'admin/users', 'users_access_pdf_failed');
Router::redirect('admin/users');
return;
}
if ($zipFilename === '') {
$zipFilename = 'user-invitations-' . gmdate('Ymd-His') . '.zip';
}
try {
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $zipFilename . '"');
header('Cache-Control: private, no-store, max-age=0');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
header('Content-Length: ' . filesize($zipPath));
readfile($zipPath);
} finally {
// Always delete temporary archive, including partial-failure paths.
@unlink($zipPath);
}

View File

@@ -0,0 +1,38 @@
<?php
use MintyPHP\Support\Guard;
use MintyPHP\Support\Flash;
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Auth\ApiTokenService;
use MintyPHP\Service\User\UserService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::API_TOKENS_MANAGE);
$uuid = trim((string) ($id ?? ''));
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
if (!$user) {
Router::redirect('admin/users');
}
$userId = (int) ($user['id'] ?? 0);
$currentUserId = (int) ($_SESSION['user']['id'] ?? 0);
$name = trim((string) ($_POST['token_name'] ?? ''));
if ($name === '') {
Flash::error(t('Token name is required'), "admin/users/edit/{$uuid}", 'api_token_error');
Router::redirect("admin/users/edit/{$uuid}");
}
$result = ApiTokenService::create($userId, $name, null, null, $currentUserId);
if ($result['ok'] ?? false) {
$_SESSION['api_token_created'] = ['token' => $result['token'], 'uuid' => $uuid];
Flash::success(t('API token created. Copy it now — it will not be shown again.'), "admin/users/edit/{$uuid}", 'api_token_created');
} else {
$error = $result['error'] ?? 'create_failed';
Flash::error(t('Could not create API token: %s', $error), "admin/users/edit/{$uuid}", 'api_token_error');
}
Router::redirect("admin/users/edit/{$uuid}");

View File

@@ -0,0 +1,26 @@
<?php
use MintyPHP\Support\Guard;
use MintyPHP\Support\Flash;
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Auth\ApiTokenService;
use MintyPHP\Service\User\UserService;
Guard::requireLogin();
Guard::requirePermissionOrForbidden(PermissionService::API_TOKENS_MANAGE);
$uuid = trim((string) ($id ?? ''));
$user = $uuid !== '' ? UserService::findByUuid($uuid) : null;
if (!$user) {
Router::redirect('admin/users');
}
$tokenId = (int) ($_POST['token_id'] ?? 0);
if ($tokenId > 0) {
ApiTokenService::revoke($tokenId);
Flash::success(t('API token revoked'), "admin/users/edit/{$uuid}", 'api_token_revoked');
}
Router::redirect("admin/users/edit/{$uuid}");

View File

@@ -10,14 +10,14 @@ use MintyPHP\Service\Tenant\TenantScopeService;
Guard::requireLogin();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/users');
Router::redirect('admin');
return;
}
$uuid = trim((string) ($id ?? ''));
if (!UserAvatarService::isValidUuid($uuid)) {
Flash::error('User not found', 'admin/users', 'user_not_found');
Router::redirect('admin/users');
Flash::error('User not found', 'admin', 'user_not_found');
Router::redirect('admin');
return;
}

View File

@@ -10,14 +10,14 @@ use MintyPHP\Service\Tenant\TenantScopeService;
Guard::requireLogin();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
Router::redirect('admin/users');
Router::redirect('admin');
return;
}
$uuid = trim((string) ($id ?? ''));
if (!UserAvatarService::isValidUuid($uuid)) {
Flash::error('User not found', 'admin/users', 'user_not_found');
Router::redirect('admin/users');
Flash::error('User not found', 'admin', 'user_not_found');
Router::redirect('admin');
return;
}

View File

@@ -1,12 +1,11 @@
<?php
use MintyPHP\Support\Guard;
use MintyPHP\Http\Request;
use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\User\UserService;
use MintyPHP\Service\User\UserAccessTemplateService;
use MintyPHP\Service\Mail\MailService;
use MintyPHP\I18n;
Guard::requireLogin();
@@ -92,32 +91,10 @@ function handleSendAccess(array $uuids, int $currentUserId): void
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,
];
$context = UserAccessTemplateService::buildContext($user);
$locale = (string) ($context['locale'] ?? '');
$subject = (string) ($context['subject'] ?? t('Your access details'));
$vars = is_array($context['vars'] ?? null) ? $context['vars'] : [];
$result = MailService::sendTemplate('access_info', $vars, (string) $user['email'], $subject, $locale);
if ($result['ok'] ?? false) {

View File

@@ -11,6 +11,7 @@ use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\Tenant\TenantService;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
use MintyPHP\Service\Settings\SettingService;
Guard::requirePermissionOrForbidden(PermissionService::USERS_CREATE);
@@ -28,10 +29,18 @@ if ($allowedTenantIds) {
$tenants = [];
}
$roles = RoleService::listActive();
$departments = $allowedTenantIds ? DepartmentService::listByTenantIds($allowedTenantIds) : [];
if (!$allowedTenantIds && !TenantScopeService::isStrict()) {
$departments = DepartmentService::list();
}
$selectedTenantIds = [];
$selectedRoleIds = [];
$selectedDepartmentIds = [];
$departmentOptionsByTenant = [];
$canEditCustomFieldValues = PermissionService::userHas($currentUserId, PermissionService::CUSTOM_FIELDS_EDIT_VALUES)
&& PermissionService::userHas($currentUserId, PermissionService::USERS_UPDATE_ASSIGNMENTS);
$customFieldDefinitionsByTenant = [];
$customFieldValueMap = [];
$customFieldPostedValues = [
'custom_field_values' => [],
'custom_field_values_multi' => [],
];
$errors = [];
$form = [
@@ -66,18 +75,75 @@ if (isset($_POST['email'])) {
if (TenantScopeService::isStrict() && !$tenantIds && !$defaultTenantId) {
$errors[] = t('Please select at least one tenant');
}
$selectedTenantIds = $tenantIds;
$selectedRoleIds = UserService::normalizeIdInput($input['role_ids'] ?? []);
$selectedDepartmentIds = UserService::normalizeIdInput($input['department_ids'] ?? []);
$departmentOptionsByTenant = DepartmentService::groupActiveByTenantIds($selectedTenantIds);
$customFieldDefinitionsByTenant = UserCustomFieldValueService::buildDefinitionsByTenant($selectedTenantIds);
$customFieldPostedValues = [
'custom_field_values' => is_array($_POST['custom_field_values'] ?? null) ? $_POST['custom_field_values'] : [],
'custom_field_values_multi' => is_array($_POST['custom_field_values_multi'] ?? null) ? $_POST['custom_field_values_multi'] : [],
];
if ($canEditCustomFieldValues) {
$customFieldValidationResult = UserCustomFieldValueService::validateForTenants(
$selectedTenantIds,
$_POST,
true
);
if (!($customFieldValidationResult['ok'] ?? false)) {
$errors = array_merge($errors, $customFieldValidationResult['errors'] ?? []);
foreach (array_keys($form) as $fieldKey) {
if (!array_key_exists($fieldKey, $input)) {
continue;
}
$fieldValue = $input[$fieldKey];
if (is_scalar($fieldValue)) {
$form[$fieldKey] = trim((string) $fieldValue);
}
}
$form['active'] = array_key_exists('active', $input) ? 1 : 0;
}
}
$input['tenant_ids'] = $tenantIds;
$result = UserService::createFromAdmin($input, $currentUserId);
$form = $result['form'] ?? $form;
$errors = array_merge($errors, $result['errors'] ?? []);
$input['role_ids'] = $selectedRoleIds;
$input['department_ids'] = $selectedDepartmentIds;
$result = ['ok' => false];
if (!$errors) {
$result = UserService::createFromAdmin($input, $currentUserId);
$form = $result['form'] ?? $form;
$errors = array_merge($errors, $result['errors'] ?? []);
}
if (($result['ok'] ?? false) && !$errors) {
$createdUuid = (string) ($result['uuid'] ?? '');
if ($canEditCustomFieldValues && $createdUuid !== '') {
$createdUser = UserService::findByUuid($createdUuid);
$createdUserId = (int) ($createdUser['id'] ?? 0);
if ($createdUserId > 0) {
$customFieldSyncResult = UserCustomFieldValueService::syncForUser(
$createdUserId,
$selectedTenantIds,
$_POST,
true
);
if (!($customFieldSyncResult['ok'] ?? false)) {
$syncErrors = $customFieldSyncResult['errors'] ?? [];
if ($syncErrors) {
Flash::error(
implode(' | ', $syncErrors),
"admin/users/edit/{$createdUuid}",
'user_custom_field_sync_failed'
);
}
}
}
}
$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'] ?? '');
$uuid = $createdUuid;
if ($uuid !== '') {
$target = "admin/users/edit/{$uuid}";
Flash::success('User created', $target, 'user_created');
@@ -90,4 +156,11 @@ if (isset($_POST['email'])) {
}
}
if (!$departmentOptionsByTenant && $selectedTenantIds) {
$departmentOptionsByTenant = DepartmentService::groupActiveByTenantIds($selectedTenantIds);
}
if (!$customFieldDefinitionsByTenant && $selectedTenantIds) {
$customFieldDefinitionsByTenant = UserCustomFieldValueService::buildDefinitionsByTenant($selectedTenantIds);
}
Buffer::set('title', t('Create user'));

View File

@@ -18,21 +18,29 @@
['label' => t('Create user')],
];
require templatePath('partials/app-breadcrumb.phtml');
$titlebar = [
'title' => t('Create user'),
'backHref' => 'admin/users',
'backTitle' => t('Cancel'),
'actions' => [
[
'form' => 'user-form',
'name' => 'action',
'value' => 'create',
'class' => 'secondary outline',
'label' => t('Create'),
],
[
'form' => 'user-form',
'name' => 'action',
'value' => 'create_close',
'class' => 'primary',
'label' => t('Create & close'),
],
],
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<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>
@@ -49,6 +57,20 @@
$passwordLabel = t('Password');
$passwordLegend = t('Password');
$passwordConfirmLabel = t('Password (again)');
$showTenants = can('users.update_assignments');
$showRoles = $showTenants;
$showDepartments = $showTenants;
$tenants = $tenants ?? [];
$roles = $roles ?? [];
$selectedTenantIds = $selectedTenantIds ?? [];
$selectedRoleIds = $selectedRoleIds ?? [];
$selectedDepartmentIds = $selectedDepartmentIds ?? [];
$departmentOptionsByTenant = $departmentOptionsByTenant ?? [];
$showCustomFieldsTab = true;
$canEditCustomFieldValues = !empty($canEditCustomFieldValues);
$customFieldDefinitionsByTenant = $customFieldDefinitionsByTenant ?? [];
$customFieldValueMap = $customFieldValueMap ?? [];
$customFieldPostedValues = $customFieldPostedValues ?? [];
require __DIR__ . '/_form.phtml';
?>
</section>

View File

@@ -5,6 +5,7 @@ use MintyPHP\Support\Flash;
use MintyPHP\Support\Guard;
use MintyPHP\I18n;
use MintyPHP\Router;
use MintyPHP\DB;
use MintyPHP\Repository\Access\UserRoleRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Repository\Org\UserDepartmentRepository;
@@ -12,6 +13,7 @@ use MintyPHP\Repository\Access\RolePermissionRepository;
use MintyPHP\Repository\Auth\PasswordResetRepository;
use MintyPHP\Repository\Auth\RememberTokenRepository;
use MintyPHP\Service\Auth\AuthService;
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Service\Tenant\TenantService;
@@ -103,11 +105,26 @@ if (!$primaryTenantId && count($selectedTenantIds) === 1) {
$roles = RoleService::listActive();
$selectedRoleIds = UserRoleRepository::listRoleIdsByUserId($userId);
$permissionRows = RolePermissionRepository::listPermissionsWithRolesByRoleIds($selectedRoleIds);
$departments = DepartmentService::listForUserAssignments($selectedTenantIds, []);
$selectedDepartmentIds = UserDepartmentRepository::listDepartmentIdsByUserId($userId);
if ($selectedDepartmentIds) {
$departments = DepartmentService::listForUserAssignments($selectedTenantIds, $selectedDepartmentIds);
$departmentOptionsByTenant = DepartmentService::groupActiveByTenantIds($selectedTenantIds);
$canEditCustomFieldValues = $canEditUser
&& (PermissionService::userHas($currentUserId, PermissionService::CUSTOM_FIELDS_EDIT_VALUES)
|| ($isOwnAccount && $canUpdateSelf));
$customFieldDefinitionsByTenant = UserCustomFieldValueService::buildDefinitionsByTenant($selectedTenantIds);
$customFieldDefinitionIds = [];
foreach ($customFieldDefinitionsByTenant as $tenantDefinitions) {
foreach ($tenantDefinitions as $definition) {
$definitionId = (int) ($definition['id'] ?? 0);
if ($definitionId > 0) {
$customFieldDefinitionIds[] = $definitionId;
}
}
}
$customFieldValueMap = UserCustomFieldValueService::buildUserValueMap($userId, $customFieldDefinitionIds);
$customFieldPostedValues = [
'custom_field_values' => [],
'custom_field_values_multi' => [],
];
$passwordResets = [];
if ($canViewUsers || $canEditUser) {
$passwordResets = PasswordResetRepository::listByUserId($userId, 25);
@@ -116,6 +133,13 @@ $rememberTokens = [];
if ($canViewUsers || $canEditUser) {
$rememberTokens = RememberTokenRepository::listByUserId($userId, 25);
}
$apiTokens = [];
$canManageApiTokens = $canEditUser
&& PermissionService::userHas($currentUserId, PermissionService::API_TOKENS_MANAGE);
if ($canViewUsers || $canEditUser) {
$apiTokens = \MintyPHP\Service\Auth\ApiTokenService::listForUser($userId);
}
$showApiTokens = !empty($apiTokens) || $canManageApiTokens;
// Keep initial $isOwnAccount/$canEditUser values for view permissions.
if (isset($_POST['email'])) {
@@ -127,28 +151,77 @@ if (isset($_POST['email'])) {
$form = $result['form'] ?? $form;
$errors = $result['errors'] ?? [];
$tenantIds = UserService::normalizeIdInput($_POST['tenant_ids'] ?? []);
if ($allowedTenantIds) {
$existingTenantIds = UserTenantRepository::listTenantIdsByUserId($userId);
$tenantIdsForSync = $tenantIds;
if ($canEditAssignments && $allowedTenantIds) {
$tenantIds = array_values(array_intersect($tenantIds, $allowedTenantIds));
} elseif (!$canManageTenants && TenantScopeService::isStrict()) {
$tenantIdsForSync = TenantScopeService::mergeTenantIdsPreservingOutOfScope(
$tenantIds,
$existingTenantIds,
$allowedTenantIds
);
} elseif ($canEditAssignments && !$canManageTenants && TenantScopeService::isStrict()) {
$tenantIds = [];
$tenantIdsForSync = $existingTenantIds;
}
if (!$canEditAssignments) {
$tenantIds = UserTenantRepository::listTenantIdsByUserId($userId);
if ($allowedTenantIds) {
$tenantIds = array_values(array_intersect($tenantIds, $allowedTenantIds));
}
$tenantIdsForSync = $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);
$departmentOptionsByTenant = DepartmentService::groupActiveByTenantIds($selectedTenantIds);
$customFieldDefinitionsByTenant = UserCustomFieldValueService::buildDefinitionsByTenant($selectedTenantIds);
$customFieldDefinitionIds = [];
foreach ($customFieldDefinitionsByTenant as $tenantDefinitions) {
foreach ($tenantDefinitions as $definition) {
$definitionId = (int) ($definition['id'] ?? 0);
if ($definitionId > 0) {
$customFieldDefinitionIds[] = $definitionId;
}
}
}
$customFieldValueMap = UserCustomFieldValueService::buildUserValueMap($userId, $customFieldDefinitionIds);
$customFieldPostedValues = [
'custom_field_values' => is_array($_POST['custom_field_values'] ?? null) ? $_POST['custom_field_values'] : [],
'custom_field_values_multi' => is_array($_POST['custom_field_values_multi'] ?? null) ? $_POST['custom_field_values_multi'] : [],
];
if ($result['ok'] ?? false) {
if ($canEditAssignments) {
UserService::syncTenants($userId, $tenantIds);
UserService::syncRoles($userId, $selectedRoleIds);
UserService::syncDepartments($userId, $selectedDepartmentIds);
$db = DB::handle();
$db->begin_transaction();
try {
UserService::syncTenants($userId, $tenantIdsForSync, false);
UserService::syncRoles($userId, $selectedRoleIds, false);
UserService::syncDepartments($userId, $selectedDepartmentIds, false);
UserService::bumpAuthzVersion($userId);
$db->commit();
} catch (\Throwable $exception) {
$db->rollback();
$errors[] = t('User assignments can not be updated');
}
// Update session if editing own account (tenant assignments changed)
if ($currentUserId === $userId) {
if ($currentUserId === $userId && !$errors) {
AuthService::loadTenantDataIntoSession($userId);
}
}
$customFieldSyncResult = UserCustomFieldValueService::syncForUser(
$userId,
$selectedTenantIds,
$_POST,
$canEditCustomFieldValues
);
if (!($customFieldSyncResult['ok'] ?? false)) {
$errors = array_merge($errors, $customFieldSyncResult['errors'] ?? []);
$customFieldValueMap = UserCustomFieldValueService::buildUserValueMap($userId, $customFieldDefinitionIds);
}
if ($currentUserId === $userId && isset($form['theme'])) {
$themes = appThemes();
$themeValue = strtolower(trim((string) ($form['theme'] ?? '')));
@@ -158,13 +231,15 @@ if (isset($_POST['email'])) {
$_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}");
if (!$errors) {
$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}");
}
}
}
}

View File

@@ -20,8 +20,26 @@ $canViewUsers = can('users.view');
$canViewAddressBook = can('address_book.view');
$canViewUserMeta = can('users.view_meta');
$canViewUserAudit = can('users.view_audit');
$hasDetailsActions = $canViewAddressBook || !empty($canEditUser) || can('users.delete');
$canAccessPdf = can('users.access_pdf');
$canEditUser = !empty($canEditUser);
$canDeleteUser = can('users.delete') && !$isOwnAccount;
$hideNavigation = $isOwnAccount && !$canViewUsers;
$lastLoginProvider = strtolower((string) ($values['last_login_provider'] ?? ($user['last_login_provider'] ?? '')));
$lastLoginAt = (string) ($values['last_login_at'] ?? ($user['last_login_at'] ?? ''));
$lastLoginProviderLabel = '-';
$lastLoginProviderVariant = 'neutral';
if ($lastLoginAt !== '') {
if ($lastLoginProvider === 'microsoft') {
$lastLoginProviderLabel = t('Microsoft');
$lastLoginProviderVariant = 'primary';
} elseif ($lastLoginProvider === 'local') {
$lastLoginProviderLabel = t('Local');
$lastLoginProviderVariant = 'secondary';
} else {
$lastLoginProviderLabel = t('Unknown');
$lastLoginProviderVariant = 'warning';
}
}
?>
@@ -37,72 +55,63 @@ $hideNavigation = $isOwnAccount && !$canViewUsers;
require templatePath('partials/app-breadcrumb.phtml');
?>
<?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 ($hasDetailsActions): ?>
<details class="dropdown">
<summary role="button" class="outline secondary"><i class="bi bi-three-dots"></i></summary>
<ul dir="rtl">
<?php if ($canViewAddressBook): ?>
<li>
<a role="button" class="transparent" href="<?php e(lurl('address-book/view/' . ($values['uuid'] ?? ''))); ?>">
<?php e(t('View in address book')); ?>
</a>
</li>
<?php endif; ?>
<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
$asideActions = [];
if ($canViewAddressBook) {
$asideActions[] = [
'type' => 'link',
'href' => lurl('address-book/view/' . ($values['uuid'] ?? '')),
'label' => t('View in address book'),
];
}
if ($canEditUser) {
$asideActions[] = [
'type' => 'form',
'method' => 'post',
'action' => 'admin/users/send-access/' . ($values['uuid'] ?? ''),
'confirm' => t('Send access email to this user?'),
'label' => t('Send access'),
];
}
if ($canAccessPdf) {
$asideActions[] = [
'type' => 'form',
'method' => 'post',
'action' => 'admin/users/access-pdf',
'label' => t('Access PDF'),
'target' => '_blank',
'fields' => [
'uuid' => (string) ($values['uuid'] ?? ''),
],
];
}
$titlebarActions = [];
if ($canEditUser) {
$titlebarActions[] = [
'form' => 'user-form',
'name' => 'action',
'value' => 'save',
'class' => 'secondary outline',
'label' => t('Save'),
];
if (!$hideNavigation) {
$titlebarActions[] = [
'form' => 'user-form',
'name' => 'action',
'value' => 'save_close',
'class' => 'primary',
'label' => t('Save & close'),
];
}
}
$titlebar = [
'title' => $titleText,
'backHref' => $hideNavigation ? '' : 'admin/users',
'backTitle' => t('Cancel'),
'actions' => $titlebarActions,
];
require templatePath('partials/app-details-titlebar.phtml');
?>
<?php if (!empty($errors)): ?>
<div class="app-details-errors">
@@ -128,9 +137,9 @@ $hideNavigation = $isOwnAccount && !$canViewUsers;
$roles = $roles ?? [];
$selectedRoleIds = $selectedRoleIds ?? [];
$showDepartments = !empty($canEditAssignments);
$departments = $departments ?? [];
$departmentOptionsByTenant = $departmentOptionsByTenant ?? [];
$selectedDepartmentIds = $selectedDepartmentIds ?? [];
$isReadOnly = !(!empty($canEditUser));
$isReadOnly = !$canEditUser;
$canEditAssignments = $canEditAssignments ?? false;
$permissionRows = $permissionRows ?? [];
$showPermissions = !empty($permissionRows) && can('permissions.view');
@@ -138,8 +147,49 @@ $hideNavigation = $isOwnAccount && !$canViewUsers;
$showPasswordResets = !empty($passwordResets);
$rememberTokens = $rememberTokens ?? [];
$showRememberTokens = !empty($rememberTokens);
$showDangerZone = $canDeleteUser;
$dangerZoneDeleteFormId = $showDangerZone ? 'user-delete-form' : '';
$dangerZoneWarning = t('This will permanently delete this user.');
$dangerZoneActionLabel = t('Delete user');
$showTokenDangerAction = $canEditUser;
$tokenDangerFormId = $showTokenDangerAction ? 'user-forget-tokens-form' : '';
$tokenDangerWarning = t('This will mark all remember-me tokens as expired. Users will need to sign in again.');
$tokenDangerActionLabel = t('Clear login tokens');
$showCustomFieldsTab = true;
$canEditCustomFieldValues = !empty($canEditCustomFieldValues);
$customFieldDefinitionsByTenant = $customFieldDefinitionsByTenant ?? [];
$customFieldValueMap = $customFieldValueMap ?? [];
$customFieldPostedValues = $customFieldPostedValues ?? [];
require __DIR__ . '/_form.phtml';
?>
<?php if ($canManageApiTokens ?? false): ?>
<form id="api-token-create-form" method="post"
action="admin/users/api-token-create/<?php e($values['uuid'] ?? ''); ?>" hidden>
<?php Session::getCsrfInput(); ?>
</form>
<?php endif; ?>
<?php if ($showTokenDangerAction): ?>
<form
id="user-forget-tokens-form"
method="post"
action="admin/users/forget-tokens/<?php e($values['uuid'] ?? ''); ?>"
hidden
onsubmit="return confirm('<?php e(t('Clear all login tokens for this user?')); ?>');"
>
<?php Session::getCsrfInput(); ?>
</form>
<?php endif; ?>
<?php if ($showDangerZone): ?>
<form
id="user-delete-form"
method="post"
action="admin/users/delete/<?php e($values['uuid'] ?? ''); ?>"
hidden
onsubmit="return confirm('<?php e(t('Delete this user?')); ?>');"
>
<?php Session::getCsrfInput(); ?>
</form>
<?php endif; ?>
</section>
<aside id="app-details-aside-section">
<div class="app-details-aside-section">
@@ -161,7 +211,7 @@ $hideNavigation = $isOwnAccount && !$canViewUsers;
<p><?php e($values['email'] ?? ''); ?></p>
</hgroup>
<hr>
<?php if (!empty($canEditUser)): ?>
<?php if ($canEditUser): ?>
<details name="user-avatar">
<summary>
<?php e(t('Upload image')); ?>
@@ -186,10 +236,15 @@ $hideNavigation = $isOwnAccount && !$canViewUsers;
<?php Session::getCsrfInput(); ?>
</form>
</details>
<hr>
<?php endif; ?>
<hr>
<?php
if ($asideActions) {
require templatePath('partials/app-details-aside-actions.phtml');
}
?>
<?php if ($canViewUserMeta): ?>
<details name="user-meta" open>
<details name="user-meta">
<summary><?php e(t('Status & meta')); ?></summary>
<hr>
<div class="grid">
@@ -223,6 +278,14 @@ $hideNavigation = $isOwnAccount && !$canViewUsers;
<?php endif; ?>
</p>
</div>
<div>
<small><?php e(t('Last login via')); ?></small>
<p>
<span class="badge" data-variant="<?php e($lastLoginProviderVariant); ?>">
<?php e($lastLoginProviderLabel); ?>
</span>
</p>
</div>
<div>
<small>
<?php e(t('UUID')); ?>
@@ -242,93 +305,22 @@ $hideNavigation = $isOwnAccount && !$canViewUsers;
<hr>
<?php endif; ?>
<?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;
$asideAuditDetailsName = 'user-audit';
$asideAuditCreated = (string) ($values['created'] ?? '');
$asideAuditCreatedByLabel = (string) ($values['created_by_label'] ?? '');
$asideAuditCreatedById = $values['created_by'] ?? null;
$asideAuditCreatedByUuid = (string) ($values['created_by_uuid'] ?? '');
$asideAuditModified = (string) ($values['modified'] ?? '');
$asideAuditModifiedByLabel = (string) ($values['modified_by_label'] ?? '');
$asideAuditModifiedById = $values['modified_by'] ?? null;
$asideAuditModifiedByUuid = (string) ($values['modified_by_uuid'] ?? '');
$asideAuditStatusChanged = (string) ($values['active_changed_at'] ?? '');
$asideAuditStatusChangedByLabel = (string) ($values['active_changed_by_label'] ?? '');
$asideAuditStatusChangedById = $values['active_changed_by'] ?? null;
$asideAuditStatusChangedByUuid = (string) ($values['active_changed_by_uuid'] ?? '');
?>
<?php if ($canViewUserAudit): ?>
<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>
<?php require templatePath('partials/app-details-aside-audit.phtml'); ?>
<?php endif; ?>
</div>
</aside>

View File

@@ -0,0 +1,15 @@
<?php
use MintyPHP\Support\Guard;
use MintyPHP\Router;
use MintyPHP\Http\Request;
Guard::requireLogin();
$slug = trim((string) ($slug ?? ''));
if ($slug === '') {
require __DIR__ . '/index().php';
return;
}
Router::redirect('error/not_found?url=' . urlencode(Request::pathWithQuery()));

View File

@@ -1,11 +1,13 @@
<?php
use MintyPHP\Router;
use MintyPHP\Session;
$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');
$canAccessPdf = can('users.access_pdf');
$canUpdateSelf = can('users.self_update');
$currentUserUuid = (string) ($_SESSION['user']['uuid'] ?? '');
$showTenantTabs = isset($tenants) && is_array($tenants) && count($tenants) > 1;
@@ -31,6 +33,11 @@ require templatePath('partials/app-breadcrumb.phtml');
<i class="bi bi-envelope"></i> <?php e(t('Send access')); ?>
</button>
<?php endif; ?>
<?php if ($canAccessPdf): ?>
<button type="button" class="secondary outline" data-users-bulk="access-pdf" hidden>
<i class="bi bi-file-earmark-pdf"></i> <?php e(t('Download access PDFs')); ?>
</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')); ?>
@@ -163,16 +170,22 @@ require templatePath('partials/app-breadcrumb.phtml');
<div class="app-list-table">
<div id="users-grid"></div>
</div>
<?php if ($canAccessPdf): ?>
<form id="users-access-pdf-bulk-form" method="post" action="admin/users/access-pdf-bulk" hidden>
<?php Session::getCsrfInput(); ?>
<input type="hidden" name="uuids" value="">
</form>
<?php endif; ?>
<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 { createServerGrid } from "<?php e(assetVersion('js/core/app-grid-factory.js')); ?>";
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, getAppBase, escapeHtml, buildBadgeList } from "<?php e(asset('js/pages/app-list-utils.js')); ?>";
import { showAsyncFlash, withLoading } from "<?php e(asset('js/components/app-async-flash.js')); ?>";
import { initUsersListPage } from "<?php e(assetVersion('js/pages/app-users-list.js')); ?>";
import { bindBulkVisibility } from "<?php e(assetVersion('js/components/app-bulk-selection.js')); ?>";
import { buildUrl, badgeHtml, getAppBase, escapeHtml, buildBadgeList } from "<?php e(assetVersion('js/pages/app-list-utils.js')); ?>";
import { showAsyncFlash, withLoading } from "<?php e(assetVersion('js/components/app-async-flash.js')); ?>";
const appBase = getAppBase();
const currentUserUuid = "<?php e($currentUserUuid); ?>";
const canUpdateUsers = <?php e($canUpdateUsers ? 'true' : 'false'); ?>;
@@ -186,6 +199,7 @@ require templatePath('partials/app-breadcrumb.phtml');
bulkDeactivateConfirm: "<?php e(t('Deactivate users?')); ?>",
bulkDeleteConfirm: "<?php e(t('Delete users?')); ?>",
bulkSendAccessConfirm: "<?php e(t('Send access emails to selected users?')); ?>",
bulkAccessPdfConfirm: "<?php e(t('Generate access PDFs for selected users?')); ?>",
primaryTenant: "<?php e(t('Primary tenant')); ?>",
bulkMessages: {
'activate': {
@@ -414,6 +428,9 @@ require templatePath('partials/app-breadcrumb.phtml');
csrf,
labels,
buildBulkUrl,
bulkDownloadForms: {
'access-pdf': '#users-access-pdf-bulk-form'
},
showFlash: showAsyncFlash,
withLoading
});
@@ -421,4 +438,4 @@ require templatePath('partials/app-breadcrumb.phtml');
containerSelector: '#users-grid',
buttonSelector: '[data-users-bulk]'
});
</script>
</script>

View File

@@ -6,9 +6,8 @@ use MintyPHP\Router;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\User\UserService;
use MintyPHP\Service\User\UserAccessTemplateService;
use MintyPHP\Service\Mail\MailService;
use MintyPHP\I18n;
use MintyPHP\Http\Request;
Guard::requireLogin();
@@ -36,32 +35,10 @@ if (!$isOwnAccount && !TenantScopeService::canAccess('users', $userId, $currentU
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,
];
$context = UserAccessTemplateService::buildContext($user);
$locale = (string) ($context['locale'] ?? '');
$subject = (string) ($context['subject'] ?? t('Your access details'));
$vars = is_array($context['vars'] ?? null) ? $context['vars'] : [];
$result = MailService::sendTemplate('access_info', $vars, (string) $user['email'], $subject, $locale);
if (!($result['ok'] ?? false)) {

View File

@@ -5,6 +5,7 @@ use MintyPHP\Router;
use MintyPHP\Session;
use MintyPHP\Service\User\UserService;
use MintyPHP\Support\Guard;
use MintyPHP\Support\Flash;
Guard::requireLogin();
@@ -34,6 +35,17 @@ if ($userId <= 0) {
return;
}
if (!allowUserTheme()) {
http_response_code(403);
if (Request::wantsJson()) {
Router::json(['ok' => false, 'error' => 'theme_disabled']);
return;
}
Flash::error(t('Theme change is disabled for this tenant'), 'admin', 'theme_disabled');
Router::redirect('admin');
return;
}
$theme = strtolower(trim((string) ($_POST['theme'] ?? '')));
$themes = appThemes();
if ($theme === '' || !isset($themes[$theme])) {