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
21 changes: 21 additions & 0 deletions api/src/models/user.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,20 @@ userSchema.statics.userInsertUpdate = async function (data, id, context) {
const verify_password = data.check_password;
let password = '';
if (verify_password === 'yes' && data.usertype !== 'api') {
if (
typeof data.password !== 'string' ||
data.password.length < 5 ||
data.password.length > 20
) {
return {
status: false,
data: null,
message: 'Password must contain between 5 and 20 characters',
};
}
if (data.retype_password !== undefined && data.retype_password !== data.password) {
return { status: false, data: null, message: 'Passwords do not match' };
}
// PHP: 'password' => 'trim|base64_encode' filter applied first
const base64Password = Buffer.from(data.password).toString('base64');
password = await bcrypt.hash(base64Password, 12);
Expand Down Expand Up @@ -1336,6 +1350,13 @@ userSchema.statics.userInsertUpdate = async function (data, id, context) {
license: context.license,
};

// updateOne does not run the save hook. Explicit resets must persist the
// hash and invalidate older sessions; ordinary edits preserve credentials.
if (id && verify_password === 'yes' && data.usertype !== 'api') {
updateData.password = password;
updateData.passwordChangedAt = new Date();
}

// Existing API credentials are write-only: a blank edit keeps the key.
if (id && data.usertype === 'api' && !appKey.trim()) delete updateData.apikey;

Expand Down
12 changes: 11 additions & 1 deletion api/src/repositories/sale.repository.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const { recordedUserName } = require('../utils/recorded-user-name');
const { reportableSales, reportSaleTotal } = require('../helpers/reportable-sales');
const pricingAuthority = require('../services/pricing-authority');
const Money = require('../utils/currency');
Expand Down Expand Up @@ -4102,6 +4103,7 @@ class SalesRepository {
branch_name: 1,
user_id: 1,
user_name: 1,
'origin.actor_name': 1,
sales_id: 1,
date: 1,
items: 1,
Expand Down Expand Up @@ -4175,6 +4177,10 @@ class SalesRepository {
.lean();

const list = (Array.isArray(rawList) ? rawList : []).map((row) => {
row = {
...row,
user_name: recordedUserName(row.user_name, row.created_by, row.origin?.actor_name),
};
if (!row.captain_payments?.length || row.payment_status !== 'Paid') return row;
const digits = row.captain_transfer_allocation?.currencyDigits;
return {
Expand Down Expand Up @@ -4459,6 +4465,7 @@ class SalesRepository {
branch_name: 1,
user_id: 1,
user_name: 1,
'origin.actor_name': 1,
sales_id: 1,
date: 1,
items: 1,
Expand Down Expand Up @@ -4528,7 +4535,10 @@ class SalesRepository {
.limit(limit)
.lean();

const list = Array.isArray(rawList) ? rawList : [];
const list = (Array.isArray(rawList) ? rawList : []).map((row) => ({
...row,
user_name: recordedUserName(row.user_name, row.created_by, row.origin?.actor_name),
}));

const total = await Model.countDocuments(filters);

Expand Down
17 changes: 17 additions & 0 deletions api/src/utils/recorded-user-name.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use strict';

// Use the recorded creator, never whoever happens to be viewing/editing a bill.
function recordedUserName(...values) {
return (
values
.find(
(value) =>
typeof value === 'string' &&
value.trim() &&
!/^(undefined|null|\[object Object\])$/i.test(value.trim())
)
?.trim() || ''
);
}

module.exports = { recordedUserName };
153 changes: 153 additions & 0 deletions api/tests/unit/security/reported-tenant-boundaries.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,156 @@ test('API key validation allows blank edits but rejects blank creates and short
).toBe(invalid);
}
});

test('explicit staff password reset persists the hash and revokes old sessions without changing other tenants', async () => {
const User = require('../../../src/models/user.model');
const bcrypt = require('bcryptjs');
const id = new ObjectId();
const original = await bcrypt.hash(Buffer.from('Old@12345').toString('base64'), 4);
await db.collection('users').insertOne({
_id: id,
license: licenseA,
username: 'staff',
email: 'staff@example.com',
password: original,
usertype: 'staff',
});
await db.collection('branches').insertOne({ _id: branchA, license: licenseA, branch_name: 'A' });
const plan = jest.spyOn(BaseModel.prototype, 'checkPlan').mockResolvedValue(0);
const log = jest.spyOn(BaseModel.prototype, 'changeLog').mockResolvedValue();
try {
const data = {
usertype: 'staff',
name: 'staff',
email: 'staff@example.com',
firstname: 'Staff',
branch_data: [{ branch_id: String(branchA), branch_name: 'A' }],
user_status: 'active',
check_password: 'no',
};
const context = {
license: licenseA,
user: { ...request(id).user, username: 'owner', usertype: 'super_admin' },
};
expect((await User.userInsertUpdate(data, String(id), context)).status).toBe(true);
expect((await db.collection('users').findOne({ _id: id })).password).toBe(original);
for (const password of ['', 'a']) {
expect(
(
await User.userInsertUpdate(
{ ...data, check_password: 'yes', password },
String(id),
context
)
).status
).toBe(false);
}
expect(
(
await User.userInsertUpdate(
{ ...data, check_password: 'yes', password: 'New@12345', retype_password: 'Different' },
String(id),
context
)
).status
).toBe(false);
expect(
(
await User.userInsertUpdate(
{ ...data, check_password: 'yes', password: 'New@12345', retype_password: 'New@12345' },
String(id),
context
)
).status
).toBe(true);
const stored = await db.collection('users').findOne({ _id: id });
expect(await bcrypt.compare(Buffer.from('New@12345').toString('base64'), stored.password)).toBe(
true
);
expect(await bcrypt.compare(Buffer.from('Old@12345').toString('base64'), stored.password)).toBe(
false
);
expect(User.hydrate(stored).changedPasswordAfter(Math.floor(Date.now() / 1000) - 60)).toBe(
true
);
await User.userInsertUpdate(
{ ...data, check_password: 'yes', password: 'Wrong@12345' },
String(id),
{ ...context, license: licenseB }
);
expect((await db.collection('users').findOne({ _id: id })).password).toBe(stored.password);
} finally {
plan.mockRestore();
log.mockRestore();
}
});

test('payment reports recover Captain origin names and preserve saved POS names in tenant scope', async () => {
const sales = require('../../../src/repositories/sale.repository');
const Model =
mongoose.models.ReportAttribution ||
mongoose.model(
'ReportAttribution',
new mongoose.Schema({}, { strict: false, collection: 'sales' })
);
const template = {
license: licenseA,
branch_id: branchA,
date: new Date('2026-10-17T12:00:00Z'),
payment_status: 'Paid',
sale_process: 'Add',
sales_total: 100,
};
await db.collection('sales').deleteMany({});
await db.collection('sales').insertMany([
{
...template,
sales_id: 'captain',
origin: { actor_name: 'Captain original' },
updated_by: 'Later cashier',
},
{ ...template, sales_id: 'pos', user_name: 'POS original', origin: { actor_name: 'Other' } },
{ ...template, sales_id: 'legacy', user_name: 'undefined', created_by: 'Legacy original' },
{ ...template, sales_id: 'unknown' },
{ ...template, license: licenseB, sales_id: 'foreign', origin: { actor_name: 'Foreign user' } },
]);
const result = await runWithRequestContext({ license: licenseA, currentBranch: branchA }, () =>
sales.paymentSalesTransactionReportPage(
{
branchid: [String(branchA)],
starting_date: '2026-10-17T00:00:00Z',
ending_date: '2026-10-17T23:59:59Z',
},
{ limit: 20 },
{ SaleModel: Model }
)
);
expect(result.status).toBe(true);
expect(Object.fromEntries(result.data.list.map((row) => [row.sales_id, row.user_name]))).toEqual({
captain: 'Captain original',
pos: 'POS original',
legacy: 'Legacy original',
unknown: '',
});
await db.collection('sales').updateMany({}, { $set: { sale_process: 'PartialReturn' } });
const returned = await runWithRequestContext({ license: licenseA, currentBranch: branchA }, () =>
sales.paymentReturnSalesTranscationReportTable(
{
branchid: [String(branchA)],
starting_date: '2026-10-17T00:00:00Z',
ending_date: '2026-10-17T23:59:59Z',
},
{ limit: 20 },
{ SaleModel: Model }
)
);
expect(returned.status).toBe(true);
expect(
Object.fromEntries(returned.data.list.map((row) => [row.sales_id, row.user_name]))
).toEqual({
captain: 'Captain original',
pos: 'POS original',
legacy: 'Legacy original',
unknown: '',
});
});
28 changes: 28 additions & 0 deletions docs/PAYMENT_ATTRIBUTION_EDITOR_FOLLOWUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Payment attribution and record-editor follow-up

Captain orders retain the creator in `origin.actor_name`. Payment reports previously read only `user_name`, producing literal `undefined`. Sale and return transaction endpoints now prefer saved user_name, legacy created_by, then the original Captain actor name. Frontend rows and exports share this fallback, reject placeholder strings and escape names. No later editor or viewer is substituted. Unknown names show an em dash. Existing bills need no backfill.

Edit User offers an explicit Change Password option. Ordinary edits preserve credentials. Explicit resets validate the password and confirmation, persist the bcrypt hash, and stamp passwordChangedAt to invalidate older sessions. Existing user-write authorization and tenant filters remain. No real account was reset during testing.

Shared record dialogs fit their contents despite legacy inline height styles. Supplier and Units browser checks at 1200px and 390px measured 17px below the buttons without horizontal overflow.

## Earlier reports

Existing regression tests cover branch Save, active-tab preservation, phone binding, saved-state selection, stale country responses, optional country-specific branch tax registration, new branch regional fields, user permission errors, Accountant-role saving, paired passwords and category shape colors. The preceding change restored the desktop menu and table text alignment. Device counts are already unlimited by default.

State defaults use the saved branch, not IP geolocation. Missing state cannot safely be inferred from city. Legacy Core Settings reminder preferences do not implement a sending scheduler; this change does not send messages. Partner venue commission is under Restaurant > Hotels and other venues, with results under Reports > Money > Commission owed. The separate tax calculation foundation remains internal, not a new report screen.

## Sync and rollout

Report attribution is read-only. Branch-scoped sales already carry origin. No migration or new collection is needed. Users already have a global-within-tenant batch lane in POS api/src/sync/collections.json, Gateway apps/sync-gateway/src/collections.js and apps/sync-agent/sync-config.json. Reset uses existing password/passwordChangedAt fields and advances updated_date. The agent transfers whole documents using updated_date/_id checkpoints and last-write-wins. No plaintext password, session or reset command is newly replicated; hashes follow the existing user lane.

Offline tills retain their last synchronized account until reconnect. They need the updated user document before the password/session cutoff applies locally. There are no new deletion or file behaviors. Physical cross-till credential delivery remains unverified. The installed white-label 1.9.2 still contains old branch validation code and uses its own branded stable feed. Source checks do not mean that installation is updated.

## Validation

- Read-only inspection confirmed the original actor on the three reported Captain bills.
- 283 API regressions passed for user model, tenant isolation and Captain transfer; follow-up sale/return attribution checks passed.
- 27 payment/display/export regressions passed.
- Form regressions, frontend production build and offscreen Chromium layout checks passed.

Full local checks: lint, formatting, API docs, packaging and attribution passed when run directly. The final desktop run passed 4,339 tests, with four skipped; its customer-branding guard found a branded name in this note. The name was removed and all six branding tests passed. An earlier stale server-message catalog was corrected and all 51 i18n tests passed on rerun.
8 changes: 7 additions & 1 deletion frontend/modals/user.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ <h4 id="exampleModalCenterTitle">
</label>
<input type="text" class="form-control border-control user-text-clear" name="name" id="users_name" minlength="3" maxlength="250" placeholder="Enter the username" data-t-placeholder="lang_enter_the_username" autocomplete="off" required>
</div>
<div class="form-group col-md-12" id="user_password_change_row" hidden>
<label for="user_change_password">
<input type="checkbox" id="user_change_password" onchange="PosnicPro.users.setPasswordEditing(this.checked)">
<lang class="lang_changepassword_title">Change Password</lang>
</label>
</div>
<div class="form-group col-md-12">
<label for="users_password">
<lang class="lang_enterpassword_title">Password</lang>
Expand All @@ -107,7 +113,7 @@ <h4 id="exampleModalCenterTitle">
<input type="hidden" name="id" id="users_id">
</div>
<div class="form-group col-md-12">
<label for="users_password">
<label for="users_retype_password">
<lang class="lang_retypepassword_title">Retype Password </lang>
<span class="text-danger">*</span>
</label>
Expand Down
2 changes: 1 addition & 1 deletion frontend/static/script/js/core/record-workspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@
if (user) {
var profile = section(user, 'lang_vieweditprofile_title', 'Profile Details');
profile.parentElement.classList.add('record-staff-profile');
moveFields(user, profile, ['users_firstname', 'users_lastname', 'users_name', 'users_email', 'users_password', 'users_retype_password']);
moveFields(user, profile, ['users_firstname', 'users_lastname', 'users_name', 'users_email', 'user_change_password', 'users_password', 'users_retype_password']);
user.prepend(profile.parentElement);
var photo = user.querySelector('.profilebox');
if (photo) user.prepend(photo);
Expand Down
13 changes: 9 additions & 4 deletions frontend/static/script/js/modules/js/report_payment.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
PosnicPro.paymentreport = {
recordedUser: function (row) {
return [row.user_name, row.created_by, row.origin && row.origin.actor_name].find(function (value) {
return typeof value === 'string' && value.trim() && !/^(undefined|null|\[object Object\])$/i.test(value.trim());
}) || '—';
},
showDataTablePage: function () {
var loader = $(".loader-sales-payment-report");
loader.find(".loadingSpinner:first").remove();
Expand Down Expand Up @@ -190,7 +195,7 @@ PosnicPro.paymentransaction = {
let DateFormat = moment(update_timeStamp_value).tz(timeZone).format('YYYY/MM/DD LT');
let updateDate = PosnicPro.convertDate(DateFormat);
let row_no = (table.data('current_page') - 1) * table.data('per_page') + i + 1;
let trow = '<tr> <td scope="row">' + row_no + '</td> <td>' + row.sales_id + '</td> <td>' + updateDate + '</td> <td>' + row.user_name + '</td> <td>' + row.payment_mode + '</td> <td class="text-right">' + currency + '&nbsp;<span class="number">' + (row.report_amount ?? row.items_total) + '</span></td></tr>';
let trow = '<tr> <td scope="row">' + row_no + '</td> <td>' + row.sales_id + '</td> <td>' + updateDate + '</td> <td>' + $('<span>').text(PosnicPro.paymentreport.recordedUser(row)).html() + '</td> <td>' + row.payment_mode + '</td> <td class="text-right">' + currency + '&nbsp;<span class="number">' + (row.report_amount ?? row.items_total) + '</span></td></tr>';
$('#view_paymentransaction').children('tbody').append(trow);
var digits = Number.isInteger(row.currencyDigits) && row.currencyDigits >= 0 && row.currencyDigits <= 4 ? row.currencyDigits : 2;
$('#view_paymentransaction tbody tr:last span.number').number(true, digits);
Expand All @@ -204,7 +209,7 @@ PosnicPro.paymentransaction = {
let DateFormat = moment(update_timeStamp_value).tz(timeZone).format('YYYY/MM/DD LT');
let date = PosnicPro.convertDate(DateFormat);
let id = val.sales_id;
let customer = val.user_name;
let customer = PosnicPro.paymentreport.recordedUser(val);
let mode = val.payment_mode;
let total = val.report_amount ?? val.items_total;
paymentsalereportexportdata.push({SaleId: id, Date: date, UserName: customer, PaymentMethod: mode, Amount: total});
Expand Down Expand Up @@ -300,7 +305,7 @@ PosnicPro.paymentreturntransaction = {
let DateFormat = moment(update_timeStamp_value).tz(timeZone).format('YYYY/MM/DD LT');
let updateDate = PosnicPro.convertDate(DateFormat);
let row_no = (table.data('current_page') - 1) * table.data('per_page') + i + 1;
let trow = '<tr> <td scope="row">' + row_no + '</td> <td>' + row.sales_id + '</td> <td>' + updateDate + '</td> <td>' + row.user_name + '</td> <td>' + row.payment_mode + '</td> <td class="text-right">' + currency + '&nbsp;<span class="number">' + row.items_return_total + '</span></td></tr>';
let trow = '<tr> <td scope="row">' + row_no + '</td> <td>' + row.sales_id + '</td> <td>' + updateDate + '</td> <td>' + $('<span>').text(PosnicPro.paymentreport.recordedUser(row)).html() + '</td> <td>' + row.payment_mode + '</td> <td class="text-right">' + currency + '&nbsp;<span class="number">' + row.items_return_total + '</span></td></tr>';
$('#view_paymentreturntransaction').children('tbody').append(trow);
$('span.number').number(true, 2);
}
Expand All @@ -313,7 +318,7 @@ PosnicPro.paymentreturntransaction = {
let DateFormat = moment(update_timeStamp_value).tz(timeZone).format('YYYY/MM/DD LT');
let date = PosnicPro.convertDate(DateFormat);
let id = val.sales_id;
let customer = val.user_name;
let customer = PosnicPro.paymentreport.recordedUser(val);
let mode = val.payment_mode;
let total = val.items_return_total;
paymentsalereportexportdata.push({SaleId: id, Date: date, UserName: customer, PaymentMethod: mode, Amount: total});
Expand Down
Loading