Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d43f29c
Add Magento registry, boot-gated Vue adapters, and desk JS yield on d…
Aug 18, 2026
0b932f9
Register AuthorizationDialog in generated Vue component types.
Aug 18, 2026
c441209
Enhance POS components with conditional rendering and new features
Aug 24, 2026
7898316
Enhance stock validation and invoice handling in POS
Aug 24, 2026
3c3a745
Enhance InvoiceCart and posCart for improved free item handling
Aug 25, 2026
1e53275
feat: implement POS authorization gate system with action registry, v…
MostafaKadry Aug 25, 2026
6e0b904
feat: add Item Price test protection and support for extensible POS o…
MostafaKadry Aug 26, 2026
5d4c16f
Enhance customer creation and offer validation in POS
Sep 6, 2026
67c4b3c
fix: decide promotions ownership at call time, not import
Sep 8, 2026
9b69390
fix: keep POS payment GL in company currency via base_amount
Sep 8, 2026
aa5d4d0
Fixed. The wallet MoP cache no longer lives forever without invalidat…
Sep 8, 2026
9eac646
Moved _validate_pos_payment_accounts() to after super().validate().
Sep 8, 2026
3e44013
Done. `_resolve_pos_customer` now:
Sep 8, 2026
00e17f5
Done. Gift Pool / GWP no longer live in core cart.
Sep 8, 2026
3714e73
Pin same-SKU GWP carve/restore with a unit-tested helper.
Sep 8, 2026
8cebe05
Removed the unreachable block. Behavior is unchanged: leaves return i…
Sep 8, 2026
a81cb0c
after_customer_insert now logs and continues on hook failure, matchin…
Sep 8, 2026
17d4137
Make split smoke tests take --site and fix apps path bootstrap order.
Sep 8, 2026
37564f1
Rewrote test_split_smoke.py so it fails when the seam is broken, not …
Sep 8, 2026
626acef
IntegrationTestCase isn’t available in this Frappe version (frappe.te…
Sep 8, 2026
a7b2bdd
refactor(auth): fix authorization flow and remove app duplication
MostafaKadry Sep 9, 2026
cd8172f
Rate limit: 20/min, scoped to IP + approver instead of IP only.
MostafaKadry Sep 9, 2026
479d06a
Authorization gate: Now only applies to POS documents and skips inter…
MostafaKadry Sep 9, 2026
e55cf49
Merge branch 'develop' into feature/thin-develop-split
MostafaKadry Sep 9, 2026
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 POS/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ActionButton: typeof import('./src/components/common/ActionButton.vue')['default']
AuthorizationDialog: typeof import('./src/components/common/AuthorizationDialog.vue')['default']
AutocompleteSelect: typeof import('./src/components/common/AutocompleteSelect.vue')['default']
BatchSerialDialog: typeof import('./src/components/sale/BatchSerialDialog.vue')['default']
CheckboxField: typeof import('./src/components/settings/CheckboxField.vue')['default']
Expand Down
2 changes: 2 additions & 0 deletions POS/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
<div>
<router-view :key="translationVersion" />
<Toast />
<AuthorizationDialog />
</div>
</template>

<script setup>
import AuthorizationDialog from "@/components/common/AuthorizationDialog.vue";
import Toast from "@/components/common/Toast.vue";
import { translationVersion } from "@/utils/translation";
</script>
205 changes: 205 additions & 0 deletions POS/src/components/common/AuthorizationDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<template>
<div
v-if="state.open"
class="pointer-events-auto fixed inset-0 z-[var(--z-authorization)] flex items-center justify-center bg-black/50 p-4"
@click.self="onCancel"
@pointerdown.stop
>
<FocusScope trapped as-child>
<div class="w-full max-w-sm rounded-xl bg-white shadow-xl dark:bg-gray-800">
<div class="border-b border-gray-200 px-5 py-4 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ __("Authorization Required") }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ __("A manager must approve this action.") }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High — the approver is never shown what they are approving.

This dialog renders "A manager must approve this action.", an approver picker and a PIN box. It never shows the action name, the amount, the customer or the invoice. state.actionLabel is captured in useAuthorization.js and never rendered anywhere.

The refund amount is supplied by the client (ReturnInvoiceDialog.vue, in the requireAuthorization context) and becomes the grant's binding ceiling on the server. So a manager who walks to a till and types a PIN has no way to tell whether they are approving a 12.00 refund or a 12,000.00 one — which is the one thing this control exists to prevent.

Render the action, the amount and return_against in the dialog body.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MostafaKadry
please review

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@engahmed1190 I think this is more of a UX improvement than a security/correctness issue in our current flow.

The authorization dialog is not shown to the manager on a separate device or in isolation. The cashier calls the manager over to the cashier's terminal, where the full invoice and its details are already visible. The customer is also physically present with the printed invoice, which is required to process the return, so the manager can verify the printed invoice against the invoice shown on the cashier's screen before approving.

The Auth Required dialog is simply the final approval step after those checks have already been performed.

This flow is also exactly what the business team presented to the customer, and it matches the current customer's agreed requirements. So I would not classify the absence of these details in the authorization dialog as a critical security gap. I agree that showing the action, amount, and return reference could be a useful UX improvement, but I don't think it should be treated as a high-severity issue for this flow.

</p>
</div>

<dl
v-if="summaryRows.length"
class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 border-b border-gray-200 bg-gray-50 px-5 py-3 text-sm dark:border-gray-700 dark:bg-gray-900/40"
>
<template v-for="row in summaryRows" :key="row.key">
<dt class="text-gray-500 dark:text-gray-400">{{ row.label }}</dt>
<dd
class="text-end font-medium text-gray-900 break-all dark:text-gray-100"
:data-test="`auth-summary-${row.key}`"
>
{{ row.value }}
</dd>
</template>
</dl>

<div class="space-y-4 px-5 py-4">
<div v-if="loading" class="py-6 text-center text-sm text-gray-500">
{{ __("Loading approvers…") }}
</div>

<div
v-else-if="!authorizers.length"
class="rounded-lg bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/30 dark:text-amber-200"
>
{{
__(
"No approver is available. Ask a System Manager to set an authorization PIN for a manager."
)
}}
</div>

<template v-else>
<div>
<label
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ __("Approver") }}
</label>
<select
v-model="approver"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
>
<option
v-for="person in authorizers"
:key="person.user"
:value="person.user"
>
{{ person.full_name || person.user }}
</option>
</select>
</div>

