diff --git a/docs/producto/api-terceros.md b/docs/producto/api-terceros.md new file mode 100644 index 0000000..4374fe1 --- /dev/null +++ b/docs/producto/api-terceros.md @@ -0,0 +1,69 @@ +# API de terceros gestionable (claves de API) + +La API pública `/skeen/api/v1/*` (consumida por Hermes) se autentica con el +método `auth='api_key'`, implementado en +`skeen_plataforma/models/ir_http.py`. Acepta dos tipos de credencial: + +1. **Clave legacy de Hermes** — `ir.config_parameter 'skeen.api.legacy_key'`, + sembrada con el valor histórico `skeen_api_key_2026`. Tiene todos los + alcances (compatibilidad total). +2. **Claves del modelo `skeen.api.key`** — con alcances (*scopes*), vencimiento + y revocación. + +## Formato de autenticación + +``` +Authorization: Bearer +X-API-Key: +?api_key= (query/form) +``` + +## Modelo `skeen.api.key` + +| Campo | Descripción | +| --- | --- | +| `name` | Nombre de la clave | +| `prefijo` | Prefijo visible (los primeros 14 caracteres) | +| `hash` | SHA-256 de la clave (nunca se guarda en claro) | +| `activo` | Habilitada | +| `scopes` | Lista JSON de alcances; vacío = todos | +| `vence` | Fecha de vencimiento (opcional) | +| `ultimo_uso`, `usos` | Uso registrado en cada autenticación | +| `notas`, `creado_por_login` | Metadatos | +| `revocada_at`, `revocada_por` | Revocación | + +La clave **se muestra una sola vez** al crearla (solo se guarda el hash; la +comparación es en tiempo constante). + +## Alcances por endpoint + +| Endpoint | Alcance | +| --- | --- | +| `POST /skeen/api/v1/available_slots` | `citas.read` | +| `POST /skeen/api/v1/create_appointment` | `citas.write` | +| `POST /skeen/api/v1/cancel_appointment` | `citas.write` | +| `POST /skeen/api/v1/patient_balance` | `pacientes.read` | +| `POST /skeen/api/v1/wallet_redeem` | `monedero.write` | +| `GET/POST /skeen/api/v1/services` | `servicios.read` | + +Una clave sin el alcance recibe **HTTP 403** con mensaje claro. La clave legacy +no tiene restricción de alcance. + +Las respuestas son **JSON plano** (no JSON-RPC): códigos 200 correctos, +`401/403` para clave ausente/inválida o sin alcance. + +## Endpoints de gestión + +| Método | Ruta | Acceso | +| --- | --- | --- | +| GET | `/skeen/frontend/v1/api_keys` | permiso `api.claves` (admin) | +| POST | `/skeen/frontend/v1/api_keys` | permiso `api.claves` (admin) | +| PUT | `/skeen/frontend/v1/api_keys/` | permiso `api.claves` (admin) | +| POST | `/skeen/frontend/v1/api_keys//revocar` | permiso `api.claves` (admin) | + +## UI + +Configuración → pestaña **API** (admin): lista de claves (nombre, prefijo, +alcances, último uso, usos, estado), botón "nueva clave" con modal que muestra +la clave una vez y botón de copiar, editar y revocar; y documentación breve de +los endpoints y del formato de autenticación. diff --git a/docs/producto/licencias.md b/docs/producto/licencias.md new file mode 100644 index 0000000..ab11b81 --- /dev/null +++ b/docs/producto/licencias.md @@ -0,0 +1,118 @@ +# Licenciamiento por plan + +Producto comercial: cada instancia white-label puede llevar una **licencia +firmada** que decide qué módulos incluye y sus límites de uso. Sin licencia +aplicada (el default) el sistema se comporta **exactamente como hoy**. + +## Bloque `licencia` de clinic-settings + +```json +{ + "plan": "custom", + "modo": "aviso", + "cliente": "", + "vence": "", + "clave": "", + "modulos": [], + "limites": { + "usuarios_max": 0, + "sucursales_max": 0, + "citas_mes_max": 0, + "almacenamiento_mb": 0 + } +} +``` + +* `plan`: `basico | profesional | enterprise | custom`. +* `modo`: `aviso` (default; **solo advierte**) o `bloqueo` (aplica de verdad). +* `limites`: `0` = **sin límite** (default → nada cambia). +* `modulos`: lista de módulos incluidos; **vacío = todos**. +* `clave`: la clave firmada por el vendedor. Vacía = sin licencia aplicada. + +La licencia **nunca** bloquea lo indispensable: login, agenda, pacientes, POS, +inventario, reportes y configuración siempre están disponibles. Solo aplica a +los *módulos gateables* y a los límites indicados. + +## Tabla de planes + +| Plan | Módulos incluidos | +| --- | --- | +| `basico` | `agenda`, `pacientes`, `pos`, `inventario`, `reportes` (sin whatsapp, notificaciones, reservas, recursos, membresías, compras, comisiones, formularios) | +| `profesional` | Básico **+** `whatsapp`, `notificaciones`, `reservas_online`, `recursos` | +| `enterprise` | Todos los módulos (los 5 base + los 8 gateables) | +| `custom` | Lo que diga la clave (si no lista módulos, todos) | + +Módulos *gateables* (controlables por licencia): `whatsapp`, `notificaciones`, +`reservas_online`, `recursos`, `membresias`, `compras`, `comisiones`, +`formularios`. + +## Aplicación con `modo=bloqueo` + +* **Módulos no incluidos**: sus secciones desaparecen del menú, sus endpoints + responden **403** con un mensaje claro (`Módulo no incluido en tu plan`) y su + bloque `activo` de clinic-settings se considera apagado. +* **Límites**: se impide crear más allá de `usuarios_max`, `sucursales_max`, + `citas_mes_max` (citas del mes en curso) y `almacenamiento_mb` (suma de + adjuntos). El error indica el límite y cómo ampliarlo. +* Con `modo=aviso` nada se bloquea; los excesos solo quedan advertidos. + +Las tareas internas (cron, migraciones) pueden saltarse los límites con el +contexto `skeen_licencia_skip=True`. + +## Formato de la clave de licencia + +Texto en una línea: + +``` +SKN1.. +``` + +* `payload_json` = JSON con claves ordenadas y separadores mínimos: + `cliente`, `plan`, `vence`, `emitida`, `modulos`, `limites`. +* Firmado con **HMAC-SHA256** y la clave del vendedor. + +### Clave del vendedor + +Debe estar presente en la instancia para poder validar (HMAC es simétrico): + +1. `ir.config_parameter 'skeen.licencia.vendor_key'` +2. Variable de entorno `SKEEN_LICENCIA_VENDOR_KEY` +3. Archivo `/root/.skeen-licencias/vendor.key` (permisos `600`) + +> **Ruta del secreto:** `/root/.skeen-licencias/vendor.key` (fuera del repo, 600). +> Nota de seguridad: al ser HMAC simétrico, quien tenga la clave del vendedor +> puede forjar licencias; mantenerla fuera de las instancias de cliente en un +> despliegue real (aquí vive en un VPS de desarrollo compartido). + +### Herramienta del vendedor + +`/root/skeen-proyecto/infra/licencias/generar_licencia.py` (CLI, Python stdlib): + +```bash +generar_licencia.py generar --cliente "Spa Bella" --plan profesional --vence 2027-12-31 +generar_licencia.py generar --cliente "Spa Bella" --plan custom --vence 2027-12-31 \ + --modulos agenda,pacientes,pos,inventario,reportes,compras \ + --usuarios-max 10 --sucursales-max 2 --citas-mes-max 500 --almacenamiento-mb 4096 +generar_licencia.py verificar --clave "SKN1...." +``` + +## Endpoints + +| Método | Ruta | Acceso | Descripción | +| --- | --- | --- | --- | +| GET | `/skeen/frontend/v1/licencia` | admin/gerencia | Estado completo + uso vs límites | +| PUT | `/skeen/frontend/v1/licencia` | admin | Aplicar/actualizar/quitar licencia | +| POST | `/skeen/frontend/v1/licencia/validar` | admin/gerencia | Probar una clave sin aplicarla | + +`PUT` con `{"clave": "SKN1..."}` verifica firma y vigencia; si es inválida o +vencida responde **400** y **no sobrescribe** la licencia previa. `{"clave": ""}` +quita la licencia y vuelve a habilitarlo todo. `{"modo": "bloqueo"}` cambia el +modo sin re-firmar. + +## UI + +Configuración → pestaña **Licencia** (admin; gerencia también ve el estado): +plan y cliente, vencimiento con días restantes, campo para pegar la clave, +tabla de uso vs límites con semáforo, tabla de módulos incluidos/no incluidos, +avisos de vencimiento (≤ 15 días) y, si no hay licencia, el mensaje +*"sin licencia aplicada — todas las funciones habilitadas"*. diff --git a/docs/producto/webhooks.md b/docs/producto/webhooks.md new file mode 100644 index 0000000..77eb9a4 --- /dev/null +++ b/docs/producto/webhooks.md @@ -0,0 +1,112 @@ +# Webhooks salientes + +Notificación de eventos de la clínica hacia sistemas externos, con patrón +**outbox + cron + reintentos + bitácora**. Sin webhooks activos suscritos no se +crea ninguna fila ni se envía nada (cero impacto). + +## Modelos + +### `skeen.webhook` + +| Campo | Descripción | +| --- | --- | +| `name`, `url` | Nombre y URL destino | +| `eventos` | Claves separadas por coma; vacío = todos | +| `activo` | Habilitado | +| `secreto` | Con este secreto se firma cada entrega | +| `headers` | Cabeceras extra (JSON, opcional) | +| `notas`, `creado_por_login` | Metadatos | +| `ultima_entrega_at` | Última entrega correcta | +| `fallos_consecutivos` | Se desactiva tras N fallos (default 20, configurable en `skeen.webhooks.fallos_max`) | + +### `skeen.webhook.evento` (outbox) + +`webhook_id`, `evento`, `payload` (JSON), `estado` +(`pendiente|enviado|fallido|descartado`), `intentos`, `proximo_intento`, +`respuesta_codigo`, `respuesta_texto`, `enviado_at`, `clave_unica` +(idempotencia por evento+objeto). + +## Catálogo de eventos + +| Evento | Cuándo se emite | +| --- | --- | +| `cita.creada` | Al crear una cita | +| `cita.confirmada` | `state` → `confirmed` | +| `cita.cancelada` | `state` → `cancelled` | +| `cita.no_show` | `state` → `no_show` | +| `cita.completada` | `state` → `done` | +| `venta.pagada` | `state` → `paid` | +| `venta.cancelada` | `state` → `cancelled` o `refunded` | +| `pago.registrado` | Al crear un `skeen.pago` | +| `paciente.creado` | Al crear un `res.partner` con `is_patient` | +| `membresia.renovada` | `action_renovar` | +| `formulario.llenado` | `estado` → `llenado` | +| `compra.recibida` | `estado` → `recibida`/`recibida_parcial` | + +### Ejemplo de payload (`cita.creada`) + +```json +{ + "id": 123, + "reference": "CITA-0123", + "state": "pending", + "date": "2026-10-20", + "patient_id": 55, + "patient": "María López", + "service_id": 7, + "servicio": "Limpieza facial", + "doctor": "Dra. Pérez" +} +``` + +## Entrega + +Cron cada 5 minutos (`skeen.webhook.service._cron_tick`) que toma los pendientes +vencidos y hace `POST` JSON con: + +| Cabecera | Valor | +| --- | --- | +| `X-Skeen-Event` | Clave del evento | +| `X-Skeen-Delivery` | UUID de la entrega | +| `X-Skeen-Signature` | `sha256=` | + +* Timeouts cortos (5 s conexión / 15 s lectura). +* **Reintentos con backoff**: 1, 5, 30 min, 2 h, 6 h (tope 5 intentos); al + agotarlos el evento queda `fallido`. +* Tras N fallos consecutivos (default 20) el webhook se desactiva. + +### Verificar la firma (Python) + +```python +import hashlib, hmac +firma = request.headers['X-Skeen-Signature'] # 'sha256=...' +esperada = 'sha256=' + hmac.new( + SECRETO.encode(), request.body, hashlib.sha256).hexdigest() +assert hmac.compare_digest(firma, esperada) +``` + +### Verificar la firma (Node.js) + +```js +const crypto = require('crypto'); +const esperada = 'sha256=' + crypto + .createHmac('sha256', SECRETO) + .update(req.rawBody) + .digest('hex'); +``` + +## Endpoints + +| Método | Ruta | Acceso | +| --- | --- | --- | +| GET/POST | `/skeen/frontend/v1/webhooks` | permiso `webhooks.editar` (admin/gerencia) | +| PUT/DELETE | `/skeen/frontend/v1/webhooks/` | idem | +| GET | `/skeen/frontend/v1/webhooks//eventos` | idem (filtra por `estado`, `evento`, `limit`) | +| POST | `/skeen/frontend/v1/webhooks//probar` | idem (evento de prueba) | +| POST | `/skeen/frontend/v1/webhook_eventos//reintentar` | idem | + +## UI + +Configuración → pestaña **Webhooks** (admin/gerencia): CRUD, selector de +eventos, secreto (generado o propio), visor del payload de ejemplo, log de +entregas con estado/código/fecha, reintento manual y "probar". diff --git a/frontend/src/components/ApiKeysPanel.tsx b/frontend/src/components/ApiKeysPanel.tsx new file mode 100644 index 0000000..ea6db15 --- /dev/null +++ b/frontend/src/components/ApiKeysPanel.tsx @@ -0,0 +1,230 @@ +import type { FC } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { Copy, KeyRound, Plus, RefreshCw, ShieldOff, Pencil } from 'lucide-react'; +import { Button, Card, Input, Modal, TextArea, toast } from './ui'; +import { odooApi, type ApiKey } from '../services/odoo'; + +// Pestaña Configuración -> API (solo administración). + +const ESTADO_BADGE: Record = { + activa: 'bg-emerald-100 text-emerald-700', + vencida: 'bg-amber-100 text-amber-700', + revocada: 'bg-rose-100 text-rose-700', +}; + +const ENDPOINTS_DOC = [ + 'available_slots → citas.read', + 'create_appointment → citas.write', + 'cancel_appointment → citas.write', + 'patient_balance → pacientes.read', + 'wallet_redeem → monedero.write', + 'services → servicios.read', +]; + +const ApiKeysPanel: FC = () => { + const [keys, setKeys] = useState([]); + const [scopes, setScopes] = useState<{ clave: string; label: string }[]>([]); + const [, setCargando] = useState(true); + const [nuevaClave, setNuevaClave] = useState(null); + const [mostrarCrear, setMostrarCrear] = useState(false); + const [editando, setEditando] = useState(null); + + // Formulario de creación + const [nombre, setNombre] = useState(''); + const [alcances, setAlcances] = useState([]); + const [vence, setVence] = useState(''); + const [guardando, setGuardando] = useState(false); + + const cargar = useCallback(async () => { + try { + const res = await odooApi.listApiKeys(); + if (res.status === 'success') { setKeys(res.api_keys); setScopes(res.scopes); } + } catch { + toast.error('No se pudieron cargar las claves de API'); + } finally { + setCargando(false); + } + }, []); + + useEffect(() => { cargar(); }, [cargar]); + + const crear = async () => { + if (!nombre.trim()) { toast.error('Ponle un nombre a la clave'); return; } + setGuardando(true); + try { + const res = await odooApi.createApiKey({ name: nombre.trim(), scopes: alcances, vence }); + setNuevaClave(res.clave); + setMostrarCrear(false); + setNombre(''); setAlcances([]); setVence(''); + await cargar(); + } catch { + toast.error('No se pudo crear la clave'); + } finally { + setGuardando(false); + } + }; + + const revocar = async (k: ApiKey) => { + if (!window.confirm(`¿Revocar la clave "${k.name}"? Dejará de funcionar de inmediato.`)) return; + try { + await odooApi.revocarApiKey(k.id); + toast.success('Clave revocada'); + await cargar(); + } catch { + toast.error('No se pudo revocar'); + } + }; + + const guardarEdicion = async () => { + if (!editando) return; + try { + await odooApi.updateApiKey(editando.id, { + name: editando.name, scopes: editando.scopes, activo: editando.activo, vence: editando.vence || '', notas: editando.notas, + }); + toast.success('Clave actualizada'); + setEditando(null); + await cargar(); + } catch { + toast.error('No se pudo actualizar'); + } + }; + + const copiar = async (texto: string) => { + try { await navigator.clipboard.writeText(texto); toast.success('Copiado'); } + catch { toast.error('No se pudo copiar'); } + }; + + const toggleAlcance = (c: string) => setAlcances((prev) => prev.includes(c) ? prev.filter((x) => x !== c) : [...prev, c]); + + return ( +
+ + +
+

