feat: add clientApiKey per-app for mobile bug submission

This commit is contained in:
2026-04-30 13:50:29 +08:00
parent 4e9d5964ae
commit 40a5f38eaf
4 changed files with 98 additions and 5 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "App" ADD COLUMN "clientApiKey" TEXT;
CREATE UNIQUE INDEX "App_clientApiKey_key" ON "App"("clientApiKey");

View File

@@ -77,9 +77,10 @@ model App {
version String?
minVersion String?
maintenance Boolean @default(false)
active Boolean @default(true)
urlApi String?
apiKey String?
active Boolean @default(true)
urlApi String?
apiKey String?
clientApiKey String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -377,6 +377,7 @@ export function createApp() {
maintenance: app.maintenance,
active: app.active,
urlApi: app.urlApi,
hasClientApiKey: !!app.clientApiKey,
}))
}, {
query: t.Object({
@@ -491,6 +492,20 @@ export function createApp() {
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
@@ -854,10 +869,21 @@ 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

View File

@@ -53,11 +53,13 @@ import {
TbApps,
TbBug,
TbChevronRight,
TbCopy,
TbCircleFilled,
TbCode,
TbDatabase,
TbDots,
TbFileText,
TbKey,
TbLayoutDashboard,
TbLayoutSidebarLeftCollapse,
TbLayoutSidebarLeftExpand,
@@ -1469,6 +1471,7 @@ interface AppEntry {
urlApi: string | null
status: string
active: boolean
hasClientApiKey: boolean
}
function SettingsPanel() {
@@ -1489,6 +1492,11 @@ function SettingsPanel() {
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: '' })
@@ -1542,6 +1550,31 @@ function SettingsPanel() {
},
})
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)
}
}
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>,
@@ -1583,6 +1616,10 @@ function SettingsPanel() {
? <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>
@@ -1592,6 +1629,9 @@ function SettingsPanel() {
<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>
@@ -1622,6 +1662,30 @@ function SettingsPanel() {
</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">