<div>
<label
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ __("PIN") }}
</label>
<input
ref="pinInput"
v-model="pin"
type="password"
inputmode="numeric"
autocomplete="off"
:maxlength="pinLength"
:placeholder="__('{0}-digit PIN', [pinLength])"
class="w-full rounded-lg border px-3 py-2 text-center text-2xl tracking-[0.5em] dark:bg-gray-700 dark:text-gray-100"
:class="
errorMessage
? 'border-red-500'
: 'border-gray-300 dark:border-gray-600'
"
@keyup.enter="onApprove"
/>
<p v-if="errorMessage" class="mt-1.5 text-sm text-red-600">
{{ errorMessage }}
</p>
</div>
</template>
</div>

<div
class="flex justify-end gap-2 border-t border-gray-200 px-5 py-3 dark:border-gray-700"
>
<button
type="button"
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
@click="onCancel"
>
{{ __("Cancel") }}
</button>
<button
type="button"
:disabled="!canApprove"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
@click="onApprove"
>
{{ verifying ? __("Verifying…") : __("Approve") }}
</button>
</div>
</div>
</FocusScope>
</div>
</template>

<script setup>
import { useAuthorizationDialog } from "@/composables/useAuthorization";
import { isRateLimitError } from "@/utils/authorizationError";
import { buildAuthorizationSummary } from "@/utils/authorizationSummary";
import { computed, nextTick, ref, watch } from "vue";
import { FocusScope } from "reka-ui";

const {
state,
loadAuthorizers,
requestGrant,
pinLength: getPinLength,
approve,
cancel,
} = useAuthorizationDialog();

const authorizers = ref([]);
const approver = ref("");
const pin = ref("");
const errorMessage = ref("");
const loading = ref(false);
const verifying = ref(false);
const pinInput = ref(null);
const pinLength = ref(getPinLength());
const summaryRows = computed(() => buildAuthorizationSummary(state));

const canApprove = computed(
() => Boolean(approver.value) && pin.value.length === pinLength.value && !verifying.value
);

watch(
() => state.open,
async (open) => {
if (!open) return;

authorizers.value = [];
approver.value = "";
pin.value = "";
pinLength.value = getPinLength();
errorMessage.value = "";
loading.value = true;

authorizers.value = await loadAuthorizers();
if (authorizers.value.length) {
approver.value = authorizers.value[0].user;
}
loading.value = false;

await nextTick();
pinInput.value?.focus();
}
);

async function onApprove() {
if (!canApprove.value) return;

verifying.value = true;
errorMessage.value = "";

try {
const result = await requestGrant(approver.value, pin.value);
if (result?.authorized) {
approve(result);
return;
}
errorMessage.value = result?.message || __("Authorization failed");
} catch (error) {
errorMessage.value = isRateLimitError(error)
? __("Too many PIN attempts just now. Wait a moment and try again.")
: error?.message || __("Authorization failed");
} finally {
verifying.value = false;
pin.value = "";
await nextTick();
pinInput.value?.focus();
}
}

