// backend/services/hanaUsers.js // Manages portal users in SAP SQL Server // // USER ROLES: // manager — L1 approver: reviews PENDING BOM requests // sr_manager — L2 approver: reviews L1_APPROVED BOM requests // sap_adder — Final approver: pushes to SAP B1, manages portal users // // TABLE: ZCUST_USERS // ID INT IDENTITY(1,1) PRIMARY KEY // USERNAME NVARCHAR(50) UNIQUE NOT NULL // PASSWORD NVARCHAR(200) -- bcrypt hash // FULL_NAME NVARCHAR(100) // EMAIL NVARCHAR(150) // ROLE NVARCHAR(20) -- 'manager' | 'sr_manager' | 'sap_adder' // ACTIVE TINYINT DEFAULT 1 // CREATED_AT DATETIME2 // LAST_LOGIN DATETIME2 const bcrypt = require('bcryptjs'); const { getPool } = require('./appSqlPool'); const { DEFAULT_COMPANY } = require('./companyConfig'); const cryptoUtil = require('./cryptoUtil'); const DB_SCHEMA = DEFAULT_COMPANY; const TABLE = `[dbo].[ZCUST_USERS]`; const SEQ = `[dbo].[ZCUST_USERS_SEQ]`; const VALID_ROLES = ['manager', 'sr_manager', 'sap_adder', 'system_admin', 'board_member', 'admin', 'user', 'project_approver']; async function exec(sqlQuery, params = []) { const pool = await getPool(); const request = pool.request(); params.forEach((param, index) => { request.input(`param${index}`, param); }); const replacedSql = sqlQuery.replace(/\?/g, (match, offset, string) => { const paramIndex = (string.slice(0, offset).match(/\?/g) || []).length; return `@param${paramIndex}`; }); const result = await request.query(replacedSql); return result.recordset || []; } // ── Bootstrap ───────────────────────────────────────────────────────────────── async function bootstrap() { console.log('[SQL-USERS] Checking user table…'); const alreadyExists = e => { const m = (e.message || '').toLowerCase(); return m.includes('already exists') || m.includes('duplicate') || m.includes('existing object') || m.includes('there is already an object'); }; // Table — ROLE is NVARCHAR(20) to hold 'sr_manager' let tableExisted = false; await exec(` CREATE TABLE ${TABLE} ( ID INT IDENTITY(1,1) PRIMARY KEY, USERNAME NVARCHAR(50) NOT NULL, PASSWORD NVARCHAR(200) NOT NULL, FULL_NAME NVARCHAR(100), EMAIL NVARCHAR(150), ROLE NVARCHAR(20) DEFAULT 'manager', ACTIVE TINYINT DEFAULT 1, CREATED_AT DATETIME2, LAST_LOGIN DATETIME2 ) `).catch(e => { if (alreadyExists(e)) { tableExisted = true; console.log('[SQL-USERS] Table already exists — OK'); } else throw e; }); // Unique index on USERNAME await exec(`CREATE UNIQUE INDEX IDX_ZCUST_USERS_UN ON ${TABLE} ([USERNAME])`).catch(() => {}); // Migration: add columns to existing tables await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='MODULES') ALTER TABLE ${TABLE} ADD [MODULES] NVARCHAR(MAX)`).catch(() => {}); await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SAP_USER_ID') ALTER TABLE ${TABLE} ADD [SAP_USER_ID] INT`).catch(() => {}); await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='APPROVAL_DEPT') ALTER TABLE ${TABLE} ADD [APPROVAL_DEPT] NVARCHAR(50)`).catch(() => {}); await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='APPROVAL_STEPS') ALTER TABLE ${TABLE} ADD [APPROVAL_STEPS] NVARCHAR(MAX)`).catch(() => {}); await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='APPROVAL_STEPS_MIGRATED') ALTER TABLE ${TABLE} ADD [APPROVAL_STEPS_MIGRATED] TINYINT DEFAULT 0`).catch(() => {}); await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='APPROVE_PERM_MIGRATED') ALTER TABLE ${TABLE} ADD [APPROVE_PERM_MIGRATED] TINYINT DEFAULT 0`).catch(() => {}); await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='PO_MANUAL_CREATE_MIGRATED') ALTER TABLE ${TABLE} ADD [PO_MANUAL_CREATE_MIGRATED] TINYINT DEFAULT 0`).catch(() => {}); await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='REQ_MANAGE_MIGRATED') ALTER TABLE ${TABLE} ADD [REQ_MANAGE_MIGRATED] TINYINT DEFAULT 0`).catch(() => {}); // Per-user SAP Service-Layer login (so SAP documents are attributed to the // actual user, not one shared account). Two users may share the same SAP // login. Password is AES-encrypted at rest (services/cryptoUtil.js). await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SAP_LOGIN_USER') ALTER TABLE ${TABLE} ADD [SAP_LOGIN_USER] NVARCHAR(100)`).catch(() => {}); await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SAP_LOGIN_PWD') ALTER TABLE ${TABLE} ADD [SAP_LOGIN_PWD] NVARCHAR(MAX)`).catch(() => {}); // Per-user allowed Item Groups for Issue for Production (JSON array of group codes). await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='ISSUE_ITEM_GROUPS') ALTER TABLE ${TABLE} ADD [ISSUE_ITEM_GROUPS] NVARCHAR(MAX)`).catch(() => {}); // Per-user allowed Item Groups for Production Order — Manual Entry (JSON // array of group codes). Separate from ISSUE_ITEM_GROUPS above — issuing // materials and originating a standalone PWO are different actions, an // admin may want a different scope for each. Empty/null = no restriction. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='MANUAL_PO_ITEM_GROUPS') ALTER TABLE ${TABLE} ADD [MANUAL_PO_ITEM_GROUPS] NVARCHAR(MAX)`).catch(() => {}); // Per-user allowed Man Power tabs (JSON array of tab keys). Empty = all. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='MANPOWER_TABS') ALTER TABLE ${TABLE} ADD [MANPOWER_TABS] NVARCHAR(MAX)`).catch(() => {}); // Per-user allowed OEE (Overall Equipment Efficiency) tabs (JSON array of tab keys). Empty = all. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='OEE_TABS') ALTER TABLE ${TABLE} ADD [OEE_TABS] NVARCHAR(MAX)`).catch(() => {}); // Per-user handwritten signature image (base64 data URI) — drawn on printed // Work Orders wherever this user signed an approval step. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SIGNATURE') ALTER TABLE ${TABLE} ADD [SIGNATURE] NVARCHAR(MAX)`).catch(() => {}); // Per-user allowed SAP companies to show in the "Displayed SAP Company" // dropdown (JSON array of company DB names, e.g. "Test_MI-NewDB2"). Empty/ // null = no restriction (sees every company GET /api/companies finds). // Purely a display-level filter (see public/company-list.js) — it doesn't // block API calls made with an explicit company param, same class of // restriction as ISSUE_ITEM_GROUPS/MANPOWER_TABS/OEE_TABS above. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='ALLOWED_COMPANIES') ALTER TABLE ${TABLE} ADD [ALLOWED_COMPANIES] NVARCHAR(MAX)`).catch(() => {}); // Per-company SAP Service-Layer logins — SAP B1 companies each have their // own OUSR table, so the SAME SAP username can have a DIFFERENT password in // each company DB. The old SAP_LOGIN_USER/SAP_LOGIN_PWD pair above only // ever worked for one company; this JSON map holds one {user, pwdEnc} entry // PER company DB. SAP_LOGIN_USER/SAP_LOGIN_PWD are kept (not dropped) as the // legacy entry for the default company — see getSapLoginMapRaw()'s // migration-on-read fold-in, no destructive migration needed. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SAP_LOGIN_MAP') ALTER TABLE ${TABLE} ADD [SAP_LOGIN_MAP] NVARCHAR(MAX)`).catch(() => {}); // Per-user allowed SAP Approval Request document types (JSON array of // ObjectType codes, e.g. "1470000113" for Purchase Request — see // public/sap-approvals.html's OBJ_MAP). Empty/null = no restriction (sees // every type), same convention as ISSUE_ITEM_GROUPS/MANPOWER_TABS/OEE_TABS // above. Restricts what routes/sap.js's GET /approval-requests returns — // not just a display-level filter, since approval requests can be acted on. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='SAP_APPROVAL_TYPES') ALTER TABLE ${TABLE} ADD [SAP_APPROVAL_TYPES] NVARCHAR(MAX)`).catch(() => {}); // Per-user allowed Purchase Request Departments (JSON array of SAP OUDP // department names — the same free-text value stored in U_Depart/ // U_Department). Empty/null = no restriction (sees/can use every // department), same convention as the other per-user restriction lists. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='ALLOWED_DEPARTMENTS') ALTER TABLE ${TABLE} ADD [ALLOWED_DEPARTMENTS] NVARCHAR(MAX)`).catch(() => {}); // Per-user opt-out of stage-change email notifications (services/notifyStore.js). // DEFAULT 1 so every existing/new user keeps getting emails unless they (or // an admin) explicitly uncheck it — this only gates whether THIS user's own // email is ever added as a recipient; admin-configured fixed extra // recipients (appSettingsStore.notifyExtraEmails()) are unaffected either way. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='EMAIL_NOTIFY') ALTER TABLE ${TABLE} ADD [EMAIL_NOTIFY] TINYINT DEFAULT 1`).catch(() => {}); // Work Order Verify override: lets this user, when stamping a row // "verified" (routes/workOrders.js POST /:id/row/:section/:index/mark), // sign as ANY user (not just themselves) and set the sign date/time to a // backdate instead of the server's current timestamp. Off by default — // a narrow, explicitly-granted power, not a general permission. await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='WO_VERIFY_OVERRIDE') ALTER TABLE ${TABLE} ADD [WO_VERIFY_OVERRIDE] TINYINT DEFAULT 0`).catch(() => {}); // Impersonate: lets this user act on behalf of ANY other (non-admin, // unless they're admin themselves) user — sees exactly what that user // would see, with that user's permissions, until they return to their own // account. Off by default, a narrow explicit grant (see server.js's // POST /api/auth/impersonate). await exec(`IF NOT EXISTS (SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ZCUST_USERS' AND COLUMN_NAME='CAN_IMPERSONATE') ALTER TABLE ${TABLE} ADD [CAN_IMPERSONATE] TINYINT DEFAULT 0`).catch(() => {}); // Seed default users if table is empty const cntRows = await exec(`SELECT COUNT(*) AS "CNT" FROM ${TABLE}`); const cnt = Number(cntRows[0]?.CNT || cntRows[0]?.['CNT'] || 0); if (cnt === 0) { console.log('[SQL-USERS] Seeding default users…'); await createUser({ username: 'admin', password: 'Admin@123', fullName: 'System Administrator', email: 'admin@company.com', role: 'admin' }); //await createUser({ username: 'admin', password: 'Admin@123', fullName: 'System Admin', email: 'admin@company.com', role: 'sap_adder' }); await createUser({ username: 'manager1', password: 'Manager@123', fullName: 'Field Manager', email: 'manager@company.com', role: 'manager' }); await createUser({ username: 'srmanager1',password: 'SrMgr@123', fullName: 'Senior Manager', email: 'srmgr@company.com', role: 'sr_manager' }); console.log('[SQL-USERS] ✅ Seeded: admin (sap_adder) + manager1 (manager) + srmanager1 (sr_manager)'); } await backfillApprovalSteps(); await backfillApprovePerm(); await backfillManualCreatePerm(); await backfillRequirementManagePerm(); console.log('[HANA-USERS] ✅ User table ready'); } // One-time backfill: compute a default approvalSteps array from each user's // CURRENT role/approvalDept, so switching enforcement from role-based to // step-based causes ZERO regression on deploy — existing approvers keep // exactly the access they have today. Only runs for not-yet-migrated rows. async function backfillApprovalSteps() { const rows = await exec(`SELECT ID, ROLE, APPROVAL_DEPT FROM ${TABLE} WHERE APPROVAL_STEPS_MIGRATED = 0 OR APPROVAL_STEPS_MIGRATED IS NULL`); if (!rows.length) return; console.log(`[SQL-USERS] Backfilling approval steps for ${rows.length} user(s)…`); for (const r of rows) { const role = r.ROLE, dept = r.APPROVAL_DEPT; const steps = [ // Work Order + Customer/Vendor verify were previously UNGATED (anyone // logged in could act) — preserve that on deploy. 'work_order:prepared_qa', 'work_order:checked_qc', 'work_order:checked_production', 'work_order:checked_mgr_production', 'work_order:approved_mgr_qa', 'customer_vendor:verify', ]; if (role === 'admin') { steps.push('bom:level1', 'bom:level2', 'bom:level3', 'bom:level4', 'customer_vendor:approve', 'project:purchase', 'project:engineering', 'project:qa', 'project:qc', 'project:legal', 'project:owner', 'project:plant', 'project:finance'); } else { if (role === 'manager') steps.push('bom:level1'); else if (role === 'sr_manager') steps.push('bom:level2'); else if (role === 'sap_adder') steps.push('bom:level3', 'bom:level4', 'customer_vendor:approve'); if (role === 'project_approver' && dept) steps.push(`project:${dept}`); } await exec(`UPDATE ${TABLE} SET APPROVAL_STEPS = ?, APPROVAL_STEPS_MIGRATED = 1 WHERE ID = ?`, [JSON.stringify(steps), r.ID]); } console.log('[SQL-USERS] ✅ Approval steps backfill complete'); } // One-time backfill for the 'approve' permission split (added after 'edit' // already existed): before this, 'edit' on a step was ALSO what let someone // approve/reject at that step — there was no separate concept. Any existing // per-step {step,perms} object that already has 'edit' but not 'approve' // gets 'approve' added too, so nobody who could already act on a stage // silently loses that ability the moment this ships. Only runs once per // user; steps left as legacy plain strings are untouched (they already // resolve to ALL_PERMS, including 'approve', via normalizeSteps). async function backfillApprovePerm() { const rows = await exec(`SELECT ID, APPROVAL_STEPS FROM ${TABLE} WHERE APPROVE_PERM_MIGRATED = 0 OR APPROVE_PERM_MIGRATED IS NULL`); if (!rows.length) return; console.log(`[SQL-USERS] Backfilling 'approve' perm for ${rows.length} user(s)…`); for (const r of rows) { let steps = safeJson(r.APPROVAL_STEPS, []); if (Array.isArray(steps)) { steps = steps.map(s => { if (s && typeof s === 'object' && Array.isArray(s.perms) && s.perms.includes('edit') && !s.perms.includes('approve')) return { ...s, perms: [...s.perms, 'approve'] }; return s; }); } await exec(`UPDATE ${TABLE} SET APPROVAL_STEPS = ?, APPROVE_PERM_MIGRATED = 1 WHERE ID = ?`, [JSON.stringify(steps), r.ID]); } console.log('[SQL-USERS] ✅ Approve-perm backfill complete'); } // One-time backfill for the Manual-Entry split (production_order:create was // split into 'create' (from Work Order) and 'manual_create' (standalone) — // before this, 'add' on 'create' let someone do BOTH). Anyone who already // held 'add' on production_order:create gets the same perms copied onto // production_order:manual_create too, so nobody loses manual-entry ability // the moment this ships. Only runs once per user. async function backfillManualCreatePerm() { const rows = await exec(`SELECT ID, APPROVAL_STEPS FROM ${TABLE} WHERE PO_MANUAL_CREATE_MIGRATED = 0 OR PO_MANUAL_CREATE_MIGRATED IS NULL`); if (!rows.length) return; console.log(`[SQL-USERS] Backfilling Production Order manual-entry perm for ${rows.length} user(s)…`); for (const r of rows) { let steps = safeJson(r.APPROVAL_STEPS, []); if (Array.isArray(steps)) { const createEntry = steps.find(s => s && typeof s === 'object' && s.step === 'production_order:create'); const hasManual = steps.some(s => s && typeof s === 'object' && s.step === 'production_order:manual_create'); if (createEntry && Array.isArray(createEntry.perms) && createEntry.perms.includes('add') && !hasManual) { steps = [...steps, { step: 'production_order:manual_create', perms: [...createEntry.perms] }]; } } await exec(`UPDATE ${TABLE} SET APPROVAL_STEPS = ?, PO_MANUAL_CREATE_MIGRATED = 1 WHERE ID = ?`, [JSON.stringify(steps), r.ID]); } console.log('[SQL-USERS] ✅ Production Order manual-entry backfill complete'); } // One-time backfill: Requirements CRUD was previously all-or-nothing via the // 'production-requirements' MODULES checkbox alone — no per-action gate. Now // that requirement:manage's view/add/edit/delete perms gate it, anyone who // already had the module gets full view/add/edit/delete on that step, so // nobody loses Requirements access the moment this ships. Only runs once // per user (admins are unaffected — they always bypass via role check). async function backfillRequirementManagePerm() { const rows = await exec(`SELECT ID, MODULES, APPROVAL_STEPS FROM ${TABLE} WHERE REQ_MANAGE_MIGRATED = 0 OR REQ_MANAGE_MIGRATED IS NULL`); if (!rows.length) return; console.log(`[SQL-USERS] Backfilling Requirements manage perm for ${rows.length} user(s)…`); for (const r of rows) { const modules = safeJson(r.MODULES, []); let steps = safeJson(r.APPROVAL_STEPS, []); if (!Array.isArray(steps)) steps = []; if (Array.isArray(modules) && modules.includes('production-requirements')) { const already = steps.some(s => s && typeof s === 'object' && s.step === 'requirement:manage'); if (!already) steps = [...steps, { step: 'requirement:manage', perms: ['view', 'add', 'edit', 'delete'] }]; } await exec(`UPDATE ${TABLE} SET APPROVAL_STEPS = ?, REQ_MANAGE_MIGRATED = 1 WHERE ID = ?`, [JSON.stringify(steps), r.ID]); } console.log('[SQL-USERS] ✅ Requirements manage-perm backfill complete'); } // ── Row → JS object ─────────────────────────────────────────────────────────── function fromRow(r) { if (!r) return null; return { id: r.ID, username: r.USERNAME, fullName: r.FULL_NAME || '', email: r.EMAIL || '', role: r.ROLE || 'manager', active: r.ACTIVE === 1, modules: safeJson(r.MODULES, null), sapUserId: r.SAP_USER_ID || null, sapLoginUser: r.SAP_LOGIN_USER || '', hasSapLogin: !!(r.SAP_LOGIN_USER && r.SAP_LOGIN_PWD), approvalDept: r.APPROVAL_DEPT || null, approvalSteps: safeJson(r.APPROVAL_STEPS, []), // Item groups this user may issue in Issue for Production. [] / null = no // restriction (can issue any item). Non-empty = only these groups. issueItemGroups: safeJson(r.ISSUE_ITEM_GROUPS, []), // Item groups this user may originate a standalone (Manual Entry) // Production Order for. [] / null = no restriction. manualPoItemGroups: safeJson(r.MANUAL_PO_ITEM_GROUPS, []), // Man Power tabs this user may fill. [] / null = all (no restriction). manpowerTabs: safeJson(r.MANPOWER_TABS, []), // OEE tabs this user may fill. [] / null = all (no restriction). oeeTabs: safeJson(r.OEE_TABS, []), // SAP companies shown to this user in company dropdowns. [] / null = all // (no restriction) — see public/company-list.js. allowedCompanies: safeJson(r.ALLOWED_COMPANIES, []), // SAP Approval Request document types (ObjectType codes) this user may // see/act on in SAP Approval. [] / null = all (no restriction). sapApprovalTypes: safeJson(r.SAP_APPROVAL_TYPES, []), // Purchase Request Departments this user may pick from. [] / null = all // (no restriction). allowedDepartments: safeJson(r.ALLOWED_DEPARTMENTS, []), // Per-company SAP logins this user has configured — { [companyDB]: { user, hasPwd } }. // Never includes the encrypted password itself (see getSapLoginMapRaw for // the server-only raw form used to build the JWT). sapLogins: (() => { const map = safeJson(r.SAP_LOGIN_MAP, {}); const out = {}; Object.keys(map).forEach(c => { out[c] = { user: (map[c] && map[c].user) || '', hasPwd: !!(map[c] && map[c].pwdEnc) }; }); return out; })(), // Whether a signature image is on file (the image itself is fetched // separately via getSignature* — kept out of list/profile payloads). hasSignature: !!r.SIGNATURE, // Stage-change email notifications — opt-out, not opt-in: null (legacy // row before this column existed) or 1 = enabled, only 0 = disabled. emailNotify: r.EMAIL_NOTIFY !== 0, // See migration comment above — lets this user sign a Work Order // verify stamp as any user and backdate it. woVerifyOverride: r.WO_VERIFY_OVERRIDE === 1, canImpersonate: r.CAN_IMPERSONATE === 1, createdAt: r.CREATED_AT ? new Date(r.CREATED_AT).toISOString() : null, lastLogin: r.LAST_LOGIN ? new Date(r.LAST_LOGIN).toISOString() : null, }; } function safeJson(v,fb){if(!v)return fb;try{return JSON.parse(v);}catch(_e){return fb;}} // ── Create user ─────────────────────────────────────────────────────────────── async function createUser({ username, password, fullName, email, role, modules, approvalDept, approvalSteps, sapLoginUser, sapLoginPwd, issueItemGroups, manualPoItemGroups, manpowerTabs, oeeTabs, signature, allowedCompanies, emailNotify, sapApprovalTypes, allowedDepartments, woVerifyOverride, canImpersonate }) { if (!VALID_ROLES.includes(role)) throw new Error(`Invalid role: ${role}. Must be one of: ${VALID_ROLES.join(', ')}`); const hash = await bcrypt.hash(password, 10); const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23); const sapUser = (sapLoginUser || '').trim() || null; const sapPwd = sapLoginPwd ? cryptoUtil.encrypt(sapLoginPwd) : null; // INSERT and SELECT SCOPE_IDENTITY() must be one batch — as two separate // exec() calls, a pooled connection can route the second one to a // DIFFERENT physical connection than the one that just inserted, where // SCOPE_IDENTITY() correctly returns NULL (see workOrderStore.js's // insertWorkOrder() for the full write-up of this bug class). const idRows = await exec( `INSERT INTO ${TABLE} (USERNAME, PASSWORD, FULL_NAME, EMAIL, ROLE, ACTIVE, CREATED_AT, MODULES, APPROVAL_DEPT, APPROVAL_STEPS, APPROVAL_STEPS_MIGRATED, SAP_LOGIN_USER, SAP_LOGIN_PWD, ISSUE_ITEM_GROUPS, MANUAL_PO_ITEM_GROUPS, MANPOWER_TABS, OEE_TABS, SIGNATURE, ALLOWED_COMPANIES, EMAIL_NOTIFY, SAP_APPROVAL_TYPES, ALLOWED_DEPARTMENTS, WO_VERIFY_OVERRIDE, CAN_IMPERSONATE) VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); SELECT SCOPE_IDENTITY() AS ID;`, [(username || '').toLowerCase(), hash, fullName || '', email || '', role || 'manager', now, modules ? JSON.stringify(modules) : null, approvalDept || null, JSON.stringify(Array.isArray(approvalSteps) ? approvalSteps : []), sapUser, sapPwd, JSON.stringify(Array.isArray(issueItemGroups) ? issueItemGroups.map(String) : []), JSON.stringify(Array.isArray(manualPoItemGroups) ? manualPoItemGroups.map(String) : []), JSON.stringify(Array.isArray(manpowerTabs) ? manpowerTabs.map(String) : []), JSON.stringify(Array.isArray(oeeTabs) ? oeeTabs.map(String) : []), (signature || '').trim() || null, JSON.stringify(Array.isArray(allowedCompanies) ? allowedCompanies.map(String) : []), emailNotify === false ? 0 : 1, JSON.stringify(Array.isArray(sapApprovalTypes) ? sapApprovalTypes.map(String) : []), JSON.stringify(Array.isArray(allowedDepartments) ? allowedDepartments.map(String) : []), woVerifyOverride ? 1 : 0, canImpersonate ? 1 : 0] ); const id = idRows[0].ID; console.log(`[SQL-USERS] ✅ Created user: ${username} (${role})`); return id; } // ── Find by username (includes hash for auth) ───────────────────────────────── async function findByUsername(username) { const rows = await exec( `SELECT * FROM ${TABLE} WHERE USERNAME = ? AND ACTIVE = 1`, [(username || '').toLowerCase()] ); if (!rows.length) return null; const r = rows[0]; return { ...fromRow(r), passwordHash: r.PASSWORD }; } // ── Find by email (for central-auth SSO — the only identity it hands us) ────── async function findByEmail(email) { const rows = await exec( `SELECT * FROM ${TABLE} WHERE LOWER(EMAIL) = ? AND ACTIVE = 1`, [(email || '').trim().toLowerCase()] ); return rows.length ? fromRow(rows[0]) : null; } // ── List all users ──────────────────────────────────────────────────────────── async function listUsers() { const rows = await exec(`SELECT * FROM ${TABLE} ORDER BY ROLE, USERNAME`); return rows.map(fromRow); } // ── Find by ID ──────────────────────────────────────────────────────────────── async function findById(id) { const rows = await exec(`SELECT * FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]); return rows.length ? fromRow(rows[0]) : null; } // ── Update user ─────────────────────────────────────────────────────────────── async function updateUser(id, patch) { const sets = []; const vals = []; if (patch.fullName !== undefined) { sets.push('FULL_NAME = ?'); vals.push(patch.fullName); } if (patch.email !== undefined) { sets.push('EMAIL = ?'); vals.push(patch.email); } if (patch.role !== undefined) { if (!VALID_ROLES.includes(patch.role)) throw new Error(`Invalid role: ${patch.role}`); sets.push('ROLE = ?'); vals.push(patch.role); } if (patch.active !== undefined) { sets.push('ACTIVE = ?'); vals.push(patch.active ? 1 : 0); } if (patch.modules !== undefined) { sets.push('MODULES = ?'); vals.push(JSON.stringify(patch.modules)); } if (patch.sapUserId !== undefined) { sets.push('SAP_USER_ID = ?'); vals.push(parseInt(patch.sapUserId)||null); } if (patch.sapLoginUser !== undefined) { sets.push('SAP_LOGIN_USER = ?'); vals.push((patch.sapLoginUser || '').trim() || null); } // sapLoginPwd: pass the PLAINTEXT password; it's encrypted here. Empty // string means "clear it"; undefined means "leave unchanged". if (patch.sapLoginPwd !== undefined) { sets.push('SAP_LOGIN_PWD = ?'); vals.push(patch.sapLoginPwd ? cryptoUtil.encrypt(patch.sapLoginPwd) : null); } if (patch.approvalDept !== undefined) { sets.push('APPROVAL_DEPT = ?'); vals.push(patch.approvalDept || null); } if (patch.approvalSteps !== undefined) { sets.push('APPROVAL_STEPS = ?'); vals.push(JSON.stringify(Array.isArray(patch.approvalSteps) ? patch.approvalSteps : [])); } if (patch.issueItemGroups !== undefined) { sets.push('ISSUE_ITEM_GROUPS = ?'); vals.push(JSON.stringify(Array.isArray(patch.issueItemGroups) ? patch.issueItemGroups.map(String) : [])); } if (patch.manualPoItemGroups !== undefined) { sets.push('MANUAL_PO_ITEM_GROUPS = ?'); vals.push(JSON.stringify(Array.isArray(patch.manualPoItemGroups) ? patch.manualPoItemGroups.map(String) : [])); } if (patch.manpowerTabs !== undefined) { sets.push('MANPOWER_TABS = ?'); vals.push(JSON.stringify(Array.isArray(patch.manpowerTabs) ? patch.manpowerTabs.map(String) : [])); } if (patch.oeeTabs !== undefined) { sets.push('OEE_TABS = ?'); vals.push(JSON.stringify(Array.isArray(patch.oeeTabs) ? patch.oeeTabs.map(String) : [])); } if (patch.allowedCompanies!== undefined) { sets.push('ALLOWED_COMPANIES = ?'); vals.push(JSON.stringify(Array.isArray(patch.allowedCompanies) ? patch.allowedCompanies.map(String) : [])); } if (patch.sapApprovalTypes!== undefined) { sets.push('SAP_APPROVAL_TYPES = ?'); vals.push(JSON.stringify(Array.isArray(patch.sapApprovalTypes) ? patch.sapApprovalTypes.map(String) : [])); } if (patch.allowedDepartments!== undefined) { sets.push('ALLOWED_DEPARTMENTS = ?'); vals.push(JSON.stringify(Array.isArray(patch.allowedDepartments) ? patch.allowedDepartments.map(String) : [])); } if (patch.emailNotify !== undefined) { sets.push('EMAIL_NOTIFY = ?'); vals.push(patch.emailNotify ? 1 : 0); } if (patch.woVerifyOverride !== undefined) { sets.push('WO_VERIFY_OVERRIDE = ?'); vals.push(patch.woVerifyOverride ? 1 : 0); } if (patch.canImpersonate !== undefined) { sets.push('CAN_IMPERSONATE = ?'); vals.push(patch.canImpersonate ? 1 : 0); } // signature: base64 data URI; '' clears it, undefined leaves unchanged. if (patch.signature !== undefined) { sets.push('SIGNATURE = ?'); vals.push((patch.signature || '').trim() || null); } if (patch.password) { const hash = await bcrypt.hash(patch.password, 10); sets.push('PASSWORD = ?'); vals.push(hash); } if (!sets.length) return; vals.push(parseInt(id)); await exec(`UPDATE ${TABLE} SET ${sets.join(', ')} WHERE ID = ?`, vals); console.log(`[HANA-USERS] ✅ Updated user ID=${id} (${sets.length} fields)`); } // ── Update last login ───────────────────────────────────────────────────────── async function touchLastLogin(id) { const now = new Date().toISOString().replace('T', ' ').replace('Z', '').substring(0, 23); await exec(`UPDATE ${TABLE} SET LAST_LOGIN = ? WHERE ID = ?`, [now, parseInt(id)]); } // ── Delete user ─────────────────────────────────────────────────────────────── async function deleteUser(id) { await exec(`DELETE FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]); console.log(`[HANA-USERS] ✅ Deleted user ID=${id}`); } // ── Verify password ─────────────────────────────────────────────────────────── async function verifyPassword(plaintext, hash) { // "md5:" = an account migrated from the old msale portal (unsalted // MD5, see scripts/migrate-msale.js). Accepted once; the login route // re-hashes it to bcrypt immediately on success (upgradeLegacyPassword). if (typeof hash === 'string' && hash.startsWith('md5:')) { return require('crypto').createHash('md5').update(String(plaintext)).digest('hex') === hash.slice(4).toLowerCase(); } return bcrypt.compare(plaintext, hash); } async function upgradeLegacyPassword(id, plaintext, hash) { if (typeof hash === 'string' && hash.startsWith('md5:')) await updateUser(id, { password: plaintext }); } // ── Per-user SAP credentials ────────────────────────────────────────────────── // Set the current/given user's own SAP Service-Layer login. Password is // stored encrypted; pass '' to clear. async function setSapCredentials(id, sapUser, sapPasswordPlain) { await updateUser(id, { sapLoginUser: sapUser, sapLoginPwd: sapPasswordPlain }); } // Returns { sapUser, sapPassword } with the password DECRYPTED — server-side // use only (never send to the client). Null if the user has no SAP login set. async function getSapCredentials(id) { const rows = await exec(`SELECT SAP_LOGIN_USER, SAP_LOGIN_PWD FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]); if (!rows.length) return null; const u = rows[0].SAP_LOGIN_USER, p = rows[0].SAP_LOGIN_PWD; if (!u || !p) return null; return { sapUser: u, sapPassword: cryptoUtil.decrypt(p) }; } // ── Per-company SAP credentials ───────────────────────────────────────────── // SAP B1 companies each have their own OUSR table — the same SAP username can // have a DIFFERENT password per company. These let one portal user store a // distinct SAP login per company, instead of the single pair above. // Raw map with passwords STILL ENCRYPTED (pwdEnc) — safe to embed in a JWT // (same trust model the old single sapPwdEnc field already used), never sent // as plain JSON to a client outside the token. Folds the legacy single // SAP_LOGIN_USER/PWD pair in as the DEFAULT_COMPANY entry when the map itself // has no explicit entry for it, so nobody's already-configured login breaks. async function getSapLoginMapRaw(id) { const rows = await exec(`SELECT SAP_LOGIN_MAP, SAP_LOGIN_USER, SAP_LOGIN_PWD FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]); if (!rows.length) return {}; const map = safeJson(rows[0].SAP_LOGIN_MAP, {}); if (!map[DEFAULT_COMPANY] && rows[0].SAP_LOGIN_USER && rows[0].SAP_LOGIN_PWD) { map[DEFAULT_COMPANY] = { user: rows[0].SAP_LOGIN_USER, pwdEnc: rows[0].SAP_LOGIN_PWD }; } return map; } // Decrypted { sapUser, sapPassword } for ONE company, or null if not set. async function getSapCredentialsForCompany(id, companyDB) { const map = await getSapLoginMapRaw(id); const entry = map[companyDB]; if (!entry || !entry.user || !entry.pwdEnc) return null; return { sapUser: entry.user, sapPassword: cryptoUtil.decrypt(entry.pwdEnc) }; } // Read-modify-write: set/replace this user's SAP login for ONE company only, // leaving every other company's entry untouched. async function setSapLoginForCompany(id, companyDB, sapUser, sapPasswordPlain) { const map = await getSapLoginMapRaw(id); map[companyDB] = { user: sapUser, pwdEnc: cryptoUtil.encrypt(sapPasswordPlain) }; await exec(`UPDATE ${TABLE} SET SAP_LOGIN_MAP = ? WHERE ID = ?`, [JSON.stringify(map), parseInt(id)]); } async function clearSapLoginForCompany(id, companyDB) { const map = await getSapLoginMapRaw(id); delete map[companyDB]; // getSapLoginMapRaw() folds the legacy single SAP_LOGIN_USER/SAP_LOGIN_PWD // columns back in as the DEFAULT_COMPANY entry whenever the map has none — // if those columns are left standing, clearing DEFAULT_COMPANY's entry gets // silently un-done the very next read (the removed login reappears). Clear // them too when the company being removed is the default one. if (companyDB === DEFAULT_COMPANY) { await exec(`UPDATE ${TABLE} SET SAP_LOGIN_MAP = ?, SAP_LOGIN_USER = NULL, SAP_LOGIN_PWD = NULL WHERE ID = ?`, [JSON.stringify(map), parseInt(id)]); } else { await exec(`UPDATE ${TABLE} SET SAP_LOGIN_MAP = ? WHERE ID = ?`, [JSON.stringify(map), parseInt(id)]); } } // ── Signature image (base64 data URI) ────────────────────────────────────────── async function getSignature(id) { const rows = await exec(`SELECT SIGNATURE FROM ${TABLE} WHERE ID = ?`, [parseInt(id)]); return rows.length ? (rows[0].SIGNATURE || '') : ''; } async function getSignatureByUsername(username) { const rows = await exec(`SELECT SIGNATURE FROM ${TABLE} WHERE USERNAME = ?`, [(username || '').toLowerCase()]); return rows.length ? (rows[0].SIGNATURE || '') : ''; } module.exports = { bootstrap, createUser, findByUsername, findByEmail, listUsers, findById, updateUser, deleteUser, touchLastLogin, verifyPassword, upgradeLegacyPassword, setSapCredentials, getSapCredentials, getSapLoginMapRaw, getSapCredentialsForCompany, setSapLoginForCompany, clearSapLoginForCompany, getSignature, getSignatureByUsername, VALID_ROLES, };