diff --git a/API.md b/API.md index 5954173..89db016 100644 --- a/API.md +++ b/API.md @@ -128,6 +128,15 @@ guardada). Envía un correo real con los valores del formulario (aunque no se hayan guardado) y responde `{ sent: true }` o `400` con el error real del servidor SMTP. Rate-limited (10/15min). +### Marca de la empresa (`/api/company`) +- `GET /api/company` — público (sin auth, lo usan login/registro/recuperación). `{ name, hasLogo, + logoVersion }`; `name` por defecto "LinkedCorp" si nunca se configuró. +- `GET /api/company/logo` — público. Devuelve el archivo de imagen del logo, o `404` si no hay + ninguno configurado (el cliente cae al logo estático embebido). +- `PUT /api/company` — solo Superadministrador, multipart: `name?` (texto) y `logo?` (archivo, + PNG/JPG/WEBP/SVG, máx 3MB). Al subir un logo nuevo se borra el archivo anterior (nunca quedan + huérfanos). Guardado en `system_settings` (mismo patrón que SMTP), sin tabla nueva. + ## Errores esperados - `400` validación diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 59d67ac..345676c 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -80,6 +80,11 @@ proceso del backend (`setInterval` cada 15 min, sin cron del SO ni cola nueva). - `system_settings.value` para `smtp_pass_enc` va **cifrado** (AES-256-GCM, `ENCRYPTION_KEY` del `.env`) — nunca en texto plano, nunca se devuelve por la API (solo `configured: true/false`). +- `system_settings`: `company_name`/`company_logo_filename`/`company_logo_mime` — marca configurable + (nombre y logo de la empresa, `GET /api/company` público, `PUT` solo superadmin), mismo patrón + clave/valor que SMTP, sin tabla nueva. El archivo del logo vive en `STORAGE_ROOT/branding/`, un + solo archivo a la vez (se borra el anterior al subir uno nuevo). Sin valor guardado = "LinkedCorp" + y logo estático embebido (comportamiento por defecto, no rompe instalaciones existentes). - `activities.sort_order`/`tasks.sort_order`: orden manual (arrastrar con el mouse en la UI). `0` = nunca reordenado a mano (la migración `012` lo inicializa con el `id` para preservar el orden previo); el endpoint de reordenar siempre escribe posiciones `1..N`, nunca `0`, para que esa diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a0171c..9598619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog - project.linkedcorp.net +## [1.15.0] - 2026-09-15 +### Added +- Marca de la empresa configurable: nombre y logo editables desde Configuración → Empresa (solo + superadmin), guardados en `system_settings` (mismo patrón que SMTP, sin tabla nueva). Nuevo + endpoint público `GET /api/company` (+ `/logo`) para que login/registro/recuperación de clave + puedan mostrar la marca antes de iniciar sesión. Sin configurar, se ve "LinkedCorp" y el logo + estático de siempre — no rompe instalaciones existentes. +- **Solo desplegado en desarrollo por pedido explícito del usuario** (no se tocó producción esta + vez) — queda commiteado para poder retroceder/avanzar por git antes de decidir el despliegue. + ## [1.14.1] - 2026-09-15 ### Fixed - El pie del menú lateral mostraba siempre el texto fijo "v0.1.0" en vez de la versión real de la diff --git a/VERSION b/VERSION index 63e799c..141f2e8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.14.1 +1.15.0 diff --git a/backend/package.json b/backend/package.json index a4b6078..d0677fc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.14.1", + "version": "1.15.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index 7bb2d97..e8c2b4d 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -17,6 +17,7 @@ import notificationsRoutes from './modules/notifications/routes.js'; import dashboardRoutes from './modules/dashboard/routes.js'; import auditRoutes from './modules/audit/routes.js'; import settingsRoutes from './modules/settings/routes.js'; +import companyRoutes from './modules/company/routes.js'; import recurringTasksRoutes from './modules/recurring-tasks/routes.js'; export const app = express(); @@ -39,6 +40,7 @@ app.use('/api/notifications', notificationsRoutes); app.use('/api/dashboard', dashboardRoutes); app.use('/api/audit', auditRoutes); app.use('/api/settings', settingsRoutes); +app.use('/api/company', companyRoutes); app.use('/api/recurring-tasks', recurringTasksRoutes); // 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 diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index feb1126..fc820ce 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -57,4 +57,5 @@ export const ROLES = { export const CAN_MANAGE_CATALOGS = [ROLES.SUPERADMIN]; export const CAN_MANAGE_USERS = [ROLES.SUPERADMIN, ROLES.ADMIN]; export const CAN_MANAGE_SMTP = [ROLES.SUPERADMIN]; +export const CAN_MANAGE_SETTINGS = [ROLES.SUPERADMIN]; export const CAN_WRITE = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD, ROLES.COLLABORATOR]; diff --git a/backend/src/middleware/uploadLogo.js b/backend/src/middleware/uploadLogo.js new file mode 100644 index 0000000..3258490 --- /dev/null +++ b/backend/src/middleware/uploadLogo.js @@ -0,0 +1,31 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import multer from 'multer'; +import { env } from '../config/env.js'; + +const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']); + +export const logoDir = path.join(env.storageRoot, 'branding'); +fs.mkdirSync(logoDir, { recursive: true }); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, logoDir), + // Nombre generado por el servidor (evita colisiones/path traversal) y sirve de version para + // el cache-busting del logo publico (cambia en cada subida). + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).slice(0, 10); + cb(null, `logo-${Date.now()}-${crypto.randomBytes(6).toString('hex')}${ext}`); + }, +}); + +export const uploadLogo = multer({ + storage, + limits: { fileSize: 3 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (!ALLOWED_MIME.has(file.mimetype)) { + return cb(new Error('Tipo de archivo no permitido (usa PNG, JPG, WEBP o SVG)')); + } + cb(null, true); + }, +}); diff --git a/backend/src/modules/company/controller.js b/backend/src/modules/company/controller.js new file mode 100644 index 0000000..460965f --- /dev/null +++ b/backend/src/modules/company/controller.js @@ -0,0 +1,17 @@ +import path from 'node:path'; +import { ok, asyncHandler, HttpError } from '../../utils/http.js'; +import * as service from './service.js'; + +// Publico (sin auth): lo necesitan las paginas de login/registro/recuperacion antes de iniciar sesion. +export const getCompany = asyncHandler(async (req, res) => ok(res, await service.getCompanyInfo())); + +export const getCompanyLogo = asyncHandler(async (req, res) => { + const file = await service.getCompanyLogoFile(); + if (!file) throw new HttpError(404, 'No hay logo configurado'); + // sendFile exige ruta absoluta (a diferencia de res.download, que la resuelve sola). + res.type(file.mime).sendFile(path.resolve(file.filePath)); +}); + +export const putCompany = asyncHandler(async (req, res) => { + ok(res, await service.updateCompanyInfo({ name: req.body.name, file: req.file }, req.currentUser.id)); +}); diff --git a/backend/src/modules/company/routes.js b/backend/src/modules/company/routes.js new file mode 100644 index 0000000..5b1faa9 --- /dev/null +++ b/backend/src/modules/company/routes.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { requireAuth, requireRole, CAN_MANAGE_SETTINGS } from '../../middleware/auth.js'; +import { uploadLogo } from '../../middleware/uploadLogo.js'; +import { getCompany, getCompanyLogo, putCompany } from './controller.js'; + +const router = Router(); + +// GET publico (sin requireAuth): las paginas de login/registro/recuperacion lo necesitan antes +// de tener sesion. Solo la escritura exige superadmin. +router.get('/', getCompany); +router.get('/logo', getCompanyLogo); +router.put('/', requireAuth, requireRole(...CAN_MANAGE_SETTINGS), uploadLogo.single('logo'), putCompany); + +export default router; diff --git a/backend/src/modules/company/service.js b/backend/src/modules/company/service.js new file mode 100644 index 0000000..c795f76 --- /dev/null +++ b/backend/src/modules/company/service.js @@ -0,0 +1,48 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as settingsRepo from '../settings/repository.js'; +import { logoDir } from '../../middleware/uploadLogo.js'; +import { logAudit } from '../../utils/audit.js'; +import { HttpError } from '../../utils/http.js'; + +const COMPANY_KEYS = ['company_name', 'company_logo_filename', 'company_logo_mime']; +const DEFAULT_NAME = 'LinkedCorp'; + +export async function getCompanyInfo() { + const values = await settingsRepo.getAll(COMPANY_KEYS); + return { + name: values.company_name || DEFAULT_NAME, + hasLogo: !!values.company_logo_filename, + // El propio nombre de archivo (unico por subida) sirve de version para cache-busting del logo. + logoVersion: values.company_logo_filename || null, + }; +} + +export async function getCompanyLogoFile() { + const values = await settingsRepo.getAll(['company_logo_filename', 'company_logo_mime']); + if (!values.company_logo_filename) return null; + const filePath = path.join(logoDir, values.company_logo_filename); + if (!fs.existsSync(filePath)) return null; + return { filePath, mime: values.company_logo_mime || 'application/octet-stream' }; +} + +export async function updateCompanyInfo({ name, file }, actorId) { + const entries = {}; + if (name !== undefined) { + const trimmed = (name || '').trim(); + if (!trimmed) throw new HttpError(400, 'El nombre de la empresa es obligatorio'); + entries.company_name = trimmed; + } + if (file) { + const values = await settingsRepo.getAll(['company_logo_filename']); + if (values.company_logo_filename) { + const oldPath = path.join(logoDir, values.company_logo_filename); + if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); + } + entries.company_logo_filename = file.filename; + entries.company_logo_mime = file.mimetype; + } + if (Object.keys(entries).length) await settingsRepo.setMany(entries); + await logAudit({ userId: actorId, action: 'update', entityType: 'settings:company', entityId: null }); + return getCompanyInfo(); +} diff --git a/frontend/package.json b/frontend/package.json index bdacf67..c019800 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.14.1", + "version": "1.15.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index d840dfb..8ddbaed 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -1,6 +1,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { AuthProvider } from '../context/auth-context.jsx'; import { UiProvider } from '../context/ui-context.jsx'; +import { BrandingProvider } from '../context/branding-context.jsx'; import { ContentDialogHost } from '../components/ContentDialog.jsx'; import { ToastHost } from '../components/ToastHost.jsx'; import { ProtectedRoute } from './ProtectedRoute.jsx'; @@ -20,29 +21,31 @@ import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx'; export default function App() { return ( - - - - - } /> - } /> - } /> - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - - - + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + + ); } diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx index 54caf3d..5eca37c 100644 --- a/frontend/src/components/NavigationView.jsx +++ b/frontend/src/components/NavigationView.jsx @@ -2,6 +2,7 @@ import { NavLink } from 'react-router-dom'; import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat } from 'lucide-react'; import { useUi } from '../context/ui-context.jsx'; import { useAuth } from '../context/auth-context.jsx'; +import { useBranding } from '../context/branding-context.jsx'; import logoDark from '../assets/logoLinkedCorpTxt.png'; import logoLight from '../assets/logoLinkedCorpTxtBlack.png'; import logoIcon from '../assets/logoLinked1.png'; @@ -18,8 +19,10 @@ const ITEMS = [ export function NavigationView({ mode }) { const { t, theme } = useUi(); const { user } = useAuth(); + const branding = useBranding(); const compact = mode === 'compact'; const items = ITEMS.filter((item) => !item.superadminOnly || user?.roleCode === 'superadmin'); + const brandName = branding.name || t('appName'); return (