From 11c53c956a695bdbb45d4001ca76d22a649b8122 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 10 Jul 2026 13:33:22 -0400 Subject: [PATCH] =?UTF-8?q?feat(ops):=20staff=20console=20=E2=80=94=20one-?= =?UTF-8?q?click=20dedupe=20of=20duplicate=20Authentik=20accounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/ops/src/api/staff.js | 2 ++ .../src/components/shared/StaffConsole.vue | 10 +++++++++- services/targo-hub/lib/auth.js | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/ops/src/api/staff.js b/apps/ops/src/api/staff.js index acf2c32..3c92d98 100644 --- a/apps/ops/src/api/staff.js +++ b/apps/ops/src/api/staff.js @@ -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 }) diff --git a/apps/ops/src/components/shared/StaffConsole.vue b/apps/ops/src/components/shared/StaffConsole.vue index d429dda..bed013b 100644 --- a/apps/ops/src/components/shared/StaffConsole.vue +++ b/apps/ops/src/components/shared/StaffConsole.vue @@ -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) { + {{ r.is_active ? 'Désactiver (garde l\'historique)' : 'Réactiver' }} Supprimer (gardé : refusé si références) diff --git a/services/targo-hub/lib/auth.js b/services/targo-hub/lib/auth.js index 6f58eaa..103920c 100644 --- a/services/targo-hub/lib/auth.js +++ b/services/targo-hub/lib/auth.js @@ -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')