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,178 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { UserPlus, Mail, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
interface AddMemberInlineFormProps {
teamId: string;
teamName: string;
onAddMember: (email: string, permission: 'view' | 'share' | 'manager') => Promise<void>;
onCancel: () => void;
loading?: boolean;
}
export function AddMemberInlineForm({
teamId,
teamName,
onAddMember,
onCancel,
loading = false
}: AddMemberInlineFormProps) {
const [email, setEmail] = useState('');
const [permission, setPermission] = useState<'view' | 'share' | 'manager'>('view');
const [error, setError] = useState('');
const validateEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!email.trim()) {
setError('Email is required');
return;
}
if (!validateEmail(email)) {
setError('Please enter a valid email address');
return;
}
try {
await onAddMember(email.trim().toLowerCase(), permission);
setEmail('');
setPermission('view');
setError('');
onCancel(); // Close form after successful submission
} catch (err: any) {
setError(err.message || 'Failed to add member');
}
};
const handleCancel = () => {
setEmail('');
setPermission('view');
setError('');
onCancel();
};
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="mb-6 border-2 border-gt-green rounded-lg bg-gt-green/5 overflow-hidden"
>
<div className="p-6 space-y-4">
{/* Header */}
<div className="flex items-center gap-3 pb-4 border-b border-gt-green/20">
<div className="w-10 h-10 rounded-full bg-gt-green/20 flex items-center justify-center">
<UserPlus className="w-5 h-5 text-gt-green" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Add Team Member</h3>
<p className="text-sm text-gray-500">{teamName}</p>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Email Input */}
<div className="space-y-2">
<Label htmlFor="inline-email" className="text-sm font-medium">
Email Address
</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
id="inline-email"
type="email"
value={email}
onChange={(value) => setEmail(value)}
placeholder="member@example.com"
className="pl-10"
disabled={loading}
autoFocus
clearable
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
</div>
{/* Permission Level */}
<div className="space-y-3">
<Label className="text-sm font-medium">Permission Level</Label>
<RadioGroup
value={permission}
onValueChange={(value) => setPermission(value as 'view' | 'share' | 'manager')}
>
<div className="space-y-2">
<div className="flex items-center space-x-2 p-3 rounded-lg border hover:bg-white cursor-pointer">
<RadioGroupItem value="view" id="inline-permission-view" />
<Label htmlFor="inline-permission-view" className="flex items-center gap-2 cursor-pointer flex-1">
<Shield className="w-4 h-4 text-gray-600" />
<div>
<div className="font-medium text-sm">Member</div>
<div className="text-xs text-gray-500">Can access shared resources</div>
</div>
</Label>
</div>
<div className="flex items-center space-x-2 p-3 rounded-lg border hover:bg-white cursor-pointer">
<RadioGroupItem value="share" id="inline-permission-share" />
<Label htmlFor="inline-permission-share" className="flex items-center gap-2 cursor-pointer flex-1">
<Shield className="w-4 h-4 text-blue-600" />
<div>
<div className="font-medium text-sm">Contributor</div>
<div className="text-xs text-gray-500">Can share own resources to the team</div>
</div>
</Label>
</div>
<div className="flex items-center space-x-2 p-3 rounded-lg border hover:bg-white cursor-pointer">
<RadioGroupItem value="manager" id="inline-permission-manager" />
<Label htmlFor="inline-permission-manager" className="flex items-center gap-2 cursor-pointer flex-1">
<Shield className="w-4 h-4 text-gt-green" />
<div>
<div className="font-medium text-sm">Manager</div>
<div className="text-xs text-gray-500">Can manage members, view Observable activity, and share resources</div>
</div>
</Label>
</div>
</div>
</RadioGroup>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={loading}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !email.trim()}
className="flex-1 bg-gt-green hover:bg-gt-green/90"
>
{loading ? 'Adding...' : 'Add Member'}
</Button>
</div>
</form>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useState } from 'react';
import { X, UserPlus, Mail, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { cn } from '@/lib/utils';
interface AddMemberModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamId: string;
teamName: string;
onAddMember: (email: string, permission: 'view' | 'share' | 'manager') => Promise<void>;
loading?: boolean;
}
export function AddMemberModal({
open,
onOpenChange,
teamId,
teamName,
onAddMember,
loading = false
}: AddMemberModalProps) {
const [email, setEmail] = useState('');
const [permission, setPermission] = useState<'view' | 'share' | 'manager'>('view');
const [error, setError] = useState('');
const validateEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!email.trim()) {
setError('Email is required');
return;
}
if (!validateEmail(email)) {
setError('Please enter a valid email address');
return;
}
try {
await onAddMember(email.trim().toLowerCase(), permission);
setEmail('');
setPermission('view');
onOpenChange(false);
} catch (err: any) {
setError(err.message || 'Failed to add member');
}
};
const handleClose = () => {
setEmail('');
setPermission('view');
setError('');
onOpenChange(false);
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<UserPlus className="w-5 h-5 text-blue-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Add Team Member</h2>
<p className="text-sm text-gray-500">{teamName}</p>
</div>
</div>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
disabled={loading}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Email Input */}
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
Email Address
</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
id="email"
type="email"
value={email}
onChange={(value) => setEmail(value)}
placeholder="member@example.com"
className="pl-10"
disabled={loading}
autoFocus
clearable
/>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
</div>
{/* Permission Level */}
<div className="space-y-3">
<Label className="text-sm font-medium">Permission Level</Label>
<RadioGroup
value={permission}
onValueChange={(value) => setPermission(value as 'view' | 'share' | 'manager')}
>
<div className="space-y-2">
<div className="flex items-center space-x-2 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
<RadioGroupItem value="view" id="permission-view" />
<Label htmlFor="permission-view" className="flex items-center gap-2 cursor-pointer flex-1">
<Shield className="w-4 h-4 text-gray-600" />
<div>
<div className="font-medium text-sm">Member</div>
<div className="text-xs text-gray-500">Can access shared resources</div>
</div>
</Label>
</div>
<div className="flex items-center space-x-2 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
<RadioGroupItem value="share" id="permission-share" />
<Label htmlFor="permission-share" className="flex items-center gap-2 cursor-pointer flex-1">
<Shield className="w-4 h-4 text-blue-600" />
<div>
<div className="font-medium text-sm">Contributor</div>
<div className="text-xs text-gray-500">Can share own resources to the team</div>
</div>
</Label>
</div>
<div className="flex items-center space-x-2 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
<RadioGroupItem value="manager" id="permission-manager" />
<Label htmlFor="permission-manager" className="flex items-center gap-2 cursor-pointer flex-1">
<Shield className="w-4 h-4 text-gt-green" />
<div>
<div className="font-medium text-sm">Manager</div>
<div className="text-xs text-gray-500">Can manage members, view Observable activity, and share resources</div>
</div>
</Label>
</div>
</div>
</RadioGroup>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={loading}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !email.trim()}
className="flex-1 bg-gt-green hover:bg-gt-green/90"
>
{loading ? 'Adding...' : 'Add Member'}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react';
import type { Team } from '@/services';
interface DeleteTeamDialogProps {
open: boolean;
team: Team | null;
onOpenChange: (open: boolean) => void;
onConfirm: (teamId: string) => Promise<void>;
loading?: boolean;
}
export function DeleteTeamDialog({
open,
team,
onOpenChange,
onConfirm,
loading = false
}: DeleteTeamDialogProps) {
const handleConfirm = async () => {
if (!team) return;
try {
await onConfirm(team.id);
onOpenChange(false);
} catch (error) {
console.error('Failed to delete team:', error);
}
};
if (!team) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<DialogTitle className="text-xl">Delete Team</DialogTitle>
</div>
<DialogDescription className="text-base">
Are you sure you want to delete <strong className="text-gray-900">{team.name}</strong>?
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-900 font-medium mb-2">
This action cannot be undone. This will:
</p>
<ul className="text-sm text-red-800 space-y-1 list-disc list-inside">
<li>Remove all team members ({team.member_count} {team.member_count === 1 ? 'member' : 'members'})</li>
<li>Remove all shared resources from members</li>
<li>Permanently delete the team</li>
</ul>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={loading}
className="bg-red-600 hover:bg-red-700 text-white"
>
{loading ? 'Deleting...' : 'Delete Team'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Edit3, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { Team } from '@/services';
interface EditTeamInlineFormProps {
team: Team;
onUpdateTeam: (name: string, description: string) => Promise<void>;
onCancel: () => void;
loading?: boolean;
}
export function EditTeamInlineForm({
team,
onUpdateTeam,
onCancel,
loading = false
}: EditTeamInlineFormProps) {
const [name, setName] = useState(team.name);
const [description, setDescription] = useState(team.description || '');
const [error, setError] = useState('');
// Update form when team changes
useEffect(() => {
setName(team.name);
setDescription(team.description || '');
}, [team]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Team name is required');
return;
}
try {
await onUpdateTeam(name.trim(), description.trim());
onCancel(); // Close form after successful submission
} catch (err: any) {
setError(err.message || 'Failed to update team');
}
};
const handleCancel = () => {
setName(team.name);
setDescription(team.description || '');
setError('');
onCancel();
};
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="mb-6 border-2 border-blue-500 rounded-lg bg-blue-50 overflow-hidden"
>
<div className="p-6 space-y-4">
{/* Header */}
<div className="flex items-center gap-3 pb-4 border-b border-blue-200">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<Edit3 className="w-5 h-5 text-blue-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Edit Team</h3>
<p className="text-sm text-gray-500">Update team information</p>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Team Name */}
<div className="space-y-2">
<Label htmlFor="inline-team-name" className="text-sm font-medium">
Team Name *
</Label>
<Input
id="inline-team-name"
type="text"
value={name}
onChange={(value) => setName(value)}
placeholder="Engineering Team"
disabled={loading}
autoFocus
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="inline-team-description" className="text-sm font-medium">
Description
</Label>
<Textarea
id="inline-team-description"
value={description}
onChange={(value) => setDescription(value)}
placeholder="Describe the purpose of this team..."
rows={3}
disabled={loading}
/>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={loading}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !name.trim()}
className="flex-1 bg-blue-600 hover:bg-blue-700"
>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,20 @@
/**
* GT 2.0 Teams Components
*
* Export barrel for team collaboration components
*/
export { TeamCard } from './team-card';
export { TeamCreateModal } from './team-create-modal';
export { TeamEditModal } from './team-edit-modal';
export { DeleteTeamDialog } from './delete-team-dialog';
export { LeaveTeamDialog } from './leave-team-dialog';
export { TeamManagementPanel } from './team-management-panel';
export { InvitationCard } from './invitation-card';
export { InvitationPanel } from './invitation-panel';
export { ObservableRequestCard } from './observable-request-card';
export { ObservableRequestPanel } from './observable-request-panel';
export type { TeamCardProps } from './team-card';
export type { CreateTeamData } from './team-create-modal';
export type { UpdateTeamData } from './team-edit-modal';

