Files
gt-ai-os-community/apps/tenant-app/src/components/layout/sidebar.tsx
HackWeasel b9dfb86260 GT AI OS Community Edition v2.0.33
Security hardening release addressing CodeQL and Dependabot alerts:

- Fix stack trace exposure in error responses
- Add SSRF protection with DNS resolution checking
- Implement proper URL hostname validation (replaces substring matching)
- Add centralized path sanitization to prevent path traversal
- Fix ReDoS vulnerability in email validation regex
- Improve HTML sanitization in validation utilities
- Fix capability wildcard matching in auth utilities
- Update glob dependency to address CVE
- Add CodeQL suppression comments for verified false positives

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 17:04:45 -05:00

725 lines
28 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { ConversationHistorySidebar } from '@/components/chat/conversation-history-sidebar';
import {
MessageCircle,
X,
Bot,
Brain,
Globe,
Database,
Menu,
LogOut,
ChevronLeft,
ChevronRight,
History,
Search,
MoreHorizontal,
Clock,
Filter,
BarChart3,
Users
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { VersionDisplay } from '@/components/ui/version-display';
import Link from 'next/link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { User } from '@/types';
import { useAuthStore } from '@/stores/auth-store';
import { useChatStore } from '@/stores/chat-store';
import { getInitials } from '@/lib/utils';
import { getAuthToken, getTenantInfo } from '@/services/auth';
import { getUserRole } from '@/lib/permissions';
interface SidebarProps {
user: User | null;
onCollapseChange?: (collapsed: boolean) => void;
onSelectConversation?: (conversationId: string) => void;
}
export function Sidebar({ user, onCollapseChange, onSelectConversation }: SidebarProps) {
const pathname = usePathname();
const { logout } = useAuthStore();
const { unreadCounts } = useChatStore();
const [showUserMenu, setShowUserMenu] = useState(false);
const [tenantInfo, setTenantInfo] = useState<{name: string; domain: string} | null>(null);
const [availableAgents, setAvailableAgents] = useState<{id: string, name: string}[]>([]);
const [isCollapsed, setIsCollapsed] = useState(() => {
// Load saved state from localStorage on initial render
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('gt-sidebar-collapsed');
return saved ? JSON.parse(saved) : false;
}
return false;
});
// Track if user has ever interacted with the sidebar
const [hasUserInteracted, setHasUserInteracted] = useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('gt-sidebar-collapsed') !== null;
}
return false;
});
const [showPulse, setShowPulse] = useState(false);
const [isTabHovered, setIsTabHovered] = useState(false);
const [isNarrowScreen, setIsNarrowScreen] = useState(false);
const [userManuallyExpanded, setUserManuallyExpanded] = useState(false);
// Fetch tenant display name from control panel (via Next.js API route)
// Cache in sessionStorage to persist across navigation
useEffect(() => {
const fetchTenantDisplayName = async () => {
if (typeof window === 'undefined') return;
try {
const localTenantInfo = getTenantInfo();
if (!localTenantInfo) return;
// Check if we have cached tenant display name in sessionStorage
const cachedTenantName = sessionStorage.getItem('gt2_tenant_display_name');
if (cachedTenantName) {
// Use cached value immediately
setTenantInfo({
domain: localTenantInfo.domain,
name: cachedTenantName,
id: localTenantInfo.id
});
return;
}
// Fetch tenant display name via Next.js API route (avoids CORS)
const response = await fetch('/api/tenant-info');
if (response.ok) {
const data = await response.json();
const displayName = data.name || localTenantInfo.name;
// Cache the display name in sessionStorage
sessionStorage.setItem('gt2_tenant_display_name', displayName);
// Update with correct display name from control panel
setTenantInfo({
domain: localTenantInfo.domain,
name: displayName,
id: localTenantInfo.id
});
} else {
// If API fails, use localStorage value
setTenantInfo(localTenantInfo);
}
} catch (error) {
console.error('Failed to fetch tenant display name:', error);
// On error, use localStorage value
const tenant = getTenantInfo();
if (tenant) {
setTenantInfo(tenant);
}
}
};
fetchTenantDisplayName();
}, []);
// Check screen width and auto-collapse on narrow screens
useEffect(() => {
const checkScreenWidth = () => {
const screenWidth = window.innerWidth;
const sidebarWidth = 320; // w-80 = 320px
const minMainContentWidth = 400; // Minimum space needed for main content
const collisionPoint = sidebarWidth + minMainContentWidth; // 720px
const isNarrow = screenWidth < 1024; // lg breakpoint
const isVeryNarrow = screenWidth < 768; // md breakpoint
const wouldOverlap = screenWidth < collisionPoint; // Hard boundary at 720px
setIsNarrowScreen(isNarrow);
// Auto-collapse on narrow screens if user has never interacted with the sidebar
if (isNarrow && !isCollapsed && !hasUserInteracted) {
setIsCollapsed(true);
}
// Force collapse on very narrow screens regardless of user interaction
if (isVeryNarrow && !isCollapsed) {
setIsCollapsed(true);
setUserManuallyExpanded(false); // Reset manual override on very narrow screens
}
// HARD BOUNDARY: Force collapse when sidebar would overlap main content
if (wouldOverlap && !isCollapsed) {
setIsCollapsed(true);
setUserManuallyExpanded(false); // Reset manual override when hitting boundary
}
// Reset manual override when going back to wide screen
if (!isNarrow) {
setUserManuallyExpanded(false);
}
};
if (typeof window !== 'undefined') {
// Only run checkScreenWidth on mount and resize, not on every render
checkScreenWidth();
window.addEventListener('resize', checkScreenWidth);
return () => window.removeEventListener('resize', checkScreenWidth);
}
}, []); // Remove dependencies to prevent running on state changes
// Helper to check if any navigation page is active
const isNavActive = ['/chat', '/agents', '/datasets', '/teams', '/observability'].some(path =>
pathname === path || pathname.startsWith(path + '/')
);
// Profile menu is no longer used for navigation, only for sign out
// Save collapse state to localStorage whenever it changes
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('gt-sidebar-collapsed', JSON.stringify(isCollapsed));
onCollapseChange?.(isCollapsed);
}
}, [isCollapsed, onCollapseChange]);
// Load user's available agents - ONLY on chat page for performance
useEffect(() => {
const loadUserAgents = async () => {
try {
const token = getAuthToken();
if (!token) return;
// Use lightweight minimal endpoint for better performance
const response = await fetch('/api/v1/agents/minimal', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const agents = await response.json();
console.log('🤖 Loaded minimal agents for filtering:', agents);
setAvailableAgents(agents);
}
} catch (error) {
console.error('Error loading user agents:', error);
}
};
// Load agents once on first mount, cache for entire session
// No pathname check - agents needed for filter dropdown on all pages
if (availableAgents.length === 0) {
loadUserAgents();
}
}, [availableAgents.length]);
const handleCollapseToggle = () => {
setShowPulse(true);
const newCollapsedState = !isCollapsed;
const screenWidth = window.innerWidth;
const sidebarWidth = 320; // w-80 = 320px
const minMainContentWidth = 400; // Minimum space needed for main content
const collisionPoint = sidebarWidth + minMainContentWidth; // 720px
const isVeryNarrow = screenWidth < 768; // md breakpoint
const wouldOverlap = screenWidth < collisionPoint; // Hard boundary
// Prevent expansion on very narrow screens
if (!newCollapsedState && isVeryNarrow) {
setTimeout(() => setShowPulse(false), 300);
return; // Don't expand on very narrow screens
}
// HARD BOUNDARY: Prevent expansion when it would overlap main content
if (!newCollapsedState && wouldOverlap) {
setTimeout(() => setShowPulse(false), 300);
return; // Don't expand when it would cause overlap
}
setIsCollapsed(newCollapsedState);
setIsTabHovered(false); // Reset hover state when toggling
setHasUserInteracted(true); // Mark that user has interacted with sidebar
// If user is expanding on a narrow screen, mark as manual override
if (!newCollapsedState && isNarrowScreen && !isVeryNarrow && !wouldOverlap) {
setUserManuallyExpanded(true);
}
setTimeout(() => setShowPulse(false), 300);
};
return (
<div className={cn(
"relative h-full transition-all duration-700 ease-out",
isCollapsed ? "w-16" : "w-80"
)}>
{/* Collapsed Menu - Always visible when collapsed */}
<div className={cn(
"absolute top-4 left-0 bottom-4 z-50 flex flex-col items-center justify-start transition-all duration-300 ease-out",
isCollapsed ? "w-16 opacity-100 translate-x-0 delay-300" : "w-16 opacity-0 translate-x-0 pointer-events-none delay-0"
)}>
{/* Logo with Expand Arrow */}
<div
className={cn(
"cursor-pointer p-2 rounded-xl transition-all duration-200 backdrop-blur-md border",
isTabHovered
? "bg-white/70 border-white/60 shadow-md"
: "bg-white/30 border-white/40"
)}
onMouseEnter={() => setIsTabHovered(true)}
onMouseLeave={() => setIsTabHovered(false)}
onClick={handleCollapseToggle}
>
<div className="relative flex items-center justify-center min-w-5 h-5">
{/* GT Logo */}
<Image
src="/gt-small-logo.png"
alt="GT Logo"
width={35}
height={21}
className={cn(
"w-8 h-auto transition-all duration-300",
isTabHovered ? "opacity-40" : "opacity-90"
)}
priority
/>
{/* Arrow Reveal Overlay */}
<div className={cn(
"absolute inset-0 flex items-center justify-center transition-all duration-300",
isTabHovered ? "opacity-100" : "opacity-0"
)}>
<ChevronRight className={cn(
"w-4 h-4 text-black transition-all duration-300",
showPulse && "text-gt-green"
)} />
</div>
</div>
</div>
{/* Main Navigation Icons */}
<div className="flex flex-col space-y-2 mt-4">
<Link
href="/agents"
className={cn(
"p-2 rounded-xl transition-all duration-200 backdrop-blur-md border",
pathname === '/agents'
? "bg-gt-green/20 border-gt-green/40 shadow-md"
: "bg-white/30 border-white/40 hover:bg-white/50"
)}
>
<Bot className={cn(
"w-5 h-5 transition-colors",
pathname === '/agents' ? "text-gt-green" : "text-gt-gray-700"
)} />
</Link>
<Link
href="/datasets"
className={cn(
"p-2 rounded-xl transition-all duration-200 backdrop-blur-md border",
pathname === '/datasets'
? "bg-gt-green/20 border-gt-green/40 shadow-md"
: "bg-white/30 border-white/40 hover:bg-white/50"
)}
>
<Database className={cn(
"w-5 h-5 transition-colors",
pathname === '/datasets' ? "text-gt-green" : "text-gt-gray-700"
)} />
</Link>
<Link
href="/teams"
className={cn(
"p-2 rounded-xl transition-all duration-200 backdrop-blur-md border",
pathname === '/teams'
? "bg-gt-green/20 border-gt-green/40 shadow-md"
: "bg-white/30 border-white/40 hover:bg-white/50"
)}
>
<Users className={cn(
"w-5 h-5 transition-colors",
pathname === '/teams' ? "text-gt-green" : "text-gt-gray-700"
)} />
</Link>
{/* Observability - All Users */}
<Link
href="/observability"
className={cn(
"p-2 rounded-xl transition-all duration-200 backdrop-blur-md border",
pathname === '/observability'
? "bg-gt-green/20 border-gt-green/40 shadow-md"
: "bg-white/30 border-white/40 hover:bg-white/50"
)}
>
<BarChart3 className={cn(
"w-5 h-5 transition-colors",
pathname === '/observability' ? "text-gt-green" : "text-gt-gray-700"
)} />
</Link>
</div>
{/* Conversation History Icon (when collapsed) */}
<div className="flex-1">
<div className="mt-4 flex justify-center">
<button
onClick={() => {
const screenWidth = window.innerWidth;
const sidebarWidth = 320; // w-80 = 320px
const minMainContentWidth = 400; // Minimum space needed for main content
const collisionPoint = sidebarWidth + minMainContentWidth; // 720px
const isVeryNarrow = screenWidth < 768; // md breakpoint
const wouldOverlap = screenWidth < collisionPoint; // Hard boundary
// Only expand if it won't cause overlap
if (!isVeryNarrow && !wouldOverlap) {
setIsCollapsed(false);
setUserManuallyExpanded(true);
}
}}
className={cn(
"p-2 rounded-xl transition-all duration-200 backdrop-blur-md border",
"bg-white/30 border-white/40 hover:bg-white/50 cursor-pointer"
)}
>
<History className="w-5 h-5 text-gt-gray-700" />
</button>
</div>
</div>
{/* Profile Icon */}
<div className="mt-4">
<div
onClick={() => setShowUserMenu(!showUserMenu)}
className="p-2 rounded-xl transition-all duration-200 backdrop-blur-md border bg-white/30 border-white/40 hover:bg-white/50 relative cursor-pointer"
>
<div className="w-6 h-6 bg-gt-green rounded-full flex items-center justify-center text-white text-xs font-medium">
{user ? getInitials(user.full_name || user.email || '') : '?'}
</div>
{/* User Menu Dropdown for collapsed state */}
{showUserMenu && (
<div className="absolute left-full bottom-0 ml-2 bg-white rounded-lg shadow-lg border border-gt-gray-200 py-1 z-50 min-w-48">
<button
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center space-x-3"
onClick={() => {
logout();
setShowUserMenu(false);
}}
>
<LogOut className="w-4 h-4" />
<span>Sign Out</span>
</button>
</div>
)}
</div>
</div>
</div>
{/* Full Sidebar - Same interface for all screen sizes */}
<div className={cn(
'absolute top-0 left-0 bottom-0 w-80 z-40 bg-gradient-to-r from-white from-90% to-gt-gray-100 transform transition-all duration-500 ease-out flex flex-col overflow-hidden',
isCollapsed ? 'opacity-0 -translate-x-full pointer-events-none delay-0' : 'opacity-100 translate-x-0 delay-200'
)}>
{/* Header - Unified interface for all screen sizes */}
<div className="flex items-center p-4 relative">
<div className="flex items-center space-x-3">
<a href="https://gtedge.ai" target="_blank" rel="noopener noreferrer">
<Image
src="/gtedgeai-green-logo.jpeg"
alt="GT Edge AI Logo"
width={1536}
height={462}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</a>
</div>
{/* Collapse arrow - always visible on all screen sizes */}
<button
onClick={handleCollapseToggle}
className={cn(
"absolute right-7 p-2 rounded-lg transition-all duration-200",
showPulse ? "bg-gt-green animate-pulse" : "hover:bg-gt-gray-200"
)}
>
<ChevronLeft className="w-4 h-4 text-black transform transition-transform duration-200" />
</button>
</div>
{/* Tenant Name - Above Navigation */}
{tenantInfo && (
<div className="px-4 pb-2 flex justify-center">
<div className="px-4 py-2 rounded-lg bg-gt-gray-100 border border-gt-gray-200">
<p className="text-base font-semibold text-gt-gray-800 text-center break-words max-w-full">
{tenantInfo.name}
</p>
</div>
</div>
)}
{/* Navigation */}
<div className="px-4 pb-4">
<div className={cn(
"p-3 rounded-lg transition-colors duration-200 border border-gt-gray-300",
isNavActive ? "bg-gt-green/10" : "bg-gt-gray-800"
)}>
<nav className="space-y-1">
<Link
href="/agents"
className={cn(
"flex items-center space-x-3 px-3 py-2 text-sm rounded-lg transition-colors",
pathname === '/agents'
? "bg-gt-green text-white"
: isNavActive
? "text-gt-gray-800 hover:bg-gt-green/20"
: "text-white hover:bg-gt-gray-700"
)}
>
<Bot className="w-4 h-4" />
<span>Agents</span>
</Link>
<Link
href="/datasets"
className={cn(
"flex items-center space-x-3 px-3 py-2 text-sm rounded-lg transition-colors",
pathname === '/datasets'
? "bg-gt-green text-white"
: isNavActive
? "text-gt-gray-800 hover:bg-gt-green/20"
: "text-white hover:bg-gt-gray-700"
)}
>
<Database className="w-4 h-4" />
<span>Datasets</span>
</Link>
<Link
href="/teams"
className={cn(
"flex items-center space-x-3 px-3 py-2 text-sm rounded-lg transition-colors",
pathname === '/teams'
? "bg-gt-green text-white"
: isNavActive
? "text-gt-gray-800 hover:bg-gt-green/20"
: "text-white hover:bg-gt-gray-700"
)}
>
<Users className="w-4 h-4" />
<span>Teams</span>
</Link>
{/* Observability - All Users */}
<Link
href="/observability"
className={cn(
"flex items-center space-x-3 px-3 py-2 text-sm rounded-lg transition-colors",
pathname === '/observability'
? "bg-gt-green text-white"
: isNavActive
? "text-gt-gray-800 hover:bg-gt-green/20"
: "text-white hover:bg-gt-gray-700"
)}
>
<BarChart3 className="w-4 h-4" />
<span>Observability</span>
</Link>
{/* AI Literacy and Services hidden for MVP - will be redesigned later */}
{/* <Link
href="/games"
onClick={onClose}
className={cn(
"flex items-center space-x-3 px-3 py-2 text-sm rounded-lg transition-colors",
pathname === '/games'
? "bg-gt-green/10 text-gt-green"
: "text-gt-gray-700 hover:bg-gt-gray-50"
)}
>
<Brain className="w-4 h-4" />
<span>AI Literacy</span>
</Link>
<Link
href="/services"
onClick={onClose}
className={cn(
"flex items-center space-x-3 px-3 py-2 text-sm rounded-lg transition-colors",
pathname === '/services'
? "bg-gt-green/10 text-gt-green"
: "text-gt-gray-700 hover:bg-gt-gray-50"
)}
>
<Globe className="w-4 h-4" />
<span>Services</span>
</Link> */}
</nav>
</div>
</div>
{/* Conversation History (All pages) */}
<div className="flex-1 px-4 pb-4 min-h-0">
<div className={cn(
"h-full max-h-full p-3 rounded-lg transition-colors duration-200 border border-gt-gray-200",
"bg-gt-gray-50 flex flex-col overflow-hidden"
)}>
{/* Navigation Header */}
<div className="flex items-center justify-between mb-3 flex-shrink-0">
<div className="flex items-center space-x-2">
<History className="w-4 h-4 text-gt-gray-700" />
<span className="text-sm font-medium text-gt-gray-800">Conversations</span>
<span className="text-xs text-gt-gray-500 ml-2" id="conversation-count">
{/* Count will be updated by conversation component */}
</span>
{/* Green pulse indicator when there are unread messages */}
{Object.keys(unreadCounts).length > 0 && (
<div className="flex items-center gap-1 ml-1">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(16,185,129,0.6)]" />
<span className="text-xs text-green-500 font-semibold">
{Object.values(unreadCounts).reduce((sum, count) => sum + count, 0)}
</span>
</div>
)}
</div>
<div className="flex items-center space-x-1">
{/* Time Filter Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="p-1 h-6 w-6 text-gt-gray-600 hover:text-gt-gray-800"
title="Filter by time"
>
<Clock className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32 z-50 bg-white border border-gray-200 shadow-lg">
<DropdownMenuItem onClick={() => window.dispatchEvent(new CustomEvent('filterTime', { detail: 'all' }))}>
All Time
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.dispatchEvent(new CustomEvent('filterTime', { detail: 'today' }))}>
Today
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.dispatchEvent(new CustomEvent('filterTime', { detail: 'week' }))}>
This Week
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.dispatchEvent(new CustomEvent('filterTime', { detail: 'month' }))}>
This Month
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Agent Filter Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="p-1 h-6 w-6 text-gt-gray-600 hover:text-gt-gray-800"
title="Filter by agent"
>
<Filter className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-50 bg-white border border-gray-200 shadow-lg max-h-[300px] overflow-y-auto">
<DropdownMenuItem onClick={() => window.dispatchEvent(new CustomEvent('filterAgent', { detail: 'all' }))}>
All Agents
</DropdownMenuItem>
{availableAgents.map(agent => (
<DropdownMenuItem
key={agent.id}
onClick={() => window.dispatchEvent(new CustomEvent('filterAgent', { detail: agent.id }))}
>
{agent.name}
</DropdownMenuItem>
))}
{availableAgents.length === 0 && (
<DropdownMenuItem disabled>
No agents found
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Conversation History Content */}
<div className="flex-1 min-h-0 overflow-hidden">
<ConversationHistorySidebar
onSelectConversation={onSelectConversation || ((conversationId) => {
// Fallback: Navigate to chat page and load conversation
window.location.href = `/chat?conversation=${conversationId}`;
})}
currentConversationId={undefined}
/>
</div>
</div>
</div>
{/* Footer */}
<div className="p-4">
{/* User Profile Section */}
<div className="mt-4">
<div className="relative">
<div
onClick={() => setShowUserMenu(!showUserMenu)}
className="w-full flex items-center space-x-3 px-3 py-2 text-sm rounded-lg transition-colors cursor-pointer bg-gt-gray-100 border border-gt-gray-200 hover:bg-gt-gray-200"
>
<div className="w-8 h-8 bg-gt-green rounded-full flex items-center justify-center text-white text-sm font-medium">
{user ? getInitials(user.full_name || user.email || '') : '?'}
</div>
<div className="flex-1 text-left">
<p className="font-medium">
{user?.full_name || 'Unknown User'}
</p>
<p className="text-xs capitalize text-gt-gray-600">
{user?.user_type?.replace('_', ' ') || 'User'}
</p>
</div>
</div>
{/* User Dropdown Menu */}
{showUserMenu && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white rounded-lg shadow-lg border border-gt-gray-200 py-1 z-50">
<button
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center space-x-3"
onClick={() => {
logout();
setShowUserMenu(false);
}}
>
<LogOut className="w-4 h-4" />
<span>Sign Out</span>
</button>
</div>
)}
</div>
</div>
{/* Version Info */}
<div className="mt-4 pt-4 border-t border-gt-gray-200">
<div className="text-center">
<p className="text-xs text-gt-gray-500">
GT AI OS Community | v2.0.33
</p>
</div>
</div>
</div>
</div>
{/* Overlay for user menu */}
{showUserMenu && (
<div
className="fixed inset-0 z-30"
onClick={() => setShowUserMenu(false)}
/>
)}
</div>
);
}