import { string, date } from 'alga-js' import fetchHelper from '../../utils/fetchHelper' import sqliteHelper from '../../utils/sqliteHelper' import setAuthCookie from '../../utils/setAuthCookie' import { trimUser, trimRole, trimOrganization, trimClient, trimWarehouse } from '../../utils/trimCookieData' import { enforceMobileWorkerGate } from '../../utils/mobileWorkerHelper' import { AUTO_FINALIZE_SECRET } from '../../utils/autoFinalizeSecret' const MOBILE_MAX_AGE = 30 * 24 * 60 * 60 // 30 days /** * Extracts a foreign-key id from an ad_user (or similar) record. OData returns * FKs as `{ id, identifier, "model-name": "..." }`; other paths may return a * raw number. Tries multiple casing variants because iDempiere's response * casing varies by endpoint. */ function extractUserFkId(user: any, variants: string[]): number { if (!user || typeof user !== 'object') return 0 let raw: any = null for (const k of variants) { if (user[k] != null) { raw = user[k]; break } } if (raw == null) return 0 if (typeof raw === 'number') return raw if (typeof raw === 'string') { const n = Number(raw); return Number.isFinite(n) ? n : 0 } if (typeof raw === 'object') { const id = raw.id ?? raw.ID if (typeof id === 'number') return id if (typeof id === 'string') { const n = Number(id); return Number.isFinite(n) ? n : 0 } } return 0 } const extractUserOrgId = (user: any) => extractUserFkId(user, ['AD_Org_ID', 'ad_org_id', 'AD_ORG_ID', 'adOrgId']) const extractUserWarehouseId = (user: any) => extractUserFkId(user, ['M_Warehouse_ID', 'm_warehouse_id', 'M_WAREHOUSE_ID', 'mWarehouseId']) const AUTO_DEBUG = process.env.IDEMPIERE_DEBUG === '1' || process.env.IDEMPIERE_DEBUG === 'true' || process.env.AUTO_FINALIZE_DEBUG === '1' const dbg = (msg: string, obj?: any) => { if (AUTO_DEBUG) console.log('[autoFinalize]', msg, obj === undefined ? '' : JSON.stringify(obj)) } /** * Look up the ad_user record by login using the CALLER's token. Used early * in the flow when we have a usable session token but not yet a userId. * Tries a couple of filter shapes because different iDempiere versions are * picky about combined predicates. */ async function findUserByLogin(event: any, token: string, clientId: any, login: string): Promise { if (!token || !login) return null const safe = String(login).replace(/'/g, "''") const cIdNum = Number(clientId) // iDempiere REST filter fields are lowercase-first; PascalCase causes a // 400. Try `name` (the field iDempiere actually expects), then `value` // (the login id), then `eMail`. const filters = [ `name eq '${safe}'`, `value eq '${safe}'`, `eMail eq '${safe}'`, ] for (const filter of filters) { const url = `models/ad_user?$filter=${string.urlEncode(filter)}&$top=10` const res: any = await fetchHelper(event, url, 'GET', token, null).catch((e: any) => { dbg('findUserByLogin fetch error', { filter, status: e?.status, message: e?.message }) return null }) const records: any[] | null = Array.isArray(res?.records) ? res.records : Array.isArray(res) ? res : null dbg('findUserByLogin try', { filter, count: records?.length ?? 0 }) if (records?.length) { const match = records.find(u => { const cId = (u?.AD_Client_ID && typeof u.AD_Client_ID === 'object') ? u.AD_Client_ID.id : u?.AD_Client_ID return Number(cId) === cIdNum }) ?? records[0] return match } } return null } /** * Internal call to the privileged /auto-defaults endpoint. The endpoint is * gated by AUTO_FINALIZE_SECRET (in-memory, per-process) so this is the * only place that can reach it. It returns ONLY { adOrgId, mWarehouseId }. * * Passes the just-issued user token (callerToken) so the endpoint can try * the user's in-tenant token before the privileged service token. */ async function fetchAutoDefaults( event: any, clientId: any, login: string, callerToken?: string | null ): Promise<{ adOrgId: number; mWarehouseId: number; _trace?: any[] } | null> { if (!login) return null try { const res: any = await $fetch('/api/idempiere-auth/auto-defaults', { method: 'POST', headers: { 'x-auto-finalize-secret': AUTO_FINALIZE_SECRET }, body: { userName: login, clientId, callerToken: callerToken ?? '' }, }) return { adOrgId: Number(res?.adOrgId) || 0, mWarehouseId: Number(res?.mWarehouseId) || 0, _trace: Array.isArray(res?._trace) ? res._trace : [], } } catch (e: any) { console.log('[auto-defaults] fetch error from login.post.ts:', { status: e?.status, message: e?.message }) return { adOrgId: 0, mWarehouseId: 0, _trace: [{ step: 'fetch-error', status: e?.status, message: e?.message ?? String(e) }] } } } /** * If the user has exactly 1 client and at least 1 role, finalize the session * server-side so the client lands on the dashboard without going through * /select-role. Primary path: pick the singleton org/warehouse when the role * gives access to exactly one — that case already works well. Fallback for * the multi-record case: read the user's default AD_Org_ID / M_Warehouse_ID * from ad_user. Returns null if neither path produces a value — caller falls * through to the clients-list response and the user picks manually. */ async function tryAutoFinalize(event: any, resToken: any, body: any, trace: any[] = []): Promise { const tlog = (step: string, info?: any) => { const entry = info === undefined ? { step } : { step, ...info } trace.push(entry) console.log('[autoFinalize]', JSON.stringify(entry)) } if (body.selectRole) { tlog('skip-selectRole'); return null } if (!resToken?.token) { tlog('bail-no-token'); return null } if (!Array.isArray(resToken?.clients)) { tlog('bail-no-clients-array'); return null } if (resToken.clients.length !== 1) { tlog('bail-multi-tenant', { clientsCount: resToken.clients.length }); return null } const c = resToken.clients[0] tlog('client', { id: c?.id, name: c?.name }) const extract = (res: any, key: string): any[] | null => { if (!res) return null if (Array.isArray(res)) return res if (Array.isArray(res[key])) return res[key] return null } // iDempiere returns a wildcard "*" entry (id=0) for roles with "All" access. // It's not a real org/warehouse, so drop it before counting. const realOnly = (xs: any[]) => xs.filter(x => Number(x?.id) > 0) // 1. Roles available for that client let rolesRes: any = null try { rolesRes = await fetchHelper(event, `auth/roles?client=${c.id}`, 'GET', resToken.token, null) } catch (e: any) { tlog('bail-roles-fetch-failed', { status: e?.status, message: e?.message }); return null } const roles = extract(rolesRes, 'roles') if (!roles?.length) { tlog('bail-no-roles'); return null } const r = roles[0] // pick first role when multiple exist tlog('role', { id: r?.id, name: r?.name, total: roles.length }) // 2. Orgs available for the role. Drop the "*" wildcard up front so the // "is there really a singleton?" check below sees the real shape. let orgsRes: any = null try { orgsRes = await fetchHelper(event, `auth/organizations?client=${c.id}&role=${r.id}`, 'GET', resToken.token, null) } catch (e: any) { tlog('bail-orgs-fetch-failed', { status: e?.status, message: e?.message }); return null } const orgsAll = extract(orgsRes, 'organizations') if (!orgsAll?.length) { tlog('bail-no-orgs'); return null } const orgs = realOnly(orgsAll) if (!orgs.length) { tlog('bail-no-real-orgs', { allCount: orgsAll.length }); return null } tlog('orgs', { realCount: orgs.length, totalCount: orgsAll.length, sample: orgs.slice(0, 3).map((o: any) => ({ id: o.id, name: o.name })) }) // 3. Resolve the user's AD_Org_ID / M_Warehouse_ID defaults so we can // disambiguate multi-org / multi-warehouse cases. Singleton org doesn't // need this, so skip the work there. We try several layers because // iDempiere is inconsistent about giving us a query-capable token early: // a) Did the initial POST already include userId? (rare, but free.) // b) Filter ad_user with the unscoped token. // c) Scout PUT to scope the token, then try userId / filter again. // d) Privileged service-token lookup that returns ONLY the two FK // defaults — see getUserAuthDefaults(). let userRec: any = null let svcDefaults: { adOrgId: number; mWarehouseId: number } | null = null let scoutToken: string | null = null let scoutOrgId = 0 let scoutWhId = 0 dbg('orgs (real)', orgs.map((o: any) => ({ id: o.id, name: o.name ?? o.Name }))) if (orgs.length > 1) { // (a) Some iDempiere builds include userId in the initial POST response. const directUserId = resToken.userId ?? resToken.user_id ?? resToken.user?.id if (directUserId) { dbg('resToken has userId', { directUserId }) userRec = await fetchHelper(event, 'models/ad_user/' + directUserId, 'GET', resToken.token, null).catch((e: any) => { dbg('fetch by directUserId failed', { status: e?.status, message: e?.message }) return null }) } // (b) Filter lookup with the unscoped token. if (!userRec) userRec = await findUserByLogin(event, resToken.token, c.id, body.userName) // (c) Scout PUT to get a scoped token + (usually) a userId. if (!userRec) { const scoutOrg = orgs[0] let scoutWhRes: any = null try { scoutWhRes = await fetchHelper(event, `auth/warehouses?client=${c.id}&role=${r.id}&organization=${scoutOrg.id}`, 'GET', resToken.token, null) } catch (e: any) { dbg('scout warehouses fetch failed', { status: e?.status }) } const scoutWhsAll = extract(scoutWhRes, 'warehouses') || [] const scoutWhsReal = realOnly(scoutWhsAll) // Prefer a real warehouse, but accept the wildcard "*" if that's all // the role exposes for this org — we just need something to make the // PUT valid; the final PUT below uses the user's true defaults. const scoutWh = scoutWhsReal[0] ?? scoutWhsAll[0] dbg('scout candidate', { orgId: scoutOrg.id, whId: scoutWh?.id, whCount: scoutWhsAll.length }) if (scoutWh) { let scout: any = null try { scout = await fetchHelper(event, 'auth/tokens', 'PUT', resToken.token, { clientId: Number(c.id), roleId: Number(r.id), organizationId: Number(scoutOrg.id), warehouseId: Number(scoutWh.id), language: 'en_US' }) } catch (e: any) { dbg('scout PUT failed', { status: e?.status, message: e?.message }) } dbg('scout PUT response shape', scout ? { hasToken: !!scout.token, userId: scout.userId, keys: Object.keys(scout).slice(0, 10) } : null) if (scout?.token) { scoutToken = scout.token scoutOrgId = Number(scoutOrg.id) scoutWhId = Number(scoutWh.id) const scoutUserId = scout.userId ?? scout.user_id ?? scout.user?.id if (scoutUserId) { userRec = await fetchHelper(event, 'models/ad_user/' + scoutUserId, 'GET', scout.token, null).catch((e: any) => { dbg('fetch by scout.userId failed', { status: e?.status }) return null }) } // Filter lookup with the scoped token. if (!userRec) userRec = await findUserByLogin(event, scout.token, c.id, body.userName) } } } // (d) Privileged dedicated endpoint — only returns the two FK ids we // actually need, gated by an in-memory per-process secret. if (!userRec) { const sd: any = await fetchAutoDefaults(event, c.id, body.userName, scoutToken ?? resToken.token) svcDefaults = sd ? { adOrgId: sd.adOrgId, mWarehouseId: sd.mWarehouseId } : null tlog('svcDefaults', { svcDefaults, innerTrace: sd?._trace }) } tlog('userRec', userRec ? { id: userRec.id, ad_org_id: extractUserOrgId(userRec), m_warehouse_id: extractUserWarehouseId(userRec), } : null) } else { tlog('singleton-org-skip-userRec-lookup') } // Centralize the "what's the user's preferred org/warehouse?" answer so // the picks below don't care which lookup layer produced it. const userPrefOrgId = extractUserOrgId(userRec) || (svcDefaults?.adOrgId ?? 0) const userPrefWhId = extractUserWarehouseId(userRec) || (svcDefaults?.mWarehouseId ?? 0) tlog('userPrefs', { userPrefOrgId, userPrefWhId }) // 4. Choose the org. Singleton → that one. Multi → user's AD_Org_ID match. let o: any = null if (orgs.length === 1) { o = orgs[0] } else if (userPrefOrgId > 0) { o = orgs.find(org => Number(org.id) === userPrefOrgId) || null } if (!o) { tlog('bail-no-org-match', { orgsCount: orgs.length, userPrefOrgId }); return null } tlog('chosen-org', { id: o.id, name: o.name }) // 5. Warehouses for the chosen org (skip the fetch if it's the scout's org — // we already saw its warehouse list above, but the cheapest correct path // is to just re-fetch with a definitely-scoped token). const queryToken = scoutToken ?? resToken.token let whRes: any = null try { whRes = await fetchHelper(event, `auth/warehouses?client=${c.id}&role=${r.id}&organization=${o.id}`, 'GET', queryToken, null) } catch { return null } const whs = realOnly(extract(whRes, 'warehouses') || []) if (!whs.length) { tlog('bail-no-warehouses'); return null } tlog('warehouses', { realCount: whs.length, sample: whs.slice(0, 3).map((w: any) => ({ id: w.id, name: w.name })) }) let w: any = null if (whs.length === 1) { w = whs[0] } else if (userPrefWhId > 0) { w = whs.find(wh => Number(wh.id) === userPrefWhId) || null } if (!w) { tlog('bail-no-warehouse-match', { whsCount: whs.length, userPrefWhId }); return null } tlog('chosen-wh', { id: w.id, name: w.name }) // 6. Final PUT — but if the scout already landed on the correct org+wh, // skip it and reuse the scout's token. let resToken2: any if (scoutToken && Number(o.id) === scoutOrgId && Number(w.id) === scoutWhId) { resToken2 = { token: scoutToken } tlog('reuse-scout-token') } else { try { resToken2 = await fetchHelper(event, 'auth/tokens', 'PUT', queryToken, { clientId: Number(c.id), roleId: Number(r.id), organizationId: Number(o.id), warehouseId: Number(w.id), language: 'en_US' }) tlog('final-put-ok', { hasToken: !!resToken2?.token, hasUserId: !!resToken2?.userId }) } catch (e: any) { tlog('bail-final-put-failed', { status: e?.status, message: e?.message }) return null } } if (!resToken2?.token) { tlog('bail-no-final-token'); return null } tlog('success', { client: c.id, role: r.id, org: o.id, wh: w.id }) setAuthCookie(event, 'logship_it', resToken2.token) if (resToken2.refresh_token) setAuthCookie(event, 'logship_rt', resToken2.refresh_token) setAuthCookie(event, 'logship_client_id', String(c.id)) setAuthCookie(event, 'logship_role_id', String(r.id)) setAuthCookie(event, 'logship_organization_id', String(o.id)) setAuthCookie(event, 'logship_warehouse_id', String(w.id)) const out: any = { token: resToken2.token, toDashboard: true } if (resToken2.userId) { setAuthCookie(event, 'logship_user_id', resToken2.userId) out.userId = resToken2.userId } if (resToken2.language) { setAuthCookie(event, 'logship_language', resToken2.language) out.language = resToken2.language } const fetchOrNull = (url: string) => fetchHelper(event, url, 'GET', resToken2.token, null).catch(() => null) // User record was already fetched during the intermediate PUT phase; reuse // it to save a round-trip. const [resClient, resRole, resOrg, resWarehouse] = await Promise.all([ fetchOrNull('models/ad_client/' + c.id), fetchOrNull('models/ad_role/' + r.id), fetchOrNull('models/ad_org/' + o.id), fetchOrNull('models/m_warehouse/' + w.id) ]) const resUser = userRec || (resToken2.userId ? await fetchOrNull('models/ad_user/' + resToken2.userId) : null) if (resUser) { setAuthCookie(event, 'logship_user', trimUser(resUser)); out.user = resUser } if (resClient) { setAuthCookie(event, 'logship_client', trimClient(resClient)); out.client = resClient } if (resRole) { setAuthCookie(event, 'logship_role', trimRole(resRole)); out.role = resRole } if (resOrg) { setAuthCookie(event, 'logship_organization', trimOrganization(resOrg)); out.organization = resOrg } if (resWarehouse) { setAuthCookie(event, 'logship_warehouse', trimWarehouse(resWarehouse)); out.warehouse = resWarehouse } enforceMobileWorkerGate(event, !!body?.mobileWorker, resUser, out) if (resToken2.refresh_token) { sqliteHelper(event, (db: any) => { const stmt = db.prepare('INSERT INTO refresh_tokens (refresh_token, token, expiration) VALUES (?, ?, ?)') stmt.run(resToken2.refresh_token, resToken2.token, date.add(date.now(''), 1)) stmt.finalize() }) } return out } export default defineEventHandler(async (event) => { const config = useRuntimeConfig() const body = await readBody(event) let data: any = {} const clientId = getCookie(event, 'logship_client_id') const roleId = getCookie(event, 'logship_role_id') const organizationId = getCookie(event, 'logship_organization_id') const warehouseId = getCookie(event, 'logship_warehouse_id') const language = getCookie(event, 'logship_language') // Set mobile worker flag FIRST so setAuthCookie can detect it if (body.mobileWorker) { setCookie(event, 'logship_mw', '1', { maxAge: MOBILE_MAX_AGE }) } // Set PWA standalone flag so cookies get persistent maxAge // This prevents Android from clearing session cookies when the PWA is backgrounded if (body.pwa) { setCookie(event, 'logship_pwa', '1', { maxAge: MOBILE_MAX_AGE }) } try { let newParameters: any = {} if(!body.selectRole && clientId && roleId) { newParameters = { parameters: { clientId, roleId } } if(organizationId) { //@ts-ignore newParameters['parameters']['organizationId'] = organizationId } if(warehouseId) { //@ts-ignore newParameters['parameters']['warehouseId'] = warehouseId } if(language) { //@ts-ignore newParameters['parameters']['language'] = language } } const resToken: any = await fetchHelper(event, 'auth/tokens', 'POST', '', { userName: body.userName, password: body.password, ...newParameters }) if(resToken.token) { const logshipSession = string.uniqid() data['token'] = resToken.token data['session'] = logshipSession setAuthCookie(event, 'logship_session', logshipSession) setAuthCookie(event, 'logship_xu', Buffer.from(body.userName, 'binary').toString('base64')) setAuthCookie(event, 'logship_py', Buffer.from(body.password, 'binary').toString('base64')) setAuthCookie(event, 'logship_it', resToken.token) //await useStorage().setItem('logship_username_'+logshipSession, body.userName) //await useStorage().setItem('logship_password_'+logshipSession, body.password) //await useStorage().setItem('logship_token_'+logshipSession, resToken.token) } if(resToken?.clients) { setAuthCookie(event, 'logship_clients', resToken.clients) data['clients'] = resToken.clients const autoTrace: any[] = [] const auto = await tryAutoFinalize(event, resToken, body, autoTrace) data['_autoTrace'] = autoTrace if (auto) { Object.assign(data, auto) delete data.clients } else { // tryAutoFinalize bailed — either by design (user ticked "Show // selection", so body.selectRole is true) or because it couldn't // pick the right org/warehouse. Either way, resolve the user's // ad_user defaults so /select-role can prefill the dropdowns. const defaults: any = await fetchAutoDefaults(event, resToken.clients[0]?.id, body.userName, resToken.token) console.log('[login] post-bail userDefaults:', defaults) data['_postBailDefaults'] = defaults if (defaults && (defaults.adOrgId > 0 || defaults.mWarehouseId > 0)) { setAuthCookie(event, 'logship_user_defaults', { adOrgId: defaults.adOrgId, mWarehouseId: defaults.mWarehouseId }) data['userDefaults'] = { adOrgId: defaults.adOrgId, mWarehouseId: defaults.mWarehouseId } } } /*if(resToken?.token && (resToken.clients?.length ?? 0) === 1) { const clientId = resToken.clients[0].id const resClient: any = await fetchHelper(event, 'models/ad_client/'+clientId, 'GET', resToken.token, null) if(resClient) { setCookie(event, 'logship_client', resClient) data['client'] = resClient } const resRoles: any = await fetchHelper(event, 'models/ad_role', 'GET', resToken.token, null) if((resRoles?.length ?? 0) === 1) { const resRole: any = await fetchHelper(event, 'models/ad_role/'+resRoles[0].id, 'GET', resToken.token, null) if(resRole) { // Save complete role data in cookie setCookie(event, 'logship_role', resRole) data['role'] = resRole } } const resOrgs: any = await fetchHelper(event, 'models/ad_org', 'GET', resToken.token, null) if((resOrgs?.length ?? 0) === 1) { const resOrg: any = await fetchHelper(event, 'models/ad_org/'+resOrgs[0].id, 'GET', resToken.token, null) if(resOrg) { setCookie(event, 'logship_organization', resOrg) data['organization'] = resOrg } } const resWarehouses: any = await fetchHelper(event, 'models/m_warehouse', 'GET', resToken.token, null) if(resWarehouses) { const resWarehouse: any = await fetchHelper(event, 'models/m_warehouse/'+resWarehouses[0].id, 'GET', resToken.token, null) if(resWarehouse) { setCookie(event, 'logship_warehouse', resWarehouse) data['warehouse'] = resWarehouse } } setCookie(event, 'logship_language', 'en_US') data['language'] = 'en_US' const resToken2: any = await fetchHelper(event, 'auth/tokens', 'PUT', resToken.token, { clientId: data['client'], roleId: data['role'], organizationId: data['organization'], warehouseId: data['warehouse'], language: data['language'] }) if(resToken2.token) { setCookie(event, 'logship_it', resToken2.token) //await useStorage().setItem('logship_token_'+logshipSession, resToken.token) setCookie(event, 'logship_rt', resToken2.refresh_token) data['token'] = resToken2.token data['refresh_token'] = resToken2.refresh_token } }*/ } else { if(resToken.userId) { setAuthCookie(event, 'logship_user_id', resToken.userId) data['userId'] = resToken.userId const fetchOrNull = (url: string) => fetchHelper(event, url, 'GET', resToken.token, null).catch((e: any) => { if (e?.status === 401 || e?.status === 403) throw e return null }) const [resUser, resClient, resRole, resOrg, resWarehouse] = await Promise.all([ fetchOrNull('models/ad_user/' + resToken.userId), clientId ? fetchOrNull('models/ad_client/' + clientId) : Promise.resolve(null), roleId ? fetchOrNull('models/ad_role/' + roleId) : Promise.resolve(null), organizationId ? fetchOrNull('models/ad_org/' + organizationId) : Promise.resolve(null), warehouseId ? fetchOrNull('models/m_warehouse/' + warehouseId) : Promise.resolve(null) ]) if(resUser) { setAuthCookie(event, 'logship_user', trimUser(resUser)) data['user'] = resUser } if(resClient) { setAuthCookie(event, 'logship_client', trimClient(resClient)) data['client'] = resClient } if(resRole) { setAuthCookie(event, 'logship_role', trimRole(resRole)) data['role'] = resRole } if(resOrg) { setAuthCookie(event, 'logship_organization', trimOrganization(resOrg)) data['organization'] = resOrg } if(resWarehouse) { setAuthCookie(event, 'logship_warehouse', trimWarehouse(resWarehouse)) data['warehouse'] = resWarehouse } data['toDashboard'] = true enforceMobileWorkerGate(event, !!body?.mobileWorker, resUser, data) } if(resToken.language) { setAuthCookie(event, 'logship_language', resToken.language) data['language'] = resToken.language } } if(resToken.refresh_token) { //@ts-ignore setAuthCookie(event, 'logship_rt', resToken.refresh_token) sqliteHelper(event, (db: any) => { const stmt = db.prepare("INSERT INTO refresh_tokens (refresh_token, token, expiration) VALUES (?, ?, ?)") stmt.run(resToken.refresh_token, resToken.token, date.add(date.now(''), 1)) stmt.finalize() }) } } catch(error: any) { // Handle authentication errors with user-friendly messages if (error.status === 401) { data = { body: { userName: body.userName, password: body.password }, status: error.status, message: 'Wrong username or password entered. Please check your credentials and try again.' } } else if (error.status === 403) { data = { body: { userName: body.userName, password: body.password }, status: error.status, message: 'Access denied. Please contact your administrator.' } } else if (error.status >= 500) { data = { body: { userName: body.userName, password: body.password }, status: error.status, message: 'Server error. Please try again later or contact support.' } } else { // For other errors, provide a generic friendly message data = { body: { userName: body.userName, password: body.password }, status: error.status, message: error.message || 'Login failed. Please try again.' } } } return data })