fix(ops): staff recon — alias-aware employee, dedupe Authentik accounts, link-not-duplicate
- resolveEmployeeForEmail + /auth/staff now resolve the Employee through the unified identity's aliases: louis@targo.ca finds HR-EMP-4 (under louispaul@targointernet.com). Fixes "no employee linked" for Louis-Paul. - /auth/staff collapses multiple Authentik accounts with the same email into one row (username `joseph` + `joseph@...` → one), flags dup_accounts for cleanup. - provision LINKS an existing Employee (match user_id → company_email → name) instead of creating a duplicate — fixes the Aurélie HR-EMP-107 doublon risk. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b43f092da3
commit
148cb9a320
|
|
@ -88,6 +88,7 @@ async function tryDelete (r) {
|
|||
<q-item-label class="text-weight-medium">{{ r.name }}
|
||||
<q-chip v-if="!r.is_active" dense size="sm" color="grey-4" text-color="grey-8" icon="logout">inactif</q-chip>
|
||||
<q-chip v-if="r.needs_group" dense size="sm" color="amber-2" text-color="amber-9" icon="group_off">sans groupe</q-chip>
|
||||
<q-chip v-if="r.dup_accounts" dense size="sm" color="red-2" text-color="red-9" icon="content_copy">{{ r.dup_accounts }} comptes Authentik<q-tooltip>Comptes en double pour ce courriel : {{ (r.usernames || []).join(' · ') }} — à nettoyer (garder un seul)</q-tooltip></q-chip>
|
||||
</q-item-label>
|
||||
<q-item-label caption>
|
||||
{{ r.email }}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user