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:
178
apps/tenant-app/src/components/teams/add-member-inline-form.tsx
Normal file
178
apps/tenant-app/src/components/teams/add-member-inline-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
apps/tenant-app/src/components/teams/add-member-modal.tsx
Normal file
184
apps/tenant-app/src/components/teams/add-member-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
apps/tenant-app/src/components/teams/delete-team-dialog.tsx
Normal file
90
apps/tenant-app/src/components/teams/delete-team-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
apps/tenant-app/src/components/teams/edit-team-inline-form.tsx
Normal file
141
apps/tenant-app/src/components/teams/edit-team-inline-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/tenant-app/src/components/teams/index.ts
Normal file
20
apps/tenant-app/src/components/teams/index.ts
Normal 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';
|
||||
144
apps/tenant-app/src/components/teams/invitation-card.tsx
Normal file
144
apps/tenant-app/src/components/teams/invitation-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
apps/tenant-app/src/components/teams/invitation-panel.tsx
Normal file
147
apps/tenant-app/src/components/teams/invitation-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
apps/tenant-app/src/components/teams/leave-team-dialog.tsx
Normal file
90
apps/tenant-app/src/components/teams/leave-team-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
apps/tenant-app/src/components/teams/observable-request-card.tsx
Normal file
137
apps/tenant-app/src/components/teams/observable-request-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
391
apps/tenant-app/src/components/teams/share-resource-modal.tsx
Normal file
391
apps/tenant-app/src/components/teams/share-resource-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
apps/tenant-app/src/components/teams/team-card.tsx
Normal file
141
apps/tenant-app/src/components/teams/team-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
apps/tenant-app/src/components/teams/team-create-modal.tsx
Normal file
192
apps/tenant-app/src/components/teams/team-create-modal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
203
apps/tenant-app/src/components/teams/team-edit-modal.tsx
Normal file
203
apps/tenant-app/src/components/teams/team-edit-modal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
1015
apps/tenant-app/src/components/teams/team-management-panel.tsx
Normal file
1015
apps/tenant-app/src/components/teams/team-management-panel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user