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 @@ + + + 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 }