// Sends the new merchant their login credentials (username + password + login URL). // Mirrors the nodemailer transport used elsewhere (localhost:25, from no-reply@logyou.de). // Bilingual (DE/EN) HTML. The password is sent in plaintext per explicit product request — // recommend the merchant change it on first login. // // When body.resetPassword is true (+ userId), a fresh 8-char password is generated, written // to AD_User.Password in iDempiere, and that new password is the one e-mailed and returned. import nodemailer from 'nodemailer' import { randomInt } from 'node:crypto' import refreshTokenHelper from "../../../utils/refreshTokenHelper" import fetchHelper from "../../../utils/fetchHelper" const LOGIN_URL = 'https://app.logship.de' const esc = (s: any) => String(s ?? '') .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"') // Random 8-char password, digits + letters; ambiguous chars (0/O/1/l/I) excluded. const generatePassword = (len = 8) => { const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789' let out = '' for (let i = 0; i < len; i++) out += charset[randomInt(0, charset.length)] return out } // Writes the new password to AD_User.Password (lowercase-first key per iDempiere write rule), // retrying once with a refreshed token on auth failure. const resetUserPassword = async (event: any, userId: string | number, newPassword: string) => { const put = async (token: any) => fetchHelper(event, 'models/ad_user/' + userId, 'PUT', token, { password: newPassword, tableName: 'AD_User', }) try { return await put(await getTokenHelper(event)) } catch (err: any) { const authToken: any = await refreshTokenHelper(event) return await put(authToken) } } // Ensures the AD_User is assigned to their org's merchant role via AD_User_Roles — the same // relation the onboarding wizard creates (see merchant-onboarding/process.post.ts step 9). // We want a user receiving their login mail to actually be able to sign in, so if the link is // missing we add it here. Idempotent: an existing assignment is left as-is (re-activated if // inactive — a fresh POST would clash on AD_User_Roles' composite PK). Best-effort: the caller // treats any failure as non-fatal and still sends the credentials email. // Returns { status: 'assigned'|'reactivated'|'exists'|'skipped', message, roleId? }. const ensureUserRoleAssignment = async (event: any, userId: string | number, bpartnerId: string | number = '') => { let token = await getTokenHelper(event) let refreshed = false // Single shared token; refresh at most once across all calls on the first auth failure. const call = async (url: string, method: string, payload: any = null): Promise => { try { return await fetchHelper(event, url, method, token, payload) } catch (e: any) { if (refreshed) throw e refreshed = true token = await refreshTokenHelper(event) return await fetchHelper(event, url, method, token, payload) } } // 1) The merchant org the role lives in. Resolve it from the BPARTNER (ad_org.C_BPartner_ID) — // that is authoritative. The user's own AD_Org_ID is unreliable here: a contact/login user // attached to the bpartner often sits at org 0 (* / tenant level), which previously tripped // the "no merchant org" guard and skipped the assignment. Fall back to the user's org only // if the bpartner doesn't resolve. let orgId: any = null if (bpartnerId) { const orgRes: any = await call(`models/ad_org?$filter=C_BPartner_ID eq ${bpartnerId} and IsActive eq true&$top=1`, 'GET') orgId = orgRes?.records?.[0]?.id } if (!orgId || Number(orgId) === 0) { const user: any = await call(`models/ad_user/${userId}`, 'GET') orgId = user?.AD_Org_ID?.id } if (!orgId || Number(orgId) === 0) return { status: 'skipped', message: 'Could not resolve merchant org' } // 2) The merchant role for that org — the cloned role is OWNED by the merchant org // (copyRoleHelper sets AD_Role.AD_Org_ID = merchant org). Prefer FrontendMenu='c' // (Fulfillment Customer), or the single active role. If that finds nothing, fall back to // roles that merely have org-access (matches onboarding step 10's resolution). const rolesRes: any = await call(`models/ad_role?$filter=AD_Org_ID eq ${orgId} and IsActive eq true&$top=50`, 'GET') const roles: any[] = rolesRes?.records || [] let merchantRole = roles.find((r: any) => (r.FrontendMenu?.id ?? r.FrontendMenu) === 'c') || (roles.length === 1 ? roles[0] : null) let roleId = merchantRole?.id if (!roleId) { const accessRes: any = await call(`models/ad_role_orgaccess?$filter=AD_Org_ID eq ${orgId} and IsActive eq true&$expand=AD_Role_ID&$top=1000`, 'GET') const accRow = (accessRes?.records || []).find((r: any) => (r.AD_Role_ID?.FrontendMenu?.id ?? r.AD_Role_ID?.FrontendMenu) === 'c') roleId = accRow?.AD_Role_ID?.id } if (!roleId) return { status: 'skipped', message: `No merchant role found for org ${orgId}` } // 3) Already assigned? Match on user+role (composite PK), ignoring IsActive for the lookup. const existing: any = await call(`models/ad_user_roles?$filter=AD_User_ID eq ${userId} and AD_Role_ID eq ${roleId}&$top=1`, 'GET') const rec = (existing?.records || [])[0] if (rec) { if (rec.IsActive === true) return { status: 'exists', message: 'Already assigned', roleId } if (rec.uid) { await call(`models/ad_user_roles/${rec.uid}`, 'PUT', { isActive: true, tableName: 'AD_User_Roles' }) return { status: 'reactivated', message: 'Re-activated existing assignment', roleId } } return { status: 'exists', message: 'Already assigned (inactive, no uid)', roleId } } // 4) Create the assignment exactly like onboarding step 9. const res: any = await call('models/ad_user_roles', 'POST', { AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, AD_User_ID: { id: userId, tableName: 'AD_User' }, AD_Role_ID: { id: roleId, tableName: 'AD_Role' }, isActive: true, tableName: 'AD_User_Roles', }) // AD_User_Roles has a composite PK (no surrogate id): a successful POST returns a `uid`. if (!res?.uid && !res?.id) throw new Error(res?.summary || res?.message || 'AD_User_Roles not created') return { status: 'assigned', message: 'Assigned to merchant role', roleId } } // Validates that the recipient's own AD_User.AD_Org_ID is actually set to their merchant org — // the org whose C_BPartner_ID is this partner (the authoritative source, same org the role-access // path resolves to). A user record pointing at the wrong org (or org 0 / *) would land them on the // wrong organization — or fail — at login, so we block the credentials send and ask an admin to fix // the user's org first. Returns { ok: true } | { ok: false, expectedOrgId, userOrgId }. // If the merchant org can't be resolved from the bpartner, we don't block here (separate problem). const validateUserOrg = async (event: any, userId: string | number, bpartnerId: string | number) => { let token = await getTokenHelper(event) let refreshed = false const call = async (url: string): Promise => { try { return await fetchHelper(event, url, 'GET', token, null) } catch (e: any) { if (refreshed) throw e refreshed = true token = await refreshTokenHelper(event) return await fetchHelper(event, url, 'GET', token, null) } } let expectedOrgId: any = null if (bpartnerId) { const orgRes: any = await call(`models/ad_org?$filter=C_BPartner_ID eq ${bpartnerId} and IsActive eq true&$top=1`) expectedOrgId = orgRes?.records?.[0]?.id } if (!expectedOrgId || Number(expectedOrgId) === 0) return { ok: true } const user: any = await call(`models/ad_user/${userId}`) const userOrgId = user?.AD_Org_ID?.id ?? user?.AD_Org_ID if (userOrgId == null || Number(userOrgId) !== Number(expectedOrgId)) { return { ok: false, expectedOrgId, userOrgId: userOrgId ?? null } } return { ok: true } } export default defineEventHandler(async (event) => { const body = await readBody(event) const toEmail = String(body?.toEmail || '').trim() const username = String(body?.username || '').trim() const resetPassword = body?.resetPassword === true const userId = body?.userId const bpartnerId = body?.bpartnerId // Greeting source: AD_User.Description first, fall back to AD_User.Name. const greetingName = String(body?.description || '').trim() || String(body?.name || '').trim() const greetingLine = greetingName ? `Hallo ${esc(greetingName)},` : 'Guten Tag,' if (!toEmail || !username) { throw createError({ statusCode: 400, statusMessage: 'toEmail and username are required.' }) } // Guard: the recipient's AD_User.AD_Org_ID must point at their merchant org, otherwise their // login would resolve to the wrong org. Block the send (no password reset, no email) on a // confirmed mismatch. Infra/auth errors in the check itself fail open (do not block). if (userId && bpartnerId) { try { const orgCheck: any = await validateUserOrg(event, userId, bpartnerId) if (!orgCheck.ok) { return { status: 409, message: `Zugangsdaten nicht gesendet: Die Organisation dieses Benutzers ist nicht korrekt gesetzt ` + `(Benutzer-Org: ${orgCheck.userOrgId ?? '–'}, erforderlich: ${orgCheck.expectedOrgId}). ` + `Bitte einen Administrator bitten, die Organisation des Benutzers zuerst zu korrigieren. / ` + `Cannot send: this user's organization is not set correctly ` + `(user org: ${orgCheck.userOrgId ?? '–'}, required: ${orgCheck.expectedOrgId}). ` + `Please ask an admin to fix the user's org first.` } } } catch (e: any) { // validation failed (network/auth) → fail open, continue with the send } } // Reset (generate + persist) the password before sending, when requested. let password = String(body?.password || '') let newPassword = '' if (resetPassword) { if (!userId) { return { status: 400, message: 'userId is required to reset the password' } } newPassword = generatePassword(8) try { const res: any = await resetUserPassword(event, userId, newPassword) if (!res || (res.status && Number(res.status) >= 400)) { return { status: 500, message: res?.message || 'Failed to update password in iDempiere' } } } catch (e: any) { return { status: 500, message: e?.data?.message || e?.message || 'Failed to update password' } } password = newPassword } // Make sure the recipient can actually sign in: assign them to their org's merchant role // (AD_User_Roles) if they aren't yet — the same link the onboarding wizard creates. Non-fatal, // so a problem here never blocks the credentials email; the outcome is surfaced in the response. let roleAssignment: any = { status: 'skipped', message: 'No userId provided' } if (userId) { try { roleAssignment = await ensureUserRoleAssignment(event, userId, bpartnerId) } catch (e: any) { roleAssignment = { status: 'error', message: e?.data?.message || e?.message || 'Role assignment failed' } } } const transporter = nodemailer.createTransport({ host: 'localhost', port: 25, secure: false, tls: { rejectUnauthorized: false }, }) const html = `
LogYou
Willkommen bei LogShip
Welcome to LogShip

