Add system status displays for Minio and Redis, and enhance backup configuration

This commit is contained in:
Jeppe Bundgaard
2026-07-13 10:08:00 +02:00
parent fa1ade555f
commit 012e5366ba
55 changed files with 7968 additions and 706 deletions
+436 -82
View File
@@ -6,10 +6,12 @@ use classes\authentication;
use classes\economic;
use classes\gatewayapi;
use classes\response;
use classes\subusers_schema_bootstrap;
use classes\subuser_permission_templates_service;
use classes\virkdata;
use Exception;
use modules\virkdata\helpers\virkdata_response;
use objects\customer_vehicles_o;
use objects\logs_o;
use objects\subuser_grants_o;
use objects\subusers_o;
@@ -26,6 +28,8 @@ class subusersRoute
{
use route_t;
private const DOGNVASK_PERMISSION_KEYS = ['SELFSERVE_LIST', 'SELFSERVE_ADD'];
private function getOwnPermissionForNode(subusers_permission_node_key $node)
{
return match ($node) {
@@ -182,6 +186,100 @@ class subusersRoute
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 ($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 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,
@@ -264,6 +362,170 @@ class subusersRoute
return $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 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 [
'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 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'] ?? ''));
@@ -363,6 +625,10 @@ class subusersRoute
$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) {
@@ -391,6 +657,10 @@ class subusersRoute
'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),
@@ -454,8 +724,8 @@ class subusersRoute
'id' => 's.`id`',
'created_at' => 's.`created_at`',
'updated_at' => 'row_updated_at',
'customer_number' => 'customer_number_sort',
'grant_id' => 'grant_id_sort',
'customer_number' => 'g.`billing_customer_number`',
'grant_id' => 'g.`id`',
'name' => 's.`name`',
];
@@ -492,6 +762,15 @@ class subusersRoute
$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) {
@@ -506,6 +785,10 @@ class subusersRoute
'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),
@@ -558,6 +841,10 @@ class subusersRoute
'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,
@@ -756,6 +1043,7 @@ class subusersRoute
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 (
@@ -766,7 +1054,7 @@ class subusersRoute
)
)";
$search = '%' . $pagination['search'] . '%';
for ($i = 0; $i < 9; $i++) {
for ($i = 0; $i < 10; $i++) {
$params[] = $search;
$types .= 's';
}
@@ -776,9 +1064,10 @@ class subusersRoute
$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` AND cv.`deleted_at` IS NULL
";
$countSql = "SELECT COUNT(DISTINCT s.`id`) AS `count` $fromSql $whereSql";
$countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql";
$countStatement = $db->conn->prepare($countSql);
if ($countStatement === false) {
throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error);
@@ -802,23 +1091,18 @@ class subusersRoute
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 `customer_number_sort`,
MAX(g.`id`) AS `grant_id_sort`
COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`,
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`
$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 ?
";
@@ -834,68 +1118,15 @@ class subusersRoute
$rows = $result->fetch_all(MYSQLI_ASSOC);
$pageStatement->close();
$subuserIds = array_map(static fn (array $row): int => (int)$row['id'], $rows);
$grantRowsBySubuserId = [];
if ($subuserIds !== []) {
$placeholders = implode(',', array_fill(0, count($subuserIds), '?'));
$grantWhere = [
'g.`deleted_at` IS NULL',
'g.`subuser` IN (' . $placeholders . ')',
];
$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.`created_at` AS `grant_created_at`,
g.`updated_at` AS `grant_updated_at`
FROM `subuser_grants` g
WHERE " . implode(' AND ', $grantWhere) . "
ORDER BY
g.`subuser` ASC,
g.`enabled` DESC,
g.`billing_customer_number` ASC,
COALESCE(g.`updated_at`, g.`created_at`) DESC,
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 $grantRow): int => (int)($grantRow['customer_number'] ?? 0),
$grantRows
));
foreach ($grantRows as $grantRow) {
$subuserId = (int)$grantRow['subuser_id'];
$customerNumberForGrant = (int)$grantRow['customer_number'];
$grantRow['customer_name'] = $this->resolveCustomerName($customerNumberForGrant, $customerNames);
$grantRowsBySubuserId[$subuserId] ??= [];
$grantRowsBySubuserId[$subuserId][] = $grantRow;
}
}
$customerNames = $this->resolveCustomerNames(array_map(
static fn (array $row): int => (int)($row['customer_number'] ?? 0),
$rows
));
$rows = array_map(function (array $row) use ($customerNames): array {
$customerNumberForGrant = (int)($row['customer_number'] ?? 0);
$row['customer_name'] = $this->resolveCustomerName($customerNumberForGrant, $customerNames);
return $row;
}, $rows);
$response->paginate(
(int)$pagination['page'],
@@ -907,7 +1138,7 @@ class subusersRoute
);
return array_map(
fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row, $grantRowsBySubuserId[(int)$row['id']] ?? []),
fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row, [$row]),
$rows
);
}
@@ -961,6 +1192,13 @@ class subusersRoute
$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);
}
@@ -1109,6 +1347,8 @@ class subusersRoute
public function run(): void
{
subusers_schema_bootstrap::ensureTables();
// =============================
// Subuser Grant Management
// =============================
@@ -1182,6 +1422,7 @@ class subusersRoute
'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,
@@ -1228,8 +1469,18 @@ class subusersRoute
$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);
@@ -1287,6 +1538,12 @@ class subusersRoute
$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
));
}
$response->success($grant->asArray());
},
[
@@ -1587,6 +1844,103 @@ class subusersRoute
'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($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}/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'));
$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,
'SUPERUSER_SUBUSER_DIRECT_LOGIN_LINK',
'Created chauffeur direct login link for subuser ' . (int)$subuser->id . ' and customer ' . $customerNumber
);
$response->success([
'subuser_id' => (int)$subuser->id,
'customer_number' => $customerNumber,
'login_path' => $this->buildDirectSubuserLoginPath($sessionToken, $customerNumber),
]);
}, [
'edit_subusers' => 'Create 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');