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:
125
src/app.ts
125
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<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: {
|
||||
|
||||
@@ -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 (
|
||||
<Center mih="100vh">
|
||||
<LoadingOverlay visible />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 70 }}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
export type Role = | 'ADMIN' | 'DEVELOPER'
|
||||
export type Role = 'USER' | 'ADMIN' | 'DEVELOPER'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: Role
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -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' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) && (
|
||||
<Alert icon={<TbAlertCircle size={16} />} 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.'
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -89,6 +96,17 @@ function LoginPage() {
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
<Divider label="or" labelPosition="center" />
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<FcGoogle size={18} />}
|
||||
onClick={() => { window.location.href = '/api/auth/google' }}
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
USER: 'gray',
|
||||
ADMIN: 'violet',
|
||||
DEVELOPER: 'red',
|
||||
}
|
||||
@@ -55,9 +57,26 @@ function ProfilePage() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{user?.role === 'USER' && (
|
||||
<Alert
|
||||
icon={<TbClock size={18} />}
|
||||
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.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="xl" radius="md">
|
||||
<Stack align="center" gap="md">
|
||||
<Avatar color="blue" radius="xl" size={80}>
|
||||
<Avatar
|
||||
src={user?.image ?? undefined}
|
||||
color="blue"
|
||||
radius="xl"
|
||||
size={80}
|
||||
>
|
||||
{user?.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
|
||||
@@ -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() {
|
||||
<Select
|
||||
label="Role"
|
||||
data={[
|
||||
{ value: 'USER', label: 'User (Pending)' },
|
||||
{ value: 'ADMIN', label: 'Admin' },
|
||||
{ value: 'DEVELOPER', label: 'Developer' },
|
||||
]}
|
||||
value={editForm.role}
|
||||
onChange={(val) => setEditForm({ ...editForm, role: val || 'USER' })}
|
||||
onChange={(val) => setEditForm({ ...editForm, role: val || 'ADMIN' })}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -12,6 +12,7 @@ export const env = {
|
||||
PORT: parseInt(optional('PORT', '3000'), 10),
|
||||
NODE_ENV: optional('NODE_ENV', 'development'),
|
||||
REACT_EDITOR: optional('REACT_EDITOR', 'code'),
|
||||
BASE_URL: optional('BUN_PUBLIC_BASE_URL', 'http://localhost:3000'),
|
||||
DATABASE_URL: required('DATABASE_URL'),
|
||||
GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'),
|
||||
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),
|
||||
|
||||
Reference in New Issue
Block a user