mirror of
https://github.com/lordmathis/lemma.git
synced 2025-11-05 23:44:22 +00:00
Improve admin dashboard
This commit is contained in:
@@ -6,10 +6,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CreateUser inserts a new user record into the database
|
// CreateUser inserts a new user record into the database
|
||||||
func (db *DB) CreateUser(user *models.User) error {
|
func (db *DB) CreateUser(user *models.User) (*models.User, error) {
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
@@ -18,15 +18,21 @@ func (db *DB) CreateUser(user *models.User) error {
|
|||||||
VALUES (?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?)`,
|
||||||
user.Email, user.DisplayName, user.PasswordHash, user.Role)
|
user.Email, user.DisplayName, user.PasswordHash, user.Role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := result.LastInsertId()
|
userID, err := result.LastInsertId()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
user.ID = int(userID)
|
user.ID = int(userID)
|
||||||
|
|
||||||
|
// Retrieve the created_at timestamp
|
||||||
|
err = tx.QueryRow("SELECT created_at FROM users WHERE id = ?", user.ID).Scan(&user.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// Create default workspace with default settings
|
// Create default workspace with default settings
|
||||||
defaultWorkspace := &models.Workspace{
|
defaultWorkspace := &models.Workspace{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
@@ -37,22 +43,22 @@ func (db *DB) CreateUser(user *models.User) error {
|
|||||||
// Create workspace with settings
|
// Create workspace with settings
|
||||||
err = db.createWorkspaceTx(tx, defaultWorkspace)
|
err = db.createWorkspaceTx(tx, defaultWorkspace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user's last workspace ID
|
// Update user's last workspace ID
|
||||||
_, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID)
|
_, err = tx.Exec("UPDATE users SET last_workspace_id = ? WHERE id = ?", defaultWorkspace.ID, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit()
|
err = tx.Commit()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
user.LastWorkspaceID = defaultWorkspace.ID
|
user.LastWorkspaceID = defaultWorkspace.ID
|
||||||
return nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to create a workspace in a transaction
|
// Helper function to create a workspace in a transaction
|
||||||
|
|||||||
@@ -75,18 +75,19 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
|
|||||||
Role: req.Role,
|
Role: req.Role,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.DB.CreateUser(user); err != nil {
|
insertedUser, err := h.DB.CreateUser(user)
|
||||||
|
if err != nil {
|
||||||
http.Error(w, "Failed to create user", http.StatusInternalServerError)
|
http.Error(w, "Failed to create user", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize user workspace
|
// Initialize user workspace
|
||||||
if err := h.FS.InitializeUserWorkspace(user.ID, user.LastWorkspaceID); err != nil {
|
if err := h.FS.InitializeUserWorkspace(insertedUser.ID, insertedUser.LastWorkspaceID); err != nil {
|
||||||
http.Error(w, "Failed to initialize user workspace", http.StatusInternalServerError)
|
http.Error(w, "Failed to initialize user workspace", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
respondJSON(w, user)
|
respondJSON(w, insertedUser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +198,7 @@ func (h *Handler) AdminDeleteUser() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,69 +47,18 @@ func (s *UserService) SetupAdminUser(adminEmail, adminPassword string) (*models.
|
|||||||
Role: models.RoleAdmin,
|
Role: models.RoleAdmin,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.DB.CreateUser(adminUser)
|
createdUser, err := s.DB.CreateUser(adminUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create admin user: %w", err)
|
return nil, fmt.Errorf("failed to create admin user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize workspace directory
|
// Initialize workspace directory
|
||||||
err = s.FS.InitializeUserWorkspace(adminUser.ID, adminUser.LastWorkspaceID)
|
err = s.FS.InitializeUserWorkspace(createdUser.ID, createdUser.LastWorkspaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize admin workspace: %w", err)
|
return nil, fmt.Errorf("failed to initialize admin workspace: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Created admin user with ID: %d and default workspace with ID: %d", adminUser.ID, adminUser.LastWorkspaceID)
|
log.Printf("Created admin user with ID: %d and default workspace with ID: %d", createdUser.ID, createdUser.LastWorkspaceID)
|
||||||
|
|
||||||
return adminUser, nil
|
return adminUser, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) CreateUser(user *models.User) error {
|
|
||||||
err := s.DB.CreateUser(user)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.FS.InitializeUserWorkspace(user.ID, user.LastWorkspaceID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to initialize user workspace: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) GetUserByID(id int) (*models.User, error) {
|
|
||||||
return s.DB.GetUserByID(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) GetUserByEmail(email string) (*models.User, error) {
|
|
||||||
return s.DB.GetUserByEmail(email)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) UpdateUser(user *models.User) error {
|
|
||||||
return s.DB.UpdateUser(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserService) DeleteUser(id int) error {
|
|
||||||
// First, get the user to check if they exist
|
|
||||||
user, err := s.DB.GetUserByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user's workspaces
|
|
||||||
workspaces, err := s.DB.GetWorkspacesByUserID(id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get user's workspaces: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete workspace directories
|
|
||||||
for _, workspace := range workspaces {
|
|
||||||
err = s.FS.DeleteUserWorkspace(user.ID, workspace.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete workspace files: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete user from database (this will cascade delete workspaces)
|
|
||||||
return s.DB.DeleteUser(id)
|
|
||||||
}
|
|
||||||
|
|||||||
76
frontend/src/components/modals/user/CreateUserModal.jsx
Normal file
76
frontend/src/components/modals/user/CreateUserModal.jsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
PasswordInput,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
} from '@mantine/core';
|
||||||
|
|
||||||
|
const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [role, setRole] = useState('viewer');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const result = await onCreateUser({ email, password, displayName, role });
|
||||||
|
if (result.success) {
|
||||||
|
setEmail('');
|
||||||
|
setPassword('');
|
||||||
|
setDisplayName('');
|
||||||
|
setRole('viewer');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Create New User" centered>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Display Name"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.currentTarget.value)}
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
|
placeholder="Enter password"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Role"
|
||||||
|
required
|
||||||
|
value={role}
|
||||||
|
onChange={setRole}
|
||||||
|
data={[
|
||||||
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
{ value: 'editor', label: 'Editor' },
|
||||||
|
{ value: 'viewer', label: 'Viewer' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} loading={loading}>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateUserModal;
|
||||||
29
frontend/src/components/modals/user/DeleteUserModal.jsx
Normal file
29
frontend/src/components/modals/user/DeleteUserModal.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||||
|
|
||||||
|
const DeleteUserModal = ({ opened, onClose, onConfirm, user, loading }) => (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Delete User"
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text>
|
||||||
|
Are you sure you want to delete user "{user?.email}"? This action cannot
|
||||||
|
be undone and all associated data will be permanently deleted.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onClick={onConfirm} loading={loading}>
|
||||||
|
Delete User
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DeleteUserModal;
|
||||||
@@ -1,194 +1,42 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import { Modal, Tabs } from '@mantine/core';
|
||||||
Modal,
|
import { IconUsers, IconFolders, IconChartBar } from '@tabler/icons-react';
|
||||||
Table,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
TextInput,
|
|
||||||
PasswordInput,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
ActionIcon,
|
|
||||||
Box,
|
|
||||||
LoadingOverlay,
|
|
||||||
Alert,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import {
|
|
||||||
IconTrash,
|
|
||||||
IconEdit,
|
|
||||||
IconPlus,
|
|
||||||
IconAlertCircle,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { useAdmin } from '../../../hooks/useAdmin';
|
|
||||||
import { useAuth } from '../../../contexts/AuthContext';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
import AdminUsersTab from './AdminUsersTab';
|
||||||
const CreateUserModal = ({ opened, onClose, onCreateUser, loading }) => {
|
import AdminWorkspacesTab from './AdminWorkspacesTab';
|
||||||
const [email, setEmail] = useState('');
|
import AdminStatsTab from './AdminStatsTab';
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [displayName, setDisplayName] = useState('');
|
|
||||||
const [role, setRole] = useState('viewer');
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const result = await onCreateUser({ email, password, displayName, role });
|
|
||||||
if (result.success) {
|
|
||||||
setEmail('');
|
|
||||||
setPassword('');
|
|
||||||
setDisplayName('');
|
|
||||||
setRole('viewer');
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal opened={opened} onClose={onClose} title="Create New User" centered>
|
|
||||||
<Stack>
|
|
||||||
<TextInput
|
|
||||||
label="Email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
|
||||||
placeholder="user@example.com"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Display Name"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.currentTarget.value)}
|
|
||||||
placeholder="John Doe"
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label="Password"
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
|
||||||
placeholder="Enter password"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Role"
|
|
||||||
required
|
|
||||||
value={role}
|
|
||||||
onChange={setRole}
|
|
||||||
data={[
|
|
||||||
{ value: 'admin', label: 'Admin' },
|
|
||||||
{ value: 'editor', label: 'Editor' },
|
|
||||||
{ value: 'viewer', label: 'Viewer' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button variant="default" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSubmit} loading={loading}>
|
|
||||||
Create User
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AdminDashboard = ({ opened, onClose }) => {
|
const AdminDashboard = ({ opened, onClose }) => {
|
||||||
const {
|
|
||||||
data: users,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
create,
|
|
||||||
delete: deleteUser,
|
|
||||||
} = useAdmin('users');
|
|
||||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
|
const [activeTab, setActiveTab] = useState('users');
|
||||||
const handleCreateUser = async (userData) => {
|
|
||||||
return await create(userData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteUser = async (userId) => {
|
|
||||||
if (userId === currentUser.id) {
|
|
||||||
notifications.show({
|
|
||||||
title: 'Error',
|
|
||||||
message: 'You cannot delete your own account',
|
|
||||||
color: 'red',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await deleteUser(userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows = users.map((user) => (
|
|
||||||
<Table.Tr key={user.id}>
|
|
||||||
<Table.Td>{user.email}</Table.Td>
|
|
||||||
<Table.Td>{user.displayName}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text transform="capitalize">{user.role}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs" justify="flex-end">
|
|
||||||
<ActionIcon variant="subtle" color="blue">
|
|
||||||
<IconEdit size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
onClick={() => handleDeleteUser(user.id)}
|
|
||||||
disabled={user.id === currentUser.id}
|
|
||||||
>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard">
|
<Modal opened={opened} onClose={onClose} size="xl" title="Admin Dashboard">
|
||||||
<Box pos="relative">
|
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||||
<LoadingOverlay visible={loading} />
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="users" leftSection={<IconUsers size={16} />}>
|
||||||
|
Users
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="workspaces" leftSection={<IconFolders size={16} />}>
|
||||||
|
Workspaces
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="stats" leftSection={<IconChartBar size={16} />}>
|
||||||
|
Statistics
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
{error && (
|
<Tabs.Panel value="users" pt="md">
|
||||||
<Alert
|
<AdminUsersTab currentUser={currentUser} />
|
||||||
icon={<IconAlertCircle size={16} />}
|
</Tabs.Panel>
|
||||||
title="Error"
|
|
||||||
color="red"
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between" mb="md">
|
<Tabs.Panel value="workspaces" pt="md">
|
||||||
<Text size="xl" fw={700}>
|
<AdminWorkspacesTab />
|
||||||
User Management
|
</Tabs.Panel>
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
leftSection={<IconPlus size={16} />}
|
|
||||||
onClick={() => setCreateModalOpened(true)}
|
|
||||||
>
|
|
||||||
Create User
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Table striped highlightOnHover withTableBorder>
|
<Tabs.Panel value="stats" pt="md">
|
||||||
<Table.Thead>
|
<AdminStatsTab />
|
||||||
<Table.Tr>
|
</Tabs.Panel>
|
||||||
<Table.Th>Email</Table.Th>
|
</Tabs>
|
||||||
<Table.Th>Display Name</Table.Th>
|
|
||||||
<Table.Th>Role</Table.Th>
|
|
||||||
<Table.Th>Created At</Table.Th>
|
|
||||||
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>{rows}</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
|
|
||||||
<CreateUserModal
|
|
||||||
opened={createModalOpened}
|
|
||||||
onClose={() => setCreateModalOpened(false)}
|
|
||||||
onCreateUser={handleCreateUser}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
122
frontend/src/components/settings/admin/AdminStatsTab.jsx
Normal file
122
frontend/src/components/settings/admin/AdminStatsTab.jsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
LoadingOverlay,
|
||||||
|
Alert,
|
||||||
|
RingProgress,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconUsers,
|
||||||
|
IconFolders,
|
||||||
|
IconServer,
|
||||||
|
IconFiles,
|
||||||
|
IconAlertCircle,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useAdmin } from '../../../hooks/useAdmin';
|
||||||
|
import StatCard from './StatCard';
|
||||||
|
|
||||||
|
const formatBytes = (bytes) => {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AdminStatsTab = () => {
|
||||||
|
const { data: stats, loading, error } = useAdmin('stats');
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingOverlay visible={true} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
System Statistics
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||||
|
<StatCard
|
||||||
|
title="Total Users"
|
||||||
|
value={stats.totalUsers}
|
||||||
|
icon={IconUsers}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||||
|
<StatCard
|
||||||
|
title="Total Workspaces"
|
||||||
|
value={stats.totalWorkspaces}
|
||||||
|
icon={IconFolders}
|
||||||
|
color="grape"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||||
|
<StatCard
|
||||||
|
title="Storage Used"
|
||||||
|
value={formatBytes(stats.storageUsed)}
|
||||||
|
icon={IconServer}
|
||||||
|
color="teal"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6, lg: 3 }}>
|
||||||
|
<StatCard
|
||||||
|
title="Total Files"
|
||||||
|
value={stats.totalFiles}
|
||||||
|
icon={IconFiles}
|
||||||
|
color="orange"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid mt="md">
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Card withBorder>
|
||||||
|
<Stack align="center">
|
||||||
|
<Title order={3}>Active Users</Title>
|
||||||
|
<RingProgress
|
||||||
|
size={200}
|
||||||
|
thickness={16}
|
||||||
|
roundCaps
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
value: (stats.activeUsers / stats.totalUsers) * 100,
|
||||||
|
color: 'blue',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
label={
|
||||||
|
<Text ta="center" fw={700} size="xl">
|
||||||
|
{((stats.activeUsers / stats.totalUsers) * 100).toFixed(1)}%
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{stats.activeUsers} out of {stats.totalUsers} users active in
|
||||||
|
last 30 days
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminStatsTab;
|
||||||
142
frontend/src/components/settings/admin/AdminUsersTab.jsx
Normal file
142
frontend/src/components/settings/admin/AdminUsersTab.jsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
LoadingOverlay,
|
||||||
|
Alert,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconTrash,
|
||||||
|
IconEdit,
|
||||||
|
IconPlus,
|
||||||
|
IconAlertCircle,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { useAdmin } from '../../../hooks/useAdmin';
|
||||||
|
import CreateUserModal from '../../modals/user/CreateUserModal';
|
||||||
|
import DeleteUserModal from '../../modals/user/DeleteUserModal';
|
||||||
|
|
||||||
|
const AdminUsersTab = ({ currentUser }) => {
|
||||||
|
const {
|
||||||
|
data: users,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
create,
|
||||||
|
delete: deleteUser,
|
||||||
|
} = useAdmin('users');
|
||||||
|
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||||
|
const [deleteModalData, setDeleteModalData] = useState(null);
|
||||||
|
|
||||||
|
const handleCreateUser = async (userData) => {
|
||||||
|
return await create(userData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (user) => {
|
||||||
|
if (user.id === currentUser.id) {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'You cannot delete your own account',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteModalData(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteModalData) return;
|
||||||
|
const result = await deleteUser(deleteModalData.id);
|
||||||
|
if (result.success) {
|
||||||
|
setDeleteModalData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = users.map((user) => (
|
||||||
|
<Table.Tr key={user.id}>
|
||||||
|
<Table.Td>{user.email}</Table.Td>
|
||||||
|
<Table.Td>{user.displayName}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text transform="capitalize">{user.role}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{new Date(user.createdAt).toLocaleDateString()}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs" justify="flex-end">
|
||||||
|
<ActionIcon variant="subtle" color="blue">
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={() => handleDeleteClick(user)}
|
||||||
|
disabled={user.id === currentUser.id}
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box pos="relative">
|
||||||
|
<LoadingOverlay visible={loading} />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
icon={<IconAlertCircle size={16} />}
|
||||||
|
title="Error"
|
||||||
|
color="red"
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
User Management
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => setCreateModalOpened(true)}
|
||||||
|
>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Table striped highlightOnHover withTableBorder>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Email</Table.Th>
|
||||||
|
<Table.Th>Display Name</Table.Th>
|
||||||
|
<Table.Th>Role</Table.Th>
|
||||||
|
<Table.Th>Created At</Table.Th>
|
||||||
|
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>{rows}</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<CreateUserModal
|
||||||
|
opened={createModalOpened}
|
||||||
|
onClose={() => setCreateModalOpened(false)}
|
||||||
|
onCreateUser={handleCreateUser}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteUserModal
|
||||||
|
opened={!!deleteModalData}
|
||||||
|
onClose={() => setDeleteModalData(null)}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
user={deleteModalData}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminUsersTab;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
ActionIcon,
|
||||||
|
Box,
|
||||||
|
LoadingOverlay,
|
||||||
|
Alert,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconTrash, IconEdit, IconAlertCircle } from '@tabler/icons-react';
|
||||||
|
import { useAdmin } from '../../../hooks/useAdmin';
|
||||||
|
|
||||||
|
const AdminWorkspacesTab = () => {
|
||||||
|
const { data: workspaces, loading, error } = useAdmin('workspaces');
|
||||||
|
|
||||||
|
const rows = workspaces.map((workspace) => (
|
||||||
|
<Table.Tr key={workspace.id}>
|
||||||
|
<Table.Td>{workspace.name}</Table.Td>
|
||||||
|
<Table.Td>{workspace.owner?.email}</Table.Td>
|
||||||
|
<Table.Td>{new Date(workspace.createdAt).toLocaleDateString()}</Table.Td>
|
||||||
|
<Table.Td>{workspace.gitEnabled ? 'Yes' : 'No'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs" justify="flex-end">
|
||||||
|
<ActionIcon variant="subtle" color="blue">
|
||||||
|
<IconEdit size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon variant="subtle" color="red">
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box pos="relative">
|
||||||
|
<LoadingOverlay visible={loading} />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert
|
||||||
|
icon={<IconAlertCircle size={16} />}
|
||||||
|
title="Error"
|
||||||
|
color="red"
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Text size="xl" fw={700}>
|
||||||
|
Workspace Management
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Table striped highlightOnHover withTableBorder>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Owner</Table.Th>
|
||||||
|
<Table.Th>Created At</Table.Th>
|
||||||
|
<Table.Th>Git Enabled</Table.Th>
|
||||||
|
<Table.Th style={{ width: 100 }}>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>{rows}</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminWorkspacesTab;
|
||||||
20
frontend/src/components/settings/admin/StatCard.jsx
Normal file
20
frontend/src/components/settings/admin/StatCard.jsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, Group, Box, Text } from '@mantine/core';
|
||||||
|
|
||||||
|
const StatCard = ({ title, value, icon: Icon, color = 'blue' }) => (
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group>
|
||||||
|
<Box style={{ flex: 1 }}>
|
||||||
|
<Text size="xs" tt="uppercase" fw={700} c="dimmed">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text fw={700} size="xl">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Icon size={32} color={`var(--mantine-color-${color}-filled)`} />
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default StatCard;
|
||||||
@@ -21,7 +21,11 @@ export const deleteUser = async (userId) => {
|
|||||||
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
|
const response = await apiCall(`${ADMIN_BASE_URL}/users/${userId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
return response.json();
|
if (response.status === 204) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to delete user with status: ', response.status);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateUser = async (userId, userData) => {
|
export const updateUser = async (userId, userData) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user