feat: add show/hide and copy for API keys on dev page

- Display client key and server key on Settings app cards with toggle
  visibility and copy button
- Hide API keys table in Desa Mandiri Keys tab behind toggle + copy
- Add eye toggle to password inputs in Add App and Edit API Config
  modals
- Backend now returns apiKey and clientApiKey in apps list endpoint
This commit is contained in:
2026-05-22 17:10:36 +08:00
parent 4464f42da3
commit 7d879d1901
2 changed files with 152 additions and 18 deletions

View File

@@ -370,6 +370,8 @@ export function createApp() {
errors: app.bugs.length,
active: app.active,
urlApi: app.urlApi,
apiKey: app.apiKey ?? '',
clientApiKey: app.clientApiKey ?? '',
hasClientApiKey: !!app.clientApiKey,
}))
}, {

View File

@@ -8,6 +8,7 @@ import {
Button,
Card,
Center,
CopyButton,
Container,
Divider,
Group,
@@ -59,6 +60,8 @@ import {
TbCode,
TbDatabase,
TbDots,
TbEye,
TbEyeOff,
TbFileText,
TbKey,
TbLayoutDashboard,
@@ -1474,6 +1477,8 @@ interface AppEntry {
id: string
name: string
urlApi: string | null
apiKey: string
clientApiKey: string
status: string
active: boolean
hasClientApiKey: boolean
@@ -1501,10 +1506,24 @@ function SettingsPanel() {
const [keyOpened, { open: openKey, close: closeKey }] = useDisclosure(false)
const [generatedKey, setGeneratedKey] = useState('')
const [keyCopied, setKeyCopied] = useState(false)
const [generatedKeyVisible, setGeneratedKeyVisible] = useState(false)
const [addKeyVisible, setAddKeyVisible] = useState(false)
const [apiConfigKeyVisible, setApiConfigKeyVisible] = useState(false)
const [visibleAppKeys, setVisibleAppKeys] = useState<Set<string>>(new Set())
const toggleAppKeyVisibility = (appId: string) => {
setVisibleAppKeys((prev) => {
const next = new Set(prev)
if (next.has(appId)) next.delete(appId)
else next.add(appId)
return next
})
}
const openApiModal = (app: AppEntry) => {
setApiTarget(app)
setApiForm({ urlApi: app.urlApi ?? '', apiKey: '' })
setApiConfigKeyVisible(false)
openApi()
}
@@ -1562,6 +1581,7 @@ function SettingsPanel() {
qc.invalidateQueries({ queryKey: ['apps'] })
setGeneratedKey(res.clientApiKey)
setKeyCopied(false)
setGeneratedKeyVisible(false)
openKey()
},
})
@@ -1626,6 +1646,54 @@ function SettingsPanel() {
: <Badge color="red" variant="dot" size="xs">No client key</Badge>
}
</Group>
{app.clientApiKey && (
<>
<Text size="xs" fw={500} c="gray" mt={4}>Client Key (untuk mobile app mengakses monitoring):</Text>
<Group gap={4} wrap="nowrap">
<Text size="xs" ff="monospace" c="dimmed" style={{ userSelect: visibleAppKeys.has(app.id) ? 'text' : 'none' }}>
{visibleAppKeys.has(app.id) ? app.clientApiKey : '•'.repeat(32)}
</Text>
<Tooltip label={visibleAppKeys.has(app.id) ? 'Sembunyikan' : 'Tampilkan'}>
<ActionIcon variant="subtle" size="xs" color="gray" onClick={() => toggleAppKeyVisibility(app.id)}>
{visibleAppKeys.has(app.id) ? <TbEyeOff size={12} /> : <TbEye size={12} />}
</ActionIcon>
</Tooltip>
<CopyButton value={app.clientApiKey}>
{({ copy }) => (
<Tooltip label="Salin">
<ActionIcon variant="subtle" size="xs" color="gray" onClick={copy}>
<TbCopy size={12} />
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
</>
)}
{app.apiKey && (
<>
<Text size="xs" fw={500} c="gray" mt={4}>Server Key (untuk monitoring mengakses API external):</Text>
<Group gap={4} wrap="nowrap">
<Text size="xs" ff="monospace" c="dimmed" style={{ userSelect: visibleAppKeys.has(`server-${app.id}`) ? 'text' : 'none' }}>
{visibleAppKeys.has(`server-${app.id}`) ? app.apiKey : '•'.repeat(32)}
</Text>
<Tooltip label={visibleAppKeys.has(`server-${app.id}`) ? 'Sembunyikan' : 'Tampilkan'}>
<ActionIcon variant="subtle" size="xs" color="gray" onClick={() => toggleAppKeyVisibility(`server-${app.id}`)}>
{visibleAppKeys.has(`server-${app.id}`) ? <TbEyeOff size={12} /> : <TbEye size={12} />}
</ActionIcon>
</Tooltip>
<CopyButton value={app.apiKey}>
{({ copy }) => (
<Tooltip label="Salin">
<ActionIcon variant="subtle" size="xs" color="gray" onClick={copy}>
<TbCopy size={12} />
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
</>
)}
</Box>
</Group>
<Group gap="xs" wrap="nowrap">
@@ -1659,7 +1727,19 @@ function SettingsPanel() {
<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 }))} />
<TextInput
label="Server Key (API External)"
description="Key untuk monitoring mengakses API external app ini."
placeholder="secret-key"
type={addKeyVisible ? 'text' : 'password'}
value={newApp.apiKey}
onChange={(e) => setNewApp((p) => ({ ...p, apiKey: e.target.value }))}
rightSection={
<ActionIcon variant="subtle" size="sm" color="gray" onClick={() => setAddKeyVisible((v) => !v)}>
{addKeyVisible ? <TbEyeOff size={14} /> : <TbEye size={14} />}
</ActionIcon>
}
/>
<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>
@@ -1671,21 +1751,28 @@ function SettingsPanel() {
<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) }}
<Group gap={4} wrap="nowrap" align="center">
<Box
p="sm"
flex={1}
style={{ background: 'var(--mantine-color-dark-6)', borderRadius: 6, fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all', userSelect: generatedKeyVisible ? 'text' : 'none' }}
>
{keyCopied ? 'Copied!' : 'Copy to Clipboard'}
</Button>
{generatedKeyVisible ? generatedKey : '•'.repeat(48)}
</Box>
<Tooltip label={generatedKeyVisible ? 'Sembunyikan' : 'Tampilkan'}>
<ActionIcon variant="subtle" color="gray" onClick={() => setGeneratedKeyVisible((v) => !v)}>
{generatedKeyVisible ? <TbEyeOff size={16} /> : <TbEye size={16} />}
</ActionIcon>
</Tooltip>
</Group>
<Group justify="flex-end">
<CopyButton value={generatedKey}>
{({ copy }) => (
<Button variant="light" color={keyCopied ? 'green' : 'blue'} leftSection={<TbCopy size={14} />} onClick={() => { copy(); setKeyCopied(true) }}>
{keyCopied ? 'Copied!' : 'Copy to Clipboard'}
</Button>
)}
</CopyButton>
<Button variant="subtle" color="gray" onClick={closeKey}>Close</Button>
</Group>
</Stack>
@@ -1695,14 +1782,26 @@ function SettingsPanel() {
<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 }))} />
<TextInput
label="Server Key (API External)"
description="Key untuk monitoring mengakses API external. Kosongkan untuk tetap menggunakan key yang ada."
placeholder="Kosongkan untuk tetap menggunakan key yang ada"
type={apiConfigKeyVisible ? 'text' : 'password'}
value={apiForm.apiKey}
onChange={(e) => setApiForm((p) => ({ ...p, apiKey: e.target.value }))}
rightSection={
<ActionIcon variant="subtle" size="sm" color="gray" onClick={() => setApiConfigKeyVisible((v) => !v)}>
{apiConfigKeyVisible ? <TbEyeOff size={14} /> : <TbEye size={14} />}
</ActionIcon>
}
/>
<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 }
const body: { urlApi: string; apiKey?: string } = { urlApi: apiForm.urlApi }
if (apiForm.apiKey) body.apiKey = apiForm.apiKey
apiMutation.mutate({ id: apiTarget.id, body })
}}
@@ -1734,6 +1833,16 @@ function ApiKeysPanel() {
const [createdKey, setCreatedKey] = useState<string | null>(null)
const [keyCopied, setKeyCopied] = useState(false)
const [revealedOpened, { open: openRevealed, close: closeRevealed }] = useDisclosure(false)
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set())
const toggleKeyVisibility = (keyId: string) => {
setVisibleKeys((prev) => {
const next = new Set(prev)
if (next.has(keyId)) next.delete(keyId)
else next.add(keyId)
return next
})
}
const { data, isLoading } = useQuery({
queryKey: ['admin', 'api-keys'],
@@ -1837,7 +1946,30 @@ function ApiKeysPanel() {
<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>
<Group gap={4} wrap="nowrap">
<Text size="xs" ff="monospace" c="dimmed" style={{ userSelect: visibleKeys.has(k.id) ? 'text' : 'none' }}>
{visibleKeys.has(k.id) ? k.key : '•'.repeat(32)}
</Text>
<Tooltip label={visibleKeys.has(k.id) ? 'Sembunyikan' : 'Tampilkan'}>
<ActionIcon
variant="subtle"
size="xs"
color="gray"
onClick={() => toggleKeyVisibility(k.id)}
>
{visibleKeys.has(k.id) ? <TbEyeOff size={12} /> : <TbEye size={12} />}
</ActionIcon>
</Tooltip>
<CopyButton value={k.key}>
{({ copy }) => (
<Tooltip label="Salin">
<ActionIcon variant="subtle" size="xs" color="gray" onClick={copy}>
<TbCopy size={12} />
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
</Table.Td>
<Table.Td>
<Badge color={k.isActive ? 'green' : 'gray'} variant="light">