Files
soft_usite/resources/views/api_keys.html
T
Lizandro GuarnizoandClaude Sonnet 5 2e3be1ca92 feat(api): monta los endpoints VCard en /api/v2 con scope propio
La integración VCard vivía solo en /api/v1, que autentica por cookie de
sesión: obliga a manejar cookie jar, no permite allowlist por IP y no se
puede revocar sin tocar la contraseña del usuario (ver
docs/api-v1-contrato.md, punto 7).

Ahora los mismos controladores están también bajo /api/v2/vcard/* con
Bearer + IP + scope, que es lo que administra la pantalla /app/api-keys.
El scope "vcard" acota la llave a estos 12 endpoints: sin él, esa
integración tendría acceso a los otros ~300 de v2.

/api/v1 se mantiene intacto — esto es un camino nuevo, no un reemplazo
forzado, así que lo que ya está instalado sigue andando mientras migran.

Se agregan dos chequeos porque el compilador no ve ninguno de los dos
errores: que las 12 rutas queden registradas con su método y path (un
typo se descubriría recién con un 404 del lado del integrador), y que
todas figuren en el spec de /api/v2, que se mantiene a mano y se
desincroniza en silencio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 02:06:49 -05:00

280 lines
16 KiB
HTML

<!-- Vista: API Keys — credenciales scoped para /api/v2 -->
<div x-data="apiKeysApp()" x-init="init()" @keydown.escape.window="closeModal()" class="bg-white rounded-lg shadow">
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
</div>
<div class="container mx-auto p-6 w-full">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold">API Keys</h1>
<p class="text-xs text-slate-500 mt-0.5">Credenciales para <code>/api/v2</code> restringidas a una IP y a un alcance puntual — alternativa a compartir la llave maestra (<code>ADMIN_API_KEY</code>).</p>
</div>
<button @click="openAdd()"
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
Nueva API key
</button>
</div>
<div x-show="errorMsg" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
<div x-show="successMsg" x-cloak class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700" x-text="successMsg"></div>
<div class="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-700">
Solo estos scopes restringen de verdad hoy: <strong>oss</strong>, <strong>query_runner</strong>, <strong>usuarios</strong>, <strong>pasarelas</strong>, <strong>vcard</strong>. El resto de <code>/api/v2</code> sigue funcionando únicamente con la llave maestra (fase 2 pendiente).
</div>
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm">
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
<tr>
<th class="py-2 px-3">Nombre</th>
<th class="py-2 px-3">Token</th>
<th class="py-2 px-3">IP permitida</th>
<th class="py-2 px-3">Scopes</th>
<th class="py-2 px-3">Último uso</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3 text-right">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-if="items.length === 0">
<tr><td colspan="7" class="py-8 text-center text-gray-400">Sin API keys registradas</td></tr>
</template>
<template x-for="item in items" :key="item.ID">
<tr class="hover:bg-gray-50 transition">
<td class="py-2 px-3 font-medium" x-text="item.nombre"></td>
<td class="py-2 px-3 font-mono text-xs text-gray-400" x-text="item.token_preview"></td>
<td class="py-2 px-3 font-mono text-xs text-gray-600" x-text="item.ip_permitida"></td>
<td class="py-2 px-3">
<div class="flex flex-wrap gap-1">
<template x-for="s in (item.scopes || '').split(',').filter(x => x.trim())" :key="s">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-[#e9f0cf] text-[#5a7a1e]" x-text="s.trim()"></span>
</template>
</div>
</td>
<td class="py-2 px-3 text-xs text-gray-500">
<span x-show="item.ultimo_uso_at" x-text="item.ultimo_uso_at ? new Date(item.ultimo_uso_at).toLocaleString() + ' · ' + item.ultimo_uso_ip : ''"></span>
<span x-show="!item.ultimo_uso_at" class="text-gray-300">Nunca usada</span>
</td>
<td class="py-2 px-3">
<span :class="item.activa ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
class="px-2 py-0.5 rounded-full text-xs font-semibold"
x-text="item.activa ? 'Activa' : 'Inactiva'"></span>
</td>
<td class="py-2 px-3 text-right">
<div class="flex justify-end gap-2">
<button @click="openEdit(item)"
class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">Editar</button>
<button @click="confirmRegenerar(item.ID)"
class="text-xs text-amber-600 hover:text-amber-800 font-medium transition">Regenerar token</button>
<button @click="confirmDelete(item.ID)"
class="text-xs text-red-500 hover:text-red-700 font-medium transition">Eliminar</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Modal crear / editar -->
<div x-show="showModal" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
<div @click.outside="closeModal()" class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h2 class="text-lg font-bold mb-4" x-text="editItem ? 'Editar API key' : 'Nueva API key'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre *</label>
<input x-model="form.nombre" type="text" required placeholder="Ej: Integración vCard"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">IP permitida * (IP exacta o CIDR)</label>
<input x-model="form.ip_permitida" type="text" required placeholder="190.10.20.30 o 190.10.0.0/16"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<p class="text-[10px] text-gray-400 mt-1">Obligatoria — sin ella la llave no puede usarse desde ningún lado.</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-2">Scopes *</label>
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
<template x-for="s in ['oss', 'query_runner', 'usuarios', 'pasarelas', 'vcard']" :key="s">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" :checked="form.scopes.includes(s)"
@change="toggleScope(s)" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
<span class="text-sm text-gray-700" x-text="s"></span>
</label>
</template>
</div>
</div>
<div class="flex items-center gap-2">
<input x-model="form.activa" type="checkbox" id="ak_activa" class="rounded" />
<label for="ak_activa" class="text-sm text-gray-700">Activa</label>
</div>
</div>
<div x-show="formError" class="mt-3 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-600" x-text="formError"></div>
<div class="flex justify-end gap-3 mt-5">
<button type="button" @click="closeModal()"
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancelar</button>
<button type="submit" :disabled="saving"
class="px-4 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<span x-text="saving ? 'Guardando...' : (editItem ? 'Actualizar' : 'Crear')"></span>
</button>
</div>
</form>
</div>
</div>
<!-- Modal token generado -->
<div x-show="tokenResult" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h2 class="text-lg font-bold mb-1">⚠️ Guarda este token ahora</h2>
<p class="text-xs text-gray-500 mb-4">No se volverá a mostrar completo.</p>
<div class="flex gap-2">
<input readonly :value="tokenResult && tokenResult.token" class="w-full border border-gray-300 rounded-lg px-3 py-2 text-xs font-mono bg-gray-50">
<button @click="copiar(tokenResult.token)" class="px-3 py-2 text-xs border border-gray-300 rounded-lg hover:bg-gray-50">Copiar</button>
</div>
<div class="flex justify-end mt-5">
<button @click="tokenResult = null; load()" class="px-4 py-2 text-sm text-white rounded-lg" style="background-color:#8eb02f">Listo, ya lo guardé</button>
</div>
</div>
</div>
<!-- Modal confirmar regenerar -->
<div x-show="regenerarId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
<p class="text-gray-700 font-semibold mb-1">¿Regenerar token?</p>
<p class="text-xs text-gray-500 mb-5">El token actual dejará de funcionar de inmediato.</p>
<div class="flex justify-center gap-3">
<button @click="regenerarId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
<button @click="doRegenerar()" :disabled="saving"
class="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition disabled:opacity-50">
<span x-text="saving ? 'Regenerando...' : 'Regenerar'"></span>
</button>
</div>
</div>
</div>
<!-- Modal confirmar eliminación -->
<div x-show="deleteId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
<p class="text-gray-700 font-semibold mb-1">¿Eliminar API key?</p>
<p class="text-xs text-gray-500 mb-5">Esta acción no se puede deshacer.</p>
<div class="flex justify-center gap-3">
<button @click="deleteId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
<button @click="doDelete()" :disabled="saving"
class="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50">
<span x-text="saving ? 'Eliminando...' : 'Eliminar'"></span>
</button>
</div>
</div>
</div>
</div>
<script>
function apiKeysApp() {
return {
loading: false, saving: false,
items: [],
showModal: false, editItem: null, deleteId: null, regenerarId: null, tokenResult: null,
errorMsg: '', successMsg: '', formError: '',
form: { nombre: '', ip_permitida: '', scopes: [], activa: true },
async init() { await this.load() },
async load() {
this.loading = true; this.errorMsg = ''
const res = await fetch('/app/api-keys/list')
const data = await res.json()
this.loading = false
if (!res.ok) { this.errorMsg = data.error || 'Error cargando datos'; return }
this.items = data.items || []
},
toggleScope(s) {
const idx = this.form.scopes.indexOf(s)
if (idx >= 0) { this.form.scopes.splice(idx, 1) } else { this.form.scopes.push(s) }
},
openAdd() {
this.editItem = null
this.form = { nombre: '', ip_permitida: '', scopes: [], activa: true }
this.formError = ''
this.showModal = true
},
openEdit(item) {
this.editItem = item
this.form = {
nombre: item.nombre,
ip_permitida: item.ip_permitida || '',
scopes: (item.scopes || '').split(',').map(s => s.trim()).filter(s => s),
activa: item.activa,
}
this.formError = ''
this.showModal = true
},
closeModal() { this.showModal = false; this.editItem = null; this.formError = '' },
async save() {
this.saving = true; this.formError = ''
const url = this.editItem ? `/app/api-keys/${this.editItem.ID}` : '/app/api-keys'
const method = this.editItem ? 'PUT' : 'POST'
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.form) })
const data = await res.json()
this.saving = false
if (!res.ok) { this.formError = data.error || 'Error guardando'; return }
this.closeModal()
if (data.token) {
this.tokenResult = data
} else {
this.successMsg = 'API key actualizada'
setTimeout(() => this.successMsg = '', 3000)
await this.load()
}
},
confirmRegenerar(id) { this.regenerarId = id },
async doRegenerar() {
this.saving = true
const res = await fetch(`/app/api-keys/${this.regenerarId}/regenerar-token`, { method: 'POST' })
const data = await res.json()
this.saving = false
this.regenerarId = null
if (!res.ok) { this.errorMsg = data.error || 'Error regenerando token'; return }
this.tokenResult = data
},
confirmDelete(id) { this.deleteId = id },
async doDelete() {
this.saving = true
const res = await fetch(`/app/api-keys/${this.deleteId}`, { method: 'DELETE' })
this.saving = false
this.deleteId = null
if (!res.ok) { this.errorMsg = 'Error eliminando'; return }
this.successMsg = 'API key eliminada'
setTimeout(() => this.successMsg = '', 3000)
await this.load()
},
copiar(texto) {
if (!texto) return
navigator.clipboard.writeText(texto)
this.successMsg = 'Copiado al portapapeles'
setTimeout(() => this.successMsg = '', 2000)
}
}
}
</script>