fix: order status dropdown could bypass confirm-gated cancel/refund
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

The status <select> on the order detail page let an admin jump
straight to 'cancelled'/'refunded' with no confirmation, bypassing the
dedicated cancel()/requestRefund() buttons that do confirm. It also
stayed editable after an order reached a terminal status, so it could
be moved backward out of cancelled/refunded.

- Dropdown options now exclude terminal statuses; reaching them
  requires the confirm-gated buttons.
- setStatus() guards against a terminal status slipping through
  regardless.
- Once an order is terminal (isTerminal(), already computed but
  unused), the dropdown and both action buttons are disabled.
- Same fix applied to the orders list page's bulk status dropdown,
  which had the identical gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-13 07:42:55 +04:00
parent 4ef5ea2f58
commit 8a91a862ca
4 changed files with 18 additions and 6 deletions

View File

@@ -46,14 +46,17 @@
</div>
<div class="card no-print">
<h3>{{ 'adminOrders.changeStatus' | translate }}</h3>
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)">
@for (status of statuses; track status) {
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)" [disabled]="isTerminal()">
@for (status of selectableStatuses; track status) {
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
}
@if (isTerminal()) {
<option [value]="order.status">{{ ('adminOrders.status.' + order.status) | translate }}</option>
}
</select>
<div class="actions">
<app-button variant="secondary" size="sm" (click)="requestRefund(order.id)">{{ 'adminOrders.requestRefund' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="cancel(order.id)">{{ 'adminOrders.cancelOrder' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="requestRefund(order.id)" [disabled]="isTerminal()">{{ 'adminOrders.requestRefund' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="cancel(order.id)" [disabled]="isTerminal()">{{ 'adminOrders.cancelOrder' | translate }}</app-button>
</div>
</div>
</section>

View File

@@ -30,6 +30,8 @@ export class AdminOrderDetailPageComponent {
private readonly translate = inject(TranslateService);
readonly statuses: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
/** Terminal statuses are only reachable via the confirm-gated cancel()/requestRefund(), never the raw dropdown. */
readonly selectableStatuses: AdminOrderStatus[] = this.statuses.filter(status => !TERMINAL_STATUSES.includes(status));
readonly workflowSteps = WORKFLOW_STEPS;
readonly noteDraft = signal('');
readonly internalNoteDraft = signal('');
@@ -74,6 +76,11 @@ export class AdminOrderDetailPageComponent {
}
setStatus(id: string, status: AdminOrderStatus): void {
if (TERMINAL_STATUSES.includes(status)) {
// Unreachable from the dropdown (options are filtered), but guard anyway
// since terminal transitions must always go through the confirm dialog.
return;
}
this.facade.setStatus(id, status);
}

View File

@@ -37,8 +37,8 @@
<div class="bulk-actions">
<span>{{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }}</span>
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="bulkStatusValue()" (ngModelChange)="bulkStatusValue.set($event)">
@for (status of statuses; track status) {
@if (status !== 'all') { <option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option> }
@for (status of bulkSelectableStatuses; track status) {
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
}
</select>
<app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button>

View File

@@ -30,6 +30,8 @@ export class AdminOrdersListPageComponent {
private readonly languageService = inject(LanguageService);
readonly statuses = ['all', 'pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'] as const;
/** Bulk status change excludes terminal statuses - cancel/refund must go through the confirm-gated single-order flow. */
readonly bulkSelectableStatuses: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered'];
readonly allColumns = ALL_ORDER_COLUMNS;
protected readonly columnsPanelOpen = signal(false);
protected readonly bulkStatusValue = signal<AdminOrderStatus>('pending');