diff --git a/src/app.ts b/src/app.ts
index 3802ce9..b1a38ef 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -285,7 +285,7 @@ export function createApp() {
.get('/api/operators/stats', async () => {
const [totalStaff, activeNow, rolesGroup] = await Promise.all([
- prisma.user.count(),
+ prisma.user.count({where: {active: true}}),
prisma.session.count({
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 () => {
return await prisma.user.findMany({
select: { id: true, name: true, image: true },
diff --git a/src/frontend/config/api.ts b/src/frontend/config/api.ts
index dde9bee..6c10800 100644
--- a/src/frontend/config/api.ts
+++ b/src/frontend/config/api.ts
@@ -31,6 +31,9 @@ export const API_URLS = {
getOperators: (page: number, search: string) =>
`/api/operators?page=${page}&search=${encodeURIComponent(search)}`,
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) =>
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
createBug: () => `/api/bugs`,
diff --git a/src/frontend/routes/users.tsx b/src/frontend/routes/users.tsx
index ec0a12b..52c21ef 100644
--- a/src/frontend/routes/users.tsx
+++ b/src/frontend/routes/users.tsx
@@ -18,9 +18,14 @@ import {
List,
Divider,
Pagination,
+ Modal,
+ Select,
+ PasswordInput,
} from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
+import { useDisclosure } from '@mantine/hooks'
+import { notifications } from '@mantine/notifications'
import {
TbPlus,
TbSearch,
@@ -30,6 +35,7 @@ import {
TbShieldCheck,
TbAccessPoint,
TbCircleCheck,
+ TbCircleX,
TbClock,
TbApps,
} from 'react-icons/tb'
@@ -80,14 +86,131 @@ function UsersPage() {
return () => clearTimeout(timer)
}, [search])
- const { data: stats } = useSWR(API_URLS.getOperatorStats(), fetcher)
- const { data: response, isLoading } = useSWR(
+ 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: })
+ 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: })
+ } finally {
+ setIsCreating(false)
+ }
+ }
+
+ // ── Edit User Modal ──
+ const [editOpened, { open: openEdit, close: closeEdit }] = useDisclosure(false)
+ const [isEditing, setIsEditing] = useState(false)
+ const [editingUserId, setEditingUserId] = useState(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: })
+ mutateOperators()
+ closeEdit()
+ } else {
+ throw new Error('Failed to update user')
+ }
+ } catch (e) {
+ notifications.show({ title: 'Error', message: 'Something went wrong.', color: 'red', icon: })
+ } finally {
+ setIsEditing(false)
+ }
+ }
+
+ // ── Delete User ──
+ const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false)
+ const [isDeleting, setIsDeleting] = useState(false)
+ const [deletingUser, setDeletingUser] = useState(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: })
+ 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: })
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
return (
@@ -131,6 +254,7 @@ function UsersPage() {
gradient={{ from: '#2563EB', to: '#7C3AED', deg: 135 }}
leftSection={}
radius="md"
+ onClick={openCreate}
>
Add New User
@@ -183,10 +307,10 @@ function UsersPage() {
-
+ handleOpenEdit(user)}>
-
+ handleOpenDelete(user)}>
@@ -256,7 +380,131 @@ function UsersPage() {
+
+ {/* Create User Modal */}
+ Add New User}
+ radius="xl"
+ overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
+ >
+
+ setCreateForm({ ...createForm, name: e.target.value })}
+ />
+ setCreateForm({ ...createForm, email: e.target.value })}
+ />
+ setCreateForm({ ...createForm, password: e.target.value })}
+ />
+
+
+
+ {/* Edit User Modal */}
+ Edit User}
+ radius="xl"
+ overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
+ >
+
+ setEditForm({ ...editForm, name: e.target.value })}
+ />
+ setEditForm({ ...editForm, email: e.target.value })}
+ />
+
+
+
+ {/* Delete Confirmation Modal */}
+ Delete User}
+ radius="xl"
+ size="sm"
+ overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
+ >
+
+
+ Are you sure you want to delete {deletingUser?.name}? This action cannot be undone.
+
+
+
+
+
+
+
)
}
-