function onCancel() {
cancel();
}
</script>
76 changes: 69 additions & 7 deletions POS/src/components/common/SelectInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,45 @@
:ref="(el) => (optionRefs[index] = el)"
tabindex="0"
role="option"
:aria-selected="option.value === modelValue"
:aria-selected="isSelected(option.value)"
class="px-2 py-1.5 text-base cursor-pointer text-start transition-colors focus:outline-none"
:class="
option.value === modelValue
isSelected(option.value)
? 'bg-blue-50 text-blue-700'
: 'text-gray-800 hover:bg-gray-100 focus:bg-gray-100'
"
>
<div v-if="option.subtitle" class="flex flex-col">
<span class="text-sm font-medium">{{ option.label }}</span>
<span class="text-xs text-gray-500">{{ option.subtitle }}</span>
<div class="flex items-center gap-2">
<span
v-if="multiple"
class="flex-shrink-0 w-4 h-4 border rounded flex items-center justify-center"
:class="
isSelected(option.value)
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-300 bg-white'
"
>
<svg
v-if="isSelected(option.value)"
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
</span>
<div v-if="option.subtitle" class="flex flex-col min-w-0">
<span class="text-sm font-medium">{{ option.label }}</span>
<span class="text-xs text-gray-500">{{ option.subtitle }}</span>
</div>
<span v-else class="min-w-0 truncate">{{ option.label }}</span>
</div>
<span v-else>{{ option.label }}</span>
</div>
</div>
</div>
Expand All @@ -115,7 +141,7 @@ defineOptions({

const props = defineProps({
modelValue: {
type: [String, Number],
type: [String, Number, Array],
default: "",
},
options: {
Expand Down Expand Up @@ -150,6 +176,10 @@ const props = defineProps({
type: Number,
default: 50, // Limit displayed options for performance
},
multiple: {
type: Boolean,
default: false,
},
});

const emit = defineEmits(["update:modelValue", "change"]);
Expand All @@ -163,11 +193,31 @@ const optionRefs = ref([]);
const dropdownPosition = ref({ top: 0, left: 0, width: 0 });
const searchQuery = ref("");

const selectedValues = computed(() => {
if (props.multiple) {
return Array.isArray(props.modelValue) ? props.modelValue : [];
}
return props.modelValue === 0 || props.modelValue ? [props.modelValue] : [];
});

const selectedLabel = computed(() => {
if (props.multiple) {
const count = selectedValues.value.length;
if (!count) return "";
if (count === 1) {
const selected = props.options.find((opt) => opt.value === selectedValues.value[0]);
return selected?.label || String(selectedValues.value[0]);
}
return `${count} selected`;
}
const selected = props.options.find((opt) => opt.value === props.modelValue);
return selected?.label || "";
});

function isSelected(value) {
return selectedValues.value.includes(value);
}

const filteredOptions = computed(() => {
let result = props.options;

Expand Down Expand Up @@ -237,6 +287,18 @@ function openAndFocusFirst() {
}

function selectOption(option) {
if (props.multiple) {
const current = [...selectedValues.value];
const idx = current.indexOf(option.value);
if (idx >= 0) {
current.splice(idx, 1);
} else {
current.push(option.value);
}
emit("update:modelValue", current);
emit("change", current);
return;
}
emit("update:modelValue", option.value);
emit("change", option.value);
close();
Expand Down
5 changes: 3 additions & 2 deletions POS/src/components/sale/CouponDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
</template>

<script setup>
import { promoApi } from "@/utils/promoApi";
import { DEFAULT_CURRENCY, formatCurrency as formatCurrencyUtil } from "@/utils/currency";
import { Button, Dialog, Input, createResource } from "frappe-ui";
import { ref, watch } from "vue";
Expand Down Expand Up @@ -281,7 +282,7 @@ const errorMessage = ref("");

// Resource to load gift cards
const giftCardsResource = createResource({
url: "pos_next.api.offers.get_active_coupons",
url: promoApi.getActiveCoupons(),
makeParams() {
return {
customer: props.customer,
Expand All @@ -296,7 +297,7 @@ const giftCardsResource = createResource({

// Resource to validate coupon
const couponResource = createResource({
url: "pos_next.api.offers.validate_coupon",
url: promoApi.validateCoupon(),
makeParams() {
return {
coupon_code: couponCode.value,
Expand Down
Loading
Loading