diff --git a/apps/ops/src/api/staff.js b/apps/ops/src/api/staff.js index 3c92d98..628b153 100644 --- a/apps/ops/src/api/staff.js +++ b/apps/ops/src/api/staff.js @@ -17,3 +17,5 @@ export const staffImpact = (email) => j('/auth/staff/impact?email=' + encodeURIC export const deleteStaff = (email) => j('/auth/staff?email=' + encodeURIComponent(email), { method: 'DELETE' }) // Dé-dup : garde le compte Authentik utilisé, supprime le(s) doublon(s) jamais connecté(s) pour ce courriel. export const dedupeAccounts = (email) => post('/auth/staff/dedupe', { email }) +// Backfill : lie automatiquement Authentik ↔ Employee ↔ Tech dans l'identité unifiée (alias depuis le courriel entreprise). +export const syncIdentities = () => post('/auth/staff/sync-identities', {}) diff --git a/apps/ops/src/components/shared/StaffConsole.vue b/apps/ops/src/components/shared/StaffConsole.vue index 66803ea..7433bf6 100644 --- a/apps/ops/src/components/shared/StaffConsole.vue +++ b/apps/ops/src/components/shared/StaffConsole.vue @@ -13,7 +13,7 @@ import { ref, computed, onMounted } from 'vue' import { useRouter } from 'vue-router' import { Notify, Dialog } from 'quasar' -import { listStaff, provisionStaff, setStaffActive, staffImpact, deleteStaff, dedupeAccounts } from 'src/api/staff' +import { listStaff, provisionStaff, setStaffActive, staffImpact, deleteStaff, dedupeAccounts, syncIdentities } from 'src/api/staff' const OPS_GROUPS = ['admin', 'sysadmin', 'tech', 'support', 'comptabilite', 'facturation', 'dev'] const rows = ref([]) @@ -21,6 +21,12 @@ const loading = ref(false) const filter = ref('orphan') // orphan | departed | ok | all const search = ref('') const busy = ref('') // email en cours d'action +const syncing = ref(false) +async function doSync () { + syncing.value = true + try { const r = await syncIdentities(); Notify.create({ type: 'positive', message: r.linked + ' identités liées (Authentik ↔ employé ↔ tech)' }); await load() } + catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { syncing.value = false } +} const prov = ref(null) // formulaire de provisioning const router = useRouter() // Horaire d'un membre = MÊME module que Planification (TechScheduleDialog : récurrent + congés + pause). @@ -90,6 +96,7 @@ async function tryDelete (r) { :options="[{label:'À provisionner ('+(counts.orphan||0)+')',value:'orphan'},...(counts.dup?[{label:'⚠ Doublons ('+counts.dup+')',value:'dup'}]:[]),{label:'Partis / inactifs ('+(counts.departed||0)+')',value:'departed'},{label:'OK ('+(counts.ok||0)+')',value:'ok'},{label:'Tous',value:'all'}]" /> + Lie automatiquement Authentik ↔ Employé ↔ Technicien dans l'identité unifiée (alias depuis le courriel entreprise) diff --git a/services/targo-hub/lib/auth.js b/services/targo-hub/lib/auth.js index 8a61deb..0d1853d 100644 --- a/services/targo-hub/lib/auth.js +++ b/services/targo-hub/lib/auth.js @@ -382,6 +382,48 @@ async function handle (req, res, method, path, url) { return json(res, 200, { ok: true, kept: keeper.username, deleted, skipped }) } + // Backfill : LIER automatiquement dans l'identité unifiée les comptes Authentik ↔ Employee ↔ Dispatch Technician. + // Pour chaque user Authentik interne : identité { label, primary_email=login, alias=company_email/user_id de l'Employee + // si différents, tech_id, employee, active }. Upsert (jamais de suppression) → alimente l'annuaire unifié d'un coup. + if (resource === 'staff' && parts[1] === 'sync-identities' && method === 'POST') { + const erp = require('./erp'); const idlib = require('./identity') + const akUsers = [] + for (let p = 1; p <= 12; p++) { const r = await akFetch(`/core/users/?page=${p}&page_size=100&ordering=username`); if (r.status !== 200) break; akUsers.push(...(r.data.results || []).filter(u => u.email && !u.username.startsWith('ak-outpost') && u.type !== 'internal_service_account')); if (!r.data.pagination || !r.data.pagination.next) break } + const [emps, techs] = await Promise.all([ + erp.list('Employee', { fields: ['name', 'employee_name', 'company_email', 'user_id'], limit: 2000 }).catch(() => []), + erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'employee'], limit: 1000 }).catch(() => []), + ]) + 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 } + // dédup par courriel (un seul upsert par personne) + const seen = new Set(); let count = 0 + for (const u of akUsers) { + const email = String(u.email || '').toLowerCase(); if (!email || seen.has(email)) continue; seen.add(email) + // RÉSOUT D'ABORD l'identité existante (seed OU édition manuelle) → on FUSIONNE dans SA clé, sans jamais + // écraser un label/tech/alias déjà posé (ex. Louis-Paul : login louis@targo.ca ≠ courriel employé louispaul@). + const existing = idlib.resolveIdentity(email) + const key = existing ? existing.key : email + const primary = existing ? existing.primary_email : email + // Employee résolu sur TOUS les courriels de l'identité (alias) : louispaul@ trouve HR-EMP-4. + const candidateEmails = [email, primary, ...(existing ? existing.alias_emails || [] : [])].map(x => String(x || '').toLowerCase()) + let emp = null; for (const e of candidateEmails) { if (empByEmail[e]) { emp = empByEmail[e]; break } } + const tech = emp ? techByEmp[emp.name] : null + const aliases = [...(existing ? existing.alias_emails || [] : []), email, ...(emp ? [emp.company_email, emp.user_id] : [])] + .map(x => String(x || '').toLowerCase()).filter(x => x && x !== primary) + const payload = { + key, primary_email: primary, + label: (existing && existing.label) || u.name || email, // garde le label manuel + alias_emails: [...new Set(aliases)], + tech_id: (existing && existing.tech_id) || (tech ? (tech.technician_id || tech.name) : ''), + employee: (existing && existing.employee) || (emp ? emp.name : ''), + } + if (!existing) payload.active = u.is_active // ne pas ré-activer un départ manuel ; les nouveaux suivent Authentik + idlib.upsert(payload) + count++ + } + return json(res, 200, { ok: true, linked: count }) + } + // Suppression GARDÉE : refuse si le retrait a un impact (tickets/jobs) → propose la désactivation. if (resource === 'staff' && !parts[1] && method === 'DELETE') { const erp = require('./erp'); const idlib = require('./identity')