gigafibre-fsm/services/targo-hub/lib/auth.js
louispaulb b43f092da3 feat(ops): staff reconciliation console (Authentik ↔ System User/Employee/Tech)
Settings → Staff tab: one row per internal Authentik user with provisioning
state (active, groups, System User / Employee / Dispatch Technician / identity),
flagging orphan (active but no System User → not provisioned, e.g. Karim
Takougang), departed (inactive/identity-departed), ok.

Hub /auth/staff (GET reconciliation) + /auth/staff/provision (groups + System
User + Employee + Tech + identity in one click) + /auth/staff/active
(deactivate/reactivate, non-destructive) + /auth/staff/impact + guarded DELETE
(refuses with 409 if tickets/jobs reference the person → offers deactivate).
Writes admin-gated. identity.js gains programmatic upsert/setActive.

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

696 lines
38 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')
let rows = await erp.list('Employee', { filters: [['user_id', '=', e]], fields: EMPLOYEE_FIELDS, limit: 1 })
if (!rows.length) rows = await erp.list('Employee', { filters: [['company_email', '=', e]], 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 }
const rows = akUsers.map(u => {
const email = String(u.email || '').toLowerCase()
const groups = (u.groups || []).map(pk => gmap.get(pk)).filter(n => OPS_GROUPS.includes(n))
const emp = empByEmail[email] || null
const tech = emp ? techByEmp[emp.name] : null
const id = idlib.resolveIdentity(email)
const hasSys = sysSet.has(email)
let status = 'ok'
if (!u.is_active || (id && id.active === false)) status = 'departed' // inactif Authentik ou identité en départ
else if (!hasSys) status = 'orphan' // actif Authentik mais SANS System User = non provisionné (ex. Karim Takougang)
const needs_group = u.is_active && hasSys && !groups.length // provisionné mais sans groupe OPS (info, pas orphelin)
return { email, username: u.username, name: u.name || (id && id.label) || email, is_active: u.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 (lien user_id).
let empName = b.employee || ''
if (b.create_employee !== false) {
try {
const ex = await erp.list('Employee', { filters: [['user_id', '=', email]], fields: ['name'], limit: 1 })
if (ex.length) { empName = ex[0].name; out.steps.employee = 'exists' }
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 })
}
// 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 }