'use strict' const cfg = require('./config') const crypto = require('crypto') const { log, json, parseBody, httpRequest, erpFetch } = require('./helpers') const { sendEmail } = require('./email') // Strong-but-readable password: 4 base32 chunks separated by dashes, // e.g. "X7K2-9NQB-4GHM-3RTW". Avoids look-alike chars (0/O, 1/I/L) so // the user can copy it from a Slack/SMS without guessing case. function generateInvitePassword () { const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // no 0/O/1/I/L const out = [] const buf = crypto.randomBytes(16) for (let i = 0; i < 16; i++) out.push(alphabet[buf[i] % alphabet.length]) return `${out.slice(0,4).join('')}-${out.slice(4,8).join('')}-${out.slice(8,12).join('')}-${out.slice(12,16).join('')}` } 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 } } // Cache courriel → NOM COMPLET Authentik (TTL 5 min). Sert au « From » sortant // personnel : on veut afficher le nom canonique (« Louis-Paul Bourdon ») et non // un nom dérivé du préfixe courriel (« louis@ » → « Louis »). '' si introuvable. const nameCache = new Map() // email → { name, ts } async function getDisplayNameByEmail (email) { const key = String(email || '').trim().toLowerCase() if (!key) return '' // Identité unifiée d'abord : un alias (louispaul@…) résout au LABEL canonique (« Louis-Paul Bourdon »). try { const lbl = require('./identity').labelForEmail(key); if (lbl) return lbl } catch (e) { /* store absent → Authentik */ } const hit = nameCache.get(key) if (hit && Date.now() - hit.ts < 300000) return hit.name let name = '' try { const lookup = await findUserByEmail(key) if (!lookup.error && lookup.user && lookup.user.name) name = String(lookup.user.name).trim() } catch { /* Authentik indispo → repli sur dérivation locale côté appelant */ } nameCache.set(key, { name, ts: Date.now() }) return name } // Repli : quand le courriel ne correspond à aucun compte OPS (courriel vide côté whoami, ou différent de // celui provisionné), on retrouve l'utilisateur par son username Authentik (transmis aussi par whoami). async function findUserByUsername (username) { const r = await akFetch('/core/users/?search=' + encodeURIComponent(username) + '&page_size=5') if (r.status !== 200) return { error: 'Authentik user lookup failed', status: 502 } const user = r.data.results?.find(u => u.username?.toLowerCase() === username.toLowerCase()) return user ? { user } : { error: 'User not found: ' + username, status: 404 } } // Résout le profil EMPLOYÉ ERPNext lié à un compte de session (courriel Authentik). // Lien = Employee.user_id (posé à l'invitation) ; REPLI sur company_email. null si non lié. const EMPLOYEE_FIELDS = ['name', 'employee_name', 'designation', 'department', 'cell_number', 'office_extension', 'company_email', 'user_id', 'status'] async function resolveEmployeeForEmail (email) { const e = String(email || '').trim() if (!e) return null try { const erp = require('./erp') // ALIAS-AWARE : le compte de session (louis@targo.ca) et l'Employee (sous louispaul@targointernet.com) // sont la même personne → on cherche l'Employee sous N'IMPORTE QUEL courriel de l'identité unifiée. let emails = [e] try { const id = require('./identity').resolveIdentity(e); if (id) emails = [...new Set([id.primary_email, ...(id.alias_emails || []), e].filter(Boolean))] } catch (x) {} let rows = await erp.list('Employee', { filters: [['user_id', 'in', emails]], fields: EMPLOYEE_FIELDS, limit: 1 }) if (!rows.length) rows = await erp.list('Employee', { filters: [['company_email', 'in', emails]], fields: EMPLOYEE_FIELDS, limit: 1 }) return rows[0] || null } catch (err) { log('resolveEmployeeForEmail ' + e + ': ' + err.message); return null } } // Admin = mêmes groupes que identity/avatars/user-prefs (écritures staff sensibles). function isAdminReq (req) { const g = String(req.headers['x-authentik-groups'] || req.headers['x-authentik-group'] || '') return /(^|[,|;])\s*(ops-?admin|admin(istrateurs?)?|superviseurs?)\s*([,|;]|$)/i.test(g) } 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'] const username = url.searchParams.get('username') || req.headers['x-authentik-username'] if (!email && !username) return json(res, 400, { error: 'Missing email or username parameter' }) // Recherche par courriel, puis REPLI par username (courriel absent/non provisionné). let lookup = email ? await findUserByEmail(email) : { error: 'no email', status: 404 } if (lookup.error && username) lookup = await findUserByUsername(username) 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 } // Profil employé lié (log de la session ↔ Employee) — non bloquant si ERPNext indispo. const employee = await resolveEmployeeForEmail(user.email) log(`Auth: session ${user.email} (${user.name}) → employé ${employee ? employee.name : 'non lié'}`) 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, employee, }) } 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 }) } // ── CONSOLE STAFF : réconciliation Authentik ↔ System User / Employee / Dispatch Technician / identité ── if (resource === 'staff' && !parts[1] && method === 'GET') { const erp = require('./erp'); const idlib = require('./identity') // 1. Users Authentik internes (toutes les pages ; exclut outposts + comptes de service). const akUsers = [] for (let p = 1; p <= 12; p++) { const r = await akFetch(`/core/users/?page=${p}&page_size=100&ordering=username`) if (r.status !== 200) break akUsers.push(...(r.data.results || []).filter(u => u.email && !u.username.startsWith('ak-outpost') && u.type !== 'internal_service_account')) if (!r.data.pagination || !r.data.pagination.next) break } const allGroups = await fetchGroups(); const gmap = new Map(allGroups.map(g => [g.pk, g.name])) // 2. ERPNext en lot (1 requête par doctype). const [sysU, emps, techs] = await Promise.all([ erp.list('User', { filters: [['user_type', '=', 'System User']], fields: ['name', 'enabled'], limit: 1000 }).catch(() => []), erp.list('Employee', { fields: ['name', 'employee_name', 'company_email', 'user_id', 'status'], limit: 2000 }).catch(() => []), erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'employee', 'status'], limit: 1000 }).catch(() => []), ]) const sysSet = new Set(sysU.map(u => String(u.name).toLowerCase())) const empByEmail = {}; for (const e of emps) { if (e.user_id) empByEmail[String(e.user_id).toLowerCase()] = e; if (e.company_email && !empByEmail[String(e.company_email).toLowerCase()]) empByEmail[String(e.company_email).toLowerCase()] = e } const techByEmp = {}; for (const t of techs) { if (t.employee) techByEmp[t.employee] = t } // DÉ-DUP par courriel : plusieurs comptes Authentik pour la même personne (username `joseph` + `joseph@…`) // → UNE ligne, en listant les usernames en double à nettoyer. Actif si au MOINS un compte actif. const byEmail = new Map() for (const u of akUsers) { const email = String(u.email || '').toLowerCase(); if (!email) continue const g = byEmail.get(email) || { email, usernames: [], is_active: false, name: '', groupsSet: new Set() } g.usernames.push(u.username) g.is_active = g.is_active || !!u.is_active if (!g.name && u.name) g.name = u.name for (const pk of (u.groups || [])) { const n = gmap.get(pk); if (OPS_GROUPS.includes(n)) g.groupsSet.add(n) } byEmail.set(email, g) } const rows = [...byEmail.values()].map(g => { const email = g.email const id = idlib.resolveIdentity(email) // Employee résolu via l'identité (alias) : louis@targo.ca → Employee sous louispaul@targointernet.com. const idEmails = id ? [id.primary_email, ...(id.alias_emails || []), email] : [email] let emp = null; for (const e of idEmails) { if (empByEmail[String(e).toLowerCase()]) { emp = empByEmail[String(e).toLowerCase()]; break } } const tech = (emp && techByEmp[emp.name]) || (id && id.tech_id ? { technician_id: id.tech_id } : null) const hasSys = idEmails.some(e => sysSet.has(String(e).toLowerCase())) const groups = [...g.groupsSet] let status = 'ok' if (!g.is_active || (id && id.active === false)) status = 'departed' else if (!hasSys) status = 'orphan' const needs_group = g.is_active && hasSys && !groups.length const dup_accounts = g.usernames.length > 1 ? g.usernames.length : 0 return { email, username: g.usernames[0], usernames: g.usernames, dup_accounts, name: g.name || (id && id.label) || email, is_active: g.is_active, groups, has_system_user: hasSys, employee: emp ? emp.name : '', tech_id: tech ? (tech.technician_id || tech.name) : '', identity_key: id ? id.key : '', identity_active: id ? id.active !== false : null, label: id ? id.label : '', status, needs_group } }).sort((a, b) => (a.status === 'orphan' ? 0 : 1) - (b.status === 'orphan' ? 0 : 1) || String(a.name).localeCompare(b.name)) return json(res, 200, { staff: rows }) } // Impact d'un retrait : références qui empêchent une suppression propre (tickets assignés, jobs). if (resource === 'staff' && parts[1] === 'impact' && method === 'GET') { const erp = require('./erp'); const idlib = require('./identity') const email = String(url.searchParams.get('email') || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' }) const id = idlib.resolveIdentity(email) const emails = id ? [id.primary_email, ...(id.alias_emails || [])] : [email] let tickets = 0 for (const e of emails) { try { const r = await erp.list('Issue', { filters: [['_assign', 'like', '%' + e + '%']], fields: ['name'], limit: 200 }); tickets += (r || []).length } catch (x) {} } let jobs = 0 if (id && id.tech_id) { try { const r = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', id.tech_id]], fields: ['name'], limit: 500 }); jobs = (r || []).length } catch (x) {} } return json(res, 200, { email, tickets, jobs, clean: tickets === 0 && jobs === 0 }) } // ── Écritures staff : admin seulement ── if (resource === 'staff' && method !== 'GET' && !isAdminReq(req)) return json(res, 403, { error: 'réservé aux administrateurs' }) // Provisionner (create-staff) : à partir d'un user Authentik EXISTANT (ou nouveau) → groupe + System User + Employee + Tech + identité. if (resource === 'staff' && parts[1] === 'provision' && method === 'POST') { const b = await parseBody(req); const erp = require('./erp'); const idlib = require('./identity') const email = String(b.email || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' }) const label = String(b.label || email).trim(); const out = { email, steps: {} } // 1. Authentik : assigner les groupes OPS + garder actif. try { const lookup = await findUserByEmail(email) if (lookup.user && Array.isArray(b.groups) && b.groups.length) { const allG = await fetchGroups(); const pks = b.groups.map(n => { const g = allG.find(x => x.name === n); return g && g.pk }).filter(Boolean) await akFetch('/core/users/' + lookup.user.pk + '/', 'PATCH', { groups: pks, is_active: true }); invalidateCache() out.steps.groups = b.groups } } catch (e) { out.steps.groups_err = e.message } // 2. ERPNext System User (créer si absent, sinon promouvoir). try { const ex = await erp.list('User', { filters: [['name', '=', email]], fields: ['name', 'user_type'], limit: 1 }) if (!ex.length) { await erp.create('User', { email, first_name: label, send_welcome_email: 0, user_type: 'System User', roles: [{ role: 'Employee' }] }); out.steps.system_user = 'created' } else if (ex[0].user_type !== 'System User') { await erp.update('User', email, { user_type: 'System User' }); out.steps.system_user = 'promoted' } else out.steps.system_user = 'exists' } catch (e) { out.steps.system_user_err = e.message } // 3. Employee — LIER l'existant plutôt que dupliquer (évite le doublon craint : Aurélie a déjà HR-EMP-107 non lié). let empName = b.employee || '' if (b.create_employee !== false) { try { if (empName) { // l'admin a choisi un Employee précis → le lier const cur = await erp.list('Employee', { filters: [['name', '=', empName]], fields: ['name', 'user_id'], limit: 1 }) if (cur.length && !cur[0].user_id) { await erp.update('Employee', empName, { user_id: email, company_email: email }); out.steps.employee = 'linked' } else out.steps.employee = cur.length ? 'exists' : 'introuvable' } else { let ex = await erp.list('Employee', { filters: [['user_id', '=', email]], fields: ['name', 'user_id'], limit: 1 }) if (!ex.length) ex = await erp.list('Employee', { filters: [['company_email', '=', email]], fields: ['name', 'user_id'], limit: 1 }) if (!ex.length && label) ex = await erp.list('Employee', { filters: [['employee_name', '=', label]], fields: ['name', 'user_id'], limit: 2 }) // match par NOM (ex. Aurélie Bourdon = HR-EMP-107) if (ex.length === 1) { empName = ex[0].name; if (!ex[0].user_id) { await erp.update('Employee', empName, { user_id: email, company_email: email }); out.steps.employee = 'linked' } else out.steps.employee = 'exists' } else if (ex.length > 1) { out.steps.employee = 'ambigu(' + ex.length + ') — choisir/lier manuellement, non créé' } // ne PAS deviner else { const r = await erp.create('Employee', { employee_name: label, company_email: email, user_id: email, status: 'Active' }); empName = (r && (r.name || (r.data && r.data.name))) || ''; out.steps.employee = 'created' } } } catch (e) { out.steps.employee_err = e.message } } // 4. Dispatch Technician (optionnel). let techId = b.tech_id || '' if (b.create_tech) { try { const r = await erp.create('Dispatch Technician', { full_name: label, resource_type: 'human', status: 'Disponible', employee: empName || undefined }); techId = (r && ((r.data && r.data.name) || r.name)) || ''; out.steps.tech = techId || 'created' } catch (e) { out.steps.tech_err = e.message } } // 5. Identité unifiée. try { idlib.upsert({ label, primary_email: email, tech_id: techId, employee: empName, active: true, kind: b.kind || 'employee' }); out.steps.identity = 'ok' } catch (e) { out.steps.identity_err = e.message } return json(res, 200, { ok: true, ...out }) } // Désactiver / réactiver (non destructif) : Authentik is_active + identité départ. if (resource === 'staff' && parts[1] === 'active' && method === 'POST') { const b = await parseBody(req); const idlib = require('./identity') const email = String(b.email || '').toLowerCase(); const active = b.active !== false if (!email) return json(res, 400, { error: 'email requis' }) try { const lookup = await findUserByEmail(email); if (lookup.user) { await akFetch('/core/users/' + lookup.user.pk + '/', 'PATCH', { is_active: active }); invalidateCache() } } catch (e) { return json(res, 500, { error: e.message }) } try { idlib.setActive(email, active) } catch (e) {} return json(res, 200, { ok: true, email, is_active: active }) } // Dé-dupliquer les comptes Authentik d'un même courriel : garde l'utilisé (last_login) sinon le plus ancien, // supprime UNIQUEMENT les doublons JAMAIS connectés (sûr : aucune session/historique). Body: { email }. if (resource === 'staff' && parts[1] === 'dedupe' && method === 'POST') { const b = await parseBody(req); const email = String(b.email || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' }) const r = await akFetch('/core/users/?search=' + encodeURIComponent(email) + '&page_size=50') const recs = (r.data.results || []).filter(u => String(u.email || '').toLowerCase() === email) if (recs.length < 2) return json(res, 200, { ok: true, noop: true, count: recs.length }) // KEEPER = compte à GARDER : actif d'abord, puis dernier last_login, puis plus ancien (l'original). const keeper = recs.slice().sort((a, b) => (b.is_active ? 1 : 0) - (a.is_active ? 1 : 0) || (new Date(b.last_login || 0) - new Date(a.last_login || 0)) || (new Date(a.date_joined || 0) - new Date(b.date_joined || 0)))[0] const deleted = [], skipped = [] for (const u of recs) { if (u.pk === keeper.pk) continue // Supprimer un doublon SÛR : inactif OU jamais connecté (aucune session active). On NE supprime PAS // un 2e compte ENCORE actif et utilisé (rare) → à résoudre manuellement. if (u.is_active && u.last_login) { skipped.push(u.username + ' (2e compte actif utilisé — résoudre manuellement)'); continue } const d = await akFetch('/core/users/' + u.pk + '/', 'DELETE') if (d.status < 300) deleted.push(u.username); else skipped.push(u.username + ' (erreur ' + d.status + ')') } invalidateCache() return json(res, 200, { ok: true, kept: keeper.username, deleted, skipped }) } // Suppression GARDÉE : refuse si le retrait a un impact (tickets/jobs) → propose la désactivation. if (resource === 'staff' && !parts[1] && method === 'DELETE') { const erp = require('./erp'); const idlib = require('./identity') const email = String(url.searchParams.get('email') || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' }) const id = idlib.resolveIdentity(email) const emails = id ? [id.primary_email, ...(id.alias_emails || [])] : [email] let tickets = 0; for (const e of emails) { try { const r = await erp.list('Issue', { filters: [['_assign', 'like', '%' + e + '%']], fields: ['name'], limit: 200 }); tickets += (r || []).length } catch (x) {} } let jobs = 0; if (id && id.tech_id) { try { const r = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', id.tech_id]], fields: ['name'], limit: 500 }); jobs = (r || []).length } catch (x) {} } if (tickets > 0 || jobs > 0) return json(res, 409, { ok: false, refused: true, reason: 'références existantes', tickets, jobs, hint: 'Désactiver plutôt que supprimer (garde l\'historique).' }) const done = {} try { const lookup = await findUserByEmail(email); if (lookup.user) { await akFetch('/core/users/' + lookup.user.pk + '/', 'DELETE'); invalidateCache(); done.authentik = 'deleted' } } catch (e) { done.authentik_err = e.message } try { await erp.remove('User', email); done.system_user = 'deleted' } catch (e) { done.system_user_err = e.message } try { if (id) idlib.setActive(email, false) } catch (e) {} // l'identité reste (départ) même après suppression du compte return json(res, 200, { ok: true, email, ...done }) } 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 }) } // PUT /auth/users/{email}/name — modifier le nom complet Authentik. // Source unique du nom affiché partout (Users & Permissions, From sortant // personnel). L'adresse et les groupes ne changent pas. if (resource === 'users' && parts[2] === 'name' && 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 body = await parseBody(req) const name = String(body.name || '').trim().replace(/[\r\n]/g, '').slice(0, 120) if (!name) return json(res, 400, { error: 'name required' }) const r = await akFetch('/core/users/' + user.pk + '/', 'PATCH', { name }) if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status }) nameCache.delete(email.toLowerCase()) log(`Auth: updated name for ${email}: ${name}`) return json(res, 200, { ok: true, email, name }) } 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. Set a temporary password + email it via the hub's own SMTP. // We tried Authentik's recovery_email/ first but the brand has // no flow_recovery configured ("No recovery flow set"), and // Authentik's global SMTP is also unset. Rather than build out // a full recovery flow via API right now, we generate a // readable temp password on our side, set it via /set_password/ // and mail it to the user via Mailjet (already wired into the // hub for ERPNext invoice emails). The user can change it on // first login. Password is also returned to the admin so they // can hand it over via Slack/SMS if the email gets stuck. const tempPassword = generateInvitePassword() let passwordSet = false try { const sp = await akFetch('/core/users/' + akUser.pk + '/set_password/', 'POST', { password: tempPassword }) passwordSet = sp.status >= 200 && sp.status < 300 if (!passwordSet) log('set_password failed:', sp.status, JSON.stringify(sp.data).slice(0, 200)) } catch (e) { log('set_password threw:', e.message) } let emailSent = false if (passwordSet) { try { await sendEmail({ to: email, subject: 'Bienvenue chez Gigafibre — votre accès', html: `
Bonjour ${fullName},
Un compte vient d'être créé pour vous. Pour vous connecter, utilisez :
${tempPassword}À votre première connexion, changez votre mot de passe via votre profil.
Vous pouvez aussi passer par auth.targo.ca pour le SSO.
Cet email a été envoyé automatiquement par l'application interne Gigafibre.
`, }) emailSent = true } catch (e) { log('invite email failed:', e.message) } } // 2. Create the matching ERPNext User. Frappe wants `roles` as a child // table of {role: