Initial commit: full-stack Bun + Elysia + React template

Elysia.js API with session-based auth (email/password + Google OAuth),
role system (USER/ADMIN/SUPER_ADMIN), Prisma + PostgreSQL, React 19
with Mantine UI, TanStack Router, dark theme, and comprehensive test
suite (unit, integration, E2E with Lightpanda).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
bipproduction
2026-04-01 10:12:19 +08:00
commit 08a1054e3c
57 changed files with 3732 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import { NotFound } from '@/frontend/components/NotFound'
import { ErrorPage } from '@/frontend/components/ErrorPage'
interface RouterContext {
queryClient: QueryClient
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
notFoundComponent: NotFound,
errorComponent: ({ error }) => <ErrorPage error={error} />,
})

View File

@@ -0,0 +1,94 @@
import {
Avatar,
Badge,
Button,
Card,
Container,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { TbChartBar, TbLogout, TbSettings, TbUsers } from 'react-icons/tb'
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
export const Route = createFileRoute('/dashboard')({
beforeLoad: async ({ context }) => {
try {
const data = await context.queryClient.ensureQueryData({
queryKey: ['auth', 'session'],
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
})
if (!data?.user) throw redirect({ to: '/login' })
if (data.user.role !== 'SUPER_ADMIN') throw redirect({ to: '/profile' })
} catch (e) {
if (e instanceof Error) throw redirect({ to: '/login' })
throw e
}
},
component: DashboardPage,
})
const stats = [
{ title: 'Users', value: '1,234', icon: TbUsers, color: 'blue' },
{ title: 'Revenue', value: '$12.4k', icon: TbChartBar, color: 'green' },
{ title: 'Settings', value: '3 active', icon: TbSettings, color: 'violet' },
]
function DashboardPage() {
const { data } = useSession()
const logout = useLogout()
const user = data?.user
return (
<Container size="md" py="xl">
<Stack gap="xl">
<Group justify="space-between">
<Title order={2}>Dashboard</Title>
<Button
variant="light"
color="red"
leftSection={<TbLogout size={16} />}
onClick={() => logout.mutate()}
loading={logout.isPending}
>
Logout
</Button>
</Group>
<Paper withBorder p="lg" radius="md">
<Group>
<Avatar color="blue" radius="xl" size="lg">
{user?.name?.charAt(0).toUpperCase()}
</Avatar>
<div>
<Group gap="xs">
<Text fw={500}>{user?.name}</Text>
<Badge color="red" variant="light" size="sm">SUPER ADMIN</Badge>
</Group>
<Text c="dimmed" size="sm">{user?.email}</Text>
</div>
</Group>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{stats.map((stat) => (
<Card key={stat.title} withBorder padding="lg" radius="md">
<Group justify="space-between" mb="xs">
<Text size="sm" c="dimmed" fw={500}>{stat.title}</Text>
<ThemeIcon variant="light" color={stat.color} size="sm">
<stat.icon size={14} />
</ThemeIcon>
</Group>
<Text fw={700} size="xl">{stat.value}</Text>
</Card>
))}
</SimpleGrid>
</Stack>
</Container>
)
}

View File

@@ -0,0 +1,36 @@
import { Button, Container, Group, Stack, Text, Title } from '@mantine/core'
import { Link, createFileRoute } from '@tanstack/react-router'
import { SiBun } from 'react-icons/si'
import { TbBrandReact, TbLogin, TbRocket } from 'react-icons/tb'
export const Route = createFileRoute('/')({
component: HomePage,
})
function HomePage() {
return (
<Container size="sm" py="xl">
<Stack align="center" gap="lg">
<Group gap="lg">
<SiBun size={64} color="#fbf0df" />
<TbBrandReact size={64} color="#61dafb" />
</Group>
<Title order={1}>Bun + Elysia + Vite + React</Title>
<Text c="dimmed" ta="center" maw={480}>
Full-stack starter template with Mantine UI, TanStack Router, and session-based auth.
</Text>
<Group>
<Button component={Link} to="/login" leftSection={<TbLogin size={18} />} variant="filled">
Login
</Button>
<Button component={Link} to="/dashboard" leftSection={<TbRocket size={18} />} variant="light">
Dashboard
</Button>
</Group>
</Stack>
</Container>
)
}

View File

@@ -0,0 +1,115 @@
import {
Alert,
Button,
Center,
Divider,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState } from 'react'
import { FcGoogle } from 'react-icons/fc'
import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb'
import { useLogin } from '@/frontend/hooks/useAuth'
export const Route = createFileRoute('/login')({
validateSearch: (search: Record<string, unknown>) => ({
error: (search.error as string) || undefined,
}),
beforeLoad: async ({ context }) => {
try {
const data = await context.queryClient.ensureQueryData({
queryKey: ['auth', 'session'],
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
})
if (data?.user) {
throw redirect({ to: data.user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' })
}
} catch (e) {
if (e instanceof Error) return
throw e
}
},
component: LoginPage,
})
function LoginPage() {
const login = useLogin()
const { error: searchError } = Route.useSearch()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
login.mutate({ email, password })
}
return (
<Center mih="100vh">
<Paper shadow="md" p="xl" radius="md" w={400} withBorder>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Title order={2} ta="center">
Login
</Title>
<Text c="dimmed" size="sm" ta="center">
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
<br />
atau: <strong>user@example.com</strong> / <strong>user123</strong>
</Text>
{(login.isError || searchError) && (
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
{login.isError ? login.error.message : 'Login dengan Google gagal, coba lagi.'}
</Alert>
)}
<TextInput
label="Email"
placeholder="email@example.com"
leftSection={<TbMail size={16} />}
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
required
/>
<PasswordInput
label="Password"
placeholder="Password"
leftSection={<TbLock size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
required
/>
<Button
type="submit"
fullWidth
leftSection={<TbLogin size={18} />}
loading={login.isPending}
>
Sign in
</Button>
<Divider label="atau" labelPosition="center" />
<Button
component="a"
href="/api/auth/google"
fullWidth
variant="default"
leftSection={<FcGoogle size={18} />}
>
Login dengan Google
</Button>
</Stack>
</form>
</Paper>
</Center>
)
}

View File

@@ -0,0 +1,97 @@
import {
Avatar,
Badge,
Button,
Container,
Group,
Paper,
Stack,
Text,
Title,
} from '@mantine/core'
import { createFileRoute, redirect } from '@tanstack/react-router'
import { TbLogout, TbUser } from 'react-icons/tb'
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
export const Route = createFileRoute('/profile')({
beforeLoad: async ({ context }) => {
try {
const data = await context.queryClient.ensureQueryData({
queryKey: ['auth', 'session'],
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
})
if (!data?.user) throw redirect({ to: '/login' })
} catch (e) {
if (e instanceof Error) throw redirect({ to: '/login' })
throw e
}
},
component: ProfilePage,
})
const roleBadgeColor: Record<string, string> = {
USER: 'blue',
ADMIN: 'violet',
SUPER_ADMIN: 'red',
}
function ProfilePage() {
const { data } = useSession()
const logout = useLogout()
const user = data?.user
return (
<Container size="sm" py="xl">
<Stack gap="xl">
<Group justify="space-between">
<Title order={2}>Profile</Title>
<Button
variant="light"
color="red"
leftSection={<TbLogout size={16} />}
onClick={() => logout.mutate()}
loading={logout.isPending}
>
Logout
</Button>
</Group>
<Paper withBorder p="xl" radius="md">
<Stack align="center" gap="md">
<Avatar color="blue" radius="xl" size={80}>
{user?.name?.charAt(0).toUpperCase()}
</Avatar>
<div style={{ textAlign: 'center' }}>
<Text fw={600} size="lg">{user?.name}</Text>
<Text c="dimmed" size="sm">{user?.email}</Text>
</div>
<Badge color={roleBadgeColor[user?.role ?? 'USER']} variant="light" size="lg">
{user?.role}
</Badge>
</Stack>
</Paper>
<Paper withBorder p="lg" radius="md">
<Stack gap="sm">
<Group gap="xs">
<TbUser size={16} />
<Text fw={500} size="sm">Account Info</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Name</Text>
<Text size="sm">{user?.name}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Email</Text>
<Text size="sm">{user?.email}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Role</Text>
<Text size="sm">{user?.role}</Text>
</Group>
</Stack>
</Paper>
</Stack>
</Container>
)
}