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:
@@ -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'] ?? ''));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user