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>
This commit is contained in:
HackWeasel
2025-12-12 17:04:45 -05:00
commit b9dfb86260
746 changed files with 232071 additions and 0 deletions

View File

@@ -0,0 +1,514 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { AgentCard } from '@/components/agents/agent-card';
import { AgentCreateModal } from '@/components/agents';
import { AgentExecutionModal } from '@/components/agents/agent-execution-modal';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import {
Plus,
Search,
Filter,
Bot,
Brain,
Code,
Activity,
Clock,
DollarSign,
TrendingUp,
Zap
} from 'lucide-react';
interface Agent {
id: string;
name: string;
description?: string;
agent_type: 'research' | 'coding' | 'analysis' | 'custom';
capabilities: string[];
usage_count: number;
last_used?: string;
is_active: boolean;
created_at: string;
}
interface AgentExecution {
id: string;
agent_id: string;
task_description: string;
task_parameters: Record<string, any>;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress_percentage: number;
current_step?: string;
result_data: Record<string, any>;
output_artifacts: string[];
error_details?: string;
execution_time_ms?: number;
tokens_used: number;
cost_cents: number;
tool_calls_count: number;
started_at?: string;
completed_at?: string;
created_at: string;
}
import { AppLayout } from '@/components/layout/app-layout';
function AgentsPageContent() {
const [agents, setAgents] = useState<Agent[]>([]);
const [executions, setExecutions] = useState<AgentExecution[]>([]);
const [filteredAgents, setFilteredAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedType, setSelectedType] = useState<string>('all');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showExecuteModal, setShowExecuteModal] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const [currentExecution, setCurrentExecution] = useState<AgentExecution | null>(null);
// Load real data from backend API
useEffect(() => {
const loadAgents = async () => {
try {
setLoading(true);
// Load agents from backend API - using standardized GT 2.0 token key
const authToken = localStorage.getItem('gt2_token');
const response = await fetch('http://localhost:8001/api/v1/agents', {
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const backendAgents = await response.json();
// Convert backend format to frontend format
const convertedAgents: Agent[] = backendAgents.map((agent: any) => ({
id: agent.id,
name: agent.name,
description: agent.description,
agent_type: agent.agent_type,
capabilities: agent.capabilities || [],
usage_count: agent.usage_count || 0,
last_used: agent.last_used,
is_active: agent.is_active,
created_at: agent.created_at
}));
setAgents(convertedAgents);
setFilteredAgents(convertedAgents);
// Load recent executions
// TODO: Add execution history endpoint to backend
setExecutions([]);
} catch (error) {
console.error('Failed to load agents:', error);
// Fallback to empty state instead of mock data
setAgents([]);
setFilteredAgents([]);
setExecutions([]);
} finally {
setLoading(false);
}
};
loadAgents();
}, []);
// Filter agents based on search and type
useEffect(() => {
let filtered = agents;
if (searchQuery) {
filtered = filtered.filter(agent =>
agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
agent.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
agent.capabilities.some(cap => cap.toLowerCase().includes(searchQuery.toLowerCase()))
);
}
if (selectedType !== 'all') {
filtered = filtered.filter(agent => agent.agent_type === selectedType);
}
setFilteredAgents(filtered);
}, [agents, searchQuery, selectedType]);
const handleCreateAgent = async (agentData: any) => {
try {
const authToken = localStorage.getItem('gt2_token');
const response = await fetch('http://localhost:8001/api/v1/agents', {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: agentData.name,
description: agentData.description,
agent_type: agentData.agent_type,
prompt_template: agentData.prompt_template || `You are a ${agentData.agent_type} agent focused on helping users with ${agentData.description || 'various tasks'}.`,
capabilities: agentData.capabilities || [],
model_preferences: agentData.model_preferences || {},
personality_config: agentData.personality_config || {},
memory_type: agentData.memory_type || 'conversation',
available_tools: agentData.available_tools || [],
resource_bindings: agentData.resource_bindings || []
})
});
if (!response.ok) {
throw new Error(`Failed to create agent: ${response.status}`);
}
const newAgent = await response.json();
// Convert to frontend format and add to state
const convertedAgent: Agent = {
id: newAgent.id,
name: newAgent.name,
description: newAgent.description,
agent_type: newAgent.agent_type,
capabilities: newAgent.capabilities || [],
usage_count: newAgent.usage_count || 0,
last_used: newAgent.last_used,
is_active: newAgent.is_active,
created_at: newAgent.created_at
};
setAgents(prev => [convertedAgent, ...prev]);
setShowCreateModal(false);
} catch (error) {
console.error('Failed to create agent:', error);
alert('Failed to create agent. Please try again.');
}
};
const handleExecuteAgent = async (agentId: string, taskDescription: string, parameters: Record<string, any>) => {
try {
const authToken = localStorage.getItem('gt2_token');
const response = await fetch(`http://localhost:8001/api/v1/agents/${agentId}/execute`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
task_description: taskDescription,
task_parameters: parameters,
execution_context: {}
})
});
if (!response.ok) {
throw new Error(`Failed to execute agent: ${response.status}`);
}
const execution = await response.json();
// Convert to frontend format
const convertedExecution: AgentExecution = {
id: execution.id,
agent_id: execution.agent_id,
task_description: execution.task_description,
task_parameters: execution.task_parameters || {},
status: execution.status,
progress_percentage: execution.progress_percentage || 0,
current_step: execution.current_step,
result_data: execution.result_data || {},
output_artifacts: execution.output_artifacts || [],
tokens_used: execution.tokens_used || 0,
cost_cents: execution.cost_cents || 0,
tool_calls_count: execution.tool_calls_count || 0,
started_at: execution.started_at,
completed_at: execution.completed_at,
created_at: execution.created_at
};
setCurrentExecution(convertedExecution);
setExecutions(prev => [convertedExecution, ...prev]);
// Poll for updates if execution is running
if (convertedExecution.status === 'running' || convertedExecution.status === 'pending') {
const pollExecution = async () => {
try {
const statusResponse = await fetch(`http://localhost:8001/api/v1/agents/executions/${execution.id}`, {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
if (statusResponse.ok) {
const updatedExecution = await statusResponse.json();
const convertedUpdate: AgentExecution = {
id: updatedExecution.id,
agent_id: updatedExecution.agent_id,
task_description: updatedExecution.task_description,
task_parameters: updatedExecution.task_parameters || {},
status: updatedExecution.status,
progress_percentage: updatedExecution.progress_percentage || 0,
current_step: updatedExecution.current_step,
result_data: updatedExecution.result_data || {},
output_artifacts: updatedExecution.output_artifacts || [],
tokens_used: updatedExecution.tokens_used || 0,
cost_cents: updatedExecution.cost_cents || 0,
tool_calls_count: updatedExecution.tool_calls_count || 0,
started_at: updatedExecution.started_at,
completed_at: updatedExecution.completed_at,
created_at: updatedExecution.created_at
};
setCurrentExecution(convertedUpdate);
// Continue polling if still running
if (convertedUpdate.status === 'running' || convertedUpdate.status === 'pending') {
setTimeout(pollExecution, 2000);
}
}
} catch (pollError) {
console.error('Error polling execution status:', pollError);
}
};
// Start polling after 2 seconds
setTimeout(pollExecution, 2000);
}
} catch (error) {
console.error('Failed to execute agent:', error);
alert('Failed to execute agent. Please try again.');
}
};
const handleEditAgent = (agent: Agent) => {
// TODO: Implement edit functionality
console.log('Edit agent:', agent);
};
const handleDeleteAgent = async (agentId: string) => {
try {
const authToken = localStorage.getItem('gt2_token');
const response = await fetch(`http://localhost:8001/api/v1/agents/${agentId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to delete agent: ${response.status}`);
}
setAgents(prev => prev.filter(agent => agent.id !== agentId));
} catch (error) {
console.error('Failed to delete agent:', error);
alert('Failed to delete agent. Please try again.');
}
};
const handleCloneAgent = (agent: Agent) => {
// TODO: Implement clone functionality
console.log('Clone agent:', agent);
};
const openExecuteModal = (agentId: string) => {
const agent = agents.find(a => a.id === agentId);
setSelectedAgent(agent || null);
setCurrentExecution(null);
setShowExecuteModal(true);
};
// Calculate stats
const totalAgents = agents.length;
const activeAgents = agents.filter(a => a.is_active).length;
const totalExecutions = executions.length;
const totalTokensUsed = executions.reduce((sum, exec) => sum + exec.tokens_used, 0);
const totalCost = executions.reduce((sum, exec) => sum + exec.cost_cents, 0);
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-gt-green border-t-transparent"></div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">AI Agents</h1>
<p className="text-gray-600">Create and manage AI agents for automated tasks</p>
</div>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Agent
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-2">
<Bot className="h-4 w-4 text-blue-600" />
<div>
<p className="text-sm text-gray-600">Total Agents</p>
<p className="text-xl font-semibold">{totalAgents}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-2">
<Zap className="h-4 w-4 text-green-600" />
<div>
<p className="text-sm text-gray-600">Active</p>
<p className="text-xl font-semibold">{activeAgents}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-2">
<Activity className="h-4 w-4 text-purple-600" />
<div>
<p className="text-sm text-gray-600">Executions</p>
<p className="text-xl font-semibold">{totalExecutions}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-2">
<Brain className="h-4 w-4 text-orange-600" />
<div>
<p className="text-sm text-gray-600">Tokens Used</p>
<p className="text-xl font-semibold">{totalTokensUsed.toLocaleString()}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-2">
<DollarSign className="h-4 w-4 text-green-600" />
<div>
<p className="text-sm text-gray-600">Total Cost</p>
<p className="text-xl font-semibold">${(totalCost / 100).toFixed(2)}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<Input
placeholder="Search agents..."
value={searchQuery}
onChange={(e: any) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<select
value={selectedType}
onChange={(e) => setSelectedType((e as React.ChangeEvent<HTMLSelectElement>).target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gt-green focus:border-transparent"
>
<option value="all">All Types</option>
<option value="research">Research</option>
<option value="coding">Coding</option>
<option value="analysis">Analysis</option>
<option value="custom">Custom</option>
</select>
</div>
{/* Agents Grid */}
{filteredAgents.length === 0 ? (
<Card>
<CardContent className="text-center py-12">
<Bot className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{searchQuery || selectedType !== 'all' ? 'No agents found' : 'No agents yet'}
</h3>
<p className="text-gray-600 mb-4">
{searchQuery || selectedType !== 'all'
? 'Try adjusting your search criteria'
: 'Create your first AI agent to get started'
}
</p>
{!searchQuery && selectedType === 'all' && (
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Your First Agent
</Button>
)}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredAgents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
onExecute={openExecuteModal}
onEdit={handleEditAgent}
onDelete={handleDeleteAgent}
onClone={handleCloneAgent}
/>
))}
</div>
)}
{/* Create Agent Modal */}
<AgentCreateModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateAgent}
/>
{/* Execute Agent Modal */}
<AgentExecutionModal
isOpen={showExecuteModal}
onClose={() => {
setShowExecuteModal(false);
setSelectedAgent(null);
setCurrentExecution(null);
}}
agent={selectedAgent}
onExecute={handleExecuteAgent}
execution={currentExecution}
/>
</div>
);
}
export default function AgentsPage() {
return (
<AppLayout>
<AgentsPageContent />
</AppLayout>
);
}

View File

