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:
158
src/app.ts
Normal file
158
src/app.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { cors } from '@elysiajs/cors'
|
||||
import { html } from '@elysiajs/html'
|
||||
import { Elysia } from 'elysia'
|
||||
import { prisma } from './lib/db'
|
||||
import { env } from './lib/env'
|
||||
|
||||
export function createApp() {
|
||||
return new Elysia()
|
||||
.use(cors())
|
||||
.use(html())
|
||||
|
||||
// ─── Global Error Handler ────────────────────────
|
||||
.onError(({ code, error }) => {
|
||||
if (code === 'NOT_FOUND') {
|
||||
return new Response(JSON.stringify({ error: 'Not Found', status: 404 }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
console.error('[Server Error]', error)
|
||||
return new Response(JSON.stringify({ error: 'Internal Server Error', status: 500 }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
|
||||
// API routes
|
||||
.get('/health', () => ({ status: 'ok' }))
|
||||
|
||||
// ─── Auth API ──────────────────────────────────────
|
||||
.post('/api/auth/login', async ({ request, set }) => {
|
||||
const { email, password } = (await request.json()) as { email: string; password: string }
|
||||
let user = await prisma.user.findUnique({ where: { email } })
|
||||
if (!user || !(await Bun.password.verify(password, user.password))) {
|
||||
set.status = 401
|
||||
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' } })
|
||||
}
|
||||
const token = crypto.randomUUID()
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours
|
||||
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
||||
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
|
||||
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
|
||||
})
|
||||
|
||||
.post('/api/auth/logout', async ({ request, set }) => {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
if (token) await prisma.session.deleteMany({ where: { token } })
|
||||
set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0'
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
.get('/api/auth/session', async ({ request, set }) => {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
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 } } },
|
||||
})
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
if (session) await prisma.session.delete({ where: { id: session.id } })
|
||||
set.status = 401
|
||||
return { user: null }
|
||||
}
|
||||
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 } })
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
// ─── Example API ───────────────────────────────────
|
||||
.get('/api/hello', () => ({
|
||||
message: 'Hello, world!',
|
||||
method: 'GET',
|
||||
}))
|
||||
.put('/api/hello', () => ({
|
||||
message: 'Hello, world!',
|
||||
method: 'PUT',
|
||||
}))
|
||||
.get('/api/hello/:name', ({ params }) => ({
|
||||
message: `Hello, ${params.name}!`,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user