505 lines
17 KiB
TypeScript
505 lines
17 KiB
TypeScript
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
|
|
import { StatsCard } from '@/frontend/components/StatsCard'
|
|
import {
|
|
ActionIcon,
|
|
Avatar,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Container,
|
|
Divider,
|
|
Group,
|
|
List,
|
|
Modal,
|
|
Pagination,
|
|
Paper,
|
|
PasswordInput,
|
|
Select,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Tabs,
|
|
Text,
|
|
TextInput,
|
|
ThemeIcon,
|
|
Title,
|
|
} from '@mantine/core'
|
|
import { useDisclosure } from '@mantine/hooks'
|
|
import { notifications } from '@mantine/notifications'
|
|
import { createFileRoute } from '@tanstack/react-router'
|
|
import { useEffect, useState } from 'react'
|
|
import {
|
|
TbAccessPoint,
|
|
TbCircleCheck,
|
|
TbCircleX,
|
|
TbPencil,
|
|
TbPlus,
|
|
TbSearch,
|
|
TbShieldCheck,
|
|
TbTrash,
|
|
TbUserCheck
|
|
} from 'react-icons/tb'
|
|
import useSWR from 'swr'
|
|
import { API_URLS } from '../config/api'
|
|
import { useSession } from '../hooks/useAuth'
|
|
|
|
export const Route = createFileRoute('/users')({
|
|
component: UsersPage,
|
|
})
|
|
|
|
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
|
|
|
const getRoleColor = (role: string) => {
|
|
const r = (role || '').toLowerCase()
|
|
if (r.includes('super')) return 'red'
|
|
if (r.includes('admin')) return 'brand-blue'
|
|
if (r.includes('developer')) return 'violet'
|
|
return 'gray'
|
|
}
|
|
|
|
const roles = [
|
|
{
|
|
name: 'DEVELOPER',
|
|
color: 'red',
|
|
permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management']
|
|
},
|
|
{
|
|
name: 'ADMIN',
|
|
color: 'orange',
|
|
permissions: ['View All Apps', 'View Logs', 'Report Errors']
|
|
},
|
|
]
|
|
|
|
function UsersPage() {
|
|
const [search, setSearch] = useState('')
|
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
const [page, setPage] = useState(1)
|
|
const { data: session } = useSession()
|
|
const isDeveloper = session?.user?.role === 'DEVELOPER'
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setDebouncedSearch(search), 300)
|
|
return () => clearTimeout(timer)
|
|
}, [search])
|
|
|
|
const { data: stats, mutate: mutateStats } = useSWR(API_URLS.getOperatorStats(), fetcher)
|
|
const { data: response, isLoading, mutate: mutateOperators } = useSWR(
|
|
API_URLS.getOperators(page, debouncedSearch),
|
|
fetcher
|
|
)
|
|
|
|
const operators = response?.data || []
|
|
|
|
// ── Create User Modal ──
|
|
const [createOpened, { open: openCreate, close: closeCreate }] = useDisclosure(false)
|
|
const [isCreating, setIsCreating] = useState(false)
|
|
const [createForm, setCreateForm] = useState({
|
|
name: '',
|
|
email: '',
|
|
password: '',
|
|
role: 'USER',
|
|
})
|
|
|
|
const handleCreateUser = async () => {
|
|
if (!createForm.name || !createForm.email || !createForm.password) {
|
|
notifications.show({ title: 'Validation Error', message: 'Please fill in all required fields.', color: 'red' })
|
|
return
|
|
}
|
|
|
|
setIsCreating(true)
|
|
try {
|
|
const res = await fetch(API_URLS.createOperator(), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(createForm),
|
|
})
|
|
|
|
if (res.ok) {
|
|
notifications.show({ title: 'Success', message: 'User has been created.', color: 'teal', icon: <TbCircleCheck size={18} /> })
|
|
mutateOperators()
|
|
mutateStats()
|
|
closeCreate()
|
|
setCreateForm({ name: '', email: '', password: '', role: 'USER' })
|
|
} else {
|
|
const err = await res.json()
|
|
throw new Error(err.error || 'Failed to create user')
|
|
}
|
|
} catch (e: any) {
|
|
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
|
|
} finally {
|
|
setIsCreating(false)
|
|
}
|
|
}
|
|
|
|
// ── Edit User Modal ──
|
|
const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const [editingUserId, setEditingUserId] = useState<string | null>(null)
|
|
const [editForm, setEditForm] = useState({
|
|
name: '',
|
|
email: '',
|
|
role: '',
|
|
})
|
|
|
|
const handleOpenEdit = (user: any) => {
|
|
setEditingUserId(user.id)
|
|
setEditForm({ name: user.name, email: user.email, role: user.role })
|
|
openEdit()
|
|
}
|
|
|
|
const handleEditUser = async () => {
|
|
if (!editingUserId || !editForm.name || !editForm.email) return
|
|
|
|
setIsEditing(true)
|
|
try {
|
|
const res = await fetch(API_URLS.editOperator(editingUserId), {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(editForm),
|
|
})
|
|
|
|
if (res.ok) {
|
|
notifications.show({ title: 'Success', message: 'User has been updated.', color: 'teal', icon: <TbCircleCheck size={18} /> })
|
|
mutateOperators()
|
|
closeEdit()
|
|
} else {
|
|
throw new Error('Failed to update user')
|
|
}
|
|
} catch (e) {
|
|
notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
|
|
} finally {
|
|
setIsEditing(false)
|
|
}
|
|
}
|
|
|
|
// ── Delete User ──
|
|
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
const [deletingUser, setDeletingUser] = useState<any>(null)
|
|
|
|
const handleOpenDelete = (user: any) => {
|
|
setDeletingUser(user)
|
|
openDelete()
|
|
}
|
|
|
|
const handleDeleteUser = async () => {
|
|
if (!deletingUser) return
|
|
|
|
setIsDeleting(true)
|
|
try {
|
|
const res = await fetch(API_URLS.deleteOperator(deletingUser.id), {
|
|
method: 'DELETE',
|
|
})
|
|
|
|
if (res.ok) {
|
|
notifications.show({ title: 'Success', message: 'User has been deleted.', color: 'teal', icon: <TbCircleCheck size={18} /> })
|
|
mutateOperators()
|
|
mutateStats()
|
|
closeDelete()
|
|
} else {
|
|
const err = await res.json()
|
|
throw new Error(err.error || 'Failed to delete user')
|
|
}
|
|
} catch (e: any) {
|
|
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
|
|
} finally {
|
|
setIsDeleting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<DashboardLayout>
|
|
<Container size="xl" py="lg">
|
|
<Stack gap="xl">
|
|
<Group justify="space-between" align="center">
|
|
<Stack gap={0}>
|
|
<Title order={2} className="gradient-text">Users</Title>
|
|
<Text size="sm" c="dimmed">Manage system users, security roles, and application access control.</Text>
|
|
</Stack>
|
|
</Group>
|
|
|
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg">
|
|
<StatsCard title="Total Staff" value={stats?.totalStaff ?? 0} icon={TbUserCheck} color="brand-blue" />
|
|
<StatsCard title="Active Now" value={stats?.activeNow ?? 0} icon={TbAccessPoint} color="teal" />
|
|
<StatsCard title="Security Roles" value={stats?.rolesCount ?? 0} icon={TbShieldCheck} color="purple-primary" />
|
|
</SimpleGrid>
|
|
|
|
<Tabs defaultValue="users" color="brand-blue" variant="pills" radius="md">
|
|
<Tabs.List>
|
|
<Tabs.Tab value="users" leftSection={<TbUserCheck size={16} />}>User Management</Tabs.Tab>
|
|
<Tabs.Tab value="roles" leftSection={<TbShieldCheck size={16} />}>Role Management</Tabs.Tab>
|
|
</Tabs.List>
|
|
|
|
<Tabs.Panel value="users" pt="xl">
|
|
<Stack gap="md">
|
|
<Group justify="space-between">
|
|
<TextInput
|
|
placeholder="Search users..."
|
|
leftSection={<TbSearch size={16} />}
|
|
radius="md"
|
|
w={350}
|
|
variant="filled"
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.currentTarget.value)
|
|
setPage(1)
|
|
}}
|
|
/>
|
|
{isDeveloper && (
|
|
<Button
|
|
variant="gradient"
|
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
|
leftSection={<TbPlus size={18} />}
|
|
radius="md"
|
|
onClick={openCreate}
|
|
>
|
|
Add New User
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
|
|
<Paper withBorder radius="2xl" className="glass" p={0} style={{ overflow: 'hidden' }}>
|
|
<Table className="data-table" verticalSpacing="md" highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Name & Contact</Table.Th>
|
|
<Table.Th>Role</Table.Th>
|
|
<Table.Th>Joined Date</Table.Th>
|
|
<Table.Th>Actions</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{isLoading ? (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={4} align="center">
|
|
<Text size="sm" c="dimmed" py="xl">Loading user data...</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
) : operators.length === 0 ? (
|
|
<Table.Tr>
|
|
<Table.Td colSpan={4} align="center">
|
|
<Text size="sm" c="dimmed" py="xl">No users found.</Text>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
) : (
|
|
operators.map((user: any) => (
|
|
<Table.Tr key={user.id}>
|
|
<Table.Td>
|
|
<Group gap="sm">
|
|
<Avatar size="sm" radius="xl" color={getRoleColor(user.role)} src={user.image}>
|
|
{user.name.charAt(0)}
|
|
</Avatar>
|
|
<Stack gap={0}>
|
|
<Text fw={600} size="sm">{user.name}</Text>
|
|
<Text size="xs" c="dimmed">{user.email}</Text>
|
|
</Stack>
|
|
</Group>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge variant="light" color={getRoleColor(user.role)}>
|
|
{user.role}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Text size="xs" fw={500}>{new Date(user.createdAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</Text>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Group gap="xs">
|
|
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
|
|
<TbPencil size={14} />
|
|
</ActionIcon>
|
|
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
|
|
<TbTrash size={14} />
|
|
</ActionIcon>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))
|
|
)}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Paper>
|
|
|
|
{response?.totalPages > 1 && (
|
|
<Group justify="center" mt="md">
|
|
<Pagination
|
|
total={response.totalPages}
|
|
value={page}
|
|
onChange={setPage}
|
|
radius="md"
|
|
/>
|
|
</Group>
|
|
)}
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
<Tabs.Panel value="roles" pt="xl">
|
|
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="lg">
|
|
{roles.map((role) => (
|
|
<Card key={role.name} withBorder radius="2xl" padding="xl" className="glass">
|
|
<Stack gap="md">
|
|
<Group justify="space-between">
|
|
<ThemeIcon size="xl" radius="md" color={role.color} variant="light">
|
|
<TbShieldCheck size={28} />
|
|
</ThemeIcon>
|
|
</Group>
|
|
|
|
<Stack gap={4}>
|
|
<Title order={4}>{role.name.replace('_', ' ')}</Title>
|
|
<Text size="sm" c="dimmed">Core role for secure app management.</Text>
|
|
</Stack>
|
|
|
|
<Divider />
|
|
|
|
<Text size="xs" fw={700} c="dimmed" style={{ textTransform: 'uppercase' }}>Key Permissions</Text>
|
|
<List
|
|
spacing="xs"
|
|
size="sm"
|
|
center
|
|
icon={
|
|
<ThemeIcon color="teal" size={16} radius="xl">
|
|
<TbCircleCheck size={12} />
|
|
</ThemeIcon>
|
|
}
|
|
>
|
|
{role.permissions.map((p) => (
|
|
<List.Item key={p}>{p}</List.Item>
|
|
))}
|
|
</List>
|
|
|
|
<Button fullWidth variant="light" color={role.color} mt="md" radius="md">
|
|
Edit Permissions
|
|
</Button>
|
|
</Stack>
|
|
</Card>
|
|
))}
|
|
</SimpleGrid>
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
</Stack>
|
|
</Container>
|
|
|
|
{/* Create User Modal */}
|
|
<Modal
|
|
opened={createOpened}
|
|
onClose={closeCreate}
|
|
title={<Text fw={700} size="lg">Add New User</Text>}
|
|
radius="xl"
|
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
|
>
|
|
<Stack gap="md">
|
|
<TextInput
|
|
label="Full Name"
|
|
placeholder="Enter full name"
|
|
required
|
|
value={createForm.name}
|
|
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
|
/>
|
|
<TextInput
|
|
label="Email"
|
|
placeholder="Enter email address"
|
|
required
|
|
value={createForm.email}
|
|
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
|
|
/>
|
|
<PasswordInput
|
|
label="Password"
|
|
placeholder="Enter password"
|
|
required
|
|
value={createForm.password}
|
|
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
|
|
/>
|
|
<Select
|
|
label="Role"
|
|
data={[
|
|
{ value: 'ADMIN', label: 'Admin' },
|
|
{ value: 'DEVELOPER', label: 'Developer' },
|
|
]}
|
|
value={createForm.role}
|
|
onChange={(val) => setCreateForm({ ...createForm, role: val || 'USER' })}
|
|
/>
|
|
<Button
|
|
fullWidth
|
|
mt="md"
|
|
variant="gradient"
|
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
|
loading={isCreating}
|
|
onClick={handleCreateUser}
|
|
>
|
|
Create User
|
|
</Button>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
{/* Edit User Modal */}
|
|
<Modal
|
|
opened={editOpened}
|
|
onClose={closeEdit}
|
|
title={<Text fw={700} size="lg">Edit User</Text>}
|
|
radius="xl"
|
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
|
>
|
|
<Stack gap="md">
|
|
<TextInput
|
|
label="Full Name"
|
|
placeholder="Enter full name"
|
|
required
|
|
value={editForm.name}
|
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
|
/>
|
|
<TextInput
|
|
label="Email"
|
|
placeholder="Enter email address"
|
|
required
|
|
value={editForm.email}
|
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
|
/>
|
|
<Select
|
|
label="Role"
|
|
data={[
|
|
{ value: 'ADMIN', label: 'Admin' },
|
|
{ value: 'DEVELOPER', label: 'Developer' },
|
|
]}
|
|
value={editForm.role}
|
|
onChange={(val) => setEditForm({ ...editForm, role: val || 'USER' })}
|
|
/>
|
|
<Button
|
|
fullWidth
|
|
mt="md"
|
|
variant="gradient"
|
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
|
loading={isEditing}
|
|
onClick={handleEditUser}
|
|
>
|
|
Save Changes
|
|
</Button>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
<Modal
|
|
opened={deleteOpened}
|
|
onClose={closeDelete}
|
|
title={<Text fw={700} size="lg">Delete User</Text>}
|
|
radius="xl"
|
|
size="sm"
|
|
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm">
|
|
Are you sure you want to delete <Text component="span" fw={700}>{deletingUser?.name}</Text>? This action cannot be undone.
|
|
</Text>
|
|
<Group justify="flex-end" mt="md">
|
|
<Button variant="subtle" color="gray" onClick={closeDelete}>
|
|
Cancel
|
|
</Button>
|
|
<Button color="red" loading={isDeleting} onClick={handleDeleteUser}>
|
|
Delete User
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
</DashboardLayout>
|
|
)
|
|
}
|