@@ -0,0 +1,747 @@
'use client';
import React, { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { AppLayout } from '@/components/layout/app-layout';
import { AgentGallery } from '@/components/agents/agent-gallery';
import { AgentQuickTile } from '@/components/agents/agent-quick-tile';
import { AuthGuard } from '@/components/auth/auth-guard';
import { GT2_CAPABILITIES } from '@/lib/capabilities';
import { agentService, type EnhancedAgent } from '@/services';
import { getFavoriteAgents, updateFavoriteAgents } from '@/services/user';
import { Bot, Plus, LayoutGrid, List, Star, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Search } from 'lucide-react';
import { usePageTitle } from '@/hooks/use-page-title';
import { useDebouncedValue } from '@/lib/utils';
// Dynamically import heavy modal components for better performance
const FavoriteAgentSelectorModal = dynamic(
() => import('@/components/agents/favorite-agent-selector-modal').then(mod => ({ default: mod.FavoriteAgentSelectorModal })),
{ ssr: false }
);
const AgentBulkImportModal = dynamic(
() => import('@/components/agents/agent-bulk-import-modal').then(mod => ({ default: mod.AgentBulkImportModal })),
{ ssr: false }
);
type ViewMode = 'quick' | 'detailed';
type SortBy = 'name' | 'created_at' | 'usage_count' | 'recent_usage' | 'my_most_used';
function AgentsPage() {
usePageTitle('Agents');
const [agents, setAgents] = useState<EnhancedAgent[]>([]);
const [loading, setLoading] = useState(true);
const router = useRouter();
const [triggerCreate, setTriggerCreate] = useState(false);
// Quick View state - Default to 'quick' view (favorites) as landing page
const [viewMode, setViewMode] = useState<ViewMode>('quick');
const [favoriteAgentIds, setFavoriteAgentIds] = useState<string[]>([]);
const [showFavoriteSelector, setShowFavoriteSelector] = useState(false);
const [showBulkImportModal, setShowBulkImportModal] = useState(false);
const [loadingFavorites, setLoadingFavorites] = useState(true);
// Quick View filters
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); // Performance optimization: debounce search
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [selectedTag, setSelectedTag] = useState<string>('all');
const [selectedCreator, setSelectedCreator] = useState<string>('all');
const [sortBy, setSortBy] = useState<SortBy>('recent_usage'); // Default to recently used
const loadAgents = async () => {
setLoading(true);
try {
// Build query parameters for backend usage tracking
const params: any = {};
// Add sort parameter if using user-relative sorting
if (sortBy === 'recent_usage' || sortBy === 'my_most_used') {
params.sort_by = sortBy;
}
const res = await agentService.listAgents(params);
console.log('📋 Agent service response:', res);
// Backend returns wrapped in ApiResponse: {data: {data: [], total: 0, limit: 50, offset: 0}, status: 200}
if (res && res.data && res.data.data && Array.isArray(res.data.data)) {
console.log('📋 Found agents in res.data.data:', res.data.data);
// Log first agent's permission flags for debugging
if (res.data.data.length > 0) {
const firstAgent = res.data.data[0];
console.log('🔐 First agent permissions (RAW from backend):', {
name: firstAgent.name,
can_edit: firstAgent.can_edit,
can_edit_type: typeof firstAgent.can_edit,
can_delete: firstAgent.can_delete,
can_delete_type: typeof firstAgent.can_delete,
is_owner: firstAgent.is_owner,
is_owner_type: typeof firstAgent.is_owner
});
}
// Adapt backend AgentResponse to frontend EnhancedAgent interface
const adaptedAgents = res.data.data.map((agent: any) => {
const adapted = {
...agent,
// Provide defaults for missing EnhancedAgent fields
team_id: agent.team_id || '',
disclaimer: agent.disclaimer || '',
easy_prompts: agent.easy_prompts || [],
visibility: agent.visibility || 'individual',
featured: agent.featured || false,
personality_type: agent.personality_type || 'minimal',
custom_avatar_url: agent.custom_avatar_url || '',
model_id: agent.model_id || agent.model || '',
system_prompt: agent.system_prompt || '',
model_parameters: agent.model_parameters || {},
dataset_connection: agent.dataset_connection || 'all',
selected_dataset_ids: agent.selected_dataset_ids || [],
require_moderation: agent.require_moderation || false,
blocked_terms: agent.blocked_terms || [],
enabled_capabilities: agent.enabled_capabilities || [],
mcp_integration_ids: agent.mcp_integration_ids || [],
tool_configurations: agent.tool_configurations || {},
collaborator_ids: agent.collaborator_ids || [],
can_fork: agent.can_fork || true,
parent_agent_id: agent.parent_agent_id,
version: agent.version || 1,
usage_count: agent.usage_count || 0,
average_rating: agent.average_rating,
tags: agent.tags || [],
example_prompts: agent.example_prompts || [],
safety_flags: agent.safety_flags || [],
created_at: agent.created_at,
updated_at: agent.updated_at,
// Permission flags from backend - default to false for security
can_edit: Boolean(agent.can_edit),
can_delete: Boolean(agent.can_delete),
is_owner: Boolean(agent.is_owner),
// Creator information
owner_name: agent.created_by_name || agent.owner_name
};
console.log('🔐 Adapted agent:', adapted.name, 'can_edit:', adapted.can_edit, 'can_delete:', adapted.can_delete);
return adapted;
});
setAgents(adaptedAgents);
} else if (res && res.data && Array.isArray(res.data)) {
console.log('📋 Found agents in res.data:', res.data);
// Map permission flags even for this path - default to false for security
const mappedAgents = res.data.map((agent: any) => ({
...agent,
can_edit: Boolean(agent.can_edit),
can_delete: Boolean(agent.can_delete),
is_owner: Boolean(agent.is_owner),
owner_name: agent.created_by_name || agent.owner_name
}));
setAgents(mappedAgents);
} else if (Array.isArray(res)) {
console.log('📋 Response is array:', res);
// Map permission flags even for this path - default to false for security
const mappedAgents = res.map((agent: any) => ({
...agent,
can_edit: Boolean(agent.can_edit),
can_delete: Boolean(agent.can_delete),
is_owner: Boolean(agent.is_owner)
}));
setAgents(mappedAgents);
} else {
console.log('📋 No agents found or unexpected response format:', res);
setAgents([]);
}
} catch (error) {
console.error('❌ Error loading agents:', error);
setAgents([]);
} finally {
setLoading(false);
}
};
const loadFavorites = async () => {
setLoadingFavorites(true);
try {
const res = await getFavoriteAgents();
if (res.data?.favorite_agent_ids && res.data.favorite_agent_ids.length > 0) {
// User has favorites set
setFavoriteAgentIds(res.data.favorite_agent_ids);
} else {
// No favorites set - mark all agents as favorites by default
// Wait for agents to load first
if (agents.length === 0) {
// Agents not loaded yet, wait for them
setLoadingFavorites(false);
return;
}
const allAgentIds = agents.map(agent => agent.id);
setFavoriteAgentIds(allAgentIds);
// Save to backend
if (allAgentIds.length > 0) {
try {
await updateFavoriteAgents(allAgentIds);
console.log('✅ All agents marked as favorites by default');
} catch (saveError) {
console.error('❌ Error saving default favorites:', saveError);
}
}
}
} catch (error) {
console.error('❌ Error loading favorite agents:', error);
} finally {
setLoadingFavorites(false);
}
};
useEffect(() => {
loadAgents();
}, []);
// Load favorites after agents are loaded, or allow empty state if no agents exist
useEffect(() => {
if (!loading) {
if (agents.length > 0 && loadingFavorites) {
loadFavorites();
} else if (agents.length === 0 && loadingFavorites) {
// No agents visible to this user - allow empty favorites state
setLoadingFavorites(false);
}
}
}, [agents, loading]);
// Reload agents when sort or filter changes
useEffect(() => {
if (!loading) {
loadAgents();
}
}, [sortBy]);
const handleSaveFavorites = async (newFavoriteIds: string[]) => {
try {
const res = await updateFavoriteAgents(newFavoriteIds);
if (res.status >= 200 && res.status < 300) {
setFavoriteAgentIds(newFavoriteIds);
console.log('✅ Favorite agents updated');
} else {
throw new Error(res.error || 'Failed to update favorites');
}
} catch (error) {
console.error('❌ Error saving favorite agents:', error);
alert('Failed to save favorite agents. Please try again.');
}
};
// Get favorite agents (filtered list)
const favoriteAgents = React.useMemo(() => {
return agents.filter(agent => favoriteAgentIds.includes(agent.id));
}, [agents, favoriteAgentIds]);
// Extract unique categories, tags, and creators from agents
const { categories, tags, creators } = React.useMemo(() => {
const categorySet = new Set<string>();
const tagSet = new Set<string>();
const creatorSet = new Set<string>();
agents.forEach(agent => {
if (agent.category) categorySet.add(agent.category);
agent.tags?.forEach(tag => tagSet.add(tag));
if (agent.owner_name) creatorSet.add(agent.owner_name);
});
return {
categories: Array.from(categorySet).sort(),
tags: Array.from(tagSet).sort(),
creators: Array.from(creatorSet).sort()
};
}, [agents]);
// Filter and sort favorite agents for Quick View
const filteredFavoriteAgents = React.useMemo(() => {
let filtered = favoriteAgents.filter(agent => {
const matchesSearch = !debouncedSearchQuery ||
agent.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
agent.description?.toLowerCase().includes(debouncedSearchQuery.toLowerCase());
const matchesCategory = selectedCategory === 'all' || agent.category === selectedCategory;
const matchesTag = selectedTag === 'all' || agent.tags?.includes(selectedTag);
const matchesCreator = selectedCreator === 'all' || agent.owner_name === selectedCreator;
return matchesSearch && matchesCategory && matchesTag && matchesCreator;
});
// Sort agents locally (only if not using backend sorting)
if (sortBy === 'recent_usage' || sortBy === 'my_most_used') {
// Backend already sorted, preserve order
return filtered;
}
filtered.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'usage_count':
return (b.usage_count || 0) - (a.usage_count || 0);
case 'created_at':
default:
return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime();
}
});
return filtered;
}, [favoriteAgents, debouncedSearchQuery, selectedCategory, selectedTag, selectedCreator, sortBy]);
const handleSelectAgent = (agent: EnhancedAgent) => {
router.push(`/chat?agent=${agent.id}`);
};
const handleCreateAgent = async (agentData: any) => {
try {
console.log('🚀 Creating agent with data:', agentData);
// Essential fields that backend expects including model_id
const createRequest = {
name: agentData.name,
description: agentData.description || "",
category: agentData.category || agentData.agent_type || "general",
model_id: agentData.model_id,
temperature: agentData.temperature || agentData.model_parameters?.temperature,
max_tokens: agentData.max_tokens || agentData.model_parameters?.max_tokens,
prompt_template: agentData.system_prompt,
tags: agentData.tags || [],
selected_dataset_ids: agentData.selected_dataset_ids || [],
visibility: agentData.visibility || "individual",
disclaimer: agentData.disclaimer,
easy_prompts: agentData.easy_prompts || []
};
console.log('📦 Sending request:', createRequest);
const result = await agentService.createAgent(createRequest);
console.log('📥 Backend result:', result);
if (result.data && result.status >= 200 && result.status < 300) {
// Refresh the agents list
const refreshResult = await agentService.listAgents();
if (refreshResult && refreshResult.data && refreshResult.data.data && Array.isArray(refreshResult.data.data)) {
const adaptedAgents = refreshResult.data.data.map((agent: any) => ({
...agent,
team_id: agent.team_id || '',
disclaimer: agent.disclaimer || '',
easy_prompts: agent.easy_prompts || [],
visibility: agent.visibility || 'individual',
featured: agent.featured || false,
personality_type: agent.personality_type || 'minimal',
custom_avatar_url: agent.custom_avatar_url || '',
model_id: agent.model_id || agent.model || '',
system_prompt: agent.system_prompt || '',
model_parameters: agent.model_parameters || {},
dataset_connection: agent.dataset_connection || 'all',
selected_dataset_ids: agent.selected_dataset_ids || [],
require_moderation: agent.require_moderation || false,
blocked_terms: agent.blocked_terms || [],
enabled_capabilities: agent.enabled_capabilities || [],
mcp_integration_ids: agent.mcp_integration_ids || [],
tool_configurations: agent.tool_configurations || {},
collaborator_ids: agent.collaborator_ids || [],
can_fork: agent.can_fork || true,
parent_agent_id: agent.parent_agent_id,
version: agent.version || 1,
usage_count: agent.usage_count || 0,
average_rating: agent.average_rating,
tags: agent.tags || [],
example_prompts: agent.example_prompts || [],
safety_flags: agent.safety_flags || [],
created_at: agent.created_at,
updated_at: agent.updated_at,
can_edit: agent.can_edit === true,
can_delete: agent.can_delete === true,
is_owner: agent.is_owner === true,
owner_name: agent.created_by_name || agent.owner_name
}));
setAgents(adaptedAgents);
} else if (refreshResult && refreshResult.data && Array.isArray(refreshResult.data)) {
setAgents(refreshResult.data);
} else if (Array.isArray(refreshResult)) {
setAgents(refreshResult);
}
console.log('✅ Agent created successfully');
} else {
throw new Error(result.error || 'Failed to create agent');
}
} catch (error) {
console.error('❌ Error creating agent:', error);
throw error;
}
};
const handleEditAgent = async (agentData: any) => {
try {
console.log('📝 Updating agent with data:', agentData);
// The agentData should contain the agent ID and update fields
const updateRequest = {
name: agentData.name,
description: agentData.description || "",
category: agentData.category || agentData.agent_type || "general",
prompt_template: agentData.system_prompt || agentData.prompt_template || "",
model: agentData.model_id || agentData.model || "",
temperature: agentData.model_parameters?.temperature || 0.7,
max_tokens: agentData.model_parameters?.max_tokens || 4096,
personality_config: agentData.personality_profile || agentData.personality_config || {},
resource_preferences: agentData.resource_preferences || {},
tags: agentData.tags || [],
is_favorite: agentData.is_favorite || false,
visibility: agentData.visibility || "individual",
selected_dataset_ids: agentData.selected_dataset_ids || [],
disclaimer: agentData.disclaimer,
easy_prompts: agentData.easy_prompts || [],
team_shares: agentData.team_shares
};
console.log('📦 Sending update request:', updateRequest);
const result = await agentService.updateAgent(agentData.id, updateRequest);
console.log('📥 Backend update result:', result);
if (result.data && result.status >= 200 && result.status < 300) {
// Refresh the agents list
const refreshResult = await agentService.listAgents();
if (refreshResult && refreshResult.data && refreshResult.data.data && Array.isArray(refreshResult.data.data)) {
const adaptedAgents = refreshResult.data.data.map((agent: any) => ({
...agent,
team_id: agent.team_id || '',
disclaimer: agent.disclaimer || '',
easy_prompts: agent.easy_prompts || [],
visibility: agent.visibility || 'individual',
featured: agent.featured || false,
personality_type: agent.personality_type || 'minimal',
custom_avatar_url: agent.custom_avatar_url || '',
model_id: agent.model_id || agent.model || '',
system_prompt: agent.system_prompt || '',
model_parameters: agent.model_parameters || {},
dataset_connection: agent.dataset_connection || 'all',
selected_dataset_ids: agent.selected_dataset_ids || [],
require_moderation: agent.require_moderation || false,
blocked_terms: agent.blocked_terms || [],
enabled_capabilities: agent.enabled_capabilities || [],
mcp_integration_ids: agent.mcp_integration_ids || [],
tool_configurations: agent.tool_configurations || {},
collaborator_ids: agent.collaborator_ids || [],
can_fork: agent.can_fork || true,
parent_agent_id: agent.parent_agent_id,
version: agent.version || 1,
usage_count: agent.usage_count || 0,
average_rating: agent.average_rating,
tags: agent.tags || [],
example_prompts: agent.example_prompts || [],
safety_flags: agent.safety_flags || [],
created_at: agent.created_at,
updated_at: agent.updated_at,
can_edit: agent.can_edit === true,
can_delete: agent.can_delete === true,
is_owner: agent.is_owner === true,
owner_name: agent.created_by_name || agent.owner_name
}));
setAgents(adaptedAgents);
} else if (refreshResult && refreshResult.data && Array.isArray(refreshResult.data)) {
setAgents(refreshResult.data);
} else if (Array.isArray(refreshResult)) {
setAgents(refreshResult);
}
console.log('✅ Agent updated successfully');
} else {
throw new Error(result.error || 'Failed to update agent');
}
} catch (error) {
console.error('❌ Error updating agent:', error);
throw error;
}
};
const handleDeleteAgent = async (agentId: string) => {
try {
const result = await agentService.deleteAgent(agentId);
if (result.status >= 200 && result.status < 300) {
// Remove from local state (archived agents are filtered out)
setAgents(prev => prev.filter(agent => agent.id !== agentId));
console.log('✅ Agent archived successfully');
} else {
throw new Error(result.error || 'Failed to archive agent');
}
} catch (error) {
console.error('❌ Error archiving agent:', error);
alert('Failed to archive agent. Please try again.');
}
};
const handleDuplicateAgent = async (agent: EnhancedAgent) => {
try {
const newName = `${agent.name} (Copy)`;
const result = await agentService.forkAgent(agent.id, newName);
if (result.data && result.status >= 200 && result.status < 300) {
// Refresh the agents list
await loadAgents();
console.log('✅ Agent duplicated successfully');
} else {
throw new Error(result.error || 'Failed to duplicate agent');
}
} catch (error) {
console.error('❌ Error duplicating agent:', error);
alert('Failed to duplicate agent. Please try again.');
}
};
const handleViewHistory = (agent: EnhancedAgent) => {
// Navigate to chat page with agent filter
router.push(`/chat?agent=${agent.id}`);
};
const handleOpenCreateAgent = () => {
setTriggerCreate(true);
};
if (loading || loadingFavorites) {
return (
<AppLayout>
<div className="max-w-7xl mx-auto p-6">
<div className="text-center py-16">
<div className="text-lg text-gray-600">Loading agents...</div>
</div>
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="p-6 space-y-6">
{/* Header */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
<Bot className="w-8 h-8 text-gt-green" />
{viewMode === 'quick' ? 'Favorite Agents' : 'Agent Configuration'}
</h1>
{/* Removed subtitle text per issue #167 requirements */}
</div>
<div className="flex items-center gap-2">
{/* Action Button (changes based on view mode) - LEFT position */}
{viewMode === 'quick' ? (
<Button
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 focus:ring-green-500"
onClick={() => setShowFavoriteSelector(true)}
>
<Star className="w-4 h-4 mr-2" />
Add Favorites
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => setShowBulkImportModal(true)}
className="px-4 py-2"
>
<Upload className="w-4 h-4 mr-2" />
Import Agent
</Button>
<Button
onClick={handleOpenCreateAgent}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 focus:ring-blue-500"
>
<Plus className="w-4 h-4 mr-2" />
Create Agent
</Button>
</>
)}
{/* View Mode Toggle - Color-coded: Blue = Configuration, Green = Navigation - RIGHT position */}
{viewMode === 'quick' ? (
<Button
onClick={() => setViewMode('detailed')}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white focus:ring-blue-500"
>
<List className="w-4 h-4" />
Agent Configuration
</Button>
) : (
<Button
onClick={() => setViewMode('quick')}
className="flex items-center gap-2 px-4 py-2 bg-green-500 hover:bg-green-600 text-white focus:ring-green-500"
>
<LayoutGrid className="w-4 h-4" />
Back to Favorites
</Button>
)}
</div>
</div>
</div>
{/* Content: Quick View or Detailed View */}
{viewMode === 'quick' ? (
<>
{/* Quick View: Empty State or Agent Tiles */}
{favoriteAgentIds.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm border p-12">
<div className="text-center max-w-md mx-auto">
<Star className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-900 mb-2">
No favorites selected
</h3>
<p className="text-gray-600 mb-6">
Click on the button below to select your Favorite Agents from the list of agents available in the catalog.
</p>
<Button
onClick={() => setShowFavoriteSelector(true)}
className="bg-green-500 hover:bg-green-600 text-white focus:ring-green-500"
>
<Star className="w-4 h-4 mr-2" />
Add Favorites
</Button>
</div>
</div>
) : (
<>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
{/* Search */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 z-10" />
<Input
placeholder="Search favorite agents..."
value={searchQuery}
onChange={(value: string) => setSearchQuery(value)}
className="pl-10"
clearable
/>
</div>
{/* Filters */}
<div className="flex gap-2 items-center">
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent className="z-[100] backdrop-blur-sm bg-white/95 border shadow-lg" position="popper" sideOffset={5}>
<SelectItem value="all">All Categories</SelectItem>
{categories.map(category => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedTag} onValueChange={setSelectedTag}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Tag" />
</SelectTrigger>
<SelectContent className="z-[100] backdrop-blur-sm bg-white/95 border shadow-lg" position="popper" sideOffset={5}>
<SelectItem value="all">All Tags</SelectItem>
{tags.map(tag => (
<SelectItem key={tag} value={tag}>
{tag}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedCreator} onValueChange={setSelectedCreator}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Creator" />
</SelectTrigger>
<SelectContent className="z-[100] backdrop-blur-sm bg-white/95 border shadow-lg" position="popper" sideOffset={5}>
<SelectItem value="all">All Creators</SelectItem>
{creators.map(creator => (
<SelectItem key={creator} value={creator}>
{creator}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortBy)}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent className="z-[100] backdrop-blur-sm bg-white/95 border shadow-lg" position="popper" sideOffset={5}>
<SelectItem value="created_at">Date Created</SelectItem>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="usage_count">Usage (Global)</SelectItem>
<SelectItem value="recent_usage">Recently Used</SelectItem>
<SelectItem value="my_most_used">Most Used</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Agent Tiles Grid (4 columns) */}
{filteredFavoriteAgents.length === 0 ? (
<div className="text-center py-12 bg-white rounded-lg border">
<Search className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No agents found</h3>
<p className="text-gray-600">Try adjusting your search or filter criteria.</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
{filteredFavoriteAgents.map((agent) => (
<AgentQuickTile
key={agent.id}
agent={agent}
onSelect={handleSelectAgent}
/>
))}
</div>
)}
</>
)}
</>
) : (
/* Detailed View: Existing AgentGallery */
<AgentGallery
agents={agents}
onSelectAgent={handleSelectAgent}
onCreateAgent={handleCreateAgent}
onEditAgent={handleEditAgent}
onDeleteAgent={handleDeleteAgent}
onDuplicateAgent={handleDuplicateAgent}
onViewHistory={handleViewHistory}
hideHeader={true}
className="mt-0"
triggerCreate={triggerCreate}
onTriggerComplete={() => setTriggerCreate(false)}
/>
)}
{/* Favorite Agent Selector Modal */}
<FavoriteAgentSelectorModal
isOpen={showFavoriteSelector}
onClose={() => setShowFavoriteSelector(false)}
agents={agents}
currentFavorites={favoriteAgentIds}
onSave={handleSaveFavorites}
/>
{/* Bulk Import Modal */}
<AgentBulkImportModal
isOpen={showBulkImportModal}
onClose={() => setShowBulkImportModal(false)}
onImportComplete={loadAgents}
/>
</div>
</AppLayout>
);
}
export default function Page() {
return (
<AuthGuard requiredCapabilities={[GT2_CAPABILITIES.AGENTS_READ]}>
<AgentsPage />
</AuthGuard>
);
}

View File

@@ -0,0 +1,85 @@
import { NextResponse } from 'next/server';
/**
* Server-side API route to fetch tenant information from Control Panel backend
*
* This route runs on the Next.js server, so it can communicate with the Control Panel
* backend without CORS issues. The client fetches from this same-origin endpoint.
*
* Caching disabled to ensure immediate updates when tenant name changes in Control Panel.
*/
// Disable Next.js route caching (force fresh data on every request)
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export async function GET() {
try {
// Get configuration from server environment variables
const tenantDomain = process.env.TENANT_DOMAIN || 'test-company';
const controlPanelUrl = process.env.CONTROL_PANEL_URL || 'http://localhost:8001';
// Server-to-server request (no CORS)
// Disable caching to ensure fresh tenant data
const response = await fetch(
`${controlPanelUrl}/api/v1/tenant-info?tenant_domain=${tenantDomain}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
},
cache: 'no-store',
// Server-side fetch with reasonable timeout
signal: AbortSignal.timeout(5000),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
return NextResponse.json(
{
error: errorData.detail || 'Failed to fetch tenant info',
status: response.status
},
{ status: response.status }
);
}
const data = await response.json();
// Validate response has required fields
if (!data.name || !data.domain) {
return NextResponse.json(
{ error: 'Invalid tenant info response from Control Panel' },
{ status: 500 }
);
}
// Return with no-cache headers to prevent browser caching
return NextResponse.json(data, {
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
}
});
} catch (error) {
console.error('Server-side tenant info fetch error:', error);
// Check if it's a timeout error
if (error instanceof Error && error.name === 'TimeoutError') {
return NextResponse.json(
{ error: 'Control Panel backend timeout' },
{ status: 504 }
);
}
return NextResponse.json(
{ error: 'Failed to fetch tenant information' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,147 @@
/**
* Next.js API Route Proxy
*
* Proxies all /api/v1/* requests from browser to tenant-backend via Docker network.
* This is required because Next.js rewrites don't work for client-side fetch() calls.
*
* Flow:
* 1. Browser → fetch('/api/v1/models')
* 2. Next.js catches via this route (server-side)
* 3. Proxy → http://tenant-backend:8000/api/v1/models (Docker network)
* 4. Response → Return to browser
*/
import { NextRequest, NextResponse } from 'next/server';
const BACKEND_URL = process.env.INTERNAL_BACKEND_URL || 'http://tenant-backend:8000';
interface RouteContext {
params: Promise<{ path: string[] }>;
}
/**
* Proxy request to tenant-backend via Docker network
*/
async function proxyRequest(
request: NextRequest,
method: string,
path: string
): Promise<NextResponse> {
try {
const url = `${BACKEND_URL}/api/v1/${path}`;
console.log(`[API Proxy] ${method} /api/v1/${path}${url}`);
// Forward body for POST/PUT/PATCH
let body: string | FormData | undefined;
const contentType = request.headers.get('content-type');
const isMultipart = contentType?.includes('multipart/form-data');
if (['POST', 'PUT', 'PATCH'].includes(method)) {
if (isMultipart) {
body = await request.formData();
} else {
body = await request.text();
}
}
// Forward headers (auth, tenant domain, content-type)
const headers = new Headers();
request.headers.forEach((value, key) => {
const lowerKey = key.toLowerCase();
// Don't forward host-related headers
// Don't forward content-length or content-type for multipart/form-data
// (fetch will generate new headers with correct boundary)
if (!lowerKey.startsWith('host') &&
!lowerKey.startsWith('connection') &&
!(isMultipart && lowerKey === 'content-length') &&
!(isMultipart && lowerKey === 'content-type')) {
headers.set(key, value);
}
});
// Forward query parameters
const searchParams = request.nextUrl.searchParams.toString();
const finalUrl = searchParams ? `${url}?${searchParams}` : url;
// Make server-side request to backend via Docker network
// Follow redirects automatically (FastAPI trailing slash redirects)
const response = await fetch(finalUrl, {
method,
headers,
body,
redirect: 'follow',
});
console.log(`[API Proxy] Response: ${response.status} ${response.statusText}`);
// Forward response headers
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
responseHeaders.set(key, value);
});
// Return response to browser
return new NextResponse(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch (error) {
console.error(`[API Proxy] Error:`, error);
return NextResponse.json(
{
error: 'Proxy error',
message: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 502 }
);
}
}
// HTTP Method Handlers
export async function GET(request: NextRequest, context: RouteContext) {
const params = await context.params;
let path = params.path.join('/');
// Preserve trailing slash from original URL
if (request.nextUrl.pathname.endsWith('/')) {
path = path + '/';
}
return proxyRequest(request, 'GET', path);
}
export async function POST(request: NextRequest, context: RouteContext) {
const params = await context.params;
let path = params.path.join('/');
if (request.nextUrl.pathname.endsWith('/')) {
path = path + '/';
}
return proxyRequest(request, 'POST', path);
}
export async function PUT(request: NextRequest, context: RouteContext) {
const params = await context.params;
let path = params.path.join('/');
if (request.nextUrl.pathname.endsWith('/')) {
path = path + '/';
}
return proxyRequest(request, 'PUT', path);
}
export async function DELETE(request: NextRequest, context: RouteContext) {
const params = await context.params;
let path = params.path.join('/');
if (request.nextUrl.pathname.endsWith('/')) {
path = path + '/';
}
return proxyRequest(request, 'DELETE', path);
}
export async function PATCH(request: NextRequest, context: RouteContext) {
const params = await context.params;
let path = params.path.join('/');
if (request.nextUrl.pathname.endsWith('/')) {
path = path + '/';
}
return proxyRequest(request, 'PATCH', path);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
/**
* Conversations page redirect
*
* This page has been removed. Users are redirected to the chat page.
*/
export default function ConversationsPage() {
const router = useRouter();
useEffect(() => {
router.replace('/chat');
}, [router]);
return null;
}

View File

@@ -0,0 +1,474 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { Plus, Search, Filter, Database, FileText, BarChart3,
Trash2, Edit3, Eye, Lock, Users, Globe, Upload } from 'lucide-react';
import {
Dataset,
Document,
AccessGroup,
AccessFilter,
} from '@/services';
import { AppLayout } from '@/components/layout/app-layout';
import { AuthGuard } from '@/components/auth/auth-guard';
import { GT2_CAPABILITIES } from '@/lib/capabilities';
import {
DatasetCard,
DatasetCreateModal,
DatasetEditModal,
BulkUpload,
DocumentSummaryModal,
CreateDatasetData,
UpdateDatasetData,
DatasetDetailsDrawer
} from '@/components/datasets';
import { DatasetDocumentsModal } from '@/components/datasets/dataset-documents-modal';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { usePageTitle } from '@/hooks/use-page-title';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useDatasets, useDatasetSummary, useCreateDataset, useUpdateDataset, useDeleteDataset, datasetKeys } from '@/hooks/use-datasets';
import { useQueryClient } from '@tanstack/react-query';
import { formatStorageSize } from '@/lib/utils';
// Statistics interface
interface DatasetSummary {
total_datasets: number;
owned_datasets: number;
team_datasets: number;
org_datasets: number;
total_documents: number;
assigned_documents: number;
unassigned_documents: number;
total_storage_mb: number;
assigned_storage_mb: number;
unassigned_storage_mb: number;
is_admin?: boolean;
total_tenant_storage_mb?: number;
}
function DatasetsPageContent() {
usePageTitle('Datasets');
const searchParams = useSearchParams();
const queryClient = useQueryClient();
// Filter and search state
const [searchQuery, setSearchQuery] = useState('');
const [accessFilter, setAccessFilter] = useState<AccessFilter>('all');
// React Query hooks
const { data: datasets = [], isLoading: loading } = useDatasets(accessFilter);
const { data: summary = null } = useDatasetSummary();
const createDataset = useCreateDataset();
const updateDataset = useUpdateDataset();
const deleteDataset = useDeleteDataset();
// Helper to refresh dataset data
const refreshDatasets = () => {
queryClient.invalidateQueries({ queryKey: datasetKeys.all });
};
// UI state
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null);
const [showDetailsDrawer, setShowDetailsDrawer] = useState(false);
const [selectedDatasets, setSelectedDatasets] = useState<string[]>([]);
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showBulkUpload, setShowBulkUpload] = useState(false);
const [selectedDatasetForUpload, setSelectedDatasetForUpload] = useState<string>('');
const [editingDataset, setEditingDataset] = useState<Dataset | null>(null);
const [showDocumentSummary, setShowDocumentSummary] = useState(false);
const [summaryDocumentId, setSummaryDocumentId] = useState<string>('');
const [showDocumentsModal, setShowDocumentsModal] = useState(false);
const [documentsDatasetId, setDocumentsDatasetId] = useState<string | null>(null);
const [documentsDatasetName, setDocumentsDatasetName] = useState<string>('');
const [lastUploadedDatasetId, setLastUploadedDatasetId] = useState<string | null>(null);
const [initialDocuments, setInitialDocuments] = useState<any[]>([]); // Documents from recent upload
// Clear any stale dataset selections on mount to prevent foreign key errors
useEffect(() => {
setSelectedDatasetForUpload('');
setSelectedDatasets([]);
}, []);
// Dataset action handlers
const handleCreateDataset = async (datasetData: CreateDatasetData) => {
try {
const result = await createDataset.mutateAsync({
name: datasetData.name,
description: datasetData.description,
access_group: datasetData.access_group,
team_members: datasetData.team_members,
tags: datasetData.tags
});
console.log('Dataset created successfully:', result?.name);
} catch (error) {
console.error('Failed to create dataset:', error);
}
};
const handleDatasetView = (datasetId: string) => {
const dataset = datasets.find(d => d.id === datasetId);
setDocumentsDatasetId(datasetId);
setDocumentsDatasetName(dataset?.name || '');
setShowDocumentsModal(true);
};
const handleDatasetEdit = (datasetId: string) => {
const dataset = datasets.find(d => d.id === datasetId);
if (dataset) {
setEditingDataset(dataset);
setShowEditModal(true);
}
};
const handleUpdateDataset = async (datasetId: string, updateData: UpdateDatasetData) => {
try {
const result = await updateDataset.mutateAsync({ datasetId, updateData });
console.log('Dataset updated successfully:', result?.name);
setShowEditModal(false);
setEditingDataset(null);
} catch (error) {
console.error('Failed to update dataset:', error);
}
};
const handleDatasetDelete = async (datasetId: string) => {
if (!confirm('Are you sure you want to delete this dataset? This action cannot be undone.')) {
return;
}
try {
await deleteDataset.mutateAsync(datasetId);
console.log('Dataset deleted successfully');
} catch (error) {
console.error('Failed to delete dataset:', error);
}
};
const handleDatasetUpload = (datasetId: string) => {
console.log('Uploading to dataset:', datasetId);
// Verify the dataset still exists in our current list
const dataset = datasets.find(d => d.id === datasetId);
if (!dataset) {
console.error('Dataset not found:', datasetId);
alert('Dataset not found. Please refresh the page and try again.');
refreshDatasets(); // Refresh datasets
return;
}
// Store the dataset ID for routing after upload completes
setLastUploadedDatasetId(datasetId);
setSelectedDatasetForUpload(datasetId);
setShowBulkUpload(true);
};
const handleDatasetProcess = (datasetId: string) => {
console.log('Processing dataset:', datasetId);
// TODO: Trigger processing for all documents in dataset
};
const handleDatasetReindex = (datasetId: string) => {
console.log('Reindexing dataset:', datasetId);
// TODO: Trigger reindexing
};
// Filter datasets based on search query
const filteredDatasets = datasets.filter(dataset =>
dataset.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
dataset.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
dataset.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
// Get icon for access group
const getAccessIcon = (accessGroup: AccessGroup) => {
switch (accessGroup) {
case 'individual': return <Lock className="w-4 h-4" />;
case 'team': return <Users className="w-4 h-4" />;
case 'organization': return <Globe className="w-4 h-4" />;
default: return <Lock className="w-4 h-4" />;
}
};
// Get access group color
const getAccessColor = (accessGroup: AccessGroup) => {
switch (accessGroup) {
case 'individual': return 'text-gray-600';
case 'team': return 'text-blue-600';
case 'organization': return 'text-green-600';
default: return 'text-gray-600';
}
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
<Database className="w-8 h-8 text-gt-green" />
Dataset Management Hub
</h1>
<p className="text-gray-600 mt-1">
Manage your datasets and documents for RAG in one unified interface
</p>
</div>
</div>
</div>
{/* Statistics Cards */}
{summary && (
<div className={`grid grid-cols-1 gap-4 ${summary.is_admin ? 'md:grid-cols-3' : 'md:grid-cols-2'}`}>
<button
onClick={() => setAccessFilter('mine')}
className="w-full bg-gradient-to-br from-green-50 to-green-100 p-4 rounded-lg hover:shadow-md transition-all cursor-pointer"
>
<div className="flex items-center justify-between">
<div>
<p className="text-green-600 text-sm font-medium">My Datasets</p>
<p className="text-2xl font-bold text-green-900">{summary.owned_datasets}</p>
</div>
<Lock className="w-8 h-8 text-green-600" />
</div>
</button>
<div className="bg-gradient-to-br from-orange-50 to-orange-100 p-4 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-orange-600 text-sm font-medium">My Storage</p>
<p className="text-2xl font-bold text-orange-900">
{formatStorageSize(summary.total_storage_mb)}
</p>
{/* TODO: Show % of allocation when storage_allocation_mb added to tenant schema */}
</div>
<BarChart3 className="w-8 h-8 text-orange-600" />
</div>
</div>
{summary.is_admin && summary.total_tenant_storage_mb !== undefined && (
<div className="bg-gradient-to-br from-blue-50 to-blue-100 p-4 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="text-blue-600 text-sm font-medium">Total Tenant Storage</p>
<p className="text-2xl font-bold text-blue-900">
{formatStorageSize(summary.total_tenant_storage_mb)}
</p>
</div>
<BarChart3 className="w-8 h-8 text-blue-600" />
</div>
</div>
)}
</div>
)}
{/* Main Content Area */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<div className="space-y-6">
{/* Controls */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex flex-col sm:flex-row gap-4 flex-1">
{/* Search */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 z-10" />
<Input
type="text"
placeholder="Search datasets..."
value={searchQuery}
onChange={(value) => setSearchQuery(value)}
className="pl-10"
clearable
/>
</div>
{/* Access Filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-400" />
<Select value={accessFilter} onValueChange={(value: AccessFilter) => setAccessFilter(value)}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Filter by access" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Access</SelectItem>
<SelectItem value="mine">My Datasets</SelectItem>
<SelectItem value="org">Organization</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowBulkUpload(true)}
className="flex items-center gap-2"
>
<Upload className="w-4 h-4" />
Upload Documents
</Button>
<Button
size="sm"
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
New Dataset
</Button>
</div>
</div>
{/* Dataset List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gt-green"></div>
</div>
) : (
<div className="space-y-3">
{filteredDatasets.map((dataset) => (
<DatasetCard
key={dataset.id}
dataset={{
...dataset,
embedding_model: 'BAAI/bge-m3', // TODO: Get from dataset
search_method: 'hybrid', // TODO: Get from dataset
processing_status: 'idle' // TODO: Get actual status
}}
onView={handleDatasetView}
onEdit={handleDatasetEdit}
onDelete={handleDatasetDelete}
onUpload={handleDatasetUpload}
onProcess={handleDatasetProcess}
onReindex={handleDatasetReindex}
/>
))}
</div>
)}
{!loading && filteredDatasets.length === 0 && (
<div className="text-center py-12">
<Database className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No datasets found</h3>
<p className="text-gray-600 mb-6">
{searchQuery
? `No datasets match "${searchQuery}"`
: "Create your first dataset to get started"
}
</p>
<Button onClick={() => setShowCreateModal(true)}>
Create Dataset
</Button>
</div>
)}
</div>
</div>
{/* Modals */}
<DatasetCreateModal
open={showCreateModal}
onOpenChange={setShowCreateModal}
onCreateDataset={handleCreateDataset}
loading={createDataset.isPending}
/>
<BulkUpload
open={showBulkUpload}
onOpenChange={(open) => {
setShowBulkUpload(open);
if (!open) {
setSelectedDatasetForUpload(''); // Clear selection when modal closes
}
}}
datasets={datasets.map(d => ({
id: d.id,
name: d.name,
document_count: d.document_count
}))}
preselectedDatasetId={selectedDatasetForUpload}
onCreateDataset={() => {
setShowBulkUpload(false);
setShowCreateModal(true);
}}
onUploadStart={(datasetId, documents) => {
// Route to documents page immediately when upload starts
// Store any initial documents to display immediately
if (documents && documents.length > 0) {
setInitialDocuments(documents);
}
handleDatasetView(datasetId);
}}
onUploadComplete={async (results) => {
console.log('Upload documents completed:', results);
// React Query will auto-refresh via cache invalidation
}}
/>
<DatasetEditModal
open={showEditModal}
onOpenChange={(open) => {
setShowEditModal(open);
if (!open) {
setEditingDataset(null);
}
}}
onUpdateDataset={handleUpdateDataset}
dataset={editingDataset}
loading={updateDataset.isPending}
/>
<DatasetDetailsDrawer
datasetId={selectedDatasetId}
isOpen={showDetailsDrawer}
onClose={() => {
setShowDetailsDrawer(false);
setSelectedDatasetId(null);
}}
onDatasetDeleted={refreshDatasets}
onDatasetUpdated={refreshDatasets}
/>
<DocumentSummaryModal
open={showDocumentSummary}
onOpenChange={(open) => {
setShowDocumentSummary(open);
if (!open) {
setSummaryDocumentId('');
}
}}
documentId={summaryDocumentId}
/>
<DatasetDocumentsModal
open={showDocumentsModal}
onOpenChange={(open) => {
setShowDocumentsModal(open);
if (!open) {
setDocumentsDatasetId(null);
setDocumentsDatasetName('');
setInitialDocuments([]); // Clear initial documents when modal closes
}
}}
datasetId={documentsDatasetId}
datasetName={documentsDatasetName}
initialDocuments={initialDocuments}
/>
</div>
);
}
export default function DatasetsPage() {
return (
<AuthGuard requiredCapabilities={[GT2_CAPABILITIES.DATASETS_READ]}>
<AppLayout>
<DatasetsPageContent />
</AppLayout>
</AuthGuard>
);
}

View File

@@ -0,0 +1,877 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useDropzone } from 'react-dropzone';
import { Header } from '@/components/layout/header';
import { Sidebar } from '@/components/layout/sidebar';
import { Button } from '@/components/ui/button';
import { LoadingScreen } from '@/components/ui/loading-screen';
import { useAuthStore } from '@/stores/auth-store';
import { AuthGuard } from '@/components/auth/auth-guard';
import { GT2_CAPABILITIES } from '@/lib/capabilities';
import { listDocuments, listDatasets, uploadDocument, processDocument } from '@/services/documents';
import {
Upload,
File,
FileText,
FileImage,
FileCode,
FileArchive,
Search,
Filter,
Download,
Trash2,
Eye,
MoreVertical,
AlertCircle,
CheckCircle,
Clock,
Brain,
Database,
Layers,
RefreshCw,
Plus,
FolderOpen,
Tags,
Calendar,
User,
FileCheck,
Activity,
Zap,
} from 'lucide-react';
import { formatDateTime } from '@/lib/utils';
interface Document {
id: string;
filename: string;
original_name: string;
file_type: string;
file_size: number;
processing_status: 'pending' | 'processing' | 'completed' | 'failed';
chunk_count?: number;
vector_count?: number;
dataset_id?: string;
dataset_name?: string;
uploaded_at: string;
processed_at?: string;
error_message?: string;
metadata: {
pages?: number;
language?: string;
author?: string;
created_date?: string;
};
processing_progress?: number;
}
interface RAGDataset {
id: string;
name: string;
description: string;
document_count: number;
chunk_count: number;
vector_count: number;
embedding_model: string;
created_at: string;
updated_at: string;
status: 'active' | 'processing' | 'inactive';
storage_size_mb: number;
}
function DocumentsPageContent() {
const { user, isAuthenticated, isLoading } = useAuthStore();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [documents, setDocuments] = useState<Document[]>([]);
const [datasets, setDatasets] = useState<RAGDataset[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [selectedDataset, setSelectedDataset] = useState<string>('');
const [showCreateDataset, setShowCreateDataset] = useState(false);
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set());
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Load real data from API
useEffect(() => {
if (isAuthenticated) {
loadDocumentsAndDatasets();
}
}, [isAuthenticated]);
const loadDocumentsAndDatasets = async () => {
try {
setLoading(true);
// Load documents and datasets in parallel
const [documentsResponse, datasetsResponse] = await Promise.all([
listDocuments(),
listDatasets()
]);
if (documentsResponse.data) {
const docsWithMetadata = documentsResponse.data.map(doc => ({
id: doc.id,
filename: doc.filename,
original_name: doc.original_filename,
file_type: doc.file_type,
file_size: doc.file_size_bytes,
processing_status: doc.processing_status,
chunk_count: doc.chunk_count,
vector_count: doc.vector_count,
dataset_id: undefined, // Documents aren't necessarily in datasets
dataset_name: 'Individual Document',
uploaded_at: doc.created_at,
processed_at: doc.processed_at,
error_message: doc.error_details?.message,
metadata: {
// Add metadata extraction if available
},
processing_progress: doc.processing_status === 'processing' ? 50 : undefined
}));
setDocuments(docsWithMetadata);
}
if (datasetsResponse.data) {
const datasetsWithMetadata = datasetsResponse.data.map(ds => ({
id: ds.id,
name: ds.dataset_name,
description: ds.description || '',
document_count: ds.document_count,
chunk_count: ds.chunk_count,
vector_count: ds.vector_count,
embedding_model: ds.embedding_model,
created_at: ds.created_at,
updated_at: ds.updated_at,
status: 'active' as const,
storage_size_mb: Math.round(ds.total_size_bytes / (1024 * 1024) * 100) / 100
}));
setDatasets(datasetsWithMetadata);
}
} catch (error) {
console.error('Failed to load documents and datasets:', error);
// Fallback to empty arrays - user can still upload
setDocuments([]);
setDatasets([]);
} finally {
setLoading(false);
}
};
// Keep mock data as fallback for development if API fails
const loadMockData = () => {
if (isAuthenticated) {
const mockDocuments: Document[] = [
{
id: '1',
filename: 'company_handbook_2024.pdf',
original_name: 'Company Handbook 2024.pdf',
file_type: 'application/pdf',
file_size: 2048576, // 2MB
processing_status: 'completed',
chunk_count: 45,
vector_count: 45,
dataset_id: 'ds_1',
dataset_name: 'Company Policies',
uploaded_at: '2024-01-15T10:30:00Z',
processed_at: '2024-01-15T10:32:15Z',
metadata: {
pages: 67,
language: 'en',
author: 'HR Department',
created_date: '2024-01-01',
},
},
{
id: '2',
filename: 'technical_specs_v3.docx',
original_name: 'Technical Specifications v3.docx',
file_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
file_size: 1572864, // 1.5MB
processing_status: 'processing',
processing_progress: 67,
dataset_id: 'ds_2',
dataset_name: 'Technical Documentation',
uploaded_at: '2024-01-15T11:15:00Z',
metadata: {
pages: 23,
language: 'en',
},
},
{
id: '3',
filename: 'market_research_q4.xlsx',
original_name: 'Market Research Q4 2023.xlsx',
file_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
file_size: 512000, // 500KB
processing_status: 'failed',
error_message: 'Unsupported file format for text extraction',
uploaded_at: '2024-01-15T09:45:00Z',
metadata: {},
},
{
id: '4',
filename: 'project_proposal.txt',
original_name: 'Project Proposal - AI Initiative.txt',
file_type: 'text/plain',
file_size: 25600, // 25KB
processing_status: 'completed',
chunk_count: 8,
vector_count: 8,
dataset_id: 'ds_3',
dataset_name: 'Project Documents',
uploaded_at: '2024-01-14T16:20:00Z',
processed_at: '2024-01-14T16:21:30Z',
metadata: {
language: 'en',
},
},
{
id: '5',
filename: 'meeting_notes_jan.md',
original_name: 'Meeting Notes - January 2024.md',
file_type: 'text/markdown',
file_size: 12800, // 12.5KB
processing_status: 'pending',
uploaded_at: '2024-01-15T14:00:00Z',
metadata: {},
},
];
const mockDatasets: RAGDataset[] = [
{
id: 'ds_1',
name: 'Company Policies',
description: 'HR policies, handbooks, and company guidelines',
document_count: 12,
chunk_count: 234,
vector_count: 234,
embedding_model: 'BAAI/bge-m3',
created_at: '2024-01-10T09:00:00Z',
updated_at: '2024-01-15T10:32:15Z',
status: 'active',
storage_size_mb: 15.7,
},
{
id: 'ds_2',
name: 'Technical Documentation',
description: 'API docs, technical specifications, and architecture guides',
document_count: 8,
chunk_count: 156,
vector_count: 156,
embedding_model: 'BAAI/bge-m3',
created_at: '2024-01-12T14:30:00Z',
updated_at: '2024-01-15T11:15:00Z',
status: 'processing',
storage_size_mb: 8.2,
},
{
id: 'ds_3',
name: 'Project Documents',
description: 'Project proposals, meeting notes, and planning documents',
document_count: 5,
chunk_count: 67,
vector_count: 67,
embedding_model: 'BAAI/bge-m3',
created_at: '2024-01-08T11:00:00Z',
updated_at: '2024-01-14T16:21:30Z',
status: 'active',
storage_size_mb: 4.1,
},
];
setDocuments(mockDocuments);
setDatasets(mockDatasets);
setLoading(false);
}
};
// Filter documents based on search and status
const filteredDocuments = documents.filter(doc => {
const matchesSearch = searchQuery === '' ||
doc.original_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
doc.dataset_name?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === 'all' || doc.processing_status === statusFilter;
const matchesDataset = selectedDataset === '' || doc.dataset_id === selectedDataset;
return matchesSearch && matchesStatus && matchesDataset;
});
// File upload handling with real API
const handleFileUpload = useCallback(async (files: FileList | File[]) => {
setUploading(true);
// Convert FileList to Array
const fileArray = Array.from(files);
try {
// Upload files one by one
for (const file of fileArray) {
console.log('Uploading file:', file.name);
// Upload document
const uploadResponse = await uploadDocument(file, {
dataset_id: selectedDataset || undefined
});
if (uploadResponse.data) {
const uploadedDoc = uploadResponse.data;
// Add to documents list immediately
const newDocument = {
id: uploadedDoc.id,
filename: uploadedDoc.filename,
original_name: uploadedDoc.original_filename,
file_type: uploadedDoc.file_type,
file_size: uploadedDoc.file_size_bytes,
processing_status: uploadedDoc.processing_status,
chunk_count: uploadedDoc.chunk_count,
vector_count: uploadedDoc.vector_count,
dataset_id: undefined,
dataset_name: 'Individual Document',
uploaded_at: uploadedDoc.created_at,
processed_at: uploadedDoc.processed_at,
metadata: {}
};
setDocuments(prev => [newDocument, ...prev]);
// Auto-process the document
if (uploadedDoc.processing_status === 'pending') {
try {
await processDocument(uploadedDoc.id, 'hybrid');
console.log(`Started processing document: ${file.name}`);
// Update document status
setDocuments(prev => prev.map(doc =>
doc.id === uploadedDoc.id
? { ...doc, processing_status: 'processing' }
: doc
));
} catch (processError) {
console.error(`Failed to process document ${file.name}:`, processError);
}
}
} else if (uploadResponse.error) {
console.error(`Upload failed for ${file.name}:`, uploadResponse.error);
// You could show a toast notification here
}
}
// Reload documents and datasets to get updated stats
await loadDocumentsAndDatasets();
} catch (error) {
console.error('File upload error:', error);
} finally {
setUploading(false);
}
}, [selectedDataset]);
// Legacy simulation code kept as fallback
const simulateFileUpload = (files: FileList | File[]) => {
setUploading(true);
const fileArray = Array.from(files);
fileArray.forEach((file) => {
// Create a new document entry
const newDocument: Document = {
id: `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
filename: file.name.replace(/[^a-zA-Z0-9.-]/g, '_'),
original_name: file.name,
file_type: file.type,
file_size: file.size,
processing_status: 'pending',
uploaded_at: new Date().toISOString(),
metadata: {},
};
setDocuments(prev => [newDocument, ...prev]);
// Simulate processing delay
setTimeout(() => {
setDocuments(prev => prev.map(doc =>
doc.id === newDocument.id
? { ...doc, processing_status: 'processing', processing_progress: 0 }
: doc
));
// Simulate progress updates
const progressInterval = setInterval(() => {
setDocuments(prev => prev.map(doc => {
if (doc.id === newDocument.id && doc.processing_progress !== undefined) {
const newProgress = Math.min((doc.processing_progress || 0) + 15, 100);
if (newProgress >= 100) {
clearInterval(progressInterval);
return {
...doc,
processing_status: 'completed',
processing_progress: undefined,
chunk_count: Math.floor(Math.random() * 20) + 5,
vector_count: Math.floor(Math.random() * 20) + 5,
processed_at: new Date().toISOString(),
dataset_id: datasets[0]?.id,
dataset_name: datasets[0]?.name,
};
}
return { ...doc, processing_progress: newProgress };
}
return doc;
}));
}, 800);
}, 1000);
});
setTimeout(() => setUploading(false), 1500);
};
// File input change handler
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files && files.length > 0) {
handleFileUpload(files);
}
};
// Drag and drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleFileUpload(files);
}
};
// Click handler for upload area
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const getFileIcon = (fileType: string) => {
if (fileType.includes('pdf')) return <FileText className="h-5 w-5 text-red-600" />;
if (fileType.includes('image')) return <FileImage className="h-5 w-5 text-green-600" />;
if (fileType.includes('text') || fileType.includes('markdown')) return <FileText className="h-5 w-5 text-blue-600" />;
if (fileType.includes('code') || fileType.includes('json')) return <FileCode className="h-5 w-5 text-purple-600" />;
if (fileType.includes('zip') || fileType.includes('archive')) return <FileArchive className="h-5 w-5 text-orange-600" />;
return <File className="h-5 w-5 text-gray-600" />;
};
const getStatusBadge = (status: string, progress?: number) => {
switch (status) {
case 'completed':
return (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<CheckCircle className="h-3 w-3 mr-1" />
Processed
</span>
);
case 'processing':
return (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
Processing {progress ? `${progress}%` : ''}
</span>
);
case 'pending':
return (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<Clock className="h-3 w-3 mr-1" />
Pending
</span>
);
case 'failed':
return (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
<AlertCircle className="h-3 w-3 mr-1" />
Failed
</span>
);
default:
return null;
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
if (isLoading) {
return <LoadingScreen />;
}
if (!isAuthenticated) {
return <div>Please log in to access documents.</div>;
}
return (
<div className="h-screen flex bg-gt-gray-50">
{/* Sidebar */}
<Sidebar
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
user={{ id: 1, email: "user@example.com" }}
onMenuClick={() => {}}
/>
{/* Main Content */}
<div className="flex-1 flex flex-col">
{/* Header */}
<Header
user={user}
onMenuClick={() => setSidebarOpen(true)}
/>
{/* Documents Interface */}
<main className="flex-1 bg-gt-white overflow-hidden">
<div className="h-full flex flex-col p-6">
{/* Page Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gt-gray-900">Document Management</h1>
<p className="text-gt-gray-600 mt-1">
Upload, process, and manage your documents for AI-powered search and analysis
</p>
</div>
<div className="flex space-x-3">
<Button
variant="secondary"
onClick={() => setShowCreateDataset(true)}
>
<Plus className="h-4 w-4 mr-2" />
New Dataset
</Button>
<Button onClick={handleUploadClick}>
<Upload className="h-4 w-4 mr-2" />
Upload Files
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.doc,.docx,.txt,.md,.csv,.json"
onChange={handleFileInputChange}
className="hidden"
/>
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg border border-gt-gray-200 p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<FileText className="h-6 w-6 text-blue-600" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gt-gray-500">Total Documents</p>
<p className="text-lg font-semibold text-gt-gray-900">{documents.length}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gt-gray-200 p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Database className="h-6 w-6 text-green-600" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gt-gray-500">RAG Datasets</p>
<p className="text-lg font-semibold text-gt-gray-900">{datasets.length}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gt-gray-200 p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Layers className="h-6 w-6 text-purple-600" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gt-gray-500">Total Chunks</p>
<p className="text-lg font-semibold text-gt-gray-900">
{documents.reduce((sum, doc) => sum + (doc.chunk_count || 0), 0)}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gt-gray-200 p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Brain className="h-6 w-6 text-orange-600" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gt-gray-500">Vector Embeddings</p>
<p className="text-lg font-semibold text-gt-gray-900">
{documents.reduce((sum, doc) => sum + (doc.vector_count || 0), 0)}
</p>
</div>
</div>
</div>
</div>
{/* Upload Area */}
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleUploadClick}
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer mb-6 ${
dragActive
? 'border-gt-green bg-gt-green/5'
: 'border-gt-gray-300 hover:border-gt-green hover:bg-gt-gray-50'
}`}
>
<Upload className="h-12 w-12 text-gt-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gt-gray-900 mb-2">
{dragActive ? 'Drop files here' : 'Upload Documents'}
</h3>
<p className="text-gt-gray-600 mb-4">
Drag and drop files here, or click to select files
</p>
<p className="text-sm text-gt-gray-500">
Supports PDF, DOC, DOCX, TXT, MD, CSV, and JSON files up to 10MB
</p>
</div>
{/* Filters and Search */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex-1">
<div className="relative">
<Search className="h-5 w-5 text-gt-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search documents..."
value={searchQuery}
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
className="w-full pl-10 pr-4 py-2 border border-gt-gray-300 rounded-lg focus:ring-2 focus:ring-gt-green focus:border-transparent"
/>
</div>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter((e as React.ChangeEvent<HTMLSelectElement>).target.value)}
className="px-3 py-2 border border-gt-gray-300 rounded-lg focus:ring-2 focus:ring-gt-green focus:border-transparent"
>
<option value="all">All Status</option>
<option value="completed">Processed</option>
<option value="processing">Processing</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
</select>
<select
value={selectedDataset}
onChange={(e) => setSelectedDataset((e as React.ChangeEvent<HTMLSelectElement>).target.value)}
className="px-3 py-2 border border-gt-gray-300 rounded-lg focus:ring-2 focus:ring-gt-green focus:border-transparent"
>
<option value="">All Datasets</option>
{datasets.map(dataset => (
<option key={dataset.id} value={dataset.id}>{dataset.name}</option>
))}
</select>
</div>
{/* RAG Datasets Overview */}
<div className="mb-6">
<h2 className="text-lg font-semibold text-gt-gray-900 mb-3">RAG Datasets</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{datasets.map(dataset => (
<div key={dataset.id} className="bg-white rounded-lg border border-gt-gray-200 p-4">
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium text-gt-gray-900">{dataset.name}</h3>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
dataset.status === 'active' ? 'bg-green-100 text-green-800' :
dataset.status === 'processing' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{dataset.status}
</span>
</div>
<p className="text-sm text-gt-gray-600 mb-3">{dataset.description}</p>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gt-gray-500">Documents:</span>
<span className="ml-1 font-medium">{dataset.document_count}</span>
</div>
<div>
<span className="text-gt-gray-500">Chunks:</span>
<span className="ml-1 font-medium">{dataset.chunk_count}</span>
</div>
<div>
<span className="text-gt-gray-500">Vectors:</span>
<span className="ml-1 font-medium">{dataset.vector_count}</span>
</div>
<div>
<span className="text-gt-gray-500">Size:</span>
<span className="ml-1 font-medium">{dataset.storage_size_mb.toFixed(1)} MB</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Documents List */}
<div className="flex-1 overflow-hidden">
<div className="bg-white rounded-lg border border-gt-gray-200 h-full flex flex-col">
<div className="px-6 py-4 border-b border-gt-gray-200">
<h2 className="text-lg font-semibold text-gt-gray-900">
Documents ({filteredDocuments.length})
</h2>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="p-6 space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="flex items-center space-x-4">
<div className="w-10 h-10 bg-gt-gray-200 rounded"></div>
<div className="flex-1">
<div className="w-1/2 h-4 bg-gt-gray-200 rounded mb-2"></div>
<div className="w-1/4 h-3 bg-gt-gray-200 rounded"></div>
</div>
</div>
</div>
))}
</div>
) : filteredDocuments.length === 0 ? (
<div className="p-12 text-center">
<FolderOpen className="h-12 w-12 text-gt-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gt-gray-900 mb-2">No documents found</h3>
<p className="text-gt-gray-600">
Upload your first document to get started with AI-powered document search.
</p>
</div>
) : (
<div className="divide-y divide-gt-gray-200">
{filteredDocuments.map(document => (
<div key={document.id} className="p-6 hover:bg-gt-gray-50 transition-colors">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0">
{getFileIcon(document.file_type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-sm font-medium text-gt-gray-900 truncate">
{document.original_name}
</h3>
<div className="mt-1 flex items-center space-x-4 text-sm text-gt-gray-500">
<span>{formatFileSize(document.file_size)}</span>
<span></span>
<span>{formatDateTime(document.uploaded_at)}</span>
{document.dataset_name && (
<>
<span></span>
<span className="text-gt-green font-medium">{document.dataset_name}</span>
</>
)}
</div>
{document.processing_status === 'completed' && (
<div className="mt-2 flex items-center space-x-4 text-sm text-gt-gray-600">
<span className="flex items-center">
<Layers className="h-3 w-3 mr-1" />
{document.chunk_count} chunks
</span>
<span className="flex items-center">
<Brain className="h-3 w-3 mr-1" />
{document.vector_count} vectors
</span>
{document.metadata.pages && (
<span className="flex items-center">
<FileText className="h-3 w-3 mr-1" />
{document.metadata.pages} pages
</span>
)}
</div>
)}
{document.error_message && (
<div className="mt-2 text-sm text-red-600">
{document.error_message}
</div>
)}
</div>
<div className="flex items-center space-x-3 ml-4">
{getStatusBadge(document.processing_status, document.processing_progress)}
<div className="flex items-center space-x-1">
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<Download className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</main>
</div>
{/* Mobile Sidebar Overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
</div>
);
}
export default function DocumentsPage() {
return (
<AuthGuard requiredCapabilities={[GT2_CAPABILITIES.DOCUMENTS_READ]}>
<DocumentsPageContent />
</AuthGuard>
);
}

View File

@@ -0,0 +1,246 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* GT 2.0 Custom Styles */
@layer base {
:root {
--gt-white: #ffffff;
--gt-black: #000000;
--gt-green: #00d084;
--gt-gray-50: #fafbfc;
--gt-gray-100: #f4f6f8;
--gt-gray-200: #e8ecef;
--gt-gray-300: #d1d9e0;
--gt-gray-400: #9aa5b1;
--gt-gray-500: #677489;
--gt-gray-600: #4a5568;
--gt-gray-700: #2d3748;
--gt-gray-800: #1a202c;
--gt-gray-900: #171923;
}
html {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'SF Pro Display', Roboto, Helvetica, Arial, sans-serif;
}
body {
@apply bg-gt-white text-gt-gray-900 antialiased;
}
}
@layer components {
/* Chat Interface Styles */
.chat-container {
@apply grid grid-rows-[auto_1fr_auto] h-screen bg-gt-white;
}
.chat-messages {
@apply flex-1 overflow-y-auto px-4 py-6 space-y-4;
}
.message-user {
@apply bg-gt-gray-100 text-gt-gray-900 ml-12 rounded-2xl rounded-br-sm px-4 py-3;
}
.message-system {
@apply bg-gt-gray-50 text-gt-gray-700 mx-8 rounded-lg px-3 py-2 text-sm text-center;
}
/* Input Styles */
.chat-input {
@apply bg-gt-white border-2 border-gt-gray-200 rounded-xl px-4 py-3 transition-colors duration-150 resize-none;
}
.chat-input:focus {
@apply outline-none border-gt-green shadow-[0_0_0_3px_rgba(0,208,132,0.1)];
}
/* Button Styles */
.btn-primary {
@apply bg-gt-green text-gt-white font-medium px-4 py-2 rounded-lg transition-all duration-150 hover:bg-opacity-90 hover:-translate-y-0.5 hover:shadow-md;
}
.btn-secondary {
@apply bg-gt-white text-gt-gray-700 font-medium px-4 py-2 rounded-lg border border-gt-gray-300 transition-all duration-150 hover:bg-gt-gray-50;
}
.btn-ghost {
@apply bg-transparent text-gt-gray-600 font-medium px-4 py-2 rounded-lg transition-all duration-150 hover:bg-gt-gray-50;
}
/* Status Indicators */
.status-indicator {
@apply w-2 h-2 rounded-full;
}
.status-online {
@apply bg-gt-green;
}
.status-offline {
@apply bg-gt-gray-400;
}
.status-error {
@apply bg-red-500;
}
/* Animations */
.typing-indicator {
@apply flex items-center gap-1;
}
.typing-dot {
@apply w-1.5 h-1.5 bg-gt-green rounded-full animate-neural-pulse;
}
/* Chat Animation Classes */
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
.animate-fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Enhanced Message Styles */
.message-agent {
@apply bg-gradient-to-r from-gt-green to-green-600 text-white mr-12 rounded-2xl rounded-bl-sm px-4 py-3 shadow-lg;
}
.message-agent:hover {
@apply shadow-xl transform-gpu;
}
/* Code Block Improvements */
.hljs {
background: #1a202c !important;
color: #e2e8f0 !important;
}
/* Scrollbar Styling */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: rgba(0, 208, 132, 0.3);
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: rgba(0, 208, 132, 0.5);
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
/* Document Upload */
.upload-zone {
@apply border-2 border-dashed border-gt-gray-300 rounded-lg p-8 text-center bg-gt-gray-25 transition-all duration-200;
}
.upload-zone.dragover {
@apply border-gt-green bg-gt-green bg-opacity-5;
}
/* Loading States */
.loading-skeleton {
@apply bg-gt-gray-200 animate-pulse-gentle rounded;
}
/* Security Indicators */
.security-badge {
@apply inline-flex items-center px-2 py-1 rounded-sm text-xs font-medium;
}
.security-badge.secure {
@apply bg-green-100 text-green-700 border border-green-300;
}
.security-badge.warning {
@apply bg-yellow-100 text-yellow-700 border border-yellow-300;
}
.security-badge.error {
@apply bg-red-100 text-red-700 border border-red-300;
}
}
@layer utilities {
/* Custom Select - Hide Native Arrow */
.select-no-arrow {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: none;
}
.select-no-arrow::-ms-expand {
display: none;
}
/* Custom Scrollbar */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--gt-gray-300) var(--gt-gray-100);
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: var(--gt-gray-100);
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: var(--gt-gray-300);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: var(--gt-gray-400);
}
/* Text Utilities */
.text-balance {
text-wrap: balance;
}
/* Animation Utilities */
.animate-fade-in {
animation: fadeIn 0.5s ease-in-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
service: 'gt2-tenant-app',
version: '1.0.0'
});
}

View File

@@ -0,0 +1,19 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
/**
* Home page redirect
*
* This page has been removed. Users are redirected to the agents page.
*/
export default function HomePage() {
const router = useRouter();
useEffect(() => {
router.replace('/agents');
}, [router]);
return null;
}

View File

@@ -0,0 +1,74 @@
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from '@/lib/providers';
const inter = Inter({ subsets: ['latin'] });
// Viewport must be exported separately in Next.js 14+
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
};
export const metadata: Metadata = {
title: {
template: 'GT AI OS | %s',
default: 'GT AI OS'
},
description: 'Your intelligent AI agent for enterprise workflows and decision-making',
keywords: ['AI', 'agent', 'enterprise', 'chat', 'documents', 'productivity'],
authors: [{ name: 'GT Edge AI' }],
robots: 'noindex, nofollow', // Tenant apps should not be indexed
manifest: '/manifest.json',
icons: {
icon: '/favicon.png',
shortcut: '/favicon.png',
apple: '/gt-logo.png'
},
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'GT AI OS'
}
};
interface RootLayoutProps {
children: React.ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en" className="h-full">
<head>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="format-detection" content="telephone=no" />
<link rel="icon" href="/gt-small-logo.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
{/* Console log suppression - controlled by NEXT_PUBLIC_DISABLE_CONSOLE_LOGS */}
{process.env.NEXT_PUBLIC_DISABLE_CONSOLE_LOGS === 'true' && (
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
// Override console methods to suppress logs
// Keep error and warn for critical issues
console.log = function() {};
console.debug = function() {};
console.info = function() {};
})();
`,
}}
/>
)}
</head>
<body className={`${inter.className} h-full antialiased`}>
<Providers>
<div className="flex flex-col h-full bg-gt-white text-gt-gray-900">
{children}
</div>
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { LoginForm } from '@/components/auth/login-form';
import { useAuthStore } from '@/stores/auth-store';
interface LoginPageClientProps {
tenantName: string;
}
/**
* Session expiration type for different messages (Issue #242)
* - 'idle': 30-minute inactivity timeout
* - 'absolute': 8-hour session limit reached
*/
type SessionExpiredType = 'idle' | 'absolute' | null;
/**
* Client Component - Handles auth checks and redirects
* Receives tenant name from Server Component (no flash)
*/
export function LoginPageClient({ tenantName }: LoginPageClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated, checkAuth } = useAuthStore();
const [sessionExpiredType, setSessionExpiredType] = useState<SessionExpiredType>(null);
useEffect(() => {
document.title = 'GT AI OS | Login';
}, []);
useEffect(() => {
// Check for session expiration parameter (NIST/OWASP Issue #242)
const sessionExpiredParam = searchParams.get('session_expired');
if (sessionExpiredParam === 'true') {
// Idle timeout (30 min inactivity)
setSessionExpiredType('idle');
} else if (sessionExpiredParam === 'absolute') {
// Absolute timeout (8 hour session limit)
setSessionExpiredType('absolute');
}
// Clean up the URL by removing the query parameter (after a delay to show the message)
if (sessionExpiredParam) {
setTimeout(() => {
router.replace('/login');
}, 100);
}
}, [searchParams, router]);
useEffect(() => {
// Check authentication status on mount to sync localStorage with store
checkAuth();
}, [checkAuth]);
useEffect(() => {
// Don't auto-redirect if we just completed TFA (prevents race condition flash)
if (sessionStorage.getItem('gt2_tfa_verified')) {
return;
}
// Redirect to agents if already authenticated (prevents redirect loop)
if (isAuthenticated) {
router.push('/agents');
}
}, [isAuthenticated, router]);
// Get the appropriate message based on expiration type (Issue #242)
const getSessionExpiredMessage = (): string => {
if (sessionExpiredType === 'absolute') {
return 'Your session has reached the maximum duration (8 hours). Please log in again.';
}
return 'Your session has expired due to inactivity. Please log in again.';
};
return (
<>
{sessionExpiredType && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 animate-in fade-in slide-in-from-top-2">
<div className="bg-red-50 border border-red-200 text-red-800 px-6 py-3 rounded-lg shadow-lg flex items-center gap-3">
<svg
className="w-5 h-5 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">{getSessionExpiredMessage()}</span>
</div>
</div>
)}
<LoginForm tenantName={tenantName} />
</>
);
}

