Add limited backoffice employee QR login links

This commit is contained in:
Jeppe Bundgaard
2026-07-06 17:32:58 +02:00
parent 8544ce0a18
commit 7ac5c5585b
4 changed files with 265 additions and 0 deletions
+40
View File
@@ -40,6 +40,8 @@ tags:
description: Account security and passkey management endpoints description: Account security and passkey management endpoints
- name: Users - name: Users
description: User management and customer operations description: User management and customer operations
- name: Limited Backoffice
description: Limited backoffice employee and department management
- name: Search - name: Search
description: System-wide search endpoints description: System-wide search endpoints
- name: Orders - name: Orders
@@ -2762,6 +2764,44 @@ paths:
properties: properties:
token: {type: string} token: {type: string}
/limited-backoffice/employees/{employeeId}/login-link:
post:
tags:
- Limited Backoffice
summary: Create a managed employee QR login link
description: Create a reusable auth-token login link for an active employee managed through the limited backoffice.
operationId: createLimitedBackofficeEmployeeLoginLink
parameters:
- name: employeeId
in: path
required: true
schema:
type: integer
minimum: 1
responses:
'200':
description: Login link created successfully
content:
application/json:
schema:
type: object
properties:
employee_id:
type: integer
login_path:
type: string
example: /login/qr?token=abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
# User Endpoints # User Endpoints
/users: /users:
get: get:
@@ -3,6 +3,7 @@
namespace classes; namespace classes;
use mysqli; use mysqli;
use objects\logs_o;
use objects\products_o; use objects\products_o;
use objects\users_o; use objects\users_o;
@@ -766,6 +767,47 @@ class limited_backoffice_service
return $this->updateEmployee($manager, $employeeId, ['active' => false]); return $this->updateEmployee($manager, $employeeId, ['active' => false]);
} }
/**
* @return array{employee_id:int,login_path:string}
*/
public function createEmployeeLoginLink(users_o $manager, int $employeeId): array
{
$this->assertNotSelfEdit($manager, $employeeId);
$employee = $this->loadManagedEmployee($employeeId);
if ($employee === null) {
throw new limited_backoffice_exception('Managed employee not found.', 404);
}
$departmentIds = $this->decodeDepartmentIds((string)$employee['department_ids']);
$this->assertDepartmentSubset($manager, $departmentIds);
$this->assertManagedTargetIsSafe($employee);
if (!$this->isEmployeeRowActive($employee)) {
throw new limited_backoffice_exception('Cannot create a login link for an inactive employee.', 409);
}
$token = (new authentication())->create_employee_token($employeeId);
try {
(new logs_o())->add(
'auth',
'global',
1,
(int)$manager->id,
'AUTH_SUCCESS_LIMITED_BACKOFFICE_EMPLOYEE_LOGIN_LINK',
'Created limited backoffice login link for employee: ' . $employeeId
);
} catch (\Throwable) {
// Audit logging should not block login-link generation.
}
return [
'employee_id' => $employeeId,
'login_path' => '/login/qr?token=' . $token,
];
}
private function mysqli(): mysqli private function mysqli(): mysqli
{ {
global $db; global $db;
@@ -78,6 +78,17 @@ class limitedBackofficeRoute
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees', limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]); ]);
$this->post('/limited-backoffice/employees/{employeeId}/login-link', function () {
$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->createEmployeeLoginLink($user, $this->routePositiveInt('employeeId'));
});
}, [
limited_backoffice_service::PERMISSION_ACCESS => 'Access the limited backoffice',
limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES => 'Manage limited backoffice employees',
]);
$this->put('/limited-backoffice/employees/{employeeId}', function () { $this->put('/limited-backoffice/employees/{employeeId}', function () {
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array { $this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS); $this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
@@ -800,6 +800,178 @@ it('caps limited employee permissions to the manager permissions and selected de
->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES); ->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
}); });
it('generates reusable QR login links for active scoped employees', function (): void {
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Department']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited QR Employee',
'email' => 'limited-qr@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'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);
$response = api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-link',
[],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$loginPath = (string)($response->data()['login_path'] ?? '');
expect($response->data()['employee_id'] ?? null)->toBe($employeeId);
expect($loginPath)->toMatch('/^\/login\/qr\?token=[a-f0-9]{64}$/');
parse_str((string)parse_url($loginPath, PHP_URL_QUERY), $query);
$token = (string)($query['token'] ?? '');
expect($token)->toMatch('/^[a-f0-9]{64}$/');
$tokenRow = api_test_runtime()->queryOne(
"SELECT `user_id`, `type` FROM `tokens` WHERE `token` = '" .
api_test_runtime()->db()->real_escape_string($token) .
"' LIMIT 1"
);
expect($tokenRow)->not->toBeNull();
expect((int)($tokenRow['user_id'] ?? 0))->toBe($employeeId);
expect($tokenRow['type'] ?? null)->toBe('AUTH_TOKEN');
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
$list
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($list->body)->not->toContain($token);
expect($list->body)->not->toContain('login_path');
});
it('rejects invalid limited backoffice employee QR login link generation', function (): void {
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'auth');
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'validation');
$department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Own']);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Login Link Other']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$otherSession = limited_backoffice_manager_session([(int)$otherDepartment['id']]);
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Link Target',
'email' => 'limited-link-target@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $session['headers']);
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$withoutManageEmployees = api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
'department_access_' . (int)$department['id'],
]);
api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-link',
[],
$withoutManageEmployees['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions([limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES]);
api_client()->post(
'/limited-backoffice/employees/' . (int)$session['user']['id'] . '/login-link',
[],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Managers cannot edit themselves.');
api_client()->post('/limited-backoffice/employees/999999999/login-link', [], $session['headers'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Managed employee not found.');
$otherCreated = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Other Department Target',
'email' => 'limited-other-target@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$otherDepartment['id']],
], $otherSession['headers']);
$otherEmployeeId = (int)($otherCreated->data()['id'] ?? 0);
expect($otherEmployeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($otherEmployeeId);
api_client()->post(
'/limited-backoffice/employees/' . $otherEmployeeId . '/login-link',
[],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
api_client()->post('/limited-backoffice/employees/' . $employeeId . '/login-link', [], $session['headers'])
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot create a login link for an inactive employee.');
$superuser = api_fixtures()->createUser(['group_id' => 1]);
api_test_runtime()->db()->query(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
VALUES (' . (int)$superuser['id'] . ", 1, 'department_admin', '[" . (int)$department['id'] . "]', " . (int)$session['user']['id'] . ')'
);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$superuser['id']]);
api_client()->post('/limited-backoffice/employees/' . (int)$superuser['id'] . '/login-link', [], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot manage superuser accounts.');
$sharedGroup = api_fixtures()->createGroup();
$firstSharedUser = api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
$departmentJson = '[' . (int)$department['id'] . ']';
api_test_runtime()->db()->query(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
VALUES (' . (int)$firstSharedUser['id'] . ', ' . (int)$sharedGroup['id'] . ", 'viewer', '" . $departmentJson . "', " . (int)$session['user']['id'] . ')'
);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$firstSharedUser['id']]);
api_client()->post('/limited-backoffice/employees/' . (int)$firstSharedUser['id'] . '/login-link', [], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot manage shared groups.');
});
it('includes limited employees in the regular employee list and protects raw user edits', function (): void { 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('GET /users', 'limited backoffice employee list');
api_test_covers('PUT /users', 'limited backoffice guard'); api_test_covers('PUT /users', 'limited backoffice guard');