gigafibre-fsm/services/targo-hub/lib/auth.js
louispaulb 11c53c956a feat(ops): staff console — one-click dedupe of duplicate Authentik accounts
POST /auth/staff/dedupe {email}: keeps the used account (has last_login, else
oldest) and deletes ONLY never-logged-in duplicate Authentik records for that
email — safe (no sessions/history, ERPNext untouched since it's one User per
email). Console shows "Nettoyer le doublon" on flagged rows with a confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:33:22 -04:00

743 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 })
const used = recs.filter(u => u.last_login).sort((a, b) => new Date(b.last_login) - new Date(a.last_login))
const keeper = used[0] || recs.slice().sort((a, b) => new Date(a.date_joined) - new Date(b.date_joined))[0]
const deleted = [], skipped = []
for (const u of recs) {
if (u.pk === keeper.pk) continue
if (u.last_login) { skipped.push(u.username + ' (déjà utilisé — résoudre manuellement)'); continue } // ne jamais supprimer un compte utilisé
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: `<p>Bonjour ${fullName},</p>
<p>Un compte vient d'être créé pour vous. Pour vous connecter, utilisez :</p>
<ul>
<li><b>URL ERPNext</b> : <a href="https://erp.gigafibre.ca/">https://erp.gigafibre.ca/</a></li>
<li><b>Identifiant</b> : ${email}</li>
<li><b>Mot de passe temporaire</b> : <code style="background:#f3f4f6;padding:4px 8px;border-radius:4px;font-size:1.05em">${tempPassword}</code></li>
</ul>
<p>À votre première connexion, changez votre mot de passe via votre profil.</p>
<p>Vous pouvez aussi passer par <a href="https://auth.targo.ca/">auth.targo.ca</a> pour le SSO.</p>
<hr/>
<p style="font-size:0.85em;color:#6b7280">Cet email a été envoyé automatiquement par l'application interne Gigafibre.</p>`,
})
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: <Role Name>} 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'}, email=${emailSent ? 'sent' : '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,
// The temp password is the canonical credential for this invite.
// Returned even when emailSent=true so the admin can verify or
// hand it over manually if the user can't find the email.
temp_password: passwordSet ? tempPassword : null,
password_set: passwordSet,
email_sent: emailSent,
})
}
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 })
}
}
// Capacités EFFECTIVES d'un utilisateur (ops_permissions des groupes Authentik overrides perso).
// RÉUTILISE les mêmes primitives que GET /auth/permissions (findUserBy*, fetchGroups, CAPABILITIES,
// OPS_GROUPS) sans passer par HTTP. Sert au copilote STAFF (lib/staff-agent.js) pour gater les outils
// d'ÉCRITURE par capacité (assign_jobs, create_jobs, …) — parité avec le usePermissions du front.
// → { configured, found, is_superuser, capabilities:{cap:bool}, groups:[...] }
// configured=false quand Authentik n'est pas branché (dev/aperçu) → l'appelant décide (ne pas bloquer en dev).
async function effectiveCapabilities (email, username) {
if (!cfg.AUTHENTIK_TOKEN) return { configured: false, found: false, is_superuser: false, capabilities: {}, groups: [] }
let lookup = email ? await findUserByEmail(email) : { error: 'no email', status: 404 }
if (lookup.error && username) lookup = await findUserByUsername(username)
if (lookup.error) return { configured: true, found: false, is_superuser: false, capabilities: {}, groups: [] }
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 [k, v] of Object.entries(perms)) if (v === true) merged[k] = true
}
const overrides = user.attributes?.ops_permissions_override || {}
for (const [k, v] of Object.entries(overrides)) if (typeof v === 'boolean') merged[k] = v
return { configured: true, found: true, is_superuser: user.is_superuser || false, capabilities: merged, groups: userGroups.map(g => g.name) }
}
module.exports = { handle, CAPABILITIES, OPS_GROUPS, getDisplayNameByEmail, effectiveCapabilities }