feat(ops): staff console — one-click dedupe of duplicate Authentik accounts
POST /auth/staff/dedupe {email}: keeps the used account (has last_login, else
oldest) and deletes ONLY never-logged-in duplicate Authentik records for that
email — safe (no sessions/history, ERPNext untouched since it's one User per
email). Console shows "Nettoyer le doublon" on flagged rows with a confirm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
148cb9a320
commit
11c53c956a
|
|
@ -15,3 +15,5 @@ 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' })
|
||||
// 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 })
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Notify, Dialog } from 'quasar'
|
||||
import { listStaff, provisionStaff, setStaffActive, staffImpact, deleteStaff } from 'src/api/staff'
|
||||
import { listStaff, provisionStaff, setStaffActive, staffImpact, deleteStaff, dedupeAccounts } from 'src/api/staff'
|
||||
|
||||
const OPS_GROUPS = ['admin', 'sysadmin', 'tech', 'support', 'comptabilite', 'facturation', 'dev']
|
||||
const rows = ref([])
|
||||
|
|
@ -51,6 +51,13 @@ async function toggleActive (r) {
|
|||
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 tryDedupe (r) {
|
||||
Dialog.create({ title: 'Supprimer le compte en double', message: `${r.name} a ${r.dup_accounts} comptes Authentik pour ${r.email} (${(r.usernames || []).join(', ')}). Garder le compte utilisé et supprimer le doublon jamais connecté ? (n'affecte pas ERPNext ni l'historique)`, cancel: 'Annuler', ok: { label: 'Nettoyer', color: 'red-7' } }).onOk(async () => {
|
||||
busy.value = r.email
|
||||
try { const d = await dedupeAccounts(r.email); Notify.create({ type: 'positive', message: 'Gardé : ' + d.kept + (d.deleted && d.deleted.length ? ' · supprimé : ' + d.deleted.join(', ') : ''), caption: (d.skipped && d.skipped.length ? 'Ignoré : ' + d.skipped.join(', ') : ''), timeout: 4500 }); await load() }
|
||||
catch (e) { Notify.create({ type: 'negative', message: e.message || e }) } finally { busy.value = '' }
|
||||
})
|
||||
}
|
||||
async function tryDelete (r) {
|
||||
busy.value = r.email
|
||||
try {
|
||||
|
|
@ -102,6 +109,7 @@ async function tryDelete (r) {
|
|||
</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.dup_accounts" dense unelevated no-caps size="sm" color="red-6" icon="content_copy" label="Nettoyer le doublon" class="q-mr-xs" @click="tryDedupe(r)" />
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -357,6 +357,26 @@ async function handle (req, res, method, path, url) {
|
|||
return json(res, 200, { ok: true, email, is_active: active })
|
||||
}
|
||||
|
||||
// Dé-dupliquer les comptes Authentik d'un même courriel : garde l'utilisé (last_login) sinon le plus ancien,
|
||||
// supprime UNIQUEMENT les doublons JAMAIS connectés (sûr : aucune session/historique). Body: { email }.
|
||||
if (resource === 'staff' && parts[1] === 'dedupe' && method === 'POST') {
|
||||
const b = await parseBody(req); const email = String(b.email || '').toLowerCase(); if (!email) return json(res, 400, { error: 'email requis' })
|
||||
const r = await akFetch('/core/users/?search=' + encodeURIComponent(email) + '&page_size=50')
|
||||
const recs = (r.data.results || []).filter(u => String(u.email || '').toLowerCase() === email)
|
||||
if (recs.length < 2) return json(res, 200, { ok: true, noop: true, count: recs.length })
|
||||
const used = recs.filter(u => u.last_login).sort((a, b) => new Date(b.last_login) - new Date(a.last_login))
|
||||
const keeper = used[0] || recs.slice().sort((a, b) => new Date(a.date_joined) - new Date(b.date_joined))[0]
|
||||
const deleted = [], skipped = []
|
||||
for (const u of recs) {
|
||||
if (u.pk === keeper.pk) continue
|
||||
if (u.last_login) { skipped.push(u.username + ' (déjà utilisé — résoudre manuellement)'); continue } // ne jamais supprimer un compte utilisé
|
||||
const d = await akFetch('/core/users/' + u.pk + '/', 'DELETE')
|
||||
if (d.status < 300) deleted.push(u.username); else skipped.push(u.username + ' (erreur ' + d.status + ')')
|
||||
}
|
||||
invalidateCache()
|
||||
return json(res, 200, { ok: true, kept: keeper.username, deleted, skipped })
|
||||
}
|
||||
|
||||
// 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