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>
696 lines
38 KiB
JavaScript
696 lines
38 KiB
JavaScript
'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 }
|