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:
@@ -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,
|
||||||
}))
|
}))
|
||||||
}, {
|
}, {
|
||||||
|
|||||||
@@ -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>
|
||||||
<Box
|
<Group gap={4} wrap="nowrap" align="center">
|
||||||
p="sm"
|
<Box
|
||||||
style={{ background: 'var(--mantine-color-dark-6)', borderRadius: 6, fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all' }}
|
p="sm"
|
||||||
>
|
flex={1}
|
||||||
{generatedKey}
|
style={{ background: 'var(--mantine-color-dark-6)', borderRadius: 6, fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all', userSelect: generatedKeyVisible ? 'text' : 'none' }}
|
||||||
</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'}
|
{generatedKeyVisible ? generatedKey : '•'.repeat(48)}
|
||||||
</Button>
|
</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>
|
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user