572 lines
36 KiB
JavaScript
572 lines
36 KiB
JavaScript
// 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:<hex>" = 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,
|
|
}; |