/ frontend / src / app / views / users / List.jsx
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  }