feat: nombre y logo de empresa configurables (solo dev, no desplegado a prod)
Nueva marca configurable desde Configuracion -> Empresa (solo superadmin): nombre y logo, guardados en system_settings (mismo patron que SMTP, sin tabla nueva). Nuevo endpoint publico GET /api/company (+ /logo) para que login/registro/recuperacion de clave puedan mostrar la marca antes de iniciar sesion. Sin configurar, se ve "LinkedCorp" y el logo estatico de siempre. Probado en dev (build + Playwright: panel de Configuracion, sidebar expandido/compacto, login publico), sin desplegar a produccion por pedido explicito del usuario -- este commit sirve de punto de control en git antes de decidir el despliegue. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "1.14.1",
|
||||
"version": "1.15.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/server.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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "1.14.1",
|
||||
"version": "1.15.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+27
-24
@@ -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 (
|
||||
<UiProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/registro" element={<RegisterPage />} />
|
||||
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
||||
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
||||
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="proyectos" element={<ProyectosPage />} />
|
||||
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
|
||||
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
|
||||
<Route path="organizacion" element={<OrganizacionPage />} />
|
||||
<Route path="configuracion" element={<ConfiguracionPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<ContentDialogHost />
|
||||
<ToastHost />
|
||||
</AuthProvider>
|
||||
</UiProvider>
|
||||
<BrandingProvider>
|
||||
<UiProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/registro" element={<RegisterPage />} />
|
||||
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
||||
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
||||
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||
<Route path="/aceptar-invitacion" element={<AcceptInvitePage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="proyectos" element={<ProyectosPage />} />
|
||||
<Route path="proyectos/:id" element={<ProyectoDetallePage />} />
|
||||
<Route path="tareas-recurrentes" element={<RecurringTasksPage />} />
|
||||
<Route path="organizacion" element={<OrganizacionPage />} />
|
||||
<Route path="configuracion" element={<ConfiguracionPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<ContentDialogHost />
|
||||
<ToastHost />
|
||||
</AuthProvider>
|
||||
</UiProvider>
|
||||
</BrandingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<nav className={`nav-view ${mode}`}>
|
||||
<div className="nav-items">
|
||||
@@ -32,10 +35,10 @@ export function NavigationView({ mode }) {
|
||||
</div>
|
||||
<div className="nav-footer">
|
||||
{compact ? (
|
||||
<img src={logoIcon} alt={t('appName')} className="nav-logo-icon-footer" />
|
||||
<img src={branding.logoUrl || logoIcon} alt={brandName} className="nav-logo-icon-footer" />
|
||||
) : (
|
||||
<>
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="nav-logo-full-footer" />
|
||||
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={brandName} className="nav-logo-full-footer" />
|
||||
<span className="nav-version">v{pkg.version}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { getCompanyInfo, companyLogoUrl } from '../services/company.js';
|
||||
|
||||
const DEFAULT_BRANDING = { name: 'LinkedCorp', hasLogo: false, logoVersion: null };
|
||||
|
||||
const BrandingContext = createContext({ ...DEFAULT_BRANDING, logoUrl: null, reload: () => {} });
|
||||
|
||||
export function BrandingProvider({ children }) {
|
||||
const [company, setCompany] = useState(DEFAULT_BRANDING);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setCompany(await getCompanyInfo());
|
||||
} catch {
|
||||
// Publico y de solo lectura: si falla (backend caido, etc.) se sigue con el nombre/logo
|
||||
// por defecto en vez de romper login/registro.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// El nombre de archivo (unico por subida) sirve de cache-buster para que el logo se refresque
|
||||
// de inmediato tras cambiarlo, sin depender de que el navegador invalide su cache.
|
||||
const logoUrl = company.hasLogo ? `${companyLogoUrl()}?v=${encodeURIComponent(company.logoVersion)}` : null;
|
||||
|
||||
return (
|
||||
<BrandingContext.Provider value={{ ...company, logoUrl, reload }}>
|
||||
{children}
|
||||
</BrandingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBranding() {
|
||||
return useContext(BrandingContext);
|
||||
}
|
||||
@@ -48,6 +48,13 @@ export const messages = {
|
||||
user_approve_title: 'Aprobar usuario',
|
||||
user_reject_confirm_msg: '¿Seguro que deseas rechazar esta solicitud de registro? Se eliminara permanentemente.',
|
||||
|
||||
config_company: 'Empresa',
|
||||
company_name: 'Nombre de la empresa',
|
||||
company_logo: 'Logo',
|
||||
company_logo_hint: 'PNG, JPG, WEBP o SVG. Se usa en el menu lateral y en las pantallas de acceso.',
|
||||
company_logo_none: 'Sin logo',
|
||||
company_logo_choose: 'Elegir imagen',
|
||||
|
||||
config_smtp: 'Correo (SMTP)',
|
||||
config_smtp_superadmin_only: 'Solo el superadministrador puede configurar el correo del sistema.',
|
||||
smtp_host: 'Servidor (host)',
|
||||
@@ -282,6 +289,13 @@ export const messages = {
|
||||
user_approve_title: 'Approve user',
|
||||
user_reject_confirm_msg: 'Are you sure you want to reject this registration request? It will be permanently deleted.',
|
||||
|
||||
config_company: 'Company',
|
||||
company_name: 'Company name',
|
||||
company_logo: 'Logo',
|
||||
company_logo_hint: 'PNG, JPG, WEBP or SVG. Used in the sidebar and on the sign-in screens.',
|
||||
company_logo_none: 'No logo',
|
||||
company_logo_choose: 'Choose image',
|
||||
|
||||
config_smtp: 'Email (SMTP)',
|
||||
config_smtp_superadmin_only: 'Only the superadmin can configure the system email.',
|
||||
smtp_host: 'Host',
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Save, Upload } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useBranding } from '../../context/branding-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { getCompanyInfo, updateCompanyInfo } from '../../services/company.js';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
|
||||
export default function CompanySettingsPanel() {
|
||||
const { t, pushToast } = useUi();
|
||||
const branding = useBranding();
|
||||
const runAction = useAction();
|
||||
const { data: company, reload } = useAsync(getCompanyInfo, []);
|
||||
const [name, setName] = useState('');
|
||||
const [logoFile, setLogoFile] = useState(null);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (company) setName(company.name);
|
||||
}, [company]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logoFile) { setPreview(null); return; }
|
||||
const url = URL.createObjectURL(logoFile);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [logoFile]);
|
||||
|
||||
async function save() {
|
||||
await runAction(async () => {
|
||||
await updateCompanyInfo({ name, logoFile });
|
||||
setLogoFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
await reload();
|
||||
await branding.reload();
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
});
|
||||
}
|
||||
|
||||
if (!company) return <div className="text-muted">{t('common_loading')}</div>;
|
||||
|
||||
const currentLogoSrc = preview || branding.logoUrl;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Field label={t('company_name')}>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="LinkedCorp" />
|
||||
</Field>
|
||||
<Field label={t('company_logo')} hint={t('company_logo_hint')}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div className="company-logo-preview">
|
||||
{currentLogoSrc ? <img src={currentLogoSrc} alt={name} /> : <span className="text-muted" style={{ fontSize: '.7rem' }}>{t('company_logo_none')}</span>}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => setLogoFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<Button variant="standard" icon={Upload} onClick={() => fileInputRef.current?.click()}>
|
||||
{t('company_logo_choose')}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail } from 'lucide-react';
|
||||
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2 } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { Expander } from '../../components/Expander.jsx';
|
||||
@@ -7,6 +7,7 @@ import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { CatalogEditor } from './CatalogEditor.jsx';
|
||||
import SmtpSettingsPanel from './SmtpSettingsPanel.jsx';
|
||||
import CompanySettingsPanel from './CompanySettingsPanel.jsx';
|
||||
|
||||
export default function ConfiguracionPage() {
|
||||
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
||||
@@ -20,7 +21,11 @@ export default function ConfiguracionPage() {
|
||||
<div>
|
||||
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('config_title')}</h2>
|
||||
|
||||
<Expander icon={Palette} title={t('config_appearance')} defaultOpen>
|
||||
<Expander icon={Building2} title={t('config_company')} defaultOpen>
|
||||
<CompanySettingsPanel />
|
||||
</Expander>
|
||||
|
||||
<Expander icon={Palette} title={t('config_appearance')}>
|
||||
<FieldRow>
|
||||
<Field label="Tema / Theme">
|
||||
<ComboBox
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useBranding } from '../../context/branding-context.jsx';
|
||||
import { getInviteInfo, acceptInvite } from '../../services/auth.js';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
@@ -13,6 +14,7 @@ import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
export default function AcceptInvitePage() {
|
||||
const { setSession } = useAuth();
|
||||
const { t, theme } = useUi();
|
||||
const branding = useBranding();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get('token') || '';
|
||||
@@ -56,7 +58,7 @@ export default function AcceptInvitePage() {
|
||||
<div className="login-shell">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('accept_invite_title')}</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Send } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useBranding } from '../../context/branding-context.jsx';
|
||||
import { forgotPassword } from '../../services/auth.js';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
@@ -10,6 +11,7 @@ import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t, theme } = useUi();
|
||||
const branding = useBranding();
|
||||
const [email, setEmail] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -29,7 +31,7 @@ export default function ForgotPasswordPage() {
|
||||
<div className="login-shell">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('forgot_password_title')}</div>
|
||||
{sent ? (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useBranding } from '../../context/branding-context.jsx';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||
@@ -12,6 +13,7 @@ import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const { t, theme } = useUi();
|
||||
const branding = useBranding();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -36,7 +38,7 @@ export default function LoginPage() {
|
||||
<div className="login-shell">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('login_title')}</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
import { useAuth } from '../../context/auth-context.jsx';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useBranding } from '../../context/branding-context.jsx';
|
||||
import { register } from '../../services/auth.js';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
@@ -13,6 +14,7 @@ import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
export default function RegisterPage() {
|
||||
const { setSession } = useAuth();
|
||||
const { t, theme } = useUi();
|
||||
const branding = useBranding();
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -48,7 +50,7 @@ export default function RegisterPage() {
|
||||
<div className="login-shell">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('register_title')}</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useBranding } from '../../context/branding-context.jsx';
|
||||
import { resetPassword } from '../../services/auth.js';
|
||||
import { Field } from '../../components/Field.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
@@ -11,6 +12,7 @@ import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { t, theme } = useUi();
|
||||
const branding = useBranding();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get('token') || '';
|
||||
@@ -41,7 +43,7 @@ export default function ResetPasswordPage() {
|
||||
<div className="login-shell">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('reset_password_title')}</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useBranding } from '../../context/branding-context.jsx';
|
||||
import { verifyEmail } from '../../services/auth.js';
|
||||
import logoDark from '../../assets/logoLinkedCorpTxt.png';
|
||||
import logoLight from '../../assets/logoLinkedCorpTxtBlack.png';
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const { t, theme } = useUi();
|
||||
const branding = useBranding();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [state, setState] = useState('loading');
|
||||
const [message, setMessage] = useState('');
|
||||
@@ -29,7 +31,7 @@ export default function VerifyEmailPage() {
|
||||
<div className="login-shell">
|
||||
<div className="login-card">
|
||||
<div className="login-logo">
|
||||
<img src={theme === 'light' ? logoLight : logoDark} alt={t('appName')} className="login-logo-img" />
|
||||
<img src={branding.logoUrl || (theme === 'light' ? logoLight : logoDark)} alt={branding.name || t('appName')} className="login-logo-img" />
|
||||
</div>
|
||||
<div className="login-title">{t('verify_email_title')}</div>
|
||||
{state === 'loading' && <div className="text-muted">{t('common_loading')}</div>}
|
||||
|
||||
@@ -44,6 +44,7 @@ export const api = {
|
||||
patch: (path, body) => request('PATCH', path, { body }),
|
||||
delete: (path) => request('DELETE', path),
|
||||
postForm: (path, formData) => request('POST', path, { body: formData, isForm: true }),
|
||||
putForm: (path, formData) => request('PUT', path, { body: formData, isForm: true }),
|
||||
setToken: (token) => {
|
||||
if (token) localStorage.setItem('plc_token', token);
|
||||
else localStorage.removeItem('plc_token');
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { api } from './api.js';
|
||||
|
||||
export const getCompanyInfo = () => api.get('/company');
|
||||
|
||||
export function updateCompanyInfo({ name, logoFile }) {
|
||||
const form = new FormData();
|
||||
if (name !== undefined) form.append('name', name);
|
||||
if (logoFile) form.append('logo', logoFile);
|
||||
return api.putForm('/company', form);
|
||||
}
|
||||
|
||||
export const companyLogoUrl = () => api.downloadUrl('/company/logo');
|
||||
@@ -187,6 +187,12 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
||||
.kpi-label { font-size: .72rem; color: rgb(var(--muted)); margin-top: 4px; }
|
||||
.panel-card { background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); padding: 16px; margin-bottom: 16px; }
|
||||
.panel-card h3 { margin: 0 0 12px; font-size: .95rem; }
|
||||
.company-logo-preview {
|
||||
width: 72px; height: 72px; flex: none; border-radius: var(--r-sm);
|
||||
border: 1px dashed rgb(var(--line)); background: rgb(var(--field));
|
||||
display: flex; align-items: center; justify-content: center; overflow: hidden; text-align: center; padding: 4px;
|
||||
}
|
||||
.company-logo-preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
|
||||
/* --- ContentDialog --- */
|
||||
.dialog-overlay {
|
||||
|
||||
Reference in New Issue
Block a user