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:
Milton Gonzalez
2026-09-15 14:42:15 -05:00
parent 7ba4a89d49
commit 2a1bdf1ba9
27 changed files with 336 additions and 37 deletions
+27 -24
View File
@@ -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>
);
}
+5 -2
View File
@@ -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>
</>
)}
+35
View File
@@ -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);
}
+14
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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>}
+3 -1
View File
@@ -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>}
+1
View File
@@ -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');
+12
View File
@@ -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');
+6
View File
@@ -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 {