Introduce unit and API tests for subuser password policies ensuring compliance with complexity requirements. Normalize subuser grant permission handling for consistency, including support for legacy zero permissions.
1086 lines
48 KiB
PHP
1086 lines
48 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\authentication;
|
|
use classes\economic;
|
|
use classes\gatewayapi;
|
|
use classes\response;
|
|
use classes\virkdata;
|
|
use Exception;
|
|
use modules\virkdata\helpers\virkdata_response;
|
|
use objects\logs_o;
|
|
use objects\subuser_grants_o;
|
|
use objects\subusers_o;
|
|
use objects\users_o;
|
|
use modules\subusers\permissions\subusers_permission_nodes_bookings;
|
|
use modules\subusers\permissions\subusers_permission_nodes_orders;
|
|
use modules\subusers\permissions\subusers_permission_nodes_selfserve;
|
|
use modules\subusers\permissions\subusers_permission_nodes_subusers;
|
|
use modules\subusers\permissions\subusers_permission_nodes_vehicles;
|
|
use modules\subusers\helpers\subusers_permission_node_key;
|
|
use traits\route_t;
|
|
|
|
class subusersRoute
|
|
{
|
|
use route_t;
|
|
|
|
private function getOwnPermissionForNode(subusers_permission_node_key $node)
|
|
{
|
|
return match ($node) {
|
|
subusers_permission_node_key::SUBUSERS_LIST => self::definePermission('list_own_subusers', subusers_permission_node_key::SUBUSERS_LIST),
|
|
subusers_permission_node_key::SUBUSERS_ADD => self::definePermission('add_own_subusers', subusers_permission_node_key::SUBUSERS_ADD),
|
|
subusers_permission_node_key::SUBUSERS_EDIT => self::definePermission('edit_own_subusers', subusers_permission_node_key::SUBUSERS_EDIT),
|
|
subusers_permission_node_key::SUBUSERS_DELETE => self::definePermission('delete_own_subusers', subusers_permission_node_key::SUBUSERS_DELETE),
|
|
default => self::definePermission(strtolower($node->name), $node),
|
|
};
|
|
}
|
|
|
|
private static function requireRegex(string $input, string $regex, string $error_message): void
|
|
{
|
|
if (!preg_match($regex, $input)) {
|
|
global $response;
|
|
$response->error($error_message, 400);
|
|
}
|
|
}
|
|
|
|
private function requireSubuserPasswordPolicy(string $password): void
|
|
{
|
|
self::requireType($password, self::type_string());
|
|
self::requireMinLength('password', subusers_o::PASSWORD_MIN_LENGTH);
|
|
self::requireMaxLength('password', subusers_o::PASSWORD_MAX_LENGTH);
|
|
self::requireRegex($password, subusers_o::PASSWORD_PATTERN, subusers_o::PASSWORD_COMPLEXITY_MESSAGE);
|
|
}
|
|
|
|
private function requireManagedCustomerScope(subusers_permission_node_key $node, ?int $targetCustomerNumber = null): int
|
|
{
|
|
global $response;
|
|
|
|
$auth = new authentication();
|
|
$permission = $this->getOwnPermissionForNode($node);
|
|
|
|
$subuser = $auth->get_subuser();
|
|
if ($subuser !== false) {
|
|
$customerNumber = (int)$auth->get_subuser_customer_number_target();
|
|
if ($customerNumber <= 0) {
|
|
$response->error('Unauthorized', 401);
|
|
}
|
|
if ($targetCustomerNumber !== null && $customerNumber !== (int)$targetCustomerNumber) {
|
|
$this->emitForbidden([$permission]);
|
|
}
|
|
self::requirePermission($permission);
|
|
return $customerNumber;
|
|
}
|
|
|
|
$user = $auth->get_user();
|
|
if ($user !== false) {
|
|
$customerNumber = (int)$user->customer_number->value();
|
|
if ($customerNumber <= 0) {
|
|
$response->error('Unauthorized', 401);
|
|
}
|
|
if ($targetCustomerNumber !== null && $customerNumber !== (int)$targetCustomerNumber) {
|
|
$this->emitForbidden([$permission]);
|
|
}
|
|
return $customerNumber;
|
|
}
|
|
|
|
$response->error('Unauthorized', 401);
|
|
return 0;
|
|
}
|
|
|
|
private function parsePermissionsPayload(mixed $raw, ?array $default = null): ?array
|
|
{
|
|
global $response;
|
|
|
|
if ($raw === null) {
|
|
return $default;
|
|
}
|
|
|
|
if (is_string($raw)) {
|
|
$decoded = json_decode($raw, true);
|
|
if (!is_array($decoded)) {
|
|
$response->error('Invalid permissions payload', 400);
|
|
}
|
|
$raw = $decoded;
|
|
}
|
|
|
|
if (!is_array($raw)) {
|
|
$response->error('Invalid permissions type', 400);
|
|
}
|
|
|
|
$permissions = [];
|
|
foreach ($raw as $permission) {
|
|
if (!is_string($permission) || subusers_permission_node_key::tryFrom($permission) === null) {
|
|
$response->error('Unknown permission key: ' . (string)$permission, 400);
|
|
}
|
|
$permissions[] = strtoupper(trim($permission));
|
|
}
|
|
|
|
return array_values(array_unique($permissions));
|
|
}
|
|
|
|
private function normalizeOptionalString(mixed $value): ?string
|
|
{
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = trim((string)$value);
|
|
return $normalized === '' ? null : $normalized;
|
|
}
|
|
|
|
private function assertSubuserIdentifiersAvailable(
|
|
?int $phoneCountryCode,
|
|
?int $phone,
|
|
?string $username = null,
|
|
?string $email = null,
|
|
?int $ignoreSubuserId = null
|
|
): void {
|
|
global $response;
|
|
|
|
if ($phoneCountryCode !== null && $phone !== null) {
|
|
$existingByPhone = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone);
|
|
if ($existingByPhone !== null && (int)$existingByPhone->id !== (int)$ignoreSubuserId) {
|
|
$response->error('Account already exists with this phone number', 400);
|
|
}
|
|
}
|
|
|
|
if ($username !== null) {
|
|
$existingByUsername = (new subusers_o())->getSubuserByUsername($username);
|
|
if ($existingByUsername !== null && (int)$existingByUsername->id !== (int)$ignoreSubuserId) {
|
|
$response->error('Account already exists with this username', 400);
|
|
}
|
|
}
|
|
|
|
if ($email !== null) {
|
|
$existingByEmail = (new subusers_o())->getSubuserByEmail($email);
|
|
if ($existingByEmail !== null && (int)$existingByEmail->id !== (int)$ignoreSubuserId) {
|
|
$response->error('Account already exists with this email address', 400);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function resolveSubuserByIdentifiers(
|
|
?int $phoneCountryCode,
|
|
?int $phone,
|
|
?string $username = null,
|
|
?string $email = null
|
|
): ?subusers_o {
|
|
global $response;
|
|
|
|
$matches = [];
|
|
|
|
if ($phoneCountryCode !== null && $phone !== null) {
|
|
$existingByPhone = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone);
|
|
if ($existingByPhone !== null) {
|
|
$matches[(int)$existingByPhone->id] = $existingByPhone;
|
|
}
|
|
}
|
|
|
|
if ($username !== null) {
|
|
$existingByUsername = (new subusers_o())->getSubuserByUsername($username);
|
|
if ($existingByUsername !== null) {
|
|
$matches[(int)$existingByUsername->id] = $existingByUsername;
|
|
}
|
|
}
|
|
|
|
if ($email !== null) {
|
|
$existingByEmail = (new subusers_o())->getSubuserByEmail($email);
|
|
if ($existingByEmail !== null) {
|
|
$matches[(int)$existingByEmail->id] = $existingByEmail;
|
|
}
|
|
}
|
|
|
|
if (count($matches) > 1) {
|
|
$response->error('Provided driver identifiers match multiple existing accounts', 409);
|
|
}
|
|
|
|
return count($matches) === 1 ? array_values($matches)[0] : null;
|
|
}
|
|
|
|
private function buildSetupLink(string $token): string
|
|
{
|
|
return 'https://truckwash.io/complete-registration?token=' . $token;
|
|
}
|
|
|
|
private function issueSetupInvite(subusers_o $subuser): array
|
|
{
|
|
if (!$subuser->requiresSetup()) {
|
|
return [
|
|
'setup_token' => null,
|
|
'setup_link' => null,
|
|
'delivery' => [
|
|
'channel' => 'sms',
|
|
'status' => 'not_required',
|
|
'message' => 'Driver account already accepted the invitation.',
|
|
],
|
|
];
|
|
}
|
|
|
|
$token = $subuser->generateSetupToken();
|
|
$link = $this->buildSetupLink($token);
|
|
$delivery = [
|
|
'channel' => 'sms',
|
|
'status' => 'unavailable',
|
|
'message' => 'SMS delivery is not configured.',
|
|
];
|
|
|
|
try {
|
|
$gatewayAPI = new gatewayapi();
|
|
if ($gatewayAPI->isEnabled()) {
|
|
$phoneNumber = (string)$subuser->phone_country_code->value() . (string)$subuser->phone->value();
|
|
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
|
|
$gatewayAPI->send([$phoneNumber], $message);
|
|
$delivery = [
|
|
'channel' => 'sms',
|
|
'status' => 'sent',
|
|
'message' => 'Invite sent successfully.',
|
|
];
|
|
}
|
|
} catch (Exception $exception) {
|
|
$delivery = [
|
|
'channel' => 'sms',
|
|
'status' => 'failed',
|
|
'message' => $exception->getMessage(),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'setup_token' => $token,
|
|
'setup_link' => $link,
|
|
'delivery' => $delivery,
|
|
];
|
|
}
|
|
|
|
private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber): array
|
|
{
|
|
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
|
|
$grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : [];
|
|
$setupRequired = $subuser->requiresSetup();
|
|
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
|
|
$inviteAccepted = !$setupRequired;
|
|
|
|
$accessState = 'inactive';
|
|
if ($grant !== null && $grantEnabled) {
|
|
$accessState = $setupRequired ? 'pending_setup' : 'active';
|
|
} elseif ($grant !== null) {
|
|
$accessState = 'disabled';
|
|
}
|
|
|
|
return [
|
|
'id' => (int)$subuser->id,
|
|
'username' => $subuser->username->value(),
|
|
'name' => $subuser->name->value(),
|
|
'email' => $subuser->email->value(),
|
|
'phone_country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null,
|
|
'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
|
|
'created_at' => $subuser->created_at->value() ?? null,
|
|
'updated_at' => $subuser->updated_at->value() ?? null,
|
|
'suspended_at' => $subuser->suspended_at->value() ?? null,
|
|
'two_factor_enabled' => $subuser->isTwoFactorEnabled(),
|
|
'setup_required' => $setupRequired,
|
|
'invite_accepted' => $inviteAccepted,
|
|
'can_resend_invite' => $setupRequired,
|
|
'profile_editable_by_manager' => false,
|
|
'grant_id' => $grant ? (int)$grant->id : null,
|
|
'grant_enabled' => $grantEnabled,
|
|
'grant_note' => $grant ? $grant->note->value() : null,
|
|
'grant_permissions' => $grantPermissions,
|
|
'permissions' => $grantPermissions,
|
|
'access_state' => $accessState,
|
|
];
|
|
}
|
|
|
|
private function buildCurrentSubuserPayload(subusers_o $subuser): array
|
|
{
|
|
$grants = (new subuser_grants_o())->getFieldsWhere([
|
|
'subuser' => $subuser->id,
|
|
'enabled' => 1,
|
|
'deleted_at' => null,
|
|
], ['permissions', 'billing_customer_number']);
|
|
|
|
return [
|
|
'id' => (int)$subuser->id,
|
|
'username' => $subuser->username->value(),
|
|
'name' => $subuser->name->value(),
|
|
'email' => $subuser->email->value(),
|
|
'phone_country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null,
|
|
'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null,
|
|
'grants' => array_map(function ($grant) {
|
|
return [
|
|
'name' => (new users_o())->getCustomerName((int)$grant['billing_customer_number']),
|
|
'billing_customer_number' => (int)$grant['billing_customer_number'],
|
|
'permissions' => subuser_grants_o::normalizePermissionsValue($grant['permissions'] ?? null),
|
|
];
|
|
}, $grants),
|
|
'created_at' => $subuser->created_at->value() ?? null,
|
|
'updated_at' => $subuser->updated_at->value() ?? null,
|
|
'suspended_at' => $subuser->suspended_at->value() ?? null,
|
|
'two_factor_enabled' => $subuser->isTwoFactorEnabled(),
|
|
];
|
|
}
|
|
|
|
public function run(): void
|
|
{
|
|
// =============================
|
|
// Subuser Grant Management
|
|
// =============================
|
|
$this->get('/subusers/grants', function () {
|
|
global $response;
|
|
/** Permissions (subuser-aware) */
|
|
$permission_own = self::definePermission('list_own_subuser_grants', subusers_permission_node_key::SUBUSERS_LIST);
|
|
$permission_other = self::definePermission('list_subuser_grants');
|
|
$has_permission_other = self::hasPermission($permission_other);
|
|
|
|
// Optional filters: customer_number, subuser_id
|
|
$filters = ['deleted_at' => null];
|
|
$hasFilter = false;
|
|
$requestedCustomer = null;
|
|
if (self::isParametersSet(['customer_number'])) {
|
|
$customer_number = (int)self::getParameter('customer_number');
|
|
self::requireType($customer_number, self::type_int());
|
|
$filters['billing_customer_number'] = $customer_number;
|
|
$requestedCustomer = (int)$customer_number;
|
|
$hasFilter = true;
|
|
}
|
|
if (self::isParametersSet(['subuser_id'])) {
|
|
$subuser_id = (int)self::getParameter('subuser_id');
|
|
self::requireType($subuser_id, self::type_int());
|
|
$filters['subuser'] = $subuser_id;
|
|
$hasFilter = true;
|
|
}
|
|
|
|
// Effective customer context (classic or subuser header)
|
|
$effectiveCustomer = self::resolveEffectiveCustomerNumber();
|
|
|
|
// Allow via own-scope or admin-scope
|
|
self::allowOwnOrDepartmentAccess(
|
|
$permission_own,
|
|
$permission_other,
|
|
$requestedCustomer !== null ? (int)$requestedCustomer : ($effectiveCustomer !== null ? (int)$effectiveCustomer : null),
|
|
null,
|
|
null,
|
|
'You do not have permission to list subuser grants.'
|
|
);
|
|
|
|
// If not admin/department permission, force restrict to effective customer
|
|
if (!$has_permission_other) {
|
|
if ($effectiveCustomer === null) {
|
|
$response->forbidden([$permission_other->permission]);
|
|
}
|
|
$filters['billing_customer_number'] = (int)$effectiveCustomer;
|
|
$hasFilter = true; // ensure we don't fail below
|
|
}
|
|
|
|
// Fallback: try to infer from session for classic users
|
|
if (!$hasFilter) {
|
|
$user = (new authentication())->get_user();
|
|
if ($user && !empty($user->customer_number->value())) {
|
|
$filters['billing_customer_number'] = (int)$user->customer_number->value();
|
|
$hasFilter = true;
|
|
}
|
|
}
|
|
|
|
if (!$hasFilter) {
|
|
$response->error('You must provide at least one of: customer_number or subuser_id', 400);
|
|
}
|
|
|
|
$paginatedResponse = (new subuser_grants_o())->listObjectsWithPaginationIfSet(function ($o) {
|
|
$o = (object)$o;
|
|
$subuser = (new subusers_o())->select((int)$o->subuser);
|
|
return [
|
|
'id' => $o->id,
|
|
'billing_customer_number' => $o->billing_customer_number,
|
|
'subuser' => $o->subuser,
|
|
'name' => $subuser->name->value(),
|
|
'enabled' => $o->enabled,
|
|
'note' => $o->note,
|
|
'permissions' => subuser_grants_o::normalizePermissionsValue($o->permissions ?? null),
|
|
'created_at' => $o->created_at,
|
|
'updated_at' => $o->updated_at,
|
|
];
|
|
}, $filters);
|
|
$response->success($paginatedResponse);
|
|
}, [
|
|
'list_subuser_grants' => 'List subuser grants for any customer (admin).',
|
|
'list_own_subuser_grants' => 'List subuser grants for the effective customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.'
|
|
]);
|
|
|
|
$this->post('/subusers/grants', function () {
|
|
global $response;
|
|
/** Permissions (subuser-aware) */
|
|
$permission_own = self::definePermission('add_own_subusers', subusers_permission_node_key::SUBUSERS_ADD);
|
|
$permission_other = self::definePermission('manage_subuser_grants');
|
|
|
|
self::requireParameters(['customer_number', 'subuser_id']);
|
|
$customer_number = (int)self::getParameter('customer_number');
|
|
$subuser_id = (int)self::getParameter('subuser_id');
|
|
self::requireType($customer_number, self::type_int());
|
|
self::requireType($subuser_id, self::type_int());
|
|
|
|
if (!self::hasPermission($permission_other, (int)$customer_number)) {
|
|
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD, (int)$customer_number);
|
|
}
|
|
|
|
$enabled = true;
|
|
if (self::isParametersSet(['enabled'])) {
|
|
$enabled = (bool)filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
|
if ($enabled === null) { $enabled = true; }
|
|
}
|
|
$note = null;
|
|
if (self::isParametersSet(['note'])) {
|
|
$note = (string)self::getParameter('note');
|
|
self::requireType($note, self::type_string());
|
|
self::requireMaxLength('note', 65535);
|
|
}
|
|
$permissions = null;
|
|
if (self::isParametersSet(['permissions'])) {
|
|
$raw = self::getParameter('permissions');
|
|
// Expect array (already parsed) or JSON string
|
|
if (is_string($raw)) {
|
|
$decoded = json_decode($raw, true);
|
|
if (!is_array($decoded)) {
|
|
$response->error('Invalid permissions payload', 400);
|
|
}
|
|
$permissions = $decoded;
|
|
} elseif (is_array($raw)) {
|
|
$permissions = $raw;
|
|
} else {
|
|
$response->error('Invalid permissions type', 400);
|
|
}
|
|
// Validate each permission is a known key
|
|
foreach ($permissions as $perm) {
|
|
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
|
|
$response->error('Unknown permission key: ' . (string)$perm, 400);
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
|
|
$response->success(['grant' => $grant->asArray()]);
|
|
} catch (Exception $e) {
|
|
$response->error('Failed to add subuser grant', 500);
|
|
}
|
|
}, [
|
|
'manage_subuser_grants' => 'Create subuser grants for any customer (admin).',
|
|
'add_own_subusers' => 'Create subuser grants for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.'
|
|
]);
|
|
|
|
$this->put('/subusers/grants', function () {
|
|
global $response;
|
|
/** Permissions (subuser-aware) */
|
|
$permission_own = self::definePermission('edit_own_subusers', subusers_permission_node_key::SUBUSERS_EDIT);
|
|
$permission_delete_own = self::definePermission('delete_own_subusers', subusers_permission_node_key::SUBUSERS_DELETE);
|
|
$permission_other = self::definePermission('manage_subuser_grants');
|
|
|
|
self::requireParameters(['id']);
|
|
self::requireType(self::getParameter('id'), self::type_int());
|
|
$grant = (new subuser_grants_o())->select((int)self::getParameter('id'));
|
|
if (!$grant->exists()) {
|
|
$response->error('Grant not found', 404);
|
|
}
|
|
|
|
$targetCustomer = (int)$grant->billing_customer_number->value();
|
|
if (!self::hasPermission($permission_other, $targetCustomer)) {
|
|
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT, $targetCustomer);
|
|
}
|
|
|
|
if (self::isParametersSet(['enabled'])) {
|
|
$enabledPreview = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
|
if ($enabledPreview === false && !self::hasPermission($permission_other)) {
|
|
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_DELETE, $targetCustomer);
|
|
}
|
|
}
|
|
|
|
// Update fields provided in the request
|
|
if (self::isParametersSet(['enabled'])) {
|
|
$enabled = (bool)filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
|
if ($enabled === null) { $enabled = false; }
|
|
$grant->enabled->set($enabled);
|
|
}
|
|
if (self::isParametersSet(['note'])) {
|
|
$note = (string)self::getParameter('note');
|
|
self::requireType($note, self::type_string());
|
|
self::requireMaxLength('note', 65535);
|
|
$grant->note->set($note);
|
|
}
|
|
if (self::isParametersSet(['permissions'])) {
|
|
$raw = self::getParameter('permissions');
|
|
// Expect array (already parsed) or JSON string
|
|
if (is_string($raw)) {
|
|
$decoded = json_decode($raw, true);
|
|
if (!is_array($decoded)) {
|
|
$response->error('Invalid permissions payload', 400);
|
|
}
|
|
$permissions = $decoded;
|
|
} elseif (is_array($raw)) {
|
|
$permissions = $raw;
|
|
} else {
|
|
$response->error('Invalid permissions type', 400);
|
|
}
|
|
// Validate each permission is a known key
|
|
foreach ($permissions as $perm) {
|
|
if (!is_string($perm) || subusers_permission_node_key::tryFrom($perm) === null) {
|
|
$response->error('Unknown permission key: ' . (string)$perm, 400);
|
|
}
|
|
}
|
|
$grant->permissions->set($permissions);
|
|
}
|
|
$response->success($grant->asArray());
|
|
},
|
|
[
|
|
'manage_subuser_grants' => 'Edit subuser grants for any customer (admin).',
|
|
'edit_own_subusers' => 'Edit subuser grants for own customer. Subusers require node: SUBUSERS_EDIT and X-Customer-Number header.',
|
|
'delete_own_subusers' => 'Disable subuser grants for own customer. Subusers require node: SUBUSERS_DELETE and X-Customer-Number header.'
|
|
]
|
|
);
|
|
|
|
$this->get('/subusers/permission-nodes', function () {
|
|
global $response;
|
|
// Build groups
|
|
$groups = [
|
|
new subusers_permission_nodes_bookings(),
|
|
new subusers_permission_nodes_orders(),
|
|
new subusers_permission_nodes_selfserve(),
|
|
new subusers_permission_nodes_subusers(),
|
|
new subusers_permission_nodes_vehicles(),
|
|
];
|
|
$out = [];
|
|
foreach ($groups as $group) {
|
|
$nodes = [];
|
|
foreach ($group->nodes as $key => $node) {
|
|
$nodes[] = [
|
|
'key' => $key,
|
|
'name' => $node->name,
|
|
'description' => $node->description,
|
|
'type' => $node->type->name,
|
|
'default' => (bool)($node->value ?? false),
|
|
];
|
|
}
|
|
$out[] = [
|
|
'group' => $group->group_name,
|
|
'description' => $group->description,
|
|
'nodes' => $nodes,
|
|
];
|
|
}
|
|
$response->success($out);
|
|
}, []);
|
|
|
|
$this->post('/subusers', function () {
|
|
global /** @var response $response */
|
|
$response;
|
|
self::requireParameters([
|
|
'cvr',
|
|
'phone_country_code',
|
|
'phone'
|
|
]);
|
|
|
|
$cvr = (int)self::getParameter('cvr');
|
|
$phone_country_code =(int) self::getParameter('phone_country_code');
|
|
$phone = (int)self::getParameter('phone');
|
|
/** Validation */
|
|
// Check if the CVR number is valid (8 digits)
|
|
self::requireType($cvr, self::type_int());
|
|
self::requireMinLength('cvr', 8);
|
|
self::requireMaxLength('cvr', 8);
|
|
// Check if the phone country code is valid (1-3 digits)
|
|
self::requireType($phone_country_code, self::type_int());
|
|
self::requireMinLength('phone_country_code', 1);
|
|
self::requireMaxLength('phone_country_code', 3);
|
|
// Check if the phone number is valid (4-15 digits)
|
|
self::requireType($phone, self::type_int());
|
|
self::requireMinLength('phone', 4);
|
|
self::requireMaxLength('phone', 15);
|
|
// Check if the CVR is registered to a company in the database
|
|
$economic = new economic();
|
|
$results = $economic->customers->customers->search([
|
|
'corporateIdentificationNumber' => (string)$cvr,
|
|
],[
|
|
'skipPages' => 0,
|
|
'pageSize' => 1000, // Since the limit is 1000, we need to set the page size to 1000.
|
|
])->collection;
|
|
if (count($results) === 0) {
|
|
$response->error('Customer not found', 404);
|
|
}
|
|
/** At this point, we know that the CVR is valid and that the company exists in the database. */
|
|
$company_user = (new users_o())->getUserByCustomerNumber((int)$results[0]->customerNumber);
|
|
$company_user->requireSelected();
|
|
/** We can now check if a subuser already exists with the same phone number. */
|
|
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
|
|
if ($subuser !== null) {
|
|
$response->error('Account already exists with this phone number', 400);
|
|
}
|
|
/** We can now create the subuser and send a request to the company user to link the subuser to the company. */
|
|
$subuser = (new subusers_o())->add(
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
(int)$phone_country_code,
|
|
(int)$phone
|
|
);
|
|
// Send an SMS with a link to complete the registration process.
|
|
$gatewayAPI = new gatewayapi();
|
|
if ($gatewayAPI->isEnabled()) {
|
|
$token = $subuser->generateSetupToken();
|
|
$link = 'https://truckwash.io/complete-registration?token=' . $token;
|
|
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
|
|
$phone_number_array = [(string)$phone_country_code . (string)$phone];
|
|
$gatewayAPI->send($phone_number_array, $message);
|
|
}
|
|
// Add the grant request
|
|
$subuser_grants_o = new subuser_grants_o();
|
|
try {
|
|
$subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null);
|
|
} catch (Exception $e) {
|
|
$response->error('Failed to add subuser grant', 500);
|
|
}
|
|
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
|
|
// Code for creating a new subuser would go here
|
|
});
|
|
$this->get('/subusers/setup', function () {
|
|
// Require the user to be logged in
|
|
global $response;
|
|
self::requireParameters(['token']);
|
|
$token = self::getParameter('token');
|
|
// Get the subuser with the setup token
|
|
$subuser = (new subusers_o())->getSubuserBySetupToken($token);
|
|
if ($subuser === null) {
|
|
$response->error('Invalid or expired token', 400);
|
|
}
|
|
$response->success(['message' => 'Token is valid', 'subuser_id' => $subuser->id]);
|
|
});
|
|
$this->post('/subusers/setup', function () {
|
|
// Require the user to be logged in
|
|
global $response;
|
|
self::requireParameters(['token', 'password', 'name']);
|
|
$token = (string)self::getParameter('token');
|
|
$password = (string)self::getParameter('password');
|
|
$name = (string)self::getParameter('name');
|
|
$this->requireSubuserPasswordPolicy($password);
|
|
self::requireType($name, self::type_string());
|
|
self::requireMinLength('name', 3);
|
|
self::requireMaxLength('name', 255);
|
|
/**
|
|
* Optional fields:
|
|
* - username: string, 3-255 characters
|
|
* - email: string, valid email format, 3-255 characters
|
|
*/
|
|
$username = null;
|
|
$email = null;
|
|
if (self::isParametersSet(['username'])) {
|
|
$username = (string)self::getParameter('username');
|
|
self::requireType($username, self::type_string());
|
|
self::requireMinLength('username', 3);
|
|
self::requireMaxLength('username', 255);
|
|
}
|
|
if (self::isParametersSet(['email'])) {
|
|
$email = (string)self::getParameter('email');
|
|
self::requireType($email, self::type_string());
|
|
self::requireMinLength('email', 3);
|
|
self::requireMaxLength('email', 255);
|
|
if(!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
$response->error('Invalid email format', 400);
|
|
}
|
|
}
|
|
// Get the subuser with the setup token
|
|
$subuser = (new subusers_o())->getSubuserBySetupToken($token);
|
|
if ($subuser === null) {
|
|
$response->error('Invalid or expired token', 400);
|
|
}
|
|
// Set the password for the subuser
|
|
try {
|
|
$subuser->setPassword($password);
|
|
if (!empty($username) || !empty($email) || !empty($name)) {
|
|
$subuser->update([
|
|
...(!empty($username) ? ['username' => $username] : []),
|
|
...(!empty($email) ? ['email' => $email] : []),
|
|
...(!empty($name) ? ['name' => $name] : []),
|
|
]);
|
|
}
|
|
// Invalidate the setup token
|
|
$subuser->invalidateSetupToken($token);
|
|
$response->success(['message' => 'Complete registration successful']);
|
|
} catch (Exception $e) {
|
|
$response->error($e->getMessage(), 400);
|
|
}
|
|
});
|
|
|
|
$this->post('/subusers/auth/password', function () {
|
|
global $response;
|
|
/**
|
|
* Username types:
|
|
* 1. Phone number & country code
|
|
* 2. Subuser id
|
|
* 3. Username
|
|
*/
|
|
$phone_country_code = null;
|
|
$phone = null;
|
|
$subuser_id = null;
|
|
$username = null;
|
|
if (self::isParametersSet(['phone_country_code', 'phone'])) {
|
|
$phone_country_code = (int)self::getParameter('phone_country_code');
|
|
$phone = (int)self::getParameter('phone');
|
|
self::requireType($phone_country_code, self::type_int());
|
|
self::requireMinLength('phone_country_code', 1);
|
|
self::requireMaxLength('phone_country_code', 3);
|
|
self::requireType($phone, self::type_int());
|
|
self::requireMinLength('phone', 4);
|
|
self::requireMaxLength('phone', 15);
|
|
} elseif (self::isParametersSet(['subuser_id'])) {
|
|
$subuser_id = (int)self::getParameter('subuser_id');
|
|
self::requireType($subuser_id, self::type_int());
|
|
} elseif (self::isParametersSet(['username'])) {
|
|
$username = (string)self::getParameter('username');
|
|
self::requireType($username, self::type_string());
|
|
self::requireMinLength('username', 3);
|
|
self::requireMaxLength('username', 255);
|
|
} else {
|
|
$response->error('You must provide either phone_country_code & phone, subuser_id or username', 400);
|
|
}
|
|
// Get the subuser based on the provided username type
|
|
$subuser = null;
|
|
if ($phone_country_code !== null && $phone !== null) {
|
|
$subuser = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
|
|
} elseif ($subuser_id !== null) {
|
|
$subuser = (new subusers_o())->select($subuser_id);
|
|
} elseif ($username !== null) {
|
|
$subuser = (new subusers_o())->getSubuserByUsername($username);
|
|
}
|
|
if ($subuser === null) {
|
|
$response->error('Subuser not found', 404);
|
|
}
|
|
self::requireParameters(['password']);
|
|
$password = (string)self::getParameter('password');
|
|
$this->requireSubuserPasswordPolicy($password);
|
|
try {
|
|
if (password_verify($password, $subuser->password->value())) {
|
|
if ($subuser->isTwoFactorEnabled()) {
|
|
$token = (new authentication())->create_2fa_token($subuser->id, '2FA_VERIFICATION_SUBUSER');
|
|
$response->success(['2fa_required' => true, '2fa_token' => $token]);
|
|
}
|
|
// Generate a new session for the subuser
|
|
$session = $subuser->generateSession();
|
|
$response->success(['session' => $session]);
|
|
} else {
|
|
$response->error('Invalid password', 400);
|
|
}
|
|
} catch (Exception $e) {
|
|
$response->error($e->getMessage(), 500);
|
|
}
|
|
});
|
|
|
|
// =============================
|
|
// Subusers - List & Get (with grant visibility)
|
|
// =============================
|
|
$this->get('/subusers', function () {
|
|
global $response;
|
|
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
|
|
$includeNonEnabled = false;
|
|
if (self::isParametersSet(['include_non_enabled'])) {
|
|
$tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
|
$includeNonEnabled = $tmp === null ? false : (bool)$tmp;
|
|
}
|
|
|
|
$existsClause = sprintf(
|
|
"EXISTS (SELECT 1 FROM `subuser_grants` sg WHERE sg.`subuser` = `subusers`.`id` AND %ssg.`deleted_at` IS NULL AND sg.`billing_customer_number` = %d)",
|
|
$includeNonEnabled ? '' : 'sg.`enabled` = 1 AND ',
|
|
$customerNumber
|
|
);
|
|
|
|
$objects = (new subusers_o())
|
|
->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber) {
|
|
$subuser = (new subusers_o())->select((int)$o['id']);
|
|
if (!$subuser->exists()) {
|
|
return null;
|
|
}
|
|
$subuser->getObjectProperties();
|
|
return $this->buildSubuserManagementPayload($subuser, $customerNumber);
|
|
}, null, [], $existsClause);
|
|
|
|
if (is_array($objects)) {
|
|
$objects = array_values(array_filter($objects, static fn ($item) => $item !== null));
|
|
}
|
|
|
|
$response->success($objects);
|
|
}, [
|
|
'list_own_subusers' => 'List chauffeurs for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.',
|
|
]);
|
|
|
|
$this->get('/subusers/me', function () {
|
|
global $response;
|
|
$subuser = (new authentication())->get_subuser();
|
|
if ($subuser === false) {
|
|
$response->error('Unauthorized', 401);
|
|
}
|
|
|
|
$response->success($this->buildCurrentSubuserPayload($subuser));
|
|
}, []);
|
|
|
|
$this->put('/subusers/me', function () {
|
|
global $response;
|
|
$subuser = (new authentication())->get_subuser();
|
|
if ($subuser === false) {
|
|
$response->error('Unauthorized', 401);
|
|
}
|
|
|
|
$updates = [];
|
|
|
|
if (self::isParametersSet(['name'])) {
|
|
$name = $this->normalizeOptionalString(self::getParameter('name'));
|
|
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
|
|
$response->error('Name must be between 3 and 255 characters long', 400);
|
|
}
|
|
$updates['name'] = $name;
|
|
}
|
|
|
|
if (self::isParametersSet(['username'])) {
|
|
$username = $this->normalizeOptionalString(self::getParameter('username'));
|
|
if ($username !== null && (strlen($username) < 3 || strlen($username) > 50)) {
|
|
$response->error('Username must be between 3 and 50 characters long', 400);
|
|
}
|
|
$updates['username'] = $username;
|
|
}
|
|
|
|
if (self::isParametersSet(['email'])) {
|
|
$email = $this->normalizeOptionalString(self::getParameter('email'));
|
|
if ($email !== null && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
$response->error('Invalid email format', 400);
|
|
}
|
|
if ($email !== null && strlen($email) > 255) {
|
|
$response->error('Email must be at most 255 characters long', 400);
|
|
}
|
|
$updates['email'] = $email;
|
|
}
|
|
|
|
if (count($updates) === 0) {
|
|
$response->error('No fields to update', 400);
|
|
}
|
|
|
|
$this->assertSubuserIdentifiersAvailable(
|
|
null,
|
|
null,
|
|
$updates['username'] ?? null,
|
|
$updates['email'] ?? null,
|
|
(int)$subuser->id
|
|
);
|
|
|
|
try {
|
|
$subuser->update($updates);
|
|
} catch (Exception $exception) {
|
|
$response->error($exception->getMessage(), 400);
|
|
}
|
|
|
|
$subuser = (new subusers_o())->select((int)$subuser->id);
|
|
$subuser->getObjectProperties();
|
|
$response->success($this->buildCurrentSubuserPayload($subuser));
|
|
}, []);
|
|
|
|
$this->post('/subusers/invite', function () {
|
|
global $response;
|
|
|
|
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
|
|
self::requireParameters(['name', 'phone_country_code', 'phone']);
|
|
|
|
$name = $this->normalizeOptionalString(self::getParameter('name'));
|
|
$phoneCountryCode = (int)self::getParameter('phone_country_code');
|
|
$phone = (int)self::getParameter('phone');
|
|
$note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null;
|
|
$enabled = true;
|
|
if (self::isParametersSet(['enabled'])) {
|
|
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
|
$enabled = $tmp === null ? true : (bool)$tmp;
|
|
}
|
|
$permissions = self::isParametersSet(['permissions'])
|
|
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
|
|
: null;
|
|
|
|
if ($name === null || strlen($name) < 3 || strlen($name) > 255) {
|
|
$response->error('Name must be between 3 and 255 characters long', 400);
|
|
}
|
|
self::requireType($phoneCountryCode, self::type_int());
|
|
self::requireType($phone, self::type_int());
|
|
self::requireMinLength('phone_country_code', 1);
|
|
self::requireMaxLength('phone_country_code', 3);
|
|
self::requireMinLength('phone', 4);
|
|
self::requireMaxLength('phone', 15);
|
|
if ($note !== null && strlen($note) > 65535) {
|
|
$response->error('Note must be at most 65535 characters long', 400);
|
|
}
|
|
|
|
$subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone);
|
|
if ($subuser === null) {
|
|
try {
|
|
$subuser = (new subusers_o())->add(
|
|
null,
|
|
null,
|
|
$name,
|
|
null,
|
|
$phoneCountryCode,
|
|
$phone
|
|
);
|
|
} catch (Exception $exception) {
|
|
$response->error($exception->getMessage(), 400);
|
|
}
|
|
}
|
|
|
|
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
|
|
if ($grant === null) {
|
|
try {
|
|
$grant = (new subuser_grants_o())->add(
|
|
$customerNumber,
|
|
(int)$subuser->id,
|
|
$enabled,
|
|
$note,
|
|
$permissions ?? subuser_grants_o::defaultPermissions
|
|
);
|
|
} catch (Exception $exception) {
|
|
$response->error('Failed to create subuser grant', 500);
|
|
}
|
|
} else {
|
|
$grantUpdates = ['enabled' => $enabled];
|
|
if (self::isParametersSet(['note'])) {
|
|
$grantUpdates['note'] = $note;
|
|
}
|
|
if ($permissions !== null) {
|
|
$grantUpdates['permissions'] = $permissions;
|
|
}
|
|
try {
|
|
$grant->update($grantUpdates);
|
|
} catch (Exception $exception) {
|
|
$response->error('Failed to update subuser grant', 500);
|
|
}
|
|
$grant = (new subuser_grants_o())->select((int)$grant->id);
|
|
$grant->getObjectProperties();
|
|
}
|
|
|
|
$invite = $this->issueSetupInvite($subuser);
|
|
$response->success([
|
|
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
|
|
'grant' => $grant->asArray(),
|
|
'invite' => $invite,
|
|
]);
|
|
}, [
|
|
'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.',
|
|
]);
|
|
|
|
$this->post('/subusers/invite/resend', function () {
|
|
global $response;
|
|
|
|
self::requireParameters(['id']);
|
|
$subuserId = (int)self::getParameter('id');
|
|
self::requireType($subuserId, self::type_int());
|
|
|
|
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT);
|
|
$subuser = (new subusers_o())->select($subuserId);
|
|
if (!$subuser->exists()) {
|
|
$response->error('Subuser not found', 404);
|
|
}
|
|
$subuser->getObjectProperties();
|
|
|
|
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true);
|
|
if ($grant === null) {
|
|
$response->error('Subuser grant not found for selected customer', 404);
|
|
}
|
|
|
|
if (!$subuser->requiresSetup()) {
|
|
$response->error('Driver account already accepted the invitation.', 409);
|
|
}
|
|
|
|
$invite = $this->issueSetupInvite($subuser);
|
|
$response->success([
|
|
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
|
|
'grant' => $grant->asArray(),
|
|
'invite' => $invite,
|
|
]);
|
|
}, [
|
|
'edit_own_subusers' => 'Resend chauffeur invite for own customer. Subusers require node: SUBUSERS_EDIT and X-Customer-Number header.',
|
|
]);
|
|
|
|
$this->put('/subusers', function () {
|
|
global $response;
|
|
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT);
|
|
$response->error('Customers can only manage subuser grants. Drivers own their account profile.', 403);
|
|
}, [
|
|
'edit_own_subusers' => 'Customers cannot edit chauffeur account profiles. They may only manage grants, permissions, and enabled state.',
|
|
]);
|
|
// Public registration endpoint (alias of POST /subusers) matching OpenAPI: POST /subusers/me
|
|
$this->post('/subusers/me', function () {
|
|
global /** @var response $response */
|
|
$response;
|
|
// Required input
|
|
self::requireParameters([
|
|
'cvr',
|
|
'phone_country_code',
|
|
'phone'
|
|
]);
|
|
|
|
$cvr = (int)self::getParameter('cvr');
|
|
$phone_country_code = (int)self::getParameter('phone_country_code');
|
|
$phone = (int)self::getParameter('phone');
|
|
|
|
// Validation
|
|
self::requireType($cvr, self::type_int());
|
|
self::requireMinLength('cvr', 8);
|
|
self::requireMaxLength('cvr', 8);
|
|
self::requireType($phone_country_code, self::type_int());
|
|
self::requireMinLength('phone_country_code', 1);
|
|
self::requireMaxLength('phone_country_code', 3);
|
|
self::requireType($phone, self::type_int());
|
|
self::requireMinLength('phone', 4);
|
|
self::requireMaxLength('phone', 15);
|
|
|
|
// Look up company by CVR in e-conomic
|
|
$economic = new economic();
|
|
$results = $economic->customers->customers->search([
|
|
'corporateIdentificationNumber' => (string)$cvr,
|
|
], [
|
|
'skipPages' => 0,
|
|
'pageSize' => 1000,
|
|
])->collection;
|
|
if (count($results) === 0) {
|
|
$response->error('Customer not found', 404);
|
|
}
|
|
|
|
// Ensure company user exists (sanity) and phone is not already registered
|
|
$company_user = (new users_o())->getUserByCustomerNumber((int)$results[0]->customerNumber);
|
|
$company_user->requireSelected();
|
|
|
|
$existing = (new subusers_o())->getSubuserByPhone($phone_country_code, $phone);
|
|
if ($existing !== null) {
|
|
$response->error('Account already exists with this phone number', 400);
|
|
}
|
|
|
|
// Create subuser skeleton
|
|
$subuser = (new subusers_o())->add(
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
(int)$phone_country_code,
|
|
(int)$phone
|
|
);
|
|
|
|
// Optionally send SMS with setup link
|
|
$gatewayAPI = new gatewayapi();
|
|
if ($gatewayAPI->isEnabled()) {
|
|
$token = $subuser->generateSetupToken();
|
|
$link = 'https://truckwash.io/complete-registration?token=' . $token;
|
|
$message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link;
|
|
$phone_number_array = [(string)$phone_country_code . (string)$phone];
|
|
$gatewayAPI->send($phone_number_array, $message);
|
|
}
|
|
|
|
// Create a pending grant request for the company
|
|
$subuser_grants_o = new subuser_grants_o();
|
|
try {
|
|
$subuser_grants_o->add($results[0]->customerNumber, $subuser->id, false, null);
|
|
} catch (Exception $e) {
|
|
$response->error('Failed to add subuser grant', 500);
|
|
}
|
|
|
|
$response->success(['cvr' => $cvr, 'customer_number' => $results[0]->customerNumber]);
|
|
});
|
|
}
|
|
}
|