Add DEMO feature to the project
This commit is contained in:
@@ -3,9 +3,11 @@ import { clsx } from 'clsx';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useTenantInitializer } from '../../../stores/useTenantInitializer';
|
||||
import { useHasAccess } from '../../../hooks/useAccessControl';
|
||||
import { Header } from '../Header';
|
||||
import { Sidebar } from '../Sidebar';
|
||||
import { Footer } from '../Footer';
|
||||
import { DemoBanner } from '../DemoBanner';
|
||||
|
||||
export interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
@@ -74,10 +76,10 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
||||
loadingComponent,
|
||||
errorBoundary: ErrorBoundary,
|
||||
}, ref) => {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const authLoading = false; // Since we're in a protected route, auth loading should be false
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const hasAccess = useHasAccess(); // Check both authentication and demo mode
|
||||
|
||||
// Initialize tenant data for authenticated users
|
||||
useTenantInitializer();
|
||||
|
||||
@@ -196,12 +198,12 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
||||
return <ErrorBoundary error={error}>{children}</ErrorBoundary>;
|
||||
}
|
||||
|
||||
const shouldShowSidebar = showSidebar && isAuthenticated && !fullScreen;
|
||||
const shouldShowSidebar = showSidebar && hasAccess && !fullScreen;
|
||||
const shouldShowHeader = showHeader && !fullScreen;
|
||||
const shouldShowFooter = showFooter && !fullScreen;
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={clsx(
|
||||
'min-h-screen bg-[var(--bg-primary)] flex flex-col',
|
||||
resolvedTheme,
|
||||
@@ -209,6 +211,9 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
||||
)}
|
||||
data-testid="app-shell"
|
||||
>
|
||||
{/* Demo Banner */}
|
||||
<DemoBanner />
|
||||
|
||||
{/* Header */}
|
||||
{shouldShowHeader && (
|
||||
<Header
|
||||
@@ -250,8 +255,8 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
||||
// Add header offset
|
||||
shouldShowHeader && 'pt-[var(--header-height)]',
|
||||
// Adjust margins based on sidebar state
|
||||
shouldShowSidebar && isAuthenticated && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
|
||||
shouldShowSidebar && isAuthenticated && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]',
|
||||
shouldShowSidebar && hasAccess && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
|
||||
shouldShowSidebar && hasAccess && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]',
|
||||
// Add padding to content
|
||||
padded && 'px-4 lg:px-6 pb-4 lg:pb-6'
|
||||
)}
|
||||
@@ -269,8 +274,8 @@ export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
|
||||
showPrivacyLinks={true}
|
||||
className={clsx(
|
||||
'transition-all duration-300 ease-in-out',
|
||||
shouldShowSidebar && isAuthenticated && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
|
||||
shouldShowSidebar && isAuthenticated && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]'
|
||||
shouldShowSidebar && hasAccess && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
|
||||
shouldShowSidebar && hasAccess && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
146
frontend/src/components/layout/DemoBanner/DemoBanner.tsx
Normal file
146
frontend/src/components/layout/DemoBanner/DemoBanner.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { extendDemoSession, destroyDemoSession } from '../../../api/services/demo';
|
||||
import { apiClient } from '../../../api/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const DemoBanner: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isDemo, setIsDemo] = useState(false);
|
||||
const [expiresAt, setExpiresAt] = useState<string | null>(null);
|
||||
const [timeRemaining, setTimeRemaining] = useState<string>('');
|
||||
const [canExtend, setCanExtend] = useState(true);
|
||||
const [extending, setExtending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const demoMode = localStorage.getItem('demo_mode') === 'true';
|
||||
const expires = localStorage.getItem('demo_expires_at');
|
||||
|
||||
setIsDemo(demoMode);
|
||||
setExpiresAt(expires);
|
||||
|
||||
if (demoMode && expires) {
|
||||
const interval = setInterval(() => {
|
||||
const now = new Date().getTime();
|
||||
const expiryTime = new Date(expires).getTime();
|
||||
const diff = expiryTime - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
setTimeRemaining('Sesión expirada');
|
||||
handleExpiration();
|
||||
} else {
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const seconds = Math.floor((diff % 60000) / 1000);
|
||||
setTimeRemaining(`${minutes}:${seconds.toString().padStart(2, '0')}`);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [expiresAt]);
|
||||
|
||||
const handleExpiration = () => {
|
||||
localStorage.removeItem('demo_mode');
|
||||
localStorage.removeItem('demo_session_id');
|
||||
localStorage.removeItem('demo_account_type');
|
||||
localStorage.removeItem('demo_expires_at');
|
||||
apiClient.setDemoSessionId(null);
|
||||
navigate('/demo');
|
||||
};
|
||||
|
||||
const handleExtendSession = async () => {
|
||||
const sessionId = apiClient.getDemoSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
setExtending(true);
|
||||
try {
|
||||
const updatedSession = await extendDemoSession({ session_id: sessionId });
|
||||
localStorage.setItem('demo_expires_at', updatedSession.expires_at);
|
||||
setExpiresAt(updatedSession.expires_at);
|
||||
|
||||
if (updatedSession.remaining_extensions === 0) {
|
||||
setCanExtend(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extending session:', error);
|
||||
alert('No se pudo extender la sesión');
|
||||
} finally {
|
||||
setExtending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndSession = async () => {
|
||||
const sessionId = apiClient.getDemoSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
if (confirm('¿Estás seguro de que quieres terminar la sesión demo?')) {
|
||||
try {
|
||||
await destroyDemoSession({ session_id: sessionId });
|
||||
} catch (error) {
|
||||
console.error('Error destroying session:', error);
|
||||
} finally {
|
||||
handleExpiration();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isDemo) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-amber-500 to-orange-500 text-white px-4 py-2 shadow-md">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">Modo Demo</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:flex items-center text-sm">
|
||||
<svg
|
||||
className="w-4 h-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Tiempo restante: <span className="font-mono ml-1">{timeRemaining}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{canExtend && (
|
||||
<button
|
||||
onClick={handleExtendSession}
|
||||
disabled={extending}
|
||||
className="px-3 py-1 bg-white/20 hover:bg-white/30 rounded-md text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{extending ? 'Extendiendo...' : '+30 min'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleEndSession}
|
||||
className="px-3 py-1 bg-white/20 hover:bg-white/30 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
Terminar Demo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemoBanner;
|
||||
1
frontend/src/components/layout/DemoBanner/index.ts
Normal file
1
frontend/src/components/layout/DemoBanner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DemoBanner, default } from './DemoBanner';
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useNotifications } from '../../../hooks/useNotifications';
|
||||
import { useHasAccess } from '../../../hooks/useAccessControl';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { TenantSwitcher } from '../../ui/TenantSwitcher';
|
||||
@@ -87,7 +88,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const hasAccess = useHasAccess(); // Check both authentication and demo mode
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
const {
|
||||
notifications,
|
||||
@@ -183,7 +184,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
</div>
|
||||
|
||||
{/* Tenant Switcher - Desktop */}
|
||||
{isAuthenticated && (
|
||||
{hasAccess && (
|
||||
<div className="hidden md:block mx-2 lg:mx-4 flex-shrink-0">
|
||||
<TenantSwitcher
|
||||
showLabel={true}
|
||||
@@ -193,7 +194,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
)}
|
||||
|
||||
{/* Tenant Switcher - Mobile (in title area) */}
|
||||
{isAuthenticated && (
|
||||
{hasAccess && (
|
||||
<div className="md:hidden flex-1 min-w-0 ml-3">
|
||||
<TenantSwitcher
|
||||
showLabel={false}
|
||||
@@ -203,7 +204,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
)}
|
||||
|
||||
{/* Space for potential future content */ }
|
||||
{isAuthenticated && (
|
||||
{hasAccess && (
|
||||
<div className="hidden md:flex items-center flex-1 max-w-md mx-4">
|
||||
{/* Empty space to maintain layout consistency */}
|
||||
</div>
|
||||
@@ -211,12 +212,12 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
{isAuthenticated && (
|
||||
{hasAccess && (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Placeholder for potential future items */ }
|
||||
|
||||
{/* Language selector */}
|
||||
<CompactLanguageSelector className="w-auto min-w-[60px]" />
|
||||
<CompactLanguageSelector className="w-auto min-w-[50px]" />
|
||||
|
||||
{/* Theme toggle */}
|
||||
{showThemeToggle && (
|
||||
|
||||
@@ -154,10 +154,12 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
</nav>
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Language selector */}
|
||||
<div className="flex items-center gap-2 lg:gap-3">
|
||||
{/* Language selector - More compact */}
|
||||
{showLanguageSelector && (
|
||||
<CompactLanguageSelector className="hidden sm:flex" />
|
||||
<div className="hidden sm:flex">
|
||||
<CompactLanguageSelector className="w-[70px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle */}
|
||||
@@ -169,22 +171,22 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Authentication buttons */}
|
||||
{/* Authentication buttons - Enhanced */}
|
||||
{showAuthButtons && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 lg:gap-3">
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hidden sm:inline-flex"
|
||||
size="md"
|
||||
className="hidden sm:inline-flex font-medium hover:bg-[var(--bg-secondary)] transition-all duration-200"
|
||||
>
|
||||
Iniciar Sesión
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white"
|
||||
size="md"
|
||||
className="bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:opacity-90 text-white font-semibold shadow-lg hover:shadow-xl transition-all duration-200 px-6"
|
||||
>
|
||||
<span className="hidden sm:inline">Comenzar Gratis</span>
|
||||
<span className="sm:hidden">Registro</span>
|
||||
@@ -243,14 +245,21 @@ export const PublicHeader = forwardRef<PublicHeaderRef, PublicHeaderProps>(({
|
||||
|
||||
{/* Mobile auth buttons */}
|
||||
{showAuthButtons && (
|
||||
<div className="flex flex-col gap-2 pt-4 sm:hidden">
|
||||
<div className="flex flex-col gap-3 pt-4 sm:hidden">
|
||||
<Link to="/login">
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
className="w-full font-medium border border-[var(--border-primary)] hover:bg-[var(--bg-secondary)]"
|
||||
>
|
||||
Iniciar Sesión
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button size="sm" className="w-full bg-[var(--color-primary)] hover:bg-[var(--color-primary-dark)] text-white">
|
||||
<Button
|
||||
size="md"
|
||||
className="w-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] hover:opacity-90 text-white font-semibold shadow-lg"
|
||||
>
|
||||
Comenzar Gratis
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthUser, useIsAuthenticated, useAuthActions } from '../../../stores';
|
||||
import { useCurrentTenantAccess } from '../../../stores/tenant.store';
|
||||
import { useHasAccess } from '../../../hooks/useAccessControl';
|
||||
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
|
||||
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
|
||||
import { Button } from '../../ui';
|
||||
@@ -138,7 +139,8 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
const isAuthenticated = useIsAuthenticated(); // Keep for logout check
|
||||
const hasAccess = useHasAccess(); // For UI visibility
|
||||
const currentTenantAccess = useCurrentTenantAccess();
|
||||
const { logout } = useAuthActions();
|
||||
|
||||
@@ -207,37 +209,42 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
// Filter items based on user permissions - memoized to prevent infinite re-renders
|
||||
const visibleItems = useMemo(() => {
|
||||
const filterItemsByPermissions = (items: NavigationItem[]): NavigationItem[] => {
|
||||
if (!isAuthenticated || !user) return [];
|
||||
if (!hasAccess) return [];
|
||||
|
||||
return items.map(item => ({
|
||||
...item, // Create a shallow copy to avoid mutation
|
||||
children: item.children ? filterItemsByPermissions(item.children) : item.children
|
||||
})).filter(item => {
|
||||
// Combine global and tenant roles for comprehensive access control
|
||||
const globalUserRoles = user.role ? [user.role as string] : [];
|
||||
const globalUserRoles = user?.role ? [user.role as string] : [];
|
||||
const tenantRole = currentTenantAccess?.role;
|
||||
const tenantRoles = tenantRole ? [tenantRole as string] : [];
|
||||
const allUserRoles = [...globalUserRoles, ...tenantRoles];
|
||||
const tenantPermissions = currentTenantAccess?.permissions || [];
|
||||
|
||||
const hasAccess = !item.requiredPermissions && !item.requiredRoles ||
|
||||
canAccessRoute(
|
||||
{
|
||||
path: item.path,
|
||||
requiredRoles: item.requiredRoles,
|
||||
requiredPermissions: item.requiredPermissions
|
||||
} as any,
|
||||
isAuthenticated,
|
||||
allUserRoles,
|
||||
tenantPermissions
|
||||
);
|
||||
// If no specific permissions/roles required, allow access
|
||||
if (!item.requiredPermissions && !item.requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasAccess;
|
||||
// Check access based on roles and permissions
|
||||
const canAccessItem = canAccessRoute(
|
||||
{
|
||||
path: item.path,
|
||||
requiredRoles: item.requiredRoles,
|
||||
requiredPermissions: item.requiredPermissions
|
||||
} as any,
|
||||
isAuthenticated,
|
||||
allUserRoles,
|
||||
tenantPermissions
|
||||
);
|
||||
|
||||
return canAccessItem;
|
||||
});
|
||||
};
|
||||
|
||||
return filterItemsByPermissions(navigationItems);
|
||||
}, [navigationItems, isAuthenticated, user, currentTenantAccess]);
|
||||
}, [navigationItems, hasAccess, isAuthenticated, user, currentTenantAccess]);
|
||||
|
||||
// Handle item click
|
||||
const handleItemClick = useCallback((item: NavigationItem) => {
|
||||
@@ -645,7 +652,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (!hasAccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user