'use strict' const cfg = require('./config') const { log, json, parseBody, httpRequest } = require('./helpers') let mysql = null try { mysql = require('mysql2/promise') } catch (e) { /* optional */ } const CAP_DEFS = { Dashboard: { view_dashboard_kpi: 'Voir KPIs (revenus, AR)' }, Clients: { view_clients: 'Voir la liste clients', view_client_detail: 'Voir le détail client', edit_customer: 'Modifier les champs client' }, Finance: { view_invoices: 'Voir les factures', create_invoices: 'Créer/modifier factures', view_payments: 'Voir les paiements', create_payments: 'Créer paiements', edit_subscription_price: 'Modifier prix abonnement' }, Dispatch: { view_all_jobs: 'Voir tous les jobs', view_own_jobs: 'Voir ses propres jobs', assign_jobs: 'Assigner/réassigner', create_jobs: 'Créer des jobs' }, 'Équipement': { view_equipment: 'Voir diagnostics équipement', reboot_provision: 'Reboot/provisionner' }, Tickets: { view_all_tickets: 'Voir tous les tickets', view_own_tickets: 'Voir ses tickets', manage_tickets: 'Créer/fermer tickets' }, Communication: { send_sms: 'Envoyer SMS', make_calls: 'Passer des appels' }, 'Téléphonie': { manage_telephony: 'Gérer Fonoster/trunks' }, Administration: { view_settings: 'Voir paramètres', manage_settings: 'Modifier paramètres', manage_users: 'Gérer utilisateurs', delete_records: 'Supprimer des enregistrements', manage_permissions: 'Modifier les permissions' }, } const CAPABILITIES = Object.entries(CAP_DEFS).flatMap(([cat, caps]) => Object.entries(caps).map(([key, label]) => ({ key, label, category: cat }))) const OPS_GROUPS = ['admin', 'sysadmin', 'tech', 'support', 'comptabilite', 'facturation', 'dev'] function akFetch (path, method = 'GET', body = null) { if (!cfg.AUTHENTIK_TOKEN) throw new Error('AUTHENTIK_TOKEN not configured') return httpRequest(cfg.AUTHENTIK_URL, '/api/v3' + path, { method, body, headers: { Authorization: 'Bearer ' + cfg.AUTHENTIK_TOKEN }, timeout: 10000, }) } let groupCache = null, groupCacheTs = 0 async function fetchGroups () { if (groupCache && Date.now() - groupCacheTs < 60000) return groupCache const r = await akFetch('/core/groups/?page_size=100') if (r.status !== 200) throw new Error('Authentik groups fetch failed: ' + r.status) groupCache = r.data.results groupCacheTs = Date.now() return groupCache } function invalidateCache () { groupCache = null; groupCacheTs = 0 } async function findUserByEmail (email) { const r = await akFetch('/core/users/?search=' + encodeURIComponent(email) + '&page_size=5') if (r.status !== 200) return { error: 'Authentik user lookup failed', status: 502 } const user = r.data.results?.find(u => u.email?.toLowerCase() === email.toLowerCase()) return user ? { user } : { error: 'User not found: ' + email, status: 404 } } function validatePermissions (body) { const validKeys = new Set(CAPABILITIES.map(c => c.key)) const result = {} for (const [key, val] of Object.entries(body)) { if (validKeys.has(key) && typeof val === 'boolean') result[key] = val } return result } async function handle (req, res, method, path, url) { if (!cfg.AUTHENTIK_TOKEN) return json(res, 503, { error: 'Authentik not configured' }) try { const parts = path.replace('/auth/', '').split('/').filter(Boolean) const resource = parts[0] if (resource === 'capabilities' && method === 'GET') { return json(res, 200, CAPABILITIES) } if (resource === 'permissions' && method === 'GET') { const email = url.searchParams.get('email') || req.headers['x-authentik-email'] if (!email) return json(res, 400, { error: 'Missing email parameter' }) const lookup = await findUserByEmail(email) if (lookup.error) return json(res, lookup.status, { error: lookup.error }) const { user } = lookup const allGroups = await fetchGroups() const userGroupPks = new Set(user.groups || []) const userGroups = allGroups.filter(g => userGroupPks.has(g.pk) && OPS_GROUPS.includes(g.name)) const merged = {} for (const cap of CAPABILITIES) merged[cap.key] = false for (const g of userGroups) { const perms = g.attributes?.ops_permissions || {} for (const [key, val] of Object.entries(perms)) { if (val === true) merged[key] = true } } const overrides = user.attributes?.ops_permissions_override || {} for (const [key, val] of Object.entries(overrides)) { if (typeof val === 'boolean') merged[key] = val } return json(res, 200, { email: user.email, username: user.username, name: user.name, groups: userGroups.map(g => g.name), is_superuser: user.is_superuser || false, capabilities: merged, overrides, }) } if (resource === 'groups' && !parts[1] && method === 'GET') { const allGroups = await fetchGroups() const opsGroups = allGroups .filter(g => OPS_GROUPS.includes(g.name)) .map(g => ({ pk: g.pk, name: g.name, num_users: g.users_obj?.length || 0, is_superuser: g.is_superuser || false, permissions: g.attributes?.ops_permissions || {}, })) .sort((a, b) => OPS_GROUPS.indexOf(a.name) - OPS_GROUPS.indexOf(b.name)) return json(res, 200, { groups: opsGroups, capabilities: CAPABILITIES }) } if (resource === 'groups' && parts[2] === 'permissions' && method === 'PUT') { const groupName = decodeURIComponent(parts[1]) const allGroups = await fetchGroups() const group = allGroups.find(g => g.name === groupName) if (!group) return json(res, 404, { error: 'Group not found: ' + groupName }) const perms = validatePermissions(await parseBody(req)) const attrs = { ...(group.attributes || {}), ops_permissions: perms } const r = await akFetch('/core/groups/' + group.pk + '/', 'PATCH', { attributes: attrs }) if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status }) invalidateCache() log(`Auth: updated permissions for group ${groupName}: ${Object.keys(perms).filter(k => perms[k]).length} capabilities enabled`) return json(res, 200, { ok: true, group: groupName, permissions: perms }) } if (resource === 'users' && !parts[1] && method === 'GET') { const page = url.searchParams.get('page') || '1' const search = url.searchParams.get('search') || '' const groupFilter = url.searchParams.get('group') || '' let akPath = `/core/users/?page=${page}&page_size=100&ordering=username` if (search) akPath += '&search=' + encodeURIComponent(search) if (groupFilter) { const allGrps = await fetchGroups() const grp = allGrps.find(g => g.name === groupFilter) if (grp) akPath += '&groups_by_pk=' + grp.pk } const r = await akFetch(akPath) if (r.status !== 200) return json(res, 502, { error: 'Authentik users fetch failed' }) const allGroups = await fetchGroups() const groupMap = new Map(allGroups.map(g => [g.pk, g.name])) const users = r.data.results .filter(u => !u.username.startsWith('ak-outpost')) .map(u => ({ pk: u.pk, username: u.username, email: u.email, name: u.name, is_active: u.is_active, is_superuser: u.is_superuser, groups: (u.groups || []).map(pk => groupMap.get(pk)).filter(n => OPS_GROUPS.includes(n)), overrides: u.attributes?.ops_permissions_override || {}, last_login: u.last_login, })) return json(res, 200, { users, total: r.data.pagination?.count || users.length }) } if (resource === 'users' && parts[2] === 'overrides' && method === 'PUT') { const email = decodeURIComponent(parts[1]) const lookup = await findUserByEmail(email) if (lookup.error) return json(res, lookup.status, { error: lookup.error }) const { user } = lookup const overrides = validatePermissions(await parseBody(req)) const attrs = { ...(user.attributes || {}), ops_permissions_override: overrides } const r = await akFetch('/core/users/' + user.pk + '/', 'PATCH', { attributes: attrs }) if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status }) log(`Auth: updated overrides for ${email}: ${JSON.stringify(overrides)}`) return json(res, 200, { ok: true, email, overrides }) } if (resource === 'users' && parts[2] === 'groups' && method === 'PUT') { const email = decodeURIComponent(parts[1]) const lookup = await findUserByEmail(email) if (lookup.error) return json(res, lookup.status, { error: lookup.error }) const { user } = lookup const { groups: requestedGroups = [] } = await parseBody(req) const allGroups = await fetchGroups() const opsGroupMap = new Map(allGroups.filter(g => OPS_GROUPS.includes(g.name)).map(g => [g.name, g.pk])) const currentNonOps = (user.groups || []).filter(pk => { const g = allGroups.find(gr => gr.pk === pk) return g && !OPS_GROUPS.includes(g.name) }) const finalGroups = [...currentNonOps, ...requestedGroups.map(n => opsGroupMap.get(n)).filter(Boolean)] const r = await akFetch('/core/users/' + user.pk + '/', 'PATCH', { groups: finalGroups }) if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status }) invalidateCache() log(`Auth: updated groups for ${email}: [${requestedGroups.join(', ')}]`) return json(res, 200, { ok: true, email, groups: requestedGroups }) } // POST /auth/users — create a new user in Authentik AND ERPNext. // Body: { email, full_name, groups? = ['sysadmin'], roles? = ['Employee'] } // Behaviour: // 1. Authentik: create the user (random password), assign requested // OPS_GROUPS, then trigger a recovery-link email so the user picks // their own password on first login. // 2. ERPNext: create the matching User record (System User by default, // with Authentik as the Social Login source) so OAuth2 finds them // on first SSO login. Sends the standard ERPNext welcome email. // Returns the merged user record consumable by the Settings UI. if (resource === 'users' && !parts[1] && method === 'POST') { const body = await parseBody(req) const email = (body.email || '').trim().toLowerCase() const fullName = (body.full_name || '').trim() const requestedGroups = Array.isArray(body.groups) ? body.groups.filter(g => OPS_GROUPS.includes(g)) : ['sysadmin'] const roles = Array.isArray(body.roles) && body.roles.length ? body.roles : ['Employee'] if (!email || !fullName) return json(res, 400, { error: 'email + full_name required' }) if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return json(res, 400, { error: 'invalid email' }) // 1a. Pre-flight: did this user already exist on either side? Don't // fail outright — the dispatcher might be re-inviting somebody // after a botched first try; re-send the recovery + re-create // ERPNext record if missing. const existing = await akFetch('/core/users/?search=' + encodeURIComponent(email) + '&page_size=5') let akUser = existing.data?.results?.find(u => u.email?.toLowerCase() === email) // 1b. Username derivation — Authentik usernames are commonly the // local part of the email; collisions get a numeric suffix. const baseUser = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user' let username = baseUser if (!akUser) { let suffix = 0 // eslint-disable-next-line no-constant-condition while (true) { const probe = await akFetch('/core/users/?username=' + encodeURIComponent(username)) if (!probe.data?.results?.length) break suffix++; username = baseUser + suffix if (suffix > 50) return json(res, 500, { error: 'Could not allocate Authentik username' }) } } // 1c. Resolve requested group names → Authentik group PKs. const allGroups = await fetchGroups() const groupPks = requestedGroups .map(name => allGroups.find(g => g.name === name)?.pk) .filter(Boolean) // 1d. Create OR patch the Authentik user. Always end with the requested // groups attached; resetting if necessary keeps the call idempotent. if (!akUser) { const create = await akFetch('/core/users/', 'POST', { username, name: fullName, email, is_active: true, groups: groupPks, attributes: {}, }) if (create.status >= 400) { log('Authentik user create failed:', create.status, JSON.stringify(create.data)) return json(res, 502, { error: 'Authentik create failed', detail: create.data }) } akUser = create.data } else { const patch = await akFetch('/core/users/' + akUser.pk + '/', 'PATCH', { name: fullName, is_active: true, groups: groupPks, }) if (patch.status !== 200) { log('Authentik user patch failed:', patch.status, JSON.stringify(patch.data)) } else { akUser = patch.data } } // 1e. Trigger a recovery email — Authentik mails the user a one-time // link so they set their own password. Only effective if the // `Email` stage is configured globally in the recovery flow. let recoveryUrl = null try { const rec = await akFetch('/core/users/' + akUser.pk + '/recovery_email/', 'POST', {}) if (rec.status >= 400) { // Fallback: get a recovery URL we can hand back to the admin // (e.g. to send via SMS or Slack ourselves). const link = await akFetch('/core/users/' + akUser.pk + '/recovery/', 'POST', {}) if (link.status === 200) recoveryUrl = link.data?.link || null } } catch (e) { log('recovery email failed:', e.message) } // 2. Create the matching ERPNext User. Frappe wants `roles` as a child // table of {role: } rows; the Authentik OAuth2 login // matches by `email` so this record is what gets logged in to. let erpResult = null try { // Skip if already exists. const probe = await erpFetch('/api/resource/User/' + encodeURIComponent(email)) if (probe.status === 200) { erpResult = { ok: true, existing: true, name: email } } else { const erpBody = { email, first_name: fullName.split(' ')[0], last_name: fullName.split(' ').slice(1).join(' '), full_name: fullName, user_type: 'System User', enabled: 1, send_welcome_email: 1, roles: roles.map(r => ({ role: r })), social_logins: [{ provider: 'authentik', userid: email }], } const create = await erpFetch('/api/resource/User', { method: 'POST', body: JSON.stringify(erpBody) }) if (create.status >= 400) { log('ERPNext User create failed:', create.status, JSON.stringify(create.data).slice(0, 400)) erpResult = { ok: false, error: 'ERPNext create failed: ' + create.status } } else { erpResult = { ok: true, existing: false, name: create.data?.data?.name || email } } } } catch (e) { log('ERPNext User flow failed:', e.message); erpResult = { ok: false, error: e.message } } log(`Auth: invited ${email} (groups=[${requestedGroups.join(',')}], erp=${erpResult?.ok ? 'ok' : 'failed'})`) return json(res, 200, { ok: true, user: { pk: akUser.pk, username: akUser.username, email: akUser.email, name: akUser.name, is_active: akUser.is_active, groups: requestedGroups, overrides: {}, }, erpnext: erpResult, recovery_url: recoveryUrl, }) } if (resource === 'sync-legacy' && method === 'POST') { if (!mysql) return json(res, 503, { error: 'mysql2 not installed' }) const body = await parseBody(req) const dryRun = body.dry_run !== false // default true = preview only let conn try { conn = await mysql.createConnection({ host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME, }) const [rows] = await conn.execute( 'SELECT email, group_ad, first_name, last_name, status FROM staff WHERE status = 1 AND email != "" AND group_ad != ""' ) await conn.end() const allGroups = await fetchGroups() const opsGroupMap = new Map() for (const g of allGroups) { if (OPS_GROUPS.includes(g.name)) opsGroupMap.set(g.name, g) } let akUsers = [], page = 1 while (true) { const r = await akFetch('/core/users/?page=' + page + '&page_size=100&ordering=username') if (r.status !== 200) break akUsers = akUsers.concat(r.data.results || []) if (!r.data.pagination || page >= r.data.pagination.total_pages) break page++ } const emailMap = new Map() for (const u of akUsers) { if (u.email) emailMap.set(u.email.toLowerCase(), u) } const report = { matched: [], not_found: [], already_ok: [], errors: [] } for (const row of rows) { const legacyEmail = row.email.toLowerCase().trim() const targetGroup = row.group_ad.trim() if (!opsGroupMap.has(targetGroup)) continue const akUser = emailMap.get(legacyEmail) if (!akUser) { report.not_found.push({ email: legacyEmail, legacy_group: targetGroup, name: `${row.first_name} ${row.last_name}` }) continue } const group = opsGroupMap.get(targetGroup) const userGroupPks = new Set(akUser.groups || []) if (userGroupPks.has(group.pk)) { report.already_ok.push({ email: legacyEmail, group: targetGroup }) continue } const entry = { email: legacyEmail, username: akUser.username, group: targetGroup, user_pk: akUser.pk } if (!dryRun) { const newGroups = [...(akUser.groups || []), group.pk] const r = await akFetch('/core/users/' + akUser.pk + '/', 'PATCH', { groups: newGroups }) if (r.status === 200) { entry.status = 'synced' log(`Legacy sync: added ${legacyEmail} to group ${targetGroup}`) } else { entry.status = 'error' entry.error = 'Authentik PATCH failed: ' + r.status report.errors.push(entry) continue } } else { entry.status = 'pending' } report.matched.push(entry) } if (!dryRun) invalidateCache() return json(res, 200, { dry_run: dryRun, summary: { to_sync: report.matched.length, already_ok: report.already_ok.length, not_found: report.not_found.length, errors: report.errors.length, }, ...report, }) } catch (e) { if (conn) try { await conn.end() } catch {} log('Legacy sync error:', e.message) return json(res, 502, { error: 'Legacy sync error: ' + e.message }) } } return json(res, 404, { error: 'Unknown auth endpoint' }) } catch (e) { log('Auth error:', e.message) return json(res, 502, { error: 'Auth error: ' + e.message }) } } module.exports = { handle, CAPABILITIES, OPS_GROUPS }