diff --git a/apps/ops/src/api/staff.js b/apps/ops/src/api/staff.js
new file mode 100644
index 0000000..acf2c32
--- /dev/null
+++ b/apps/ops/src/api/staff.js
@@ -0,0 +1,17 @@
+// API console STAFF — réconciliation Authentik ↔ System User/Employee/Tech/identité (hub /auth/staff/*).
+// Écritures réservées admin (gaté côté hub) ; suppression GARDÉE (409 si références → proposer désactivation).
+import { HUB_URL as HUB } from 'src/config/hub'
+
+async function j (path, init) {
+ const r = await fetch(HUB + path, init)
+ const d = await r.json().catch(() => ({}))
+ if (!r.ok && r.status !== 409) throw new Error(d.error || ('Staff API ' + r.status))
+ return { status: r.status, ...d }
+}
+const post = (path, body) => j(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) })
+
+export const listStaff = () => j('/auth/staff')
+export const provisionStaff = (body) => post('/auth/staff/provision', body)
+export const setStaffActive = (email, active) => post('/auth/staff/active', { email, active })
+export const staffImpact = (email) => j('/auth/staff/impact?email=' + encodeURIComponent(email))
+export const deleteStaff = (email) => j('/auth/staff?email=' + encodeURIComponent(email), { method: 'DELETE' })
diff --git a/apps/ops/src/components/shared/StaffConsole.vue b/apps/ops/src/components/shared/StaffConsole.vue
new file mode 100644
index 0000000..1b982e1
--- /dev/null
+++ b/apps/ops/src/components/shared/StaffConsole.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
Réconcilie les comptes Authentik (source des utilisateurs actifs) avec les System User / Employé / Technicien d'OPS. Un compte actif sans System User = non provisionné (ex. Karim). Départ = désactiver (garde l'historique) ; suppression seulement si aucune référence.
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ statusMeta[r.status].label }}
+
+
+ {{ r.name }}
+ inactif
+ sans groupe
+
+
+ {{ r.email }}
+
+ user
+ employé
+ tech
+ · {{ r.groups.join(', ') }}
+
+
+
+
+
+
+ {{ r.is_active ? 'Désactiver (garde l\'historique)' : 'Réactiver' }}
+ Supprimer (gardé : refusé si références)
+
+
+ Aucun compte dans cette vue.
+
+
+
+
{ if (!v) prov = null }">
+
+
+
+ Provisionner {{ prov.label }}
+
+
+
+ Crée les pièces manquantes : groupe Authentik + System User + Employé{{ prov.create_tech ? ' + Technicien' : '' }} + identité unifiée.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/ops/src/pages/SettingsPage.vue b/apps/ops/src/pages/SettingsPage.vue
index 73e5782..61bc08a 100644
--- a/apps/ops/src/pages/SettingsPage.vue
+++ b/apps/ops/src/pages/SettingsPage.vue
@@ -26,6 +26,7 @@
+
@@ -33,6 +34,11 @@
+
+
@@ -816,6 +822,7 @@ import { useAvatar } from 'src/composables/useAvatar'
import UserAvatar from 'src/components/shared/UserAvatar.vue'
import EmployeeEditDialog from 'src/components/shared/EmployeeEditDialog.vue'
import IdentityManager from 'src/components/shared/IdentityManager.vue' // gestion des identités unifiées (label + alias courriel + lien tech + fusion)
+import StaffConsole from 'src/components/shared/StaffConsole.vue' // réconciliation Authentik ↔ staff (provisionner/désactiver/supprimer gardé)
import { listDocs } from 'src/api/erp'
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'
diff --git a/services/targo-hub/lib/auth.js b/services/targo-hub/lib/auth.js
index fe287ca..7b1acbb 100644
--- a/services/targo-hub/lib/auth.js
+++ b/services/targo-hub/lib/auth.js
@@ -102,6 +102,12 @@ async function resolveEmployeeForEmail (email) {
} catch (err) { log('resolveEmployeeForEmail ' + e + ': ' + err.message); return null }
}
+// Admin = mêmes groupes que identity/avatars/user-prefs (écritures staff sensibles).
+function isAdminReq (req) {
+ const g = String(req.headers['x-authentik-groups'] || req.headers['x-authentik-group'] || '')
+ return /(^|[,|;])\s*(ops-?admin|admin(istrateurs?)?|superviseurs?)\s*([,|;]|$)/i.test(g)
+}
+
function validatePermissions (body) {
const validKeys = new Set(CAPABILITIES.map(c => c.key))
const result = {}
@@ -221,6 +227,125 @@ async function handle (req, res, method, path, url) {
return json(res, 200, { users, total: r.data.pagination?.count || users.length })
}
+ // ── CONSOLE STAFF : réconciliation Authentik ↔ System User / Employee / Dispatch Technician / identité ──
+ if (resource === 'staff' && !parts[1] && method === 'GET') {
+ const erp = require('./erp'); const idlib = require('./identity')
+ // 1. Users Authentik internes (toutes les pages ; exclut outposts + comptes de service).
+ 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 allGroups = await fetchGroups(); const gmap = new Map(allGroups.map(g => [g.pk, g.name]))
+ // 2. ERPNext en lot (1 requête par doctype).
+ const [sysU, emps, techs] = await Promise.all([
+ erp.list('User', { filters: [['user_type', '=', 'System User']], fields: ['name', 'enabled'], limit: 1000 }).catch(() => []),
+ erp.list('Employee', { fields: ['name', 'employee_name', 'company_email', 'user_id', 'status'], limit: 2000 }).catch(() => []),
+ erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'employee', 'status'], limit: 1000 }).catch(() => []),
+ ])
+ 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
+ const id = idlib.resolveIdentity(email)
+ const hasSys = sysSet.has(email)
+ 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 }
+ }).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 })
+ }
+
+ // Impact d'un retrait : références qui empêchent une suppression propre (tickets assignés, jobs).
+ if (resource === 'staff' && parts[1] === 'impact' && method === 'GET') {
+ const erp = require('./erp'); const idlib = require('./identity')
+ const email = String(url.searchParams.get('email') || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
+ const id = idlib.resolveIdentity(email)
+ const emails = id ? [id.primary_email, ...(id.alias_emails || [])] : [email]
+ let tickets = 0
+ for (const e of emails) { try { const r = await erp.list('Issue', { filters: [['_assign', 'like', '%' + e + '%']], fields: ['name'], limit: 200 }); tickets += (r || []).length } catch (x) {} }
+ let jobs = 0
+ if (id && id.tech_id) { try { const r = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', id.tech_id]], fields: ['name'], limit: 500 }); jobs = (r || []).length } catch (x) {} }
+ return json(res, 200, { email, tickets, jobs, clean: tickets === 0 && jobs === 0 })
+ }
+
+ // ── Écritures staff : admin seulement ──
+ if (resource === 'staff' && method !== 'GET' && !isAdminReq(req)) return json(res, 403, { error: 'réservé aux administrateurs' })
+
+ // Provisionner (create-staff) : à partir d'un user Authentik EXISTANT (ou nouveau) → groupe + System User + Employee + Tech + identité.
+ if (resource === 'staff' && parts[1] === 'provision' && method === 'POST') {
+ const b = await parseBody(req); const erp = require('./erp'); const idlib = require('./identity')
+ const email = String(b.email || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
+ const label = String(b.label || email).trim(); const out = { email, steps: {} }
+ // 1. Authentik : assigner les groupes OPS + garder actif.
+ try {
+ const lookup = await findUserByEmail(email)
+ if (lookup.user && Array.isArray(b.groups) && b.groups.length) {
+ const allG = await fetchGroups(); const pks = b.groups.map(n => { const g = allG.find(x => x.name === n); return g && g.pk }).filter(Boolean)
+ await akFetch('/core/users/' + lookup.user.pk + '/', 'PATCH', { groups: pks, is_active: true }); invalidateCache()
+ out.steps.groups = b.groups
+ }
+ } catch (e) { out.steps.groups_err = e.message }
+ // 2. ERPNext System User (créer si absent, sinon promouvoir).
+ try {
+ const ex = await erp.list('User', { filters: [['name', '=', email]], fields: ['name', 'user_type'], limit: 1 })
+ if (!ex.length) { await erp.create('User', { email, first_name: label, send_welcome_email: 0, user_type: 'System User', roles: [{ role: 'Employee' }] }); out.steps.system_user = 'created' }
+ 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).
+ 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' }
+ } catch (e) { out.steps.employee_err = e.message }
+ }
+ // 4. Dispatch Technician (optionnel).
+ let techId = b.tech_id || ''
+ if (b.create_tech) {
+ try { const r = await erp.create('Dispatch Technician', { full_name: label, resource_type: 'human', status: 'Disponible', employee: empName || undefined }); techId = (r && ((r.data && r.data.name) || r.name)) || ''; out.steps.tech = techId || 'created' } catch (e) { out.steps.tech_err = e.message }
+ }
+ // 5. Identité unifiée.
+ try { idlib.upsert({ label, primary_email: email, tech_id: techId, employee: empName, active: true, kind: b.kind || 'employee' }); out.steps.identity = 'ok' } catch (e) { out.steps.identity_err = e.message }
+ return json(res, 200, { ok: true, ...out })
+ }
+
+ // Désactiver / réactiver (non destructif) : Authentik is_active + identité départ.
+ if (resource === 'staff' && parts[1] === 'active' && method === 'POST') {
+ const b = await parseBody(req); const idlib = require('./identity')
+ const email = String(b.email || '').toLowerCase(); const active = b.active !== false
+ if (!email) return json(res, 400, { error: 'email requis' })
+ try { const lookup = await findUserByEmail(email); if (lookup.user) { await akFetch('/core/users/' + lookup.user.pk + '/', 'PATCH', { is_active: active }); invalidateCache() } } catch (e) { return json(res, 500, { error: e.message }) }
+ try { idlib.setActive(email, active) } catch (e) {}
+ return json(res, 200, { ok: true, email, is_active: active })
+ }
+
+ // 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')
+ const email = String(url.searchParams.get('email') || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
+ const id = idlib.resolveIdentity(email)
+ const emails = id ? [id.primary_email, ...(id.alias_emails || [])] : [email]
+ let tickets = 0; for (const e of emails) { try { const r = await erp.list('Issue', { filters: [['_assign', 'like', '%' + e + '%']], fields: ['name'], limit: 200 }); tickets += (r || []).length } catch (x) {} }
+ let jobs = 0; if (id && id.tech_id) { try { const r = await erp.list('Dispatch Job', { filters: [['assigned_tech', '=', id.tech_id]], fields: ['name'], limit: 500 }); jobs = (r || []).length } catch (x) {} }
+ if (tickets > 0 || jobs > 0) return json(res, 409, { ok: false, refused: true, reason: 'références existantes', tickets, jobs, hint: 'Désactiver plutôt que supprimer (garde l\'historique).' })
+ const done = {}
+ try { const lookup = await findUserByEmail(email); if (lookup.user) { await akFetch('/core/users/' + lookup.user.pk + '/', 'DELETE'); invalidateCache(); done.authentik = 'deleted' } } catch (e) { done.authentik_err = e.message }
+ try { await erp.remove('User', email); done.system_user = 'deleted' } catch (e) { done.system_user_err = e.message }
+ try { if (id) idlib.setActive(email, false) } catch (e) {} // l'identité reste (départ) même après suppression du compte
+ return json(res, 200, { ok: true, email, ...done })
+ }
+
if (resource === 'users' && parts[2] === 'overrides' && method === 'PUT') {
const email = decodeURIComponent(parts[1])
const lookup = await findUserByEmail(email)
diff --git a/services/targo-hub/lib/identity.js b/services/targo-hub/lib/identity.js
index 8a04a51..491629d 100644
--- a/services/targo-hub/lib/identity.js
+++ b/services/targo-hub/lib/identity.js
@@ -59,6 +59,25 @@ function sameIdentity (a, b) { return identityKey(a) === identityKey(b) }
// Inconnu de la carte → true (on ne bloque pas quelqu'un qu'on ne connaît pas).
function isActive (input) { const id = resolveIdentity(input); return id ? id.active !== false : true }
+// Écriture programmatique (utilisée par le provisioning staff) — upsert par clé (= primary_email par défaut).
+function upsert ({ key, label, primary_email, alias_emails, tech_id, employee, active, kind } = {}) {
+ const primary = norm(primary_email); if (!primary || !label) return null
+ const m = load(); const k = key || primary; const cur = m[k] || {}
+ m[k] = {
+ label: String(label).slice(0, 120), primary_email: primary,
+ alias_emails: [...new Set([...(cur.alias_emails || []), ...(alias_emails || [])].map(norm).filter(e => e && e !== primary))],
+ tech_id: tech_id || cur.tech_id || '', employee: employee || cur.employee || '',
+ active: active === undefined ? (cur.active !== false) : (active !== false),
+ kind: kind || cur.kind || 'employee',
+ }
+ save(m); return m[k]
+}
+function setActive (keyOrEmail, active) {
+ const id = resolveIdentity(keyOrEmail); if (!id) return null
+ const m = load(); if (!m[id.key]) return null
+ m[id.key].active = active !== false; save(m); return m[id.key]
+}
+
async function handle (req, res, method, path, url) {
// ── Lecture (tout membre du personnel authentifié — annuaire interne) ──
// Carte compacte pour le SPA : email→{key,label}, tech→{key,label}, + liste éditable.
@@ -123,4 +142,4 @@ async function handle (req, res, method, path, url) {
return json(res, 404, { error: 'identity endpoint not found' })
}
-module.exports = { handle, resolveIdentity, labelForEmail, canonicalEmail, identityKey, sameIdentity, isActive }
+module.exports = { handle, resolveIdentity, labelForEmail, canonicalEmail, identityKey, sameIdentity, isActive, upsert, setActive }