Make limited backoffice employees regular employees

This commit is contained in:
Jeppe Bundgaard
2026-07-06 16:59:32 +02:00
parent 8bbdf9daf5
commit 08dc803b3e
5 changed files with 369 additions and 21 deletions
@@ -12,6 +12,18 @@ class limited_backoffice_service
public const PERMISSION_MANAGE_PRICES = 'limited_backoffice_prices_manage';
public const PERMISSION_MANAGE_EMPLOYEES = 'limited_backoffice_employees_manage';
private const PERMISSION_PUBLIC_EMPLOYEE_DATA = 'employee_public_data';
/**
* Permissions required for managed employees to sign in and appear in the employee login picker.
*
* @var array<int, string>
*/
private const MANAGED_EMPLOYEE_BASE_PERMISSIONS = [
'user',
self::PERMISSION_PUBLIC_EMPLOYEE_DATA,
];
/**
* @var array<string, array{label:string,description:string,permissions:array<int,string>}>
*/
@@ -255,15 +267,19 @@ class limited_backoffice_service
/**
* @return array<int, array{key:string,label:string,description:string,permission_groups:array<int,array{key:string,capabilities:array<int,string>}>}>
*/
public function rolePresets(): array
public function rolePresets(?users_o $manager = null): array
{
$roles = [];
foreach (self::ROLE_PRESETS as $key => $preset) {
$permissions = $manager === null
? $preset['permissions']
: $this->effectiveRolePermissionsForManager($manager, $key, false);
$roles[] = [
'key' => $key,
'label' => $preset['label'],
'description' => $preset['description'],
'permission_groups' => $this->rolePermissionGroups($preset['permissions']),
'permission_groups' => $this->rolePermissionGroups($permissions),
];
}
return $roles;
@@ -677,7 +693,7 @@ class limited_backoffice_service
try {
if ($active) {
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($roleKey, $newDepartmentIds));
$this->replaceGroupPermissions($managedGroupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $newDepartmentIds));
}
$userUpdates = [];
@@ -1226,18 +1242,40 @@ class limited_backoffice_service
$groupId = (int)$this->mysqli()->insert_id;
$statement->close();
$this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($roleKey, $departmentIds));
$this->replaceGroupPermissions($groupId, $this->permissionsForRoleAndDepartments($manager, $roleKey, $departmentIds));
return $groupId;
}
/**
* @return array<int, string>
*/
private function effectiveRolePermissionsForManager(users_o $manager, string $roleKey, bool $includePublicVisibility): array
{
$permissions = $includePublicVisibility ? self::MANAGED_EMPLOYEE_BASE_PERMISSIONS : ['user'];
foreach (self::ROLE_PRESETS[$roleKey]['permissions'] ?? [] as $permission) {
if (in_array($permission, self::MANAGED_EMPLOYEE_BASE_PERMISSIONS, true)) {
$permissions[] = $permission;
continue;
}
if ($manager->hasPermission($permission)) {
$permissions[] = $permission;
}
}
sort($permissions);
return array_values(array_unique($permissions));
}
/**
* @param array<int, int> $departmentIds
* @return array<int, string>
*/
private function permissionsForRoleAndDepartments(string $roleKey, array $departmentIds): array
private function permissionsForRoleAndDepartments(users_o $manager, string $roleKey, array $departmentIds): array
{
$permissions = self::ROLE_PRESETS[$roleKey]['permissions'] ?? [];
$permissions = $this->effectiveRolePermissionsForManager($manager, $roleKey, true);
foreach ($departmentIds as $departmentId) {
$permissions[] = 'department_access_' . $departmentId;
}
@@ -1356,6 +1394,7 @@ class limited_backoffice_service
{
return [
'id' => (int)$row['user_id'],
'user_id' => (int)$row['user_id'],
'customer_number' => (int)$row['customer_number'],
'display_name' => (string)($row['display_name'] ?? ''),
'email' => $row['email'] === null ? null : (string)$row['email'],
+56
View File
@@ -1077,9 +1077,65 @@ class users_o extends db
return $tmp;
}
/**
* @param array<int, array<string, mixed>> $users
* @return array<int, array<string, mixed>>
*/
public function markLimitedBackofficeManagedUsers(array $users): array
{
$userIds = [];
foreach ($users as $user) {
$userId = (int)($user['id'] ?? 0);
if ($userId > 0) {
$userIds[$userId] = true;
}
}
if ($userIds === []) {
return $users;
}
global $db;
$rows = $db->fetch_all($db->query(
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` IN (' .
implode(',', array_map('intval', array_keys($userIds))) .
')'
));
$managedUserIds = [];
foreach ($rows as $row) {
$managedUserIds[(int)$row['user_id']] = true;
}
foreach ($users as $key => $user) {
$users[$key]['limited_backoffice_managed'] = isset($managedUserIds[(int)($user['id'] ?? 0)]);
}
return $users;
}
public function isLimitedBackofficeManagedUser(int $userId): bool
{
if ($userId <= 0) {
return false;
}
global $db;
$result = $db->query(
'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . (int)$userId . ' LIMIT 1'
);
return $result !== false && $result->num_rows > 0;
}
public function parseCustomerNumbers(array $listObjectsWithPaginationIfSet): array
{
foreach ( $listObjectsWithPaginationIfSet as $key => $value ) {
if ((bool)($value['limited_backoffice_managed'] ?? false) || (int)($value['customer_number'] ?? -1) === 0) {
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $value['display_name'] ?? null;
continue;
}
$listObjectsWithPaginationIfSet[$key]['customer_name'] = $this->getCustomerNameById($value['id']);
}
return $listObjectsWithPaginationIfSet;
@@ -45,10 +45,10 @@ class limitedBackofficeRoute
]);
$this->get('/limited-backoffice/roles', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service): array {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
$this->requirePermission(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
return $service->rolePresets();
return $service->rolePresets($user);
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
+71 -5
View File
@@ -27,9 +27,8 @@ class usersRoute
(new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users');
// Return the list of users
$users_o = new users_o();
$response->success(
$users_o->parseUsers(
$users_o
$limitedEmployeeListMode = $this->limitedBackofficeEmployeeListMode($users_o);
$users = $users_o
->setSearchableFields([
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
'id',
@@ -37,9 +36,19 @@ class usersRoute
'group_id',
'display_name',
])
->listObjectsWithPaginationIfSet()
)
->listObjectsWithPaginationIfSet(
null,
$limitedEmployeeListMode['filters'],
[],
$limitedEmployeeListMode['additional_where']
);
if ($limitedEmployeeListMode['enabled']) {
$users = $users_o->markLimitedBackofficeManagedUsers($users);
}
$users = $users_o->parseUsers(
$users
);
$response->success($users);
} else {
// Log the incident
(new logs_o())->add('users', 'global', 1, 0, 'LIST_USERS', 'No user found, or invalid session');
@@ -153,6 +162,23 @@ class usersRoute
if (!isset($data['display_name']) || $data['display_name'] === 'null' || $data['display_name'] === '') {
$data['display_name'] = null;
}
$targetUser = (new users_o())->getUserById((int)$data['id']);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
if ((new users_o())->isLimitedBackofficeManagedUser((int)$data['id'])) {
$currentCustomerNumber = (string)$targetUser->customer_number->value();
if ((string)$data['customer_number'] !== $currentCustomerNumber) {
$response->error('Limited backoffice managed users cannot change customer number.', 403);
}
if ($data['role'] !== null && (int)$data['role'] !== (int)$targetUser->group_id->value()) {
$response->error('Limited backoffice managed users cannot change role.', 403);
}
$data['role'] = null;
}
// If the role is set, require the edit_user_role permission
if ($data['role']) {
$this->requirePermission('edit_user_role');
@@ -207,4 +233,44 @@ class usersRoute
]
);
}
/**
* @return array{enabled:bool,filters:string|null,additional_where:string|null}
*/
private function limitedBackofficeEmployeeListMode(users_o $users): array
{
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
$filters = $this->fromQuery('filters');
if (!$enabled || $filters === null || $filters === '') {
return [
'enabled' => false,
'filters' => null,
'additional_where' => null,
];
}
$filterArray = $users->filter_string_to_array($filters);
$customerNumberFilter = $filterArray['customer_number'] ?? null;
$isEmployeeFilter = $customerNumberFilter === '0'
|| $customerNumberFilter === 0
|| (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true));
if (!$isEmployeeFilter) {
return [
'enabled' => false,
'filters' => $filters,
'additional_where' => null,
];
}
unset($filterArray['customer_number']);
$activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL';
return [
'enabled' => true,
'filters' => $filterArray === [] ? null : $users->array_to_filters($filterArray),
'additional_where' => '(`customer_number` = 0 OR `id` IN (' . $activeLimitedEmployeeSubquery . '))',
];
}
}
@@ -20,6 +20,34 @@ function limited_backoffice_manager_session(array $departmentIds, array $extraPe
return api_fixtures()->createUserSession(array_values(array_unique(array_merge($permissions, $extraPermissions))));
}
function limited_backoffice_all_role_permissions(): array
{
return [
'user',
'permissions_list_own',
'list_orders',
'add_order',
'edit_order',
'delete_order',
'list_order_items',
'add_order_items',
'edit_order_items',
'delete_order_items',
'charge_order',
'list_bookings',
'list_own_bookings',
'edit_bookings',
'add_booking',
'complete_bookings',
'resend_booking_confirmations',
'department_timebookings_entries_get',
'department_timebookings_entries_post',
'department_timebookings_entries_put',
'statistics_orders_new',
'statistics_bookings_new',
];
}
function limited_backoffice_price_insert(int $departmentId, int $productId, int $price): void
{
$statement = api_test_runtime()->db()->prepare(
@@ -495,7 +523,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw
expect((int)$usersDeletedAtColumn->num_rows)->toBe(0);
$department = api_fixtures()->createDepartment(['name' => 'Limited Employee Department']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$session = limited_backoffice_manager_session([(int)$department['id']], limited_backoffice_all_role_permissions());
$roles = api_client()->get('/limited-backoffice/roles', $session['headers']);
$roles
@@ -571,6 +599,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
expect($created->data()['user_id'] ?? null)->toBe($employeeId);
expect($created->data()['email'] ?? null)->toBe('limited-cashier@example.test');
expect($created->data()['phone_country_code'] ?? null)->toBe(45);
expect($created->data()['phone'] ?? null)->toBe(12345678);
@@ -587,9 +616,14 @@ it('creates updates lists and deactivates scoped employees without exposing raw
$permissions = array_column($permissionRows, 'permission');
expect($permissions)
->toContain('department_access_' . (int)$department['id'])
->toContain('employee_public_data')
->toContain('add_order')
->not->toContain('superuser');
$publicEmployees = api_client()->get('/public/employees');
$publicEmployeeIds = array_map('intval', array_column($publicEmployees->data(), 'id'));
expect($publicEmployeeIds)->toContain($employeeId);
$updated = api_client()->put('/limited-backoffice/employees/' . $employeeId, [
'display_name' => 'Limited Lead',
'email' => 'limited-lead@example.test',
@@ -620,6 +654,7 @@ it('creates updates lists and deactivates scoped employees without exposing raw
}
}
expect($listedEmployee)->not->toBeNull();
expect($listedEmployee['user_id'] ?? null)->toBe($employeeId);
expect($listedEmployee['email'] ?? null)->toBe('limited-lead@example.test');
expect($listedEmployee['phone_country_code'] ?? null)->toBe(358);
expect($listedEmployee['phone'] ?? null)->toBe(87654321);
@@ -637,6 +672,9 @@ it('creates updates lists and deactivates scoped employees without exposing raw
expect(array_key_exists('password', $userRow ?? []))->toBeTrue();
expect($userRow['password'])->toBeNull();
expect((int)($userRow['group_id'] ?? -1))->toBe(0);
$publicEmployeesAfterDeactivation = api_client()->get('/public/employees');
$publicEmployeeIdsAfterDeactivation = array_map('intval', array_column($publicEmployeesAfterDeactivation->data(), 'id'));
expect($publicEmployeeIdsAfterDeactivation)->not->toContain($employeeId);
$employeeRow = api_test_runtime()->queryOne(
'SELECT `deactivated_at` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
);
@@ -644,6 +682,155 @@ it('creates updates lists and deactivates scoped employees without exposing raw
});
});
it('caps limited employee permissions to the manager permissions and selected departments', function (): void {
api_test_covers('POST /limited-backoffice/employees', 'auth');
api_test_covers('GET /limited-backoffice/roles', 'auth');
$department = api_fixtures()->createDepartment(['name' => 'Limited Permission Cap']);
$session = limited_backoffice_manager_session([(int)$department['id']], [
'list_orders',
]);
$roles = api_client()->get('/limited-backoffice/roles', $session['headers']);
$rolesByKey = array_column($roles->data(), null, 'key');
$operationsLeadGroups = array_column($rolesByKey['operations_lead']['permission_groups'] ?? [], 'capabilities', 'key');
expect($operationsLeadGroups['account'] ?? null)->toBe(['sign_in']);
expect($operationsLeadGroups['orders'] ?? null)->toBe(['view_orders']);
expect($roles->body)->not->toContain('create_orders');
expect($roles->body)->not->toContain('view_order_statistics');
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Capped Lead',
'email' => 'limited-capped@example.test',
'password' => 'Secret123!',
'role_key' => 'operations_lead',
'department_ids' => [(int)$department['id']],
], $session['headers']);
$created
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$groupRow = api_test_runtime()->queryOne(
'SELECT `managed_group_id` FROM `limited_backoffice_employees` WHERE `user_id` = ' . $employeeId . ' LIMIT 1'
);
$groupId = (int)($groupRow['managed_group_id'] ?? 0);
$permissionRows = api_test_runtime()->db()->query(
'SELECT `permission` FROM `groups_permissions` WHERE `group_id` = ' . $groupId
)->fetch_all(MYSQLI_ASSOC);
$permissions = array_column($permissionRows, 'permission');
expect($permissions)
->toContain('user')
->toContain('employee_public_data')
->toContain('list_orders')
->toContain('department_access_' . (int)$department['id'])
->not->toContain('add_order')
->not->toContain('delete_order')
->not->toContain('statistics_orders_new')
->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
});
it('includes limited employees in the regular employee list and protects raw user edits', function (): void {
api_test_covers('GET /users', 'limited backoffice employee list');
api_test_covers('PUT /users', 'limited backoffice guard');
$department = api_fixtures()->createDepartment(['name' => 'Limited Regular List']);
$managerSession = limited_backoffice_manager_session([(int)$department['id']], [
'permissions_list_own',
]);
$regularEmployee = api_fixtures()->createUser([
'customer_number' => 0,
'display_name' => 'Regular Backoffice Employee',
], ['employee_public_data']);
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Listed Employee',
'email' => 'limited-listed@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $managerSession['headers']);
$created
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$adminSession = api_fixtures()->createUserSession([
'list_users',
'edit_user',
]);
$withoutLimited = api_client()->get('/users?page=1&limit=50&filters=customer_number:0', $adminSession['headers']);
$withoutLimitedIds = array_map('intval', array_column($withoutLimited->data(), 'id'));
expect($withoutLimitedIds)->toContain((int)$regularEmployee['id']);
expect($withoutLimitedIds)->not->toContain($employeeId);
$withLimited = api_client()->get(
'/users?page=1&limit=50&filters=customer_number:0&include_limited_backoffice_employees=true',
$adminSession['headers']
);
$usersById = array_column($withLimited->data(), null, 'id');
expect(array_keys($usersById))->toContain((int)$regularEmployee['id']);
expect(array_keys($usersById))->toContain($employeeId);
expect($usersById[$employeeId]['limited_backoffice_managed'] ?? null)->toBeTrue();
expect($usersById[(int)$regularEmployee['id']]['limited_backoffice_managed'] ?? null)->toBeFalse();
$userRow = api_test_runtime()->queryOne(
'SELECT `customer_number`, `group_id` FROM `users` WHERE `id` = ' . $employeeId . ' LIMIT 1'
);
$customerNumber = (int)($userRow['customer_number'] ?? 0);
$groupId = (int)($userRow['group_id'] ?? 0);
api_client()->put('/users', [
'id' => $employeeId,
'customer_number' => $customerNumber + 1,
'role' => $groupId,
'display_name' => 'Blocked Customer Change',
], $adminSession['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Limited backoffice managed users cannot change customer number.');
api_client()->put('/users', [
'id' => $employeeId,
'customer_number' => $customerNumber,
'role' => 0,
'display_name' => 'Blocked Role Change',
], $adminSession['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Limited backoffice managed users cannot change role.');
api_client()->put('/users', [
'id' => $employeeId,
'customer_number' => $customerNumber,
'role' => $groupId,
'display_name' => 'Edited Limited Listed Employee',
], $adminSession['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$updatedUserRow = api_test_runtime()->queryOne(
'SELECT `customer_number`, `group_id`, `display_name` FROM `users` WHERE `id` = ' . $employeeId . ' LIMIT 1'
);
expect((int)($updatedUserRow['customer_number'] ?? 0))->toBe($customerNumber);
expect((int)($updatedUserRow['group_id'] ?? 0))->toBe($groupId);
expect($updatedUserRow['display_name'] ?? null)->toBe('Edited Limited Listed Employee');
});
it('accepts employees without optional phone details', function (): void {
api_test_covers('POST /limited-backoffice/employees', 'happy');