feat(ops): staff reconciliation console (Authentik ↔ System User/Employee/Tech)
Settings → Staff tab: one row per internal Authentik user with provisioning state (active, groups, System User / Employee / Dispatch Technician / identity), flagging orphan (active but no System User → not provisioned, e.g. Karim Takougang), departed (inactive/identity-departed), ok. Hub /auth/staff (GET reconciliation) + /auth/staff/provision (groups + System User + Employee + Tech + identity in one click) + /auth/staff/active (deactivate/reactivate, non-destructive) + /auth/staff/impact + guarded DELETE (refuses with 409 if tickets/jobs reference the person → offers deactivate). Writes admin-gated. identity.js gains programmatic upsert/setActive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dec8321823
commit
b43f092da3
17
apps/ops/src/api/staff.js
Normal file
17
apps/ops/src/api/staff.js
Normal file
|
|
@ -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' })
|
||||||
137
apps/ops/src/components/shared/StaffConsole.vue
Normal file
137
apps/ops/src/components/shared/StaffConsole.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* StaffConsole — réconciliation Authentik ↔ OPS (admins).
|
||||||
|
*
|
||||||
|
* Chaque user Authentik INTERNE avec son état de provisioning : actif ? groupes ?
|
||||||
|
* System User / Employee / Dispatch Technician / identité présents ? Signale :
|
||||||
|
* · orphan = actif dans Authentik mais SANS System User → non provisionné (ex. Karim, Junior)
|
||||||
|
* · departed = inactif Authentik ou identité en départ → à retirer/garder pour l'historique
|
||||||
|
* · ok = provisionné
|
||||||
|
* Actions : Provisionner (crée les pièces manquantes) · Désactiver/Réactiver (non destructif)
|
||||||
|
* · Supprimer (GARDÉ : refusé si tickets/jobs référencent la personne → propose la désactivation).
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { Notify, Dialog } from 'quasar'
|
||||||
|
import { listStaff, provisionStaff, setStaffActive, staffImpact, deleteStaff } from 'src/api/staff'
|
||||||
|
|
||||||
|
const OPS_GROUPS = ['admin', 'sysadmin', 'tech', 'support', 'comptabilite', 'facturation', 'dev']
|
||||||
|
const rows = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const filter = ref('orphan') // orphan | departed | ok | all
|
||||||
|
const search = ref('')
|
||||||
|
const busy = ref('') // email en cours d'action
|
||||||
|
const prov = ref(null) // formulaire de provisioning
|
||||||
|
|
||||||
|
const counts = computed(() => { const c = { orphan: 0, departed: 0, ok: 0 }; for (const r of rows.value) c[r.status] = (c[r.status] || 0) + 1; return c })
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = search.value.trim().toLowerCase()
|
||||||
|
return rows.value.filter(r => (filter.value === 'all' || r.status === filter.value) &&
|
||||||
|
(!q || (r.name + ' ' + r.email + ' ' + r.groups.join(' ')).toLowerCase().includes(q)))
|
||||||
|
})
|
||||||
|
const statusMeta = { orphan: { color: 'orange', icon: 'report_problem', label: 'à provisionner' }, departed: { color: 'blue-grey-5', icon: 'logout', label: 'parti / inactif' }, ok: { color: 'positive', icon: 'check_circle', label: 'provisionné' } }
|
||||||
|
|
||||||
|
async function load () {
|
||||||
|
loading.value = true
|
||||||
|
try { const r = await listStaff(); rows.value = r.staff || [] }
|
||||||
|
catch (e) { Notify.create({ type: 'negative', message: 'Chargement impossible : ' + (e.message || e) }) } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
function openProvision (r) { prov.value = { email: r.email, label: r.name || r.email, groups: r.groups.length ? [...r.groups] : ['tech'], create_employee: true, create_tech: false, kind: 'employee' } }
|
||||||
|
async function doProvision () {
|
||||||
|
const p = prov.value; busy.value = p.email
|
||||||
|
try {
|
||||||
|
const r = await provisionStaff(p)
|
||||||
|
Notify.create({ type: 'positive', message: p.label + ' provisionné(e)', caption: Object.entries(r.steps || {}).map(([k, v]) => k + ':' + v).join(' · '), timeout: 4000 })
|
||||||
|
prov.value = null; await load()
|
||||||
|
} catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
|
||||||
|
}
|
||||||
|
async function toggleActive (r) {
|
||||||
|
busy.value = r.email
|
||||||
|
try { await setStaffActive(r.email, !r.is_active); await load() }
|
||||||
|
catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
|
||||||
|
}
|
||||||
|
async function tryDelete (r) {
|
||||||
|
busy.value = r.email
|
||||||
|
try {
|
||||||
|
const imp = await staffImpact(r.email)
|
||||||
|
if (!imp.clean) {
|
||||||
|
Dialog.create({ title: 'Suppression bloquée', message: `${r.name} a des références : ${imp.tickets} ticket(s), ${imp.jobs} job(s). Supprimer orphelinerait l'historique. Désactiver plutôt (garde l'historique) ?`, cancel: 'Annuler', ok: { label: 'Désactiver', color: 'orange-7' } })
|
||||||
|
.onOk(async () => { await setStaffActive(r.email, false); await load() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Dialog.create({ title: 'Supprimer définitivement', message: `Aucune référence trouvée. Supprimer le compte Authentik + ERPNext de ${r.name} ? (irréversible)`, cancel: true, ok: { label: 'Supprimer', color: 'negative' } })
|
||||||
|
.onOk(async () => { const d = await deleteStaff(r.email); if (d.status === 409) { Notify.create({ type: 'warning', message: 'Références apparues — désactivé à la place' }); await setStaffActive(r.email, false) } else Notify.create({ type: 'positive', message: r.name + ' supprimé(e)' }); await load() })
|
||||||
|
} catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="q-pa-sm">
|
||||||
|
<div class="text-caption text-grey-7 q-mb-sm">Réconcilie les comptes <b>Authentik</b> (source des utilisateurs actifs) avec les <b>System User / Employé / Technicien</b> d'OPS. Un compte actif sans System User = <b>non provisionné</b> (ex. Karim). Départ = désactiver (garde l'historique) ; suppression seulement si aucune référence.</div>
|
||||||
|
|
||||||
|
<div class="row items-center q-gutter-xs q-mb-sm">
|
||||||
|
<q-btn-toggle v-model="filter" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
|
||||||
|
:options="[{label:'À provisionner ('+(counts.orphan||0)+')',value:'orphan'},{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 flat dense round icon="refresh" :loading="loading" @click="load" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-center q-pa-md"><q-spinner size="24px" color="primary" /></div>
|
||||||
|
<q-list v-else bordered separator class="rounded-borders">
|
||||||
|
<q-item v-for="r in filtered" :key="r.email">
|
||||||
|
<q-item-section avatar style="min-width:34px">
|
||||||
|
<q-icon :name="statusMeta[r.status].icon" :color="statusMeta[r.status].color" size="20px"><q-tooltip>{{ statusMeta[r.status].label }}</q-tooltip></q-icon>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="text-weight-medium">{{ r.name }}
|
||||||
|
<q-chip v-if="!r.is_active" dense size="sm" color="grey-4" text-color="grey-8" icon="logout">inactif</q-chip>
|
||||||
|
<q-chip v-if="r.needs_group" dense size="sm" color="amber-2" text-color="amber-9" icon="group_off">sans groupe</q-chip>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption>
|
||||||
|
{{ r.email }}
|
||||||
|
<span class="q-ml-sm">
|
||||||
|
<q-icon :name="r.has_system_user ? 'check' : 'close'" :color="r.has_system_user ? 'positive':'grey-5'" size="13px" /> user
|
||||||
|
<q-icon :name="r.employee ? 'check' : 'close'" :color="r.employee ? 'positive':'grey-5'" size="13px" class="q-ml-xs" /> employé
|
||||||
|
<q-icon :name="r.tech_id ? 'check' : 'close'" :color="r.tech_id ? 'positive':'grey-5'" size="13px" class="q-ml-xs" /> tech
|
||||||
|
<span v-if="r.groups.length" class="q-ml-xs text-grey-6">· {{ r.groups.join(', ') }}</span>
|
||||||
|
</span>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side class="row no-wrap items-center">
|
||||||
|
<q-spinner v-if="busy === r.email" size="16px" color="primary" class="q-mr-sm" />
|
||||||
|
<q-btn v-if="r.status === 'orphan'" dense unelevated no-caps size="sm" color="primary" icon="person_add" label="Provisionner" class="q-mr-xs" @click="openProvision(r)" />
|
||||||
|
<q-btn flat dense round size="sm" :icon="r.is_active ? 'block' : 'person_add'" :color="r.is_active ? 'orange-7':'positive'" @click="toggleActive(r)"><q-tooltip>{{ r.is_active ? 'Désactiver (garde l\'historique)' : 'Réactiver' }}</q-tooltip></q-btn>
|
||||||
|
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="tryDelete(r)"><q-tooltip>Supprimer (gardé : refusé si références)</q-tooltip></q-btn>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item v-if="!filtered.length"><q-item-section class="text-grey-6 text-caption">Aucun compte dans cette vue.</q-item-section></q-item>
|
||||||
|
</q-list>
|
||||||
|
|
||||||
|
<!-- Provisioning -->
|
||||||
|
<q-dialog :model-value="!!prov" @update:model-value="v => { if (!v) prov = null }">
|
||||||
|
<q-card style="min-width:400px;max-width:96vw" v-if="prov">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<q-icon name="person_add" color="primary" size="22px" class="q-mr-sm" />
|
||||||
|
<div class="text-subtitle1 text-weight-bold">Provisionner {{ prov.label }}</div>
|
||||||
|
<q-space /><q-btn flat round dense icon="close" @click="prov = null" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-gutter-sm">
|
||||||
|
<div class="text-caption text-grey-7">Crée les pièces manquantes : groupe Authentik + System User + Employé{{ prov.create_tech ? ' + Technicien' : '' }} + identité unifiée.</div>
|
||||||
|
<q-input dense outlined v-model="prov.label" label="Nom complet" />
|
||||||
|
<q-select dense outlined multiple use-chips v-model="prov.groups" :options="OPS_GROUPS" label="Groupe(s) OPS" />
|
||||||
|
<div class="row items-center q-gutter-md">
|
||||||
|
<q-toggle v-model="prov.create_employee" dense size="sm" label="Créer l'employé" />
|
||||||
|
<q-toggle v-model="prov.create_tech" dense size="sm" color="indigo" label="Créer le technicien (dispatch)" />
|
||||||
|
</div>
|
||||||
|
<q-option-group v-model="prov.kind" type="radio" inline dense :options="[{label:'Employé',value:'employee'},{label:'Sous-traitant',value:'subcontractor'}]" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right" class="q-px-md q-pb-md">
|
||||||
|
<q-btn flat no-caps label="Annuler" color="grey-7" @click="prov = null" />
|
||||||
|
<q-btn unelevated no-caps color="primary" icon="check" label="Provisionner" :loading="busy === prov.email" @click="doProvision" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
<q-tab name="groups" icon="groups" label="Groupes" />
|
<q-tab name="groups" icon="groups" label="Groupes" />
|
||||||
<q-tab name="matrix" icon="grid_on" label="Matrice" />
|
<q-tab name="matrix" icon="grid_on" label="Matrice" />
|
||||||
<q-tab name="identities" icon="badge" label="Identités" />
|
<q-tab name="identities" icon="badge" label="Identités" />
|
||||||
|
<q-tab name="staff" icon="manage_accounts" label="Staff" />
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
|
|
||||||
<!-- TAB: Identités unifiées (label + alias courriel + lien tech + fusion) -->
|
<!-- TAB: Identités unifiées (label + alias courriel + lien tech + fusion) -->
|
||||||
|
|
@ -33,6 +34,11 @@
|
||||||
<IdentityManager />
|
<IdentityManager />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB: Console Staff (réconciliation Authentik ↔ System User/Employee/Tech ; provisionner/désactiver/supprimer) -->
|
||||||
|
<div v-show="permTab === 'staff'">
|
||||||
|
<StaffConsole />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- TAB: Utilisateurs -->
|
<!-- TAB: Utilisateurs -->
|
||||||
<div v-show="permTab === 'users'">
|
<div v-show="permTab === 'users'">
|
||||||
<div class="row q-gutter-sm items-end q-mb-md">
|
<div class="row q-gutter-sm items-end q-mb-md">
|
||||||
|
|
@ -816,6 +822,7 @@ import { useAvatar } from 'src/composables/useAvatar'
|
||||||
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
||||||
import EmployeeEditDialog from 'src/components/shared/EmployeeEditDialog.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 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 { listDocs } from 'src/api/erp'
|
||||||
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
|
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
|
||||||
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'
|
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,12 @@ async function resolveEmployeeForEmail (email) {
|
||||||
} catch (err) { log('resolveEmployeeForEmail ' + e + ': ' + err.message); return null }
|
} 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) {
|
function validatePermissions (body) {
|
||||||
const validKeys = new Set(CAPABILITIES.map(c => c.key))
|
const validKeys = new Set(CAPABILITIES.map(c => c.key))
|
||||||
const result = {}
|
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 })
|
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') {
|
if (resource === 'users' && parts[2] === 'overrides' && method === 'PUT') {
|
||||||
const email = decodeURIComponent(parts[1])
|
const email = decodeURIComponent(parts[1])
|
||||||
const lookup = await findUserByEmail(email)
|
const lookup = await findUserByEmail(email)
|
||||||
|
|
|
||||||
|
|
@ -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).
|
// 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 }
|
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) {
|
async function handle (req, res, method, path, url) {
|
||||||
// ── Lecture (tout membre du personnel authentifié — annuaire interne) ──
|
// ── Lecture (tout membre du personnel authentifié — annuaire interne) ──
|
||||||
// Carte compacte pour le SPA : email→{key,label}, tech→{key,label}, + liste éditable.
|
// 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' })
|
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 }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user