Claves de API

+
+ + +
+
+

+ Las claves de la API de terceros (/skeen/api/v1/*) se envían en + Authorization: Bearer <clave> o X-API-Key. La clave legacy + de Hermes sigue funcionando. +

+
+
+ + + +
+ + + + + + + + + + + + + + {keys.map((k) => ( + + + + + + + + + + ))} + {keys.length === 0 && ( + + )} + +
NombrePrefijoAlcancesÚltimo usoUsosEstadoAcciones
{k.name}{k.prefijo}…{k.scopes_label}{k.ultimo_uso || '—'}{k.usos}{k.estado} +
+ + {k.estado !== 'revocada' && ( + + )} +
+
No hay claves de API.
+
+
+
+ + + +

Alcances por endpoint

+
    + {ENDPOINTS_DOC.map((e) =>
  • {e}
  • )} +
+

Una clave sin alcances definidos tiene acceso a todos.

+
+
+ + setMostrarCrear(false)} title="Nueva clave de API" + footer={<>}> +
+ setNombre(e.target.value)} placeholder="Integración Hermes" /> + setVence(e.target.value)} /> +
+

Alcances (vacío = todos)

+
+ {scopes.map((s) => ( + + ))} +
+
+
+
+ + setNuevaClave(null)} title="Guarda tu clave ahora" + footer={}> +

Esta clave se muestra una sola vez. Cópiala y guárdala en un lugar seguro.

+
+ {nuevaClave} + +
+
+ + setEditando(null)} title="Editar clave" + footer={<>}> + {editando && ( +
+ setEditando({ ...editando, name: e.target.value })} /> + setEditando({ ...editando, vence: e.target.value })} /> + +
+

Alcances

+
+ {scopes.map((s) => ( + + ))} +
+
+