6 Commits

Author SHA1 Message Date
b63117694b feat: add /api/system/version endpoint with changelog
Mengembalikan versi aplikasi, git commit hash, branch aktif, dan 20 commit terakhir untuk memverifikasi apakah staging/production sudah terupdate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:56:23 +08:00
dbbe53584c upd: sync compose.yml and .env.example with all env vars in env.ts
Add missing required vars: API_KEY, MINIO_*, and optional REDIS_URL, BUN_PUBLIC_BASE_URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 12:01:44 +08:00
06794524fd feat: block inactive users from login and fix activity log on dev operators
- Block inactive users on email/password login (403)
- Block inactive users on Google OAuth (redirect to account_disabled)
- Auto-logout inactive users on session check (deleteMany sessions)
- Delete sessions when user is deactivated via PATCH /api/operators/:id
- Add account_disabled error message on login page
- Show inactive indicator on users table with reactivate button
- Add createSystemLog calls to /api/admin/users role and activate endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 11:58:31 +08:00
73aa9729b8 upd: remove Settings menu item from DashboardLayout user menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 11:39:13 +08:00
7c5a491ba9 upd: update role management descriptions and add USER role card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 11:33:39 +08:00
3c6fac1943 feat: redesign /logs page with table UI and date range filter
- Replace timeline view with table layout (Time, Operator, Type, Message)
- Add date range filter using @mantine/dates DatePickerInput
- Add SegmentedControl for log type filter
- Disable App Logs and Settings menu on /dev
- Remove Activity Logs menu from /dev (moved to /logs)
- Add dateFrom/dateTo query params to /api/logs backend
- Import @mantine/dates/styles.css to fix datepicker styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 11:27:05 +08:00
12 changed files with 348 additions and 290 deletions

View File

@@ -1,6 +1,7 @@
# App
PORT=3000
NODE_ENV=development
BUN_PUBLIC_BASE_URL=http://localhost:3000
# Dev Inspector
REACT_EDITOR=code
@@ -13,12 +14,20 @@ DIRECT_URL=postgresql://user:password@localhost:5432/base-template
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Role
# Super Admin (comma-separated emails)
SUPER_ADMIN_EMAIL=admin@example.com
# API Key for external clients (e.g. mobile apps)
API_KEY=your-secret-api-key-here
# Telegram Notification (optional)
TELEGRAM_NOTIFY_TOKEN=
TELEGRAM_NOTIFY_CHAT_ID=
# MinIO (object storage for bug report images)
MINIO_ENDPOINT=
MINIO_PORT=443
MINIO_USE_SSL=true
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
MINIO_BUCKET=
MINIO_UPLOAD_DIR=bug-reports
# Redis (optional — enables App Logs feature on /dev)
REDIS_URL=

View File

@@ -10,6 +10,7 @@
"@elysiajs/swagger": "^1.3.1",
"@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18",
"@mantine/dates": "^9.1.1",
"@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.18",
@@ -17,6 +18,7 @@
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10",
"@xyflow/react": "^12.6.4",
"dayjs": "^1.11.20",
"elkjs": "^0.9.3",
"elysia": "^1.4.28",
"minio": "^8.0.7",
@@ -201,6 +203,8 @@
"@mantine/core": ["@mantine/core@8.3.18", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", "react-number-format": "^5.4.4", "react-remove-scroll": "^2.7.1", "react-textarea-autosize": "8.5.9", "type-fest": "^4.41.0" }, "peerDependencies": { "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA=="],
"@mantine/dates": ["@mantine/dates@9.1.1", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "9.1.1", "@mantine/hooks": "9.1.1", "dayjs": ">=1.0.0", "react": "^19.2.0", "react-dom": "^19.2.0" } }, "sha512-P1tr/Hr+EVxppbOVpTLvaZZnM1W/r0TNpqNNMeM81xfyuKYzd7zt2/SQYb6BuudgEQfRJnAee+7bIJLEsrb0uA=="],
"@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="],
"@mantine/modals": ["@mantine/modals@8.3.18", "", { "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-JfPDS4549L314SxFPC1x6CbKwzh82OdnIzwgMxPCVNsWLKV2vEHHUH/fzUYj4Wli6IBrsW4cufjMj9BTj3hm3Q=="],
@@ -481,6 +485,8 @@
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],

