Files
api/services/nginx/app/routes/subusersRoute.php
T
OpenClaw 51a87655d6 feat(auth): add scope-based access control to all existing routes (TRU-149)
Adds a scope-based access control layer to all 81 existing API routes.
Sits alongside existing session-cookie auth (does not replace it).

What this PR does:
- Audits every existing route and documents required scope per route
  (see documentation/auth/route-scope-audit.md)
- Adds classes/auth/scope.php with 10 scope constants and role→scope defaults
- Adds classes/auth/scope_middleware.php with requireScope/requireAnyScope/requireRole
- Applies require*() calls to all 81 existing routes
- Adds ScopeMiddlewareTest (unit, 178 lines) and RouteScopeTest (integration, 212 lines)

Coexistence note:
This branch's classes/auth/scope.php is a stub that will be replaced
by classes/auth/scope_registry.php (from TRU-145 / PR #396) when that
PR merges first. The two have compatible APIs.

Refs: TRU-149
2026-08-17 11:43:13 +00:00

3096 lines
131 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use classes\account_deletion_service;
use classes\economic;
use classes\email;
use classes\gatewayapi;
use classes\response;
use classes\subuser_action_token_service;
use classes\subuser_contact_verification_service;
use classes\subusers_schema_bootstrap;
use classes\subuser_permission_templates_service;
use classes\virkdata;
use Exception;
use modules\virkdata\helpers\virkdata_response;
use objects\customer_vehicles_o;
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;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class subusersRoute
{
use route_t;
private const DOGNVASK_PERMISSION_KEYS = ['SELFSERVE_LIST', 'SELFSERVE_ADD'];
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]);
}
$this->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]);
}
$this->requirePermission($permission);
return $customerNumber;
}
$response->error('Unauthorized', 401);
}
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) {
$permission = is_string($permission) ? strtoupper(trim($permission)) : $permission;
if (!is_string($permission) || subusers_permission_node_key::tryFrom($permission) === null) {
$response->error('Unknown permission key: ' . (string)$permission, 400);
}
$permissions[] = $permission;
}
return array_values(array_unique($permissions));
}
/**
* @return array{enabled:bool,permissions:array<int,string>}|null
*/
private function parseAccessTemplatePayload(): ?array
{
global $response;
if (!self::isParametersSet(['permission_template_key'])) {
return null;
}
$templateKey = (string)self::getParameter('permission_template_key');
try {
return (new subuser_permission_templates_service())->expandTemplateForWritePayload($templateKey);
} catch (\InvalidArgumentException $exception) {
$response->error($exception->getMessage(), 400);
}
}
private function normalizeOptionalString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$normalized = trim((string)$value);
return $normalized === '' ? null : $normalized;
}
private function resolveCustomerNames(array $customerNumbers): array
{
$customerNumbers = array_values(array_unique(array_filter(
array_map('intval', $customerNumbers),
static fn (int $customerNumber): bool => $customerNumber > 0
)));
if ($customerNumbers === []) {
return [];
}
return (new users_o())->getCustomerNames($customerNumbers, false);
}
private function resolveCustomerName(int $customerNumber, array $customerNames = []): ?string
{
if ($customerNumber <= 0) {
return null;
}
$key = (string)$customerNumber;
$name = $customerNames[$key] ?? null;
if (!is_string($name)) {
$name = $this->resolveCustomerNames([$customerNumber])[$key] ?? null;
}
$name = trim((string)$name);
return $name === '' || $name === 'Unknown Customer' ? null : $name;
}
private function hasDognvaskAccess(array $permissions, bool $grantEnabled): bool
{
if (!$grantEnabled) {
return false;
}
$permissions = subuser_grants_o::normalizePermissionsValue($permissions);
foreach (self::DOGNVASK_PERMISSION_KEYS as $permission) {
if (!in_array($permission, $permissions, true)) {
return false;
}
}
return true;
}
private function normalizeAssignedVehicleId(mixed $value): ?int
{
global $response;
if ($value === null || $value === '') {
return null;
}
if (is_int($value)) {
$id = $value;
} else {
$raw = trim((string)$value);
if ($raw === '') {
return null;
}
if (!preg_match('/^[1-9][0-9]*$/', $raw)) {
$response->error('Invalid assigned vehicle id', 400);
}
$id = (int)$raw;
}
if ($id <= 0) {
$response->error('Invalid assigned vehicle id', 400);
}
return $id;
}
private function assignedVehiclePayload(?int $vehicleId, ?int $customerNumber = null, bool $strict = false): ?array
{
global $response;
if ($vehicleId === null || $vehicleId <= 0) {
return null;
}
$vehicle = (new customer_vehicles_o())->select($vehicleId);
if (!$vehicle->exists()) {
if ($strict) {
$response->error('Assigned vehicle not found', 404);
}
return null;
}
$vehicle->getObjectProperties();
if ($this->customerVehiclesHaveDeletedAtColumn() && $vehicle->deleted_at->value() !== null) {
if ($strict) {
$response->error('Assigned vehicle not found', 404);
}
return null;
}
$vehicleCustomerNumber = (int)$vehicle->customer_id->value();
if ($customerNumber !== null && $vehicleCustomerNumber !== (int)$customerNumber) {
if ($strict) {
$response->error('Assigned vehicle must belong to the selected customer', 400);
}
return null;
}
$reg = strtoupper(trim((string)$vehicle->reg->value()));
return [
'id' => (int)$vehicle->id,
'reg' => $reg !== '' ? $reg : null,
];
}
private function customerVehiclesHaveDeletedAtColumn(): bool
{
return (new customer_vehicles_o())->columnsExist(['deleted_at']);
}
private function validateAssignedVehicleIdForCustomer(mixed $value, int $customerNumber): ?int
{
$vehicleId = $this->normalizeAssignedVehicleId($value);
if ($vehicleId === null) {
return null;
}
$this->assignedVehiclePayload($vehicleId, $customerNumber, true);
return $vehicleId;
}
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 frontendBaseUrl(): string
{
$frontendBaseUrl = trim((string)(
getenv('FRONTEND_URL')
?: getenv('APP_URL')
?: ($_SERVER['FRONTEND_URL'] ?? '')
?: ($_SERVER['APP_URL'] ?? '')
?: 'https://truckwash.io'
));
return rtrim($frontendBaseUrl !== '' ? $frontendBaseUrl : 'https://truckwash.io', '/');
}
private function buildSetupLink(string $token): string
{
return $this->frontendBaseUrl() . '/complete-registration?token=' . rawurlencode($token);
}
private function buildDirectSubuserLoginPath(string $sessionToken, int $customerNumber): string
{
return '/login/qr?token=' . rawurlencode($sessionToken)
. '&type=subuser&customer_number=' . rawurlencode((string)$customerNumber);
}
private function buildDirectSubuserLoginUrl(string $sessionToken, int $customerNumber): string
{
return $this->frontendBaseUrl() . $this->buildDirectSubuserLoginPath($sessionToken, $customerNumber);
}
private function createDirectSubuserLoginLinkPayload(subusers_o $subuser, string $logAction, string $logMessage): array
{
global $response;
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
$response->error('Chauffeur account is unavailable', 409);
}
$customerNumber = $this->resolveDirectLoginCustomerNumber($subuser);
try {
$sessionToken = $subuser->generateSession();
} catch (Exception $exception) {
$response->error($exception->getMessage(), 500);
}
$authUser = (new authentication())->get_user();
(new logs_o())->add(
'auth',
'global',
1,
$authUser !== false ? (int)$authUser->id : 0,
$logAction,
$logMessage . ' for subuser ' . (int)$subuser->id . ' and customer ' . $customerNumber
);
return [
'subuser_id' => (int)$subuser->id,
'customer_number' => $customerNumber,
'login_path' => $this->buildDirectSubuserLoginPath($sessionToken, $customerNumber),
'login_url' => $this->buildDirectSubuserLoginUrl($sessionToken, $customerNumber),
];
}
private function subuserContactDestination(subusers_o $subuser, string $channel): ?string
{
$status = $this->verificationService()->status($subuser);
$channelStatus = $status[$channel] ?? null;
if (!is_array($channelStatus) || !($channelStatus['available'] ?? false)) {
return null;
}
$value = trim((string)($channelStatus['value'] ?? ''));
return $value !== '' ? $value : null;
}
private function subuserLinkDelivery(string $channel, string $status, string $message): array
{
return [
'channel' => $channel,
'status' => $status,
'message' => $message,
];
}
private function deliverSubuserDirectLoginLink(subusers_o $subuser, string $channel, string $loginUrl, string $kind): array
{
$channel = $this->verificationService()->normalizeChannel($channel);
$destination = $this->subuserContactDestination($subuser, $channel);
if ($destination === null) {
return $this->subuserLinkDelivery($channel, 'missing_destination', 'Der er ingen kontaktoplysning at sende linket til.');
}
$isPasswordGuide = $kind === 'password_guide';
$recipientName = trim((string)($subuser->name->value() ?? ''));
$recipientName = $recipientName !== '' ? $recipientName : 'Chauffør';
try {
if ($channel === subuser_contact_verification_service::CHANNEL_PHONE) {
$gateway = new gatewayapi();
if (!$gateway->isEnabled()) {
return $this->subuserLinkDelivery($channel, 'unavailable', 'SMS-afsendelse er ikke konfigureret.');
}
$message = $isPasswordGuide
? 'Truck Wash: Brug dit personlige link til at logge ind og sætte en ny adgangskode: ' . $loginUrl
: 'Truck Wash: Dit personlige loginlink: ' . $loginUrl;
$gateway->send([$destination], $message);
} else {
$subject = $isPasswordGuide
? 'Guide til ny adgangskode hos Truck Wash'
: 'Loginlink til Truck Wash';
$message = $isPasswordGuide
? '<p>Du kan bruge dit personlige link til at logge ind og sætte en ny adgangskode.</p>'
: '<p>Du kan bruge dit personlige link til at logge ind på Truck Wash.</p>';
$message .= '<p><a href="' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '</a></p>';
(new email())->sendEmail($destination, $recipientName, $subject, $message);
}
} catch (Exception) {
return $this->subuserLinkDelivery($channel, 'failed', 'Linket kunne ikke sendes.');
}
return $this->subuserLinkDelivery(
$channel,
'sent',
$isPasswordGuide ? 'Guide til ny adgangskode er sendt.' : 'Loginlink er sendt.'
);
}
private function sendSuperuserSubuserDirectLoginLink(string $kind): void
{
global $response;
$this->requirePermission('edit_subusers');
$this->requirePermission('SUPERUSER_INTIMIDATE');
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
try {
$channel = $this->verificationService()->normalizeChannel((string)$this->fromRoute('channel'));
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
$isPasswordGuide = $kind === 'password_guide';
$linkPayload = $this->createDirectSubuserLoginLinkPayload(
$subuser,
$isPasswordGuide
? 'SUPERUSER_SUBUSER_PASSWORD_GUIDE_LINK'
: 'SUPERUSER_SUBUSER_LOGIN_LINK_SENT',
$isPasswordGuide
? 'Created chauffeur password guide link'
: 'Created chauffeur login link for delivery'
);
$delivery = $this->deliverSubuserDirectLoginLink($subuser, $channel, (string)$linkPayload['login_url'], $kind);
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
$response->success([
...$linkPayload,
'delivery' => $delivery,
'subuser' => $this->buildSubuserAccountPayload($subuser),
]);
}
private function loadSubuserOrFail(int $subuserId): subusers_o
{
global $response;
$subuser = (new subusers_o())->select($subuserId);
if (!$subuser->exists()) {
$response->error('Subuser not found', 404);
}
$subuser->getObjectProperties();
return $subuser;
}
private function buildSubuserAccountPayload(subusers_o $subuser): array
{
return $this->withVerificationPayload($subuser, [
'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,
'setup_required' => $subuser->requiresSetup(),
'updated_at' => $subuser->updated_at->value() ?? null,
]);
}
private function verificationService(): subuser_contact_verification_service
{
return new subuser_contact_verification_service();
}
private function withVerificationPayload(subusers_o $subuser, array $payload): array
{
$verification = $this->verificationService()->status($subuser);
return [
...$payload,
'email_verified' => (bool)($verification['email']['verified'] ?? false),
'email_verified_at' => $verification['email']['verified_at'] ?? null,
'phone_verified' => (bool)($verification['phone']['verified'] ?? false),
'phone_verified_at' => $verification['phone']['verified_at'] ?? null,
'verification_state' => $verification['state'],
'verification' => $verification,
];
}
private function verificationActorContext(string $type, ?int $id = null): array
{
return [
'type' => $type,
'id' => $id ?? 0,
];
}
private function sendContactVerificationCode(subusers_o $subuser, string $channel, array $actor): array
{
global $response;
try {
$delivery = $this->verificationService()->sendCode($subuser, $channel, $actor);
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
if (($delivery['status'] ?? null) === 'throttled') {
$response->error('Please wait before requesting another verification code.', 429);
}
return $delivery;
}
private function verifyContactVerificationCode(subusers_o $subuser, string $channel, string $code, array $actor): array
{
global $response;
try {
$result = $this->verificationService()->verifyCode($subuser, $channel, $code, $actor);
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
if (($result['status'] ?? null) !== 'verified') {
$response->error((string)($result['message'] ?? 'Invalid verification code.'), 400);
}
return $result;
}
private function parseSuperuserSubuserProfileUpdates(int $subuserId): array
{
global $response;
$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;
}
$hasPhoneCountryCode = self::isParametersSet(['phone_country_code']);
$hasPhone = self::isParametersSet(['phone']);
if ($hasPhoneCountryCode || $hasPhone) {
if (!$hasPhoneCountryCode || !$hasPhone) {
$response->error('Phone country code and phone must be provided together', 400);
}
$phoneCountryCode = trim((string)self::getParameter('phone_country_code'));
$phone = trim((string)self::getParameter('phone'));
if (!preg_match('/^[0-9]{1,3}$/', $phoneCountryCode)) {
$response->error('Phone country code must be 1-3 digits', 400);
}
if (!preg_match('/^[0-9]{4,15}$/', $phone)) {
$response->error('Phone must be 4-15 digits', 400);
}
$updates['phone_country_code'] = (int)$phoneCountryCode;
$updates['phone'] = (int)$phone;
}
if ($updates === []) {
$response->error('No fields to update', 400);
}
$this->assertSubuserIdentifiersAvailable(
$updates['phone_country_code'] ?? null,
$updates['phone'] ?? null,
$updates['username'] ?? null,
$updates['email'] ?? null,
$subuserId
);
return $updates;
}
private function getEnabledGrantForSubuserAndCustomerOrFail(int $subuserId, int $customerNumber): subuser_grants_o
{
global $response;
if ($customerNumber <= 0) {
$response->error('Customer number is required', 400);
}
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true);
if ($grant === null || !(bool)$grant->enabled->value() || $grant->deleted_at->value() !== null) {
$response->error('Enabled subuser grant not found for selected customer', 404);
}
return $grant;
}
private function resolveDirectLoginCustomerNumber(subusers_o $subuser): int
{
global $response;
if (self::isParametersSet(['grant_id'])) {
$grant = (new subuser_grants_o())->select((int)self::getParameter('grant_id'));
if ($grant->exists()) {
$grant->getObjectProperties();
}
if (
!$grant->exists()
|| (int)$grant->subuser->value() !== (int)$subuser->id
|| !(bool)$grant->enabled->value()
|| $grant->deleted_at->value() !== null
) {
$response->error('Enabled subuser grant not found', 404);
}
return (int)$grant->billing_customer_number->value();
}
if (self::isParametersSet(['customer_number'])) {
$customerNumber = (int)self::getParameter('customer_number');
$this->getEnabledGrantForSubuserAndCustomerOrFail((int)$subuser->id, $customerNumber);
return $customerNumber;
}
$grants = (new subuser_grants_o())->getFieldsWhere([
'subuser' => (int)$subuser->id,
'enabled' => 1,
'deleted_at' => null,
], ['billing_customer_number']);
$customerNumbers = array_values(array_unique(array_map(
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
$grants
)));
$customerNumbers = array_values(array_filter($customerNumbers, static fn (int $customerNumber): bool => $customerNumber > 0));
if (count($customerNumbers) !== 1) {
$response->error('Customer number or grant id is required for drivers with multiple customer grants', 400);
}
return $customerNumbers[0];
}
private function clientThrottleIp(): string
{
$remoteAddress = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
return $remoteAddress !== '' ? $remoteAddress : 'unknown';
}
private function recordThrottleAttempt(
string $scope,
string $identifier,
int $limit,
int $windowSeconds,
bool $includeClientIp = true
): ?string {
global $response;
if (!defined('redis')) {
if (PHP_SAPI === 'cli') {
return null;
}
$response->error('Request protection is temporarily unavailable.', 503);
}
$safeScope = preg_replace('/[^a-z0-9:_-]/i', '_', $scope);
$key = 'subusers_route_throttle:' . $safeScope . ':' . hash(
'sha256',
($includeClientIp ? $this->clientThrottleIp() . ':' : '') . $identifier
);
try {
$attempts = constant('redis')->incrementWithExpiration($key, $windowSeconds);
} catch (\Throwable $exception) {
error_log('[subusers] throttle unavailable: ' . $exception->getMessage());
$response->error('Request protection is temporarily unavailable.', 503);
}
if ($attempts > $limit) {
$response->error('Too many attempts. Please wait and try again.', 429);
}
return $key;
}
private function clearThrottleAttempt(?string $key): void
{
if ($key === null || !defined('redis')) {
return;
}
constant('redis')->delete($key);
}
private function subuserAuthFailure(): void
{
global $response;
$response->error('Invalid credentials', 401);
}
private function rejectBlockedSubuser(int $subuserId): void
{
global $response;
if (account_deletion_service::principalIsBlocked('subuser', $subuserId)) {
$response->error('Subuser account is unavailable', 409);
}
}
private function issueSetupInvite(subusers_o $subuser): array
{
$this->rejectBlockedSubuser((int)$subuser->id);
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' => 'Invite delivery failed.',
];
}
return [
'setup_token' => $token,
'setup_link' => $link,
'delivery' => $delivery,
];
}
private function publicRegistrationPendingKey(string $setupToken): string
{
return 'subuser_public_registration_pending:' . hash('sha256', $setupToken);
}
private function storePublicRegistrationPending(string $setupToken, int $customerNumber): void
{
global $response;
if (!defined('redis')) {
$response->error('Driver registration is temporarily unavailable.', 503);
}
constant('redis')->setEx(
$this->publicRegistrationPendingKey($setupToken),
json_encode(['customer_number' => $customerNumber], JSON_THROW_ON_ERROR),
48 * 60 * 60
);
}
private function getPublicRegistrationPending(string $setupToken): ?array
{
if (!defined('redis')) {
return null;
}
$raw = constant('redis')->get($this->publicRegistrationPendingKey($setupToken));
if (!is_string($raw) || $raw === '') {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
private function clearPublicRegistrationPending(string $setupToken): void
{
if (defined('redis')) {
constant('redis')->delete($this->publicRegistrationPendingKey($setupToken));
}
}
private function deliverSms(?string $destination, string $message): array
{
if ($destination === null || trim($destination) === '') {
return $this->subuserLinkDelivery('sms', 'missing_destination', 'SMS-modtager mangler.');
}
try {
$gateway = new gatewayapi();
if (!$gateway->isEnabled()) {
return $this->subuserLinkDelivery('sms', 'unavailable', 'SMS-afsendelse er ikke konfigureret.');
}
$gateway->send([$destination], $message);
return $this->subuserLinkDelivery('sms', 'sent', 'SMS er sendt.');
} catch (Exception) {
return $this->subuserLinkDelivery('sms', 'failed', 'SMS kunne ikke sendes.');
}
}
private function subuserPhoneDestination(subusers_o $subuser): ?string
{
$countryCode = trim((string)($subuser->phone_country_code->value() ?? ''));
$phone = trim((string)($subuser->phone->value() ?? ''));
return $countryCode !== '' && $phone !== '' ? $countryCode . $phone : null;
}
private function notifySubuserGrantDecision(subusers_o $subuser, int $customerNumber, bool $approved): array
{
$customerName = $this->resolveCustomerName($customerNumber) ?: ('kunde #' . $customerNumber);
$message = $approved
? 'Truck Wash: Din adgang til ' . $customerName . ' er godkendt.'
: 'Truck Wash: Din anmodning om adgang til ' . $customerName . ' er afvist eller deaktiveret.';
return $this->deliverSms($this->subuserPhoneDestination($subuser), $message);
}
private function notifyCustomerOfGrantRequest(
subuser_grants_o $grant,
subusers_o $subuser,
int $customerNumber
): array {
$customer = (new users_o())->getUserByCustomerNumber($customerNumber);
if (!$customer->exists()) {
return $this->subuserLinkDelivery('sms', 'missing_destination', 'Kundekontoen blev ikke fundet.');
}
$customer->getObjectProperties();
$countryCode = trim((string)($customer->phone_country_code->value() ?? ''));
$phone = trim((string)($customer->phone->value() ?? ''));
$destination = $countryCode !== '' && $phone !== '' ? $countryCode . $phone : null;
$tokens = new subuser_action_token_service();
$approveToken = $tokens->issue(
subuser_action_token_service::PURPOSE_GRANT_APPROVE,
(int)$subuser->id,
(int)$grant->id,
$customerNumber
);
$denyToken = $tokens->issue(
subuser_action_token_service::PURPOSE_GRANT_DENY,
(int)$subuser->id,
(int)$grant->id,
$customerNumber
);
$name = trim((string)($subuser->name->value() ?? '')) ?: 'En chauffør';
$base = $this->frontendBaseUrl() . '/subuser-access/decision?token=';
$message = 'Truck Wash: ' . $name . ' anmoder om adgang. '
. 'Godkend: ' . $base . rawurlencode($approveToken) . ' '
. 'Afvis: ' . $base . rawurlencode($denyToken);
return $this->deliverSms($destination, $message);
}
private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber, ?string $customerName = null): array
{
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
$grantPermissions = $grant ? subuser_grants_o::normalizePermissionsValue($grant->permissions->value()) : [];
$templateService = new subuser_permission_templates_service();
$setupRequired = $subuser->requiresSetup();
$grantEnabled = $grant ? (bool)$grant->enabled->value() : false;
$inviteAccepted = !$setupRequired;
$assignedVehicleId = $grant && $grant->assigned_vehicle_id->value() !== null
? (int)$grant->assigned_vehicle_id->value()
: null;
$assignedVehicle = $this->assignedVehiclePayload($assignedVehicleId, $customerNumber);
$accessState = 'inactive';
if ($grant !== null && $grantEnabled) {
$accessState = $setupRequired ? 'pending_setup' : 'active';
} elseif ($grant !== null) {
$accessState = 'disabled';
}
return $this->withVerificationPayload($subuser, [
'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,
'customer_number' => $customerNumber,
'customer_name' => $customerName ?? $this->resolveCustomerName($customerNumber),
'grant_id' => $grant ? (int)$grant->id : null,
'grant_enabled' => $grantEnabled,
'grant_note' => $grant ? $grant->note->value() : null,
'assigned_vehicle_id' => $assignedVehicle['id'] ?? null,
'assigned_vehicle_reg' => $assignedVehicle['reg'] ?? null,
'assigned_vehicle' => $assignedVehicle,
'dognvask_enabled' => $this->hasDognvaskAccess($grantPermissions, $grantEnabled),
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
'permission_groups' => $templateService->permissionGroups($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,
], ['id', 'permissions', 'billing_customer_number']);
$customerNames = $this->resolveCustomerNames(array_map(
static fn (array $grant): int => (int)($grant['billing_customer_number'] ?? 0),
$grants
));
return $this->withVerificationPayload($subuser, [
'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) use ($customerNames) {
$customerNumber = (int)$grant['billing_customer_number'];
return [
'grant_id' => isset($grant['id']) ? (int)$grant['id'] : null,
'name' => $this->resolveCustomerName($customerNumber, $customerNames),
'billing_customer_number' => $customerNumber,
'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(),
]);
}
private function parseSuperuserPaginationRequest(): array
{
global $response;
$page = max(1, (int)($response->getRequestParameter('page') ?: 1));
$limitRaw = $response->getRequestParameter('limit');
$limit = is_string($limitRaw) && strtolower($limitRaw) === 'all'
? 1000
: (int)($limitRaw ?: 100);
$limit = max(1, min($limit, 1000));
$search = $this->normalizeOptionalString($response->getRequestParameter('search'));
$orderRaw = (string)($response->getRequestParameter('order') ?: 'created_at:DESC');
$orderParts = explode(':', $orderRaw, 2);
$orderField = $orderParts[0] ?? 'created_at';
$orderDirection = strtoupper($orderParts[1] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
$allowedOrderFields = [
'id' => 's.`id`',
'created_at' => 's.`created_at`',
'updated_at' => 'row_updated_at',
'customer_number' => 'sort_customer_number',
'grant_id' => 'sort_grant_id',
'name' => 's.`name`',
];
if (!isset($allowedOrderFields[$orderField])) {
$orderField = 'created_at';
}
return [
'page' => $page,
'limit' => $limit,
'search' => $search,
'order_field' => $orderField,
'order_sql' => $allowedOrderFields[$orderField],
'order_direction' => $orderDirection,
];
}
private function bindStatementParameters(\mysqli_stmt $statement, string $types, array $params): void
{
if ($params === []) {
return;
}
$refs = [];
foreach ($params as $key => $value) {
$refs[$key] = &$params[$key];
}
$statement->bind_param($types, ...$refs);
}
private function buildSuperuserGrantPayload(array $row, bool $setupRequired): array
{
$grantPermissions = subuser_grants_o::normalizePermissionsValue($row['grant_permissions'] ?? null);
$templateService = new subuser_permission_templates_service();
$grantEnabled = (bool)((int)($row['grant_enabled'] ?? 0));
$assignedVehicleId = isset($row['assigned_vehicle_id']) && $row['assigned_vehicle_id'] !== null
? (int)$row['assigned_vehicle_id']
: null;
$assignedVehicleReg = isset($row['assigned_vehicle_reg']) && trim((string)$row['assigned_vehicle_reg']) !== ''
? strtoupper(trim((string)$row['assigned_vehicle_reg']))
: null;
$assignedVehicle = $assignedVehicleId !== null
? ['id' => $assignedVehicleId, 'reg' => $assignedVehicleReg]
: null;
$accessState = 'inactive';
if (!empty($row['grant_id']) && $grantEnabled) {
$accessState = $setupRequired ? 'pending_setup' : 'active';
} elseif (!empty($row['grant_id'])) {
$accessState = 'disabled';
}
return [
'grant_id' => (int)$row['grant_id'],
'customer_number' => (int)$row['customer_number'],
'customer_name' => $row['customer_name'] ?: null,
'grant_enabled' => $grantEnabled,
'grant_note' => $row['grant_note'] ?? null,
'assigned_vehicle_id' => $assignedVehicleId,
'assigned_vehicle_reg' => $assignedVehicleReg,
'assigned_vehicle' => $assignedVehicle,
'dognvask_enabled' => $this->hasDognvaskAccess($grantPermissions, $grantEnabled),
'grant_permissions' => $grantPermissions,
'permissions' => $grantPermissions,
'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled),
'permission_groups' => $templateService->permissionGroups($grantPermissions),
'grant_created_at' => $row['grant_created_at'] ?? null,
'grant_updated_at' => $row['grant_updated_at'] ?? null,
'access_state' => $accessState,
];
}
private function buildSuperuserSubuserManagementPayload(array $row, array $grantRows = []): array
{
$setupRequired = !is_string($row['password'] ?? null) || trim((string)$row['password']) === '';
if ($grantRows === [] && !empty($row['grant_id'])) {
$grantRows = [$row];
}
$grants = array_map(
fn (array $grantRow): array => $this->buildSuperuserGrantPayload($grantRow, $setupRequired),
$this->dedupeSuperuserGrantRows($grantRows)
);
$primaryGrant = $this->selectPrimarySuperuserGrant($grants);
$accessState = 'inactive';
if (array_filter($grants, static fn (array $grant): bool => $grant['access_state'] === 'active') !== []) {
$accessState = 'active';
} elseif (array_filter($grants, static fn (array $grant): bool => $grant['access_state'] === 'pending_setup') !== []) {
$accessState = 'pending_setup';
} elseif ($grants !== []) {
$accessState = 'disabled';
}
return $this->withVerificationPayload($this->subuserFromSuperuserRow($row), [
'id' => (int)$row['id'],
'username' => $row['username'] ?? null,
'name' => $row['name'] ?? null,
'email' => $row['email'] ?? null,
'phone_country_code' => $row['phone_country_code'] !== null ? (int)$row['phone_country_code'] : null,
'phone' => $row['phone'] !== null ? (int)$row['phone'] : null,
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['row_updated_at'] ?? $row['updated_at'] ?? null,
'suspended_at' => $row['suspended_at'] ?? null,
'two_factor_enabled' => (bool)((int)($row['two_factor_enabled'] ?? 0)),
'setup_required' => $setupRequired,
'invite_accepted' => !$setupRequired,
'can_resend_invite' => $setupRequired,
'profile_editable_by_manager' => false,
'customer_number' => $primaryGrant['customer_number'] ?? null,
'customer_name' => $primaryGrant['customer_name'] ?? null,
'grant_id' => $primaryGrant['grant_id'] ?? null,
'grant_enabled' => $primaryGrant['grant_enabled'] ?? false,
'grant_note' => $primaryGrant['grant_note'] ?? null,
'assigned_vehicle_id' => $primaryGrant['assigned_vehicle_id'] ?? null,
'assigned_vehicle_reg' => $primaryGrant['assigned_vehicle_reg'] ?? null,
'assigned_vehicle' => $primaryGrant['assigned_vehicle'] ?? null,
'dognvask_enabled' => $primaryGrant['dognvask_enabled'] ?? false,
'grant_permissions' => $primaryGrant['grant_permissions'] ?? [],
'permissions' => $primaryGrant['permissions'] ?? [],
'permission_template_key' => $primaryGrant['permission_template_key'] ?? subuser_permission_templates_service::TEMPLATE_DEACTIVATED,
'permission_groups' => $primaryGrant['permission_groups'] ?? [],
'grant_created_at' => $primaryGrant['grant_created_at'] ?? null,
'grant_updated_at' => $primaryGrant['grant_updated_at'] ?? null,
'grants' => $grants,
'grant_count' => count($grants),
'customer_numbers' => array_values(array_unique(array_map(
static fn (array $grant): int => (int)$grant['customer_number'],
$grants
))),
'access_state' => $accessState,
]);
}
private function subuserFromSuperuserRow(array $row): subusers_o
{
$subuser = (new subusers_o())->select((int)$row['id']);
$subuser->getObjectProperties();
return $subuser;
}
private function dedupeSuperuserGrantRows(array $grantRows): array
{
$byCustomer = [];
foreach ($grantRows as $grantRow) {
$customerNumber = (int)($grantRow['customer_number'] ?? 0);
if ($customerNumber <= 0) {
continue;
}
$existing = $byCustomer[$customerNumber] ?? null;
if ($existing === null || $this->compareSuperuserGrantRows($grantRow, $existing) < 0) {
$byCustomer[$customerNumber] = $grantRow;
}
}
$deduped = array_values($byCustomer);
usort($deduped, fn (array $left, array $right): int => $this->compareSuperuserGrantRows($left, $right));
return $deduped;
}
private function compareSuperuserGrantRows(array $left, array $right): int
{
$leftEnabled = (int)($left['grant_enabled'] ?? 0);
$rightEnabled = (int)($right['grant_enabled'] ?? 0);
if ($leftEnabled !== $rightEnabled) {
return $rightEnabled <=> $leftEnabled;
}
$leftCustomer = (int)($left['customer_number'] ?? 0);
$rightCustomer = (int)($right['customer_number'] ?? 0);
if ($leftCustomer !== $rightCustomer) {
return $leftCustomer <=> $rightCustomer;
}
$leftUpdated = strtotime((string)($left['grant_updated_at'] ?? $left['grant_created_at'] ?? '')) ?: 0;
$rightUpdated = strtotime((string)($right['grant_updated_at'] ?? $right['grant_created_at'] ?? '')) ?: 0;
if ($leftUpdated !== $rightUpdated) {
return $rightUpdated <=> $leftUpdated;
}
return (int)($right['grant_id'] ?? 0) <=> (int)($left['grant_id'] ?? 0);
}
private function selectPrimarySuperuserGrant(array $grants): ?array
{
if ($grants === []) {
return null;
}
$sorted = $grants;
usort($sorted, static function (array $left, array $right): int {
$leftUpdated = strtotime((string)($left['grant_updated_at'] ?? $left['grant_created_at'] ?? '')) ?: 0;
$rightUpdated = strtotime((string)($right['grant_updated_at'] ?? $right['grant_created_at'] ?? '')) ?: 0;
if ($leftUpdated !== $rightUpdated) {
return $rightUpdated <=> $leftUpdated;
}
$leftCreated = strtotime((string)($left['grant_created_at'] ?? '')) ?: 0;
$rightCreated = strtotime((string)($right['grant_created_at'] ?? '')) ?: 0;
if ($leftCreated !== $rightCreated) {
return $rightCreated <=> $leftCreated;
}
return (int)($right['grant_id'] ?? 0) <=> (int)($left['grant_id'] ?? 0);
});
return $sorted[0];
}
private function routePositiveInt(string $name): int
{
global $response;
$raw = $this->fromRoute($name);
if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) {
$response->error('Invalid route parameter', 400);
}
return (int)$raw;
}
private function resolveSuperuserSubuserTargetUser(int $userId): array
{
global $response;
$targetUser = (new users_o())->select($userId);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
$targetUser->getObjectProperties();
$customerNumber = (int)$targetUser->customer_number->value();
if ($customerNumber <= 0) {
$response->error('Selected user does not have a customer number', 400);
}
$customerName = $targetUser->display_name->value();
if (!is_string($customerName) || trim($customerName) === '') {
$customerName = $this->resolveCustomerName($customerNumber);
}
return [
'user_id' => (int)$targetUser->id,
'customer_number' => $customerNumber,
'customer_name' => $customerName,
];
}
private function buildSubuserSummaryForCustomer(int $customerNumber): array
{
global $db;
$statement = $db->conn->prepare("
SELECT
COUNT(*) AS `total`,
SUM(CASE WHEN g.`enabled` = 1 AND COALESCE(s.`password`, '') <> '' THEN 1 ELSE 0 END) AS `active`,
SUM(CASE WHEN g.`enabled` = 1 AND COALESCE(s.`password`, '') = '' THEN 1 ELSE 0 END) AS `pending_setup`,
SUM(CASE WHEN g.`enabled` = 0 THEN 1 ELSE 0 END) AS `disabled`
FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
WHERE g.`deleted_at` IS NULL
AND g.`billing_customer_number` = ?
");
if ($statement === false) {
throw new Exception('Failed to prepare subuser summary query: ' . $db->conn->error);
}
$statement->bind_param('i', $customerNumber);
$statement->execute();
$result = $statement->get_result();
$row = $result->fetch_assoc() ?: [];
$statement->close();
return [
'total' => (int)($row['total'] ?? 0),
'active' => (int)($row['active'] ?? 0),
'pending_setup' => (int)($row['pending_setup'] ?? 0),
'disabled' => (int)($row['disabled'] ?? 0),
];
}
private function addUserScopedSubuserMeta(array $targetUser): void
{
global $response;
$response->add_meta('user_context', $targetUser);
$response->add_meta('subusers_summary', $this->buildSubuserSummaryForCustomer((int)$targetUser['customer_number']));
}
private function listSuperuserSubusers(?int $customerNumber = null): array
{
global $db, $response;
$pagination = $this->parseSuperuserPaginationRequest();
$offset = ((int)$pagination['page'] - 1) * (int)$pagination['limit'];
$where = ['g.`deleted_at` IS NULL'];
$params = [];
$types = '';
$includeNonEnabled = true;
if (self::isParametersSet(['include_non_enabled'])) {
$tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$includeNonEnabled = $tmp === null ? true : (bool)$tmp;
}
if (!$includeNonEnabled) {
$where[] = 'g.`enabled` = 1';
}
if ($customerNumber !== null) {
$where[] = 'g.`billing_customer_number` = ?';
$params[] = $customerNumber;
$types .= 'i';
}
if ($pagination['search'] !== null) {
$where[] = "(
CAST(s.`id` AS CHAR) LIKE ?
OR s.`username` LIKE ?
OR s.`name` LIKE ?
OR s.`email` LIKE ?
OR CAST(s.`phone_country_code` AS CHAR) LIKE ?
OR CAST(s.`phone` AS CHAR) LIKE ?
OR cv.`reg` LIKE ?
OR CAST(g.`billing_customer_number` AS CHAR) LIKE ?
OR g.`note` LIKE ?
OR EXISTS (
SELECT 1
FROM `users` search_u
WHERE search_u.`customer_number` = g.`billing_customer_number`
AND search_u.`display_name` LIKE ?
)
)";
$search = '%' . $pagination['search'] . '%';
for ($i = 0; $i < 10; $i++) {
$params[] = $search;
$types .= 's';
}
}
$whereSql = 'WHERE ' . implode(' AND ', $where);
$vehicleDeletedAtJoinSql = $this->customerVehiclesHaveDeletedAtColumn()
? ' AND cv.`deleted_at` IS NULL'
: '';
$fromSql = "
FROM `subuser_grants` g
INNER JOIN `subusers` s ON s.`id` = g.`subuser`
LEFT JOIN `customer_vehicles` cv ON cv.`id` = g.`assigned_vehicle_id`{$vehicleDeletedAtJoinSql}
";
$countSql = "SELECT COUNT(DISTINCT s.`id`) AS `count` $fromSql $whereSql";
$countStatement = $db->conn->prepare($countSql);
if ($countStatement === false) {
throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error);
}
$this->bindStatementParameters($countStatement, $types, $params);
$countStatement->execute();
$countResult = $countStatement->get_result();
$total = (int)($countResult->fetch_assoc()['count'] ?? 0);
$countStatement->close();
$pageSql = "
SELECT
s.`id`,
s.`username`,
s.`password`,
s.`name`,
s.`email`,
s.`phone_country_code`,
s.`phone`,
s.`two_factor_enabled`,
s.`created_at`,
s.`updated_at`,
s.`suspended_at`,
MAX(COALESCE(g.`updated_at`, s.`updated_at`)) AS `row_updated_at`,
MIN(g.`billing_customer_number`) AS `sort_customer_number`,
MAX(g.`id`) AS `sort_grant_id`
$fromSql
$whereSql
GROUP BY
s.`id`,
s.`username`,
s.`password`,
s.`name`,
s.`email`,
s.`phone_country_code`,
s.`phone`,
s.`two_factor_enabled`,
s.`created_at`,
s.`updated_at`,
s.`suspended_at`
ORDER BY {$pagination['order_sql']} {$pagination['order_direction']}
LIMIT ? OFFSET ?
";
$pageStatement = $db->conn->prepare($pageSql);
if ($pageStatement === false) {
throw new Exception('Failed to prepare subuser list query: ' . $db->conn->error);
}
$pageParams = [...$params, (int)$pagination['limit'], $offset];
$this->bindStatementParameters($pageStatement, $types . 'ii', $pageParams);
$pageStatement->execute();
$result = $pageStatement->get_result();
$rows = $result->fetch_all(MYSQLI_ASSOC);
$pageStatement->close();
$subuserIds = array_values(array_map(
static fn (array $row): int => (int)($row['id'] ?? 0),
$rows
));
if ($subuserIds === []) {
$response->paginate(
(int)$pagination['page'],
(int)$pagination['limit'],
$total,
$pagination['search'],
null,
[$pagination['order_field'] => $pagination['order_direction']]
);
return [];
}
$grantWhere = [
'g.`deleted_at` IS NULL',
'g.`subuser` IN (' . implode(', ', array_fill(0, count($subuserIds), '?')) . ')',
];
$grantParams = $subuserIds;
$grantTypes = str_repeat('i', count($subuserIds));
if (!$includeNonEnabled) {
$grantWhere[] = 'g.`enabled` = 1';
}
if ($customerNumber !== null) {
$grantWhere[] = 'g.`billing_customer_number` = ?';
$grantParams[] = $customerNumber;
$grantTypes .= 'i';
}
$grantSql = "
SELECT
g.`subuser` AS `subuser_id`,
g.`id` AS `grant_id`,
g.`billing_customer_number` AS `customer_number`,
g.`enabled` AS `grant_enabled`,
g.`note` AS `grant_note`,
g.`permissions` AS `grant_permissions`,
g.`assigned_vehicle_id`,
cv.`reg` AS `assigned_vehicle_reg`,
g.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`
FROM `subuser_grants` g
LEFT JOIN `customer_vehicles` cv ON cv.`id` = g.`assigned_vehicle_id`{$vehicleDeletedAtJoinSql}
WHERE " . implode(' AND ', $grantWhere) . "
ORDER BY g.`enabled` DESC, g.`billing_customer_number` ASC, g.`id` DESC
";
$grantStatement = $db->conn->prepare($grantSql);
if ($grantStatement === false) {
throw new Exception('Failed to prepare subuser grant list query: ' . $db->conn->error);
}
$this->bindStatementParameters($grantStatement, $grantTypes, $grantParams);
$grantStatement->execute();
$grantResult = $grantStatement->get_result();
$grantRows = $grantResult->fetch_all(MYSQLI_ASSOC);
$grantStatement->close();
$customerNames = $this->resolveCustomerNames(array_map(
static fn (array $row): int => (int)($row['customer_number'] ?? 0),
$grantRows
));
$grantRows = array_map(function (array $row) use ($customerNames): array {
$customerNumberForGrant = (int)($row['customer_number'] ?? 0);
$row['customer_name'] = $this->resolveCustomerName($customerNumberForGrant, $customerNames);
return $row;
}, $grantRows);
$grantRowsBySubuser = [];
foreach ($grantRows as $grantRow) {
$subuserId = (int)($grantRow['subuser_id'] ?? 0);
if ($subuserId <= 0) {
continue;
}
$grantRowsBySubuser[$subuserId] ??= [];
$grantRowsBySubuser[$subuserId][] = $grantRow;
}
$response->paginate(
(int)$pagination['page'],
(int)$pagination['limit'],
$total,
$pagination['search'],
null,
[$pagination['order_field'] => $pagination['order_direction']]
);
return array_map(
fn(array $row): array => $this->buildSuperuserSubuserManagementPayload(
$row,
$grantRowsBySubuser[(int)($row['id'] ?? 0)] ?? []
),
$rows
);
}
private function getGrantForScopedUserOrFail(int $grantId, int $customerNumber): subuser_grants_o
{
global $response;
$grant = (new subuser_grants_o())->select($grantId);
if (!$grant->exists()) {
$response->error('Subuser grant not found', 404);
}
$grant->getObjectProperties();
if ((int)$grant->billing_customer_number->value() !== $customerNumber || $grant->deleted_at->value() !== null) {
$response->error('Subuser grant not found for selected user', 404);
}
return $grant;
}
private function patchScopedSubuserGrant(int $grantId, int $customerNumber): void
{
global $response;
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
$wasEnabled = (bool)$grant->enabled->value();
$this->rejectBlockedSubuser((int)$grant->subuser->value());
$updates = [];
$templateAccess = $this->parseAccessTemplatePayload();
if ($templateAccess !== null) {
$updates['enabled'] = $templateAccess['enabled'];
$updates['permissions'] = $templateAccess['permissions'];
}
if (self::isParametersSet(['enabled'])) {
$tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($tmp === null) {
$response->error('Invalid enabled value', 400);
}
$updates['enabled'] = (bool)$tmp;
}
if (self::isParametersSet(['note'])) {
$note = $this->normalizeOptionalString(self::getParameter('note'));
if ($note !== null && strlen($note) > 65535) {
$response->error('Note must be at most 65535 characters long', 400);
}
$updates['note'] = $note;
}
if (self::isParametersSet(['permissions'])) {
$updates['permissions'] = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
}
if (self::isParametersSet(['assigned_vehicle_id'])) {
$updates['assigned_vehicle_id'] = $this->validateAssignedVehicleIdForCustomer(
self::getParameter('assigned_vehicle_id'),
$customerNumber
);
}
if ($updates === []) {
$response->error('No fields to update', 400);
}
try {
$grant->update($updates);
} catch (Exception $exception) {
$response->error('Failed to update subuser grant', 500);
}
$updatedGrant = (new subuser_grants_o())->select($grantId);
$updatedGrant->getObjectProperties();
$subuser = (new subusers_o())->select((int)$updatedGrant->subuser->value());
$subuser->getObjectProperties();
$delivery = null;
$isEnabled = (bool)$updatedGrant->enabled->value();
if ($isEnabled !== $wasEnabled) {
(new subuser_action_token_service())->revokeGrantDecisions($grantId);
$delivery = $this->notifySubuserGrantDecision($subuser, $customerNumber, $isEnabled);
}
$response->success([
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
'grant' => $updatedGrant->asArray(),
'decision_notification' => $delivery,
]);
}
private function resendInviteForScopedUser(int $subuserId, int $customerNumber): void
{
global $response;
$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 user', 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,
]);
}
private function handleInviteSubuserForCustomer(int $customerNumber): void
{
global $response;
if ($customerNumber <= 0) {
$response->error('Customer number is required', 400);
}
if (self::isParametersSet(['customer_number']) && (int)self::getParameter('customer_number') !== $customerNumber) {
$response->error('Customer number does not match selected user', 400);
}
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;
$templateAccess = $this->parseAccessTemplatePayload();
$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 ($templateAccess !== null) {
$enabled = $templateAccess['enabled'];
$permissions = $templateAccess['permissions'];
}
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,
]);
}
private function registerPublicSubuser(): void
{
global $db, $response;
$this->requireRecaptcha();
self::requireParameters(['cvr', 'phone_country_code', 'phone']);
$cvr = (int)self::getParameter('cvr');
$phoneCountryCode = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
self::requireType($cvr, self::type_int());
self::requireMinLength('cvr', 8);
self::requireMaxLength('cvr', 8);
self::requireType($phoneCountryCode, 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);
$this->recordThrottleAttempt(
'public_registration_ip',
'all',
5,
15 * 60
);
$this->recordThrottleAttempt(
'public_registration_identity',
'cvr:' . $cvr . ':phone:' . $phoneCountryCode . ':' . $phone,
3,
60 * 60,
false
);
$results = (new economic())->customers->customers->search([
'corporateIdentificationNumber' => (string)$cvr,
], [
'skipPages' => 0,
'pageSize' => 1000,
])->collection;
if (!is_array($results) || count($results) === 0) {
$response->error('Customer not found', 404);
}
$customerNumber = (int)($results[0]->customerNumber ?? 0);
if ($customerNumber <= 0) {
$response->error('Customer not found', 404);
}
$companyUser = (new users_o())->getUserByCustomerNumber($customerNumber);
$companyUser->requireSelected();
$lockName = 'subuser-public-registration:' . hash(
'sha256',
$phoneCountryCode . ':' . $phone
);
$lockStatement = $db->conn->prepare('SELECT GET_LOCK(?, 5) AS `acquired`');
if ($lockStatement === false) {
$response->error('Unable to start driver registration', 503);
}
$lockStatement->bind_param('s', $lockName);
$lockStatement->execute();
$lockRow = $lockStatement->get_result()->fetch_assoc() ?: [];
$lockStatement->close();
if ((int)($lockRow['acquired'] ?? 0) !== 1) {
$response->error('Driver registration is already being processed. Please try again.', 409);
}
$subuser = null;
try {
$db->conn->begin_transaction();
$subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone);
if ($subuser !== null) {
$this->rejectBlockedSubuser((int)$subuser->id);
} else {
$subuser = (new subusers_o())->add(
null,
null,
null,
null,
$phoneCountryCode,
$phone
);
}
$db->conn->commit();
} catch (\Throwable $exception) {
$db->conn->rollback();
error_log('[subusers] public registration failed: ' . $exception->getMessage());
$response->error('Failed to create driver registration', 500);
} finally {
$releaseStatement = $db->conn->prepare('SELECT RELEASE_LOCK(?)');
if ($releaseStatement !== false) {
$releaseStatement->bind_param('s', $lockName);
$releaseStatement->execute();
$releaseStatement->close();
}
}
if (!$subuser instanceof subusers_o) {
$response->error('Failed to create driver registration', 500);
}
if ($subuser->requiresSetup()) {
$issuedInvite = $this->issueSetupInvite($subuser);
if (($issuedInvite['delivery']['status'] ?? null) !== 'sent') {
$response->error('Driver registration was saved, but the setup SMS could not be sent. Please try again.', 503);
}
$setupToken = (string)($issuedInvite['setup_token'] ?? '');
if ($setupToken === '') {
$response->error('Driver registration is temporarily unavailable.', 503);
}
$this->storePublicRegistrationPending($setupToken, $customerNumber);
}
$response->success([
'message' => 'If the driver can be registered, setup instructions have been sent.',
]);
}
public function run(): void
{
subusers_schema_bootstrap::ensureTables();
// =============================
// Subuser Grant Management
// =============================
$this->get('/subusers/grants', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/subusers/grants');
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 = $this->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,
'assigned_vehicle_id' => isset($o->assigned_vehicle_id) && $o->assigned_vehicle_id !== null ? (int)$o->assigned_vehicle_id : null,
'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 () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/grants');
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());
$this->rejectBlockedSubuser($subuser_id);
if (!$this->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);
}
$templateAccess = $this->parseAccessTemplatePayload();
$permissions = self::isParametersSet(['permissions'])
? $this->parsePermissionsPayload(self::getParameter('permissions'), [])
: null;
if ($templateAccess !== null) {
$enabled = $templateAccess['enabled'];
$permissions = $templateAccess['permissions'];
}
$assignedVehicleId = null;
if (self::isParametersSet(['assigned_vehicle_id'])) {
$assignedVehicleId = $this->validateAssignedVehicleIdForCustomer(
self::getParameter('assigned_vehicle_id'),
$customer_number
);
}
try {
$grant = (new subuser_grants_o())->add($customer_number, $subuser_id, (bool)$enabled, $note, $permissions);
if ($assignedVehicleId !== null) {
$grant->assigned_vehicle_id->set($assignedVehicleId);
}
$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 () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/grants');
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);
}
$wasEnabled = (bool)$grant->enabled->value();
$this->rejectBlockedSubuser((int)$grant->subuser->value());
$targetCustomer = (int)$grant->billing_customer_number->value();
if (!$this->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 && !$this->hasPermission($permission_other)) {
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_DELETE, $targetCustomer);
}
}
$templateAccess = $this->parseAccessTemplatePayload();
if ($templateAccess !== null) {
$grant->enabled->set($templateAccess['enabled']);
$grant->permissions->set($templateAccess['permissions']);
}
// 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'])) {
$permissions = $this->parsePermissionsPayload(self::getParameter('permissions'), []);
$grant->permissions->set($permissions);
}
if (self::isParametersSet(['assigned_vehicle_id'])) {
$grant->assigned_vehicle_id->set($this->validateAssignedVehicleIdForCustomer(
self::getParameter('assigned_vehicle_id'),
$targetCustomer
));
}
$delivery = null;
$isEnabled = (bool)$grant->enabled->value();
if ($isEnabled !== $wasEnabled) {
(new subuser_action_token_service())->revokeGrantDecisions((int)$grant->id);
$subuser = $this->loadSubuserOrFail((int)$grant->subuser->value());
$delivery = $this->notifySubuserGrantDecision($subuser, $targetCustomer, $isEnabled);
}
$response->success([
...$grant->asArray(),
'decision_notification' => $delivery,
]);
},
[
'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 () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/subusers/permission-nodes');
global $response;
$canUseGlobalManagement = $this->hasPermission('manage_subuser_grants')
|| $this->hasPermission('list_subusers')
|| $this->hasPermission('add_subusers')
|| $this->hasPermission('edit_subusers');
if (!$canUseGlobalManagement) {
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
}
// 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);
}, [
'list_own_subusers' => 'List chauffeur permission nodes for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.',
'manage_subuser_grants' => 'List chauffeur permission nodes for administrative grant management.',
'list_subusers' => 'List chauffeur permission nodes for superuser management.',
'add_subusers' => 'List chauffeur permission nodes while inviting chauffeurs.',
'edit_subusers' => 'List chauffeur permission nodes while editing chauffeur grants.',
]);
$this->get('/subusers/permission-templates', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/subusers/permission-templates');
global $response;
$canUseGlobalManagement = $this->hasPermission('manage_subuser_grants')
|| $this->hasPermission('list_subusers')
|| $this->hasPermission('add_subusers')
|| $this->hasPermission('edit_subusers');
if (!$canUseGlobalManagement) {
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
}
$response->success((new subuser_permission_templates_service())->accessModel());
}, [
'list_own_subusers' => 'List chauffeur permission templates for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.',
'manage_subuser_grants' => 'List chauffeur permission templates for administrative grant management.',
'list_subusers' => 'List chauffeur permission templates for superuser management.',
'add_subusers' => 'List chauffeur permission templates while inviting chauffeurs.',
'edit_subusers' => 'List chauffeur permission templates while editing chauffeur grants.',
]);
$this->post('/subusers', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers');
$this->registerPublicSubuser();
});
$this->get('/subusers/setup', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/subusers/setup');
// Require the user to be logged in
global $response;
self::requireParameters(['token']);
$token = self::getParameter('token');
$setupThrottleKey = $this->recordThrottleAttempt('setup_token', 'token-validation', 20, 15 * 60);
// Get the subuser with the setup token
$subuser = (new subusers_o())->getSubuserBySetupToken($token);
if ($subuser === null) {
$response->error('Invalid or expired token', 400);
}
$this->clearThrottleAttempt($setupThrottleKey);
$response->success(['message' => 'Token is valid', 'subuser_id' => $subuser->id]);
});
$this->post('/subusers/setup', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/setup');
// 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');
$setupThrottleKey = $this->recordThrottleAttempt('setup_complete', 'token-complete', 20, 15 * 60);
$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);
}
$this->assertSubuserIdentifiersAvailable(
null,
null,
$username,
$email,
(int)$subuser->id
);
// Set the password for the subuser
try {
$setupUpdates = [
'password' => password_hash($password, PASSWORD_DEFAULT),
'name' => $name,
...(!empty($username) ? ['username' => $username] : []),
...(!empty($email) ? ['email' => $email] : []),
];
$subuser->update($this->verificationService()->clearVerificationForChangedContacts($subuser, $setupUpdates));
$pendingRegistration = $this->getPublicRegistrationPending($token);
if ($pendingRegistration !== null) {
$customerNumber = (int)($pendingRegistration['customer_number'] ?? 0);
if ($customerNumber <= 0) {
$response->error('Driver registration request is invalid.', 400);
}
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer(
(int)$subuser->id,
$customerNumber,
true
);
if ($grant === null) {
$grant = (new subuser_grants_o())->add(
$customerNumber,
(int)$subuser->id,
false,
null
);
}
$notification = $this->notifyCustomerOfGrantRequest(
$grant,
$subuser,
$customerNumber
);
if (($notification['status'] ?? null) !== 'sent') {
$response->error(
'Driver setup was saved, but the customer notification could not be sent. Please try again.',
503
);
}
}
// Invalidate the setup token
$subuser->invalidateSetupToken($token);
$this->clearPublicRegistrationPending($token);
$this->clearThrottleAttempt($setupThrottleKey);
$response->success(['message' => 'Complete registration successful']);
} catch (Exception $e) {
$response->error($e->getMessage(), 400);
}
});
$this->post('/subusers/password-reset/request', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/password-reset/request');
global $response;
$this->requireRecaptcha();
self::requireParameters(['phone_country_code', 'phone']);
$countryCode = (int)self::getParameter('phone_country_code');
$phone = (int)self::getParameter('phone');
self::requireType($countryCode, 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);
$generic = ['message' => 'Hvis chaufførkontoen findes, er et nulstillingslink sendt.'];
$this->recordThrottleAttempt(
'subuser_password_reset',
'phone:' . $countryCode . ':' . $phone,
5,
15 * 60
);
$subuser = (new subusers_o())->getSubuserByPhone($countryCode, $phone);
if (
$subuser === null
|| $subuser->requiresSetup()
|| account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)
) {
$response->success($generic);
}
try {
$tokens = new subuser_action_token_service();
$tokens->revokeForSubuser(
(int)$subuser->id,
subuser_action_token_service::PURPOSE_PASSWORD_RESET
);
$token = $tokens->issue(
subuser_action_token_service::PURPOSE_PASSWORD_RESET,
(int)$subuser->id
);
$link = $this->frontendBaseUrl()
. '/auth/password-reset/' . rawurlencode($token)
. '?type=subuser';
$this->deliverSms(
$this->subuserPhoneDestination($subuser),
'Truck Wash: Nulstil din chaufføradgangskode her: ' . $link
);
} catch (Exception) {
// Preserve the same response for every account and delivery state.
}
$response->success($generic);
});
$this->get('/subusers/password-reset/validate', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/subusers/password-reset/validate');
global $response;
self::requireParameters(['token']);
$record = (new subuser_action_token_service())->inspect(
(string)self::getParameter('token'),
subuser_action_token_service::PURPOSE_PASSWORD_RESET
);
if ($record === null) {
$response->error('Invalid or expired token', 404);
}
$response->success(['valid' => true]);
});
$this->post('/subusers/password-reset/set', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/password-reset/set');
global $response;
$this->requireRecaptcha();
self::requireParameters(['token', 'password']);
$password = (string)self::getParameter('password');
$this->requireSubuserPasswordPolicy($password);
$tokens = new subuser_action_token_service();
$record = $tokens->consume(
(string)self::getParameter('token'),
subuser_action_token_service::PURPOSE_PASSWORD_RESET
);
if ($record === null) {
$response->error('Invalid or expired token', 404);
}
$subuser = $this->loadSubuserOrFail((int)$record['subuser_id']);
try {
$subuser->setPassword($password);
$subuser->invalidateCurrentSetupToken();
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
$response->success(['message' => 'Password updated successfully']);
});
$this->get('/subusers/access-decision', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/subusers/access-decision');
global $response;
self::requireParameters(['token']);
$record = (new subuser_action_token_service())->inspect((string)self::getParameter('token'));
if (
$record === null
|| !in_array($record['purpose'], [
subuser_action_token_service::PURPOSE_GRANT_APPROVE,
subuser_action_token_service::PURPOSE_GRANT_DENY,
], true)
) {
$response->error('Invalid or expired token', 404);
}
$grant = (new subuser_grants_o())->select((int)$record['grant_id']);
if (!$grant->exists()) {
$response->error('Invalid or expired token', 404);
}
$grant->getObjectProperties();
if (
(int)$grant->subuser->value() !== (int)$record['subuser_id']
|| (int)$grant->billing_customer_number->value() !== (int)$record['customer_number']
|| $grant->deleted_at->value() !== null
) {
$response->error('Invalid or expired token', 404);
}
$subuser = $this->loadSubuserOrFail((int)$record['subuser_id']);
$response->success([
'action' => $record['purpose'] === subuser_action_token_service::PURPOSE_GRANT_APPROVE
? 'approve'
: 'deny',
'subuser_name' => trim((string)($subuser->name->value() ?? '')) ?: 'Chauffør',
'customer_name' => $this->resolveCustomerName((int)$record['customer_number']),
'expires_at' => $record['expires_at'],
]);
});
$this->post('/subusers/access-decision', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/access-decision');
global $response;
self::requireParameters(['token']);
$token = (string)self::getParameter('token');
$service = new subuser_action_token_service();
$record = $service->consumeGrantDecision($token);
if ($record === null) {
$response->error('Invalid or expired token', 404);
}
$grant = (new subuser_grants_o())->select((int)$record['grant_id']);
if (!$grant->exists()) {
$response->error('Invalid or expired token', 404);
}
$grant->getObjectProperties();
if (
(int)$grant->subuser->value() !== (int)$record['subuser_id']
|| (int)$grant->billing_customer_number->value() !== (int)$record['customer_number']
|| $grant->deleted_at->value() !== null
) {
$response->error('Invalid or expired token', 404);
}
$approved = $record['purpose'] === subuser_action_token_service::PURPOSE_GRANT_APPROVE;
$subuser = $this->loadSubuserOrFail((int)$record['subuser_id']);
$delivery = $this->notifySubuserGrantDecision(
$subuser,
(int)$record['customer_number'],
$approved
);
$response->success([
'decision' => $approved ? 'approved' : 'denied',
'delivery' => $delivery,
]);
});
$this->post('/subusers/auth/password', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/auth/password');
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);
}
$identifier = $username !== null
? 'username:' . strtolower($username)
: ($subuser_id !== null
? 'id:' . (string)$subuser_id
: 'phone:' . (string)$phone_country_code . ':' . (string)$phone);
$authThrottleKey = $this->recordThrottleAttempt('auth_password', $identifier, 10, 15 * 60);
// 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) {
$candidate = (new subusers_o())->select($subuser_id);
if ($candidate->exists()) {
$candidate->getObjectProperties();
$subuser = $candidate;
}
} elseif ($username !== null) {
$subuser = (new subusers_o())->getSubuserByUsername($username);
}
if ($subuser === null) {
$this->subuserAuthFailure();
}
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
$this->subuserAuthFailure();
}
self::requireParameters(['password']);
$password = (string)self::getParameter('password');
try {
$passwordHash = $subuser->password->value();
if (is_string($passwordHash) && $passwordHash !== '' && password_verify($password, $passwordHash)) {
$this->clearThrottleAttempt($authThrottleKey);
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 {
$this->subuserAuthFailure();
}
} catch (Exception $e) {
$response->error($e->getMessage(), 500);
}
});
$this->post('/subusers/me/password', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/me/password');
global $response;
$subuser = (new authentication())->get_subuser();
if ($subuser === false) {
$response->error('Unauthorized', 401);
}
self::requireParameters(['current_password', 'password']);
$currentPassword = (string)self::getParameter('current_password');
$password = (string)self::getParameter('password');
$this->requireSubuserPasswordPolicy($password);
$passwordHash = $subuser->password->value();
if (!is_string($passwordHash) || !password_verify($currentPassword, $passwordHash)) {
$response->error('Current password is incorrect', 400);
}
try {
$subuser->setPassword($password);
(new subuser_action_token_service())->revokeForSubuser(
(int)$subuser->id,
subuser_action_token_service::PURPOSE_PASSWORD_RESET
);
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
$response->success(['message' => 'Password updated successfully']);
}, []);
// =============================
// Subusers - List & Get (with grant visibility)
// =============================
$this->get('/superuser/subusers', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/superuser/subusers');
global $response;
$this->requirePermission('list_subusers');
$response->success($this->listSuperuserSubusers());
}, [
'list_subusers' => 'List all chauffeur access grants for superusers.',
]);
$this->patch('/superuser/subusers/{subuser_id}', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/subusers/{subuser_id}');
global $response;
$this->requirePermission('edit_subusers');
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
$updates = $this->parseSuperuserSubuserProfileUpdates((int)$subuser->id);
try {
$subuser->update($this->verificationService()->clearVerificationForChangedContacts($subuser, $updates));
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
$authUser = (new authentication())->get_user();
(new logs_o())->add(
'subusers',
'global',
1,
$authUser !== false ? (int)$authUser->id : 0,
'SUPERUSER_SUBUSER_PROFILE_UPDATE',
'Updated chauffeur profile: ' . (int)$subuser->id
);
$response->success(['subuser' => $this->buildSubuserAccountPayload($subuser)]);
}, [
'edit_subusers' => 'Edit chauffeur account profile fields as a superuser.',
]);
$this->post('/superuser/subusers/{subuser_id}/verification/{channel}/send', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/subusers/{subuser_id}/verification/{channel}/send');
global $response;
$this->requirePermission('edit_subusers');
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
$authUser = (new authentication())->get_user();
$delivery = $this->sendContactVerificationCode(
$subuser,
(string)$this->fromRoute('channel'),
$this->verificationActorContext('user', $authUser !== false ? (int)$authUser->id : 0)
);
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
$response->success([
'delivery' => $delivery,
'subuser' => $this->buildSubuserAccountPayload($subuser),
]);
}, [
'edit_subusers' => 'Send chauffeur contact verification codes as a superuser.',
]);
$this->patch('/superuser/subusers/{subuser_id}/verification/{channel}', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/subusers/{subuser_id}/verification/{channel}');
global $response;
$this->requirePermission('edit_subusers');
self::requireParameters(['verified']);
$verified = self::getParameter('verified');
self::requireType($verified, self::type_bool());
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
$authUser = (new authentication())->get_user();
try {
$result = $this->verificationService()->setVerificationState(
$subuser,
(string)$this->fromRoute('channel'),
(bool)$verified,
$this->verificationActorContext('user', $authUser !== false ? (int)$authUser->id : 0)
);
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
if (($result['status'] ?? null) === 'missing_destination') {
$response->error((string)($result['message'] ?? 'No contact value is available for verification.'), 400);
}
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
$response->success([
'result' => $result,
'verification' => $this->verificationService()->status($subuser),
'subuser' => $this->buildSubuserAccountPayload($subuser),
]);
}, [
'edit_subusers' => 'Set chauffeur contact verification state as a superuser.',
]);
$this->post('/superuser/subusers/{subuser_id}/password', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/subusers/{subuser_id}/password');
global $response;
$this->requirePermission('edit_subusers');
self::requireParameters(['password']);
$password = (string)self::getParameter('password');
$this->requireSubuserPasswordPolicy($password);
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
try {
$subuser->setPassword($password);
$subuser->invalidateCurrentSetupToken();
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
$authUser = (new authentication())->get_user();
(new logs_o())->add(
'subusers',
'global',
1,
$authUser !== false ? (int)$authUser->id : 0,
'SUPERUSER_SUBUSER_PASSWORD_SET',
'Set chauffeur password: ' . (int)$subuser->id
);
$response->success(['subuser' => $this->buildSubuserAccountPayload($subuser)]);
}, [
'edit_subusers' => 'Set a chauffeur account password as a superuser.',
]);
$this->post('/superuser/subusers/{subuser_id}/login-link', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/subusers/{subuser_id}/login-link');
global $response;
$this->requirePermission('edit_subusers');
$this->requirePermission('SUPERUSER_INTIMIDATE');
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
$response->success($this->createDirectSubuserLoginLinkPayload(
$subuser,
'SUPERUSER_SUBUSER_DIRECT_LOGIN_LINK',
'Created chauffeur direct login link'
));
}, [
'edit_subusers' => 'Create chauffeur direct login links as a superuser.',
'SUPERUSER_INTIMIDATE' => 'Create direct login sessions for chauffeur accounts.',
]);
$this->post('/superuser/subusers/{subuser_id}/password-guide/{channel}/send', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/subusers/{subuser_id}/password-guide/{channel}/send');
$this->sendSuperuserSubuserDirectLoginLink('password_guide');
}, [
'edit_subusers' => 'Send chauffeur password guide links as a superuser.',
'SUPERUSER_INTIMIDATE' => 'Create direct login sessions for chauffeur accounts.',
]);
$this->post('/superuser/subusers/{subuser_id}/login-link/{channel}/send', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/subusers/{subuser_id}/login-link/{channel}/send');
$this->sendSuperuserSubuserDirectLoginLink('login_link');
}, [
'edit_subusers' => 'Send chauffeur direct login links as a superuser.',
'SUPERUSER_INTIMIDATE' => 'Create direct login sessions for chauffeur accounts.',
]);
$this->get('/superuser/users/{user_id}/subusers', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/superuser/users/{user_id}/subusers');
global $response;
$this->requirePermission('list_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->addUserScopedSubuserMeta($targetUser);
$response->success($this->listSuperuserSubusers((int)$targetUser['customer_number']));
}, [
'list_subusers' => 'List chauffeur access grants for a selected superuser customer account.',
]);
$this->get('/superuser/users/{user_id}/subusers/summary', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/superuser/users/{user_id}/subusers/summary');
global $response;
$this->requirePermission('list_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$response->add_meta('user_context', $targetUser);
$response->success($this->buildSubuserSummaryForCustomer((int)$targetUser['customer_number']));
}, [
'list_subusers' => 'Summarize chauffeur access grants for a selected superuser customer account.',
]);
$this->get('/subusers', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/subusers');
global $response;
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST);
$customerName = $this->resolveCustomerName($customerNumber);
$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, $customerName) {
$subuser = (new subusers_o())->select((int)$o['id']);
if (!$subuser->exists()) {
return null;
}
$subuser->getObjectProperties();
return $this->buildSubuserManagementPayload($subuser, $customerNumber, $customerName);
}, 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 () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/subusers/me');
global $response;
$subuser = (new authentication())->get_subuser();
if ($subuser === false) {
$response->error('Unauthorized', 401);
}
$response->success($this->buildCurrentSubuserPayload($subuser));
}, []);
$this->get('/subusers/me/verification', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_READ, '/subusers/me/verification');
global $response;
$subuser = (new authentication())->get_subuser();
if ($subuser === false) {
$response->error('Unauthorized', 401);
}
$response->success($this->verificationService()->status($subuser));
}, []);
$this->post('/subusers/me/verification/{channel}/send', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/me/verification/{channel}/send');
global $response;
$subuser = (new authentication())->get_subuser();
if ($subuser === false) {
$response->error('Unauthorized', 401);
}
$delivery = $this->sendContactVerificationCode(
$subuser,
(string)$this->fromRoute('channel'),
$this->verificationActorContext('subuser', (int)$subuser->id)
);
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
$response->success([
'delivery' => $delivery,
'verification' => $this->verificationService()->status($subuser),
]);
}, []);
$this->post('/subusers/me/verification/{channel}/verify', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/me/verification/{channel}/verify');
global $response;
$subuser = (new authentication())->get_subuser();
if ($subuser === false) {
$response->error('Unauthorized', 401);
}
self::requireParameters(['code']);
$result = $this->verifyContactVerificationCode(
$subuser,
(string)$this->fromRoute('channel'),
(string)self::getParameter('code'),
$this->verificationActorContext('subuser', (int)$subuser->id)
);
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
$response->success([
'result' => $result,
'verification' => $this->verificationService()->status($subuser),
'subuser' => $this->buildCurrentSubuserPayload($subuser),
]);
}, []);
$this->post('/subusers/{subuser_id}/verification/{channel}/send', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/{subuser_id}/verification/{channel}/send');
global $response;
$subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id'));
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT);
$grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true);
if ($grant === null) {
$response->error('Subuser grant not found for selected customer', 404);
}
$authUser = (new authentication())->get_user();
$delivery = $this->sendContactVerificationCode(
$subuser,
(string)$this->fromRoute('channel'),
$this->verificationActorContext('user', $authUser !== false ? (int)$authUser->id : 0)
);
$subuser = $this->loadSubuserOrFail((int)$subuser->id);
$response->success([
'delivery' => $delivery,
'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber),
]);
}, [
'edit_own_subusers' => 'Send chauffeur contact verification codes for own-customer chauffeurs.',
]);
$this->put('/subusers/me', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/me');
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($this->verificationService()->clearVerificationForChangedContacts($subuser, $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('/superuser/subusers/invite', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/subusers/invite');
self::requireParameters(['customer_number']);
$this->requirePermission('add_subusers');
$customerNumber = (int)self::getParameter('customer_number');
self::requireType($customerNumber, self::type_int());
$this->handleInviteSubuserForCustomer($customerNumber);
}, [
'add_subusers' => 'Invite or link chauffeurs for any customer (superuser).',
]);
$this->post('/superuser/users/{user_id}/subusers/invite', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/users/{user_id}/subusers/invite');
$this->requirePermission('add_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->handleInviteSubuserForCustomer((int)$targetUser['customer_number']);
}, [
'add_subusers' => 'Invite or link chauffeurs for a selected superuser customer account.',
]);
$this->post('/subusers/invite', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/invite');
$customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD);
$this->handleInviteSubuserForCustomer($customerNumber);
}, [
'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.',
]);
$this->post('/superuser/subusers/invite/resend', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/subusers/invite/resend');
global $response;
$this->requirePermission('edit_subusers');
self::requireParameters(['id', 'customer_number']);
$subuserId = (int)self::getParameter('id');
self::requireType($subuserId, self::type_int());
$customerNumber = (int)self::getParameter('customer_number');
self::requireType($customerNumber, self::type_int());
$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_subusers' => 'Resend chauffeur invites for a selected customer (superuser).',
]);
$this->post('/superuser/users/{user_id}/subusers/{subuser_id}/invite/resend', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/users/{user_id}/subusers/{subuser_id}/invite/resend');
$this->requirePermission('edit_subusers');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->resendInviteForScopedUser(
$this->routePositiveInt('subuser_id'),
(int)$targetUser['customer_number']
);
}, [
'edit_subusers' => 'Resend chauffeur invites for a selected superuser customer account.',
]);
$this->patch('/superuser/users/{user_id}/subusers/grants/{grant_id}', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/superuser/users/{user_id}/subusers/grants/{grant_id}');
$this->requirePermission('manage_subuser_grants');
$targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id'));
$this->patchScopedSubuserGrant(
$this->routePositiveInt('grant_id'),
(int)$targetUser['customer_number']
);
}, [
'manage_subuser_grants' => 'Edit chauffeur grants for a selected superuser customer account.',
]);
$this->post('/subusers/invite/resend', function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/invite/resend');
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 () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers');
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 () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/subusers/me');
$this->registerPublicSubuser();
});
}
}