mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
74 files imported `lucide-react` directly, pinned ^1.26.0, while @luxfi/ui — which this console renders alongside — resolves 1.27. Two Lucide copies from two resolutions in one bundle is how a set stops being one set. Two more glyphs were not from any icon library at all: the node list drew its network in/out arrows as the literal characters "↓" and "↑", and the chain onboarding selector drew its tick as "✓". Those are font glyphs — they take the text metrics, not the icon box, so they sit on a different baseline and scale with the font, not `size-*`. One source now: `@luxfi/ui/icons`, Lucide as @hanzo/gui standardises on it and @luxfi/ui themes it. `lucide-react` is out of package.json, so per-surface drift is no longer expressible; @luxfi/ui comes in at 7.4.3, which is where `./icons` lives. The three text glyphs are ArrowDown / ArrowUp / Check. All 121 distinct icon names this console uses were checked against the 6014 symbols @luxfi/ui/icons exports before the rewrite — zero missing, with a negative control. `next build` is clean: 78 routes, Next 16.2.11 + Turbopack. The d3 chart roots (node-globe, node-map, globe-view, donut-chart, fleet-map) keep their <svg>: those are plots, not glyphs. The command menu's "↑↓" stays — it is a keyboard legend inside <kbd>, and it means the keys. Co-authored-by: Hanzo Dev <[email protected]>
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
"use client"
|
|
|
|
// Organization Team Management Page
|
|
// Unified team and member management for all organizations
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
|
import { useAuth } from "@/lib/auth"
|
|
import { team as teamApi, TeamMember } from "@/lib/api"
|
|
import { Users, UserPlus, Mail, Shield, Settings, Trash2, Loader2 } from "@luxfi/ui/icons"
|
|
|
|
export default function TeamPage() {
|
|
const { user } = useAuth()
|
|
const [showInviteDialog, setShowInviteDialog] = useState(false)
|
|
const [showEditDialog, setShowEditDialog] = useState(false)
|
|
const [editingMember, setEditingMember] = useState<TeamMember | null>(null)
|
|
const [members, setMembers] = useState<TeamMember[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [inviteEmail, setInviteEmail] = useState("")
|
|
const [inviteRole, setInviteRole] = useState("viewer")
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
const roles = [
|
|
{
|
|
name: "Admin",
|
|
description: "Full access to all features and settings",
|
|
permissions: ["read", "write", "admin", "billing"]
|
|
},
|
|
{
|
|
name: "Developer",
|
|
description: "Access to API, nodes, and development features",
|
|
permissions: ["read", "write", "api"]
|
|
},
|
|
{
|
|
name: "Viewer",
|
|
description: "Read-only access to dashboards and analytics",
|
|
permissions: ["read"]
|
|
}
|
|
]
|
|
|
|
// Fetch team members
|
|
const fetchMembers = async () => {
|
|
try {
|
|
setLoading(true)
|
|
const response = await teamApi.list()
|
|
setMembers(response.members)
|
|
setError(null)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to fetch team members")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchMembers()
|
|
}, [])
|
|
|
|
// Handle invite
|
|
const handleInvite = async () => {
|
|
if (!inviteEmail) return
|
|
|
|
try {
|
|
setSubmitting(true)
|
|
await teamApi.invite({ email: inviteEmail, role: inviteRole })
|
|
setInviteEmail("")
|
|
setInviteRole("viewer")
|
|
setShowInviteDialog(false)
|
|
await fetchMembers()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to invite member")
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
// Handle remove
|
|
const handleRemove = async (memberId: string) => {
|
|
if (!confirm("Are you sure you want to remove this team member?")) return
|
|
|
|
try {
|
|
await teamApi.remove(memberId)
|
|
await fetchMembers()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to remove member")
|
|
}
|
|
}
|
|
|
|
// Handle role update
|
|
const handleUpdateRole = async () => {
|
|
if (!editingMember) return
|
|
|
|
try {
|
|
setSubmitting(true)
|
|
await teamApi.update(editingMember.id, { role: editingMember.role })
|
|
setShowEditDialog(false)
|
|
setEditingMember(null)
|
|
await fetchMembers()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to update member")
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const openEditDialog = (member: TeamMember) => {
|
|
setEditingMember({ ...member })
|
|
setShowEditDialog(true)
|
|
}
|
|
|
|
const InviteDialog = () => (
|
|
<Dialog open={showInviteDialog} onOpenChange={setShowInviteDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Invite Team Member</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Email Address</label>
|
|
<Input
|
|
placeholder="Enter email address"
|
|
className="mt-1"
|
|
value={inviteEmail}
|
|
onChange={(e) => setInviteEmail(e.target.value)}
|
|
type="email"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Role</label>
|
|
<select
|
|
className="w-full mt-1 p-2 border rounded"
|
|
value={inviteRole}
|
|
onChange={(e) => setInviteRole(e.target.value)}
|
|
>
|
|
<option value="viewer">Viewer</option>
|
|
<option value="developer">Developer</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex justify-end space-x-2">
|
|
<Button variant="outline" onClick={() => setShowInviteDialog(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleInvite} disabled={submitting || !inviteEmail}>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Sending...
|
|
</>
|
|
) : (
|
|
"Send Invite"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
|
|
const EditDialog = () => (
|
|
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Team Member</DialogTitle>
|
|
</DialogHeader>
|
|
{editingMember && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Email</label>
|
|
<Input
|
|
className="mt-1"
|
|
value={editingMember.email}
|
|
disabled
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Role</label>
|
|
<select
|
|
className="w-full mt-1 p-2 border rounded"
|
|
value={editingMember.role}
|
|
onChange={(e) => setEditingMember({ ...editingMember, role: e.target.value })}
|
|
>
|
|
<option value="viewer">Viewer</option>
|
|
<option value="developer">Developer</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex justify-end space-x-2">
|
|
<Button variant="outline" onClick={() => setShowEditDialog(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleUpdateRole} disabled={submitting}>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
"Save Changes"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-96">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Team</h1>
|
|
<p className="text-muted-foreground">
|
|
Manage your organization's team members and permissions
|
|
</p>
|
|
</div>
|
|
<Button onClick={() => setShowInviteDialog(true)}>
|
|
<UserPlus className="mr-2 h-4 w-4" />
|
|
Invite Member
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Error display */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
{error}
|
|
<Button variant="ghost" size="sm" onClick={() => setError(null)} className="ml-2">
|
|
Dismiss
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Team Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center space-x-2">
|
|
<Users className="h-5 w-5 text-blue-500" />
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Total Members</p>
|
|
<p className="text-2xl font-bold">{members.length}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center space-x-2">
|
|
<Shield className="h-5 w-5 text-green-500" />
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Admins</p>
|
|
<p className="text-2xl font-bold">
|
|
{members.filter(m => m.role.toLowerCase() === "admin").length}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center space-x-2">
|
|
<Mail className="h-5 w-5 text-orange-500" />
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Pending Invites</p>
|
|
<p className="text-2xl font-bold">
|
|
{members.filter(m => m.status === "pending").length}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center space-x-2">
|
|
<Settings className="h-5 w-5 text-purple-500" />
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">Roles</p>
|
|
<p className="text-2xl font-bold">{roles.length}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Team Members Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Team Members</CardTitle>
|
|
<CardDescription>
|
|
Manage team member access and permissions
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{members.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
<Users className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
<p>No team members yet</p>
|
|
<p className="text-sm">Invite your first team member to get started</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Member</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Role</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Joined</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{members.map((member) => (
|
|
<TableRow key={member.id}>
|
|
<TableCell>
|
|
<div className="flex items-center space-x-3">
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarImage src={member.avatar || undefined} />
|
|
<AvatarFallback>
|
|
{(member.name || member.email).charAt(0).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="font-medium">
|
|
{member.name || member.email.split("@")[0]}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>{member.email}</TableCell>
|
|
<TableCell>
|
|
<Badge variant={member.role.toLowerCase() === "admin" ? "default" : "secondary"}>
|
|
{member.role}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={member.status === "active" ? "default" : "secondary"}>
|
|
{member.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
{member.joined_at
|
|
? new Date(member.joined_at).toLocaleDateString()
|
|
: "Pending"}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => openEditDialog(member)}
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
</Button>
|
|
{member.email !== user?.email && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRemove(member.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-red-500" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Roles & Permissions */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Roles & Permissions</CardTitle>
|
|
<CardDescription>
|
|
Available roles and their permission levels
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{roles.map((role) => (
|
|
<Card key={role.name} className="border-2">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">{role.name}</CardTitle>
|
|
<CardDescription>{role.description}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">Permissions:</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{role.permissions.map((permission) => (
|
|
<Badge key={permission} variant="outline">
|
|
{permission}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<InviteDialog />
|
|
<EditDialog />
|
|
</div>
|
|
)
|
|
}
|