diff --git a/api/src/models/user.model.js b/api/src/models/user.model.js index ad5b56669..fccebd25f 100644 --- a/api/src/models/user.model.js +++ b/api/src/models/user.model.js @@ -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); @@ -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; diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index d30dda77c..b27adc198 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -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'); @@ -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, @@ -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 { @@ -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, @@ -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); diff --git a/api/src/utils/recorded-user-name.js b/api/src/utils/recorded-user-name.js new file mode 100644 index 000000000..218ce00fd --- /dev/null +++ b/api/src/utils/recorded-user-name.js @@ -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 }; diff --git a/api/tests/unit/security/reported-tenant-boundaries.test.js b/api/tests/unit/security/reported-tenant-boundaries.test.js index ae35000ab..0c4a9274e 100644 --- a/api/tests/unit/security/reported-tenant-boundaries.test.js +++ b/api/tests/unit/security/reported-tenant-boundaries.test.js @@ -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: '', + }); +}); diff --git a/docs/PAYMENT_ATTRIBUTION_EDITOR_FOLLOWUP.md b/docs/PAYMENT_ATTRIBUTION_EDITOR_FOLLOWUP.md new file mode 100644 index 000000000..232e0c490 --- /dev/null +++ b/docs/PAYMENT_ATTRIBUTION_EDITOR_FOLLOWUP.md @@ -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. diff --git a/frontend/modals/user.html b/frontend/modals/user.html index 39f0043d9..0bd5729b8 100644 --- a/frontend/modals/user.html +++ b/frontend/modals/user.html @@ -97,6 +97,12 @@

+
-