From 2cb061ea7fa58f30fc7ad086c9edf84a9dff8a88 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Wed, 13 May 2026 17:23:27 +0800 Subject: [PATCH] upd: setting api key sistem desa mandiri --- prisma/schema.prisma | 3 - src/app.ts | 67 +++++++++++ src/frontend/routes/dev.tsx | 219 +++++++++++++++++++++++++++++++++++- 3 files changed, 285 insertions(+), 4 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 45c497c..dbdc0ec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -149,6 +149,3 @@ model BugLog { @@map("bug_log") } - - - diff --git a/src/app.ts b/src/app.ts index 4a4d98d..d3cbf13 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 }) => { diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx index 7bb862c..84f6af5 100644 --- a/src/frontend/routes/dev.tsx +++ b/src/frontend/routes/dev.tsx @@ -77,7 +77,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', 'api-keys', 'settings'] as const export const Route = createFileRoute('/dev')({ validateSearch: (search: Record) => ({ @@ -117,6 +117,7 @@ const navItems = [ // { label: 'Activity Logs', icon: TbActivity, key: 'activity-logs' }, { label: 'Database', icon: TbDatabase, key: 'database' }, { label: 'Project', icon: TbSitemap, key: 'project' }, + { label: 'API Keys', icon: TbKey, key: 'api-keys' }, { label: 'Settings', icon: TbSettings, key: 'settings' }, ] @@ -274,6 +275,7 @@ function DevPage() { {active === 'activity-logs' && } {active === 'database' && } {active === 'project' && } + {active === 'api-keys' && } {active === 'settings' && } @@ -1711,6 +1713,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(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: ( + + Yakin hapus key {key.name}? Semua klien yang menggunakan key ini akan kehilangan akses. + + ), + labels: { confirm: 'Hapus', cancel: 'Batal' }, + confirmProps: { color: 'red' }, + onConfirm: () => deleteMutation.mutate(key.id), + }) + } + + return ( + + +
+ API Keys + Kelola API key untuk akses endpoint /api/ai/* +
+ +
+ + {isLoading ? ( +
+ ) : keys.length === 0 ? ( + +
+ + + Belum ada API key. Buat key pertama untuk mengakses API. + +
+
+ ) : ( + + + + + Nama + Key + Status + Dibuat + Aksi + + + + {keys.map((k) => ( + + {k.name} + + {k.key} + + + + {k.isActive ? 'Aktif' : 'Nonaktif'} + + + + {new Date(k.createdAt).toLocaleDateString('id-ID')} + + + + + toggleMutation.mutate({ id: k.id, isActive: !k.isActive })} + > + + + + + confirmDelete(k)} + > + + + + + + + ))} + +
+
+ )} + + {/* ── Create Key Modal ── */} + + + setNewKeyName(e.target.value)} + required + /> + + + + + + + + {/* ── Reveal Key Modal ── */} + + + Salin key ini sekarang — key tidak akan ditampilkan kembali setelah dialog ini ditutup. + + {createdKey} + + + + + + + +
+ ) +} + void TbFileText void TbCode void TbUser