View File

@@ -4,17 +4,30 @@ services:
container_name: monitoring-app-stg
restart: unless-stopped
environment:
# App
- PORT=${PORT:-3000}
- NODE_ENV=${NODE_ENV:-production}
- BUN_PUBLIC_BASE_URL=${BUN_PUBLIC_BASE_URL}
# Database
- DATABASE_URL=${DATABASE_URL}
- DIRECT_URL=${DIRECT_URL}
# Google OAuth
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
# App
- PORT=${PORT:-3000}
- NODE_ENV=${NODE_ENV:-production}
# Admin (initial Super Admin emails, comma-separated)
# Super Admin
- SUPER_ADMIN_EMAIL=${SUPER_ADMIN_EMAIL}
# API Key
- API_KEY=${API_KEY}
# MinIO (object storage)
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
- MINIO_PORT=${MINIO_PORT:-443}
- MINIO_USE_SSL=${MINIO_USE_SSL:-true}
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_BUCKET=${MINIO_BUCKET}
- MINIO_UPLOAD_DIR=${MINIO_UPLOAD_DIR:-bug-reports}
# Redis (optional — app logs feature)
- REDIS_URL=${REDIS_URL:-}
networks:
- public-net
- postgres-net-stg

View File

@@ -28,11 +28,16 @@
"@elysiajs/swagger": "^1.3.1",
"@mantine/charts": "^9.0.0",
"@mantine/core": "^8.3.18",
"@mantine/dates": "^9.1.1",
"@mantine/hooks": "^8.3.18",
"@mantine/modals": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@prisma/client": "6",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-router": "^1.168.10",
"@xyflow/react": "^12.6.4",
"dayjs": "^1.11.20",
"elkjs": "^0.9.3",
"elysia": "^1.4.28",
"minio": "^8.0.7",
"postcss": "^8.5.8",
@@ -42,10 +47,7 @@
"react-dom": "^19",
"react-icons": "^5.6.0",
"recharts": "^3.8.1",
"swr": "^2.4.1",
"@mantine/modals": "^8.3.18",
"@xyflow/react": "^12.6.4",
"elkjs": "^0.9.3"
"swr": "^2.4.1"
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",

View File

