Improve admin dashboard

This commit is contained in:
2024-11-08 23:49:12 +01:00
parent 51751a5af6
commit dd3ea9f65f
11 changed files with 517 additions and 247 deletions

View File

@@ -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

View File

@@ -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)
} }
} }

View File

@@ -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)
}

View 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;

View 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;

View File

@@ -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>
); );
}; };

View 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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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) => {