gigafibre-fsm/services/targo-hub/lib/identity.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

146 lines
8.8 KiB
JavaScript

'use strict'
// ── Identité unifiée du personnel — SOURCE UNIQUE ─────────────────────────────
// Problème résolu : une même personne a plusieurs COURRIELS (louis@targo.ca +
// louispaul@targointernet.com) et un enregistrement Dispatch Technician séparé,
// SANS identifiant unifié → doublons d'assignation, nom système (« louis ») au
// lieu du nom complet, user SSO ≠ tech. Ici : 1 personne = 1 identité canonique
// { key, label, primary_email, alias_emails[], tech_id, employee }
// Tous les courriels (primaire + alias) et le tech_id résolvent à CETTE identité.
//
// SSO partout → la clé = le courriel de login (primaire). Éditable depuis OPS,
// écritures réservées aux admins (mêmes groupes que avatars/user-prefs). Store
// JSON hub (data/identities.json) — pas de custom-field ERPNext (API instable).
const fs = require('fs')
const path = require('path')
const { json, parseBody, log } = require('./helpers')
const FILE = path.join(__dirname, '..', 'data', 'identities.json')
const norm = (e) => String(e || '').trim().toLowerCase()
const meOf = (req) => norm(req.headers['x-authentik-email'])
// Admin = mêmes groupes Authentik que les autres écritures self/admin (avatars.js, user-prefs.js).
function isAdmin (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 load () { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) || {} } catch { return {} } }
function save (m) { try { fs.mkdirSync(path.dirname(FILE), { recursive: true }) } catch (e) {} fs.writeFileSync(FILE, JSON.stringify(m, null, 1)); _index = null; return m }
// Index inverse (courriel→clé, tech→clé, nom→clé), reconstruit paresseusement, invalidé à chaque save().
let _index = null
function index () {
if (_index) return _index
const m = load(); const byEmail = {}, byTech = {}, byName = {}
for (const [key, id] of Object.entries(m)) {
for (const e of [id.primary_email, ...(id.alias_emails || [])].map(norm).filter(Boolean)) byEmail[e] = key
if (id.tech_id) byTech[String(id.tech_id)] = key
if (id.label) byName[norm(id.label)] = key
}
_index = { m, byEmail, byTech, byName }; return _index
}
// resolveIdentity(input) : input = courriel | tech_id | nom complet → { key, ...identité } | null
function resolveIdentity (input) {
if (!input) return null
const { m, byEmail, byTech, byName } = index()
const s = String(input).trim()
const key = byEmail[norm(s)] || byTech[s] || byName[norm(s)]
return key ? { key, ...m[key] } : null
}
// Libellé alias-aware (pour getDisplayNameByEmail) : '' si inconnu → l'appelant retombe sur Authentik.
function labelForEmail (email) { const id = resolveIdentity(email); return id ? id.label : '' }
// Courriel canonique (primaire) pour un alias — sert à DÉ-DUPLIQUER les assignations. Repli = le courriel donné.
function canonicalEmail (email) { const id = resolveIdentity(email); return id ? norm(id.primary_email) : norm(email) }
// Clé d'identité pour dé-dupliquer (courriel OU tech_id) — repli = le courriel normalisé (identité « inconnue » stable).
function identityKey (input) { const id = resolveIdentity(input); return id ? id.key : norm(input) }
// Deux entrées (courriels/tech) = la même personne ?
function sameIdentity (a, b) { return identityKey(a) === identityKey(b) }
// Actif ? (départ = active:false → masqué des sélecteurs/roster mais l'identité RESTE pour l'historique).
// Inconnu de la carte → true (on ne bloque pas quelqu'un qu'on ne connaît pas).
function isActive (input) { const id = resolveIdentity(input); return id ? id.active !== false : true }
// Écriture programmatique (utilisée par le provisioning staff) — upsert par clé (= primary_email par défaut).
function upsert ({ key, label, primary_email, alias_emails, tech_id, employee, active, kind } = {}) {
const primary = norm(primary_email); if (!primary || !label) return null
const m = load(); const k = key || primary; const cur = m[k] || {}
m[k] = {
label: String(label).slice(0, 120), primary_email: primary,
alias_emails: [...new Set([...(cur.alias_emails || []), ...(alias_emails || [])].map(norm).filter(e => e && e !== primary))],
tech_id: tech_id || cur.tech_id || '', employee: employee || cur.employee || '',
active: active === undefined ? (cur.active !== false) : (active !== false),
kind: kind || cur.kind || 'employee',
}
save(m); return m[k]
}
function setActive (keyOrEmail, active) {
const id = resolveIdentity(keyOrEmail); if (!id) return null
const m = load(); if (!m[id.key]) return null
m[id.key].active = active !== false; save(m); return m[id.key]
}
async function handle (req, res, method, path, url) {
// ── Lecture (tout membre du personnel authentifié — annuaire interne) ──
// Carte compacte pour le SPA : email→{key,label}, tech→{key,label}, + liste éditable.
if (path === '/identity/map' && method === 'GET') {
const { m } = index(); const emails = {}, techs = {}, list = []
for (const [key, id] of Object.entries(m)) {
const label = id.label || ''; const active = id.active !== false
for (const e of [id.primary_email, ...(id.alias_emails || [])].map(norm).filter(Boolean)) emails[e] = { key, label, active }
if (id.tech_id) techs[String(id.tech_id)] = { key, label, active }
list.push({ key, label, primary_email: norm(id.primary_email), alias_emails: (id.alias_emails || []).map(norm), tech_id: id.tech_id || '', employee: id.employee || '', active, kind: id.kind || 'employee' })
}
return json(res, 200, { emails, techs, list })
}
if (path === '/identity/resolve' && method === 'GET') {
const q = url.searchParams
return json(res, 200, { identity: resolveIdentity(q.get('email') || q.get('tech') || q.get('name') || '') })
}
// ── Écriture : admins seulement ──
if (method !== 'GET' && !isAdmin(req)) return json(res, 403, { error: 'réservé aux administrateurs' })
// Créer / mettre à jour une identité. Body: { key?, label, primary_email, alias_emails?, tech_id?, employee? }
if (path === '/identity' && method === 'POST') {
const b = await parseBody(req); const primary = norm(b.primary_email)
if (!primary || !b.label) return json(res, 400, { error: 'label + primary_email requis' })
const m = load(); const key = b.key || primary
m[key] = {
label: String(b.label).slice(0, 120), primary_email: primary,
alias_emails: [...new Set((b.alias_emails || []).map(norm).filter(e => e && e !== primary))],
tech_id: b.tech_id || '', employee: b.employee || '',
active: b.active !== false, // départ = false → masqué des sélecteurs, identité gardée pour l'historique
kind: b.kind || 'employee', // employee | subcontractor
}
save(m); log('[identity] upsert ' + key + ' by ' + meOf(req))
return json(res, 200, { ok: true, key, identity: m[key] })
}
// Ajouter / retirer un alias courriel. Body: { email, remove? }
const mAlias = path.match(/^\/identity\/([^/]+)\/alias$/)
if (mAlias && method === 'POST') {
const key = decodeURIComponent(mAlias[1]); const b = await parseBody(req); const e = norm(b.email); const m = load()
if (!m[key]) return json(res, 404, { error: 'identité introuvable' })
const set = new Set(m[key].alias_emails || [])
if (b.remove) set.delete(e); else if (e && e !== norm(m[key].primary_email)) set.add(e)
m[key].alias_emails = [...set]; save(m)
return json(res, 200, { ok: true, identity: m[key] })
}
// Fusionner deux identités (réconcilier user système ↔ tech en double). Body: { into, from }
if (path === '/identity/merge' && method === 'POST') {
const b = await parseBody(req); const m = load()
if (!m[b.into] || !m[b.from]) return json(res, 404, { error: 'clé(s) introuvable(s)' })
const set = new Set([...(m[b.into].alias_emails || []), norm(m[b.from].primary_email), ...(m[b.from].alias_emails || [])]
.filter(e => e && e !== norm(m[b.into].primary_email)))
m[b.into].alias_emails = [...set]
if (!m[b.into].tech_id && m[b.from].tech_id) m[b.into].tech_id = m[b.from].tech_id
if (!m[b.into].employee && m[b.from].employee) m[b.into].employee = m[b.from].employee
delete m[b.from]; save(m); log('[identity] merge ' + b.from + ' → ' + b.into + ' by ' + meOf(req))
return json(res, 200, { ok: true, identity: m[b.into] })
}
const mDel = path.match(/^\/identity\/([^/]+)$/)
if (mDel && method === 'DELETE') { const key = decodeURIComponent(mDel[1]); const m = load(); delete m[key]; save(m); return json(res, 200, { ok: true }) }
return json(res, 404, { error: 'identity endpoint not found' })
}
module.exports = { handle, resolveIdentity, labelForEmail, canonicalEmail, identityKey, sameIdentity, isActive, upsert, setActive }