View File

@@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Users, UserCheck, UserX, Clock, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { TeamInvitation } from '@/services/teams';
interface InvitationCardProps {
invitation: TeamInvitation;
onAccept: (invitationId: string) => Promise<void>;
onDecline: (invitationId: string) => Promise<void>;
}
export function InvitationCard({
invitation,
onAccept,
onDecline,
}: InvitationCardProps) {
const [isAccepting, setIsAccepting] = useState(false);
const [isDeclining, setIsDeclining] = useState(false);
const handleAccept = async () => {
setIsAccepting(true);
try {
await onAccept(invitation.id);
} catch (error) {
console.error('Failed to accept invitation:', error);
} finally {
setIsAccepting(false);
}
};
const handleDecline = async () => {
setIsDeclining(true);
try {
await onDecline(invitation.id);
} catch (error) {
console.error('Failed to decline invitation:', error);
} finally {
setIsDeclining(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(
Math.ceil((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
'day'
);
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="border-2 border-amber-200 bg-amber-50 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between gap-4">
{/* Left side - Team info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<Users className="w-5 h-5 text-amber-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">
{invitation.team_name}
</h3>
<p className="text-xs text-gray-500">
Invited by {invitation.owner_name}
</p>
</div>
</div>
{invitation.team_description && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{invitation.team_description}
</p>
)}
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>Invited {formatDate(invitation.invited_at)}</span>
</div>
<div className="flex items-center gap-1">
<Shield className="w-3 h-3" />
<Badge
variant={invitation.team_permission === 'share' ? 'default' : 'secondary'}
className="text-xs"
>
{invitation.team_permission === 'share' ? 'Share' : 'View'} Permission
</Badge>
</div>
</div>
</div>
{/* Right side - Actions */}
<div className="flex flex-col gap-2 flex-shrink-0">
<Button
size="sm"
onClick={handleAccept}
disabled={isAccepting || isDeclining}
className="bg-green-600 hover:bg-green-700 text-white"
>
{isAccepting ? (
<>
<div className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-solid border-white border-r-transparent mr-2" />
Accepting...
</>
) : (
<>
<UserCheck className="w-4 h-4 mr-2" />
Accept
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDecline}
disabled={isAccepting || isDeclining}
className="border-gray-300 hover:bg-gray-100"
>
{isDeclining ? (
<>
<div className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-solid border-gray-600 border-r-transparent mr-2" />
Declining...
</>
) : (
<>
<UserX className="w-4 h-4 mr-2" />
Decline
</>
)}
</Button>
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, ChevronRight, Bell, Inbox } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { InvitationCard } from './invitation-card';
import { usePendingInvitations, useAcceptInvitation, useDeclineInvitation } from '@/hooks/use-teams';
export function InvitationPanel() {
const [isExpanded, setIsExpanded] = useState(false);
const { data: invitations = [], isLoading } = usePendingInvitations();
const acceptInvitation = useAcceptInvitation();
const declineInvitation = useDeclineInvitation();
const handleAccept = async (invitationId: string) => {
try {
console.log('🔍 Accepting invitation:', {
invitationId,
timestamp: new Date().toISOString()
});
await acceptInvitation.mutateAsync(invitationId);
console.log('✅ Invitation accepted successfully');
alert('Invitation accepted! You are now a member of the team.');
} catch (error: any) {
console.error('❌ Failed to accept invitation:', {
invitationId,
error: error.message,
fullError: error
});
alert(`Failed to accept invitation: ${error.message || 'An error occurred'}`);
}
};
const handleDecline = async (invitationId: string) => {
try {
await declineInvitation.mutateAsync(invitationId);
alert('Invitation declined. The invitation has been removed.');
} catch (error: any) {
alert(`Failed to decline invitation: ${error.message || 'An error occurred'}`);
}
};
// Don't show panel if no invitations and not loading
if (!isLoading && invitations.length === 0) {
return null;
}
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6"
>
<div className="bg-white border-2 border-amber-200 rounded-lg shadow-sm overflow-hidden">
{/* Header - Always visible */}
<div
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-amber-50 transition-colors cursor-pointer"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center">
<Bell className="w-5 h-5 text-amber-600" />
</div>
<div className="text-left">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">
Team Invitations
</h3>
{invitations.length > 0 && (
<Badge className="bg-amber-500 hover:bg-amber-600 text-white">
{invitations.length}
</Badge>
)}
</div>
<p className="text-sm text-gray-500">
{isLoading
? 'Loading invitations...'
: `${invitations.length} pending ${invitations.length === 1 ? 'invitation' : 'invitations'}`
}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{!isExpanded && invitations.length > 0 && (
<Button
size="sm"
className="bg-green-600 hover:bg-green-700 text-white"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(true);
}}
>
View
</Button>
)}
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-400" />
) : (
<ChevronRight className="w-5 h-5 text-gray-400" />
)}
</div>
</div>
{/* Expandable content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-amber-200"
>
<div className="p-6 space-y-3">
{isLoading ? (
<div className="text-center py-8">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-amber-500 border-r-transparent mb-3" />
<p className="text-gray-600">Loading invitations...</p>
</div>
) : invitations.length > 0 ? (
invitations.map((invitation) => (
<InvitationCard
key={invitation.id}
invitation={invitation}
onAccept={handleAccept}
onDecline={handleDecline}
/>
))
) : (
<div className="text-center py-8">
<Inbox className="w-16 h-16 text-gray-300 mx-auto mb-3" />
<p className="text-gray-600 font-medium">No pending invitations</p>
<p className="text-sm text-gray-500 mt-1">
You're all caught up!
</p>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react';
import type { Team } from '@/services';
interface LeaveTeamDialogProps {
open: boolean;
team: Team | null;
onOpenChange: (open: boolean) => void;
onConfirm: (teamId: string) => Promise<void>;
loading?: boolean;
}
export function LeaveTeamDialog({
open,
team,
onOpenChange,
onConfirm,
loading = false
}: LeaveTeamDialogProps) {
const handleConfirm = async () => {
if (!team) return;
try {
await onConfirm(team.id);
onOpenChange(false);
} catch (error) {
console.error('Failed to leave team:', error);
}
};
if (!team) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-5 h-5 text-orange-600" />
</div>
<DialogTitle className="text-xl">Leave Team</DialogTitle>
</div>
<DialogDescription className="text-base">
Are you sure you want to leave <strong className="text-gray-900">{team.name}</strong>?
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-4">
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
<p className="text-sm text-orange-900 font-medium mb-2">
After leaving this team, you will:
</p>
<ul className="text-sm text-orange-800 space-y-1 list-disc list-inside">
<li>Lose access to all shared agents and datasets</li>
<li>No longer see team resources in your workspace</li>
<li>Need a new invitation to rejoin this team</li>
</ul>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={loading}
className="bg-orange-600 hover:bg-orange-700 text-white"
>
{loading ? 'Leaving...' : 'Leave Team'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Eye, Check, X, Clock, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { ObservableRequest } from '@/services/teams';
interface ObservableRequestCardProps {
request: ObservableRequest;
onApprove: (teamId: string) => Promise<void>;
onRevoke: (teamId: string) => Promise<void>;
}
export function ObservableRequestCard({
request,
onApprove,
onRevoke,
}: ObservableRequestCardProps) {
const [isApproving, setIsApproving] = useState(false);
const [isRevoking, setIsRevoking] = useState(false);
const handleApprove = async () => {
setIsApproving(true);
try {
await onApprove(request.team_id);
} catch (error) {
console.error('Failed to approve Team Observability request:', error);
} finally {
setIsApproving(false);
}
};
const handleRevoke = async () => {
setIsRevoking(true);
try {
await onRevoke(request.team_id);
} catch (error) {
console.error('Failed to revoke Team Observability request:', error);
} finally {
setIsRevoking(false);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(
Math.ceil((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
'day'
);
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="border-2 border-green-200 bg-green-50 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between gap-4">
{/* Left side - Request info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center flex-shrink-0">
<Eye className="w-5 h-5 text-green-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">
{request.team_name}
</h3>
<p className="text-xs text-gray-500">
Requested by {request.requested_by_name}
</p>
</div>
</div>
<p className="text-sm text-gray-600 mb-3">
<strong>{request.requested_by_name}</strong> wants to view your activity on the team observability dashboard.
By approving, team managers will be able to see your conversations and usage metrics.
</p>
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>Requested {formatDate(request.requested_at)}</span>
</div>
<div className="flex items-center gap-1">
<Shield className="w-3 h-3" />
<span className="text-green-600 font-medium">Team Observability Status</span>
</div>
</div>
</div>
{/* Right side - Actions */}
<div className="flex flex-col gap-2 flex-shrink-0">
<Button
size="sm"
onClick={handleApprove}
disabled={isApproving || isRevoking}
className="bg-green-600 hover:bg-green-700 text-white"
>
{isApproving ? (
<>
<div className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-solid border-white border-r-transparent mr-2" />
Approving...
</>
) : (
<>
<Check className="w-4 h-4 mr-2" />
Approve
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRevoke}
disabled={isApproving || isRevoking}
className="border-gray-300 hover:bg-gray-100"
>
{isRevoking ? (
<>
<div className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-solid border-gray-600 border-r-transparent mr-2" />
Declining...
</>
) : (
<>
<X className="w-4 h-4 mr-2" />
Decline
</>
)}
</Button>
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, ChevronRight, Eye, Inbox } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ObservableRequestCard } from './observable-request-card';
import { usePendingObservableRequests, useApproveObservableRequest, useRevokeObservableStatus } from '@/hooks/use-teams';
export function ObservableRequestPanel() {
const [isExpanded, setIsExpanded] = useState(false);
const { data: requests = [], isLoading } = usePendingObservableRequests();
const approveRequest = useApproveObservableRequest();
const revokeRequest = useRevokeObservableStatus();
const handleApprove = async (teamId: string) => {
try {
await approveRequest.mutateAsync(teamId);
alert('Team Observability approved! Team managers can now view your activity.');
} catch (error: any) {
alert(`Failed to approve Team Observability request: ${error.message || 'An error occurred'}`);
}
};
const handleRevoke = async (teamId: string) => {
try {
await revokeRequest.mutateAsync(teamId);
alert('Team Observability request declined. Managers cannot view your activity.');
} catch (error: any) {
alert(`Failed to decline Team Observability request: ${error.message || 'An error occurred'}`);
}
};
// Don't show panel if no requests and not loading
if (!isLoading && requests.length === 0) {
return null;
}
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6"
>
<div className="bg-white border-2 border-green-200 rounded-lg shadow-sm overflow-hidden">
{/* Header - Always visible */}
<div
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-green-50 transition-colors cursor-pointer"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center">
<Eye className="w-5 h-5 text-green-600" />
</div>
<div className="text-left">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">
Team Observability Requests
</h3>
{requests.length > 0 && (
<Badge className="bg-green-600 hover:bg-green-700 text-white">
{requests.length}
</Badge>
)}
</div>
<p className="text-sm text-gray-500">
{isLoading
? 'Loading requests...'
: `${requests.length} pending ${requests.length === 1 ? 'request' : 'requests'}`
}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{!isExpanded && requests.length > 0 && (
<Button
size="sm"
className="bg-green-600 hover:bg-green-700 text-white"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(true);
}}
>
View
</Button>
)}
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-400" />
) : (
<ChevronRight className="w-5 h-5 text-gray-400" />
)}
</div>
</div>
{/* Expandable content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-green-200"
>
<div className="p-6 space-y-3">
{isLoading ? (
<div className="text-center py-8">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-green-600 border-r-transparent mb-3" />
<p className="text-gray-600">Loading Team Observability requests...</p>
</div>
) : requests.length > 0 ? (
requests.map((request) => (
<ObservableRequestCard
key={request.team_id}
request={request}
onApprove={handleApprove}
onRevoke={handleRevoke}
/>
))
) : (
<div className="text-center py-8">
<Inbox className="w-16 h-16 text-gray-300 mx-auto mb-3" />
<p className="text-gray-600 font-medium">No pending Team Observability requests</p>
<p className="text-sm text-gray-500 mt-1">
You're all caught up!
</p>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,385 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Share2, Bot, Database, Search, Eye, Edit } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { getAuthToken } from '@/services/auth';
import type { TeamMember } from '@/services/teams';
interface Resource {
id: string;
name: string;
description?: string;
is_owner: boolean;
}
interface ShareResourceInlineFormProps {
teamId: string;
teamName: string;
teamMembers: TeamMember[];
onShareResource: (resourceType: 'agent' | 'dataset', resourceId: string, userPermissions: Record<string, 'read' | 'edit'>) => Promise<void>;
onCancel: () => void;
loading?: boolean;
}
export function ShareResourceInlineForm({
teamId,
teamName,
teamMembers,
onShareResource,
onCancel,
loading = false
}: ShareResourceInlineFormProps) {
const [resourceType, setResourceType] = useState<'agent' | 'dataset'>('agent');
const [resources, setResources] = useState<Resource[]>([]);
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);
const [userPermissions, setUserPermissions] = useState<Record<string, 'read' | 'edit'>>({});
const [searchQuery, setSearchQuery] = useState('');
const [loadingResources, setLoadingResources] = useState(false);
const [error, setError] = useState('');
// Load resources when component mounts or resource type changes
useEffect(() => {
loadResources();
}, [resourceType]);
const loadResources = async () => {
setLoadingResources(true);
setError('');
try {
const token = getAuthToken();
if (!token) {
setError('Not authenticated');
return;
}
const endpoint = resourceType === 'agent' ? '/api/v1/agents' : '/api/v1/datasets/';
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to load ${resourceType}s`);
}
const data = await response.json();
// Filter to only show resources the user owns or can manage
const ownedResources = Array.isArray(data)
? data.filter((r: any) => r.is_owner || r.can_manage)
: data.data?.filter((r: any) => r.is_owner || r.can_manage) || [];
setResources(ownedResources);
} catch (err: any) {
console.error(`Error loading ${resourceType}s:`, err);
setError(err.message || `Failed to load ${resourceType}s`);
} finally {
setLoadingResources(false);
}
};
const handleResourceSelect = (resourceId: string) => {
setSelectedResourceId(resourceId);
// Reset permissions when selecting a new resource
setUserPermissions({});
};
const handlePermissionChange = (userId: string, permission: 'read' | 'edit' | null) => {
setUserPermissions(prev => {
const newPermissions = { ...prev };
if (permission === null) {
delete newPermissions[userId];
} else {
newPermissions[userId] = permission;
}
return newPermissions;
});
};
const handleBulkPermission = (permission: 'read' | 'edit') => {
const newPermissions: Record<string, 'read' | 'edit'> = {};
teamMembers.forEach(member => {
newPermissions[member.user_id] = permission;
});
setUserPermissions(newPermissions);
};
const handleClearPermissions = () => {
setUserPermissions({});
};
const handleSubmit = async () => {
if (!selectedResourceId) {
setError('Please select a resource to share');
return;
}
if (Object.keys(userPermissions).length === 0) {
setError('Please grant permissions to at least one team member');
return;
}
setError('');
try {
await onShareResource(resourceType, selectedResourceId, userPermissions);
handleReset();
onCancel(); // Close form after successful submission
} catch (err: any) {
setError(err.message || 'Failed to share resource');
}
};
const handleReset = () => {
setSelectedResourceId(null);
setUserPermissions({});
setSearchQuery('');
setError('');
};
const handleCancel = () => {
handleReset();
onCancel();
};
const filteredResources = resources.filter(resource =>
resource.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
resource.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
const selectedResource = resources.find(r => r.id === selectedResourceId);
const permissionCount = Object.keys(userPermissions).length;
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="mb-6 border-2 border-green-500 rounded-lg bg-green-50 overflow-hidden"
>
<div className="p-6 space-y-4 max-h-[600px] overflow-y-auto">
{/* Header */}
<div className="flex items-center gap-3 pb-4 border-b border-green-200">
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center">
<Share2 className="w-5 h-5 text-green-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Share Resource</h3>
<p className="text-sm text-gray-500">{teamName}</p>
</div>
</div>
{/* Resource Type Tabs */}
<Tabs value={resourceType} onValueChange={(value) => setResourceType(value as 'agent' | 'dataset')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="agent" className="flex items-center gap-2">
<Bot className="w-4 h-4" />
Agents
</TabsTrigger>
<TabsTrigger value="dataset" className="flex items-center gap-2">
<Database className="w-4 h-4" />
Datasets
</TabsTrigger>
</TabsList>
<TabsContent value={resourceType} className="space-y-4 mt-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="text"
value={searchQuery}
onChange={(value) => setSearchQuery(value)}
placeholder={`Search ${resourceType}s...`}
className="pl-10"
/>
</div>
{/* Resource List */}
{loadingResources ? (
<div className="text-center py-8 text-gray-500">
Loading {resourceType}s...
</div>
) : filteredResources.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{searchQuery ? `No ${resourceType}s found matching "${searchQuery}"` : `No ${resourceType}s available to share`}
</div>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto border rounded-lg p-2 bg-white">
{filteredResources.map(resource => (
<div
key={resource.id}
onClick={() => handleResourceSelect(resource.id)}
className={cn(
"p-3 rounded-lg border cursor-pointer transition-colors",
selectedResourceId === resource.id
? "bg-green-100 border-green-300"
: "hover:bg-gray-50 border-gray-200"
)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-sm">{resource.name}</h4>
{resource.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-1">{resource.description}</p>
)}
</div>
{selectedResourceId === resource.id && (
<Badge variant="default" className="bg-gt-green text-white ml-2">
Selected
</Badge>
)}
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
{/* Member Permissions */}
{selectedResource && (
<div className="space-y-3 border-t border-green-200 pt-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Set Member Permissions
{permissionCount > 0 && (
<Badge variant="secondary" className="ml-2">
{permissionCount} member{permissionCount > 1 ? 's' : ''}
</Badge>
)}
</Label>
{teamMembers.length === 0 && (
<p className="text-xs text-gray-500">No team members yet. Add members first.</p>
)}
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleBulkPermission('read')}
className="text-xs"
>
<Eye className="w-3 h-3 mr-1" />
All View
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleBulkPermission('edit')}
className="text-xs"
>
<Edit className="w-3 h-3 mr-1" />
All Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleClearPermissions}
disabled={permissionCount === 0}
className="text-xs"
>
Clear
</Button>
</div>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto border rounded-lg p-3 bg-white">
{teamMembers.map(member => {
const hasRead = userPermissions[member.user_id] === 'read' || userPermissions[member.user_id] === 'edit';
const hasEdit = userPermissions[member.user_id] === 'edit';
return (
<div
key={member.user_id}
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50"
>
<span className="flex-1 text-sm">
{member.user_name || member.user_email}
</span>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
id={`inline-${member.user_id}-read`}
checked={hasRead}
onCheckedChange={(checked) =>
handlePermissionChange(
member.user_id,
checked ? 'read' : null
)
}
/>
<label
htmlFor={`inline-${member.user_id}-read`}
className="text-xs cursor-pointer"
>
View
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`inline-${member.user_id}-edit`}
checked={hasEdit}
onCheckedChange={(checked) =>
handlePermissionChange(
member.user_id,
checked ? 'edit' : hasRead ? 'read' : null
)
}
/>
<label
htmlFor={`inline-${member.user_id}-edit`}
className="text-xs cursor-pointer"
>
Edit
</label>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={loading}
className="flex-1"
>
Cancel
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={loading || !selectedResourceId || permissionCount === 0}
className="flex-1 bg-gt-green hover:bg-gt-green/90"
>
{loading ? 'Sharing...' : 'Share Resource'}
</Button>
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,391 @@
'use client';
import { useState, useEffect } from 'react';
import { X, Share2, Bot, Database, Search, Eye, Edit, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { getAuthToken } from '@/services/auth';
import type { TeamMember } from '@/services/teams';
interface Resource {
id: string;
name: string;
description?: string;
is_owner: boolean;
}
interface ShareResourceModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
teamId: string;
teamName: string;
teamMembers: TeamMember[];
onShareResource: (resourceType: 'agent' | 'dataset', resourceId: string, userPermissions: Record<string, 'read' | 'edit'>) => Promise<void>;
loading?: boolean;
}
export function ShareResourceModal({
open,
onOpenChange,
teamId,
teamName,
teamMembers,
onShareResource,
loading = false
}: ShareResourceModalProps) {
const [resourceType, setResourceType] = useState<'agent' | 'dataset'>('agent');
const [resources, setResources] = useState<Resource[]>([]);
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);
const [userPermissions, setUserPermissions] = useState<Record<string, 'read' | 'edit'>>({});
const [searchQuery, setSearchQuery] = useState('');
const [loadingResources, setLoadingResources] = useState(false);
const [error, setError] = useState('');
// Load resources when modal opens or resource type changes
useEffect(() => {
if (open) {
loadResources();
}
}, [open, resourceType]);
const loadResources = async () => {
setLoadingResources(true);
setError('');
try {
const token = getAuthToken();
if (!token) {
setError('Not authenticated');
return;
}
const endpoint = resourceType === 'agent' ? '/api/v1/agents' : '/api/v1/datasets/';
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to load ${resourceType}s`);
}
const data = await response.json();
// Filter to only show resources the user owns or can manage
const ownedResources = Array.isArray(data)
? data.filter((r: any) => r.is_owner || r.can_manage)
: data.data?.filter((r: any) => r.is_owner || r.can_manage) || [];
setResources(ownedResources);
} catch (err: any) {
console.error(`Error loading ${resourceType}s:`, err);
setError(err.message || `Failed to load ${resourceType}s`);
} finally {
setLoadingResources(false);
}
};
const handleResourceSelect = (resourceId: string) => {
setSelectedResourceId(resourceId);
// Reset permissions when selecting a new resource
setUserPermissions({});
};
const handlePermissionChange = (userId: string, permission: 'read' | 'edit' | null) => {
setUserPermissions(prev => {
const newPermissions = { ...prev };
if (permission === null) {
delete newPermissions[userId];
} else {
newPermissions[userId] = permission;
}
return newPermissions;
});
};
const handleBulkPermission = (permission: 'read' | 'edit') => {
const newPermissions: Record<string, 'read' | 'edit'> = {};
teamMembers.forEach(member => {
newPermissions[member.user_id] = permission;
});
setUserPermissions(newPermissions);
};
const handleClearPermissions = () => {
setUserPermissions({});
};
const handleSubmit = async () => {
if (!selectedResourceId) {
setError('Please select a resource to share');
return;
}
if (Object.keys(userPermissions).length === 0) {
setError('Please grant permissions to at least one team member');
return;
}
setError('');
try {
await onShareResource(resourceType, selectedResourceId, userPermissions);
handleClose();
} catch (err: any) {
setError(err.message || 'Failed to share resource');
}
};
const handleClose = () => {
setSelectedResourceId(null);
setUserPermissions({});
setSearchQuery('');
setError('');
onOpenChange(false);
};
const filteredResources = resources.filter(resource =>
resource.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
resource.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
const selectedResource = resources.find(r => r.id === selectedResourceId);
const permissionCount = Object.keys(userPermissions).length;
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center">
<Share2 className="w-5 h-5 text-gt-green" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Share Resource</h2>
<p className="text-sm text-gray-500">{teamName}</p>
</div>
</div>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
disabled={loading}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Resource Type Tabs */}
<Tabs value={resourceType} onValueChange={(value) => setResourceType(value as 'agent' | 'dataset')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="agent" className="flex items-center gap-2">
<Bot className="w-4 h-4" />
Agents
</TabsTrigger>
<TabsTrigger value="dataset" className="flex items-center gap-2">
<Database className="w-4 h-4" />
Datasets
</TabsTrigger>
</TabsList>
<TabsContent value={resourceType} className="space-y-4 mt-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={`Search ${resourceType}s...`}
className="pl-10"
/>
</div>
{/* Resource List */}
{loadingResources ? (
<div className="text-center py-8 text-gray-500">
Loading {resourceType}s...
</div>
) : filteredResources.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{searchQuery ? `No ${resourceType}s found matching "${searchQuery}"` : `No ${resourceType}s available to share`}
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto border rounded-lg p-2">
{filteredResources.map(resource => (
<div
key={resource.id}
onClick={() => handleResourceSelect(resource.id)}
className={cn(
"p-3 rounded-lg border cursor-pointer transition-colors",
selectedResourceId === resource.id
? "bg-green-50 border-green-300"
: "hover:bg-gray-50 border-gray-200"
)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-medium text-sm">{resource.name}</h3>
{resource.description && (
<p className="text-xs text-gray-500 mt-1 line-clamp-1">{resource.description}</p>
)}
</div>
{selectedResourceId === resource.id && (
<Badge variant="default" className="bg-gt-green text-white ml-2">
Selected
</Badge>
)}
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
{/* Member Permissions */}
{selectedResource && (
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Set Member Permissions
{permissionCount > 0 && (
<Badge variant="secondary" className="ml-2">
{permissionCount} member{permissionCount > 1 ? 's' : ''}
</Badge>
)}
</Label>
{teamMembers.length === 0 && (
<p className="text-xs text-gray-500">No team members yet. Add members first.</p>
)}
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleBulkPermission('read')}
className="text-xs"
>
<Eye className="w-3 h-3 mr-1" />
All View
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleBulkPermission('edit')}
className="text-xs"
>
<Edit className="w-3 h-3 mr-1" />
All Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleClearPermissions}
disabled={permissionCount === 0}
className="text-xs"
>
Clear
</Button>
</div>
</div>
<div className="space-y-2 max-h-48 overflow-y-auto border rounded-lg p-3">
{teamMembers.map(member => {
const hasRead = userPermissions[member.user_id] === 'read' || userPermissions[member.user_id] === 'edit';
const hasEdit = userPermissions[member.user_id] === 'edit';
return (
<div
key={member.user_id}
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50"
>
<span className="flex-1 text-sm">
{member.user_name || member.user_email}
</span>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
id={`${member.user_id}-read`}
checked={hasRead}
onCheckedChange={(checked) =>
handlePermissionChange(
member.user_id,
checked ? 'read' : null
)
}
/>
<label
htmlFor={`${member.user_id}-read`}
className="text-xs cursor-pointer"
>
View
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`${member.user_id}-edit`}
checked={hasEdit}
onCheckedChange={(checked) =>
handlePermissionChange(
member.user_id,
checked ? 'edit' : hasRead ? 'read' : null
)
}
/>
<label
htmlFor={`${member.user_id}-edit`}
className="text-xs cursor-pointer"
>
Edit
</label>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-3 p-6 border-t flex-shrink-0">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={loading}
className="flex-1"
>
Cancel
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={loading || !selectedResourceId || permissionCount === 0}
className="flex-1 bg-gt-green hover:bg-gt-green/90"
>
{loading ? 'Sharing...' : 'Share Resource'}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
'use client';
import {
Users,
Edit3,
Trash2,
Crown,
LogOut
} from 'lucide-react';
import { cn, formatDateTime } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import type { Team } from '@/services';
export interface TeamCardProps {
team: Team;
onManage?: (teamId: string) => void;
onEdit?: (teamId: string) => void;
onDelete?: (teamId: string) => void;
onLeave?: (teamId: string) => void;
className?: string;
}
export function TeamCard({
team,
onManage,
onEdit,
onDelete,
onLeave,
className = ''
}: TeamCardProps) {
return (
<div
className={cn(
'bg-white border rounded-lg p-4 hover:shadow-md transition-all duration-200 cursor-pointer',
className
)}
onClick={() => onManage?.(team.id)}
>
{/* Multi-breakpoint Responsive Grid */}
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] lg:grid-cols-[1fr_auto_auto] gap-x-4 gap-y-3 items-center">
{/* Left Section: Team Name and Info */}
<div className="min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<h3 className="text-base font-bold text-gray-900 truncate flex items-center gap-2">
<Users className="w-4 h-4 text-gt-green flex-shrink-0" />
{team.name}
</h3>
{team.is_owner && (
<Badge className="bg-gt-green text-white text-xs flex-shrink-0 flex items-center gap-1">
<Crown className="w-3 h-3" />
Owner
</Badge>
)}
</div>
{team.description && (
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{team.description}</p>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-600 mt-1">
{team.owner_name && (
<span className="text-gray-600">
Owner: <span className="font-medium">{team.owner_name}</span>
</span>
)}
<span className="text-gray-400"></span>
<span className="text-gray-400">Created {formatDateTime(team.created_at)}</span>
</div>
</div>
{/* Middle Section: Stats */}
<div className="flex items-center gap-3 md:gap-4 justify-start md:justify-end">
<div className="text-center">
<p className="font-semibold text-gray-900 text-sm">{team.member_count}</p>
<p className="text-xs text-gray-500 whitespace-nowrap">
{team.member_count === 1 ? 'Member' : 'Members'}
</p>
</div>
</div>
{/* Right Section: Actions */}
<div className="flex items-center gap-2 justify-start md:justify-end md:col-start-2 lg:col-start-3">
{/* Updated Date */}
<div className="text-xs text-gray-500 lg:max-w-[100px] lg:text-right leading-tight">
Updated {formatDateTime(team.updated_at)}
</div>
{/* Action Buttons */}
<div className="flex items-center gap-1 flex-shrink-0">
{team.can_manage ? (
<>
{/* Edit Button - Owner/Admin only */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onEdit?.(team.id);
}}
className="p-2 h-auto text-gray-400 hover:text-green-600 hover:bg-green-50"
title="Edit team"
>
<Edit3 className="w-4 h-4" />
</Button>
{/* Delete Button - Owner/Admin only */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDelete?.(team.id);
}}
className="p-2 h-auto text-gray-400 hover:text-red-600 hover:bg-red-50"
title="Delete team"
>
<Trash2 className="w-4 h-4" />
</Button>
</>
) : (
<>
{/* Leave Button - Members only (not owner) */}
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onLeave?.(team.id);
}}
className="p-2 h-auto text-gray-400 hover:text-orange-600 hover:bg-orange-50"
title="Leave team"
>
<LogOut className="w-4 h-4" />
</Button>
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { slideLeft } from '@/lib/animations/gt-animations';
import { X, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
interface TeamCreateModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateTeam: (team: CreateTeamData) => Promise<void>;
loading?: boolean;
}
export interface CreateTeamData {
name: string;
description?: string;
}
export function TeamCreateModal({
open,
onOpenChange,
onCreateTeam,
loading = false
}: TeamCreateModalProps) {
const [formData, setFormData] = useState<CreateTeamData>({
name: '',
description: ''
});
const resetForm = () => {
setFormData({
name: '',
description: ''
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) return;
try {
await onCreateTeam(formData);
resetForm();
onOpenChange(false);
} catch (error) {
console.error('Failed to create team:', error);
}
};
const handleClose = () => {
resetForm();
onOpenChange(false);
};
if (!open) return null;
return createPortal(
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[999]"
onClick={handleClose}
/>
{/* Panel */}
<motion.div
className="fixed right-0 top-0 h-screen w-full max-w-2xl bg-white shadow-2xl z-[1000] overflow-y-auto"
variants={slideLeft}
initial="initial"
animate="animate"
exit="exit"
>
{/* Header */}
<div
className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 z-10"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gt-green/10 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-gt-green" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900">Create Team</h2>
<p className="text-sm text-gray-600">Create a new team for collaboration</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClose}
className="p-1 h-auto"
>
<X className="w-5 h-5" />
</Button>
</div>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
onClick={(e) => e.stopPropagation()}
className="p-6 space-y-6"
>
{/* Basic Information */}
<div className="space-y-4">
<div>
<Label htmlFor="name" className="text-sm font-medium">
Team Name *
</Label>
<input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Engineering Team"
required
autoFocus
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gt-green focus:border-gt-green"
/>
</div>
<div>
<Label htmlFor="description" className="text-sm font-medium">
Description
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Describe the purpose of this team..."
rows={3}
className="mt-1"
/>
<p className="text-xs text-gray-500 mt-1">
Optional: Explain what this team is for and who should join
</p>
</div>
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex gap-3">
<Users className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-900">
<p className="font-medium mb-1">You will be the team owner</p>
<p className="text-blue-700">
As the owner, you can manage team members, set permissions, and share resources.
You can add members after creating the team.
</p>
</div>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end gap-3 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !formData.name.trim()}
className="bg-gt-green hover:bg-gt-green/90"
>
{loading ? 'Creating...' : 'Create Team'}
</Button>
</div>
</form>
</motion.div>
</>
)}
</AnimatePresence>,
document.body
);
}

View File

@@ -0,0 +1,203 @@
'use client';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { slideLeft } from '@/lib/animations/gt-animations';
import { X, Users, Edit3 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import type { Team } from '@/services';
interface TeamEditModalProps {
open: boolean;
team: Team | null;
onOpenChange: (open: boolean) => void;
onUpdateTeam: (teamId: string, updates: UpdateTeamData) => Promise<void>;
loading?: boolean;
}
export interface UpdateTeamData {
name?: string;
description?: string;
}
export function TeamEditModal({
open,
team,
onOpenChange,
onUpdateTeam,
loading = false
}: TeamEditModalProps) {
const [formData, setFormData] = useState<UpdateTeamData>({
name: '',
description: ''
});
// Load team data when modal opens or team changes
useEffect(() => {
if (open && team) {
setFormData({
name: team.name,
description: team.description || ''
});
}
}, [open, team]);
const resetForm = () => {
setFormData({
name: '',
description: ''
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!team || !formData.name?.trim()) return;
try {
await onUpdateTeam(team.id, formData);
resetForm();
onOpenChange(false);
} catch (error) {
console.error('Failed to update team:', error);
}
};
const handleClose = () => {
resetForm();
onOpenChange(false);
};
if (!open || !team) return null;
return createPortal(
<AnimatePresence>
{open && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[999]"
onClick={handleClose}
/>
{/* Panel */}
<motion.div
className="fixed right-0 top-0 h-screen w-full max-w-2xl bg-white shadow-2xl z-[1000] overflow-y-auto"
variants={slideLeft}
initial="initial"
animate="animate"
exit="exit"
>
{/* Header */}
<div
className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 z-10"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
<Edit3 className="w-5 h-5 text-blue-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900">Edit Team</h2>
<p className="text-sm text-gray-600">Update team information</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClose}
className="p-1 h-auto"
>
<X className="w-5 h-5" />
</Button>
</div>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
onClick={(e) => e.stopPropagation()}
className="p-6 space-y-6"
>
{/* Basic Information */}
<div className="space-y-4">
<div>
<Label htmlFor="name" className="text-sm font-medium">
Team Name *
</Label>
<input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Engineering Team"
required
autoFocus
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<Label htmlFor="description" className="text-sm font-medium">
Description
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Describe the purpose of this team..."
rows={3}
className="mt-1"
/>
</div>
</div>
{/* Team Stats */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<Users className="w-5 h-5 text-gray-600 flex-shrink-0" />
<div className="text-sm text-gray-900">
<p className="font-medium">
{team.member_count} {team.member_count === 1 ? 'member' : 'members'}
</p>
<p className="text-gray-600 text-xs">
Created {new Date(team.created_at).toLocaleDateString()}
</p>
</div>
</div>
</div>
{/* Form Actions */}
<div className="flex justify-end gap-3 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !formData.name?.trim()}
className="bg-blue-600 hover:bg-blue-700"
>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</motion.div>
</>
)}
</AnimatePresence>,
document.body
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
"use client";
import React, { useState } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Users, Info } from 'lucide-react';
import Link from 'next/link';
export interface TeamShare {
team_id: string;
user_permissions: Record<string, 'read' | 'edit'>;
}
interface TeamShareConfigurationProps {
userTeams: Array<{
id: string;
name: string;
description?: string;
user_permission?: string;
is_owner?: boolean;
can_manage?: boolean;
}>;
value: TeamShare[];
onChange: (shares: TeamShare[]) => void;
disabled?: boolean;
}
export function TeamShareConfiguration({
userTeams,
value = [],
onChange,
disabled = false,
}: TeamShareConfigurationProps) {
const [selectedTeamIds, setSelectedTeamIds] = useState<Set<string>>(
new Set(value.map((s) => s.team_id))
);
// Filter teams where user has 'share' permission, is owner, or can manage
const shareableTeams = userTeams?.filter(
(t) => t.user_permission === 'share' || t.user_permission === 'manager' || t.is_owner || t.can_manage
) || [];
// Handle team selection
const handleTeamToggle = (teamId: string, checked: boolean) => {
const newSelected = new Set(selectedTeamIds);
if (checked) {
newSelected.add(teamId);
// Add team with empty permissions (backend will auto-populate with 'read' for all members)
const existingShare = value.find((s) => s.team_id === teamId);
if (!existingShare) {
onChange([...value, { team_id: teamId, user_permissions: {} }]);
}
} else {
newSelected.delete(teamId);
// Remove team from shares
onChange(value.filter((s) => s.team_id !== teamId));
}
setSelectedTeamIds(newSelected);
};
if (shareableTeams.length === 0) {
return (
<div className="rounded-md border border-dashed p-6 text-center">
<Users className="mx-auto h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
You don't have permission to share to any teams yet.
</p>
<p className="text-xs text-muted-foreground mt-1">
Contact a team owner to get sharing permissions.
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Info Message */}
<div className="flex items-start gap-2 p-3 bg-blue-50 border border-blue-200 rounded-md">
<Info className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-blue-800">
Team members will have <strong>read access by default</strong> when you share to a team.
</p>
<p className="text-xs text-blue-700 mt-1">
For fine-grained permission control (edit access, custom permissions), manage them in{' '}
<Link href="/teams" className="underline font-medium">
Team Management
</Link>.
</p>
</div>
</div>
{/* Team Selection */}
<div>
<Label className="text-sm font-medium mb-2 block">
Select Teams to Share With
</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{shareableTeams.map((team) => (
<div
key={team.id}
className="flex items-center space-x-2 rounded-md border p-3 hover:bg-accent transition-colors"
>
<Checkbox
id={`team-${team.id}`}
checked={selectedTeamIds.has(team.id)}
onCheckedChange={(checked) =>
handleTeamToggle(team.id, checked as boolean)
}
disabled={disabled}
/>
<label
htmlFor={`team-${team.id}`}
className="flex-1 text-sm font-medium leading-none cursor-pointer"
>
{team.name}
{team.is_owner && (
<Badge variant="secondary" className="ml-2 text-xs">
Owner
</Badge>
)}
</label>
</div>
))}
</div>
</div>
</div>
);
}