View File

@@ -0,0 +1,51 @@
import Image from 'next/image';
import { LoginPageClient } from './login-page-client';
// Force dynamic rendering - this page needs runtime data
export const dynamic = 'force-dynamic';
/**
* Server Component - Fetches tenant name before rendering
* This eliminates the flash/delay when displaying tenant name
*/
async function getTenantName(): Promise<string> {
try {
const controlPanelUrl = process.env.CONTROL_PANEL_URL || 'http://control-panel-backend:8000';
const tenantDomain = process.env.TENANT_DOMAIN || 'test-company';
const response = await fetch(
`${controlPanelUrl}/api/v1/tenant-info?tenant_domain=${tenantDomain}`,
{
cache: 'no-store',
signal: AbortSignal.timeout(5000),
}
);
if (response.ok) {
const data = await response.json();
return data.name || '';
}
} catch (error) {
console.error('Failed to fetch tenant name on server:', error);
}
return '';
}
export default async function LoginPage() {
// Fetch tenant name on server before rendering
const tenantName = await getTenantName();
return (
<div className="min-h-screen bg-gradient-to-br from-gt-gray-50 to-gt-gray-100 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
<div className="relative z-10">
<LoginPageClient tenantName={tenantName} />
<div className="text-center mt-8 text-sm text-gt-gray-500 space-y-2">
<p className="text-xs">GT AI OS Community | v2.0.33</p>
<p>© 2025 GT Edge AI. All rights reserved.</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import { AppLayout } from '@/components/layout/app-layout';
import { AuthGuard } from '@/components/auth/auth-guard';
import { usePageTitle } from '@/hooks/use-page-title';
import { ObservabilityDashboard } from '@/components/observability/observability-dashboard';
/**
* Observability Dashboard Page
* Available to all authenticated users with role-based data filtering:
* - Admins/Developers: See all platform activity with user filtering
* - Analysts/Students: See only their personal activity
*
* Features:
* - Overview metrics (conversations, messages, tokens, users)
* - Time series charts for usage trends
* - Breakdown by user, agent, and model
* - Full conversation browser with content viewing
* - CSV/JSON export functionality
*/
export default function ObservabilityPage() {
usePageTitle('Observability');
return (
<AuthGuard>
<AppLayout>
<ObservabilityDashboard />
</AppLayout>
</AuthGuard>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { isTokenValid } from '@/services/auth';
import { LoadingScreen } from '@/components/ui/loading-screen';
/**
* Root page with authentication-aware redirect
*
* Checks authentication state and redirects appropriately:
* - Authenticated users go to /agents (home page)
* - Unauthenticated users go to /login
*
* This prevents the redirect loop that causes login page flickering.
*/
export default function RootPage() {
const router = useRouter();
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
const checkAuthAndRedirect = () => {
try {
// Check if user is authenticated
if (isTokenValid()) {
// Authenticated - go to agents (home page)
router.replace('/agents');
} else {
// Not authenticated - go to login
router.replace('/login');
}
} catch (error) {
console.error('Auth check failed:', error);
// On error, redirect to login for safety
router.replace('/login');
} finally {
setIsChecking(false);
}
};
checkAuthAndRedirect();
}, [router]);
// Show loading screen while checking authentication
if (isChecking) {
return <LoadingScreen message="Loading GT 2.0..." />;
}
// Fallback - should not be visible due to redirects
return null;
}

View File

@@ -0,0 +1,80 @@
'use client';
import { AppLayout } from '@/components/layout/app-layout';
import { AuthGuard } from '@/components/auth/auth-guard';
import { useAuthStore } from '@/stores/auth-store';
import { TFASettings } from '@/components/settings/tfa-settings';
import { User } from 'lucide-react';
import { usePageTitle } from '@/hooks/use-page-title';
export default function SettingsPage() {
usePageTitle('Settings');
const { user } = useAuthStore();
return (
<AuthGuard>
<AppLayout>
<div className="min-h-screen bg-gradient-to-br from-gt-gray-50 to-gt-gray-100">
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gt-gray-900">Account Settings</h1>
<p className="text-gt-gray-600 mt-2">
Manage your account preferences and security settings
</p>
</div>
{/* Profile Information */}
<div className="bg-white border border-gt-gray-200 rounded-lg p-6 space-y-4">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-gt-green rounded-full flex items-center justify-center">
<User className="w-8 h-8 text-white" />
</div>
<div>
<h2 className="text-xl font-semibold text-gt-gray-900">
{user?.full_name || 'User'}
</h2>
<p className="text-sm text-gt-gray-600">{user?.email}</p>
{user?.user_type && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gt-blue-100 text-gt-blue-800 mt-1">
{user.user_type.replace('_', ' ').toUpperCase()}
</span>
)}
</div>
</div>
</div>
{/* Security Section */}
<div className="space-y-4">
<div className="border-b border-gt-gray-200 pb-2">
<h2 className="text-xl font-semibold text-gt-gray-900">Security</h2>
<p className="text-sm text-gt-gray-600 mt-1">
Manage your account security and authentication methods
</p>
</div>
{/* TFA Settings Component */}
<TFASettings />
</div>
{/* Additional Settings Sections (Placeholder for future) */}
{/*
<div className="space-y-4">
<div className="border-b border-gt-gray-200 pb-2">
<h2 className="text-xl font-semibold text-gt-gray-900">Preferences</h2>
<p className="text-sm text-gt-gray-600 mt-1">
Customize your GT Edge AI experience
</p>
</div>
<div className="bg-white border border-gt-gray-200 rounded-lg p-6">
<p className="text-sm text-gt-gray-500">Additional preferences coming soon...</p>
</div>
</div>
*/}
</div>
</div>
</AppLayout>
</AuthGuard>
);
}

View File

@@ -0,0 +1,303 @@
'use client';
import { useState } from 'react';
import { Plus, Users, Search } from 'lucide-react';
import { AppLayout } from '@/components/layout/app-layout';
import { AuthGuard } from '@/components/auth/auth-guard';
import { GT2_CAPABILITIES } from '@/lib/capabilities';
import {
TeamCard,
TeamCreateModal,
TeamEditModal,
DeleteTeamDialog,
LeaveTeamDialog,
TeamManagementPanel,
InvitationPanel,
ObservableRequestPanel,
type CreateTeamData,
type UpdateTeamData
} from '@/components/teams';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { usePageTitle } from '@/hooks/use-page-title';
import {
useTeams,
useCreateTeam,
useUpdateTeam,
useDeleteTeam,
useRemoveTeamMember
} from '@/hooks/use-teams';
import type { Team } from '@/services';
import { getAuthToken, parseTokenPayload } from '@/services/auth';
function TeamsPageContent() {
usePageTitle('Teams');
// Search state
const [searchQuery, setSearchQuery] = useState('');
// React Query hooks
const { data: teams = [], isLoading: loading } = useTeams();
const createTeam = useCreateTeam();
const updateTeam = useUpdateTeam();
const deleteTeam = useDeleteTeam();
const removeTeamMember = useRemoveTeamMember();
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
const [showManagementPanel, setShowManagementPanel] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
// Filter teams by search query
const filteredTeams = teams.filter(team =>
team.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
team.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
// Calculate stats
const ownedTeams = teams.filter(t => t.is_owner).length;
const memberTeams = teams.filter(t => !t.is_owner).length;
// Team action handlers
const handleCreateTeam = async (data: CreateTeamData) => {
try {
await createTeam.mutateAsync(data);
console.log('Team created successfully');
} catch (error) {
console.error('Failed to create team:', error);
}
};
const handleEditTeam = (teamId: string) => {
const team = teams.find(t => t.id === teamId);
if (team) {
setSelectedTeam(team);
setShowEditModal(true);
}
};
const handleUpdateTeam = async (teamId: string, data: UpdateTeamData) => {
try {
await updateTeam.mutateAsync({ teamId, data });
console.log('Team updated successfully');
} catch (error) {
console.error('Failed to update team:', error);
}
};
const handleDeleteTeam = (teamId: string) => {
const team = teams.find(t => t.id === teamId);
if (team) {
setSelectedTeam(team);
setShowDeleteDialog(true);
}
};
const handleConfirmDelete = async (teamId: string) => {
try {
await deleteTeam.mutateAsync(teamId);
console.log('Team deleted successfully');
} catch (error) {
console.error('Failed to delete team:', error);
}
};
const handleManageTeam = (teamId: string) => {
const team = teams.find(t => t.id === teamId);
if (team) {
setSelectedTeam(team);
setShowManagementPanel(true);
}
};
const handleLeaveTeam = (teamId: string) => {
const team = teams.find(t => t.id === teamId);
if (team) {
setSelectedTeam(team);
setShowLeaveDialog(true);
}
};
const handleConfirmLeave = async (teamId: string) => {
try {
// Get user ID from JWT token
const token = getAuthToken();
if (!token) {
console.error('No auth token found');
alert('Authentication required. Please log in again.');
return;
}
const payload = parseTokenPayload(token);
if (!payload?.sub) {
console.error('User ID not found in token');
alert('Invalid authentication. Please log in again.');
return;
}
// sub contains the user ID from the JWT
await removeTeamMember.mutateAsync({ teamId, userId: payload.sub });
console.log('Successfully left team');
} catch (error: any) {
console.error('Failed to leave team:', error);
alert(`Failed to leave team: ${error.message || 'An error occurred'}`);
}
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
<Users className="w-8 h-8 text-gt-green" />
Teams
</h1>
<p className="text-gray-600 mt-1">
Collaborate and share resources with your team
</p>
</div>
<Button
onClick={() => setShowCreateModal(true)}
className="bg-gt-green hover:bg-gt-green/90"
>
<Plus className="w-4 h-4 mr-2" />
Create Team
</Button>
</div>
{/* Stats */}
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-600">Total:</span>
<span className="font-semibold text-gray-900">{teams.length}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-600">Owned:</span>
<span className="font-semibold text-gt-green">{ownedTeams}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-600">Member of:</span>
<span className="font-semibold text-blue-600">{memberTeams}</span>
</div>
</div>
</div>
{/* Pending Invitations */}
<InvitationPanel />
{/* Observable Requests */}
<ObservableRequestPanel />
{/* Search Bar */}
<div className="bg-white rounded-lg shadow-sm border p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5 z-10" />
<Input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(value) => setSearchQuery(value)}
className="pl-10"
clearable
/>
</div>
</div>
{/* Teams List */}
{loading ? (
<div className="bg-white rounded-lg shadow-sm border p-12 text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-gt-green border-r-transparent"></div>
<p className="text-gray-600 mt-4">Loading teams...</p>
</div>
) : filteredTeams.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm border p-12 text-center">
<Users className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{searchQuery ? 'No teams found' : 'No teams yet'}
</h3>
<p className="text-gray-600 mb-6">
{searchQuery
? 'Try adjusting your search query'
: 'Create your first team to start collaborating'}
</p>
{!searchQuery && (
<Button
onClick={() => setShowCreateModal(true)}
className="bg-gt-green hover:bg-gt-green/90"
>
<Plus className="w-4 h-4 mr-2" />
Create Team
</Button>
)}
</div>
) : (
<div className="space-y-3">
{filteredTeams.map(team => (
<TeamCard
key={team.id}
team={team}
onManage={handleManageTeam}
onEdit={handleEditTeam}
onDelete={handleDeleteTeam}
onLeave={handleLeaveTeam}
/>
))}
</div>
)}
{/* Modals */}
<TeamCreateModal
open={showCreateModal}
onOpenChange={setShowCreateModal}
onCreateTeam={handleCreateTeam}
loading={createTeam.isPending}
/>
<TeamEditModal
open={showEditModal}
team={selectedTeam}
onOpenChange={setShowEditModal}
onUpdateTeam={handleUpdateTeam}
loading={updateTeam.isPending}
/>
<DeleteTeamDialog
open={showDeleteDialog}
team={selectedTeam}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
loading={deleteTeam.isPending}
/>
<LeaveTeamDialog
open={showLeaveDialog}
team={selectedTeam}
onOpenChange={setShowLeaveDialog}
onConfirm={handleConfirmLeave}
loading={removeTeamMember.isPending}
/>
<TeamManagementPanel
open={showManagementPanel}
team={selectedTeam}
onOpenChange={setShowManagementPanel}
/>
</div>
);
}
export default function TeamsPage() {
return (
<AuthGuard requiredCapabilities={[GT2_CAPABILITIES.DATASETS_READ]}>
<AppLayout>
<TeamsPageContent />
</AppLayout>
</AuthGuard>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import { TestLayout } from '@/components/layout/test-layout';
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Plus, Play, Settings, Brain, Code, Shield } from 'lucide-react';
import { mockApi } from '@/lib/mock-api';
import { formatDateOnly } from '@/lib/utils';
export default function TestAgentsPage() {
const [agents, setAgents] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadAgents();
}, []);
const loadAgents = async () => {
try {
const data = await mockApi.agents.list();
setAgents(data.agents);
} catch (error) {
console.error('Failed to load agents:', error);
} finally {
setLoading(false);
}
};
const getAgentIcon = (type: string) => {
switch (type) {
case 'research': return <Brain className="w-5 h-5" />;
case 'coding': return <Code className="w-5 h-5" />;
case 'security': return <Shield className="w-5 h-5" />;
default: return <Brain className="w-5 h-5" />;
}
};
return (
<TestLayout>
<div className="p-6">
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">AI Agents</h1>
<p className="text-gray-600 mt-1">Manage your autonomous AI agents</p>
</div>
<Button className="bg-green-600 hover:bg-green-700 text-white">
<Plus className="w-4 h-4 mr-2" />
Create Agent
</Button>
</div>
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{agents.map((agent) => (
<Card key={agent.id} className="p-6 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<div className="p-2 bg-green-100 rounded-lg mr-3">
{getAgentIcon(agent.agent_type)}
</div>
<div>
<h3 className="font-semibold text-gray-900">{agent.name}</h3>
<Badge variant="secondary" className="mt-1">
{agent.agent_type}
</Badge>
</div>
</div>
<Badge
className={`${
agent.status === 'idle' ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-700'
}`}
>
{agent.status}
</Badge>
</div>
<p className="text-sm text-gray-600 mb-4">{agent.description}</p>
<div className="flex flex-wrap gap-2 mb-4">
{agent.capabilities.slice(0, 3).map((cap: string, idx: number) => (
<Badge key={idx} variant="secondary" className="text-xs">
{cap.replace('_', ' ')}
</Badge>
))}
</div>
<div className="flex items-center justify-between text-sm text-gray-500 mb-4">
<span>Executions: {agent.execution_count}</span>
<span>Last run: {formatDateOnly(agent.last_execution)}</span>
</div>
<div className="flex gap-2">
<Button className="flex-1" variant="secondary">
<Settings className="w-4 h-4 mr-2" />
Configure
</Button>
<Button className="flex-1 bg-green-600 hover:bg-green-700 text-white">
<Play className="w-4 h-4 mr-2" />
Execute
</Button>
</div>
</Card>
))}
</div>
)}
</div>
</TestLayout>
);
}

