Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
13 KiB
JavaScript
305 lines
13 KiB
JavaScript
'use strict'
|
|
const cfg = require('./config')
|
|
const { log, json, parseBody, httpRequest } = require('./helpers')
|
|
let mysql = null
|
|
try { mysql = require('mysql2/promise') } catch (e) { /* optional */ }
|
|
|
|
const CAP_DEFS = {
|
|
Dashboard: { view_dashboard_kpi: 'Voir KPIs (revenus, AR)' },
|
|
Clients: { view_clients: 'Voir la liste clients', view_client_detail: 'Voir le détail client', edit_customer: 'Modifier les champs client' },
|
|
Finance: { view_invoices: 'Voir les factures', create_invoices: 'Créer/modifier factures', view_payments: 'Voir les paiements', create_payments: 'Créer paiements', edit_subscription_price: 'Modifier prix abonnement' },
|
|
Dispatch: { view_all_jobs: 'Voir tous les jobs', view_own_jobs: 'Voir ses propres jobs', assign_jobs: 'Assigner/réassigner', create_jobs: 'Créer des jobs' },
|
|
'Équipement': { view_equipment: 'Voir diagnostics équipement', reboot_provision: 'Reboot/provisionner' },
|
|
Tickets: { view_all_tickets: 'Voir tous les tickets', view_own_tickets: 'Voir ses tickets', manage_tickets: 'Créer/fermer tickets' },
|
|
Communication: { send_sms: 'Envoyer SMS', make_calls: 'Passer des appels' },
|
|
'Téléphonie': { manage_telephony: 'Gérer Fonoster/trunks' },
|
|
Administration: { view_settings: 'Voir paramètres', manage_settings: 'Modifier paramètres', manage_users: 'Gérer utilisateurs', delete_records: 'Supprimer des enregistrements', manage_permissions: 'Modifier les permissions' },
|
|
}
|
|
const CAPABILITIES = Object.entries(CAP_DEFS).flatMap(([cat, caps]) => Object.entries(caps).map(([key, label]) => ({ key, label, category: cat })))
|
|
|
|
const OPS_GROUPS = ['admin', 'sysadmin', 'tech', 'support', 'comptabilite', 'facturation', 'dev']
|
|
|
|
function akFetch (path, method = 'GET', body = null) {
|
|
if (!cfg.AUTHENTIK_TOKEN) throw new Error('AUTHENTIK_TOKEN not configured')
|
|
return httpRequest(cfg.AUTHENTIK_URL, '/api/v3' + path, {
|
|
method, body, headers: { Authorization: 'Bearer ' + cfg.AUTHENTIK_TOKEN }, timeout: 10000,
|
|
})
|
|
}
|
|
|
|
let groupCache = null, groupCacheTs = 0
|
|
|
|
async function fetchGroups () {
|
|
if (groupCache && Date.now() - groupCacheTs < 60000) return groupCache
|
|
const r = await akFetch('/core/groups/?page_size=100')
|
|
if (r.status !== 200) throw new Error('Authentik groups fetch failed: ' + r.status)
|
|
groupCache = r.data.results
|
|
groupCacheTs = Date.now()
|
|
return groupCache
|
|
}
|
|
|
|
function invalidateCache () { groupCache = null; groupCacheTs = 0 }
|
|
|
|
async function findUserByEmail (email) {
|
|
const r = await akFetch('/core/users/?search=' + encodeURIComponent(email) + '&page_size=5')
|
|
if (r.status !== 200) return { error: 'Authentik user lookup failed', status: 502 }
|
|
const user = r.data.results?.find(u => u.email?.toLowerCase() === email.toLowerCase())
|
|
return user ? { user } : { error: 'User not found: ' + email, status: 404 }
|
|
}
|
|
|
|
function validatePermissions (body) {
|
|
const validKeys = new Set(CAPABILITIES.map(c => c.key))
|
|
const result = {}
|
|
for (const [key, val] of Object.entries(body)) {
|
|
if (validKeys.has(key) && typeof val === 'boolean') result[key] = val
|
|
}
|
|
return result
|
|
}
|
|
|
|
async function handle (req, res, method, path, url) {
|
|
if (!cfg.AUTHENTIK_TOKEN) return json(res, 503, { error: 'Authentik not configured' })
|
|
|
|
try {
|
|
const parts = path.replace('/auth/', '').split('/').filter(Boolean)
|
|
const resource = parts[0]
|
|
|
|
if (resource === 'capabilities' && method === 'GET') {
|
|
return json(res, 200, CAPABILITIES)
|
|
}
|
|
|
|
if (resource === 'permissions' && method === 'GET') {
|
|
const email = url.searchParams.get('email') || req.headers['x-authentik-email']
|
|
if (!email) return json(res, 400, { error: 'Missing email parameter' })
|
|
|
|
const lookup = await findUserByEmail(email)
|
|
if (lookup.error) return json(res, lookup.status, { error: lookup.error })
|
|
const { user } = lookup
|
|
|
|
const allGroups = await fetchGroups()
|
|
const userGroupPks = new Set(user.groups || [])
|
|
const userGroups = allGroups.filter(g => userGroupPks.has(g.pk) && OPS_GROUPS.includes(g.name))
|
|
|
|
const merged = {}
|
|
for (const cap of CAPABILITIES) merged[cap.key] = false
|
|
for (const g of userGroups) {
|
|
const perms = g.attributes?.ops_permissions || {}
|
|
for (const [key, val] of Object.entries(perms)) {
|
|
if (val === true) merged[key] = true
|
|
}
|
|
}
|
|
|
|
const overrides = user.attributes?.ops_permissions_override || {}
|
|
for (const [key, val] of Object.entries(overrides)) {
|
|
if (typeof val === 'boolean') merged[key] = val
|
|
}
|
|
|
|
return json(res, 200, {
|
|
email: user.email, username: user.username, name: user.name,
|
|
groups: userGroups.map(g => g.name), is_superuser: user.is_superuser || false,
|
|
capabilities: merged, overrides,
|
|
})
|
|
}
|
|
|
|
if (resource === 'groups' && !parts[1] && method === 'GET') {
|
|
const allGroups = await fetchGroups()
|
|
const opsGroups = allGroups
|
|
.filter(g => OPS_GROUPS.includes(g.name))
|
|
.map(g => ({
|
|
pk: g.pk, name: g.name, num_users: g.users_obj?.length || 0,
|
|
is_superuser: g.is_superuser || false, permissions: g.attributes?.ops_permissions || {},
|
|
}))
|
|
.sort((a, b) => OPS_GROUPS.indexOf(a.name) - OPS_GROUPS.indexOf(b.name))
|
|
return json(res, 200, { groups: opsGroups, capabilities: CAPABILITIES })
|
|
}
|
|
|
|
if (resource === 'groups' && parts[2] === 'permissions' && method === 'PUT') {
|
|
const groupName = decodeURIComponent(parts[1])
|
|
const allGroups = await fetchGroups()
|
|
const group = allGroups.find(g => g.name === groupName)
|
|
if (!group) return json(res, 404, { error: 'Group not found: ' + groupName })
|
|
|
|
const perms = validatePermissions(await parseBody(req))
|
|
const attrs = { ...(group.attributes || {}), ops_permissions: perms }
|
|
const r = await akFetch('/core/groups/' + group.pk + '/', 'PATCH', { attributes: attrs })
|
|
if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status })
|
|
|
|
invalidateCache()
|
|
log(`Auth: updated permissions for group ${groupName}: ${Object.keys(perms).filter(k => perms[k]).length} capabilities enabled`)
|
|
return json(res, 200, { ok: true, group: groupName, permissions: perms })
|
|
}
|
|
|
|
if (resource === 'users' && !parts[1] && method === 'GET') {
|
|
const page = url.searchParams.get('page') || '1'
|
|
const search = url.searchParams.get('search') || ''
|
|
const groupFilter = url.searchParams.get('group') || ''
|
|
let akPath = `/core/users/?page=${page}&page_size=100&ordering=username`
|
|
if (search) akPath += '&search=' + encodeURIComponent(search)
|
|
if (groupFilter) {
|
|
const allGrps = await fetchGroups()
|
|
const grp = allGrps.find(g => g.name === groupFilter)
|
|
if (grp) akPath += '&groups_by_pk=' + grp.pk
|
|
}
|
|
|
|
const r = await akFetch(akPath)
|
|
if (r.status !== 200) return json(res, 502, { error: 'Authentik users fetch failed' })
|
|
|
|
const allGroups = await fetchGroups()
|
|
const groupMap = new Map(allGroups.map(g => [g.pk, g.name]))
|
|
|
|
const users = r.data.results
|
|
.filter(u => !u.username.startsWith('ak-outpost'))
|
|
.map(u => ({
|
|
pk: u.pk, username: u.username, email: u.email, name: u.name,
|
|
is_active: u.is_active, is_superuser: u.is_superuser,
|
|
groups: (u.groups || []).map(pk => groupMap.get(pk)).filter(n => OPS_GROUPS.includes(n)),
|
|
overrides: u.attributes?.ops_permissions_override || {},
|
|
last_login: u.last_login,
|
|
}))
|
|
|
|
return json(res, 200, { users, total: r.data.pagination?.count || users.length })
|
|
}
|
|
|
|
if (resource === 'users' && parts[2] === 'overrides' && method === 'PUT') {
|
|
const email = decodeURIComponent(parts[1])
|
|
const lookup = await findUserByEmail(email)
|
|
if (lookup.error) return json(res, lookup.status, { error: lookup.error })
|
|
const { user } = lookup
|
|
|
|
const overrides = validatePermissions(await parseBody(req))
|
|
const attrs = { ...(user.attributes || {}), ops_permissions_override: overrides }
|
|
const r = await akFetch('/core/users/' + user.pk + '/', 'PATCH', { attributes: attrs })
|
|
if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status })
|
|
|
|
log(`Auth: updated overrides for ${email}: ${JSON.stringify(overrides)}`)
|
|
return json(res, 200, { ok: true, email, overrides })
|
|
}
|
|
|
|
if (resource === 'users' && parts[2] === 'groups' && method === 'PUT') {
|
|
const email = decodeURIComponent(parts[1])
|
|
const lookup = await findUserByEmail(email)
|
|
if (lookup.error) return json(res, lookup.status, { error: lookup.error })
|
|
const { user } = lookup
|
|
|
|
const { groups: requestedGroups = [] } = await parseBody(req)
|
|
const allGroups = await fetchGroups()
|
|
const opsGroupMap = new Map(allGroups.filter(g => OPS_GROUPS.includes(g.name)).map(g => [g.name, g.pk]))
|
|
|
|
const currentNonOps = (user.groups || []).filter(pk => {
|
|
const g = allGroups.find(gr => gr.pk === pk)
|
|
return g && !OPS_GROUPS.includes(g.name)
|
|
})
|
|
const finalGroups = [...currentNonOps, ...requestedGroups.map(n => opsGroupMap.get(n)).filter(Boolean)]
|
|
|
|
const r = await akFetch('/core/users/' + user.pk + '/', 'PATCH', { groups: finalGroups })
|
|
if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status })
|
|
|
|
invalidateCache()
|
|
log(`Auth: updated groups for ${email}: [${requestedGroups.join(', ')}]`)
|
|
return json(res, 200, { ok: true, email, groups: requestedGroups })
|
|
}
|
|
|
|
if (resource === 'sync-legacy' && method === 'POST') {
|
|
if (!mysql) return json(res, 503, { error: 'mysql2 not installed' })
|
|
|
|
const body = await parseBody(req)
|
|
const dryRun = body.dry_run !== false // default true = preview only
|
|
|
|
let conn
|
|
try {
|
|
conn = await mysql.createConnection({
|
|
host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER,
|
|
password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME,
|
|
})
|
|
const [rows] = await conn.execute(
|
|
'SELECT email, group_ad, first_name, last_name, status FROM staff WHERE status = 1 AND email != "" AND group_ad != ""'
|
|
)
|
|
await conn.end()
|
|
|
|
const allGroups = await fetchGroups()
|
|
const opsGroupMap = new Map()
|
|
for (const g of allGroups) {
|
|
if (OPS_GROUPS.includes(g.name)) opsGroupMap.set(g.name, g)
|
|
}
|
|
|
|
let akUsers = [], page = 1
|
|
while (true) {
|
|
const r = await akFetch('/core/users/?page=' + page + '&page_size=100&ordering=username')
|
|
if (r.status !== 200) break
|
|
akUsers = akUsers.concat(r.data.results || [])
|
|
if (!r.data.pagination || page >= r.data.pagination.total_pages) break
|
|
page++
|
|
}
|
|
|
|
const emailMap = new Map()
|
|
for (const u of akUsers) {
|
|
if (u.email) emailMap.set(u.email.toLowerCase(), u)
|
|
}
|
|
|
|
const report = { matched: [], not_found: [], already_ok: [], errors: [] }
|
|
|
|
for (const row of rows) {
|
|
const legacyEmail = row.email.toLowerCase().trim()
|
|
const targetGroup = row.group_ad.trim()
|
|
if (!opsGroupMap.has(targetGroup)) continue
|
|
|
|
const akUser = emailMap.get(legacyEmail)
|
|
if (!akUser) {
|
|
report.not_found.push({ email: legacyEmail, legacy_group: targetGroup, name: `${row.first_name} ${row.last_name}` })
|
|
continue
|
|
}
|
|
|
|
const group = opsGroupMap.get(targetGroup)
|
|
const userGroupPks = new Set(akUser.groups || [])
|
|
|
|
if (userGroupPks.has(group.pk)) {
|
|
report.already_ok.push({ email: legacyEmail, group: targetGroup })
|
|
continue
|
|
}
|
|
|
|
const entry = { email: legacyEmail, username: akUser.username, group: targetGroup, user_pk: akUser.pk }
|
|
|
|
if (!dryRun) {
|
|
const newGroups = [...(akUser.groups || []), group.pk]
|
|
const r = await akFetch('/core/users/' + akUser.pk + '/', 'PATCH', { groups: newGroups })
|
|
if (r.status === 200) {
|
|
entry.status = 'synced'
|
|
log(`Legacy sync: added ${legacyEmail} to group ${targetGroup}`)
|
|
} else {
|
|
entry.status = 'error'
|
|
entry.error = 'Authentik PATCH failed: ' + r.status
|
|
report.errors.push(entry)
|
|
continue
|
|
}
|
|
} else {
|
|
entry.status = 'pending'
|
|
}
|
|
report.matched.push(entry)
|
|
}
|
|
|
|
if (!dryRun) invalidateCache()
|
|
|
|
return json(res, 200, {
|
|
dry_run: dryRun,
|
|
summary: {
|
|
to_sync: report.matched.length,
|
|
already_ok: report.already_ok.length,
|
|
not_found: report.not_found.length,
|
|
errors: report.errors.length,
|
|
},
|
|
...report,
|
|
})
|
|
} catch (e) {
|
|
if (conn) try { await conn.end() } catch {}
|
|
log('Legacy sync error:', e.message)
|
|
return json(res, 502, { error: 'Legacy sync error: ' + e.message })
|
|
}
|
|
}
|
|
|
|
return json(res, 404, { error: 'Unknown auth endpoint' })
|
|
} catch (e) {
|
|
log('Auth error:', e.message)
|
|
return json(res, 502, { error: 'Auth error: ' + e.message })
|
|
}
|
|
}
|
|
|
|
module.exports = { handle, CAPABILITIES, OPS_GROUPS }
|