Leon4gr45/builder
0
1'use client';2 3import React, { useState, useEffect, useMemo } from 'react';4import { getLoginUrl } from '@/lib/config/storage';5import { Users, Plus, Search, MoreHorizontal, UserCheck, UserX, Trash2, Pencil, ChevronRight, ChevronDown, HardDrive, Eye, EyeOff } from 'lucide-react';6import { Button } from '@/components/ui/button';7import { Spinner } from '@/components/ui/spinner';8import { Input } from '@/components/ui/input';9import { Badge } from '@/components/ui/badge';10import { Label } from '@/components/ui/label';11import { Switch } from '@/components/ui/switch';12import {13 Dialog,14 DialogContent,15 DialogHeader,16 DialogTitle,17 DialogFooter,18} from '@/components/ui/dialog';19import {20 Select,21 SelectContent,22 SelectItem,23 SelectTrigger,24 SelectValue,25} from '@/components/ui/select';26import {27 DropdownMenu,28 DropdownMenuContent,29 DropdownMenuItem,30 DropdownMenuTrigger,31} from '@/components/ui/dropdown-menu';32import { toast } from 'sonner';33import { logger } from '@/lib/utils';34 35interface WorkspaceInfo {36 id: string;37 name: string;38 owner_id: string;39 max_projects: number;40 max_deployments: number;41 max_storage_mb: number;42 role: string;43 created_at: string;44 updated_at: string;45}46 47interface UserInfo {48 id: string;49 email: string;50 displayName: string | null;51 isAdmin: boolean;52 active: boolean;53 workspaces: WorkspaceInfo[];54 projectCount: number;55 storageMb: number;56 lastActive: string | null;57 createdAt: string;58 updatedAt: string;59}60 61 62export function UsersView() {63 const [users, setUsers] = useState<UserInfo[]>([]);64 const [loading, setLoading] = useState(true);65 const [searchQuery, setSearchQuery] = useState('');66 const [editUser, setEditUser] = useState<UserInfo | null>(null);67 const [showEditDialog, setShowEditDialog] = useState(false);68 const [showCreateDialog, setShowCreateDialog] = useState(false);69 const [saving, setSaving] = useState(false);70 71 // Edit form state72 const [editForm, setEditForm] = useState({73 displayName: '',74 active: true,75 });76 77 // Create form state78 const [createForm, setCreateForm] = useState({79 email: '',80 password: '',81 displayName: '',82 workspaceAssignment: 'new' as 'new' | 'existing' | 'none',83 workspaceId: '',84 });85 const [showPassword, setShowPassword] = useState(false);86 87 const [availableWorkspaces, setAvailableWorkspaces] = useState<Array<{ id: string; name: string }>>([]);88 89 // Expandable workspace detail state90 const [expandedUserId, setExpandedUserId] = useState<string | null>(null);91 92 const isServerMode = process.env.NEXT_PUBLIC_SERVER_MODE === 'true';93 const isManagedMode = !!process.env.NEXT_PUBLIC_GATEWAY_URL;94 95 useEffect(() => {96 loadUsers();97 }, []);98 99 useEffect(() => {100 if (showCreateDialog) {101 fetch('/api/admin/workspaces')102 .then(res => res.ok ? res.json() : null)103 .then(data => {104 if (data?.workspaces) {105 setAvailableWorkspaces(data.workspaces.map((w: { id: string; name: string }) => ({ id: w.id, name: w.name })));106 }107 })108 .catch(() => {});109 }110 }, [showCreateDialog]);111 112 const loadUsers = async () => {113 setLoading(true);114 try {115 const res = await fetch('/api/admin/users');116 if (res.status === 401) {117 window.location.href = getLoginUrl();118 return;119 }120 if (!res.ok) throw new Error('Failed to load users');121 const data = await res.json();122 setUsers(data.users);123 } catch (err) {124 logger.error('[UsersView] Failed to load users:', err);125 toast.error('Failed to load users');126 } finally {127 setLoading(false);128 }129 };130 131 const handleOpenEdit = (user: UserInfo) => {132 setEditUser(user);133 setEditForm({134 displayName: user.displayName || '',135 active: user.active,136 });137 setShowEditDialog(true);138 };139 140 const handleSaveEdit = async () => {141 if (!editUser) return;142 setSaving(true);143 try {144 const res = await fetch(`/api/admin/users/${editUser.id}`, {145 method: 'PUT',146 headers: { 'Content-Type': 'application/json' },147 body: JSON.stringify({148 displayName: editForm.displayName || undefined,149 active: editForm.active,150 }),151 });152 if (!res.ok) {153 const error = await res.json();154 throw new Error(error.error || 'Failed to update user');155 }156 toast.success('User updated');157 setShowEditDialog(false);158 setEditUser(null);159 await loadUsers();160 } catch (err) {161 logger.error('[UsersView] Failed to update user:', err);162 toast.error(err instanceof Error ? err.message : 'Failed to update user');163 } finally {164 setSaving(false);165 }166 };167 168 const handleToggleActive = async (user: UserInfo) => {169 try {170 const res = await fetch(`/api/admin/users/${user.id}`, {171 method: 'PUT',172 headers: { 'Content-Type': 'application/json' },173 body: JSON.stringify({ active: !user.active }),174 });175 if (!res.ok) {176 const error = await res.json();177 throw new Error(error.error || 'Failed to update user');178 }179 toast.success(user.active ? 'User deactivated' : 'User reactivated');180 await loadUsers();181 } catch (err) {182 logger.error('[UsersView] Failed to toggle user status:', err);183 toast.error(err instanceof Error ? err.message : 'Failed to update user');184 }185 };186 187 const handleDelete = async (user: UserInfo) => {188 if (!confirm(`Deactivate user "${user.email}"? This will soft-delete their account.`)) {189 return;190 }191 try {192 const res = await fetch(`/api/admin/users/${user.id}`, {193 method: 'DELETE',194 });195 if (!res.ok) {196 const error = await res.json();197 throw new Error(error.error || 'Failed to delete user');198 }199 toast.success('User deactivated');200 await loadUsers();201 } catch (err) {202 logger.error('[UsersView] Failed to delete user:', err);203 toast.error(err instanceof Error ? err.message : 'Failed to delete user');204 }205 };206 207 const handleCreate = async () => {208 if (!createForm.email || !createForm.password) {209 toast.error('Email and password are required');210 return;211 }212 setSaving(true);213 try {214 const res = await fetch('/api/admin/users', {215 method: 'POST',216 headers: { 'Content-Type': 'application/json' },217 body: JSON.stringify({218 email: createForm.email,219 password: createForm.password,220 displayName: createForm.displayName || undefined,221 workspaceAssignment: createForm.workspaceAssignment,222 workspaceId: createForm.workspaceId || undefined,223 }),224 });225 if (!res.ok) {226 const error = await res.json();227 throw new Error(error.error || 'Failed to create user');228 }229 toast.success('User created');230 setShowCreateDialog(false);231 setCreateForm({ email: '', password: '', displayName: '', workspaceAssignment: 'new', workspaceId: '' });232 setShowPassword(false);233 await loadUsers();234 } catch (err) {235 logger.error('[UsersView] Failed to create user:', err);236 toast.error(err instanceof Error ? err.message : 'Failed to create user');237 } finally {238 setSaving(false);239 }240 };241 242 const handleToggleExpand = (userId: string) => {243 setExpandedUserId(expandedUserId === userId ? null : userId);244 };245 246 const filteredUsers = useMemo(() => {247 if (!searchQuery) return users;248 const query = searchQuery.toLowerCase();249 return users.filter(250 user =>251 user.email.toLowerCase().includes(query) ||252 user.displayName?.toLowerCase().includes(query)253 );254 }, [users, searchQuery]);255 256 const formatDate = (dateStr: string) => {257 try {258 return new Date(dateStr).toLocaleDateString(undefined, {259 year: 'numeric',260 month: 'short',261 day: 'numeric',262 });263 } catch {264 return dateStr;265 }266 };267 268 const roleBadgeVariant = (role: string): 'default' | 'secondary' | 'destructive' | 'outline' => {269 switch (role) {270 case 'owner': return 'default';271 case 'editor': return 'secondary';272 default: return 'outline';273 }274 };275 276 if (!isServerMode) {277 return (278 <div className="h-full flex items-center justify-center">279 <div className="text-center text-muted-foreground">280 <p>User management is only available in Server Mode</p>281 </div>282 </div>283 );284 }285 286 if (loading) {287 return (288 <div className="h-full flex items-center justify-center">289 <div className="text-center">290 <Spinner size={48} color="#f97316" className="mx-auto" />291 <p className="mt-4">Loading users...</p>292 </div>293 </div>294 );295 }296 297 return (298 <>299 <div className="h-full flex flex-col">300 {/* Toolbar */}301 <div className="pt-4 px-4 pb-3 sm:pt-6 sm:px-6 sm:pb-3 shrink-0">302 <div className="mx-auto max-w-7xl flex flex-col sm:flex-row gap-3">303 {/* Search */}304 <div className="relative flex-1">305 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />306 <Input307 placeholder="Search users..."308 value={searchQuery}309 onChange={(e) => setSearchQuery(e.target.value)}310 className="pl-9"311 />312 </div>313 314 {/* Controls */}315 <div className="flex items-center gap-2">316 {isManagedMode ? (317 <p className="text-sm text-muted-foreground">318 Users are managed externally319 </p>320 ) : (321 <Button onClick={() => setShowCreateDialog(true)} size="sm" className="gap-2">322 <Plus className="h-4 w-4" />323 <span>New User</span>324 </Button>325 )}326 </div>327 </div>328 </div>329 330 {/* User List */}331 <div className="flex-1 px-4 pt-3 pb-4 sm:px-6 sm:pt-3 sm:pb-6 overflow-auto">332 <div className="mx-auto max-w-7xl">333 {filteredUsers.length === 0 ? (334 <div className="flex flex-col items-center justify-center py-16 text-center">335 <Users className="h-16 w-16 text-muted-foreground mb-4" />336 {users.length === 0 ? (337 <>338 <h2 className="text-xl font-semibold mb-2">No Users Yet</h2>339 <p className="text-muted-foreground mb-4 max-w-md">340 Create your first user by clicking the "New User" button above.341 </p>342 </>343 ) : (344 <>345 <h2 className="text-xl font-semibold mb-2">No users found</h2>346 <p className="text-muted-foreground mb-4 max-w-md">347 Try adjusting your search criteria348 </p>349 </>350 )}351 </div>352 ) : (353 <div className="space-y-2">354 {filteredUsers.map((user) => {355 const isExpanded = expandedUserId === user.id;356 return (357 <div key={user.id} className="rounded-lg border bg-card overflow-hidden">358 <div359 className="flex items-center gap-4 p-4 hover:bg-accent/50 transition-colors cursor-pointer"360 onClick={() => handleToggleExpand(user.id)}361 >362 {/* Expand chevron */}363 <div className="shrink-0 text-muted-foreground">364 {isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}365 </div>366 367 {/* User Info */}368 <div className="flex-1 min-w-0">369 <div className="flex items-center gap-2 flex-wrap">370 <span className="font-medium truncate">{user.email}</span>371 {user.isAdmin && (372 <Badge variant="destructive" className="text-xs">admin</Badge>373 )}374 {!user.active && (375 <Badge variant="outline" className="text-xs text-muted-foreground">inactive</Badge>376 )}377 {user.workspaces.map(ws => (378 <Badge key={ws.id} variant={roleBadgeVariant(ws.role)} className="text-xs">379 {ws.name} ({ws.role})380 </Badge>381 ))}382 </div>383 <div className="text-sm text-muted-foreground mt-1 flex items-center gap-3 flex-wrap">384 {user.displayName && (385 <span>{user.displayName}</span>386 )}387 <span>{user.projectCount} projects</span>388 <span>389 <HardDrive className="inline h-3 w-3 mr-0.5 relative -top-px" />390 {user.storageMb} MB391 </span>392 {user.lastActive ? (393 <span>Active {formatDate(user.lastActive)}</span>394 ) : (395 <span>Created {formatDate(user.createdAt)}</span>396 )}397 </div>398 </div>399 400 {/* Actions */}401 <DropdownMenu>402 <DropdownMenuTrigger asChild>403 <Button404 variant="ghost"405 size="sm"406 className="h-8 w-8 p-0"407 onClick={(e) => e.stopPropagation()}408 >409 <MoreHorizontal className="h-4 w-4" />410 </Button>411 </DropdownMenuTrigger>412 <DropdownMenuContent align="end">413 <DropdownMenuItem onClick={() => handleOpenEdit(user)}>414 <Pencil className="h-4 w-4 mr-2" />415 Edit416 </DropdownMenuItem>417 <DropdownMenuItem onClick={() => handleToggleActive(user)}>418 {user.active ? (419 <>420 <UserX className="h-4 w-4 mr-2" />421 Deactivate422 </>423 ) : (424 <>425 <UserCheck className="h-4 w-4 mr-2" />426 Reactivate427 </>428 )}429 </DropdownMenuItem>430 <DropdownMenuItem431 onClick={() => handleDelete(user)}432 className="text-destructive focus:text-destructive"433 >434 <Trash2 className="h-4 w-4 mr-2" />435 Delete436 </DropdownMenuItem>437 </DropdownMenuContent>438 </DropdownMenu>439 </div>440 441 {/* Expanded workspace details */}442 {isExpanded && (443 <div className="border-t bg-muted/30 px-4 py-3 pl-12">444 {user.workspaces.length > 0 ? (445 <div className="space-y-2">446 <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">447 Workspaces ({user.workspaces.length})448 </div>449 {user.workspaces.map((ws) => (450 <div451 key={ws.id}452 className="flex items-center gap-3 text-sm p-2 rounded bg-background/60"453 >454 <HardDrive className="h-3.5 w-3.5 text-muted-foreground shrink-0" />455 <div className="flex-1 min-w-0">456 <div className="flex items-center gap-2 flex-wrap">457 <span className="font-medium truncate">{ws.name}</span>458 </div>459 </div>460 <div className="flex items-center gap-3 text-xs text-muted-foreground shrink-0">461 <span>{ws.max_projects} projects</span>462 <span>{ws.max_deployments} deployments</span>463 <span>Created {formatDate(ws.created_at)}</span>464 </div>465 </div>466 ))}467 </div>468 ) : (469 <div className="text-sm text-muted-foreground py-2">470 No workspaces assigned471 </div>472 )}473 </div>474 )}475 </div>476 );477 })}478 </div>479 )}480 </div>481 </div>482 </div>483 484 {/* Edit User Dialog */}485 <Dialog open={showEditDialog} onOpenChange={(open) => { setShowEditDialog(open); if (!open) setEditUser(null); }}>486 <DialogContent className="sm:max-w-md">487 <DialogHeader>488 <DialogTitle>Edit User</DialogTitle>489 </DialogHeader>490 <div className="space-y-4 py-4">491 <div className="space-y-2">492 <Label htmlFor="edit-displayName">Display Name</Label>493 <Input494 id="edit-displayName"495 value={editForm.displayName}496 onChange={(e) => setEditForm(f => ({ ...f, displayName: e.target.value }))}497 placeholder="Display name"498 />499 </div>500 <div className="flex items-center gap-2">501 <Switch502 id="edit-active"503 checked={editForm.active}504 onCheckedChange={(checked) => setEditForm(f => ({ ...f, active: checked }))}505 />506 <Label htmlFor="edit-active">Active</Label>507 </div>508 </div>509 <DialogFooter>510 <Button variant="outline" onClick={() => { setShowEditDialog(false); setEditUser(null); }}>Cancel</Button>511 <Button onClick={handleSaveEdit} disabled={saving}>512 {saving ? 'Saving...' : 'Save Changes'}513 </Button>514 </DialogFooter>515 </DialogContent>516 </Dialog>517 518 {/* Create User Dialog */}519 <Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>520 <DialogContent className="sm:max-w-md">521 <DialogHeader>522 <DialogTitle>Create User</DialogTitle>523 </DialogHeader>524 <div className="space-y-4 py-4">525 <div className="space-y-2">526 <Label htmlFor="create-email">Email</Label>527 <Input528 id="create-email"529 type="email"530 value={createForm.email}531 onChange={(e) => setCreateForm(f => ({ ...f, email: e.target.value }))}532 placeholder="user@example.com"533 />534 </div>535 <div className="space-y-2">536 <div className="flex items-center justify-between">537 <Label htmlFor="create-password">Password</Label>538 <button539 type="button"540 className="text-xs text-primary hover:text-primary/80 transition-colors"541 onClick={() => {542 const chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%&*';543 let pw = '';544 const rng = new Uint32Array(16); crypto.getRandomValues(rng);545 for (let i = 0; i < 16; i++) pw += chars[rng[i] % chars.length];546 setCreateForm(f => ({ ...f, password: pw }));547 setShowPassword(true);548 }}549 >550 Generate551 </button>552 </div>553 <div className="relative">554 <Input555 id="create-password"556 type={showPassword ? 'text' : 'password'}557 value={createForm.password}558 onChange={(e) => setCreateForm(f => ({ ...f, password: e.target.value }))}559 placeholder="Minimum 8 characters"560 className="pr-9"561 />562 <button563 type="button"564 onClick={() => setShowPassword(!showPassword)}565 className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"566 >567 {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}568 </button>569 </div>570 </div>571 <div className="space-y-2">572 <Label htmlFor="create-displayName">Display Name</Label>573 <Input574 id="create-displayName"575 value={createForm.displayName}576 onChange={(e) => setCreateForm(f => ({ ...f, displayName: e.target.value }))}577 placeholder="Display name (optional)"578 />579 </div>580 581 {/* Workspace Assignment */}582 <div className="space-y-2">583 <Label>Workspace</Label>584 <Select585 value={createForm.workspaceAssignment}586 onValueChange={(value) => setCreateForm(f => ({587 ...f,588 workspaceAssignment: value as 'new' | 'existing' | 'none',589 workspaceId: '',590 }))}591 >592 <SelectTrigger>593 <SelectValue />594 </SelectTrigger>595 <SelectContent>596 <SelectItem value="new">Create new workspace</SelectItem>597 <SelectItem value="existing">Assign to existing workspace</SelectItem>598 <SelectItem value="none">No workspace</SelectItem>599 </SelectContent>600 </Select>601 </div>602 603 {createForm.workspaceAssignment === 'existing' && (604 <div className="space-y-2">605 <Label>Select Workspace</Label>606 <Select607 value={createForm.workspaceId}608 onValueChange={(value) => setCreateForm(f => ({ ...f, workspaceId: value }))}609 >610 <SelectTrigger>611 <SelectValue placeholder="Choose a workspace..." />612 </SelectTrigger>613 <SelectContent>614 {availableWorkspaces.map(ws => (615 <SelectItem key={ws.id} value={ws.id}>{ws.name}</SelectItem>616 ))}617 </SelectContent>618 </Select>619 </div>620 )}621 </div>622 <DialogFooter>623 <Button variant="outline" onClick={() => setShowCreateDialog(false)}>Cancel</Button>624 <Button onClick={handleCreate} disabled={saving}>625 {saving ? 'Creating...' : 'Create User'}626 </Button>627 </DialogFooter>628 </DialogContent>629 </Dialog>630 </>631 );632}633 