@@ -809,6 +815,7 @@ import { useUserGroups } from 'src/composables/useUserGroups'
import { useAvatar } from 'src/composables/useAvatar'
import UserAvatar from 'src/components/shared/UserAvatar.vue'
import EmployeeEditDialog from 'src/components/shared/EmployeeEditDialog.vue'
+import IdentityManager from 'src/components/shared/IdentityManager.vue' // gestion des identités unifiées (label + alias courriel + lien tech + fusion)
import { listDocs } from 'src/api/erp'
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'
diff --git a/services/targo-hub/lib/auth.js b/services/targo-hub/lib/auth.js
index aa2343c..fe287ca 100644
--- a/services/targo-hub/lib/auth.js
+++ b/services/targo-hub/lib/auth.js
@@ -66,6 +66,8 @@ 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 = ''
diff --git a/services/targo-hub/lib/identity.js b/services/targo-hub/lib/identity.js
new file mode 100644
index 0000000..8a04a51
--- /dev/null
+++ b/services/targo-hub/lib/identity.js
@@ -0,0 +1,126 @@
+'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 }
+
+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 }
diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js
index 76db21d..cd763e7 100644
--- a/services/targo-hub/server.js
+++ b/services/targo-hub/server.js
@@ -226,6 +226,7 @@ const server = http.createServer(async (req, res) => {
if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
if (path === '/prefs') return require('./lib/user-prefs').handle(req, res, method, path) // préférences d'affichage par utilisateur (filtres/tri/densité)
if (path === '/avatars' || path.startsWith('/avatars/')) return require('./lib/avatars').handle(req, res, method, path) // photos de profil par utilisateur
+ if (path.startsWith('/identity')) return require('./lib/identity').handle(req, res, method, path, url) // identité unifiée (courriels alias + label + lien tech) ; écritures admin
if (path.startsWith('/sla/')) return require('./lib/sla').handle(req, res, method, path) // politiques SLA (réponse/résolution par file+priorité), éditables dans Settings
if (path.startsWith('/chatwoot/')) return require('./lib/chatwoot').handle(req, res, method, path, url) // Dashboard App Chatwoot : fiche client ERP 360 (panel iframe + lookup protégé par clé)
if (path.startsWith('/billing/')) return require('./lib/proration').handle(req, res, method, path) // moteur de prorata (aperçu lignes proratées par type) — gaté staff