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

@@ -10,17 +10,13 @@ use MintyPHP\Repository\User\UserRepository;
use MintyPHP\Repository\Org\UserDepartmentRepository;
use MintyPHP\Repository\Access\UserRoleRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Repository\Tenant\TenantDepartmentRepository;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\Settings\SettingService;
use MintyPHP\Service\Tenant\TenantScopeService;
class UserService
{
private const PASSWORD_MIN_LENGTH = 12;
public static function list(): array
{
return UserRepository::list();
}
public static function listPaged(array $options): array
{
@@ -48,6 +44,146 @@ class UserService
return UserRepository::findByEmail($email);
}
public static function buildAssignmentsForUser(int $userId): array
{
if ($userId <= 0) {
return [
'tenants' => [],
'departments' => [],
'roles' => [],
];
}
$tenantIds = UserTenantRepository::listTenantIdsByUserId($userId);
$departmentIds = UserDepartmentRepository::listDepartmentIdsByUserId($userId);
$roleIds = UserRoleRepository::listRoleIdsByUserId($userId);
$tenantsById = [];
foreach (TenantRepository::listByIds($tenantIds) as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$tenantsById[$tenantId] = [
'id' => $tenantId,
'uuid' => (string) ($tenant['uuid'] ?? ''),
'description' => (string) ($tenant['description'] ?? ''),
'status' => (string) ($tenant['status'] ?? ''),
];
}
$departmentsById = [];
foreach (DepartmentRepository::listByIds($departmentIds, true) as $department) {
$departmentId = (int) ($department['id'] ?? 0);
if ($departmentId <= 0) {
continue;
}
$departmentTenantId = (int) ($department['tenant_id'] ?? 0);
$departmentsById[$departmentId] = [
'id' => $departmentId,
'uuid' => (string) ($department['uuid'] ?? ''),
'description' => (string) ($department['description'] ?? ''),
'active' => (bool) ($department['active'] ?? 0),
'tenant_id' => $departmentTenantId,
'tenant_uuid' => (string) (($tenantsById[$departmentTenantId]['uuid'] ?? '')),
];
}
$rolesById = [];
foreach (RoleRepository::listByIds($roleIds) as $role) {
$roleId = (int) ($role['id'] ?? 0);
if ($roleId <= 0) {
continue;
}
$rolesById[$roleId] = [
'id' => $roleId,
'uuid' => (string) ($role['uuid'] ?? ''),
'description' => (string) ($role['description'] ?? ''),
'active' => (bool) ($role['active'] ?? 0),
];
}
$tenants = [];
foreach ($tenantIds as $tenantId) {
if (isset($tenantsById[$tenantId])) {
$tenants[] = $tenantsById[$tenantId];
}
}
$departments = [];
foreach ($departmentIds as $departmentId) {
if (isset($departmentsById[$departmentId])) {
$departments[] = $departmentsById[$departmentId];
}
}
$roles = [];
foreach ($roleIds as $roleId) {
if (isset($rolesById[$roleId])) {
$roles[] = $rolesById[$roleId];
}
}
return [
'tenants' => $tenants,
'departments' => $departments,
'roles' => $roles,
];
}
public static function findTenantSummaryInAssignments(array $assignments, int $tenantId): ?array
{
if ($tenantId <= 0) {
return null;
}
foreach (($assignments['tenants'] ?? []) as $tenant) {
$currentTenantId = (int) ($tenant['id'] ?? 0);
if ($currentTenantId !== $tenantId) {
continue;
}
return [
'uuid' => (string) ($tenant['uuid'] ?? ''),
'description' => (string) ($tenant['description'] ?? ''),
'status' => (string) ($tenant['status'] ?? ''),
];
}
return null;
}
public static function mapAssignmentsToPublic(array $assignments): array
{
return [
'tenants' => array_values(array_map(
static fn (array $tenant): array => [
'uuid' => (string) ($tenant['uuid'] ?? ''),
'description' => (string) ($tenant['description'] ?? ''),
'status' => (string) ($tenant['status'] ?? ''),
],
$assignments['tenants'] ?? []
)),
'departments' => array_values(array_map(
static fn (array $department): array => [
'uuid' => (string) ($department['uuid'] ?? ''),
'description' => (string) ($department['description'] ?? ''),
'active' => (bool) ($department['active'] ?? 0),
'tenant_uuid' => (string) ($department['tenant_uuid'] ?? ''),
],
$assignments['departments'] ?? []
)),
'roles' => array_values(array_map(
static fn (array $role): array => [
'uuid' => (string) ($role['uuid'] ?? ''),
'description' => (string) ($role['description'] ?? ''),
'active' => (bool) ($role['active'] ?? 0),
],
$assignments['roles'] ?? []
)),
];
}
public static function setLocale(int $userId, string $locale): bool
{
return UserRepository::setLocale($userId, $locale);
@@ -227,11 +363,17 @@ class UserService
{
$form = self::sanitizeBase($input);
$form['totp_secret'] = trim((string) ($input['totp_secret'] ?? ''));
$form['theme'] = self::normalizeTheme($input['theme'] ?? null);
$form['locale'] = self::normalizeLocale($input['locale'] ?? null);
$themeProvided = array_key_exists('theme', $input);
$primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0);
$tenantIdsProvided = array_key_exists('tenant_ids', $input);
$primaryProvided = array_key_exists('primary_tenant_id', $input);
$existing = UserRepository::find($userId) ?? [];
if ($themeProvided) {
$form['theme'] = self::normalizeTheme($input['theme'] ?? null);
} else {
$form['theme'] = self::normalizeTheme($existing['theme'] ?? null);
}
if ($tenantIdsProvided) {
$tenantIds = $input['tenant_ids'] ?? [];
if (!is_array($tenantIds)) {
@@ -244,7 +386,6 @@ class UserService
if (array_key_exists('active', $input)) {
$form['active'] = isset($input['active']) ? 1 : 0;
} else {
$existing = UserRepository::find($userId);
$form['active'] = (int) ($existing['active'] ?? 1);
}
$password = (string) ($input['password'] ?? '');
@@ -287,17 +428,20 @@ class UserService
'region' => $form['region'] !== '' ? $form['region'] : null,
'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null,
'totp_secret' => $form['totp_secret'],
'theme' => $form['theme'],
'locale' => $form['locale'],
'active' => $form['active'],
'password' => $password,
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
];
if ($themeProvided) {
$updateData['theme'] = $form['theme'];
}
if ($tenantIdsProvided || $primaryProvided) {
$updateData['primary_tenant_id'] = $form['primary_tenant_id'] ?? null;
}
if ((int) ($existing['active'] ?? 1) !== (int) $form['active']) {
$activeChanged = (int) ($existing['active'] ?? 1) !== (int) $form['active'];
if ($activeChanged) {
$updateData['active_changed_at'] = gmdate('Y-m-d H:i:s');
$updateData['active_changed_by'] = $currentUserId > 0 ? $currentUserId : null;
}
@@ -308,6 +452,10 @@ class UserService
return ['ok' => false, 'errors' => [t('User can not be updated')], 'form' => $form];
}
if ($activeChanged) {
self::bumpAuthzVersion($userId);
}
return ['ok' => true, 'form' => $form];
}
@@ -338,6 +486,8 @@ class UserService
return ['ok' => false, 'status' => 500, 'error' => 'update_failed'];
}
self::bumpAuthzVersion($userId);
return ['ok' => true, 'user' => $user];
}
@@ -360,6 +510,18 @@ class UserService
return ['ok' => false, 'error' => 'update_failed'];
}
$userIds = [];
foreach ($uuids as $uuid) {
$user = UserRepository::findByUuid((string) $uuid);
$userId = (int) ($user['id'] ?? 0);
if ($userId > 0) {
$userIds[] = $userId;
}
}
if ($userIds) {
UserRepository::bumpAuthzVersionByUserIds($userIds);
}
return ['ok' => true, 'count' => count($uuids)];
}
@@ -437,13 +599,17 @@ class UserService
return ['ok' => true];
}
public static function syncTenants(int $userId, array $tenantIds): bool
public static function syncTenants(int $userId, array $tenantIds, bool $bumpAuthz = true): bool
{
$ids = self::normalizeTenantIds($tenantIds);
return UserTenantRepository::replaceForUser($userId, $ids);
$result = UserTenantRepository::replaceForUser($userId, $ids);
if ($result && $bumpAuthz) {
self::bumpAuthzVersion($userId);
}
return $result;
}
public static function syncRoles(int $userId, array $roleIds): bool
public static function syncRoles(int $userId, array $roleIds, bool $bumpAuthz = true): bool
{
$ids = array_values(array_unique(array_map('intval', $roleIds)));
$ids = array_filter($ids, static fn ($id) => $id > 0);
@@ -454,18 +620,28 @@ class UserService
}
$result = UserRoleRepository::replaceForUser($userId, $ids);
PermissionService::clearUserCache($userId);
if ($result && $bumpAuthz) {
self::bumpAuthzVersion($userId);
}
return $result;
}
public static function syncDepartments(int $userId, array $departmentIds): bool
public static function syncDepartments(int $userId, array $departmentIds, bool $bumpAuthz = true): bool
{
$ids = array_values(array_unique(array_map('intval', $departmentIds)));
$ids = array_filter($ids, static fn ($id) => $id > 0);
$userTenantIds = TenantScopeService::getUserTenantIds($userId);
// Important: use the user's assigned tenants directly (no permission bypass),
// otherwise privileged users (e.g. admins) would incorrectly allow departments
// from tenants that were just removed from their assignments.
$userTenantIds = array_values(array_unique(array_map(
'intval',
UserTenantRepository::listTenantIdsByUserId($userId)
)));
$userTenantIds = array_values(array_filter($userTenantIds, static fn ($id) => $id > 0));
if (!$userTenantIds) {
$ids = [];
} else {
$allowedIds = TenantDepartmentRepository::listDepartmentIdsByTenantIds($userTenantIds);
$allowedIds = DepartmentRepository::listActiveIdsByTenantIds($userTenantIds);
if ($allowedIds) {
$allowedMap = array_fill_keys($allowedIds, true);
$ids = array_values(array_filter($ids, static fn ($id) => isset($allowedMap[$id])));
@@ -473,7 +649,19 @@ class UserService
$ids = [];
}
}
return UserDepartmentRepository::replaceForUser($userId, $ids);
$result = UserDepartmentRepository::replaceForUser($userId, $ids);
if ($result && $bumpAuthz) {
self::bumpAuthzVersion($userId);
}
return $result;
}
public static function bumpAuthzVersion(int $userId): void
{
if ($userId <= 0) {
return;
}
UserRepository::bumpAuthzVersion($userId);
}
private static function sanitizeBase(array $input): array
@@ -498,10 +686,37 @@ class UserService
public static function normalizeIdInput($value): array
{
if (!is_array($value)) {
$value = [$value];
$raw = is_array($value) ? $value : [$value];
$flat = [];
$collect = static function ($item) use (&$collect, &$flat): void {
if (is_array($item)) {
foreach ($item as $nested) {
$collect($nested);
}
return;
}
$text = trim((string) $item);
if ($text === '') {
return;
}
if (str_contains($text, ',')) {
foreach (explode(',', $text) as $part) {
$part = trim($part);
if ($part !== '') {
$flat[] = $part;
}
}
return;
}
$flat[] = $text;
};
foreach ($raw as $item) {
$collect($item);
}
$ids = array_values(array_unique(array_map('intval', $value)));
$ids = array_values(array_unique(array_map('intval', $flat)));
return array_values(array_filter($ids, static fn ($id) => $id > 0));
}
@@ -577,7 +792,7 @@ class UserService
return [$primaryTenantId, []];
}
private static function validatePassword(
public static function validatePassword(
string $password,
string $password2,
bool $required,
@@ -726,6 +941,53 @@ class UserService
return $tenants;
}
/**
* Get only explicitly assigned active tenants for a user.
* This intentionally ignores global tenant-scope bypass permissions.
*/
public static function getAssignedActiveTenants(int $userId): array
{
if ($userId <= 0) {
return [];
}
$tenantIds = array_values(array_unique(array_map(
'intval',
UserTenantRepository::listTenantIdsByUserId($userId)
)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
return [];
}
$activeTenantIds = TenantRepository::listActiveIdsByIds($tenantIds);
if (!$activeTenantIds) {
return [];
}
$tenantsById = [];
foreach (TenantRepository::listByIds($activeTenantIds) as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$tenantsById[$tenantId] = $tenant;
}
$tenants = [];
foreach ($tenantIds as $tenantId) {
if (isset($tenantsById[$tenantId])) {
$tenants[] = $tenantsById[$tenantId];
}
}
usort($tenants, static function ($a, $b) {
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
});
return $tenants;
}
/**
* Get available departments grouped by tenant for a user.
* Returns an array of groups: ['tenant' => [...], 'departments' => [...]]
@@ -743,8 +1005,7 @@ class UserService
if ($tenantId <= 0) {
continue;
}
$departmentIds = TenantDepartmentRepository::listDepartmentIdsByTenantIds([$tenantId]);
$departments = $departmentIds ? DepartmentRepository::listByIds($departmentIds) : [];
$departments = DepartmentRepository::listByTenantIds([$tenantId]);
usort($departments, static function ($a, $b) {
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
});