13 Commits

Author SHA1 Message Date
4d5c2bf632 Merge pull request 'amalia/20-mei-26' (#24) from amalia/20-mei-26 into main
Reviewed-on: #24
2026-05-20 17:23:15 +08:00
c782f956e0 chore: bump version to 0.1.12 2026-05-20 14:08:35 +08:00
515ee01d53 chore: bump version to 0.1.11 2026-05-20 13:57:48 +08:00
058dd95b4f refactor: rename and reorder dev panel tabs for clarity
- Rename "API Keys" → "Desa Mandiri Keys" (tab + panel title + description)
- Rename "Settings" → "App Config" (tab + panel title)
- Move "Desa Mandiri Keys" to last position with a divider separator
- Import Divider from @mantine/core
2026-05-20 12:32:06 +08:00
ef2183ffb7 chore: bump version to 0.1.10 2026-05-19 15:40:05 +08:00
9afe9297e0 Merge pull request 'amalia/18-mei-26' (#23) from amalia/18-mei-26 into main
Reviewed-on: #23
2026-05-18 17:26:01 +08:00
f98fb51cfd feat: tambah field isApprover pada edit user modal 2026-05-18 16:42:32 +08:00
3b8eabc111 fix: gender select value M/F instead of Male/Female 2026-05-18 16:31:43 +08:00
88ddb7527e Merge pull request 'chore: bump version to 0.1.9' (#22) from amalia/15-mei-26 into main
Reviewed-on: #22
2026-05-15 14:22:43 +08:00
abca720f89 chore: bump version to 0.1.9 2026-05-15 11:51:57 +08:00
a69b0aad48 Merge pull request 'upd: setting api key sistem desa mandiri' (#21) from amalia/13-mei-26 into main
Reviewed-on: #21
2026-05-13 17:24:37 +08:00
2cb061ea7f upd: setting api key sistem desa mandiri 2026-05-13 17:23:27 +08:00
a53309bf15 Merge pull request 'amalia/12-mei-26' (#20) from amalia/12-mei-26 into main
Reviewed-on: #20
2026-05-12 17:24:25 +08:00
6 changed files with 304 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "bun-react-template",
"version": "0.1.8",
"version": "0.1.12",
"private": true,
"type": "module",
"scripts": {

View File

@@ -149,6 +149,3 @@ model BugLog {
@@map("bug_log")
}

View File

@@ -1640,6 +1640,73 @@ export function createApp() {
return { sessions: result, summary: { totalSessions: result.length, activeSessions: active, expiredSessions: expired, onlineUsers: onlineIds.size, byRole } }
})
// ─── API Keys (proxied to desa-plus /api/monitoring/api-keys) ─────────────
.get('/api/admin/api-keys', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
if (!app?.urlApi) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi' } }
const res = await fetch(`${app.urlApi.replace(/\/$/, '')}/api/monitoring/api-keys`, {
headers: { 'x-api-key': app.apiKey ?? '' },
})
const json = await res.json()
return { keys: json.data ?? [] }
})
.post('/api/admin/api-keys', async ({ request, set }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const body = await request.json() as { name?: string }
if (!body.name?.trim()) { set.status = 400; return { error: 'name wajib diisi' } }
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
if (!app?.urlApi) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi: urlApi kosong' } }
if (!app?.apiKey) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi: apiKey kosong' } }
try {
const res = await fetch(`${app.urlApi.replace(/\/$/, '')}/api/monitoring/api-keys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': app.apiKey },
body: JSON.stringify({ name: body.name.trim() }),
})
const json = await res.json()
set.status = res.status
return { key: json.data ?? null }
} catch (e) {
set.status = 502
return { error: `Gagal menghubungi desa-plus: ${String(e)}` }
}
})
.patch('/api/admin/api-keys/:id', async ({ request, set, params }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const body = await request.json() as { isActive?: boolean }
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
if (!app?.urlApi) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi' } }
const res = await fetch(`${app.urlApi.replace(/\/$/, '')}/api/monitoring/api-keys/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-api-key': app.apiKey ?? '' },
body: JSON.stringify({ isActive: body.isActive }),
})
const json = await res.json()
set.status = res.status
return json
})
.delete('/api/admin/api-keys/:id', async ({ request, set, params }) => {
const auth = await requireDeveloper(request, set)
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
if (!app?.urlApi) { set.status = 503; return { error: 'desa-plus belum dikonfigurasi' } }
const res = await fetch(`${app.urlApi.replace(/\/$/, '')}/api/monitoring/api-keys/${params.id}`, {
method: 'DELETE',
headers: { 'x-api-key': app.apiKey ?? '' },
})
const json = await res.json()
set.status = res.status
return json
})
// ─── Desa Plus Proxy ───────────────────────────────────────────────────────
.all('/api/proxy/desa-plus/*', async ({ request, set }) => {

View File

@@ -57,6 +57,7 @@ interface APIUser {
gender: string
isWithoutOTP: boolean
isActive: boolean
isApprover: boolean
role: string
village: string
group: string
@@ -118,7 +119,8 @@ function UsersIndexPage() {
idGroup: '',
idPosition: '',
isActive: true,
isWithoutOTP: false
isWithoutOTP: false,
isApprover: false
})
// Options Data (Shared for both Add and Edit modals)
@@ -212,7 +214,8 @@ function UsersIndexPage() {
idGroup: user.idGroup,
idPosition: user.idPosition,
isActive: user.isActive,
isWithoutOTP: user.isWithoutOTP
isWithoutOTP: user.isWithoutOTP,
isApprover: user.isApprover
})
setVillageSearch(user.village)
openEdit()
@@ -544,6 +547,12 @@ function UsersIndexPage() {
checked={editForm.isWithoutOTP}
onChange={(event) => setEditForm(f => ({ ...f, isWithoutOTP: event.currentTarget.checked }))}
/>
<Switch
label="Approver"
description="Grant approver privileges to this user"
checked={editForm.isApprover}
onChange={(event) => setEditForm(f => ({ ...f, isApprover: event.currentTarget.checked }))}
/>
</SimpleGrid>
<Button

View File

@@ -422,7 +422,7 @@ function AppVillagesIndexPage() {
<Select
label="Gender"
placeholder="Select gender"
data={['Male', 'Female']}
data={[{ label: 'Male', value: 'M' }, { label: 'Female', value: 'F' }]}
mt="sm"
required
value={form.gender}

View File

@@ -9,6 +9,7 @@ import {
Card,
Center,
Container,
Divider,
Group,
Loader,
Menu,
@@ -77,7 +78,7 @@ import { notifications } from '@mantine/notifications'
import { type Role, useLogout, useSession } from '@/frontend/hooks/useAuth'
import { usePresence } from '@/frontend/hooks/usePresence'
const validTabs = ['overview', 'operators', 'bugs', 'app-logs', 'activity-logs', 'database', 'project', 'settings'] as const
const validTabs = ['overview', 'operators', 'bugs', 'app-logs', 'activity-logs', 'database', 'project', 'settings', 'api-keys'] as const
export const Route = createFileRoute('/dev')({
validateSearch: (search: Record<string, unknown>) => ({
@@ -117,7 +118,9 @@ const navItems = [
// { 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: 'App Config', icon: TbSettings, key: 'settings' },
{ divider: true, key: '__divider-external__' },
{ label: 'Desa Mandiri Keys', icon: TbKey, key: 'api-keys' },
]
function DevPage() {
@@ -200,7 +203,8 @@ function DevPage() {
<AppShell.Section grow>
<Stack gap={4}>
{navItems.map((item) => {
const Icon = item.icon
if (item.divider) return <Divider key={item.key} my={4} />
const Icon = item.icon!
if (collapsed) {
return (
<Tooltip key={item.key} label={item.label} position="right">
@@ -274,6 +278,7 @@ function DevPage() {
{active === 'activity-logs' && <ActivityLogsPanel />}
{active === 'database' && <DatabasePanel />}
{active === 'project' && <ProjectPanel />}
{active === 'api-keys' && <ApiKeysPanel />}
{active === 'settings' && <SettingsPanel />}
</Container>
</AppShell.Main>
@@ -1588,7 +1593,7 @@ function SettingsPanel() {
<Stack>
<Group justify="space-between">
<div>
<Title order={3}>Application Settings</Title>
<Title order={3}>App Config</Title>
<Text size="sm" c="dimmed">Manage the URL API and API Key for each application.</Text>
</div>
<Button leftSection={<TbApps size={16} />} onClick={openAdd}>Add App</Button>
@@ -1711,6 +1716,221 @@ function SettingsPanel() {
)
}
// ─── API Keys Panel ────────────────────────────────────────────────────────────
interface ApiKeyItem {
id: string
name: string
key: string
isActive: boolean
createdAt: string
updatedAt: string
}
function ApiKeysPanel() {
const qc = useQueryClient()
const [createOpened, { open: openCreate, close: closeCreate }] = useDisclosure(false)
const [newKeyName, setNewKeyName] = useState('')
const [createdKey, setCreatedKey] = useState<string | null>(null)
const [keyCopied, setKeyCopied] = useState(false)
const [revealedOpened, { open: openRevealed, close: closeRevealed }] = useDisclosure(false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'api-keys'],
queryFn: () => fetch('/api/admin/api-keys', { credentials: 'include' }).then((r) => r.json()),
})
const keys: ApiKeyItem[] = data?.keys ?? []
const createMutation = useMutation({
mutationFn: async (name: string) => {
const r = await fetch('/api/admin/api-keys', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const json = await r.json()
if (!r.ok) throw new Error(json.error ?? 'Gagal membuat API key')
return json
},
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ['admin', 'api-keys'] })
closeCreate()
setNewKeyName('')
if (res.key?.key) {
setCreatedKey(res.key.key)
setKeyCopied(false)
openRevealed()
}
},
onError: (err: Error) => notifications.show({ color: 'red', title: 'Gagal', message: err.message }),
})
const toggleMutation = useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
fetch(`/api/admin/api-keys/${id}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive }),
}).then((r) => r.json()),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'api-keys'] }),
})
const deleteMutation = useMutation({
mutationFn: (id: string) =>
fetch(`/api/admin/api-keys/${id}`, { method: 'DELETE', credentials: 'include' }).then((r) => r.json()),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'api-keys'] }),
})
const confirmDelete = (key: ApiKeyItem) => {
modals.openConfirmModal({
title: 'Hapus API Key',
children: (
<Text size="sm">
Yakin hapus key <strong>{key.name}</strong>? Semua klien yang menggunakan key ini akan kehilangan akses.
</Text>
),
labels: { confirm: 'Hapus', cancel: 'Batal' },
confirmProps: { color: 'red' },
onConfirm: () => deleteMutation.mutate(key.id),
})
}
return (
<Stack>
<Group justify="space-between">
<div>
<Title order={3}>Desa Mandiri Keys</Title>
<Text size="sm" c="dimmed">Manage access tokens for the Desa Mandiri system</Text>
</div>
<Button leftSection={<TbKey size={14} />} onClick={openCreate}>
Buat Key Baru
</Button>
</Group>
{isLoading ? (
<Center><Loader /></Center>
) : keys.length === 0 ? (
<Paper withBorder p="xl" radius="md">
<Center>
<Stack align="center" gap="xs">
<ThemeIcon size="xl" variant="light" color="gray"><TbKey size={24} /></ThemeIcon>
<Text c="dimmed" size="sm">Belum ada API key. Buat key pertama untuk mengakses API.</Text>
</Stack>
</Center>
</Paper>
) : (
<Table.ScrollContainer minWidth={600}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Nama</Table.Th>
<Table.Th>Key</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Dibuat</Table.Th>
<Table.Th w={100}>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{keys.map((k) => (
<Table.Tr key={k.id}>
<Table.Td fw={500}>{k.name}</Table.Td>
<Table.Td>
<Text size="xs" ff="monospace" c="dimmed">{k.key}</Text>
</Table.Td>
<Table.Td>
<Badge color={k.isActive ? 'green' : 'gray'} variant="light">
{k.isActive ? 'Aktif' : 'Nonaktif'}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{new Date(k.createdAt).toLocaleDateString('id-ID')}</Text>
</Table.Td>
<Table.Td>
<Group gap={4}>
<Tooltip label={k.isActive ? 'Nonaktifkan' : 'Aktifkan'}>
<ActionIcon
variant="subtle"
color={k.isActive ? 'orange' : 'green'}
size="sm"
loading={toggleMutation.isPending}
onClick={() => toggleMutation.mutate({ id: k.id, isActive: !k.isActive })}
>
<TbRefresh size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="Hapus">
<ActionIcon
variant="subtle"
color="red"
size="sm"
loading={deleteMutation.isPending}
onClick={() => confirmDelete(k)}
>
<TbTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
{/* ── Create Key Modal ── */}
<Modal opened={createOpened} onClose={closeCreate} title="Buat API Key Baru" radius="md">
<Stack gap="sm">
<TextInput
label="Nama Key"
description="Label untuk mengidentifikasi key ini (misal: Jenna Mobile App)"
placeholder="Nama key..."
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
required
/>
<Group justify="flex-end" mt="xs">
<Button variant="subtle" color="gray" onClick={closeCreate}>Batal</Button>
<Button
loading={createMutation.isPending}
disabled={!newKeyName.trim()}
onClick={() => createMutation.mutate(newKeyName)}
>
Buat Key
</Button>
</Group>
</Stack>
</Modal>
{/* ── Reveal Key Modal ── */}
<Modal opened={revealedOpened} onClose={closeRevealed} title="API Key Berhasil Dibuat" radius="md">
<Stack gap="sm">
<Text size="sm" c="dimmed">Salin key ini sekarang key tidak akan ditampilkan kembali setelah dialog ini ditutup.</Text>
<Box
p="sm"
style={{ background: 'var(--mantine-color-dark-6)', borderRadius: 6, fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all' }}
>
{createdKey}
</Box>
<Group justify="flex-end">
<Button
variant="light"
color={keyCopied ? 'green' : 'blue'}
leftSection={<TbCopy size={14} />}
onClick={() => { if (createdKey) { navigator.clipboard.writeText(createdKey); setKeyCopied(true) } }}
>
{keyCopied ? 'Tersalin!' : 'Salin Key'}
</Button>
<Button variant="subtle" color="gray" onClick={() => { closeRevealed(); setCreatedKey(null) }}>Tutup</Button>
</Group>
</Stack>
</Modal>
</Stack>
)
}
void TbFileText
void TbCode
void TbUser