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:
parent
6826f11587
commit
e8dc86e058
|
|
@ -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', {})
|
||||
|
|
|
|||
|
|
@ -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'}]" />
|
||||
<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-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" />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user