amalia/30-apr-26 #17
8
bun.lock
8
bun.lock
@@ -8,9 +8,9 @@
|
||||
"@elysiajs/eden": "^1.4.9",
|
||||
"@elysiajs/html": "^1.4.0",
|
||||
"@elysiajs/swagger": "^1.3.1",
|
||||
"@mantine/charts": "^9.0.0",
|
||||
"@mantine/charts": "^8.3.0",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/dates": "^9.1.1",
|
||||
"@mantine/dates": "^8.3.0",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
@@ -202,11 +202,11 @@
|
||||
|
||||
"@kitajs/ts-html-plugin": ["@kitajs/ts-html-plugin@4.1.4", "", { "dependencies": { "chalk": "^5.6.2", "tslib": "^2.8.1", "yargs": "^18.0.0" }, "peerDependencies": { "@kitajs/html": "^4.2.10", "typescript": "^5.9.3" }, "bin": { "xss-scan": "dist/cli.js", "ts-html-plugin": "dist/cli.js" } }, "sha512-xK5mNrhnIy73kJFKx5yVGChJyWFRGmIaE0sjlVxVYllk5dyaEYVCrIh1N8AfnseEHka8gAqzPGW95HlkhDvnJA=="],
|
||||
|
||||
"@mantine/charts": ["@mantine/charts@9.0.0", "", { "peerDependencies": { "@mantine/core": "9.0.0", "@mantine/hooks": "9.0.0", "react": "^19.2.0", "react-dom": "^19.2.0", "recharts": ">=3.2.1" } }, "sha512-TnbjiT2tXZDAQWZrv/+Xu3JKYjPiTfO5jSIbcwnxZSVtLI+PIxA7zrSps+it/Nx3ch8GHpDizJ7UArC0UfmNkQ=="],
|
||||
"@mantine/charts": ["@mantine/charts@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", "recharts": ">=2.13.3" } }, "sha512-oudif3EUH7Nb9DPm0abAPxpFYDWWjR3k2S5ll0/CcB+pJzlhwaBG19QwpOJaRA6VAvAXDDKOXCO4mi9XCEN78g=="],
|
||||
|
||||
"@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/dates": ["@mantine/dates@8.3.18", "", { "dependencies": { "clsx": "^2.1.1" }, "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "dayjs": ">=1.0.0", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-FHx5teJOhupI0gO2o5evtVYQEdqOjayOkLRhEQfB5Nc5DvcysfPfmNILGkc1Nrp9ZQeQWKLT9qr+CkcCXwHOaw=="],
|
||||
|
||||
"@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="],
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bun-react-template",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -27,9 +27,9 @@
|
||||
"@elysiajs/eden": "^1.4.9",
|
||||
"@elysiajs/html": "^1.4.0",
|
||||
"@elysiajs/swagger": "^1.3.1",
|
||||
"@mantine/charts": "^9.0.0",
|
||||
"@mantine/charts": "^8.3.0",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/dates": "^9.1.1",
|
||||
"@mantine/dates": "^8.3.0",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- AlterTable: tambah urlApi dan apiKey ke App
|
||||
ALTER TABLE "App" ADD COLUMN "urlApi" TEXT;
|
||||
ALTER TABLE "App" ADD COLUMN "apiKey" TEXT;
|
||||
|
||||
-- DataMigration: pindahkan nilai dari app_config ke App sebelum drop
|
||||
UPDATE "App"
|
||||
SET "urlApi" = (SELECT value FROM app_config WHERE key = 'URL_API_DESA_PLUS')
|
||||
WHERE id = 'desa-plus';
|
||||
|
||||
UPDATE "App"
|
||||
SET "apiKey" = (SELECT value FROM app_config WHERE key = 'API_KEY_DESA_PLUS')
|
||||
WHERE id = 'desa-plus';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "app_config";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "App" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "App" ADD COLUMN "clientApiKey" TEXT;
|
||||
CREATE UNIQUE INDEX "App_clientApiKey_key" ON "App"("clientApiKey");
|
||||
@@ -72,16 +72,19 @@ model Session {
|
||||
}
|
||||
|
||||
model App {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
version String?
|
||||
minVersion String?
|
||||
maintenance Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
version String?
|
||||
minVersion String?
|
||||
maintenance Boolean @default(false)
|
||||
active Boolean @default(true)
|
||||
urlApi String?
|
||||
apiKey String?
|
||||
clientApiKey String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
bugs Bug[]
|
||||
|
||||
}
|
||||
|
||||
model Log {
|
||||
@@ -146,14 +149,6 @@ model BugLog {
|
||||
@@map("bug_log")
|
||||
}
|
||||
|
||||
model AppConfig {
|
||||
key String @id
|
||||
value String
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("app_config")
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
154
src/app.ts
154
src/app.ts
@@ -36,13 +36,6 @@ async function checkAuth(request: Request): Promise<AuthResult | null> {
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = request.headers.get('x-api-key')
|
||||
if (apiKey && apiKey === env.API_KEY) {
|
||||
const developer = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||
if (!developer) return null
|
||||
return { actingUserId: developer.id, reporterUserId: null, isApiKey: true }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -352,7 +345,8 @@ export function createApp() {
|
||||
// ─── Apps API ──────────────────────────────────────
|
||||
.get('/api/apps', async ({ query }) => {
|
||||
const search = query.search || ''
|
||||
const where: any = {}
|
||||
const all = query.all === 'true'
|
||||
const where: any = all ? {} : { active: true }
|
||||
if (search) {
|
||||
where.name = { contains: search, mode: 'insensitive' }
|
||||
}
|
||||
@@ -372,15 +366,19 @@ export function createApp() {
|
||||
status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active',
|
||||
errors: app.bugs.length,
|
||||
version: app.version ?? '-',
|
||||
minVersion: app.minVersion,
|
||||
maintenance: app.maintenance,
|
||||
active: app.active,
|
||||
urlApi: app.urlApi,
|
||||
hasClientApiKey: !!app.clientApiKey,
|
||||
}))
|
||||
}, {
|
||||
query: t.Object({
|
||||
search: t.Optional(t.String({ description: 'Filter berdasarkan nama aplikasi' })),
|
||||
search: t.Optional(t.String()),
|
||||
all: t.Optional(t.String()),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'List Apps',
|
||||
description: 'Mengembalikan semua aplikasi yang dimonitor beserta status (active/warning/error), jumlah bug OPEN, versi, dan mode maintenance.',
|
||||
tags: ['Apps'],
|
||||
},
|
||||
})
|
||||
@@ -407,6 +405,7 @@ export function createApp() {
|
||||
version: app.version ?? '-',
|
||||
minVersion: app.minVersion,
|
||||
maintenance: app.maintenance,
|
||||
urlApi: app.urlApi,
|
||||
totalBugs: app._count.bugs,
|
||||
}
|
||||
}, {
|
||||
@@ -420,6 +419,86 @@ export function createApp() {
|
||||
},
|
||||
})
|
||||
|
||||
.post('/api/apps', async ({ body, request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const { id, name, version, minVersion, maintenance, urlApi, apiKey } = body as any
|
||||
if (!id || !name) { set.status = 400; return { error: 'id and name are required' } }
|
||||
const existing = await prisma.app.findUnique({ where: { id } })
|
||||
if (existing) { set.status = 409; return { error: 'App with this ID already exists' } }
|
||||
const app = await prisma.app.create({
|
||||
data: { id, name, version: version || null, minVersion: minVersion || null, maintenance: maintenance ?? false, urlApi: urlApi || null, apiKey: apiKey || null },
|
||||
})
|
||||
await createSystemLog(auth.userId, 'CREATE', `Created app: ${app.id}`)
|
||||
return { id: app.id, name: app.name, version: app.version, minVersion: app.minVersion, maintenance: app.maintenance, urlApi: app.urlApi }
|
||||
}, {
|
||||
detail: { summary: 'Create App', tags: ['Apps'] },
|
||||
})
|
||||
|
||||
.patch('/api/apps/:appId', async ({ params: { appId }, body, 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: appId } })
|
||||
if (!app) { set.status = 404; return { error: 'App not found' } }
|
||||
const { name, version, minVersion, maintenance, urlApi, apiKey } = body as any
|
||||
const updated = await prisma.app.update({
|
||||
where: { id: appId },
|
||||
data: {
|
||||
...(name !== undefined && { name }),
|
||||
...(version !== undefined && { version: version || null }),
|
||||
...(minVersion !== undefined && { minVersion: minVersion || null }),
|
||||
...(maintenance !== undefined && { maintenance }),
|
||||
...(urlApi !== undefined && { urlApi: urlApi || null }),
|
||||
...(apiKey !== undefined && { apiKey: apiKey || null }),
|
||||
},
|
||||
})
|
||||
await createSystemLog(auth.userId, 'UPDATE', `Updated app: ${appId}`)
|
||||
return { id: updated.id, name: updated.name, version: updated.version, minVersion: updated.minVersion, maintenance: updated.maintenance, urlApi: updated.urlApi }
|
||||
}, {
|
||||
params: t.Object({ appId: t.String() }),
|
||||
detail: { summary: 'Update App', tags: ['Apps'] },
|
||||
})
|
||||
|
||||
.delete('/api/apps/:appId', async ({ params: { appId }, 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: appId } })
|
||||
if (!app) { set.status = 404; return { error: 'App not found' } }
|
||||
await prisma.app.update({ where: { id: appId }, data: { active: false } })
|
||||
await createSystemLog(auth.userId, 'UPDATE', `Deactivated app: ${appId}`)
|
||||
return { success: true }
|
||||
}, {
|
||||
params: t.Object({ appId: t.String() }),
|
||||
detail: { summary: 'Deactivate App', tags: ['Apps'] },
|
||||
})
|
||||
|
||||
.post('/api/apps/:appId/activate', async ({ params: { appId }, 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: appId } })
|
||||
if (!app) { set.status = 404; return { error: 'App not found' } }
|
||||
await prisma.app.update({ where: { id: appId }, data: { active: true } })
|
||||
await createSystemLog(auth.userId, 'UPDATE', `Activated app: ${appId}`)
|
||||
return { success: true }
|
||||
}, {
|
||||
params: t.Object({ appId: t.String() }),
|
||||
detail: { summary: 'Activate App', tags: ['Apps'] },
|
||||
})
|
||||
|
||||
.post('/api/apps/:appId/generate-key', async ({ params: { appId }, 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: appId } })
|
||||
if (!app) { set.status = 404; return { error: 'App not found' } }
|
||||
const key = `mapp_${Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('hex')}`
|
||||
await prisma.app.update({ where: { id: appId }, data: { clientApiKey: key } })
|
||||
await createSystemLog(auth.userId, 'UPDATE', `Generated client API key for app: ${appId}`)
|
||||
return { clientApiKey: key }
|
||||
}, {
|
||||
params: t.Object({ appId: t.String() }),
|
||||
detail: { summary: 'Generate Client API Key', tags: ['Apps'] },
|
||||
})
|
||||
|
||||
// ─── Logs API ──────────────────────────────────────
|
||||
.get('/api/logs', async ({ query }) => {
|
||||
const page = Number(query.page) || 1
|
||||
@@ -783,12 +862,23 @@ export function createApp() {
|
||||
})
|
||||
|
||||
.post('/api/bugs', async ({ body, request, set }) => {
|
||||
const auth = await checkAuth(request)
|
||||
let auth = await checkAuth(request)
|
||||
if (!auth) {
|
||||
const xKey = request.headers.get('x-api-key')
|
||||
const appId = (body as any).app
|
||||
if (xKey && appId) {
|
||||
const app = await prisma.app.findUnique({ where: { id: appId, active: true } })
|
||||
if (app?.clientApiKey && app.clientApiKey === xKey) {
|
||||
const developer = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||
if (developer) auth = { actingUserId: developer.id, reporterUserId: null, isApiKey: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!auth) {
|
||||
set.status = 401
|
||||
return { error: 'Unauthorized: sertakan session cookie atau header X-API-Key' }
|
||||
return { error: 'Unauthorized: provide session cookie or valid X-API-Key' }
|
||||
}
|
||||
const { actingUserId, reporterUserId, isApiKey } = auth
|
||||
const { actingUserId, reporterUserId } = auth
|
||||
|
||||
const bug = await prisma.bug.create({
|
||||
data: {
|
||||
@@ -1553,47 +1643,17 @@ export function createApp() {
|
||||
return { sessions: result, summary: { totalSessions: result.length, activeSessions: active, expiredSessions: expired, onlineUsers: onlineIds.size, byRole } }
|
||||
})
|
||||
|
||||
// ─── App Config ────────────────────────────────────────────────────────────
|
||||
|
||||
.get('/api/admin/config', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const configs = await prisma.appConfig.findMany({ orderBy: { key: 'asc' } })
|
||||
return { configs: configs.map((c) => ({ key: c.key, value: c.value, updatedAt: c.updatedAt.toISOString() })) }
|
||||
}, {
|
||||
detail: { summary: 'Get App Config', tags: ['Admin'] },
|
||||
})
|
||||
|
||||
.put('/api/admin/config', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const body = await request.json() as { key: string; value: string }
|
||||
if (!body.key || typeof body.value !== 'string') { set.status = 400; return { error: 'key and value required' } }
|
||||
const config = await prisma.appConfig.upsert({
|
||||
where: { key: body.key },
|
||||
update: { value: body.value },
|
||||
create: { key: body.key, value: body.value },
|
||||
})
|
||||
await createSystemLog(auth.userId, 'UPDATE', `Updated app config: ${body.key}`)
|
||||
return { key: config.key, value: config.value, updatedAt: config.updatedAt.toISOString() }
|
||||
}, {
|
||||
detail: { summary: 'Update App Config', tags: ['Admin'] },
|
||||
})
|
||||
|
||||
// ─── Desa Plus Proxy ───────────────────────────────────────────────────────
|
||||
|
||||
.all('/api/proxy/desa-plus/*', async ({ request, set }) => {
|
||||
const [baseConfig, apiKeyConfig] = await Promise.all([
|
||||
prisma.appConfig.findUnique({ where: { key: 'URL_API_DESA_PLUS' } }),
|
||||
prisma.appConfig.findUnique({ where: { key: 'API_KEY_DESA_PLUS' } }),
|
||||
])
|
||||
if (!baseConfig?.value) { set.status = 503; return { error: 'URL_API_DESA_PLUS belum dikonfigurasi. Set di /dev → Settings.' } }
|
||||
const base = baseConfig.value.replace(/\/$/, '')
|
||||
const app = await prisma.app.findUnique({ where: { id: 'desa-plus' } })
|
||||
if (!app?.urlApi) { set.status = 503; return { error: 'urlApi belum dikonfigurasi untuk app desa-plus.' } }
|
||||
const base = app.urlApi.replace(/\/$/, '')
|
||||
const url = new URL(request.url)
|
||||
const upstream = `${base}${url.pathname.replace('/api/proxy/desa-plus', '')}${url.search}`
|
||||
const headers = new Headers(request.headers)
|
||||
headers.delete('host')
|
||||
if (apiKeyConfig?.value) headers.set('X-API-Key', apiKeyConfig.value)
|
||||
if (app.apiKey) headers.set('X-API-Key', app.apiKey)
|
||||
try {
|
||||
const res = await fetch(upstream, { method: request.method, headers, body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined })
|
||||
const contentType = res.headers.get('content-type') ?? 'application/json'
|
||||
|
||||
@@ -186,14 +186,18 @@ function AppLogsPage() {
|
||||
<ThemeIcon variant="transparent" color="gray" size="sm">
|
||||
<TbCalendar size={14} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" fw={700}>
|
||||
{log.createdAt.split(' ').slice(1).join(' ')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{log.createdAt.split(' ')[0]}
|
||||
</Text>
|
||||
</Stack>
|
||||
{log.createdAt.endsWith('lalu') ? (
|
||||
<Text size="xs" fw={700}>{log.createdAt}</Text>
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" fw={700}>
|
||||
{log.createdAt.split(' ').slice(1).join(' ')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{log.createdAt.split(' ')[0]}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
@@ -52,11 +53,13 @@ import {
|
||||
TbApps,
|
||||
TbBug,
|
||||
TbChevronRight,
|
||||
TbCopy,
|
||||
TbCircleFilled,
|
||||
TbCode,
|
||||
TbDatabase,
|
||||
TbDots,
|
||||
TbFileText,
|
||||
TbKey,
|
||||
TbLayoutDashboard,
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
TbLayoutSidebarLeftExpand,
|
||||
@@ -1462,137 +1465,252 @@ function StaticFlowPanel({ graph, flowKey }: { graph: { nodes: Node[]; edges: Ed
|
||||
|
||||
// ─── Settings Panel ────────────────────────────────────────────────────────────
|
||||
|
||||
interface AppConfigEntry { key: string; value: string; updatedAt: string }
|
||||
|
||||
const CONFIG_DEFINITIONS: { key: string; label: string; description: string; placeholder: string; secret?: boolean }[] = [
|
||||
{
|
||||
key: 'URL_API_DESA_PLUS',
|
||||
label: 'URL API Desa Plus',
|
||||
description: 'Base URL untuk API eksternal Desa Plus. Semua request dari frontend akan diproxy melalui server ke URL ini.',
|
||||
placeholder: 'https://api.desa-plus.example.com',
|
||||
},
|
||||
{
|
||||
key: 'API_KEY_DESA_PLUS',
|
||||
label: 'API Key Desa Plus',
|
||||
description: 'API key untuk autentikasi ke API Desa Plus. Dikirim otomatis sebagai header X-API-Key pada setiap request proxy.',
|
||||
placeholder: 'your-secret-api-key',
|
||||
secret: true,
|
||||
},
|
||||
]
|
||||
interface AppEntry {
|
||||
id: string
|
||||
name: string
|
||||
urlApi: string | null
|
||||
status: string
|
||||
active: boolean
|
||||
hasClientApiKey: boolean
|
||||
}
|
||||
|
||||
function SettingsPanel() {
|
||||
const qc = useQueryClient()
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'config'],
|
||||
queryFn: () => fetch('/api/admin/config', { credentials: 'include' }).then((r) => r.json()),
|
||||
queryKey: ['apps'],
|
||||
queryFn: () => fetch('/api/apps?all=true', { credentials: 'include' }).then((r) => r.json()),
|
||||
})
|
||||
const apps: AppEntry[] = Array.isArray(data) ? data : []
|
||||
|
||||
// ── Add App modal ──
|
||||
const [addOpened, { open: openAdd, close: closeAdd }] = useDisclosure(false)
|
||||
const [newApp, setNewApp] = useState({ id: '', name: '', urlApi: '', apiKey: '' })
|
||||
|
||||
// ── API Config modal ──
|
||||
const [apiOpened, { open: openApi, close: closeApi }] = useDisclosure(false)
|
||||
const [apiTarget, setApiTarget] = useState<AppEntry | null>(null)
|
||||
const [apiForm, setApiForm] = useState({ urlApi: '', apiKey: '' })
|
||||
|
||||
// ── Generated key modal ──
|
||||
const [keyOpened, { open: openKey, close: closeKey }] = useDisclosure(false)
|
||||
const [generatedKey, setGeneratedKey] = useState('')
|
||||
const [keyCopied, setKeyCopied] = useState(false)
|
||||
|
||||
const openApiModal = (app: AppEntry) => {
|
||||
setApiTarget(app)
|
||||
setApiForm({ urlApi: app.urlApi ?? '', apiKey: '' })
|
||||
openApi()
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: typeof newApp) => fetch('/api/apps', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((r) => r.json()),
|
||||
onSuccess: (res) => {
|
||||
if (res.error) { notifications.show({ color: 'red', title: 'Error', message: res.error }); return }
|
||||
qc.invalidateQueries({ queryKey: ['apps'] })
|
||||
closeAdd()
|
||||
setNewApp({ id: '', name: '', urlApi: '', apiKey: '' })
|
||||
notifications.show({ color: 'green', title: 'Success', message: 'Application added successfully.' })
|
||||
},
|
||||
})
|
||||
|
||||
const configs: AppConfigEntry[] = data?.configs ?? []
|
||||
const apiMutation = useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: { urlApi: string; apiKey?: string } }) => fetch(`/api/apps/${id}`, {
|
||||
method: 'PATCH', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((r) => r.json()),
|
||||
onSuccess: (res) => {
|
||||
if (res.error) { notifications.show({ color: 'red', title: 'Error', message: res.error }); return }
|
||||
qc.invalidateQueries({ queryKey: ['apps'] })
|
||||
closeApi()
|
||||
notifications.show({ color: 'green', title: 'Success', message: 'API configuration updated.' })
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const initial: Record<string, string> = {}
|
||||
for (const def of CONFIG_DEFINITIONS) {
|
||||
const existing = configs.find((c) => c.key === def.key)
|
||||
initial[def.key] = existing?.value ?? ''
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => fetch(`/api/apps/${id}`, { method: 'DELETE', credentials: 'include' }).then((r) => r.json()),
|
||||
onSuccess: (res) => {
|
||||
if (res.error) { notifications.show({ color: 'red', title: 'Error', message: res.error }); return }
|
||||
qc.invalidateQueries({ queryKey: ['apps'] })
|
||||
notifications.show({ color: 'green', title: 'Success', message: 'Application deactivated.' })
|
||||
},
|
||||
})
|
||||
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: (id: string) => fetch(`/api/apps/${id}/activate`, { method: 'POST', credentials: 'include' }).then((r) => r.json()),
|
||||
onSuccess: (res) => {
|
||||
if (res.error) { notifications.show({ color: 'red', title: 'Error', message: res.error }); return }
|
||||
qc.invalidateQueries({ queryKey: ['apps'] })
|
||||
notifications.show({ color: 'green', title: 'Success', message: 'Application activated.' })
|
||||
},
|
||||
})
|
||||
|
||||
const generateKeyMutation = useMutation({
|
||||
mutationFn: (id: string) => fetch(`/api/apps/${id}/generate-key`, { method: 'POST', credentials: 'include' }).then((r) => r.json()),
|
||||
onSuccess: (res) => {
|
||||
if (res.error) { notifications.show({ color: 'red', title: 'Error', message: res.error }); return }
|
||||
qc.invalidateQueries({ queryKey: ['apps'] })
|
||||
setGeneratedKey(res.clientApiKey)
|
||||
setKeyCopied(false)
|
||||
openKey()
|
||||
},
|
||||
})
|
||||
|
||||
const confirmGenerateKey = (app: AppEntry) => {
|
||||
if (app.hasClientApiKey) {
|
||||
modals.openConfirmModal({
|
||||
title: 'Regenerate Client API Key',
|
||||
children: <Text size="sm">This will invalidate the existing key for <strong>{app.name}</strong>. Any mobile apps using the old key will stop working.</Text>,
|
||||
labels: { confirm: 'Regenerate', cancel: 'Cancel' },
|
||||
confirmProps: { color: 'orange' },
|
||||
onConfirm: () => generateKeyMutation.mutate(app.id),
|
||||
})
|
||||
} else {
|
||||
generateKeyMutation.mutate(app.id)
|
||||
}
|
||||
setValues(initial)
|
||||
}, [configs])
|
||||
}
|
||||
|
||||
const saveAllMutation = useMutation({
|
||||
mutationFn: async (vals: Record<string, string>) => {
|
||||
await Promise.all(
|
||||
CONFIG_DEFINITIONS.map((def) =>
|
||||
fetch('/api/admin/config', {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: def.key, value: vals[def.key] ?? '' }),
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'config'] })
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
notifications.show({ color: 'green', title: 'Tersimpan', message: 'Konfigurasi berhasil disimpan.' })
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({ color: 'red', title: 'Gagal', message: 'Terjadi kesalahan saat menyimpan konfigurasi.' })
|
||||
},
|
||||
const confirmDeactivate = (app: AppEntry) => modals.openConfirmModal({
|
||||
title: 'Deactivate Application',
|
||||
children: <Text size="sm">Are you sure you want to deactivate <strong>{app.name}</strong>? It will no longer appear in the monitoring list.</Text>,
|
||||
labels: { confirm: 'Deactivate', cancel: 'Cancel' },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: () => deleteMutation.mutate(app.id),
|
||||
})
|
||||
|
||||
const hasUnconfigured = CONFIG_DEFINITIONS.some((def) => !configs.find((c) => c.key === def.key))
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={3}>Settings</Title>
|
||||
<Text size="sm" c="dimmed">Konfigurasi runtime — perubahan langsung berlaku tanpa rebuild atau redeploy.</Text>
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Title order={3}>Application Settings</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>
|
||||
</Group>
|
||||
|
||||
{isLoading ? <Center><Loader /></Center> : (
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stack gap="md">
|
||||
{CONFIG_DEFINITIONS.map((def) => {
|
||||
const existing = configs.find((c) => c.key === def.key)
|
||||
return (
|
||||
<Stack key={def.key} gap={4}>
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Text fw={600} size="sm">{def.label}</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">{def.key}</Text>
|
||||
</div>
|
||||
{existing
|
||||
? <Text size="xs" c="dimmed">Diupdate {new Date(existing.updatedAt).toLocaleString('id-ID')}</Text>
|
||||
: <Badge color="orange" variant="light" size="xs">Belum dikonfigurasi</Badge>
|
||||
}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{def.description}</Text>
|
||||
<input
|
||||
type={def.secret ? 'password' : 'text'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
value={values[def.key] ?? ''}
|
||||
onChange={(e) => setValues((prev) => ({ ...prev, [def.key]: e.target.value }))}
|
||||
placeholder={def.placeholder}
|
||||
/>
|
||||
</Stack>
|
||||
)
|
||||
})}
|
||||
|
||||
{hasUnconfigured && (
|
||||
<Text size="xs" c="orange">Beberapa konfigurasi belum diisi — data tidak akan ter-load sampai disimpan.</Text>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color={saved ? 'green' : 'blue'}
|
||||
loading={saveAllMutation.isPending}
|
||||
onClick={() => saveAllMutation.mutate(values)}
|
||||
>
|
||||
{saved ? 'Tersimpan' : 'Simpan Semua'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
{isLoading ? <Center py="xl"><Loader /></Center> : apps.length === 0 ? (
|
||||
<Center py="xl"><Text c="dimmed">No applications found. Click "Add App" to get started.</Text></Center>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<Paper key={app.id} withBorder p="md" radius="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<ThemeIcon size="lg" variant="light" color={app.active ? 'green' : 'red'} radius="md">
|
||||
<TbApps size={18} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm">{app.name}</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">{app.id}</Text>
|
||||
{!app.active && <Badge color="red" variant="light" size="xs">Inactive</Badge>}
|
||||
</Group>
|
||||
<Group gap="xs" mt={2}>
|
||||
{app.urlApi
|
||||
? <Text size="xs" c="dimmed">{app.urlApi}</Text>
|
||||
: <Badge color="orange" variant="dot" size="xs">URL API not set</Badge>
|
||||
}
|
||||
{app.hasClientApiKey
|
||||
? <Badge color="teal" variant="dot" size="xs">Client key set</Badge>
|
||||
: <Badge color="red" variant="dot" size="xs">No client key</Badge>
|
||||
}
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{app.active ? (
|
||||
<>
|
||||
<Button size="xs" variant="light" color="teal" leftSection={<TbServer size={14} />} onClick={() => openApiModal(app)}>
|
||||
Edit API Config
|
||||
</Button>
|
||||
<Button size="xs" variant="light" color="violet" leftSection={<TbKey size={14} />} onClick={() => confirmGenerateKey(app)} loading={generateKeyMutation.isPending}>
|
||||
{app.hasClientApiKey ? 'Regenerate Key' : 'Generate Key'}
|
||||
</Button>
|
||||
<Button size="xs" variant="light" color="red" onClick={() => confirmDeactivate(app)} loading={deleteMutation.isPending}>
|
||||
Deactivate
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="xs" variant="light" color="green" onClick={() => activateMutation.mutate(app.id)} loading={activateMutation.isPending}>
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ── Add App Modal ── */}
|
||||
<Modal opened={addOpened} onClose={closeAdd} title="Add Application" radius="md">
|
||||
<Stack gap="sm">
|
||||
<TextInput label="App ID" description="Unique slug used as identifier (e.g. desa-plus)" placeholder="my-app" value={newApp.id} onChange={(e) => setNewApp((p) => ({ ...p, id: e.target.value }))} required />
|
||||
<TextInput label="Name" placeholder="My Application" value={newApp.name} onChange={(e) => setNewApp((p) => ({ ...p, name: e.target.value }))} required />
|
||||
<TextInput label="URL API" placeholder="https://api.example.com" value={newApp.urlApi} onChange={(e) => setNewApp((p) => ({ ...p, urlApi: e.target.value }))} />
|
||||
<TextInput label="API Key" placeholder="secret-key" type="password" value={newApp.apiKey} onChange={(e) => setNewApp((p) => ({ ...p, apiKey: e.target.value }))} />
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="subtle" color="gray" onClick={closeAdd}>Cancel</Button>
|
||||
<Button loading={createMutation.isPending} disabled={!newApp.id || !newApp.name} onClick={() => createMutation.mutate(newApp)}>Add</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* ── Generated Key Modal ── */}
|
||||
<Modal opened={keyOpened} onClose={closeKey} title="Client API Key Generated" radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">Copy this key now — it will not be shown again after you close this dialog.</Text>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{ background: 'var(--mantine-color-dark-6)', borderRadius: 6, fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all' }}
|
||||
>
|
||||
{generatedKey}
|
||||
</Box>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
color={keyCopied ? 'green' : 'blue'}
|
||||
leftSection={<TbCopy size={14} />}
|
||||
onClick={() => { navigator.clipboard.writeText(generatedKey); setKeyCopied(true) }}
|
||||
>
|
||||
{keyCopied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</Button>
|
||||
<Button variant="subtle" color="gray" onClick={closeKey}>Close</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* ── API Config Modal ── */}
|
||||
<Modal opened={apiOpened} onClose={closeApi} title={`API Config — ${apiTarget?.name}`} radius="md">
|
||||
<Stack gap="sm">
|
||||
<TextInput label="URL API" description="Base URL for proxying requests to the external API." placeholder="https://api.example.com" value={apiForm.urlApi} onChange={(e) => setApiForm((p) => ({ ...p, urlApi: e.target.value }))} />
|
||||
<TextInput label="API Key" description="Leave blank to keep the existing key unchanged." placeholder="Leave blank to keep unchanged" type="password" value={apiForm.apiKey} onChange={(e) => setApiForm((p) => ({ ...p, apiKey: e.target.value }))} />
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="subtle" color="gray" onClick={closeApi}>Cancel</Button>
|
||||
<Button
|
||||
loading={apiMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!apiTarget) return
|
||||
const body: any = { urlApi: apiForm.urlApi }
|
||||
if (apiForm.apiKey) body.apiKey = apiForm.apiKey
|
||||
apiMutation.mutate({ id: apiTarget.id, body })
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Unused imports fix ────────────────────────────────────────────────────────
|
||||
// Box, Container, Card, Modal, Paper, Select, SimpleGrid, Stack, Table, Text, ThemeIcon, Title, Tooltip — all used above
|
||||
// TbDots is used in OperatorsPanel menu
|
||||
void TbFileText
|
||||
void TbCode
|
||||
void TbUser
|
||||
|
||||
@@ -17,7 +17,6 @@ export const env = {
|
||||
GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'),
|
||||
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),
|
||||
SUPER_ADMIN_EMAILS: optional('SUPER_ADMIN_EMAIL', '').split(',').map(e => e.trim()).filter(Boolean),
|
||||
API_KEY: required('API_KEY'),
|
||||
MINIO_ENDPOINT: required('MINIO_ENDPOINT'),
|
||||
MINIO_PORT: parseInt(optional('MINIO_PORT', '443'), 10),
|
||||
MINIO_USE_SSL: optional('MINIO_USE_SSL', 'true') === 'true',
|
||||
|
||||
Reference in New Issue
Block a user