View File

@@ -0,0 +1,163 @@
'use client';
import { TestLayout } from '@/components/layout/test-layout';
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Upload, FileText, FileCheck, FileX, Download, Trash2, Search } from 'lucide-react';
import { mockApi } from '@/lib/mock-api';
import { formatDateOnly } from '@/lib/utils';
export default function TestDocumentsPage() {
const [documents, setDocuments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [storageUsed, setStorageUsed] = useState(0);
const [storageLimit, setStorageLimit] = useState(0);
useEffect(() => {
loadDocuments();
}, []);
const loadDocuments = async () => {
try {
const data = await mockApi.documents.list();
setDocuments(data.documents);
setStorageUsed(data.storage_used);
setStorageLimit(data.storage_limit);
} catch (error) {
console.error('Failed to load documents:', error);
} finally {
setLoading(false);
}
};
const formatFileSize = (bytes: number) => {
const mb = bytes / (1024 * 1024);
if (mb < 1) return `${(bytes / 1024).toFixed(2)} KB`;
return `${mb.toFixed(2)} MB`;
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return <FileCheck className="w-4 h-4 text-green-600" />;
case 'processing': return <div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />;
case 'failed': return <FileX className="w-4 h-4 text-red-600" />;
default: return <FileText className="w-4 h-4 text-gray-400" />;
}
};
const getStatusBadge = (status: string) => {
const colors = {
completed: 'bg-green-100 text-green-700',
processing: 'bg-blue-100 text-blue-700',
failed: 'bg-red-100 text-red-700',
pending: 'bg-gray-100 text-gray-700'
};
return colors[status as keyof typeof colors] || colors.pending;
};
const storagePercentage = (storageUsed / storageLimit) * 100;
return (
<TestLayout>
<div className="p-6">
{/* Header */}
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Documents</h1>
<p className="text-gray-600 mt-1">Upload and manage your knowledge base</p>
</div>
<Button className="bg-green-600 hover:bg-green-700 text-white">
<Upload className="w-4 h-4 mr-2" />
Upload Document
</Button>
</div>
{/* Storage Usage */}
<Card className="p-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700">Storage Usage</span>
<span className="text-sm text-gray-500">
{formatFileSize(storageUsed)} / {formatFileSize(storageLimit)}
</span>
</div>
<Progress value={storagePercentage} className="h-2" />
</Card>
</div>
{/* Search Bar */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search documents..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
{/* Documents List */}
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
) : (
<div className="space-y-4">
{documents.map((doc) => (
<Card key={doc.id} className="p-4 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-2 bg-gray-100 rounded-lg">
<FileText className="w-6 h-6 text-gray-600" />
</div>
<div>
<h3 className="font-medium text-gray-900">{doc.filename}</h3>
<div className="flex items-center space-x-4 mt-1">
<span className="text-sm text-gray-500">{formatFileSize(doc.file_size)}</span>
<span className="text-sm text-gray-500"></span>
<span className="text-sm text-gray-500">{doc.chunk_count} chunks</span>
<span className="text-sm text-gray-500"></span>
<span className="text-sm text-gray-500">
Uploaded {formatDateOnly(doc.created_at)}
</span>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
{getStatusIcon(doc.processing_status)}
<Badge className={getStatusBadge(doc.processing_status)}>
{doc.processing_status}
</Badge>
</div>
<div className="flex space-x-2">
<Button variant="secondary" size="sm">
<Download className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" className="text-red-600 hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</div>
{doc.processing_status === 'processing' && (
<div className="mt-3">
<Progress value={65} className="h-1" />
<p className="text-xs text-gray-500 mt-1">Processing document...</p>
</div>
)}
</Card>
))}
</div>
)}
</div>
</TestLayout>
);
}

