Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions addon/components/entity/card.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<Card.footer class={{@footerClass}}>
<div class="flex flex-row items-center space-x-1">
<Button @icon="pencil" @size="xs" @onClick={{fn this.entityActions.modal.edit @resource}} />
<Button @icon="barcode" @size="xs" @onClick={{fn this.entityActions.viewLabel @resource}} @permission="fleet-ops view entity" />
<Button @icon="trash" @size="xs" @type="danger" @onClick={{fn this.entityActions.delete @resource}} />
</div>
{{#if (has-block "footer")}}
Expand Down
2 changes: 1 addition & 1 deletion addon/components/modals/order-label.hbs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Modal::Default @modalIsOpened={{@modalIsOpened}} @options={{@options}} @confirm={{@onConfirm}} @decline={{@onDecline}}>
<div class="modal-body-container">
<object id="pdf" class="border-0 w-full shadow-sm h-40rem" data={{@options.data}} type="application/pdf" alt={{@options.order.public_id}}>
<object id="pdf" class="border-0 w-full shadow-sm h-40rem" data={{@options.data}} type="application/pdf" alt={{or @options.order.public_id @options.subject.public_id}}>
<div class="text-center font-semibold dark:text-gray-100 p-6 flex items-center justify-center">{{t "modals.order-label.loading"}}</div>
</object>
</div>
Expand Down
32 changes: 32 additions & 0 deletions addon/services/entity-actions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { action } from '@ember/object';
import { debug } from '@ember/debug';

export default class EntityActionsService extends ResourceActionService {
constructor() {
Expand Down Expand Up @@ -38,4 +40,34 @@ export default class EntityActionsService extends ResourceActionService {
});
},
};

@action async viewLabel(entity) {
// render dialog to display label within
this.modalsManager.show(`modals/order-label`, {
title: this.intl.t('order.fields.entity-label'),
modalClass: 'modal-xl',
acceptButtonText: this.intl.t('common.done'),
hideDeclineButton: true,
subject: entity,
});

try {
// load the pdf label from base64
// eslint-disable-next-line no-undef
const fileReader = new FileReader();
const { data: pdfStream } = await this.fetch.get(`orders/label/${entity.public_id}?format=base64`);
// eslint-disable-next-line no-undef
const base64 = await fetch(`data:application/pdf;base64,${pdfStream}`);
const blob = await base64.blob();
// load into file reader
fileReader.onload = (event) => {
const data = event.target.result;
this.modalsManager.setOption('data', data);
};
fileReader.readAsDataURL(blob);
} catch (err) {
this.notifications.error(this.intl.t('order.prompts.failed-to-load-entity-label'));
debug('Error loading entity label data: ' + err.message);
}
}
}
30 changes: 27 additions & 3 deletions server/src/Http/Controllers/Api/v1/LabelController.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,42 @@ protected function findLabelSubject(?string $type, string $publicId): mixed
{
switch ($type) {
case 'order':
return Order::where('public_id', $publicId)->orWhere('uuid', $publicId)->withoutGlobalScopes()->first();
return $this->findLabelSubjectFor(Order::class, $publicId);

case 'waypoint':
return Waypoint::where('public_id', $publicId)->orWhere('uuid', $publicId)->withoutGlobalScopes()->first();
return $this->findLabelSubjectFor(Waypoint::class, $publicId);

case 'entity':
return Entity::where('public_id', $publicId)->orWhere('uuid', $publicId)->withoutGlobalScopes()->first();
return $this->findLabelSubjectFor(Entity::class, $publicId);
}

return null;
}

/**
* Resolves a label subject by public id or uuid, constrained to the current company.
*
* The identifier match is grouped so the company constraint applies to both arms —
* without the closure it would read as `public_id = ? OR (uuid = ? AND company_uuid = ?)`
* and leak labels across organizations.
*/
protected function findLabelSubjectFor(string $model, string $publicId): mixed
{
$companyUuid = $this->sessionCompany();
if (!$companyUuid) {
return null;
}

return $model::where(function ($query) use ($publicId) {
$query->where('public_id', $publicId)->orWhere('uuid', $publicId);
})->where('company_uuid', $companyUuid)->withoutGlobalScopes()->first();
}

protected function sessionCompany(): ?string
{
return session('company');
}

