Add limited backoffice employee QR login links
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user