View File

@@ -0,0 +1,233 @@
'use client';
import { TestLayout } from '@/components/layout/test-layout';
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Trophy, Brain, Puzzle, Users, ChevronRight, Star, Target, TrendingUp } from 'lucide-react';
import { mockApi } from '@/lib/mock-api';
export default function TestGamesPage() {
const [games, setGames] = useState<any[]>([]);
const [progress, setProgress] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadGamesAndProgress();
}, []);
const loadGamesAndProgress = async () => {
try {
const [gamesData, progressData] = await Promise.all([
mockApi.games.list(),
mockApi.games.getProgress()
]);
setGames(gamesData.games);
setProgress(progressData);
} catch (error) {
console.error('Failed to load games:', error);
} finally {
setLoading(false);
}
};
const getGameIcon = (type: string) => {
switch (type) {
case 'chess': return '♟️';
case 'logic_puzzle': return '🧩';
case 'philosophical_dilemma': return '🤔';
default: return '🎮';
}
};
const getDifficultyColor = (level: string) => {
switch (level) {
case 'beginner':
case 'easy': return 'bg-green-100 text-green-700';
case 'intermediate':
case 'medium': return 'bg-yellow-100 text-yellow-700';
case 'expert':
case 'hard': return 'bg-red-100 text-red-700';
default: return 'bg-gray-100 text-gray-700';
}
};
if (loading) {
return (
<TestLayout>
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
</TestLayout>
);
}
return (
<TestLayout>
<div className="p-6">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">AI Literacy & Cognitive Development</h1>
<p className="text-gray-600 mt-1">Develop critical thinking skills through games and challenges</p>
</div>
{/* Progress Overview */}
{progress && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="p-4">
<div className="flex items-center justify-between mb-2">
<Trophy className="w-5 h-5 text-yellow-500" />
<span className="text-2xl font-bold">{progress.overall_progress.level}</span>
</div>
<p className="text-sm text-gray-600">Current Level</p>
<Progress value={(progress.overall_progress.experience / progress.overall_progress.next_level_xp) * 100} className="mt-2 h-1" />
<p className="text-xs text-gray-500 mt-1">
{progress.overall_progress.experience} / {progress.overall_progress.next_level_xp} XP
</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between mb-2">
<Brain className="w-5 h-5 text-purple-500" />
<span className="text-2xl font-bold">{progress.skill_metrics.strategic_thinking}%</span>
</div>
<p className="text-sm text-gray-600">Strategic Thinking</p>
<Progress value={progress.skill_metrics.strategic_thinking} className="mt-2 h-1" />
</Card>
<Card className="p-4">
<div className="flex items-center justify-between mb-2">
<Target className="w-5 h-5 text-blue-500" />
<span className="text-2xl font-bold">{progress.skill_metrics.logical_reasoning}%</span>
</div>
<p className="text-sm text-gray-600">Logical Reasoning</p>
<Progress value={progress.skill_metrics.logical_reasoning} className="mt-2 h-1" />
</Card>
<Card className="p-4">
<div className="flex items-center justify-between mb-2">
<TrendingUp className="w-5 h-5 text-green-500" />
<span className="text-2xl font-bold">{progress.learning_streak}</span>
</div>
<p className="text-sm text-gray-600">Day Streak</p>
<div className="flex mt-2">
{[...Array(7)].map((_, i) => (
<div
key={i}
className={`w-4 h-4 rounded-sm mr-1 ${
i < progress.learning_streak ? 'bg-green-500' : 'bg-gray-200'
}`}
/>
))}
</div>
</Card>
</div>
)}
{/* Skills Overview */}
{progress && (
<Card className="p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Skill Development</h2>
<div className="space-y-3">
{Object.entries(progress.skill_metrics).map(([skill, value]: [string, any]) => (
<div key={skill}>
<div className="flex justify-between mb-1">
<span className="text-sm font-medium text-gray-700">
{skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
</span>
<span className="text-sm text-gray-500">{value}%</span>
</div>
<Progress value={value} className="h-2" />
</div>
))}
</div>
</Card>
)}
{/* Games Grid */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-4">Available Games & Challenges</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{games.map((game) => (
<Card key={game.id} className="p-6 hover:shadow-lg transition-shadow cursor-pointer">
<div className="flex items-start justify-between mb-4">
<div className="text-4xl">{getGameIcon(game.type)}</div>
{game.user_rating && (
<Badge variant="secondary" className="flex items-center">
<Star className="w-3 h-3 mr-1 fill-yellow-500 text-yellow-500" />
{game.user_rating}
</Badge>
)}
</div>
<h3 className="font-semibold text-gray-900 mb-2">{game.name}</h3>
<p className="text-sm text-gray-600 mb-4">{game.description}</p>
{/* Difficulty Levels */}
{game.difficulty_levels && (
<div className="flex flex-wrap gap-2 mb-4">
{game.difficulty_levels.map((level: string) => (
<Badge key={level} className={getDifficultyColor(level)}>
{level}
</Badge>
))}
</div>
)}
{/* Stats */}
<div className="space-y-2 mb-4">
{game.games_played !== undefined && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Games Played:</span>
<span className="font-medium">{game.games_played}</span>
</div>
)}
{game.win_rate !== undefined && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Win Rate:</span>
<span className="font-medium">{(game.win_rate * 100).toFixed(0)}%</span>
</div>
)}
{game.puzzles_solved !== undefined && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Puzzles Solved:</span>
<span className="font-medium">{game.puzzles_solved}</span>
</div>
)}
{game.scenarios_completed !== undefined && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Scenarios:</span>
<span className="font-medium">{game.scenarios_completed}</span>
</div>
)}
</div>
<Button className="w-full bg-green-600 hover:bg-green-700 text-white">
Play Now
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</Card>
))}
</div>
</div>
{/* Recommendations */}
{progress?.recommendations && progress.recommendations.length > 0 && (
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4">Personalized Recommendations</h2>
<div className="space-y-3">
{progress.recommendations.map((rec: string, idx: number) => (
<div key={idx} className="flex items-start">
<div className="w-2 h-2 rounded-full bg-green-500 mt-1.5 mr-3 flex-shrink-0" />
<p className="text-sm text-gray-700">{rec}</p>
</div>
))}
</div>
</Card>
)}
</div>
</TestLayout>
);
}

