diff --git a/prisma/migrations/20260415014750_tambah_table_app_and_enum_role/migration.sql b/prisma/migrations/20260415014750_tambah_table_app_and_enum_role/migration.sql new file mode 100644 index 0000000..6a14dba --- /dev/null +++ b/prisma/migrations/20260415014750_tambah_table_app_and_enum_role/migration.sql @@ -0,0 +1,40 @@ +/* + Warnings: + + - The values [USER,SUPER_ADMIN] on the enum `Role` will be removed. If these variants are still used in the database, this will fail. + - You are about to drop the column `app` on the `bug` table. All the data in the column will be lost. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "Role_new" AS ENUM ('ADMIN', 'DEVELOPER'); +ALTER TABLE "public"."user" ALTER COLUMN "role" DROP DEFAULT; +ALTER TABLE "user" ALTER COLUMN "role" TYPE "Role_new" USING ("role"::text::"Role_new"); +ALTER TYPE "Role" RENAME TO "Role_old"; +ALTER TYPE "Role_new" RENAME TO "Role"; +DROP TYPE "public"."Role_old"; +ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'ADMIN'; +COMMIT; + +-- AlterTable +ALTER TABLE "bug" DROP COLUMN "app", +ADD COLUMN "appId" TEXT; + +-- AlterTable +ALTER TABLE "user" ALTER COLUMN "role" SET DEFAULT 'ADMIN'; + +-- CreateTable +CREATE TABLE "App" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "version" TEXT NOT NULL, + "minVersion" TEXT NOT NULL, + "maintenance" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "App_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "bug" ADD CONSTRAINT "bug_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260415024317_update_enum_app/migration.sql b/prisma/migrations/20260415024317_update_enum_app/migration.sql new file mode 100644 index 0000000..9ce2368 --- /dev/null +++ b/prisma/migrations/20260415024317_update_enum_app/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "App" ALTER COLUMN "version" DROP NOT NULL, +ALTER COLUMN "minVersion" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 64b5f82..5667144 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,9 +9,7 @@ datasource db { } enum Role { - USER ADMIN - SUPER_ADMIN DEVELOPER } @@ -44,7 +42,7 @@ model User { name String email String @unique password String - role Role @default(USER) + role Role @default(ADMIN) active Boolean @default(true) image String? createdAt DateTime @default(now()) @@ -71,6 +69,19 @@ model Session { @@map("session") } +model App { + id String @id @default(uuid()) + name String + version String? + minVersion String? + maintenance Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + bugs Bug[] + +} + model Log { id String @id @default(uuid()) userId String @@ -86,12 +97,12 @@ model Log { model Bug { id String @id @default(uuid()) userId String? - app String? + appId String? affectedVersion String device String os String status BugStatus - source BugSource + source BugSource description String stackTrace String? fixedVersion String? @@ -100,6 +111,7 @@ model Bug { updatedAt DateTime @updatedAt user User? @relation(fields: [userId], references: [id]) + app App? @relation(fields: [appId], references: [id]) images BugImage[] logs BugLog[] diff --git a/prisma/seed.ts b/prisma/seed.ts index f90de89..31e64df 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -6,9 +6,7 @@ const SUPER_ADMIN_EMAILS = (process.env.SUPER_ADMIN_EMAIL ?? '').split(',').map( async function main() { const users = [ - { name: 'Super Admin', email: 'superadmin@example.com', password: 'superadmin123', role: 'SUPER_ADMIN' as const }, { name: 'Admin', email: 'admin@example.com', password: 'admin123', role: 'ADMIN' as const }, - { name: 'User', email: 'user@example.com', password: 'user123', role: 'USER' as const }, ] for (const u of users) { @@ -21,13 +19,28 @@ async function main() { console.log(`Seeded: ${u.email} (${u.role})`) } - // Promote super admin emails from env + // Promote DEVELOPER emails from env for (const email of SUPER_ADMIN_EMAILS) { - const user = await prisma.user.findUnique({ where: { email } }) - if (user && user.role !== 'SUPER_ADMIN') { - await prisma.user.update({ where: { email }, data: { role: 'SUPER_ADMIN' } }) - console.log(`Promoted to SUPER_ADMIN: ${email}`) - } + const password = await Bun.password.hash('developer123', { algorithm: 'bcrypt' }) + await prisma.user.upsert({ + where: { email }, + update: { role: 'DEVELOPER', password }, + create: { name: email.split('@')[0].toUpperCase(), email, password, role: 'DEVELOPER' }, + }) + console.log(`Promoted to DEVELOPER: ${email}`) + } + + const apps = [ + { id: 'desa-plus', name: 'Desa+' }, + ] + + for (const a of apps) { + await prisma.app.upsert({ + where: { id: a.id }, + update: { name: a.name }, + create: { id: a.id, name: a.name }, + }) + console.log(`Seeded: ${a.name}`) } } diff --git a/src/app.ts b/src/app.ts index b1a38ef..f860ae5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -37,8 +37,8 @@ export function createApp() { return { error: 'Email atau password salah' } } // Auto-promote super admin from env - if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'SUPER_ADMIN') { - user = await prisma.user.update({ where: { id: user.id }, data: { role: 'SUPER_ADMIN' } }) + if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'DEVELOPER') { + user = await prisma.user.update({ where: { id: user.id }, data: { role: 'DEVELOPER' } }) } const token = crypto.randomUUID() const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours @@ -78,80 +78,7 @@ export function createApp() { return { user: session.user } }) - // ─── Google OAuth ────────────────────────────────── - .get('/api/auth/google', ({ request, set }) => { - const origin = new URL(request.url).origin - const params = new URLSearchParams({ - client_id: env.GOOGLE_CLIENT_ID, - redirect_uri: `${origin}/api/auth/callback/google`, - response_type: 'code', - scope: 'openid email profile', - access_type: 'offline', - prompt: 'consent', - }) - set.status = 302; set.headers['location'] = `https://accounts.google.com/o/oauth2/v2/auth?${params}` - }) - .get('/api/auth/callback/google', async ({ request, set }) => { - const url = new URL(request.url) - const code = url.searchParams.get('code') - const origin = url.origin - - if (!code) { - set.status = 302; set.headers['location'] = '/login?error=google_failed' - return - } - - // Exchange code for tokens - const tokenRes = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - code, - client_id: env.GOOGLE_CLIENT_ID, - client_secret: env.GOOGLE_CLIENT_SECRET, - redirect_uri: `${origin}/api/auth/callback/google`, - grant_type: 'authorization_code', - }), - }) - - if (!tokenRes.ok) { - set.status = 302; set.headers['location'] = '/login?error=google_failed' - return - } - - const tokens = (await tokenRes.json()) as { access_token: string } - - // Get user info - const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - }) - - if (!userInfoRes.ok) { - set.status = 302; set.headers['location'] = '/login?error=google_failed' - return - } - - const googleUser = (await userInfoRes.json()) as { email: string; name: string } - - // Upsert user (no password for Google users) - const isSuperAdmin = env.SUPER_ADMIN_EMAILS.includes(googleUser.email) - const user = await prisma.user.upsert({ - where: { email: googleUser.email }, - update: { name: googleUser.name, ...(isSuperAdmin ? { role: 'SUPER_ADMIN' } : {}) }, - create: { email: googleUser.email, name: googleUser.name, password: '', role: isSuperAdmin ? 'SUPER_ADMIN' : 'USER' }, - }) - - // Create session - const token = crypto.randomUUID() - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) - await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) - - await createSystemLog(user.id, 'LOGIN', 'Logged in via Google') - - set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` - set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' - }) // ─── Monitoring API ──────────────────────────────── .get('/api/dashboard/stats', async () => { @@ -172,7 +99,7 @@ export function createApp() { }) return bugs.map(b => ({ id: b.id, - app: b.app, + app: b.appId, message: b.description, version: b.affectedVersion, time: b.createdAt.toISOString(), @@ -180,18 +107,56 @@ export function createApp() { })) }) - .get('/api/apps', async () => { - const desaPlusErrors = await prisma.bug.count({ where: { app: { in: ['desa-plus', 'desa_plus'] }, status: 'OPEN' } }) - return [ - { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: desaPlusErrors, version: '2.4.1' }, - ] + .get('/api/apps', async ({ query }) => { + const search = (query.search as string) || '' + const where: any = {} + if (search) { + where.name = { contains: search, mode: 'insensitive' } + } + + const apps = await prisma.app.findMany({ + where, + include: { + _count: { select: { bugs: true } }, + bugs: { where: { status: 'OPEN' }, select: { id: true } }, + }, + orderBy: { name: 'asc' }, + }) + + return apps.map((app) => ({ + id: app.id, + name: app.name, + status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active', + errors: app.bugs.length, + version: app.version ?? '-', + maintenance: app.maintenance, + })) }) - .get('/api/apps/:appId', ({ params: { appId } }) => { - const apps = { - 'desa-plus': { id: 'desa-plus', name: 'Desa+', status: 'active', users: 12450, errors: 12, version: '2.4.1' }, + .get('/api/apps/:appId', async ({ params: { appId }, set }) => { + const app = await prisma.app.findUnique({ + where: { id: appId }, + include: { + _count: { select: { bugs: true } }, + bugs: { where: { status: 'OPEN' }, select: { id: true } }, + }, + }) + + if (!app) { + set.status = 404 + return { error: 'App not found' } + } + + return { + id: app.id, + name: app.name, + status: app.maintenance ? 'warning' : app.bugs.length > 0 ? 'error' : 'active', + errors: app.bugs.length, + version: app.version ?? '-', + minVersion: app.minVersion, + maintenance: app.maintenance, + totalBugs: app._count.bugs, } - return apps[appId as keyof typeof apps] || { id: appId, name: appId, status: 'active', users: 0, errors: 0, version: '1.0.0' } }) .get('/api/logs', async ({ query }) => { @@ -246,7 +211,7 @@ export function createApp() { } const body = (await request.json()) as { type: string, message: string } - const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }))?.id || '' + const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }))?.id || '' await createSystemLog(actingUserId, body.type as any, body.message) return { ok: true } @@ -419,7 +384,7 @@ export function createApp() { ] } if (app && app !== 'all') { - where.app = app + where.appId = app } if (status && status !== 'all') { where.status = status @@ -463,12 +428,12 @@ export function createApp() { } const body = (await request.json()) as any - const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }) + const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }) const actingUserId = userId || defaultAdmin?.id || '' const bug = await prisma.bug.create({ data: { - app: body.app, + appId: body.app, affectedVersion: body.affectedVersion, device: body.device, os: body.os, @@ -508,7 +473,7 @@ export function createApp() { } const body = (await request.json()) as { feedBack: string } - const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }) + const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }) const actingUserId = userId || defaultAdmin?.id || undefined const bug = await prisma.bug.update({ @@ -538,7 +503,7 @@ export function createApp() { } const body = (await request.json()) as { status: string; description?: string } - const defaultAdmin = await prisma.user.findFirst({ where: { role: 'SUPER_ADMIN' } }) + const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }) const actingUserId = userId || defaultAdmin?.id || undefined const bug = await prisma.bug.update({ @@ -562,6 +527,30 @@ export function createApp() { return bug }) + // ─── System Status API ───────────────────────────── + .get('/api/system/status', async () => { + try { + // Check database connectivity + await prisma.$queryRaw`SELECT 1` + const activeSessions = await prisma.session.count({ + where: { expiresAt: { gte: new Date() } }, + }) + return { + status: 'operational', + database: 'connected', + activeSessions, + uptime: process.uptime(), + } + } catch { + return { + status: 'degraded', + database: 'disconnected', + activeSessions: 0, + uptime: process.uptime(), + } + } + }) + // ─── Example API ─────────────────────────────────── .get('/api/hello', () => ({ message: 'Hello, world!', diff --git a/src/frontend/components/AppCard.tsx b/src/frontend/components/AppCard.tsx index 3a8d485..7c5fcff 100644 --- a/src/frontend/components/AppCard.tsx +++ b/src/frontend/components/AppCard.tsx @@ -1,17 +1,18 @@ -import { Card, Group, Text, ThemeIcon, Badge, Avatar, Stack, Button, Progress, Box, useComputedColorScheme } from '@mantine/core' +import { Avatar, Button, Card, Group, Stack, Text, useComputedColorScheme } from '@mantine/core' import { Link } from '@tanstack/react-router' -import { TbDeviceMobile, TbActivity, TbAlertTriangle, TbChevronRight } from 'react-icons/tb' +import { TbChevronRight, TbDeviceMobile } from 'react-icons/tb' interface AppCardProps { id: string name: string status: 'active' | 'warning' | 'error' - users: number + users?: number errors: number version: string + maintenance?: boolean } -export function AppCard({ id, name, status, users, errors, version }: AppCardProps) { +export function AppCard({ id, name, status, errors, version }: AppCardProps) { const statusColor = status === 'active' ? 'teal' : status === 'warning' ? 'orange' : 'red' const scheme = useComputedColorScheme('light', { getInitialValueInEffect: true }) @@ -46,12 +47,12 @@ export function AppCard({ id, name, status, users, errors, version }: AppCardPro {name} - VERSION {version} + {/* VERSION {version} */} - + {/* {status.toUpperCase()} - + */} {/* diff --git a/src/frontend/components/DashboardLayout.tsx b/src/frontend/components/DashboardLayout.tsx index 5ec63f3..7829efb 100644 --- a/src/frontend/components/DashboardLayout.tsx +++ b/src/frontend/components/DashboardLayout.tsx @@ -1,4 +1,5 @@ import { APP_CONFIGS } from '@/frontend/config/appMenus' +import { useLogout, useSession } from '@/frontend/hooks/useAuth' import { ActionIcon, AppShell, @@ -7,6 +8,7 @@ import { Burger, Button, Group, + Loader, Menu, NavLink, Select, @@ -17,6 +19,7 @@ import { useMantineColorScheme } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' +import { useQuery } from '@tanstack/react-query' import { Link, useLocation, useMatches, useNavigate, useParams } from '@tanstack/react-router' import { TbAlertTriangle, @@ -50,6 +53,26 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { const matches = useMatches() const currentPath = matches[matches.length - 1]?.pathname + // ─── Connect to auth system ────────────────────────── + const { data: sessionData } = useSession() + const user = sessionData?.user + const logout = useLogout() + + // ─── Fetch registered apps from database ───────────── + const { data: appsData } = useQuery({ + queryKey: ['apps'], + queryFn: () => fetch('/api/apps', { credentials: 'include' }).then((r) => r.json()), + staleTime: 60_000, + }) + + // ─── Fetch system status from database ─────────────── + const { data: systemStatus } = useQuery({ + queryKey: ['system', 'status'], + queryFn: () => fetch('/api/system/status', { credentials: 'include' }).then((r) => r.json()), + refetchInterval: 30_000, // refresh every 30 seconds + staleTime: 15_000, + }) + const globalNav = [ { label: 'Dashboard', icon: TbDashboard, to: '/dashboard' }, { label: 'Applications', icon: TbApps, to: '/apps' }, @@ -61,6 +84,21 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { const activeApp = appId ? APP_CONFIGS[appId] : null const navLinks = activeApp ? activeApp.menus : globalNav + // Build app selector data from API + const appSelectData = (appsData || []).map((app: any) => ({ + value: app.id, + label: app.name, + })) + + // System status indicator + const isOperational = systemStatus?.status === 'operational' + const statusColor = isOperational ? '#10b981' : '#f59e0b' + const statusText = isOperational ? 'All Systems Operational' : 'System Degraded' + + const handleLogout = () => { + logout.mutate() + } + return ( + > + {user?.name?.charAt(0).toUpperCase()} + + {user && ( + <> + + {user.name} + {user.email} + + + + )} Application - }>Profile - }>Settings + } + onClick={() => navigate({ to: '/profile' })} + > + Profile + + } + onClick={() => navigate({ to: '/dashboard' })} + > + Settings + Danger Zone - }> - Logout + } + onClick={handleLogout} + disabled={logout.isPending} + > + {logout.isPending ? 'Logging out...' : 'Logout'} @@ -160,10 +224,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { setCreateForm({ ...createForm, role: val || 'USER' })} @@ -461,10 +454,8 @@ function UsersPage() {