protected function apiError(string $message)
{
return response()->apiError($message);
Expand Down
30 changes: 27 additions & 3 deletions server/src/Http/Controllers/Internal/v1/OrderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1608,17 +1608,41 @@ protected function makeTextResponse(string $text)

protected function findOrderLabelSubject(string $id): ?Order
{
return Order::where('public_id', $id)->orWhere('uuid', $id)->withoutGlobalScopes()->first();
return $this->findLabelSubjectFor(Order::class, $id);
}

protected function findWaypointLabelSubject(string $id): ?Waypoint
{
return Waypoint::where('public_id', $id)->orWhere('uuid', $id)->withoutGlobalScopes()->first();
return $this->findLabelSubjectFor(Waypoint::class, $id);
}

protected function findEntityLabelSubject(string $id): ?Entity
{
return Entity::where('public_id', $id)->orWhere('uuid', $id)->withoutGlobalScopes()->first();
return $this->findLabelSubjectFor(Entity::class, $id);
}

/**
* Resolves a label subject by public id or uuid, constrained to the current company.
*
* The identifier match is grouped so the company constraint applies to both arms —
* without the closure it would read as `public_id = ? OR (uuid = ? AND company_uuid = ?)`
* and leak labels across organizations.
*/
protected function findLabelSubjectFor(string $model, string $id): mixed
{
$companyUuid = $this->sessionCompany();
if (!$companyUuid) {
return null;
}

return $model::where(function ($query) use ($id) {
$query->where('public_id', $id)->orWhere('uuid', $id);
})->where('company_uuid', $companyUuid)->withoutGlobalScopes()->first();
}

protected function sessionCompany(): ?string
{
return session('company');
}

/**
Expand Down
45 changes: 45 additions & 0 deletions server/tests/Feature/Http/Api/LabelControllerSubjectsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,48 @@ public function __call($method, $arguments)

expect($response->getData(true))->toBe(['error' => 'Unable to render label.']);
});

test('label subject resolution refuses subjects belonging to another company', function () {
$connection = fleetopsLabelControllerBoot();
$connection->table('orders')->insert(['uuid' => 'order-1', 'public_id' => 'order_test', 'company_uuid' => 'company-2']);
$connection->table('waypoints')->insert(['uuid' => 'waypoint-1', 'public_id' => 'waypoint_test', 'company_uuid' => 'company-2']);
$connection->table('entities')->insert(['uuid' => 'entity-1', 'public_id' => 'entity_test', 'company_uuid' => 'company-2']);

$probe = new FleetOpsLabelControllerProbe();

expect($probe->callProtected('findLabelSubject', 'order', 'order_test'))->toBeNull()
->and($probe->callProtected('findLabelSubject', 'waypoint', 'waypoint_test'))->toBeNull()
->and($probe->callProtected('findLabelSubject', 'entity', 'entity_test'))->toBeNull();
});

test('label subject resolution scopes both identifier arms to the company', function () {
$connection = fleetopsLabelControllerBoot();
// Guards the `where(public_id)->orWhere(uuid)->where(company_uuid)` precedence trap: without
// grouping the identifier match, a foreign public_id hit bypasses the company constraint.
$connection->table('entities')->insert(['uuid' => 'entity-1', 'public_id' => 'entity_public', 'company_uuid' => 'company-2']);
$connection->table('entities')->insert(['uuid' => 'entity_uuid_only', 'public_id' => 'entity_other', 'company_uuid' => 'company-2']);

$probe = new FleetOpsLabelControllerProbe();

expect($probe->callProtected('findLabelSubject', 'entity', 'entity_public'))->toBeNull()
->and($probe->callProtected('findLabelSubject', 'entity', 'entity_uuid_only'))->toBeNull();
});

test('label subject resolution fails closed without a company session', function () {
$connection = fleetopsLabelControllerBoot();
$connection->table('entities')->insert(['uuid' => 'entity-1', 'public_id' => 'entity_test', 'company_uuid' => 'company-1']);
session(['company' => null]);

$probe = new FleetOpsLabelControllerProbe();

expect($probe->callProtected('findLabelSubject', 'entity', 'entity_test'))->toBeNull();
});

test('get label refuses to render a label owned by another company', function () {
$connection = fleetopsLabelControllerBoot();
$connection->table('entities')->insert(['uuid' => 'entity-1', 'public_id' => 'entity_test', 'company_uuid' => 'company-2']);

$response = (new LabelController())->getLabel('entity_test', Illuminate\Http\Request::create('/x', 'GET'));

expect($response->getData(true))->toBe(['error' => 'Unable to render label.']);
});
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,35 @@ function fleetopsOrderHelperSeed(SQLiteConnection $connection): void
->and($probe->callHelper('findEntityLabelSubject', 'entity_helper1')?->uuid)->toBe('44444444-4444-4444-8444-444444444431');
});

test('label lookup helpers refuse subjects from another company', function () {
$connection = fleetopsOrderHelperBoot();
fleetopsOrderHelperSeed($connection);
$connection->table('orders')->where('public_id', 'order_helper1')->update(['company_uuid' => 'company-2']);
$connection->table('waypoints')->where('public_id', 'waypoint_helper1')->update(['company_uuid' => 'company-2']);
$connection->table('entities')->where('public_id', 'entity_helper1')->update(['company_uuid' => 'company-2']);

$probe = new FleetOpsInternalOrderHelperProbe();

// Also covers the identifier-precedence trap: an unguarded
// `where(public_id)->orWhere(uuid)->where(company_uuid)` chain would still resolve these.
expect($probe->callHelper('findOrderLabelSubject', 'order_helper1'))->toBeNull()
->and($probe->callHelper('findOrderLabelSubject', '44444444-4444-4444-8444-444444444401'))->toBeNull()
->and($probe->callHelper('findWaypointLabelSubject', 'waypoint_helper1'))->toBeNull()
->and($probe->callHelper('findEntityLabelSubject', 'entity_helper1'))->toBeNull();
});

test('label lookup helpers fail closed without a company session', function () {
$connection = fleetopsOrderHelperBoot();
fleetopsOrderHelperSeed($connection);
session(['company' => null]);

$probe = new FleetOpsInternalOrderHelperProbe();

expect($probe->callHelper('findOrderLabelSubject', 'order_helper1'))->toBeNull()
->and($probe->callHelper('findWaypointLabelSubject', 'waypoint_helper1'))->toBeNull()
->and($probe->callHelper('findEntityLabelSubject', 'entity_helper1'))->toBeNull();
});

test('bulk assignment transaction and response helpers persist and wrap', function () {
$connection = fleetopsOrderHelperBoot();
fleetopsOrderHelperSeed($connection);
Expand Down
2 changes: 2 additions & 0 deletions translations/bg-bg.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,7 @@ order:
order-metadata: Метаданни на поръчката
order-label: Етикет на поръчката
waypoint-label: Етикет на междинната точка
entity-label: Етикет на единицата
current-eta: Текущо очаквано време на пристигане
ect: ECT
current-destination: Текуща дестинация
Expand All @@ -712,6 +713,7 @@ order:
no-driver-assigned-error: Няма назначен шофьор за тази поръчка.
failed-to-load-order-label: Неуспешно зареждане на етикета на поръчката.
failed-to-load-waypoint-label: Неуспешно зареждане на етикета на точката.
failed-to-load-entity-label: Неуспешно зареждане на етикета на единицата.
unable-to-add-entity: Не може да се добави нов обект към поръчката.
assign-driver-success: Шофьорът ({driverName}) е назначен за поръчка {orderId}.
cancel-title: Сигурни ли сте, че искате да отмените тази поръчка?
Expand Down
2 changes: 2 additions & 0 deletions translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,7 @@ order:
order-metadata: Order Metadata
order-label: Order Label
waypoint-label: Waypoint Label
entity-label: Entity Label
current-eta: Current ETA
ect: ECT
current-destination: Current Destination
Expand All @@ -743,6 +744,7 @@ order:
no-driver-assigned-error: No driver assigned to this order.
failed-to-load-order-label: Failed to load order label.
failed-to-load-waypoint-label: Failed to load waypoint label.
failed-to-load-entity-label: Failed to load entity label.
unable-to-add-entity: Unable to add new entity to order.
assign-driver-success: Driver ({driverName}) has been assigned to order {orderId}.
cancel-title: Are you sure you wish to cancel this order?
Expand Down
2 changes: 2 additions & 0 deletions translations/es-pa.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ order:
order-metadata: Metadatos del pedido
order-label: Etiqueta de pedido
waypoint-label: Etiqueta de punto de referencia
entity-label: Etiqueta de entidad
current-eta: ETA actual
ect: TEC
current-destination: Destino actual
Expand All @@ -686,6 +687,7 @@ order:
no-driver-assigned-error: No hay ningún conductor asignado a este pedido.
failed-to-load-order-label: No se pudo cargar la etiqueta del pedido.
failed-to-load-waypoint-label: No se pudo cargar la etiqueta del waypoint.
failed-to-load-entity-label: No se pudo cargar la etiqueta de la entidad.
unable-to-add-entity: No se puede agregar una nueva entidad al pedido.
assign-driver-success: El conductor ({driverName}) ha sido asignado al pedido {orderId}.
cancel-title: ¿Está seguro de que desea cancelar este pedido?
Expand Down
2 changes: 2 additions & 0 deletions translations/fr-fr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,7 @@ order:
order-metadata: Métadonnées de la commande
order-label: Étiquette de la commande
waypoint-label: Étiquette du point de passage
entity-label: Étiquette de l'entité
current-eta: ETA actuel
ect: ECT
current-destination: Destination actuelle
Expand All @@ -721,6 +722,7 @@ order:
failed-to-load-order-label: Échec du chargement de l'étiquette de la commande.
failed-to-load-waypoint-label: Échec du chargement de l'étiquette du point de
passage.
failed-to-load-entity-label: Échec du chargement de l'étiquette de l'entité.
unable-to-add-entity: Impossible d'ajouter une nouvelle entité à la commande.
assign-driver-success: Le conducteur ({driverName}) a été assigné à la commande
{orderId}.
Expand Down
2 changes: 2 additions & 0 deletions translations/mn-mn.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,7 @@ order:
order-metadata: Захиалгын мета өгөгдөл
order-label: Захиалгын шошго
waypoint-label: Замын цэгийн шошго
entity-label: Объектийн шошго
current-eta: Одоогийн тооцоолсон хүрэх цаг
ect: ECT
current-destination: Одоогийн очих газар
Expand All @@ -708,6 +709,7 @@ order:
no-driver-assigned-error: Энэ захиалгад жолооч томилогдоогүй байна.
failed-to-load-order-label: Захиалгын шошгыг ачааллахад алдаа гарлаа.
failed-to-load-waypoint-label: Замын цэгийн шошгыг ачааллахад алдаа гарлаа.
failed-to-load-entity-label: Объектийн шошгыг ачааллахад алдаа гарлаа.
unable-to-add-entity: Захиалгад шинэ объект нэмэх боломжгүй байна.
assign-driver-success: Жолооч ({driverName}) захиалга {orderId}-д томилогдлоо.
cancel-title: Та энэ захиалгыг цуцлахдаа итгэлтэй байна уу?
Expand Down
2 changes: 2 additions & 0 deletions translations/pt-br.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,7 @@ order:
order-metadata: Metadados do Pedido
order-label: Etiqueta do Pedido
waypoint-label: Etiqueta do Ponto de Passagem
entity-label: Etiqueta da Entidade
current-eta: ETA Atual
ect: ECT
current-destination: Destino Atual
Expand All @@ -711,6 +712,7 @@ order:
no-driver-assigned-error: Nenhum motorista atribuído a este pedido.
failed-to-load-order-label: Falha ao carregar o rótulo do pedido.
failed-to-load-waypoint-label: Falha ao carregar o rótulo do ponto de passagem.
failed-to-load-entity-label: Falha ao carregar o rótulo da entidade.
unable-to-add-entity: Não foi possível adicionar nova entidade ao pedido.
assign-driver-success: O motorista ({driverName}) foi atribuído ao pedido {orderId}.
cancel-title: Tem certeza de que deseja cancelar este pedido?
Expand Down
2 changes: 2 additions & 0 deletions translations/ru-ru.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,7 @@ order:
order-metadata: Метаданные заказа
order-label: Этикетка заказа
waypoint-label: Этикетка промежуточной точки
entity-label: Этикетка сущности
current-eta: Текущее время прибытия
ect: Ожидаемое время прибытия
current-destination: Текущее место назначения
Expand All @@ -707,6 +708,7 @@ order:
no-driver-assigned-error: К этому заказу не назначен водитель.
failed-to-load-order-label: Не удалось загрузить метку заказа.
failed-to-load-waypoint-label: Не удалось загрузить метку контрольной точки.
failed-to-load-entity-label: Не удалось загрузить метку сущности.
unable-to-add-entity: Невозможно добавить новый объект в заказ.
assign-driver-success: Водитель ({driverName}) был назначен на заказ {orderId}.
cancel-title: Вы уверены, что хотите отменить этот заказ?
Expand Down