View File

@@ -0,0 +1,250 @@
'use client';
import { TestLayout } from '@/components/layout/test-layout';
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Plus, Folder, FolderOpen, Users, Calendar,
BarChart3, CheckCircle2, Clock, AlertCircle,
MoreVertical, Share2, Archive
} from 'lucide-react';
import { mockApi } from '@/lib/mock-api';
import { formatDateOnly } from '@/lib/utils';
export default function TestProjectsPage() {
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadProjects();
}, []);
const loadProjects = async () => {
try {
const data = await mockApi.projects.list();
setProjects(data.projects);
} catch (error) {
console.error('Failed to load projects:', error);
} finally {
setLoading(false);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'active': return <Clock className="w-4 h-4 text-blue-600" />;
case 'completed': return <CheckCircle2 className="w-4 h-4 text-green-600" />;
case 'on_hold': return <AlertCircle className="w-4 h-4 text-yellow-600" />;
default: return <Folder className="w-4 h-4 text-gray-400" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'bg-blue-100 text-blue-700';
case 'completed': return 'bg-green-100 text-green-700';
case 'on_hold': return 'bg-yellow-100 text-yellow-700';
default: return 'bg-gray-100 text-gray-700';
}
};
if (loading) {
return (
<TestLayout>
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
</TestLayout>
);
}
return (
<TestLayout>
<div className="p-6">
{/* Header */}
<div className="mb-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Projects</h1>
<p className="text-gray-600 mt-1">Manage your research and analysis projects</p>
</div>
<Button className="bg-green-600 hover:bg-green-700 text-white">
<Plus className="w-4 h-4 mr-2" />
New Project
</Button>
</div>
</div>
{/* Project Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Active Projects</p>
<p className="text-2xl font-bold text-gray-900">
{projects.filter(p => p.status === 'active').length}
</p>
</div>
<Clock className="w-8 h-8 text-blue-500 opacity-50" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Completed</p>
<p className="text-2xl font-bold text-gray-900">
{projects.filter(p => p.status === 'completed').length}
</p>
</div>
<CheckCircle2 className="w-8 h-8 text-green-500 opacity-50" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Total Hours</p>
<p className="text-2xl font-bold text-gray-900">
{projects.reduce((acc, p) => acc + (p.time_invested_minutes || 0), 0) / 60}h
</p>
</div>
<BarChart3 className="w-8 h-8 text-purple-500 opacity-50" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Collaborators</p>
<p className="text-2xl font-bold text-gray-900">
{projects.reduce((acc, p) => acc + (p.collaborators?.length || 0), 0)}
</p>
</div>
<Users className="w-8 h-8 text-orange-500 opacity-50" />
</div>
</Card>
</div>
{/* Projects Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<Card key={project.id} className="hover:shadow-lg transition-shadow">
<div className="p-6">
{/* Project Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
{project.status === 'active' ? (
<FolderOpen className="w-5 h-5 text-green-600 mr-2" />
) : (
<Folder className="w-5 h-5 text-gray-400 mr-2" />
)}
<h3 className="font-semibold text-gray-900">{project.name}</h3>
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="w-4 h-4" />
</Button>
</div>
{/* Description */}
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
{project.description}
</p>
{/* Progress */}
<div className="mb-4">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Progress</span>
<span className="font-medium">{project.completion_percentage}%</span>
</div>
<Progress value={project.completion_percentage} className="h-2" />
</div>
{/* Status and Type */}
<div className="flex items-center gap-2 mb-4">
<Badge className={getStatusColor(project.status)}>
{project.status.replace('_', ' ')}
</Badge>
<Badge variant="secondary">
{project.project_type}
</Badge>
</div>
{/* Resources */}
{project.linked_resources && project.linked_resources.length > 0 && (
<div className="mb-4">
<p className="text-xs text-gray-500 mb-2">Resources:</p>
<div className="flex flex-wrap gap-1">
{project.linked_resources.slice(0, 3).map((resource: string, idx: number) => (
<Badge key={idx} variant="secondary" className="text-xs">
{resource}
</Badge>
))}
{project.linked_resources.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{project.linked_resources.length - 3}
</Badge>
)}
</div>
</div>
)}
{/* Collaborators */}
{project.collaborators && project.collaborators.length > 0 && (
<div className="flex items-center justify-between mb-4">
<div className="flex -space-x-2">
{project.collaborators.slice(0, 3).map((collaborator: any, idx: number) => (
<div
key={idx}
className="w-8 h-8 rounded-full bg-gray-300 border-2 border-white flex items-center justify-center"
>
<span className="text-xs font-medium text-gray-600">
{collaborator.name.split(' ').map((n: string) => n[0]).join('')}
</span>
</div>
))}
{project.collaborators.length > 3 && (
<div className="w-8 h-8 rounded-full bg-gray-200 border-2 border-white flex items-center justify-center">
<span className="text-xs font-medium text-gray-600">
+{project.collaborators.length - 3}
</span>
</div>
)}
</div>
<Share2 className="w-4 h-4 text-gray-400" />
</div>
)}
{/* Dates */}
<div className="flex items-center justify-between text-xs text-gray-500 mb-4">
<div className="flex items-center">
<Calendar className="w-3 h-3 mr-1" />
Created {formatDateOnly(project.created_at)}
</div>
{project.last_activity && (
<span>Active {formatDateOnly(project.last_activity)}</span>
)}
</div>
{/* Actions */}
<div className="flex gap-2">
<Button className="flex-1" variant="secondary">
Open Project
</Button>
{project.status === 'active' && (
<Button variant="ghost" size="sm" className="px-2">
<Archive className="w-4 h-4" />
</Button>
)}
</div>
</div>
</Card>
))}
</div>
</div>
</TestLayout>
);
}

