feat(ops): one-click identity linking (Authentik ↔ Employee ↔ Tech), merge-safe

POST /auth/staff/sync-identities upserts a unified identity per internal
Authentik user, resolving Employee + Dispatch Technician and deriving email
aliases from the company email. Resolve-first: merges into an existing identity's
key and never overwrites a manual label/tech/alias (fixes Louis-Paul, whose
login louis@targo.ca ≠ employee email louispaul@); employee lookup is
alias-aware so louispaul@ finds HR-EMP-4. "Lier les identités" button in the
Staff console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-10 14:27:30 -04:00
parent 6826f11587
commit e8dc86e058
3 changed files with 52 additions and 1 deletions

View File

@ -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' }) 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. // 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 }) 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', {})

View File

@ -13,7 +13,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Notify, Dialog } from 'quasar' 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 OPS_GROUPS = ['admin', 'sysadmin', 'tech', 'support', 'comptabilite', 'facturation', 'dev']
const rows = ref([]) const rows = ref([])
@ -21,6 +21,12 @@ const loading = ref(false)
const filter = ref('orphan') // orphan | departed | ok | all const filter = ref('orphan') // orphan | departed | ok | all
const search = ref('') const search = ref('')
const busy = ref('') // email en cours d'action 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 prov = ref(null) // formulaire de provisioning
const router = useRouter() const router = useRouter()
// Horaire d'un membre = MÊME module que Planification (TechScheduleDialog : récurrent + congés + pause). // 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'}]" /> :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'}]" />
<q-space /> <q-space />
<q-input dense outlined v-model="search" placeholder="Chercher…" clearable style="min-width:180px"><template #prepend><q-icon name="search" /></template></q-input> <q-input dense outlined v-model="search" placeholder="Chercher…" clearable style="min-width:180px"><template #prepend><q-icon name="search" /></template></q-input>
<q-btn dense flat no-caps size="sm" color="indigo-7" icon="link" label="Lier les identités" :loading="syncing" @click="doSync"><q-tooltip>Lie automatiquement Authentik Employé Technicien dans l'identité unifiée (alias depuis le courriel entreprise)</q-tooltip></q-btn>
<q-btn flat dense round icon="refresh" :loading="loading" @click="load" /> <q-btn flat dense round icon="refresh" :loading="loading" @click="load" />
</div> </div>

View File

@ -382,6 +382,48 @@ async function handle (req, res, method, path, url) {
return json(res, 200, { ok: true, kept: keeper.username, deleted, skipped }) 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. // Suppression GARDÉE : refuse si le retrait a un impact (tickets/jobs) → propose la désactivation.
if (resource === 'staff' && !parts[1] && method === 'DELETE') { if (resource === 'staff' && !parts[1] && method === 'DELETE') {
const erp = require('./erp'); const idlib = require('./identity') const erp = require('./erp'); const idlib = require('./identity')