feat: add Google OAuth login with USER role and pending approval flow

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 15:06:13 +08:00
parent 9d80eb3b85
commit 94724a5081
9 changed files with 219 additions and 21 deletions

View File

@@ -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<string, string>
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: {