Leon4gr45/builder
0
1'use client';2 3import React, { useState, useEffect, useMemo } from 'react';4import { getLoginUrl } from '@/lib/config/storage';5import {6 Building2,7 Plus,8 Search,9 MoreHorizontal,10 Trash2,11 Pencil,12 ChevronRight,13 ChevronDown,14 UserPlus,15 UserMinus,16} from 'lucide-react';17import { Button } from '@/components/ui/button';18import { Spinner } from '@/components/ui/spinner';19import { Input } from '@/components/ui/input';20import { Badge } from '@/components/ui/badge';21import { Label } from '@/components/ui/label';22import {23 Dialog,24 DialogContent,25 DialogHeader,26 DialogTitle,27 DialogFooter,28} from '@/components/ui/dialog';29import {30 DropdownMenu,31 DropdownMenuContent,32 DropdownMenuItem,33 DropdownMenuTrigger,34} from '@/components/ui/dropdown-menu';35import { toast } from 'sonner';36import { logger } from '@/lib/utils';37 38interface WorkspaceMember {39 userId: string;40 email: string;41 displayName: string | null;42 role: string;43 joinedAt: string;44}45 46interface WorkspaceInfo {47 id: string;48 name: string;49 ownerId: string;50 ownerEmail: string | null;51 maxProjects: number;52 maxDeployments: number;53 maxStorageMb: number;54 memberCount: number;55 projectCount: number;56 deploymentCount: number;57 createdAt: string;58 updatedAt: string;59}60 61interface WorkspaceDetail extends WorkspaceInfo {62 members: WorkspaceMember[];63}64 65export function WorkspacesView() {66 const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]);67 const [loading, setLoading] = useState(true);68 const [searchQuery, setSearchQuery] = useState('');69 const [saving, setSaving] = useState(false);70 71 // Edit dialog72 const [editWorkspace, setEditWorkspace] = useState<WorkspaceInfo | null>(null);73 const [showEditDialog, setShowEditDialog] = useState(false);74 const [editForm, setEditForm] = useState({75 name: '',76 maxProjects: 3,77 maxDeployments: 1,78 maxStorageMb: 100,79 });80 81 // Create dialog82 const [showCreateDialog, setShowCreateDialog] = useState(false);83 const [createForm, setCreateForm] = useState({84 name: '',85 ownerEmail: '',86 });87 88 // Expand / member detail89 const [expandedId, setExpandedId] = useState<string | null>(null);90 const [workspaceDetail, setWorkspaceDetail] = useState<WorkspaceDetail | null>(null);91 const [loadingDetail, setLoadingDetail] = useState(false);92 93 // Add member dialog94 const [showAddMemberDialog, setShowAddMemberDialog] = useState(false);95 const [addMemberForm, setAddMemberForm] = useState({ email: '', role: 'editor' });96 const [addingMember, setAddingMember] = useState(false);97 98 const isServerMode = process.env.NEXT_PUBLIC_SERVER_MODE === 'true';99 100 useEffect(() => {101 loadWorkspaces();102 }, []);103 104 const loadWorkspaces = async () => {105 setLoading(true);106 try {107 const res = await fetch('/api/admin/workspaces');108 if (res.status === 401) {109 window.location.href = getLoginUrl();110 return;111 }112 if (!res.ok) throw new Error('Failed to load workspaces');113 const data = await res.json();114 setWorkspaces(data.workspaces);115 } catch (err) {116 logger.error('[WorkspacesView] Failed to load workspaces:', err);117 toast.error('Failed to load workspaces');118 } finally {119 setLoading(false);120 }121 };122 123 const handleOpenEdit = (ws: WorkspaceInfo) => {124 setEditWorkspace(ws);125 setEditForm({126 name: ws.name,127 maxProjects: ws.maxProjects,128 maxDeployments: ws.maxDeployments,129 maxStorageMb: ws.maxStorageMb,130 });131 setShowEditDialog(true);132 };133 134 const handleSaveEdit = async () => {135 if (!editWorkspace) return;136 setSaving(true);137 try {138 const res = await fetch(`/api/admin/workspaces/${editWorkspace.id}`, {139 method: 'PUT',140 headers: { 'Content-Type': 'application/json' },141 body: JSON.stringify({142 name: editForm.name || undefined,143 maxProjects: editForm.maxProjects,144 maxDeployments: editForm.maxDeployments,145 maxStorageMb: editForm.maxStorageMb,146 }),147 });148 if (!res.ok) {149 const error = await res.json();150 throw new Error(error.error || 'Failed to update workspace');151 }152 toast.success('Workspace updated');153 setShowEditDialog(false);154 setEditWorkspace(null);155 await loadWorkspaces();156 // Refresh detail if expanded157 if (expandedId === editWorkspace.id) {158 await loadDetail(editWorkspace.id);159 }160 } catch (err) {161 logger.error('[WorkspacesView] Failed to update workspace:', err);162 toast.error(err instanceof Error ? err.message : 'Failed to update workspace');163 } finally {164 setSaving(false);165 }166 };167 168 const handleCreate = async () => {169 if (!createForm.name || !createForm.ownerEmail) {170 toast.error('Workspace name and owner email are required');171 return;172 }173 setSaving(true);174 try {175 const res = await fetch('/api/admin/workspaces', {176 method: 'POST',177 headers: { 'Content-Type': 'application/json' },178 body: JSON.stringify({179 name: createForm.name,180 ownerEmail: createForm.ownerEmail,181 }),182 });183 if (!res.ok) {184 const error = await res.json();185 throw new Error(error.error || 'Failed to create workspace');186 }187 toast.success('Workspace created');188 setShowCreateDialog(false);189 setCreateForm({ name: '', ownerEmail: '' });190 await loadWorkspaces();191 } catch (err) {192 logger.error('[WorkspacesView] Failed to create workspace:', err);193 toast.error(err instanceof Error ? err.message : 'Failed to create workspace');194 } finally {195 setSaving(false);196 }197 };198 199 const handleDelete = async (ws: WorkspaceInfo) => {200 if (!confirm(`Delete workspace "${ws.name}"? This cannot be undone.`)) return;201 try {202 const res = await fetch(`/api/admin/workspaces/${ws.id}`, { method: 'DELETE' });203 if (!res.ok) {204 const error = await res.json();205 throw new Error(error.error || 'Failed to delete workspace');206 }207 toast.success('Workspace deleted');208 if (expandedId === ws.id) {209 setExpandedId(null);210 setWorkspaceDetail(null);211 }212 await loadWorkspaces();213 } catch (err) {214 logger.error('[WorkspacesView] Failed to delete workspace:', err);215 toast.error(err instanceof Error ? err.message : 'Failed to delete workspace');216 }217 };218 219 const loadDetail = async (wsId: string) => {220 setLoadingDetail(true);221 try {222 const res = await fetch(`/api/admin/workspaces/${wsId}`);223 if (!res.ok) throw new Error('Failed to load workspace details');224 const data = await res.json();225 setWorkspaceDetail(data);226 } catch (err) {227 logger.error('[WorkspacesView] Failed to load workspace details:', err);228 toast.error('Failed to load workspace details');229 setExpandedId(null);230 } finally {231 setLoadingDetail(false);232 }233 };234 235 const handleToggleExpand = async (wsId: string) => {236 if (expandedId === wsId) {237 setExpandedId(null);238 setWorkspaceDetail(null);239 return;240 }241 setExpandedId(wsId);242 await loadDetail(wsId);243 };244 245 const handleAddMember = async () => {246 if (!expandedId || !addMemberForm.email) {247 toast.error('Email is required');248 return;249 }250 setAddingMember(true);251 try {252 const res = await fetch(`/api/admin/workspaces/${expandedId}/access`, {253 method: 'POST',254 headers: { 'Content-Type': 'application/json' },255 body: JSON.stringify({ email: addMemberForm.email, role: addMemberForm.role }),256 });257 if (!res.ok) {258 const error = await res.json();259 throw new Error(error.error || 'Failed to add member');260 }261 toast.success('Member added');262 setShowAddMemberDialog(false);263 setAddMemberForm({ email: '', role: 'editor' });264 await loadDetail(expandedId);265 await loadWorkspaces();266 } catch (err) {267 logger.error('[WorkspacesView] Failed to add member:', err);268 toast.error(err instanceof Error ? err.message : 'Failed to add member');269 } finally {270 setAddingMember(false);271 }272 };273 274 const handleRemoveMember = async (wsId: string, userId: string, memberEmail: string) => {275 if (!confirm(`Remove ${memberEmail} from this workspace?`)) return;276 try {277 const res = await fetch(`/api/admin/workspaces/${wsId}/access`, {278 method: 'DELETE',279 headers: { 'Content-Type': 'application/json' },280 body: JSON.stringify({ userId }),281 });282 if (!res.ok) {283 const error = await res.json();284 throw new Error(error.error || 'Failed to remove member');285 }286 toast.success('Member removed');287 await loadDetail(wsId);288 await loadWorkspaces();289 } catch (err) {290 logger.error('[WorkspacesView] Failed to remove member:', err);291 toast.error(err instanceof Error ? err.message : 'Failed to remove member');292 }293 };294 295 const filteredWorkspaces = useMemo(() => {296 if (!searchQuery) return workspaces;297 const query = searchQuery.toLowerCase();298 return workspaces.filter(299 ws =>300 ws.name.toLowerCase().includes(query) ||301 ws.ownerEmail?.toLowerCase().includes(query)302 );303 }, [workspaces, searchQuery]);304 305 const formatDate = (dateStr: string) => {306 try {307 return new Date(dateStr).toLocaleDateString(undefined, {308 year: 'numeric',309 month: 'short',310 day: 'numeric',311 });312 } catch {313 return dateStr;314 }315 };316 317 const roleBadgeVariant = (role: string): 'default' | 'secondary' | 'outline' => {318 switch (role) {319 case 'owner': return 'default';320 case 'editor': return 'secondary';321 default: return 'outline';322 }323 };324 325 if (!isServerMode) {326 return (327 <div className="h-full flex items-center justify-center">328 <div className="text-center text-muted-foreground">329 <p>Workspace management is only available in Server Mode</p>330 </div>331 </div>332 );333 }334 335 if (loading) {336 return (337 <div className="h-full flex items-center justify-center">338 <div className="text-center">339 <Spinner size={48} color="#f97316" className="mx-auto" />340 <p className="mt-4">Loading workspaces...</p>341 </div>342 </div>343 );344 }345 346 return (347 <>348 <div className="h-full flex flex-col">349 {/* Toolbar */}350 <div className="pt-4 px-4 pb-3 sm:pt-6 sm:px-6 sm:pb-3 shrink-0">351 <div className="mx-auto max-w-7xl flex flex-col sm:flex-row gap-3">352 <div className="relative flex-1">353 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />354 <Input355 placeholder="Search workspaces..."356 value={searchQuery}357 onChange={(e) => setSearchQuery(e.target.value)}358 className="pl-9"359 />360 </div>361 <div className="flex items-center gap-2">362 <Button onClick={() => setShowCreateDialog(true)} size="sm" className="gap-2">363 <Plus className="h-4 w-4" />364 <span>New Workspace</span>365 </Button>366 </div>367 </div>368 </div>369 370 {/* Workspace List */}371 <div className="flex-1 px-4 pt-3 pb-4 sm:px-6 sm:pt-3 sm:pb-6 overflow-auto">372 <div className="mx-auto max-w-7xl">373 {filteredWorkspaces.length === 0 ? (374 <div className="flex flex-col items-center justify-center py-16 text-center">375 <Building2 className="h-16 w-16 text-muted-foreground mb-4" />376 {workspaces.length === 0 ? (377 <>378 <h2 className="text-xl font-semibold mb-2">No Workspaces Yet</h2>379 <p className="text-muted-foreground mb-4 max-w-md">380 Create your first workspace by clicking the "New Workspace" button above.381 </p>382 </>383 ) : (384 <>385 <h2 className="text-xl font-semibold mb-2">No workspaces found</h2>386 <p className="text-muted-foreground mb-4 max-w-md">387 Try adjusting your search criteria388 </p>389 </>390 )}391 </div>392 ) : (393 <div className="space-y-2">394 {filteredWorkspaces.map((ws) => {395 const isExpanded = expandedId === ws.id;396 return (397 <div key={ws.id} className="rounded-lg border bg-card overflow-hidden">398 <div399 className="flex items-center gap-4 p-4 hover:bg-accent/50 transition-colors cursor-pointer"400 onClick={() => handleToggleExpand(ws.id)}401 >402 {/* Expand chevron */}403 <div className="shrink-0 text-muted-foreground">404 {isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}405 </div>406 407 {/* Workspace Info */}408 <div className="flex-1 min-w-0">409 <div className="flex items-center gap-2 flex-wrap">410 <span className="font-medium truncate">{ws.name}</span>411 </div>412 <div className="text-sm text-muted-foreground mt-1 flex items-center gap-3 flex-wrap">413 {ws.ownerEmail && (414 <span>Owner: {ws.ownerEmail}</span>415 )}416 <span>{ws.memberCount} {ws.memberCount === 1 ? 'member' : 'members'}</span>417 <span>{ws.projectCount} {ws.projectCount === 1 ? 'project' : 'projects'}</span>418 <span>Created {formatDate(ws.createdAt)}</span>419 </div>420 </div>421 422 {/* Actions */}423 <DropdownMenu>424 <DropdownMenuTrigger asChild>425 <Button426 variant="ghost"427 size="sm"428 className="h-8 w-8 p-0"429 onClick={(e) => e.stopPropagation()}430 >431 <MoreHorizontal className="h-4 w-4" />432 </Button>433 </DropdownMenuTrigger>434 <DropdownMenuContent align="end">435 <DropdownMenuItem onClick={() => handleOpenEdit(ws)}>436 <Pencil className="h-4 w-4 mr-2" />437 Edit438 </DropdownMenuItem>439 <DropdownMenuItem440 onClick={() => handleDelete(ws)}441 className="text-destructive focus:text-destructive"442 >443 <Trash2 className="h-4 w-4 mr-2" />444 Delete445 </DropdownMenuItem>446 </DropdownMenuContent>447 </DropdownMenu>448 </div>449 450 {/* Expanded members section */}451 {isExpanded && (452 <div className="border-t bg-muted/30 px-4 py-3 pl-12">453 {loadingDetail ? (454 <div className="flex items-center gap-2 text-sm text-muted-foreground py-2">455 <Spinner size={16} color="#f97316" />456 <span>Loading members...</span>457 </div>458 ) : workspaceDetail ? (459 <div className="space-y-3">460 {/* Stats row */}461 <div className="flex items-center gap-4 text-xs text-muted-foreground">462 <span>Max projects: {workspaceDetail.maxProjects}</span>463 <span>Max deployments: {workspaceDetail.maxDeployments}</span>464 <span>Max storage: {workspaceDetail.maxStorageMb} MB</span>465 <span>Deployments: {workspaceDetail.deploymentCount}</span>466 </div>467 468 {/* Members */}469 <div>470 <div className="flex items-center justify-between mb-2">471 <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">472 Members ({workspaceDetail.members.length})473 </div>474 <Button475 size="sm"476 variant="outline"477 className="h-7 text-xs gap-1"478 onClick={(e) => {479 e.stopPropagation();480 setShowAddMemberDialog(true);481 }}482 >483 <UserPlus className="h-3 w-3" />484 Add Member485 </Button>486 </div>487 {workspaceDetail.members.length > 0 ? (488 <div className="space-y-1">489 {workspaceDetail.members.map((member) => (490 <div491 key={member.userId}492 className="flex items-center gap-3 text-sm p-2 rounded bg-background/60"493 onClick={(e) => e.stopPropagation()}494 >495 <div className="flex-1 min-w-0">496 <div className="flex items-center gap-2 flex-wrap">497 <span className="font-medium truncate">{member.email}</span>498 <Badge variant={roleBadgeVariant(member.role)} className="text-xs">499 {member.role}500 </Badge>501 </div>502 {member.displayName && (503 <div className="text-xs text-muted-foreground">{member.displayName}</div>504 )}505 </div>506 <div className="text-xs text-muted-foreground shrink-0">507 {formatDate(member.joinedAt)}508 </div>509 {member.userId !== workspaceDetail.ownerId && (510 <Button511 variant="ghost"512 size="sm"513 className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"514 onClick={(e) => {515 e.stopPropagation();516 handleRemoveMember(ws.id, member.userId, member.email);517 }}518 title="Remove member"519 >520 <UserMinus className="h-3.5 w-3.5" />521 </Button>522 )}523 </div>524 ))}525 </div>526 ) : (527 <div className="text-sm text-muted-foreground py-1">No members</div>528 )}529 </div>530 </div>531 ) : null}532 </div>533 )}534 </div>535 );536 })}537 </div>538 )}539 </div>540 </div>541 </div>542 543 {/* Edit Workspace Dialog */}544 <Dialog open={showEditDialog} onOpenChange={(open) => { setShowEditDialog(open); if (!open) setEditWorkspace(null); }}>545 <DialogContent className="sm:max-w-md">546 <DialogHeader>547 <DialogTitle>Edit Workspace</DialogTitle>548 </DialogHeader>549 <div className="space-y-4 py-4">550 <div className="space-y-2">551 <Label htmlFor="edit-name">Name</Label>552 <Input553 id="edit-name"554 value={editForm.name}555 onChange={(e) => setEditForm(f => ({ ...f, name: e.target.value }))}556 placeholder="Workspace name"557 />558 </div>559 <div className="grid grid-cols-3 gap-3">560 <div className="space-y-2">561 <Label htmlFor="edit-maxProjects">Max Projects</Label>562 <Input563 id="edit-maxProjects"564 type="number"565 min={0}566 value={editForm.maxProjects}567 onChange={(e) => setEditForm(f => ({ ...f, maxProjects: Number(e.target.value) }))}568 />569 </div>570 <div className="space-y-2">571 <Label htmlFor="edit-maxDeployments">Max Deploys</Label>572 <Input573 id="edit-maxDeployments"574 type="number"575 min={0}576 value={editForm.maxDeployments}577 onChange={(e) => setEditForm(f => ({ ...f, maxDeployments: Number(e.target.value) }))}578 />579 </div>580 <div className="space-y-2">581 <Label htmlFor="edit-maxStorage">Storage (MB)</Label>582 <Input583 id="edit-maxStorage"584 type="number"585 min={0}586 value={editForm.maxStorageMb}587 onChange={(e) => setEditForm(f => ({ ...f, maxStorageMb: Number(e.target.value) }))}588 />589 </div>590 </div>591 </div>592 <DialogFooter>593 <Button variant="outline" onClick={() => { setShowEditDialog(false); setEditWorkspace(null); }}>Cancel</Button>594 <Button onClick={handleSaveEdit} disabled={saving}>595 {saving ? 'Saving...' : 'Save Changes'}596 </Button>597 </DialogFooter>598 </DialogContent>599 </Dialog>600 601 {/* Create Workspace Dialog */}602 <Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>603 <DialogContent className="sm:max-w-md">604 <DialogHeader>605 <DialogTitle>Create Workspace</DialogTitle>606 </DialogHeader>607 <div className="space-y-4 py-4">608 <div className="space-y-2">609 <Label htmlFor="create-name">Name</Label>610 <Input611 id="create-name"612 value={createForm.name}613 onChange={(e) => setCreateForm(f => ({ ...f, name: e.target.value }))}614 placeholder="Workspace name"615 />616 </div>617 <div className="space-y-2">618 <Label htmlFor="create-ownerEmail">Owner Email</Label>619 <Input620 id="create-ownerEmail"621 type="email"622 value={createForm.ownerEmail}623 onChange={(e) => setCreateForm(f => ({ ...f, ownerEmail: e.target.value }))}624 placeholder="owner@example.com"625 />626 </div>627 </div>628 <DialogFooter>629 <Button variant="outline" onClick={() => setShowCreateDialog(false)}>Cancel</Button>630 <Button onClick={handleCreate} disabled={saving}>631 {saving ? 'Creating...' : 'Create Workspace'}632 </Button>633 </DialogFooter>634 </DialogContent>635 </Dialog>636 637 {/* Add Member Dialog */}638 <Dialog open={showAddMemberDialog} onOpenChange={setShowAddMemberDialog}>639 <DialogContent className="sm:max-w-md">640 <DialogHeader>641 <DialogTitle>Add Member</DialogTitle>642 </DialogHeader>643 <div className="space-y-4 py-4">644 <div className="space-y-2">645 <Label htmlFor="add-member-email">User Email</Label>646 <Input647 id="add-member-email"648 type="email"649 value={addMemberForm.email}650 onChange={(e) => setAddMemberForm(f => ({ ...f, email: e.target.value }))}651 placeholder="user@example.com"652 />653 </div>654 <div className="space-y-2">655 <Label htmlFor="add-member-role">Role</Label>656 <select657 id="add-member-role"658 className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"659 value={addMemberForm.role}660 onChange={(e) => setAddMemberForm(f => ({ ...f, role: e.target.value }))}661 >662 <option value="viewer">viewer</option>663 <option value="editor">editor</option>664 <option value="owner">owner</option>665 </select>666 </div>667 </div>668 <DialogFooter>669 <Button variant="outline" onClick={() => setShowAddMemberDialog(false)}>Cancel</Button>670 <Button onClick={handleAddMember} disabled={addingMember}>671 {addingMember ? 'Adding...' : 'Add Member'}672 </Button>673 </DialogFooter>674 </DialogContent>675 </Dialog>676 </>677 );678}679 