List.jsx
1 import { useState, useEffect } from "react"; 2 import { Box, Button, Chip, IconButton, Tooltip, Avatar, styled } from "@mui/material"; 3 import { Add, Edit, Delete, Visibility, People } from "@mui/icons-material"; 4 import { useNavigate } from "react-router-dom"; 5 import sha256 from "crypto-js/sha256"; 6 import { toast } from "react-toastify"; 7 import useAuth from "app/hooks/useAuth"; 8 import Breadcrumb from "app/components/Breadcrumb"; 9 import DataList from "app/components/DataList"; 10 import { useTranslation } from "react-i18next"; 11 import api from "app/utils/api"; 12 import { colors } from "app/utils/themeColors"; 13 14 const Container = styled("div")(({ theme }) => ({ 15 margin: "24px 48px", 16 [theme.breakpoints.down("md")]: { margin: "24px 32px" }, 17 [theme.breakpoints.down("sm")]: { margin: 16 }, 18 "& .breadcrumb": { marginBottom: 24 }, 19 })); 20 21 export default function Users() { 22 const { t } = useTranslation(); 23 const [users, setUsers] = useState([]); 24 const auth = useAuth(); 25 const navigate = useNavigate(); 26 const isAdmin = auth.user?.is_admin; 27 28 const fetchUsers = () => { 29 api.get("/users", auth.user.token) 30 .then((d) => setUsers(d.users || [])) 31 .catch(() => {}); 32 }; 33 34 useEffect(() => { 35 document.title = (process.env.REACT_APP_RESTAI_NAME || "RESTai") + " - Users"; 36 fetchUsers(); 37 }, []); 38 39 const handleDelete = (e, user) => { 40 e.stopPropagation(); 41 if (!window.confirm(`Delete user "${user.username}"?`)) return; 42 api.delete("/users/" + user.username, auth.user.token) 43 .then(() => { 44 toast.success(`Deleted ${user.username}`); 45 fetchUsers(); 46 }) 47 .catch(() => {}); 48 }; 49 50 const bulkDelete = async (rows) => { 51 const names = rows.map((r) => r.username); 52 if (!window.confirm(`Delete ${rows.length} user${rows.length === 1 ? "" : "s"}?\n\n${names.join(", ")}`)) return; 53 // Fire deletes sequentially to avoid rate-limit pile-ups and keep 54 // per-row toast counts readable. 55 let ok = 0, failed = 0; 56 for (const name of names) { 57 try { 58 await api.delete("/users/" + name, auth.user.token, { silent: true }); 59 ok++; 60 } catch { 61 failed++; 62 } 63 } 64 if (ok) toast.success(`Deleted ${ok} user${ok === 1 ? "" : "s"}`); 65 if (failed) toast.error(`Failed to delete ${failed} user${failed === 1 ? "" : "s"}`); 66 fetchUsers(); 67 }; 68 69 const columns = [ 70 { 71 key: "username", 72 label: "User", 73 sortable: true, 74 render: (row) => ( 75 <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}> 76 <Avatar 77 src={`https://www.gravatar.com/avatar/${sha256(row.username)}`} 78 sx={{ width: 32, height: 32 }} 79 /> 80 <Box sx={{ fontWeight: 500 }}>{row.username}</Box> 81 </Box> 82 ), 83 }, 84 { 85 key: "is_admin", 86 label: "Role", 87 sortable: true, 88 sortValue: (row) => (row.is_admin ? 0 : 1), 89 render: (row) => ( 90 <Chip 91 label={row.is_admin ? "Admin" : "User"} 92 size="small" 93 sx={{ 94 backgroundColor: row.is_admin ? "rgba(239,68,68,0.12)" : "rgba(107,114,128,0.15)", 95 color: row.is_admin ? colors.status.error : colors.status.muted, 96 fontWeight: 600, 97 fontSize: "0.72rem", 98 height: 22, 99 }} 100 /> 101 ), 102 }, 103 { 104 key: "sso", 105 label: "Auth", 106 sortable: true, 107 sortValue: (row) => (row.sso ? "sso" : "local"), 108 render: (row) => ( 109 <Chip 110 label={row.sso ? "SSO" : "Local"} 111 size="small" 112 variant="outlined" 113 sx={{ fontSize: "0.72rem", height: 22 }} 114 /> 115 ), 116 }, 117 { 118 key: "is_restricted", 119 label: "Access", 120 sortable: true, 121 sortValue: (row) => (row.is_restricted ? 1 : 0), 122 render: (row) => ( 123 <Chip 124 label={row.is_restricted ? "Read-only" : "Read/Write"} 125 size="small" 126 sx={{ 127 backgroundColor: row.is_restricted ? "rgba(245,158,11,0.12)" : "rgba(16,185,129,0.12)", 128 color: row.is_restricted ? colors.status.warning : colors.status.success, 129 fontWeight: 600, 130 fontSize: "0.72rem", 131 height: 22, 132 }} 133 /> 134 ), 135 }, 136 { 137 key: "projects", 138 label: "Projects", 139 sortable: true, 140 sortValue: (row) => (row.projects || []).length, 141 render: (row) => (row.projects || []).length, 142 }, 143 ]; 144 145 return ( 146 <Container> 147 <Box className="breadcrumb"> 148 <Breadcrumb routeSegments={[{ name: t("nav.users"), path: "/users" }]} /> 149 </Box> 150 151 <DataList 152 title={t("users.title")} 153 subtitle={t("users.subtitle")} 154 data={users} 155 columns={columns} 156 searchKeys={["username"]} 157 filters={[ 158 { 159 key: "is_admin", 160 label: "Role", 161 options: [ 162 { value: "true", label: "Admin" }, 163 { value: "false", label: "User" }, 164 ], 165 getValue: (row) => (row.is_admin ? "true" : "false"), 166 }, 167 { 168 key: "is_restricted", 169 label: "Access", 170 options: [ 171 { value: "true", label: "Read-only" }, 172 { value: "false", label: "Read/Write" }, 173 ], 174 getValue: (row) => (row.is_restricted ? "true" : "false"), 175 }, 176 ]} 177 onRowClick={(row) => navigate(`/user/${row.username}`)} 178 rowKey={(row) => row.username} 179 defaultSort={{ key: "username", direction: "asc" }} 180 headerAction={ 181 isAdmin && ( 182 <Button 183 variant="contained" 184 startIcon={<Add />} 185 onClick={() => navigate("/users/new")} 186 > 187 New User 188 </Button> 189 ) 190 } 191 actions={(row) => ( 192 <> 193 <Tooltip title="View"> 194 <IconButton size="small" onClick={() => navigate(`/user/${row.username}`)}> 195 <Visibility fontSize="small" /> 196 </IconButton> 197 </Tooltip> 198 {isAdmin && ( 199 <> 200 <Tooltip title="Edit"> 201 <IconButton size="small" onClick={() => navigate(`/user/${row.username}`)}> 202 <Edit fontSize="small" /> 203 </IconButton> 204 </Tooltip> 205 <Tooltip title="Delete"> 206 <IconButton size="small" color="error" onClick={(e) => handleDelete(e, row)}> 207 <Delete fontSize="small" /> 208 </IconButton> 209 </Tooltip> 210 </> 211 )} 212 </> 213 )} 214 bulkActions={isAdmin ? [ 215 { label: "Delete", icon: <Delete fontSize="small" />, color: "error", onClick: bulkDelete }, 216 ] : []} 217 emptyState={{ 218 icon: People, 219 title: "No users yet", 220 message: "Platform users show up here. Add a first admin or teammate to get started.", 221 actionLabel: isAdmin ? "New User" : undefined, 222 actionIcon: <Add fontSize="small" />, 223 onAction: isAdmin ? () => navigate("/users/new") : undefined, 224 }} 225 emptyMessage="No users yet." 226 /> 227 </Container> 228 ); 229 }