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, errors: app.bugs.length,
active: app.active, active: app.active,
urlApi: app.urlApi, urlApi: app.urlApi,
apiKey: app.apiKey ?? '',
clientApiKey: app.clientApiKey ?? '',
hasClientApiKey: !!app.clientApiKey, hasClientApiKey: !!app.clientApiKey,
})) }))
}, { }, {

View File

@@ -8,6 +8,7 @@ import {
Button, Button,
Card, Card,
Center, Center,
CopyButton,
Container, Container,
Divider, Divider,
Group, Group,
@@ -59,6 +60,8 @@ import {
TbCode, TbCode,
TbDatabase, TbDatabase,
TbDots, TbDots,
TbEye,
TbEyeOff,
TbFileText, TbFileText,
TbKey, TbKey,
TbLayoutDashboard, TbLayoutDashboard,
@@ -1474,6 +1477,8 @@ interface AppEntry {
id: string id: string
name: string name: string
urlApi: string | null urlApi: string | null
apiKey: string
clientApiKey: string
status: string status: string
active: boolean active: boolean
hasClientApiKey: boolean hasClientApiKey: boolean
@@ -1501,10 +1506,24 @@ function SettingsPanel() {
const [keyOpened, { open: openKey, close: closeKey }] = useDisclosure(false) const [keyOpened, { open: openKey, close: closeKey }] = useDisclosure(false)
const [generatedKey, setGeneratedKey] = useState('') const [generatedKey, setGeneratedKey] = useState('')
const [keyCopied, setKeyCopied] = useState(false) 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) => { const openApiModal = (app: AppEntry) => {
setApiTarget(app) setApiTarget(app)
setApiForm({ urlApi: app.urlApi ?? '', apiKey: '' }) setApiForm({ urlApi: app.urlApi ?? '', apiKey: '' })
setApiConfigKeyVisible(false)
openApi() openApi()
} }
@@ -1562,6 +1581,7 @@ function SettingsPanel() {
qc.invalidateQueries({ queryKey: ['apps'] }) qc.invalidateQueries({ queryKey: ['apps'] })
setGeneratedKey(res.clientApiKey) setGeneratedKey(res.clientApiKey)
setKeyCopied(false) setKeyCopied(false)
setGeneratedKeyVisible(false)
openKey() openKey()
}, },
}) })
@@ -1626,6 +1646,54 @@ function SettingsPanel() {
: <Badge color="red" variant="dot" size="xs">No client key</Badge> : <Badge color="red" variant="dot" size="xs">No client key</Badge>
} }
</Group> </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> </Box>
</Group> </Group>
<Group gap="xs" wrap="nowrap"> <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="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="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="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"> <Group justify="flex-end" mt="xs">
<Button variant="subtle" color="gray" onClick={closeAdd}>Cancel</Button> <Button variant="subtle" color="gray" onClick={closeAdd}>Cancel</Button>
<Button loading={createMutation.isPending} disabled={!newApp.id || !newApp.name} onClick={() => createMutation.mutate(newApp)}>Add</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"> <Modal opened={keyOpened} onClose={closeKey} title="Client API Key Generated" radius="md">
<Stack gap="sm"> <Stack gap="sm">
<Text size="sm" c="dimmed">Copy this key now it will not be shown again after you close this dialog.</Text> <Text size="sm" c="dimmed">Copy this key now it will not be shown again after you close this dialog.</Text>
<Group gap={4} wrap="nowrap" align="center">
<Box <Box
p="sm" p="sm"
style={{ background: 'var(--mantine-color-dark-6)', borderRadius: 6, fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all' }} flex={1}
style={{ background: 'var(--mantine-color-dark-6)', borderRadius: 6, fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all', userSelect: generatedKeyVisible ? 'text' : 'none' }}
> >
{generatedKey} {generatedKeyVisible ? generatedKey : '•'.repeat(48)}
</Box> </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"> <Group justify="flex-end">
<Button <CopyButton value={generatedKey}>
variant="light" {({ copy }) => (
color={keyCopied ? 'green' : 'blue'} <Button variant="light" color={keyCopied ? 'green' : 'blue'} leftSection={<TbCopy size={14} />} onClick={() => { copy(); setKeyCopied(true) }}>
leftSection={<TbCopy size={14} />}
onClick={() => { navigator.clipboard.writeText(generatedKey); setKeyCopied(true) }}
>
{keyCopied ? 'Copied!' : 'Copy to Clipboard'} {keyCopied ? 'Copied!' : 'Copy to Clipboard'}
</Button> </Button>
)}
</CopyButton>
<Button variant="subtle" color="gray" onClick={closeKey}>Close</Button> <Button variant="subtle" color="gray" onClick={closeKey}>Close</Button>
</Group> </Group>
</Stack> </Stack>
@@ -1695,14 +1782,26 @@ function SettingsPanel() {
<Modal opened={apiOpened} onClose={closeApi} title={`API Config — ${apiTarget?.name}`} radius="md"> <Modal opened={apiOpened} onClose={closeApi} title={`API Config — ${apiTarget?.name}`} radius="md">
<Stack gap="sm"> <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="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"> <Group justify="flex-end" mt="xs">
<Button variant="subtle" color="gray" onClick={closeApi}>Cancel</Button> <Button variant="subtle" color="gray" onClick={closeApi}>Cancel</Button>
<Button <Button
loading={apiMutation.isPending} loading={apiMutation.isPending}
onClick={() => { onClick={() => {
if (!apiTarget) return 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 if (apiForm.apiKey) body.apiKey = apiForm.apiKey
apiMutation.mutate({ id: apiTarget.id, body }) apiMutation.mutate({ id: apiTarget.id, body })
}} }}
@@ -1734,6 +1833,16 @@ function ApiKeysPanel() {
const [createdKey, setCreatedKey] = useState<string | null>(null) const [createdKey, setCreatedKey] = useState<string | null>(null)
const [keyCopied, setKeyCopied] = useState(false) const [keyCopied, setKeyCopied] = useState(false)
const [revealedOpened, { open: openRevealed, close: closeRevealed }] = useDisclosure(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({ const { data, isLoading } = useQuery({
queryKey: ['admin', 'api-keys'], queryKey: ['admin', 'api-keys'],
@@ -1837,7 +1946,30 @@ function ApiKeysPanel() {
<Table.Tr key={k.id}> <Table.Tr key={k.id}>
<Table.Td fw={500}>{k.name}</Table.Td> <Table.Td fw={500}>{k.name}</Table.Td>
<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>
<Table.Td> <Table.Td>
<Badge color={k.isActive ? 'green' : 'gray'} variant="light"> <Badge color={k.isActive ? 'green' : 'gray'} variant="light">