diff --git a/prisma/migrations/20260430060000_add_client_api_key_to_app/migration.sql b/prisma/migrations/20260430060000_add_client_api_key_to_app/migration.sql new file mode 100644 index 0000000..b91e813 --- /dev/null +++ b/prisma/migrations/20260430060000_add_client_api_key_to_app/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "App" ADD COLUMN "clientApiKey" TEXT; +CREATE UNIQUE INDEX "App_clientApiKey_key" ON "App"("clientApiKey"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 362b2d8..45c497c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app.ts b/src/app.ts index 5d81372..2b2348f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 diff --git a/src/frontend/routes/dev.tsx b/src/frontend/routes/dev.tsx index 708d0c3..7bb862c 100644 --- a/src/frontend/routes/dev.tsx +++ b/src/frontend/routes/dev.tsx @@ -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(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: This will invalidate the existing key for {app.name}. Any mobile apps using the old key will stop working., + 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: Are you sure you want to deactivate {app.name}? It will no longer appear in the monitoring list., @@ -1583,6 +1616,10 @@ function SettingsPanel() { ? {app.urlApi} : URL API not set } + {app.hasClientApiKey + ? Client key set + : No client key + } @@ -1592,6 +1629,9 @@ function SettingsPanel() { + @@ -1622,6 +1662,30 @@ function SettingsPanel() { + {/* ── Generated Key Modal ── */} + + + Copy this key now — it will not be shown again after you close this dialog. + + {generatedKey} + + + + + + + + {/* ── API Config Modal ── */}