Files
monitoring-app/src/frontend/routes/users.tsx
2026-04-16 09:52:17 +08:00

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