CoolFace
Apppublic

Leon4gr45/builder

sourceHugging Facemitupdated 2d agoView on Hugging Face
0likes
users-view.tsx633 linesDownload Raw Back to views
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 &quot;New User&quot; 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