View File

@@ -0,0 +1,258 @@
'use client';
import { TestLayout } from '@/components/layout/test-layout';
import { useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
User, Bell, Shield, Palette, Globe, Database,
CreditCard, HelpCircle, ChevronRight, Save, Moon, Sun
} from 'lucide-react';
export default function TestSettingsPage() {
const [theme, setTheme] = useState('system');
const [notifications, setNotifications] = useState({
email: true,
push: true,
sms: false,
mentions: true,
updates: false,
});
const [privacy, setPrivacy] = useState({
analytics: true,
progressSharing: false,
peerComparison: false,
});
const settingsSections = [
{
title: 'Profile',
icon: User,
items: [
{ label: 'Name', value: 'Jane User' },
{ label: 'Email', value: 'jane@test-company.com' },
{ label: 'Role', value: 'Tenant User', badge: true },
{ label: 'Department', value: 'Research & Development' },
]
},
{
title: 'Appearance',
icon: Palette,
items: [
{ label: 'Theme', component: 'theme-selector' },
{ label: 'UI Density', value: 'Comfortable' },
{ label: 'Accent Color', value: '#00d084', color: true },
{ label: 'Font Size', value: 'Medium' },
]
},
{
title: 'Notifications',
icon: Bell,
items: [
{ label: 'Email Notifications', toggle: 'email' },
{ label: 'Push Notifications', toggle: 'push' },
{ label: 'SMS Alerts', toggle: 'sms' },
{ label: 'Mentions', toggle: 'mentions' },
{ label: 'Product Updates', toggle: 'updates' },
]
},
{
title: 'Privacy & Security',
icon: Shield,
items: [
{ label: 'Two-Factor Authentication', value: 'Enabled', badge: 'green' },
{ label: 'Session Timeout', value: '30 minutes' },
{ label: 'Usage Analytics', toggle: 'analytics' },
{ label: 'Progress Sharing', toggle: 'progressSharing' },
{ label: 'Peer Comparison', toggle: 'peerComparison' },
]
},
{
title: 'AI Preferences',
icon: Globe,
items: [
{ label: 'Default Model', value: 'GPT-4' },
{ label: 'Temperature', value: '0.7' },
{ label: 'Max Tokens', value: '2000' },
{ label: 'Explanation Level', value: 'Intermediate' },
{ label: 'Auto-suggestions', value: 'Enabled', badge: 'green' },
]
},
{
title: 'Storage & Usage',
icon: Database,
items: [
{ label: 'Storage Used', value: '3.6 GB / 10 GB', progress: 36 },
{ label: 'API Calls', value: '12,456 / 50,000', progress: 25 },
{ label: 'Compute Hours', value: '45 / 100', progress: 45 },
]
},
{
title: 'Billing',
icon: CreditCard,
items: [
{ label: 'Current Plan', value: 'Professional', badge: 'blue' },
{ label: 'Billing Cycle', value: 'Monthly' },
{ label: 'Next Payment', value: 'Feb 1, 2024' },
{ label: 'Payment Method', value: '•••• 4242', action: true },
]
},
];
const handleToggle = (section: string, key: string) => {
if (section === 'notifications') {
setNotifications(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }));
} else if (section === 'privacy') {
setPrivacy(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }));
}
};
const getToggleValue = (section: string, key: string) => {
if (section === 'notifications') return notifications[key as keyof typeof notifications];
if (section === 'privacy') return privacy[key as keyof typeof privacy];
return false;
};
return (
<TestLayout>
<div className="p-6">
{/* Header */}
<div className="mb-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<p className="text-gray-600 mt-1">Manage your account and preferences</p>
</div>
<Button className="bg-green-600 hover:bg-green-700 text-white">
<Save className="w-4 h-4 mr-2" />
Save Changes
</Button>
</div>
</div>
{/* Settings Sections */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{settingsSections.map((section) => {
const Icon = section.icon;
return (
<Card key={section.title} className="p-6">
<div className="flex items-center mb-4">
<Icon className="w-5 h-5 text-green-600 mr-2" />
<h2 className="text-lg font-semibold text-gray-900">{section.title}</h2>
</div>
<div className="space-y-4">
{section.items.map((item, idx) => (
<div key={idx} className="flex items-center justify-between">
<span className="text-sm text-gray-600">{item.label}</span>
{'component' in item && item.component === 'theme-selector' ? (
<div className="flex gap-2">
<Button
size="sm"
variant={theme === 'light' ? 'primary' : 'secondary'}
onClick={() => setTheme('light')}
className="h-8 px-3"
>
<Sun className="w-4 h-4" />
</Button>
<Button
size="sm"
variant={theme === 'dark' ? 'primary' : 'secondary'}
onClick={() => setTheme('dark')}
className="h-8 px-3"
>
<Moon className="w-4 h-4" />
</Button>
<Button
size="sm"
variant={theme === 'system' ? 'primary' : 'secondary'}
onClick={() => setTheme('system')}
className="h-8 px-3"
>
Auto
</Button>
</div>
) : 'toggle' in item && item.toggle ? (
<Switch
checked={getToggleValue(
section.title.toLowerCase().includes('notification') ? 'notifications' :
section.title.toLowerCase().includes('privacy') ? 'privacy' : '',
item.toggle
)}
onCheckedChange={() => handleToggle(
section.title.toLowerCase().includes('notification') ? 'notifications' :
section.title.toLowerCase().includes('privacy') ? 'privacy' : '',
item.toggle
)}
/>
) : 'badge' in item && item.badge ? (
<Badge
className={
item.badge === 'green' ? 'bg-green-100 text-green-700' :
item.badge === 'blue' ? 'bg-blue-100 text-blue-700' :
item.badge === true ? 'bg-gray-100 text-gray-700' : ''
}
>
{item.value}
</Badge>
) : 'color' in item && item.color ? (
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded border border-gray-300"
style={{ backgroundColor: item.value }}
/>
<span className="text-sm font-medium">{item.value}</span>
</div>
) : 'progress' in item && item.progress !== undefined ? (
<div className="flex items-center gap-3 flex-1 max-w-xs ml-4">
<span className="text-sm font-medium text-gray-900">{item.value}</span>
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className="bg-green-600 h-2 rounded-full"
style={{ width: `${item.progress}%` }}
/>
</div>
</div>
) : 'action' in item && item.action ? (
<Button variant="ghost" size="sm" className="h-8">
<span className="text-sm mr-1">{item.value}</span>
<ChevronRight className="w-4 h-4" />
</Button>
) : (
<span className="text-sm font-medium text-gray-900">{item.value}</span>
)}
</div>
))}
</div>
</Card>
);
})}
</div>
{/* Help Section */}
<Card className="mt-6 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<HelpCircle className="w-5 h-5 text-green-600 mr-3" />
<div>
<h3 className="font-semibold text-gray-900">Need Help?</h3>
<p className="text-sm text-gray-600 mt-1">
Access documentation, tutorials, and contact support
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="secondary">View Documentation</Button>
<Button className="bg-green-600 hover:bg-green-700 text-white">
Contact Support
</Button>
</div>
</div>
</Card>
</div>
</TestLayout>
);
}

View File

