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}|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 ? '

Du kan bruge dit personlige link til at logge ind og sætte en ny adgangskode.

' : '

Du kan bruge dit personlige link til at logge ind på Truck Wash.

'; $message .= '

' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '

'; (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 () { 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 () { 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 () { 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 () { 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 () { 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 () { $this->registerPublicSubuser(); }); $this->get('/subusers/setup', function () { // 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 () { // 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { $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 () { $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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { 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 () { $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 () { $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 () { 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 () { $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 () { $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 () { global $response; self::requireParameters(['id']); $subuserId = (int)self::getParameter('id'); self::requireType($subuserId, self::type_int()); $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT); $subuser = (new subusers_o())->select($subuserId); if (!$subuser->exists()) { $response->error('Subuser not found', 404); } $subuser->getObjectProperties(); $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true); if ($grant === null) { $response->error('Subuser grant not found for selected customer', 404); } if (!$subuser->requiresSetup()) { $response->error('Driver account already accepted the invitation.', 409); } $invite = $this->issueSetupInvite($subuser); $response->success([ 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), 'grant' => $grant->asArray(), 'invite' => $invite, ]); }, [ 'edit_own_subusers' => 'Resend chauffeur invite for own customer. Subusers require node: SUBUSERS_EDIT and X-Customer-Number header.', ]); $this->put('/subusers', function () { global $response; $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT); $response->error('Customers can only manage subuser grants. Drivers own their account profile.', 403); }, [ 'edit_own_subusers' => 'Customers cannot edit chauffeur account profiles. They may only manage grants, permissions, and enabled state.', ]); // Public registration endpoint (alias of POST /subusers) matching OpenAPI: POST /subusers/me $this->post('/subusers/me', function () { $this->registerPublicSubuser(); }); } }