@@ -203,6 +203,10 @@ export function createApp() {
})
}
if (!user.active) {
return new Response(null, { status: 302, headers: { Location: '/login?error=account_disabled' } })
}
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') {
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
}
@@ -233,6 +237,10 @@ export function createApp() {
set.status = 401
return { error: 'Email atau password salah' }
}
if (!user.active) {
set.status = 403
return { error: 'Akun Anda telah dinonaktifkan. Hubungi admin untuk informasi lebih lanjut.' }
}
// Auto-promote super admin from env
if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') {
user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } })
@@ -281,13 +289,18 @@ export function createApp() {
if (!token) { set.status = 401; return { user: null } }
const session = await prisma.session.findUnique({
where: { token },
include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } },
include: { user: { select: { id: true, name: true, email: true, role: true, image: true, active: true } } },
})
if (!session || session.expiresAt < new Date()) {
if (session) await prisma.session.delete({ where: { id: session.id } })
set.status = 401
return { user: null }
}
if (!session.user.active) {
await prisma.session.deleteMany({ where: { userId: session.user.id } })
set.status = 401
return { user: null }
}
return { user: session.user }
}, {
detail: {
@@ -414,6 +427,8 @@ export function createApp() {
const search = query.search || ''
const type = query.type as any
const userId = query.userId
const dateFrom = query.dateFrom
const dateTo = query.dateTo
const where: any = {}
if (search) {
@@ -428,6 +443,15 @@ export function createApp() {
if (userId && userId !== 'all') {
where.userId = userId
}
if (dateFrom || dateTo) {
where.createdAt = {}
if (dateFrom) where.createdAt.gte = new Date(dateFrom)
if (dateTo) {
const end = new Date(dateTo)
end.setHours(23, 59, 59, 999)
where.createdAt.lte = end
}
}
const [logs, total] = await Promise.all([
prisma.log.findMany({
@@ -452,6 +476,8 @@ export function createApp() {
search: t.Optional(t.String({ description: 'Cari berdasarkan pesan log atau nama pengguna' })),
type: t.Optional(t.String({ description: 'Filter tipe: CREATE | UPDATE | DELETE | LOGIN | LOGOUT | all' })),
userId: t.Optional(t.String({ description: 'Filter berdasarkan ID pengguna, atau "all"' })),
dateFrom: t.Optional(t.String({ description: 'Filter dari tanggal (ISO string atau YYYY-MM-DD)' })),
dateTo: t.Optional(t.String({ description: 'Filter sampai tanggal (ISO string atau YYYY-MM-DD)' })),
}),
detail: {
summary: 'List Activity Logs',
@@ -628,6 +654,10 @@ export function createApp() {
},
})
if (body.active === false) {
await prisma.session.deleteMany({ where: { userId: id } })
}
if (userId) {
await createSystemLog(userId, 'UPDATE', `Updated user: ${user.name} (${user.email})`)
}
@@ -980,6 +1010,39 @@ export function createApp() {
tags: ['System'],
},
})
.get('/api/system/version', async () => {
const pkg = await Bun.file('./package.json').json()
let commit = 'unknown'
let branch = 'unknown'
let changelog: { hash: string; date: string; author: string; message: string }[] = []
try {
const commitProc = Bun.spawn(['git', 'rev-parse', '--short', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' })
commit = (await new Response(commitProc.stdout).text()).trim()
const branchProc = Bun.spawn(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { stdout: 'pipe', stderr: 'pipe' })
branch = (await new Response(branchProc.stdout).text()).trim()
const logProc = Bun.spawn(
['git', 'log', '--pretty=format:%h|%aI|%an|%s', '-20'],
{ stdout: 'pipe', stderr: 'pipe' },
)
const logText = (await new Response(logProc.stdout).text()).trim()
changelog = logText.split('\n').filter(Boolean).map(line => {
const [hash, date, author, ...msgParts] = line.split('|')
return { hash, date, author, message: msgParts.join('|') }
})
} catch { /* git not available */ }
return {
version: pkg.version as string,
commit,
branch,
changelog,
}
}, {
detail: {
summary: 'Version Info',
description: 'Mengembalikan versi aplikasi, git commit hash, branch aktif, dan 20 commit terakhir sebagai changelog.',
tags: ['System'],
},
})
// ─── Example API ───────────────────────────────────
.get('/api/hello', () => ({
@@ -1041,6 +1104,7 @@ export function createApp() {
select: { id: true, name: true, email: true, role: true, active: true, createdAt: true },
})
await appLog('info', `Role changed: ${user.email} ${target?.role}${role}`)
await createSystemLog(auth.userId, 'UPDATE', `Role changed: ${user.name} (${user.email}) ${target?.role}${role}`)
return { user }
})
@@ -1056,6 +1120,7 @@ export function createApp() {
})
if (!active) await prisma.session.deleteMany({ where: { userId: params.id } })
await appLog('info', `User ${active ? 'activated' : 'deactivated'}: ${user.email}`)
await createSystemLog(auth.userId, active ? 'UPDATE' : 'DELETE', `User ${active ? 'activated' : 'deactivated'}: ${user.name} (${user.email})`)
return { user }
})

View File

@@ -1,5 +1,6 @@
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
import '@mantine/core/styles.css'
import '@mantine/dates/styles.css'
import '@mantine/notifications/styles.css'
import { ModalsProvider } from '@mantine/modals'
import { Notifications } from '@mantine/notifications'

View File

@@ -201,12 +201,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<TbSettings size={16} />}
onClick={() => navigate({ to: '/dashboard' })}
>
Settings
</Menu.Item>
<Menu.Divider />
<Menu.Label>Danger Zone</Menu.Label>
<Menu.Item

View File

@@ -25,8 +25,12 @@ export const API_URLS = {
editUser: () => `${API_BASE_URL}/api/monitoring/edit-user`,
updateStatusVillages: () => `${API_BASE_URL}/api/monitoring/update-status-villages`,
editVillages: () => `${API_BASE_URL}/api/monitoring/edit-villages`,
getGlobalLogs: (page: number, search: string, type: string, userId: string) =>
`/api/logs?page=${page}&search=${encodeURIComponent(search)}&type=${type}&userId=${userId}`,
getGlobalLogs: (page: number, search: string, type: string, userId: string, dateFrom?: string, dateTo?: string) => {
const params = new URLSearchParams({ page: String(page), search, type, userId })
if (dateFrom) params.set('dateFrom', dateFrom)
if (dateTo) params.set('dateTo', dateTo)
return `/api/logs?${params}`
},
getLogOperators: () => `/api/logs/operators`,
getOperators: (page: number, search: string) =>
`/api/operators?page=${page}&search=${encodeURIComponent(search)}`,

View File

@@ -109,11 +109,11 @@ const navItems = [
{ label: 'Overview', icon: TbLayoutDashboard, key: 'overview' },
{ label: 'Operators', icon: TbUsers, key: 'operators' },
{ label: 'Bugs', icon: TbBug, key: 'bugs' },
{ label: 'App Logs', icon: TbServer, key: 'app-logs' },
{ label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' },
{ label: 'App Logs', icon: TbServer, key: 'app-logs', disabled: true },
// { label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' },
{ label: 'Database', icon: TbDatabase, key: 'database' },
{ label: 'Project', icon: TbSitemap, key: 'project' },
{ label: 'Settings', icon: TbSettings, key: 'settings' },
// { label: 'Settings', icon: TbSettings, key: 'settings' },
]
function DevPage() {
@@ -204,7 +204,8 @@ function DevPage() {
variant={active === item.key ? 'filled' : 'subtle'}
color={active === item.key ? 'blue' : 'gray'}
size="lg"
onClick={() => setActive(item.key)}
disabled={item.disabled}
onClick={() => !item.disabled && setActive(item.key)}
>
<Icon size={18} />
</ActionIcon>
@@ -218,7 +219,8 @@ function DevPage() {
leftSection={<Icon size={16} />}
rightSection={active === item.key ? <TbChevronRight size={14} /> : undefined}
active={active === item.key}
onClick={() => setActive(item.key)}
disabled={item.disabled}
onClick={() => !item.disabled && setActive(item.key)}
style={{ borderRadius: 6 }}
/>
)

View File

@@ -66,6 +66,7 @@ function LoginPage() {
invalid_state: 'Sesi OAuth tidak valid, silakan coba lagi.',
token_failed: 'Gagal menukar token Google, silakan coba lagi.',
userinfo_failed: 'Gagal mengambil info akun Google, silakan coba lagi.',
account_disabled: 'Akun Anda telah dinonaktifkan. Hubungi admin untuk informasi lebih lanjut.',
}[searchError ?? ''] ?? 'Login dengan Google gagal, silakan coba lagi.'
)}
</Alert>

View File

@@ -1,22 +1,24 @@
import {
ActionIcon,
Badge,
Center,
Container,
Group,
Stack,
Text,
Paper,
TextInput,
Select,
Avatar,
Box,
Divider,
Loader,
Pagination,
Center,
Tooltip,
SegmentedControl,
Select,
Stack,
Table,
Text,
Title,
} from '@mantine/core'
import { useState, useMemo, useEffect } from 'react'
import { DatePickerInput, type DatesRangeValue } from '@mantine/dates'
import dayjs from 'dayjs'
import 'dayjs/locale/id'
import { useMemo, useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { TbSearch, TbClock, TbCheck, TbX } from 'react-icons/tb'
import { TbRefresh } from 'react-icons/tb'
import { DashboardLayout } from '@/frontend/components/DashboardLayout'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
@@ -25,263 +27,144 @@ export const Route = createFileRoute('/logs')({
component: GlobalLogsPage,
})
const fetcher = (url: string) => fetch(url).then((res) => res.json())
const fetcher = (url: string) => fetch(url, { credentials: 'include' }).then((r) => r.json())
const typeConfig: Record<string, { color: string; icon?: any }> = {
CREATE: { color: 'blue', icon: TbCheck },
UPDATE: { color: 'teal', icon: TbCheck },
DELETE: { color: 'red', icon: TbX },
LOGIN: { color: 'green', icon: TbClock },
LOGOUT: { color: 'orange', icon: TbClock },
}
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'
}
function groupLogsByDate(logs: any[]) {
const groups: Record<string, any[]> = {}
const today = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
const yesterday = new Date(Date.now() - 86400000).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
logs.forEach(log => {
const dateObj = new Date(log.createdAt)
let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
if (dateStr === today) dateStr = 'TODAY'
else if (dateStr === yesterday) dateStr = 'YESTERDAY'
if (!groups[dateStr]) groups[dateStr] = []
const timeStr = dateObj.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
groups[dateStr].push({
id: log.id,
time: timeStr,
user: log.user,
type: log.type,
content: log.message,
color: log.user ? getRoleColor(log.user.role) : 'gray',
icon: typeConfig[log.type as string]?.icon
})
})
// We want to keep the order as they came from the API (sorted by createdAt desc)
// but grouped by date. Object.entries might mess up the order if dates are not sequential.
// However, since the source logs are sorted, the first encounter of a date defines the group order.
const result: { date: string; logs: any[] }[] = []
const seenDates = new Set<string>()
logs.forEach(log => {
const dateObj = new Date(log.createdAt)
let dateStr = dateObj.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).toUpperCase()
if (dateStr === today) dateStr = 'TODAY'
else if (dateStr === yesterday) dateStr = 'YESTERDAY'
if (!seenDates.has(dateStr)) {
result.push({ date: dateStr, logs: groups[dateStr] })
seenDates.add(dateStr)
}
})
return result
const LOG_TYPES = ['all', 'LOGIN', 'LOGOUT', 'CREATE', 'UPDATE', 'DELETE'] as const
const LOG_TYPE_COLOR: Record<string, string> = {
LOGIN: 'green',
LOGOUT: 'gray',
CREATE: 'blue',
UPDATE: 'yellow',
DELETE: 'red',
}
function GlobalLogsPage() {
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [logType, setLogType] = useState<string | null>('all')
const [operatorId, setOperatorId] = useState<string | null>('all')
const [type, setType] = useState('all')
const [operatorId, setOperatorId] = useState('all')
const [dateRange, setDateRange] = useState<DatesRangeValue>([null, null])
const [page, setPage] = useState(1)
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300)
return () => clearTimeout(timer)
}, [search])
const { data: operatorsData } = useSWR(API_URLS.getLogOperators(), fetcher)
const operatorOptions = useMemo(() => {
if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'All Operators' }]
if (!operatorsData || !Array.isArray(operatorsData)) return [{ value: 'all', label: 'Semua operator' }]
return [
{ value: 'all', label: 'All Operators' },
...operatorsData.map((op: any) => ({ value: op.id, label: op.name }))
{ value: 'all', label: 'Semua user' },
...operatorsData.map((op: any) => ({ value: op.id, label: op.name })),
]
}, [operatorsData])
const { data: response, isLoading } = useSWR(
API_URLS.getGlobalLogs(page, debouncedSearch, logType || 'all', operatorId || 'all'),
fetcher
const dateFrom = dateRange[0] ? dayjs(dateRange[0]).format('YYYY-MM-DD') : undefined
const dateTo = dateRange[1] ? dayjs(dateRange[1]).format('YYYY-MM-DD') : undefined
const { data, isLoading, mutate } = useSWR(
API_URLS.getGlobalLogs(page, '', type, operatorId, dateFrom, dateTo),
fetcher,
{ refreshInterval: 10_000 },
)
const filteredTimeline = useMemo(() => {
if (!response?.data) return []
return groupLogsByDate(response.data)
}, [response?.data])
const logs: any[] = data?.data ?? []
const totalPages: number = data?.totalPages ?? 1
return (
<DashboardLayout>
<Container size="xl" py="lg">
{/* Header Controls */}
<Group mb="xl" gap="md">
<TextInput
placeholder="Search operator or message..."
leftSection={<TbSearch size={16} />}
radius="md"
w={250}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value)
setPage(1)
}}
/>
<Select
placeholder="Log Type"
data={[
{ value: 'all', label: 'All Types' },
{ value: 'CREATE', label: 'Create' },
{ value: 'UPDATE', label: 'Update' },
{ value: 'DELETE', label: 'Delete' },
{ value: 'LOGIN', label: 'Login' },
{ value: 'LOGOUT', label: 'Logout' },
]}
radius="md"
w={160}
value={logType}
onChange={(val) => {
setLogType(val)
setPage(1)
}}
/>
<Select
placeholder="Operator"
data={operatorOptions}
searchable
radius="md"
w={200}
value={operatorId}
onChange={(val) => {
setOperatorId(val)
setPage(1)
}}
/>
</Group>
<Stack>
<Group justify="space-between">
<Title order={3}>Activity Logs</Title>
<ActionIcon variant="subtle" color="gray" onClick={() => mutate()}>
<TbRefresh size={16} />
</ActionIcon>
</Group>
<Group gap="sm" wrap="wrap">
<Select
placeholder="Filter user"
value={operatorId}
onChange={(v) => { setOperatorId(v ?? 'all'); setPage(1) }}
data={operatorOptions}
w={180}
clearable
/>
<DatePickerInput
type="range"
placeholder="Filter tanggal"
value={dateRange}
onChange={(v) => { setDateRange(v); setPage(1) }}
locale="id"
valueFormat="DD MMM YYYY"
clearable
w={300}
/>
<SegmentedControl
value={type}
onChange={(v) => { setType(v); setPage(1) }}
data={LOG_TYPES.map((t) => ({ label: t === 'all' ? 'All' : t, value: t }))}
/>
</Group>
{/* Timeline Content */}
<Paper withBorder p="md" radius="2xl" className="glass" style={{ background: 'var(--mantine-color-body)', minHeight: 400 }}>
{isLoading ? (
<Center py="xl">
<Text c="dimmed">Loading logs...</Text>
</Center>
) : filteredTimeline.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No logs found matching your filters.</Text>
<Center py="xl"><Loader /></Center>
) : (
<>
{filteredTimeline.map((group, groupIndex) => (
<Box key={group.date}>
<Text
size="xs"
fw={700}
c="dimmed"
mt={groupIndex > 0 ? "xl" : 0}
mb="md"
style={{ textTransform: 'uppercase' }}
>
{group.date}
</Text>
<Stack gap={0} pl={4}>
{group.logs.map((log, logIndex) => {
const isLastLog = logIndex === group.logs.length - 1;
return (
<Group
key={log.id}
wrap="nowrap"
align="flex-start"
gap="lg"
style={{ position: 'relative', paddingBottom: isLastLog ? 0 : 32 }}
>
{/* Left: Time */}
<Text
size="xs"
c="dimmed"
w={70}
style={{ flexShrink: 0, marginTop: 4, textAlign: 'left' }}
>
{log.time}
</Text>
{/* Middle: Line & Avatar */}
<Box style={{ position: 'relative', width: 20, flexShrink: 0, alignSelf: 'stretch' }}>
{/* Vertical Line */}
{!isLastLog && (
<Box
style={{
position: 'absolute',
top: 24,
bottom: -8,
left: '50%',
transform: 'translateX(-50%)',
width: 1,
backgroundColor: 'rgba(128,128,128,0.2)'
}}
/>
)}
{/* Avatar */}
<Box style={{ position: 'relative', zIndex: 2 }}>
<Tooltip label={`${log.user?.name || 'Unknown'} (${log.user?.role || 'User'})`} withArrow radius="md">
<Avatar
size={24}
radius="xl"
color={log.color}
variant="light"
src={log.user?.image}
style={{ cursor: 'help' }}
>
{log.icon ? <log.icon size={14} /> : (log.user?.name?.charAt(0) || '?')}
</Avatar>
</Tooltip>
</Box>
</Box>
{/* Right: Content */}
<Box style={{ flexGrow: 1, marginTop: 2 }}>
<Text size="sm">
<Text component="span" fw={600} mr={4}>{log.user?.name || 'Unknown'}</Text>
{log.content}
</Text>
</Box>
</Group>
)
})}
</Stack>
{groupIndex < filteredTimeline.length - 1 && (
<Divider my="xl" color="rgba(128,128,128,0.1)" />
)}
</Box>
))}
{response?.totalPages > 1 && (
<Center mt="xl">
<Pagination
total={response.totalPages}
value={page}
onChange={setPage}
radius="md"
/>
<Table.ScrollContainer minWidth={600}>
<Table striped highlightOnHover fz="xs" style={{ tableLayout: 'fixed', width: '100%' }}>
<colgroup>
<col style={{ width: 160 }} />
<col style={{ width: 200 }} />
<col style={{ width: 100 }} />
<col />
</colgroup>
<Table.Thead>
<Table.Tr>
<Table.Th>Time</Table.Th>
<Table.Th>Operator</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Message</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{logs.map((log: any) => (
<Table.Tr key={log.id}>
<Table.Td style={{ whiteSpace: 'nowrap' }}>
{new Date(log.createdAt).toLocaleString('id-ID')}
</Table.Td>
<Table.Td>
{log.user ? (
<div>
<Text fw={500} truncate>{log.user.name}</Text>
<Text c="dimmed" truncate>{log.user.email}</Text>
</div>
) : <Text c="dimmed"></Text>}
</Table.Td>
<Table.Td>
<Badge color={LOG_TYPE_COLOR[log.type] ?? 'gray'} variant="light">
{log.type}
</Badge>
</Table.Td>
<Table.Td>
<Text>{log.message}</Text>
</Table.Td>
</Table.Tr>
))}
{logs.length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Center py="xl"><Text c="dimmed">Belum ada log aktivitas</Text></Center>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{totalPages > 1 && (
<Center>
<Pagination total={totalPages} value={page} onChange={setPage} size="sm" />
</Center>
)}
</>
)}
</Paper>
</Stack>
</Container>
</DashboardLayout>
)

View File

@@ -4,6 +4,7 @@ import {
ActionIcon,
Avatar,
Badge,
Box,
Button,
Card,
Container,
@@ -23,6 +24,7 @@ import {
TextInput,
ThemeIcon,
Title,
Tooltip,
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { notifications } from '@mantine/notifications'
@@ -37,7 +39,8 @@ import {
TbSearch,
TbShieldCheck,
TbTrash,
TbUserCheck
TbUserCheck,
TbUserPlus,
} from 'react-icons/tb'
import useSWR from 'swr'
import { API_URLS } from '../config/api'
@@ -58,13 +61,37 @@ const getRoleColor = (role: string) => {
const roles = [
{
name: 'DEVELOPER',
color: 'red',
permissions: ['Full Access', 'Error Feedback', 'Error Management', 'App Version Management', 'User Management']
color: 'violet',
description: 'Super admin dengan akses penuh ke seluruh sistem termasuk Dev Console.',
permissions: [
'Akses Dev Console (/dev)',
'Manajemen user & role',
'Kelola bug report & feedback',
'Lihat semua app & log aktivitas',
'Kelola versi & status aplikasi',
'Hapus log sistem',
],
},
{
name: 'ADMIN',
color: 'orange',
permissions: ['View All Apps', 'View Logs', 'Report Errors']
color: 'blue',
description: 'Operator yang dapat mengelola aplikasi, bug, dan melihat log aktivitas.',
permissions: [
'Lihat & kelola semua aplikasi',
'Kelola bug report',
'Lihat log aktivitas',
'Lihat data user, desa, orders',
'Update status village & produk',
],
},
{
name: 'USER',
color: 'gray',
description: 'Akun baru yang belum disetujui. Menunggu approval dari Admin atau Developer.',
permissions: [
'Akses halaman profil',
'Lihat status persetujuan akun',
],
},
]
@@ -205,6 +232,28 @@ function UsersPage() {
}
}
// ── Activate User ──
const handleActivateUser = async (user: any) => {
try {
const res = await fetch(`/api/operators/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ active: true }),
})
if (res.ok) {
notifications.show({ title: 'Success', message: `${user.name} telah diaktifkan kembali.`, color: 'teal', icon: <TbCircleCheck size={18} /> })
mutateOperators()
mutateStats()
} else {
const err = await res.json()
throw new Error(err.error || 'Failed to activate user')
}
} catch (e: any) {
notifications.show({ title: 'Error', message: e.message || 'Something went wrong.', color: 'red', icon: <TbCircleX size={18} /> })
}
}
return (
<DashboardLayout>
<Container size="xl" py="lg">
@@ -282,33 +331,62 @@ function UsersPage() {
) : (
operators.map((user: any) => (
<Table.Tr key={user.id}>
<Table.Td>
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Group gap="sm">
<Avatar size="sm" radius="xl" color={getRoleColor(user.role)} src={user.image}>
{user.name.charAt(0)}
</Avatar>
<Box style={{ position: 'relative' }}>
<Avatar size="sm" radius="xl" color={user.active === false ? 'gray' : getRoleColor(user.role)} src={user.image}>
{user.name.charAt(0)}
</Avatar>
{user.active === false && (
<Box
style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
background: 'var(--mantine-color-red-6)',
border: '1.5px solid var(--mantine-color-body)',
}}
/>
)}
</Box>
<Stack gap={0}>
<Text fw={600} size="sm">{user.name}</Text>
<Group gap={6}>
<Text fw={600} size="sm" c={user.active === false ? 'dimmed' : undefined}>{user.name}</Text>
{user.active === false && (
<Badge size="xs" color="red" variant="light">Inactive</Badge>
)}
</Group>
<Text size="xs" c="dimmed">{user.email}</Text>
</Stack>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light" color={getRoleColor(user.role)}>
<Table.Td style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Badge variant="light" color={user.active === false ? 'gray' : 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 style={{ opacity: user.active === false ? 0.45 : 1 }}>
<Text size="xs" fw={500} c={user.active === false ? 'dimmed' : undefined}>
{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>
{user.active === false ? (
<Tooltip label="Aktifkan user" withArrow>
<ActionIcon disabled={!isDeveloper} variant="light" size="sm" color="teal" onClick={() => handleActivateUser(user)}>
<TbUserPlus size={14} />
</ActionIcon>
</Tooltip>
) : (
<>
<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>
@@ -343,8 +421,8 @@ function UsersPage() {
</Group>
<Stack gap={4}>
<Title order={4}>{role.name.replace('_', ' ')}</Title>
<Text size="sm" c="dimmed">Core role for secure app management.</Text>
<Title order={4}>{role.name}</Title>
<Text size="sm" c="dimmed">{role.description}</Text>
</Stack>
<Divider />