upd: user staff
Deskripsi: - connected to database pada halaman user - tambah user - delete user - update user No Issues
This commit is contained in:
95
src/app.ts
95
src/app.ts
@@ -285,7 +285,7 @@ export function createApp() {
|
|||||||
|
|
||||||
.get('/api/operators/stats', async () => {
|
.get('/api/operators/stats', async () => {
|
||||||
const [totalStaff, activeNow, rolesGroup] = await Promise.all([
|
const [totalStaff, activeNow, rolesGroup] = await Promise.all([
|
||||||
prisma.user.count(),
|
prisma.user.count({where: {active: true}}),
|
||||||
prisma.session.count({
|
prisma.session.count({
|
||||||
where: { expiresAt: { gte: new Date() } },
|
where: { expiresAt: { gte: new Date() } },
|
||||||
}),
|
}),
|
||||||
@@ -302,6 +302,99 @@ export function createApp() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
.post('/api/operators', async ({ request, set }) => {
|
||||||
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||||
|
let userId: string | undefined
|
||||||
|
if (token) {
|
||||||
|
const session = await prisma.session.findUnique({ where: { token } })
|
||||||
|
if (session && session.expiresAt > new Date()) userId = session.userId
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as { name: string; email: string; password: string; role: string }
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email: body.email } })
|
||||||
|
if (existing) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: 'Email sudah terdaftar' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await Bun.password.hash(body.password)
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: body.name,
|
||||||
|
email: body.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: body.role as any,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await createSystemLog(userId, 'CREATE', `Created new user: ${body.name} (${body.email})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: user.id, name: user.name, email: user.email, role: user.role }
|
||||||
|
})
|
||||||
|
|
||||||
|
.patch('/api/operators/:id', async ({ params: { id }, request, set }) => {
|
||||||
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||||
|
let userId: string | undefined
|
||||||
|
if (token) {
|
||||||
|
const session = await prisma.session.findUnique({ where: { token } })
|
||||||
|
if (session && session.expiresAt > new Date()) userId = session.userId
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as { name?: string; email?: string; role?: string; active?: boolean }
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(body.name !== undefined && { name: body.name }),
|
||||||
|
...(body.email !== undefined && { email: body.email }),
|
||||||
|
...(body.role !== undefined && { role: body.role as any }),
|
||||||
|
...(body.active !== undefined && { active: body.active }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await createSystemLog(userId, 'UPDATE', `Updated user: ${user.name} (${user.email})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: user.id, name: user.name, email: user.email, role: user.role, active: user.active }
|
||||||
|
})
|
||||||
|
|
||||||
|
.delete('/api/operators/:id', async ({ params: { id }, request, set }) => {
|
||||||
|
const cookie = request.headers.get('cookie') ?? ''
|
||||||
|
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||||
|
let userId: string | undefined
|
||||||
|
if (token) {
|
||||||
|
const session = await prisma.session.findUnique({ where: { token } })
|
||||||
|
if (session && session.expiresAt > new Date()) userId = session.userId
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id } })
|
||||||
|
if (!user) {
|
||||||
|
set.status = 404
|
||||||
|
return { error: 'User not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting self
|
||||||
|
if (userId === id) {
|
||||||
|
set.status = 400
|
||||||
|
return { error: 'Cannot delete your own account' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.session.deleteMany({ where: { userId: id } })
|
||||||
|
await prisma.user.update({ where: { id }, data: { active: false } })
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
await createSystemLog(userId, 'DELETE', `Deactivated user: ${user.name} (${user.email})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
.get('/api/logs/operators', async () => {
|
.get('/api/logs/operators', async () => {
|
||||||
return await prisma.user.findMany({
|
return await prisma.user.findMany({
|
||||||
select: { id: true, name: true, image: true },
|
select: { id: true, name: true, image: true },
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export const API_URLS = {
|
|||||||
getOperators: (page: number, search: string) =>
|
getOperators: (page: number, search: string) =>
|
||||||
`/api/operators?page=${page}&search=${encodeURIComponent(search)}`,
|
`/api/operators?page=${page}&search=${encodeURIComponent(search)}`,
|
||||||
getOperatorStats: () => `/api/operators/stats`,
|
getOperatorStats: () => `/api/operators/stats`,
|
||||||
|
createOperator: () => `/api/operators`,
|
||||||
|
editOperator: (id: string) => `/api/operators/${id}`,
|
||||||
|
deleteOperator: (id: string) => `/api/operators/${id}`,
|
||||||
getBugs: (page: number, search: string, app: string, status: string) =>
|
getBugs: (page: number, search: string, app: string, status: string) =>
|
||||||
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
|
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
|
||||||
createBug: () => `/api/bugs`,
|
createBug: () => `/api/bugs`,
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ import {
|
|||||||
List,
|
List,
|
||||||
Divider,
|
Divider,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
PasswordInput,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
|
import { notifications } from '@mantine/notifications'
|
||||||
import {
|
import {
|
||||||
TbPlus,
|
TbPlus,
|
||||||
TbSearch,
|
TbSearch,
|
||||||
@@ -30,6 +35,7 @@ import {
|
|||||||
TbShieldCheck,
|
TbShieldCheck,
|
||||||
TbAccessPoint,
|
TbAccessPoint,
|
||||||
TbCircleCheck,
|
TbCircleCheck,
|
||||||
|
TbCircleX,
|
||||||
TbClock,
|
TbClock,
|
||||||
TbApps,
|
TbApps,
|
||||||
} from 'react-icons/tb'
|
} from 'react-icons/tb'
|
||||||
@@ -80,14 +86,131 @@ function UsersPage() {
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [search])
|
}, [search])
|
||||||
|
|
||||||
const { data: stats } = useSWR(API_URLS.getOperatorStats(), fetcher)
|
const { data: stats, mutate: mutateStats } = useSWR(API_URLS.getOperatorStats(), fetcher)
|
||||||
const { data: response, isLoading } = useSWR(
|
const { data: response, isLoading, mutate: mutateOperators } = useSWR(
|
||||||
API_URLS.getOperators(page, debouncedSearch),
|
API_URLS.getOperators(page, debouncedSearch),
|
||||||
fetcher
|
fetcher
|
||||||
)
|
)
|
||||||
|
|
||||||
const operators = response?.data || []
|
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 (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<Container size="xl" py="lg">
|
<Container size="xl" py="lg">
|
||||||
@@ -131,6 +254,7 @@ function UsersPage() {
|
|||||||
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
|
||||||
leftSection={<TbPlus size={18} />}
|
leftSection={<TbPlus size={18} />}
|
||||||
radius="md"
|
radius="md"
|
||||||
|
onClick={openCreate}
|
||||||
>
|
>
|
||||||
Add New User
|
Add New User
|
||||||
</Button>
|
</Button>
|
||||||
@@ -183,10 +307,10 @@ function UsersPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ActionIcon variant="light" size="sm" color="blue">
|
<ActionIcon variant="light" size="sm" color="blue" onClick={() => handleOpenEdit(user)}>
|
||||||
<TbPencil size={14} />
|
<TbPencil size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon variant="light" size="sm" color="red">
|
<ActionIcon variant="light" size="sm" color="red" onClick={() => handleOpenDelete(user)}>
|
||||||
<TbTrash size={14} />
|
<TbTrash size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -256,7 +380,131 @@ function UsersPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</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: 'USER', label: 'User' },
|
||||||
|
{ value: 'ADMIN', label: 'Admin' },
|
||||||
|
{ value: 'DEVELOPER', label: 'Developer' },
|
||||||
|
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
|
||||||
|
]}
|
||||||
|
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: 'USER', label: 'User' },
|
||||||
|
{ value: 'ADMIN', label: 'Admin' },
|
||||||
|
{ value: 'DEVELOPER', label: 'Developer' },
|
||||||
|
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
|
||||||
|
]}
|
||||||
|
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>
|
</DashboardLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user