From 94724a508185d381f3d3c19a31091c838f509153 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 28 Apr 2026 15:06:13 +0800 Subject: [PATCH 1/2] feat: add Google OAuth login with USER role and pending approval flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/auth/google and GET /api/auth/callback/google routes with CSRF state protection and account linking via googleId - Add getPublicOrigin() for dynamic redirect_uri (supports reverse proxy via X-Forwarded-Proto) - Add USER role to schema (default for new Google sign-ins), make password optional, add googleId and image fields - Role-based redirect after login: USER → /profile, ADMIN/DEVELOPER → /dashboard - Profile page shows pending approval alert for USER role - Dashboard redirects USER role back to profile - Login page shows specific error messages per OAuth error code Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 21 +++ prisma/schema.prisma | 6 +- src/app.ts | 125 +++++++++++++++++- src/frontend/components/DashboardLayout.tsx | 24 +++- src/frontend/hooks/useAuth.ts | 5 +- src/frontend/routes/login.tsx | 22 ++- src/frontend/routes/profile.tsx | 23 +++- src/frontend/routes/users.tsx | 13 +- src/lib/env.ts | 1 + 9 files changed, 219 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql diff --git a/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql b/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql new file mode 100644 index 0000000..b64087c --- /dev/null +++ b/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql @@ -0,0 +1,21 @@ +-- AlterEnum: add USER back to Role +BEGIN; +CREATE TYPE "Role_new" AS ENUM ('USER', '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 'USER'; +COMMIT; + +-- AlterTable: make password nullable, change default role +ALTER TABLE "user" + ALTER COLUMN "password" DROP NOT NULL, + ALTER COLUMN "role" SET DEFAULT 'USER'; + +-- AlterTable: add googleId column +ALTER TABLE "user" ADD COLUMN "googleId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "user_googleId_key" ON "user"("googleId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5667144..475e633 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,6 +9,7 @@ datasource db { } enum Role { + USER ADMIN DEVELOPER } @@ -41,8 +42,9 @@ model User { id String @id @default(uuid()) name String email String @unique - password String - role Role @default(ADMIN) + password String? + googleId String? @unique + role Role @default(USER) active Boolean @default(true) image String? createdAt DateTime @default(now()) diff --git a/src/app.ts b/src/app.ts index be47ff6..8eb4205 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,14 @@ import { env } from './lib/env' import { createSystemLog } from './lib/logger' import { getMinioDownloadUrl, uploadBugImage } from './lib/minio' +function getPublicOrigin(request: Request): string { + if (process.env.BUN_PUBLIC_BASE_URL) return process.env.BUN_PUBLIC_BASE_URL.replace(/\/$/, '') + const url = new URL(request.url) + const proto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() + const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host + return `${proto ?? url.protocol.replace(':', '')}://${host}` +} + interface AuthResult { actingUserId: string reporterUserId: string | null // null jika via API key (tidak ada user spesifik) @@ -80,10 +88,117 @@ export function createApp() { }) // ─── Auth API ────────────────────────────────────── + // ─── Google OAuth ────────────────────────────────── + .get('/api/auth/google', ({ request }) => { + const origin = getPublicOrigin(request) + const state = crypto.randomUUID() + const params = new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + redirect_uri: `${origin}/api/auth/callback/google`, + response_type: 'code', + scope: 'openid email profile', + state, + access_type: 'online', + prompt: 'select_account', + }) + const headers = new Headers() + headers.set('Location', `https://accounts.google.com/o/oauth2/v2/auth?${params}`) + headers.set('Set-Cookie', `oauth_state=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`) + return new Response(null, { status: 302, headers }) + }, { + detail: { + summary: 'Google OAuth Login', + description: 'Menginisiasi alur Google OAuth. Meredirect pengguna ke halaman login Google.', + tags: ['Auth'], + }, + }) + + .get('/api/auth/callback/google', async ({ query, request }) => { + const { code, state, error } = query as Record + const origin = getPublicOrigin(request) + + if (error) { + return new Response(null, { status: 302, headers: { Location: '/login?error=google_denied' } }) + } + + const cookie = request.headers.get('cookie') ?? '' + const storedState = cookie.match(/oauth_state=([^;]+)/)?.[1] + if (!state || !storedState || state !== storedState) { + return new Response(null, { status: 302, headers: { Location: '/login?error=invalid_state' } }) + } + + 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) { + return new Response(null, { status: 302, headers: { Location: '/login?error=token_failed' } }) + } + + const { access_token } = await tokenRes.json() as { access_token: string } + + const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${access_token}` }, + }) + + if (!userInfoRes.ok) { + return new Response(null, { status: 302, headers: { Location: '/login?error=userinfo_failed' } }) + } + + const { id: googleId, email, name, picture } = await userInfoRes.json() as { + id: string; email: string; name: string; picture?: string + } + + let user = await prisma.user.findFirst({ + where: { OR: [{ googleId }, { email }] }, + }) + + if (!user) { + user = await prisma.user.create({ + data: { name, email, googleId, image: picture, role: 'USER' }, + }) + } else if (!user.googleId) { + user = await prisma.user.update({ + where: { id: user.id }, + data: { googleId, image: picture ?? user.image }, + }) + } + + 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) + await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) + await createSystemLog(user.id, 'LOGIN', 'Logged in with Google') + + const redirectPath = user.role === 'USER' ? '/profile' : '/dashboard' + const headers = new Headers() + headers.append('Location', redirectPath) + headers.append('Set-Cookie', `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`) + headers.append('Set-Cookie', 'oauth_state=; Path=/; HttpOnly; Max-Age=0') + return new Response(null, { status: 302, headers }) + }, { + detail: { + summary: 'Google OAuth Callback', + description: 'Menerima callback dari Google, membuat/menautkan akun, membuat sesi, lalu meredirect ke /dashboard (ADMIN/DEVELOPER) atau /profile (USER baru).', + tags: ['Auth'], + }, + }) + .post('/api/auth/login', async ({ body, set }) => { const { email, password } = body let user = await prisma.user.findUnique({ where: { email } }) - if (!user || !(await Bun.password.verify(password, user.password))) { + if (!user || !user.password || !(await Bun.password.verify(password, user.password))) { set.status = 401 return { error: 'Email atau password salah' } } @@ -96,7 +211,7 @@ export function createApp() { await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` await createSystemLog(user.id, 'LOGIN', 'Logged in successfully') - return { user: { id: user.id, name: user.name, email: user.email, role: user.role } } + return { user: { id: user.id, name: user.name, email: user.email, role: user.role, image: user.image } } }, { body: t.Object({ email: t.String({ format: 'email', description: 'Email pengguna' }), @@ -135,7 +250,7 @@ export function createApp() { if (!token) { set.status = 401; return { user: null } } const session = await prisma.session.findUnique({ where: { token }, - include: { user: { select: { id: true, name: true, email: true, role: true } } }, + include: { user: { select: { id: true, name: true, email: true, role: true, image: true } } }, }) if (!session || session.expiresAt < new Date()) { if (session) await prisma.session.delete({ where: { id: session.id } }) @@ -454,7 +569,7 @@ export function createApp() { name: t.String({ minLength: 1, description: 'Nama lengkap operator' }), email: t.String({ format: 'email', description: 'Alamat email (harus unik)' }), password: t.String({ minLength: 6, description: 'Password (minimal 6 karakter)' }), - role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role: ADMIN atau DEVELOPER' }), + role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role untuk akun yang dibuat manual: ADMIN atau DEVELOPER' }), }), detail: { summary: 'Create Operator', @@ -494,7 +609,7 @@ export function createApp() { body: t.Object({ name: t.Optional(t.String({ minLength: 1, description: 'Nama baru' })), email: t.Optional(t.String({ format: 'email', description: 'Email baru' })), - role: t.Optional(t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })), + role: t.Optional(t.Union([t.Literal('USER'), t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })), active: t.Optional(t.Boolean({ description: 'Status aktif operator' })), }), detail: { diff --git a/src/frontend/components/DashboardLayout.tsx b/src/frontend/components/DashboardLayout.tsx index 6891f49..dfb3293 100644 --- a/src/frontend/components/DashboardLayout.tsx +++ b/src/frontend/components/DashboardLayout.tsx @@ -1,20 +1,25 @@ import { APP_CONFIGS } from '@/frontend/config/appMenus' import { useLogout, useSession } from '@/frontend/hooks/useAuth' +import React from 'react' import { ActionIcon, + Alert, AppShell, Avatar, Box, Burger, Button, + Center, Group, Loader, + LoadingOverlay, Menu, NavLink, Select, Stack, Text, ThemeIcon, + Title, useComputedColorScheme, useMantineColorScheme } from '@mantine/core' @@ -26,6 +31,7 @@ import { TbApps, TbArrowLeft, TbChevronRight, + TbClock, TbDashboard, TbDeviceMobile, TbHistory, @@ -54,10 +60,17 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { const currentPath = matches[matches.length - 1]?.pathname // ─── Connect to auth system ────────────────────────── - const { data: sessionData } = useSession() + const { data: sessionData, isLoading: sessionLoading } = useSession() const user = sessionData?.user const logout = useLogout() + // Redirect USER role to profile (pending approval) + React.useEffect(() => { + if (!sessionLoading && user?.role === 'USER') { + navigate({ to: '/profile' }) + } + }, [user?.role, sessionLoading, navigate]) + // ─── Fetch registered apps from database ───────────── const { data: appsData } = useQuery({ queryKey: ['apps'], @@ -99,6 +112,15 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { logout.mutate() } + // Prevent dashboard flash for USER role while redirect is happening + if (sessionLoading || user?.role === 'USER') { + return ( +
+ +
+ ) + } + return ( (path: string, init?: RequestInit): Promise { @@ -41,7 +42,7 @@ export function useLogin() { }), onSuccess: (data) => { queryClient.setQueryData(['auth', 'session'], data) - navigate({ to: '/dashboard' }) + navigate({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' }) }, }) } diff --git a/src/frontend/routes/login.tsx b/src/frontend/routes/login.tsx index 5432337..f534cfc 100644 --- a/src/frontend/routes/login.tsx +++ b/src/frontend/routes/login.tsx @@ -27,7 +27,7 @@ export const Route = createFileRoute('/login')({ queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()), }) if (data?.user) { - throw redirect({ to: '/dashboard' }) + throw redirect({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' }) } } catch (e) { if (e instanceof Error) return @@ -59,7 +59,14 @@ function LoginPage() { {(login.isError || searchError) && ( } color="red" variant="light"> - {login.isError ? login.error.message : 'Google login failed, please try again.'} + {login.isError ? login.error.message : ( + { + google_denied: 'Login dengan Google dibatalkan.', + invalid_state: 'Sesi OAuth tidak valid, silakan coba lagi.', + token_failed: 'Gagal menukar token Google, silakan coba lagi.', + userinfo_failed: 'Gagal mengambil info akun Google, silakan coba lagi.', + }[searchError ?? ''] ?? 'Login dengan Google gagal, silakan coba lagi.' + )} )} @@ -89,6 +96,17 @@ function LoginPage() { > Sign in + + + + diff --git a/src/frontend/routes/profile.tsx b/src/frontend/routes/profile.tsx index be019ed..2a99298 100644 --- a/src/frontend/routes/profile.tsx +++ b/src/frontend/routes/profile.tsx @@ -1,4 +1,5 @@ import { + Alert, Avatar, Badge, Button, @@ -10,7 +11,7 @@ import { Title, } from '@mantine/core' import { createFileRoute, redirect } from '@tanstack/react-router' -import { TbLogout, TbUser } from 'react-icons/tb' +import { TbClock, TbLogout, TbUser } from 'react-icons/tb' import { useLogout, useSession } from '@/frontend/hooks/useAuth' export const Route = createFileRoute('/profile')({ @@ -30,6 +31,7 @@ export const Route = createFileRoute('/profile')({ }) const roleBadgeColor: Record = { + USER: 'gray', ADMIN: 'violet', DEVELOPER: 'red', } @@ -55,9 +57,26 @@ function ProfilePage() { + {user?.role === 'USER' && ( + } + title="Akun Menunggu Persetujuan" + color="yellow" + variant="light" + radius="md" + > + Akun kamu sedang menunggu persetujuan admin. Hubungi admin atau developer untuk mendapatkan akses ke fitur dashboard. + + )} + - + {user?.name?.charAt(0).toUpperCase()}
diff --git a/src/frontend/routes/users.tsx b/src/frontend/routes/users.tsx index 57ae6d6..00a7793 100644 --- a/src/frontend/routes/users.tsx +++ b/src/frontend/routes/users.tsx @@ -50,10 +50,8 @@ export const Route = createFileRoute('/users')({ const fetcher = (url: string) => fetch(url).then((res) => res.json()) const getRoleColor = (role: string) => { - const r = (role || '').toLowerCase() - if (r.includes('super')) return 'red' - if (r.includes('admin')) return 'brand-blue' - if (r.includes('developer')) return 'violet' + if (role === 'DEVELOPER') return 'violet' + if (role === 'ADMIN') return 'brand-blue' return 'gray' } @@ -97,7 +95,7 @@ function UsersPage() { name: '', email: '', password: '', - role: 'USER', + role: 'ADMIN', }) const handleCreateUser = async () => { @@ -119,7 +117,7 @@ function UsersPage() { mutateOperators() mutateStats() closeCreate() - setCreateForm({ name: '', email: '', password: '', role: 'USER' }) + setCreateForm({ name: '', email: '', password: '', role: 'ADMIN' }) } else { const err = await res.json() throw new Error(err.error || 'Failed to create user') @@ -457,11 +455,12 @@ function UsersPage() { { setUserId(v ?? 'all'); setPage(1) }} + data={[{ value: 'all', label: 'Semua operator' }, ...users.map((u) => ({ value: u.id, label: u.name }))]} + size="xs" + w={180} + clearable + /> + { setType(v); setPage(1) }} + data={LOG_TYPES.map((t) => ({ label: t === 'all' ? 'All' : t, value: t }))} + size="xs" + /> + + {isLoading ?
: ( + <> + + + + + Time + Operator + Type + Message + + + + {pageLogs.map((log: any) => ( + + {new Date(log.createdAt).toLocaleString('id-ID')} + + {log.user ? ( +
+ {log.user.name} + {log.user.email} +
+ ) : } +
+ {log.type} + {log.message} +
+ ))} + {pageLogs.length === 0 && ( +
Belum ada log aktivitas
+ )} +
+
+
+ {totalPages > 1 &&
} + + )} + + ) +} + +// ─── ELK Layout Utility ──────────────────────────────────────────────────────── + +const elk = new ELK() + +async function applyElkLayout( + nodes: Node[], + edges: Edge[], + direction: 'DOWN' | 'RIGHT' | 'UP' | 'LEFT' = 'RIGHT', +): Promise<{ nodes: Node[]; edges: Edge[] }> { + if (nodes.length === 0) return { nodes, edges } + const elkNodes = nodes.map((n) => ({ id: n.id, width: (n.measured?.width ?? n.data?.width ?? 200) as number, height: (n.measured?.height ?? n.data?.height ?? 60) as number })) + const elkEdges = edges.map((e) => ({ id: e.id, sources: [e.source], targets: [e.target] })) + const graph = await elk.layout({ + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': direction, + 'elk.spacing.nodeNode': '40', + 'elk.layered.spacing.nodeNodeBetweenLayers': '60', + }, + children: elkNodes, + edges: elkEdges, + }) + const positioned = new Map(graph.children?.map((n) => [n.id, { x: n.x ?? 0, y: n.y ?? 0 }]) ?? []) + return { + nodes: nodes.map((n) => ({ ...n, position: positioned.get(n.id) ?? n.position })), + edges, + } +} + +function useFlowAutoSave(key: string) { + const savePositions = useCallback((nodes: Node[]) => { + const pos: Record = {} + for (const n of nodes) pos[n.id] = n.position + localStorage.setItem(`dev:flow:${key}:positions`, JSON.stringify(pos)) + }, [key]) + const saveViewport = useCallback((vp: { x: number; y: number; zoom: number }) => { + localStorage.setItem(`dev:flow:${key}:viewport`, JSON.stringify(vp)) + }, [key]) + const loadPositions = useCallback((): Record => { + try { return JSON.parse(localStorage.getItem(`dev:flow:${key}:positions`) ?? '{}') } catch { return {} } + }, [key]) + const loadViewport = useCallback(() => { + try { return JSON.parse(localStorage.getItem(`dev:flow:${key}:viewport`) ?? 'null') } catch { return null } + }, [key]) + return { savePositions, saveViewport, loadPositions, loadViewport } +} + +// ─── Database Panel ──────────────────────────────────────────────────────────── + +interface SchemaField { name: string; type: string; isId: boolean; isUnique: boolean; isOptional: boolean; isList: boolean; isRelation: boolean; default?: string } +interface SchemaRelation { from: string; fromField: string; to: string; toField: string; onDelete?: string } +interface SchemaModel { name: string; tableName: string; fields: SchemaField[] } +interface SchemaEnum { name: string; values: string[] } +interface ParsedSchema { models: SchemaModel[]; enums: SchemaEnum[]; relations: SchemaRelation[] } + +function ModelNode({ data }: { data: any }) { + return ( + + + + {data.name} + {data.tableName !== data.name && @map({data.tableName})} + + + {data.fields?.map((f: SchemaField) => ( + + + {f.isId && PK} + {f.isUnique && !f.isId && UQ} + {f.name}{f.isOptional ? '?' : ''} + + {f.type} + + ))} + + + + ) +} + +function EnumNode({ data }: { data: any }) { + return ( + + + enum {data.name} + + + {data.values?.map((v: string) => ( + + {v} + + ))} + + + ) +} + +const DB_NODE_TYPES = { model: ModelNode, enum: EnumNode } + +function DatabaseFlowInner({ schema }: { schema: ParsedSchema }) { + const { fitView, setViewport } = useReactFlow() + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave('db') + const saveTimer = useRef | undefined>(undefined) + + useEffect(() => { + const savedPos = loadPositions() + const hasSaved = Object.keys(savedPos).length > 0 + + const newNodes: Node[] = [ + ...schema.models.map((m, i) => ({ + id: m.name, type: 'model', + position: savedPos[m.name] ?? { x: i * 220, y: 0 }, + data: { name: m.name, tableName: m.tableName, fields: m.fields }, + })), + ...schema.enums.map((e, i) => ({ + id: `enum_${e.name}`, type: 'enum', + position: savedPos[`enum_${e.name}`] ?? { x: i * 160, y: 400 }, + data: { name: e.name, values: e.values }, + })), + ] + + const newEdges: Edge[] = schema.relations.map((r) => ({ + id: `${r.from}-${r.fromField}-${r.to}`, + source: r.from, target: r.to, + label: `${r.fromField} → ${r.toField}${r.onDelete ? ` [${r.onDelete}]` : ''}`, + markerEnd: { type: MarkerType.ArrowClosed }, + style: { stroke: 'var(--mantine-color-blue-5)' }, + })) + + if (hasSaved) { + setNodes(newNodes) + setEdges(newEdges) + const savedVp = loadViewport() + if (savedVp) setTimeout(() => setViewport(savedVp), 50) + else setTimeout(() => fitView({ padding: 0.2 }), 50) + } else { + applyElkLayout(newNodes, newEdges, 'RIGHT').then(({ nodes: ln, edges: le }) => { + setNodes(ln) + setEdges(le) + setTimeout(() => fitView({ padding: 0.2 }), 100) + }) + } + }, [schema]) + + const onNodeDragStop = useCallback((_: any, __: any, allNodes: Node[]) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => savePositions(allNodes), 500) + }, [savePositions]) + + const onMoveEnd = useCallback((_: any, vp: any) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => saveViewport(vp), 500) + }, [saveViewport]) + + return ( + + + + ) +} + +function DatabasePanel() { + const { data, isLoading, refetch } = useQuery({ + queryKey: ['admin', 'schema'], + queryFn: () => fetch('/api/admin/schema', { credentials: 'include' }).then((r) => r.json()), + }) + const schema: ParsedSchema | null = data?.schema ?? null + + return ( + + + Database Schema + refetch()}> + + {isLoading ?
: schema ? ( + + + + + + ) : Schema tidak tersedia} +
+ ) +} + +// ─── Project Panel ───────────────────────────────────────────────────────────── + +const PROJECT_VIEWS = [ + { group: 'Architecture', items: [ + { value: 'api-routes', label: 'API Routes' }, + { value: 'file-structure', label: 'File Structure' }, + { value: 'user-flow', label: 'User Flow' }, + { value: 'data-flow', label: 'Data Flow' }, + ]}, + { group: 'DevOps', items: [ + { value: 'env-map', label: 'Env Variables' }, + { value: 'test-coverage', label: 'Test Coverage' }, + { value: 'dependencies', label: 'Dependencies' }, + { value: 'migrations', label: 'Migrations' }, + ]}, + { group: 'Live', items: [ + { value: 'sessions', label: 'Sessions' }, + { value: 'live-requests', label: 'Live Requests' }, + ]}, +] + +function GenericFlowPanel({ queryKey, queryFn, buildGraph }: { + queryKey: string[] + queryFn: () => Promise + buildGraph: (data: any) => { nodes: Node[]; edges: Edge[] } +}) { + const { data, isLoading, refetch } = useQuery({ queryKey, queryFn, refetchInterval: queryKey.includes('sessions') ? 10_000 : undefined }) + const { fitView, setViewport } = useReactFlow() + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const flowKey = queryKey.join('-') + const { savePositions, saveViewport, loadPositions, loadViewport } = useFlowAutoSave(flowKey) + const saveTimer = useRef | undefined>(undefined) + + useEffect(() => { + if (!data) return + const { nodes: newNodes, edges: newEdges } = buildGraph(data) + const savedPos = loadPositions() + const hasSaved = Object.keys(savedPos).length > 0 + + if (hasSaved) { + const withPos = newNodes.map((n) => ({ ...n, position: savedPos[n.id] ?? n.position })) + setNodes(withPos); setEdges(newEdges) + const savedVp = loadViewport() + if (savedVp) setTimeout(() => setViewport(savedVp), 50) + else setTimeout(() => fitView({ padding: 0.15 }), 50) + } else { + applyElkLayout(newNodes, newEdges, 'RIGHT').then(({ nodes: ln, edges: le }) => { + setNodes(ln); setEdges(le) + setTimeout(() => fitView({ padding: 0.15 }), 100) + }) + } + }, [data]) + + const onNodeDragStop = useCallback((_: any, __: any, all: Node[]) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => savePositions(all), 500) + }, [savePositions]) + const onMoveEnd = useCallback((_: any, vp: any) => { + clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => saveViewport(vp), 500) + }, [saveViewport]) + + if (isLoading) return
+ return ( + + + { refetch() }}> + + + + + + ) +} + +function SimpleNode({ data }: { data: any }) { + const methodColor: Record = { GET: 'green', POST: 'blue', PUT: 'orange', PATCH: 'yellow', DELETE: 'red', WS: 'violet', PAGE: 'gray' } + const authColor: Record = { public: 'gray', developer: 'violet', admin: 'blue', authenticated: 'teal', apiKeyOrSession: 'orange' } + return ( + + + + {data.method && {data.method}} + {data.auth && {data.auth}} + + {data.path ?? data.label} + {data.description && {data.description}} + + + ) +} + +const SIMPLE_NODE_TYPES = { simple: SimpleNode } + +function buildApiRoutesGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const routes: any[] = data?.routes ?? [] + const nodes: Node[] = routes.map((r, i) => ({ + id: r.path, type: 'simple', + position: { x: i * 30, y: i * 30 }, + data: { label: r.path, method: r.method, path: r.path, auth: r.auth, description: r.description }, + })) + const loginNode = nodes.find((n) => n.id === '/login') + const edges: Edge[] = [] + if (loginNode) { + for (const n of nodes) { + if (['/dev', '/dashboard', '/profile'].includes(n.id)) { + edges.push({ id: `login-${n.id}`, source: '/login', target: n.id, label: '', markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: 'var(--mantine-color-blue-5)', strokeDasharray: '4' } }) + } + } + } + return { nodes, edges } +} + +function buildFileStructureGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const files: any[] = data?.files ?? [] + const catColor: Record = { route: 'blue', hook: 'cyan', component: 'teal', frontend: 'green', lib: 'orange', backend: 'red', prisma: 'violet', 'test-unit': 'yellow', 'test-integration': 'yellow', test: 'yellow', config: 'gray' } + const nodes: Node[] = files.map((f, i) => ({ + id: f.path, type: 'simple', + position: { x: i * 30, y: i * 30 }, + data: { label: f.path.split('/').pop(), path: f.path, description: `${f.lines} lines • ${f.exports.length} exports`, auth: f.category }, + style: { '--badge-color': catColor[f.category] ?? 'gray' } as React.CSSProperties, + })) + const edges: Edge[] = [] + for (const f of files) { + for (const imp of f.imports) { + if (files.find((x) => x.path === imp.from)) { + edges.push({ id: `${f.path}-${imp.from}`, source: f.path, target: imp.from, markerEnd: { type: MarkerType.ArrowClosed }, style: { stroke: 'var(--mantine-color-gray-5)', opacity: 0.5 } }) + } + } + } + return { nodes, edges } +} + +function buildEnvMapGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const vars: any[] = data?.variables ?? [] + const nodes: Node[] = [ + ...vars.map((v, i) => ({ + id: `env_${v.name}`, type: 'simple', + position: { x: 0, y: i * 70 }, + data: { label: v.name, path: v.name, method: v.required ? 'REQ' : 'OPT', auth: v.isSet ? 'set' : 'unset', description: v.description }, + })), + ...[...new Set(vars.flatMap((v) => v.usedBy))].map((f: string, i) => ({ + id: `file_${f}`, type: 'simple', + position: { x: 400, y: i * 60 }, + data: { label: f.split('/').pop(), path: f, description: f }, + })), + ] + const edges: Edge[] = vars.flatMap((v) => + v.usedBy.map((f: string) => ({ id: `${v.name}-${f}`, source: `env_${v.name}`, target: `file_${f}`, markerEnd: { type: MarkerType.ArrowClosed } })) + ) + return { nodes, edges } +} + +function buildTestCoverageGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const src: any[] = data?.sourceFiles ?? [] + const tests: any[] = data?.testFiles ?? [] + const covColor: Record = { covered: 'green', partial: 'yellow', uncovered: 'red' } + const nodes: Node[] = [ + ...src.map((f, i) => ({ + id: f.path, type: 'simple', + position: { x: 0, y: i * 60 }, + data: { label: f.path.split('/').pop(), path: f.path, auth: f.coverage, description: `${f.lines} lines • ${f.coverage}` }, + })), + ...tests.map((t, i) => ({ + id: t.path, type: 'simple', + position: { x: 400, y: i * 60 }, + data: { label: t.path.split('/').pop(), path: t.path, description: `${t.lines} lines • ${t.type}` }, + })), + ] + const edges: Edge[] = tests.flatMap((t) => + t.targets.map((target: string) => ({ id: `${t.path}-${target}`, source: target, target: t.path, markerEnd: { type: MarkerType.ArrowClosed } })) + ) + return { nodes, edges } +} + +function buildDependenciesGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const pkgs: any[] = data?.packages ?? [] + const catColor: Record = { server: 'orange', ui: 'blue', database: 'green', storage: 'cyan', build: 'gray', other: 'gray' } + const nodes: Node[] = [ + ...pkgs.map((p, i) => ({ + id: `pkg_${p.name}`, type: 'simple', + position: { x: 0, y: i * 55 }, + data: { label: p.name, method: p.type === 'runtime' ? 'RT' : 'DEV', auth: p.category, description: p.version }, + })), + ...[...new Set(pkgs.flatMap((p) => p.usedBy))].map((f: string, i) => ({ + id: `file_${f}`, type: 'simple', + position: { x: 350, y: i * 60 }, + data: { label: f.split('/').pop(), path: f, description: f }, + })), + ] + const edges: Edge[] = pkgs.flatMap((p) => + p.usedBy.map((f: string) => ({ id: `${p.name}-${f}`, source: `pkg_${p.name}`, target: `file_${f}`, markerEnd: { type: MarkerType.ArrowClosed } })) + ) + return { nodes, edges } +} + +function buildMigrationsGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const migrations: any[] = data?.migrations ?? [] + const nodes: Node[] = migrations.map((m, i) => ({ + id: m.folder, type: 'simple', + position: { x: i * 250, y: 100 }, + data: { label: m.name, description: `${m.changes.length} changes • ${new Date(m.createdAt).toLocaleDateString('id-ID')}` }, + })) + const edges: Edge[] = migrations.slice(1).map((m, i) => ({ + id: `mig-${i}`, source: migrations[i].folder, target: m.folder, markerEnd: { type: MarkerType.ArrowClosed }, + })) + return { nodes, edges } +} + +function buildSessionsGraph(data: any): { nodes: Node[]; edges: Edge[] } { + const sessions: any[] = data?.sessions ?? [] + const roleColor: Record = { DEVELOPER: 'violet', ADMIN: 'blue', USER: 'gray' } + const uniqueUsers = [...new Map(sessions.map((s) => [s.userId, s])).values()] + const roles = [...new Set(sessions.map((s) => s.userRole))] + const nodes: Node[] = [ + ...uniqueUsers.map((s, i) => ({ + id: `user_${s.userId}`, type: 'simple', + position: { x: 0, y: i * 70 }, + data: { label: s.userName, method: s.isOnline ? 'ON' : 'OFF', auth: s.userRole.toLowerCase(), description: s.userEmail }, + })), + ...roles.map((r, i) => ({ + id: `role_${r}`, type: 'simple', + position: { x: 350, y: i * 100 }, + data: { label: r, description: `Access: ${r === 'DEVELOPER' ? '/dev, /dashboard' : r === 'ADMIN' ? '/dashboard' : '/profile'}` }, + })), + ] + const edges: Edge[] = uniqueUsers.map((s) => ({ + id: `${s.userId}-${s.userRole}`, source: `user_${s.userId}`, target: `role_${s.userRole}`, markerEnd: { type: MarkerType.ArrowClosed }, + })) + return { nodes, edges } +} + +// Static flow graphs +function buildUserFlowGraph(): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = [ + { id: 'visit', type: 'simple', position: { x: 0, y: 0 }, data: { label: 'Visit App', description: 'User opens browser' } }, + { id: 'login', type: 'simple', position: { x: 200, y: 0 }, data: { label: '/login', description: 'Authentication page' } }, + { id: 'auth-check', type: 'simple', position: { x: 400, y: 0 }, data: { label: 'Auth Check', description: 'Validate session' } }, + { id: 'dev', type: 'simple', position: { x: 600, y: -100 }, data: { label: '/dev', auth: 'developer', description: 'Dev Console (DEVELOPER)' } }, + { id: 'dashboard', type: 'simple', position: { x: 600, y: 0 }, data: { label: '/dashboard', auth: 'admin', description: 'Admin Dashboard (ADMIN+)' } }, + { id: 'profile', type: 'simple', position: { x: 600, y: 100 }, data: { label: '/profile', auth: 'authenticated', description: 'User Profile (all)' } }, + ] + const edges: Edge[] = [ + { id: 'v-l', source: 'visit', target: 'login', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'l-a', source: 'login', target: 'auth-check', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'a-d', source: 'auth-check', target: 'dev', label: 'DEVELOPER', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'a-da', source: 'auth-check', target: 'dashboard', label: 'ADMIN', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'a-p', source: 'auth-check', target: 'profile', label: 'USER', markerEnd: { type: MarkerType.ArrowClosed } }, + ] + return { nodes, edges } +} + +function buildDataFlowGraph(): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = [ + { id: 'client', type: 'simple', position: { x: 0, y: 0 }, data: { label: 'Client (React)', description: 'TanStack Router + Query' } }, + { id: 'elysia', type: 'simple', position: { x: 200, y: 0 }, data: { label: 'Elysia.js', description: 'HTTP framework (Bun)' } }, + { id: 'auth-mw', type: 'simple', position: { x: 400, y: -80 }, data: { label: 'Auth Middleware', description: 'Session cookie / API Key' } }, + { id: 'handler', type: 'simple', position: { x: 400, y: 80 }, data: { label: 'Route Handler', description: 'Business logic' } }, + { id: 'prisma', type: 'simple', position: { x: 600, y: 0 }, data: { label: 'Prisma ORM', description: 'PostgreSQL queries' } }, + { id: 'minio', type: 'simple', position: { x: 600, y: 120 }, data: { label: 'MinIO', description: 'Object storage (images)' } }, + { id: 'redis', type: 'simple', position: { x: 600, y: -120 }, data: { label: 'Redis', description: 'App log ring buffer' } }, + { id: 'response', type: 'simple', position: { x: 800, y: 0 }, data: { label: 'JSON Response', description: 'Back to client' } }, + ] + const edges: Edge[] = [ + { id: 'c-e', source: 'client', target: 'elysia', label: 'HTTP / WS', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'e-a', source: 'elysia', target: 'auth-mw', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'e-h', source: 'elysia', target: 'handler', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'h-p', source: 'handler', target: 'prisma', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'h-m', source: 'handler', target: 'minio', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'h-r', source: 'handler', target: 'redis', markerEnd: { type: MarkerType.ArrowClosed } }, + { id: 'p-res', source: 'prisma', target: 'response', markerEnd: { type: MarkerType.ArrowClosed } }, + ] + return { nodes, edges } +} + +// Live Requests sub-view +function LiveRequestsPanel() { + const [requests, setRequests] = useState([]) + const [paused, setPaused] = useState(false) + const wsRef = useRef(null) + + useEffect(() => { + const proto = location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${location.host}/ws/presence`) + wsRef.current = ws + ws.onmessage = (e) => { + const msg = JSON.parse(e.data) + if (msg.type === 'request' && !paused) { + setRequests((prev) => [msg, ...prev].slice(0, 100)) + } + } + return () => { ws.close() } + }, [paused]) + + const statusColor = (s: number) => s >= 500 ? 'red' : s >= 400 ? 'orange' : s >= 300 ? 'yellow' : 'green' + + return ( + + + Live Requests + + + + + + + + + + Time + Method + Path + Status + Duration + + + + {requests.map((r, i) => ( + + {new Date(r.timestamp).toLocaleTimeString('id-ID')} + )[r.method] ?? 'gray'} variant="filled">{r.method} + {r.path} + {r.status} + {r.duration}ms + + ))} + {requests.length === 0 && ( +
Menunggu request masuk...
+ )} +
+
+
+
+ ) +} + +function ProjectPanel() { + const [view, setView] = useState('api-routes') + + const viewData = useMemo(() => { + switch (view) { + case 'api-routes': return { + queryKey: ['admin', 'routes'], + queryFn: () => fetch('/api/admin/routes', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildApiRoutesGraph, + } + case 'file-structure': return { + queryKey: ['admin', 'project-structure'], + queryFn: () => fetch('/api/admin/project-structure', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildFileStructureGraph, + } + case 'env-map': return { + queryKey: ['admin', 'env-map'], + queryFn: () => fetch('/api/admin/env-map', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildEnvMapGraph, + } + case 'test-coverage': return { + queryKey: ['admin', 'test-coverage'], + queryFn: () => fetch('/api/admin/test-coverage', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildTestCoverageGraph, + } + case 'dependencies': return { + queryKey: ['admin', 'dependencies'], + queryFn: () => fetch('/api/admin/dependencies', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildDependenciesGraph, + } + case 'migrations': return { + queryKey: ['admin', 'migrations'], + queryFn: () => fetch('/api/admin/migrations', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildMigrationsGraph, + } + case 'sessions': return { + queryKey: ['admin', 'sessions'], + queryFn: () => fetch('/api/admin/sessions', { credentials: 'include' }).then((r) => r.json()), + buildGraph: buildSessionsGraph, + } + default: return null + } + }, [view]) + + const isStaticView = view === 'user-flow' || view === 'data-flow' + const isLiveView = view === 'live-requests' + + const staticGraph = useMemo(() => { + if (view === 'user-flow') return buildUserFlowGraph() + if (view === 'data-flow') return buildDataFlowGraph() + return null + }, [view]) + + return ( + + + Project +