${greetingLine}

Ihr Zugang zum LogShip-Portal wurde eingerichtet. Mit den folgenden Zugangsdaten können Sie sich anmelden:

Your access to the LogShip portal has been created. Sign in with the credentials below:

Benutzername / Username
${esc(username)}
${password ? `
Passwort / Password
${esc(password)}
` : ''}
Portal
${password ? 'Tipp: Auf Benutzername bzw. Passwort tippen/klicken markiert den Wert zum Kopieren. · Tip: tap/click the username or password to select it for copying.' : 'Tipp: Auf den Benutzernamen tippen/klicken markiert ihn zum Kopieren. · Tip: tap/click the username to select it for copying.'}
Zum Login / Go to Login

In LogShip sehen Sie jederzeit den Live-Status Ihrer Aufträge und Sendungen. Sie verwalten Ihre Bestellungen und Artikel, behalten Ihre Lagerbestände im Blick, verfolgen den Versand in Echtzeit, bearbeiten Retouren und greifen jederzeit auf Ihre Dokumente und Rechnungen zu – und vieles mehr. LogShip nutzen Sie auf allen Geräten – Desktop, Smartphone und Tablet.

In LogShip you can track the real-time status of your orders and shipments, manage your orders and articles, keep an eye on your stock levels, follow deliveries live, handle returns and access your documents and invoices — and much more. Use LogShip on any device — desktop, mobile and tablet.

LogYou
Mit freundlichen Grüßen / Best regards · ${LOGIN_URL}
` const text = `Willkommen bei LogShip / Welcome to LogShip ${greetingName ? `Hallo ${greetingName},\n\n` : ''}Benutzername / Username: ${username} ${password ? `Passwort / Password: ${password}\n` : ''}Login: ${LOGIN_URL} In LogShip sehen Sie jederzeit den Live-Status Ihrer Aufträge und Sendungen. Sie verwalten Ihre Bestellungen und Artikel, behalten Ihre Lagerbestände im Blick, verfolgen den Versand in Echtzeit, bearbeiten Retouren und greifen jederzeit auf Ihre Dokumente und Rechnungen zu – und vieles mehr. LogShip nutzen Sie auf allen Geräten – Desktop, Smartphone und Tablet. LogYou` try { await transporter.sendMail({ from: 'no-reply@logyou.de', to: toEmail, subject: 'Ihre LogShip Zugangsdaten / Your LogShip credentials', text, html, }) return { status: 200, message: 'Email sent successfully', newPassword: resetPassword ? newPassword : undefined, roleAssignment } } catch (error: any) { return { status: 500, message: error?.message || 'Failed to send credentials email' } } })