diff --git a/apps/ops/src/components/shared/StaffConsole.vue b/apps/ops/src/components/shared/StaffConsole.vue index 1b982e1..d429dda 100644 --- a/apps/ops/src/components/shared/StaffConsole.vue +++ b/apps/ops/src/components/shared/StaffConsole.vue @@ -88,6 +88,7 @@ async function tryDelete (r) { {{ r.name }} inactif sans groupe + {{ r.dup_accounts }} comptes AuthentikComptes en double pour ce courriel : {{ (r.usernames || []).join(' · ') }} — à nettoyer (garder un seul) {{ r.email }} diff --git a/services/targo-hub/lib/auth.js b/services/targo-hub/lib/auth.js index 7b1acbb..6f58eaa 100644 --- a/services/targo-hub/lib/auth.js +++ b/services/targo-hub/lib/auth.js @@ -96,8 +96,12 @@ async function resolveEmployeeForEmail (email) { 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 }) + // ALIAS-AWARE : le compte de session (louis@targo.ca) et l'Employee (sous louispaul@targointernet.com) + // sont la même personne → on cherche l'Employee sous N'IMPORTE QUEL courriel de l'identité unifiée. + let emails = [e] + try { const id = require('./identity').resolveIdentity(e); if (id) emails = [...new Set([id.primary_email, ...(id.alias_emails || []), e].filter(Boolean))] } catch (x) {} + let rows = await erp.list('Employee', { filters: [['user_id', 'in', emails]], fields: EMPLOYEE_FIELDS, limit: 1 }) + if (!rows.length) rows = await erp.list('Employee', { filters: [['company_email', 'in', emails]], fields: EMPLOYEE_FIELDS, limit: 1 }) return rows[0] || null } catch (err) { log('resolveEmployeeForEmail ' + e + ': ' + err.message); return null } } @@ -248,18 +252,33 @@ async function handle (req, res, method, path, url) { 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 + // DÉ-DUP par courriel : plusieurs comptes Authentik pour la même personne (username `joseph` + `joseph@…`) + // → UNE ligne, en listant les usernames en double à nettoyer. Actif si au MOINS un compte actif. + const byEmail = new Map() + for (const u of akUsers) { + const email = String(u.email || '').toLowerCase(); if (!email) continue + const g = byEmail.get(email) || { email, usernames: [], is_active: false, name: '', groupsSet: new Set() } + g.usernames.push(u.username) + g.is_active = g.is_active || !!u.is_active + if (!g.name && u.name) g.name = u.name + for (const pk of (u.groups || [])) { const n = gmap.get(pk); if (OPS_GROUPS.includes(n)) g.groupsSet.add(n) } + byEmail.set(email, g) + } + const rows = [...byEmail.values()].map(g => { + const email = g.email const id = idlib.resolveIdentity(email) - const hasSys = sysSet.has(email) + // Employee résolu via l'identité (alias) : louis@targo.ca → Employee sous louispaul@targointernet.com. + const idEmails = id ? [id.primary_email, ...(id.alias_emails || []), email] : [email] + let emp = null; for (const e of idEmails) { if (empByEmail[String(e).toLowerCase()]) { emp = empByEmail[String(e).toLowerCase()]; break } } + const tech = (emp && techByEmp[emp.name]) || (id && id.tech_id ? { technician_id: id.tech_id } : null) + const hasSys = idEmails.some(e => sysSet.has(String(e).toLowerCase())) + const groups = [...g.groupsSet] 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 } + if (!g.is_active || (id && id.active === false)) status = 'departed' + else if (!hasSys) status = 'orphan' + const needs_group = g.is_active && hasSys && !groups.length + const dup_accounts = g.usernames.length > 1 ? g.usernames.length : 0 + return { email, username: g.usernames[0], usernames: g.usernames, dup_accounts, name: g.name || (id && id.label) || email, is_active: g.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 }) } @@ -301,13 +320,21 @@ async function handle (req, res, method, path, url) { 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). + // 3. Employee — LIER l'existant plutôt que dupliquer (évite le doublon craint : Aurélie a déjà HR-EMP-107 non lié). 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' } + if (empName) { // l'admin a choisi un Employee précis → le lier + const cur = await erp.list('Employee', { filters: [['name', '=', empName]], fields: ['name', 'user_id'], limit: 1 }) + if (cur.length && !cur[0].user_id) { await erp.update('Employee', empName, { user_id: email, company_email: email }); out.steps.employee = 'linked' } else out.steps.employee = cur.length ? 'exists' : 'introuvable' + } else { + let ex = await erp.list('Employee', { filters: [['user_id', '=', email]], fields: ['name', 'user_id'], limit: 1 }) + if (!ex.length) ex = await erp.list('Employee', { filters: [['company_email', '=', email]], fields: ['name', 'user_id'], limit: 1 }) + if (!ex.length && label) ex = await erp.list('Employee', { filters: [['employee_name', '=', label]], fields: ['name', 'user_id'], limit: 2 }) // match par NOM (ex. Aurélie Bourdon = HR-EMP-107) + if (ex.length === 1) { empName = ex[0].name; if (!ex[0].user_id) { await erp.update('Employee', empName, { user_id: email, company_email: email }); out.steps.employee = 'linked' } else out.steps.employee = 'exists' } + else if (ex.length > 1) { out.steps.employee = 'ambigu(' + ex.length + ') — choisir/lier manuellement, non créé' } // ne PAS deviner + 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).