diff --git a/API.md b/API.md
index a9dd1f4..3232e43 100644
--- a/API.md
+++ b/API.md
@@ -119,6 +119,16 @@
- `GET /api/search?q=texto&projectId=1` — acotado a un proyecto: solo `{ activities, tasks }` de
ESE proyecto (sin el dato del proyecto, ya es el contexto).
+### Actualizaciones (`/api/updater`) — solo Superadministrador
+- `GET /api/updater/status` — compara la `VERSION` local contra `CHANGELOG.md` de `main` en
+ Gitea (leído de forma anónima por HTTPS, sin token — requiere repo público y vista anónima
+ habilitada en el servidor de Gitea). `{ currentVersion, upToDate, latestVersion,
+ overallCriticality?, hasMigrations, pending: [{ version, date, criticality, summary }] }`.
+ `criticality` se deriva del dígito de versión que cambió (X.Y.Z: 1ro=alta, 2do=media,
+ 3ro=baja), nunca se etiqueta a mano. `hasMigrations` compara los archivos de
+ `backend/database/migrations/` locales contra los de `main`. Es solo informativo — no aplica
+ ningún cambio, la actualización real sigue siendo manual.
+
### Dashboard (`/api/dashboard`)
- `GET /api/dashboard/summary` — KPIs (activos, atrasados, pendientes/vencidas/hoy, avance promedio)
- `GET /api/dashboard/calendar?month=2026-09` — actividades por fecha límite
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c174391..aceb4b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog - project.linkedcorp.net
+## [1.19.0] - 2026-09-16
+### Added
+- Panel "Actualizaciones" en Configuración (solo superadmin): compara la versión instalada contra
+ el `CHANGELOG.md` de `main` en Gitea y muestra cada versión pendiente con su criticidad (deriva
+ del dígito que cambió — 1ro=Alta, 2do=Media, 3ro=Baja, sin etiquetar nada a mano), una
+ descripción breve, y si trae cambios de base de datos (comparando los archivos de
+ `backend/database/migrations/` locales contra los de `main`). Banner en rojo, siempre visible
+ si hay algo pendiente, avisando que la actualización requiere reiniciar el sistema. No instala
+ nada automáticamente — solo informa; aplicar la actualización sigue siendo manual (SSH), a
+ criterio del administrador de cada instalación.
+- Nuevo endpoint `GET /api/updater/status` (solo superadmin). Lee el repo de Gitea de forma
+ anónima por HTTPS — no requiere ningún token ni credencial en el server instalado, solo que el
+ repo sea público y el servidor de Gitea permita vista anónima.
+
## [1.18.0] - 2026-09-16
### Added
- Buscador de Proyectos ahora también busca en actividades y tareas de cualquier proyecto, no
diff --git a/DEPLOY_WEB.md b/DEPLOY_WEB.md
index b94b969..7ff6dee 100644
--- a/DEPLOY_WEB.md
+++ b/DEPLOY_WEB.md
@@ -26,6 +26,12 @@ hace falta `create-admin.js` en instalaciones nuevas.
Las secciones de abajo son la referencia manual (lo que hace el script paso a paso) — útiles para
entender el porqué de cada cosa o para resolver un problema puntual sin volver a correr todo.
+**Requisito para el panel "Actualizaciones"** (Configuración → superadmin): el servidor instalado
+necesita poder llegar por HTTPS a `gitea.linkedcorp.net` de forma anónima (repo público + vista
+anónima habilitada en Gitea) para poder consultar el `CHANGELOG.md` y las migraciones nuevas. No
+requiere ningún token ni credencial. Si no hay conectividad a Gitea, ese panel simplemente muestra
+el error al buscar actualizaciones — el resto de la app funciona igual.
+
---
## Primer despliegue en producción (ya ejecutado — referencia)
diff --git a/VERSION b/VERSION
index 84cc529..815d5ca 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.18.0
+1.19.0
diff --git a/backend/package.json b/backend/package.json
index 3443bd1..b20a7b5 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
- "version": "1.18.0",
+ "version": "1.19.0",
"private": true,
"type": "module",
"main": "src/server.js",
diff --git a/backend/src/app.js b/backend/src/app.js
index c24e084..f7f3b17 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -20,6 +20,7 @@ import settingsRoutes from './modules/settings/routes.js';
import companyRoutes from './modules/company/routes.js';
import recurringTasksRoutes from './modules/recurring-tasks/routes.js';
import searchRoutes from './modules/search/routes.js';
+import updaterRoutes from './modules/updater/routes.js';
export const app = express();
@@ -44,6 +45,7 @@ app.use('/api/settings', settingsRoutes);
app.use('/api/company', companyRoutes);
app.use('/api/recurring-tasks', recurringTasksRoutes);
app.use('/api/search', searchRoutes);
+app.use('/api/updater', updaterRoutes);
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
diff --git a/backend/src/modules/updater/controller.js b/backend/src/modules/updater/controller.js
new file mode 100644
index 0000000..9cbbf75
--- /dev/null
+++ b/backend/src/modules/updater/controller.js
@@ -0,0 +1,6 @@
+import { ok, asyncHandler } from '../../utils/http.js';
+import * as service from './service.js';
+
+export const getUpdateStatus = asyncHandler(async (req, res) => {
+ ok(res, await service.checkForUpdates());
+});
diff --git a/backend/src/modules/updater/repository.js b/backend/src/modules/updater/repository.js
new file mode 100644
index 0000000..20e0c39
--- /dev/null
+++ b/backend/src/modules/updater/repository.js
@@ -0,0 +1,26 @@
+// Lee el repo en Gitea de forma anonima (repo publico + vista anonima habilitada en el servidor
+// de Gitea) — el servidor de un cliente instalado en otro lado NO necesita ningun token ni
+// credencial para esto, solo poder llegar por HTTPS a Gitea.
+const GITEA_BASE = process.env.GITEA_BASE_URL || 'http://gitea.linkedcorp.net';
+const GITEA_OWNER = process.env.GITEA_OWNER || 'mgonzalez';
+const GITEA_REPO = process.env.GITEA_REPO || 'project.linkedcorp.net';
+const BRANCH = 'main';
+
+async function getRaw(path) {
+ const url = `${GITEA_BASE}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/raw/${path}?ref=${BRANCH}`;
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`Gitea respondio ${res.status} al leer ${path}`);
+ return res.text();
+}
+
+export async function fetchChangelog() {
+ return getRaw('CHANGELOG.md');
+}
+
+export async function fetchRemoteMigrationNames() {
+ const url = `${GITEA_BASE}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/contents/backend/database/migrations?ref=${BRANCH}`;
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`Gitea respondio ${res.status} al listar migraciones`);
+ const entries = await res.json();
+ return entries.filter((e) => e.type === 'file').map((e) => e.name);
+}
diff --git a/backend/src/modules/updater/routes.js b/backend/src/modules/updater/routes.js
new file mode 100644
index 0000000..af959b2
--- /dev/null
+++ b/backend/src/modules/updater/routes.js
@@ -0,0 +1,10 @@
+import { Router } from 'express';
+import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js';
+import { getUpdateStatus } from './controller.js';
+
+const router = Router();
+
+router.use(requireAuth, requireRole(...CAN_MANAGE_SETTINGS));
+router.get('/status', getUpdateStatus);
+
+export default router;
diff --git a/backend/src/modules/updater/service.js b/backend/src/modules/updater/service.js
new file mode 100644
index 0000000..d769794
--- /dev/null
+++ b/backend/src/modules/updater/service.js
@@ -0,0 +1,102 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import * as repo from './repository.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const VERSION_FILE = path.resolve(__dirname, '../../../../VERSION');
+const MIGRATIONS_DIR = path.resolve(__dirname, '../../../database/migrations');
+
+function parseVersion(v) {
+ const [major, minor, patch] = v.trim().split('.').map(Number);
+ return { major, minor, patch };
+}
+
+function compareVersions(a, b) {
+ const va = parseVersion(a);
+ const vb = parseVersion(b);
+ if (va.major !== vb.major) return va.major - vb.major;
+ if (va.minor !== vb.minor) return va.minor - vb.minor;
+ return va.patch - vb.patch;
+}
+
+// Regla del proyecto (VERSIONADO.md): cambia el 1er digito = alta, 2do = media, 3er = baja.
+// Sin etiquetar nada a mano — se deriva solo de que numero cambio entre las dos versiones.
+function classifyBump(fromVersion, toVersion) {
+ const a = parseVersion(fromVersion);
+ const b = parseVersion(toVersion);
+ if (b.major !== a.major) return 'alta';
+ if (b.minor !== a.minor) return 'media';
+ return 'baja';
+}
+
+// Se parte el texto en la propia delimitacion "## [" (en vez de una sola regex con lookahead de
+// fin-de-texto): con la bandera `m`, `$` matchea fin de CUALQUIER linea, asi que un lookahead
+// `\s*$` para "fin del changelog completo" en realidad cortaba en la primera linea en blanco
+// (el cuerpo quedaba vacio siempre). Partiendo por bloque no hay esa ambiguedad.
+function parseChangelog(text) {
+ const entries = [];
+ const blocks = text.split(/\n(?=## \[)/);
+ for (const block of blocks) {
+ const match = block.match(/^## \[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})\s*\n([\s\S]*)$/);
+ if (match) entries.push({ version: match[1], date: match[2], body: match[3].trim() });
+ }
+ return entries;
+}
+
+// Resumen corto: saca los encabezados "### Algo" y las vinietas "- ", deja solo el texto, truncado.
+function summarize(body, maxLength = 260) {
+ const lines = body
+ .split('\n')
+ .map((l) => l.trim().replace(/^-\s+/, ''))
+ .filter((l) => l && !l.startsWith('###'));
+ const joined = lines.join(' ').replace(/\s+/g, ' ').trim();
+ return joined.length > maxLength ? `${joined.slice(0, maxLength).trim()}…` : joined;
+}
+
+function getLocalVersion() {
+ return fs.readFileSync(VERSION_FILE, 'utf8').trim();
+}
+
+function getLocalMigrationNames() {
+ return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'));
+}
+
+export async function checkForUpdates() {
+ const currentVersion = getLocalVersion();
+ const changelogText = await repo.fetchChangelog();
+ const allEntries = parseChangelog(changelogText);
+
+ const pending = allEntries
+ .filter((e) => compareVersions(e.version, currentVersion) > 0)
+ .sort((a, b) => compareVersions(a.version, b.version));
+
+ if (pending.length === 0) {
+ return { currentVersion, upToDate: true, latestVersion: currentVersion, pending: [], hasMigrations: false };
+ }
+
+ let previousInChain = currentVersion;
+ const pendingWithMeta = pending.map((e) => {
+ const criticality = classifyBump(previousInChain, e.version);
+ previousInChain = e.version;
+ return { version: e.version, date: e.date, criticality, summary: summarize(e.body) };
+ });
+
+ const localMigrations = new Set(getLocalMigrationNames());
+ const remoteMigrations = await repo.fetchRemoteMigrationNames();
+ const hasMigrations = remoteMigrations.some((name) => !localMigrations.has(name));
+
+ const latestVersion = pending[pending.length - 1].version;
+ const overallCriticality = pendingWithMeta.some((e) => e.criticality === 'alta')
+ ? 'alta'
+ : pendingWithMeta.some((e) => e.criticality === 'media') ? 'media' : 'baja';
+
+ return {
+ currentVersion,
+ upToDate: false,
+ latestVersion,
+ overallCriticality,
+ hasMigrations,
+ pending: pendingWithMeta,
+ };
+}
diff --git a/frontend/package.json b/frontend/package.json
index c42377b..51339c3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
- "version": "1.18.0",
+ "version": "1.19.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js
index 83f803b..ce633dc 100644
--- a/frontend/src/i18n/messages.js
+++ b/frontend/src/i18n/messages.js
@@ -70,6 +70,18 @@ export const messages = {
smtp_test_send: 'Enviar prueba',
smtp_test_sending: 'Enviando...',
smtp_test_success: 'Correo de prueba enviado correctamente a',
+
+ config_updater: 'Actualizaciones',
+ updater_check: 'Buscar actualizaciones',
+ updater_current_version: 'Versión instalada:',
+ updater_up_to_date: 'Estás en la última versión disponible.',
+ updater_available: 'Actualización disponible',
+ updater_restart_required: 'Esta actualización requiere reiniciar el sistema — coordina con tu administrador antes de aplicarla.',
+ updater_has_migrations: 'Incluye cambios en la base de datos (migraciones nuevas).',
+ updater_criticality_alta: 'Alta',
+ updater_criticality_media: 'Media',
+ updater_criticality_baja: 'Baja',
+ updater_rollback_hint: 'Antes de actualizar, respalda la base de datos. Si algo sale mal, restaura ese respaldo y volvé al commit anterior a esta actualización en el historial de Gitea.',
password_show: 'Mostrar clave',
password_hide: 'Ocultar clave',
common_new: 'Nuevo',
@@ -318,6 +330,18 @@ export const messages = {
smtp_test_send: 'Send test',
smtp_test_sending: 'Sending...',
smtp_test_success: 'Test email sent successfully to',
+
+ config_updater: 'Updates',
+ updater_check: 'Check for updates',
+ updater_current_version: 'Installed version:',
+ updater_up_to_date: 'You are on the latest available version.',
+ updater_available: 'Update available',
+ updater_restart_required: 'This update requires restarting the system — coordinate with your administrator before applying it.',
+ updater_has_migrations: 'Includes database changes (new migrations).',
+ updater_criticality_alta: 'High',
+ updater_criticality_media: 'Medium',
+ updater_criticality_baja: 'Low',
+ updater_rollback_hint: 'Back up the database before updating. If something goes wrong, restore that backup and roll back to the commit before this update in the Gitea history.',
password_show: 'Show password',
password_hide: 'Hide password',
common_new: 'New',
diff --git a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx
index dbed569..b966f59 100644
--- a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx
+++ b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx
@@ -1,5 +1,5 @@
import { Navigate } from 'react-router-dom';
-import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2 } from 'lucide-react';
+import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { Expander } from '../../components/Expander.jsx';
@@ -8,6 +8,7 @@ import { ComboBox } from '../../components/ComboBox.jsx';
import { CatalogEditor } from './CatalogEditor.jsx';
import SmtpSettingsPanel from './SmtpSettingsPanel.jsx';
import CompanySettingsPanel from './CompanySettingsPanel.jsx';
+import UpdaterPanel from './UpdaterPanel.jsx';
export default function ConfiguracionPage() {
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
@@ -101,6 +102,10 @@ export default function ConfiguracionPage() {