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>
146 lines
8.8 KiB
JavaScript
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 }
|