feat: merge url_api & api_key to App, add application settings page

This commit is contained in:
2026-04-30 11:28:25 +08:00
parent e2ad6f9313
commit 4e9d5964ae
7 changed files with 274 additions and 169 deletions

View File

@@ -22,6 +22,7 @@ import {
Stack,
Table,
Text,
TextInput,
ThemeIcon,
Title,
Tooltip,
@@ -1462,137 +1463,190 @@ 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
}
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 : []
const configs: AppConfigEntry[] = data?.configs ?? []
// ── Add App modal ──
const [addOpened, { open: openAdd, close: closeAdd }] = useDisclosure(false)
const [newApp, setNewApp] = useState({ id: '', name: '', urlApi: '', apiKey: '' })
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 ?? ''
}
setValues(initial)
}, [configs])
// ── API Config modal ──
const [apiOpened, { open: openApi, close: closeApi }] = useDisclosure(false)
const [apiTarget, setApiTarget] = useState<AppEntry | null>(null)
const [apiForm, setApiForm] = useState({ urlApi: '', apiKey: '' })
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 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 hasUnconfigured = CONFIG_DEFINITIONS.some((def) => !configs.find((c) => c.key === def.key))
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.' })
},
})
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 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),
})
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>
}
</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="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>
{/* ── 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