@@ -0,0 +1,364 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore, useHasHydrated } from '@/stores/auth-store';
import { verifyTFALogin, getTFASessionData, getTFAQRCodeBlob } from '@/services/tfa';
import { parseCapabilities, setAuthToken, setUser, parseTokenPayload, mapControlPanelRoleToTenantRole } from '@/services/auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
export default function VerifyTFAPage() {
const router = useRouter();
const hasHydrated = useHasHydrated();
const {
requiresTfa,
tfaConfigured,
logout,
} = useAuthStore();
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isFetchingSession, setIsFetchingSession] = useState(true);
const [attempts, setAttempts] = useState(0);
// Session data fetched from server
const [qrCodeBlobUrl, setQrCodeBlobUrl] = useState<string | null>(null);
const [manualEntryKey, setManualEntryKey] = useState<string | null>(null);
useEffect(() => {
// Wait for hydration before checking TFA state
if (!hasHydrated) {
return;
}
// Fetch TFA session data from server using HTTP-only cookie
const fetchSessionData = async () => {
if (!requiresTfa) {
// User doesn't need TFA, redirect to login
router.push('/login');
return;
}
try {
setIsFetchingSession(true);
// Fetch session metadata
const sessionData = await getTFASessionData();
if (sessionData.manual_entry_key) {
setManualEntryKey(sessionData.manual_entry_key);
}
// Fetch QR code as secure blob (if needed for setup)
if (!sessionData.tfa_configured) {
const blobUrl = await getTFAQRCodeBlob();
setQrCodeBlobUrl(blobUrl);
}
} catch (err: any) {
console.error('Failed to fetch TFA session data:', err);
setError('Session expired. Please login again.');
setTimeout(() => {
router.push('/login');
}, 2000);
} finally {
setIsFetchingSession(false);
}
};
fetchSessionData();
// Cleanup: revoke blob URL on unmount
return () => {
if (qrCodeBlobUrl) {
URL.revokeObjectURL(qrCodeBlobUrl);
}
};
}, [hasHydrated, requiresTfa, router]);
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validate code format (6 digits)
if (!/^\d{6}$/.test(code)) {
setError('Please enter a valid 6-digit code');
return;
}
setIsLoading(true);
try {
// Session cookie automatically sent with request
const result = await verifyTFALogin(code);
if (result.success && result.access_token) {
// Save token
setAuthToken(result.access_token);
// Decode JWT to extract user data
const payload = parseTokenPayload(result.access_token);
if (!payload) {
throw new Error('Failed to decode token');
}
// Construct user object from JWT claims
const user = {
id: parseInt(payload.sub),
email: payload.email,
full_name: payload.current_tenant?.display_name || payload.email,
role: mapControlPanelRoleToTenantRole(payload.user_type),
user_type: payload.user_type,
tenant_id: payload.current_tenant?.id ? parseInt(payload.current_tenant.id) : null,
is_active: true,
available_tenants: payload.available_tenants || []
};
// Parse capabilities from JWT
const capabilityStrings = parseCapabilities(result.access_token);
// Save user to localStorage
setUser(user);
// Update auth store
useAuthStore.setState({
token: result.access_token,
user: user,
capabilities: capabilityStrings,
isAuthenticated: true,
requiresTfa: false,
tfaConfigured: false,
isLoading: false,
});
// Small delay ensures localStorage writes complete before navigation
await new Promise(resolve => setTimeout(resolve, 50));
// Signal that TFA was just completed (prevents login page flash)
sessionStorage.setItem('gt2_tfa_verified', 'true');
// Use replace to skip login page in browser history
router.replace('/agents');
} else {
throw new Error(result.message || 'Verification failed');
}
} catch (err: any) {
const newAttempts = attempts + 1;
setAttempts(newAttempts);
if (newAttempts >= 5) {
setError('Too many attempts. Please wait 60 seconds and try again.');
} else {
setError(err.message || 'Invalid verification code. Please try again.');
}
setCode('');
setIsLoading(false);
}
};
const handleCancel = () => {
// Only show cancel if NOT mandatory (tfa_configured=true means optional)
logout();
router.push('/login');
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
// Show loading while hydrating or fetching session data
if (!hasHydrated || isFetchingSession) {
return (
<div className="min-h-screen bg-gradient-to-br from-gt-gray-50 to-gt-gray-100 flex items-center justify-center">
<div className="text-center">
<div className="mx-auto w-16 h-16 bg-gt-green rounded-full flex items-center justify-center mb-4">
<svg className="animate-spin h-8 w-8 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<p className="text-gt-gray-600">Loading TFA setup...</p>
</div>
</div>
);
}
if (!requiresTfa) {
return null; // Will redirect via useEffect
}
return (
<div className="min-h-screen bg-gradient-to-br from-gt-gray-50 to-gt-gray-100 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
<div className="relative z-10 w-full max-w-md">
<div className="text-center mb-8">
<div className="mx-auto w-16 h-16 bg-gt-green rounded-full flex items-center justify-center mb-4">
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h1 className="text-3xl font-bold text-gt-gray-900 mb-2">
{tfaConfigured ? 'Two-Factor Authentication' : 'Setup Two-Factor Authentication'}
</h1>
<p className="text-gt-gray-600">
{tfaConfigured
? 'Enter the 6-digit code from your authenticator app'
: 'Your administrator requires 2FA for your account'}
</p>
</div>
<div className="bg-white rounded-xl shadow-lg p-8 border border-gt-gray-200">
{/* Mode A: Setup (tfa_configured=false) */}
{!tfaConfigured && qrCodeBlobUrl && (
<div className="mb-6">
<h2 className="text-lg font-semibold text-gt-gray-900 mb-4">
Scan QR Code
</h2>
{/* QR Code Display (secure blob URL - TOTP secret never in JavaScript) */}
<div className="bg-white p-4 rounded-lg border-2 border-gt-gray-200 mb-4 flex justify-center">
<img
src={qrCodeBlobUrl}
alt="QR Code"
className="w-48 h-48"
/>
</div>
{/* Manual Entry Key */}
{manualEntryKey && (
<div className="mb-4">
<label className="block text-sm font-medium text-gt-gray-700 mb-2">
Manual Entry Key
</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-gt-gray-50 border border-gt-gray-200 rounded-lg text-sm font-mono">
{manualEntryKey}
</code>
<Button
variant="secondary"
size="sm"
onClick={() => copyToClipboard(manualEntryKey.replace(/\s/g, ''))}
>
Copy
</Button>
</div>
</div>
)}
<div className="bg-gt-blue-50 border border-gt-blue-200 rounded-lg p-4 mb-4">
<p className="text-sm text-gt-blue-900">
<strong>Instructions:</strong>
</p>
<ol className="text-sm text-gt-blue-800 mt-2 ml-4 list-decimal space-y-1">
<li>Download Google Authenticator or any TOTP app</li>
<li>Scan the QR code or enter the manual key as shown above</li>
<li>Enter the 6-digit code below to complete setup</li>
</ol>
</div>
</div>
)}
{/* Code Input (both modes) */}
<form onSubmit={handleVerify} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gt-gray-700 mb-2">
6-Digit Code
</label>
<Input
type="text"
value={code}
onChange={(value) => setCode(value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
maxLength={6}
autoFocus
disabled={isLoading || attempts >= 5}
className="text-center text-2xl tracking-widest font-mono"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-center">
<svg
className="w-4 h-4 text-red-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
)}
{attempts > 0 && attempts < 5 && (
<p className="text-sm text-gt-gray-600 text-center">
Attempts remaining: {5 - attempts}
</p>
)}
<div className="flex gap-3">
<Button
type="submit"
variant="primary"
size="lg"
loading={isLoading}
disabled={isLoading || code.length !== 6 || attempts >= 5}
className="flex-1"
>
{isLoading ? 'Verifying...' : tfaConfigured ? 'Verify' : 'Verify and Complete Setup'}
</Button>
{/* Only show cancel if TFA is already configured (optional flow) */}
{tfaConfigured && (
<Button
type="button"
variant="secondary"
size="lg"
onClick={handleCancel}
disabled={isLoading}
>
Cancel
</Button>
)}
</div>
</form>
{/* No cancel button for mandatory setup (Mode A) */}
{!tfaConfigured && (
<div className="mt-4 text-center">
<p className="text-xs text-gt-gray-500">
2FA is required for your account. Contact your administrator if you need assistance.
</p>
</div>
)}
</div>
{/* Security Info */}
<div className="mt-6 text-center text-sm text-gt-gray-500">
<p>Secured by GT Edge AI Enterprise Grade Security</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,785 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { EnhancedWorkflowCanvas } from '@/components/workflow/EnhancedWorkflowCanvas';
import { WorkflowChatInterface } from '@/components/workflow/WorkflowChatInterface';
import { WorkflowButtonInterface } from '@/components/workflow/WorkflowButtonInterface';
import { WorkflowFormInterface } from '@/components/workflow/WorkflowFormInterface';
import { WorkflowExecutionView } from '@/components/workflow/WorkflowExecutionView';
import { AppLayout } from '@/components/layout/app-layout';
import { AuthGuard } from '@/components/auth/auth-guard';
import { GT2_CAPABILITIES } from '@/lib/capabilities';
import {
Plus,
Search,
Filter,
Play,
Pause,
Settings,
MoreHorizontal,
Bot,
Zap,
Clock,
DollarSign,
Activity,
Edit,
Copy,
Trash2,
Eye,
MessageSquare,
Square,
BarChart3,
Workflow
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface Workflow {
id: string;
name: string;
description?: string;
status: 'draft' | 'active' | 'paused' | 'archived';
definition: {
nodes: any[];
edges: any[];
config?: Record<string, any>;
};
interaction_modes: string[];
execution_count: number;
last_executed?: string;
total_cost_cents: number;
average_execution_time_ms?: number;
created_at: string;
updated_at: string;
}
interface WorkflowExecution {
id: string;
workflow_id: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress_percentage: number;
started_at: string;
completed_at?: string;
tokens_used: number;
cost_cents: number;
interaction_mode: string;
}
function WorkflowsPageContent() {
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [executions, setExecutions] = useState<WorkflowExecution[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'editor' | 'chat' | 'button' | 'form' | 'execution'>('list');
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecution | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
// Load workflows and executions
useEffect(() => {
const loadData = async () => {
try {
setLoading(true);
const authToken = localStorage.getItem('gt2_token');
// Load workflows
const workflowResponse = await fetch('/api/v1/workflows', {
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
}
});
if (workflowResponse.ok) {
const workflowData = await workflowResponse.json();
setWorkflows(workflowData);
}
// Load recent executions for dashboard
setExecutions([]); // TODO: Implement executions endpoint
} catch (error) {
console.error('Failed to load workflows:', error);
setWorkflows([]);
setExecutions([]);
} finally {
setLoading(false);
}
};
loadData();
}, []);
// Filter workflows
const filteredWorkflows = workflows.filter(workflow => {
const matchesSearch = !searchQuery ||
workflow.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
workflow.description?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === 'all' || workflow.status === statusFilter;
return matchesSearch && matchesStatus;
});
// Execute workflow
const handleExecuteWorkflow = async (workflow: Workflow, inputData: Record<string, any>) => {
try {
const authToken = localStorage.getItem('gt2_token');
const response = await fetch(`/api/v1/workflows/${workflow.id}/execute`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
input_data: inputData,
interaction_mode: viewMode,
trigger_type: 'manual'
})
});
if (response.ok) {
const execution = await response.json();
setSelectedExecution(execution);
return execution;
} else {
throw new Error('Failed to execute workflow');
}
} catch (error) {
console.error('Failed to execute workflow:', error);
throw error;
}
};
// Create new workflow
const handleCreateWorkflow = async () => {
try {
const authToken = localStorage.getItem('gt2_token');
const response = await fetch('/api/v1/workflows', {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'New Workflow',
description: 'A new workflow created with the visual editor',
definition: {
nodes: [
{
id: 'trigger-1',
type: 'trigger',
data: { name: 'Manual Trigger' },
position: { x: 300, y: 200 }
}
],
edges: []
},
interaction_modes: ['button'],
triggers: []
})
});
if (response.ok) {
const newWorkflow = await response.json();
setWorkflows(prev => [newWorkflow, ...prev]);
setSelectedWorkflow(newWorkflow);
setViewMode('editor');
} else {
alert('Failed to create workflow. Please try again.');
}
} catch (error) {
console.error('Failed to create workflow:', error);
alert('Failed to create workflow. Please try again.');
}
};
// Save workflow changes
const handleSaveWorkflow = async (definition: any) => {
if (!selectedWorkflow) return;
try {
const authToken = localStorage.getItem('gt2_token');
const response = await fetch(`/api/v1/workflows/${selectedWorkflow.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
definition: definition,
status: 'active' // Activate workflow when saved
})
});
if (response.ok) {
const updatedWorkflow = await response.json();
setWorkflows(prev => prev.map(w =>
w.id === updatedWorkflow.id ? updatedWorkflow : w
));
setSelectedWorkflow(updatedWorkflow);
alert('Workflow saved successfully!');
} else {
alert('Failed to save workflow.');
}
} catch (error) {
console.error('Failed to save workflow:', error);
alert('Failed to save workflow.');
}
};
// Delete workflow
const handleDeleteWorkflow = async (workflowId: string) => {
if (!confirm('Are you sure you want to delete this workflow?')) return;
try {
const authToken = localStorage.getItem('gt2_token');
const response = await fetch(`/api/v1/workflows/${workflowId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
setWorkflows(prev => prev.filter(w => w.id !== workflowId));
if (selectedWorkflow?.id === workflowId) {
setSelectedWorkflow(null);
setViewMode('list');
}
} else {
alert('Failed to delete workflow.');
}
} catch (error) {
console.error('Failed to delete workflow:', error);
alert('Failed to delete workflow.');
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'bg-green-100 text-green-800';
case 'draft': return 'bg-yellow-100 text-yellow-800';
case 'paused': return 'bg-orange-100 text-orange-800';
case 'archived': return 'bg-gray-100 text-gray-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const getInteractionModeIcon = (mode: string) => {
switch (mode) {
case 'chat': return MessageSquare;
case 'button': return Square;
case 'form': return Edit;
case 'dashboard': return BarChart3;
default: return Square;
}
};
// Calculate stats
const totalWorkflows = workflows.length;
const activeWorkflows = workflows.filter(w => w.status === 'active').length;
const totalExecutions = workflows.reduce((sum, w) => sum + w.execution_count, 0);
const totalCost = workflows.reduce((sum, w) => sum + w.total_cost_cents, 0);
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-gt-green border-t-transparent"></div>
</div>
);
}
// Interface views for different interaction modes
if ((viewMode === 'chat' || viewMode === 'button' || viewMode === 'form') && selectedWorkflow) {
const backToList = () => {
setViewMode('list');
setSelectedWorkflow(null);
};
const handleExecutionUpdate = (execution: WorkflowExecution) => {
setSelectedExecution(execution);
// Optionally switch to execution view to show results
if (execution.status === 'completed' || execution.status === 'failed') {
setViewMode('execution');
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* Interface Header */}
<div className="bg-white border-b border-gray-200 p-4">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={backToList}>
Back to Workflows
</Button>
<div>
<h1 className="text-xl font-bold text-gray-900">
{selectedWorkflow.name}
</h1>
<p className="text-gray-600 text-sm capitalize">
{viewMode} Interface
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge className={getStatusColor(selectedWorkflow.status)}>
{selectedWorkflow.status}
</Badge>
{/* Interface Mode Switcher */}
<div className="flex bg-gray-100 rounded-lg p-1">
{selectedWorkflow.interaction_modes.map(mode => (
<Button
key={mode}
variant={viewMode === mode ? 'primary' : 'ghost'}
size="sm"
onClick={() => setViewMode(mode as any)}
className="capitalize"
>
{mode}
</Button>
))}
</div>
</div>
</div>
</div>
{/* Interface Content */}
<div className="max-w-4xl mx-auto p-6">
{viewMode === 'chat' && (
<WorkflowChatInterface
workflow={selectedWorkflow as any}
onExecute={(inputData) => handleExecuteWorkflow(selectedWorkflow, inputData)}
onExecutionUpdate={handleExecutionUpdate}
/>
)}
{viewMode === 'button' && (
<WorkflowButtonInterface
workflow={selectedWorkflow as any}
onExecute={(inputData) => handleExecuteWorkflow(selectedWorkflow, inputData)}
onExecutionUpdate={handleExecutionUpdate}
showDetailedStats={true}
/>
)}
{viewMode === 'form' && (
<WorkflowFormInterface
workflow={selectedWorkflow as any}
onExecute={(inputData) => handleExecuteWorkflow(selectedWorkflow, inputData)}
onExecutionUpdate={handleExecutionUpdate}
multiStep={selectedWorkflow.definition.nodes.length > 3}
showPreview={true}
/>
)}
</div>
</div>
);
}
// Execution view
if (viewMode === 'execution' && selectedExecution) {
return (
<div className="min-h-screen bg-gray-50">
{/* Execution Header */}
<div className="bg-white border-b border-gray-200 p-4">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => {
setViewMode('list');
setSelectedWorkflow(null);
setSelectedExecution(null);
}}
>
Back to Workflows
</Button>
<div>
<h1 className="text-xl font-bold text-gray-900">
Execution Details
</h1>
<p className="text-gray-600 text-sm">
{selectedWorkflow?.name || 'Workflow Execution'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{selectedWorkflow && (
<Button
variant="secondary"
onClick={() => {
setViewMode('button');
setSelectedExecution(null);
}}
>
Run Again
</Button>
)}
</div>
</div>
</div>
{/* Execution Content */}
<div className="max-w-6xl mx-auto p-6">
<WorkflowExecutionView
execution={selectedExecution as any}
workflow={selectedWorkflow as any}
onRerun={() => {
setViewMode('button');
setSelectedExecution(null);
}}
realtime={selectedExecution.status === 'running'}
/>
</div>
</div>
);
}
// Editor view
if (viewMode === 'editor' && selectedWorkflow) {
return (
<div className="h-screen flex flex-col">
{/* Editor Header */}
<div className="bg-white border-b border-gray-200 p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => {
setViewMode('list');
setSelectedWorkflow(null);
}}
>
Back to Workflows
</Button>
<div>
<h1 className="text-xl font-bold text-gray-900">
{selectedWorkflow.name}
</h1>
<p className="text-gray-600 text-sm">
Visual Workflow Editor
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge className={getStatusColor(selectedWorkflow.status)}>
{selectedWorkflow.status}
</Badge>
<Button
size="sm"
variant="secondary"
onClick={() => handleExecuteWorkflow(selectedWorkflow, {})}
>
<Play className="h-4 w-4 mr-1" />
Test Run
</Button>
</div>
</div>
{/* Enhanced Workflow Canvas */}
<div className="flex-1">
<EnhancedWorkflowCanvas
workflow={selectedWorkflow}
onSave={handleSaveWorkflow}
onExecute={(definition) => handleExecuteWorkflow(selectedWorkflow, {})}
onValidate={(definition) => {
// Basic validation logic
const errors: Array<{ nodeId?: string; edgeId?: string; message: string; type: 'error' | 'warning' }> = [];
// Check for orphaned nodes
const connectedNodes = new Set();
definition.edges.forEach(edge => {
connectedNodes.add(edge.source);
connectedNodes.add(edge.target);
});
definition.nodes.forEach(node => {
if (node.type !== 'trigger' && !connectedNodes.has(node.id)) {
errors.push({
nodeId: node.id,
message: 'Node is not connected to the workflow',
type: 'warning' as const
});
}
if (node.type === 'agent' && !node.data.agent_id && !node.data.agent_id) {
errors.push({
nodeId: node.id,
message: 'Agent node requires an agent to be selected',
type: 'error' as const
});
}
});
return {
isValid: errors.filter(e => e.type === 'error').length === 0,
errors
};
}}
autoSave={true}
autoSaveInterval={3000}
/>
</div>
</div>
);
}
// List/Grid view
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
<Workflow className="w-8 h-8 text-gt-green" />
Workflows
</h1>
<p className="text-gray-600 mt-1">
Create visual workflows using your AI Agents
</p>
</div>
<div className="flex items-center gap-2">
{/* View Mode Toggle */}
<div className="flex bg-gray-100 rounded-lg p-1">
<Button
variant={viewMode === 'list' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
>
List
</Button>
<Button
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
size="sm"
onClick={() => setViewMode('grid')}
>
Grid
</Button>
</div>
<Button onClick={handleCreateWorkflow} className="bg-gt-green hover:bg-gt-green/90">
<Plus className="h-4 w-4 mr-2" />
Create Workflow
</Button>
</div>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<Search className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<Input
placeholder="Search workflows..."
value={searchQuery}
onChange={(value) => setSearchQuery(value)}
className="pl-10"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter((e as React.ChangeEvent<HTMLSelectElement>).target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gt-green focus:border-transparent"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="draft">Draft</option>
<option value="paused">Paused</option>
<option value="archived">Archived</option>
</select>
</div>
{/* Workflows List/Grid */}
{filteredWorkflows.length === 0 ? (
<Card>
<CardContent className="text-center py-12">
<Bot className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{searchQuery || statusFilter !== 'all' ? 'No workflows found' : 'No workflows yet'}
</h3>
<p className="text-gray-600 mb-4">
{searchQuery || statusFilter !== 'all'
? 'Try adjusting your search criteria'
: 'Create your first visual workflow to get started'
}
</p>
{!searchQuery && statusFilter === 'all' && (
<Button onClick={handleCreateWorkflow}>
<Plus className="h-4 w-4 mr-2" />
Create Your First Workflow
</Button>
)}
</CardContent>
</Card>
) : (
<div className={cn(
viewMode === 'grid'
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
: "space-y-4"
)}>
{filteredWorkflows.map((workflow) => (
<Card
key={workflow.id}
className="hover:shadow-lg transition-shadow duration-200 cursor-pointer"
onClick={() => {
setSelectedWorkflow(workflow);
setViewMode('editor');
}}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
<Bot className="h-5 w-5 text-white" />
</div>
<div>
<h3 className="font-semibold text-gray-900 truncate">
{workflow.name}
</h3>
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{workflow.definition.nodes.length} nodes</span>
<span></span>
<span>{workflow.execution_count} runs</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge className={getStatusColor(workflow.status)}>
{workflow.status}
</Badge>
<Button
size="sm"
variant="ghost"
onClick={() => {
// Show actions menu
}}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{workflow.description && (
<p className="text-sm text-gray-600 line-clamp-2">
{workflow.description}
</p>
)}
{/* Interaction Modes */}
<div className="flex flex-wrap gap-1">
{workflow.interaction_modes.map(mode => {
const Icon = getInteractionModeIcon(mode);
return (
<Badge
key={mode}
variant="secondary"
className="text-xs px-2 py-1 cursor-pointer hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation();
setSelectedWorkflow(workflow);
setViewMode(mode as any);
}}
>
<Icon className="h-3 w-3 mr-1" />
{mode}
</Badge>
);
})}
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-gray-200">
<div className="text-center">
<p className="text-xs text-gray-500">Executions</p>
<p className="font-semibold">{workflow.execution_count}</p>
</div>
<div className="text-center">
<p className="text-xs text-gray-500">Avg. Time</p>
<p className="font-semibold">
{workflow.average_execution_time_ms ?
`${Math.round(workflow.average_execution_time_ms / 1000)}s` :
'-'
}
</p>
</div>
<div className="text-center">
<p className="text-xs text-gray-500">Cost</p>
<p className="font-semibold">
${(workflow.total_cost_cents / 100).toFixed(2)}
</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-3">
<Button
size="sm"
variant="secondary"
onClick={() => {
setSelectedWorkflow(workflow);
// Use the first available interaction mode for quick run
const firstMode = workflow.interaction_modes[0] || 'button';
setViewMode(firstMode as any);
}}
className="flex-1"
>
<Play className="h-3 w-3 mr-1" />
Run
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
setSelectedWorkflow(workflow);
setViewMode('editor');
}}
className="flex-1"
>
<Edit className="h-3 w-3 mr-1" />
Edit
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
export default function WorkflowsPage() {
return (
<AuthGuard requiredCapabilities={[GT2_CAPABILITIES.AGENTS_EXECUTE]}>
<AppLayout>
<WorkflowsPageContent />
</AppLayout>
</AuthGuard>
);
}