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

@@ -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");

View File

@@ -9,6 +9,7 @@ datasource db {
} }
enum Role { enum Role {
USER
ADMIN ADMIN
DEVELOPER DEVELOPER
} }
@@ -41,8 +42,9 @@ model User {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
email String @unique email String @unique
password String password String?
role Role @default(ADMIN) googleId String? @unique
role Role @default(USER)
active Boolean @default(true) active Boolean @default(true)
image String? image String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@@ -8,6 +8,14 @@ import { env } from './lib/env'
import { createSystemLog } from './lib/logger' import { createSystemLog } from './lib/logger'
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio' 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 { interface AuthResult {
actingUserId: string actingUserId: string
reporterUserId: string | null // null jika via API key (tidak ada user spesifik) reporterUserId: string | null // null jika via API key (tidak ada user spesifik)
@@ -80,10 +88,117 @@ export function createApp() {
}) })
// ─── Auth API ────────────────────────────────────── // ─── 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 }) => { .post('/api/auth/login', async ({ body, set }) => {
const { email, password } = body const { email, password } = body
let user = await prisma.user.findUnique({ where: { email } }) 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 set.status = 401
return { error: 'Email atau password salah' } return { error: 'Email atau password salah' }
} }
@@ -96,7 +211,7 @@ export function createApp() {
await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
await createSystemLog(user.id, 'LOGIN', 'Logged in successfully') 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({ body: t.Object({
email: t.String({ format: 'email', description: 'Email pengguna' }), email: t.String({ format: 'email', description: 'Email pengguna' }),
@@ -135,7 +250,7 @@ export function createApp() {
if (!token) { set.status = 401; return { user: null } } if (!token) { set.status = 401; return { user: null } }
const session = await prisma.session.findUnique({ const session = await prisma.session.findUnique({
where: { token }, 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 || session.expiresAt < new Date()) {
if (session) await prisma.session.delete({ where: { id: session.id } }) 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' }), name: t.String({ minLength: 1, description: 'Nama lengkap operator' }),
email: t.String({ format: 'email', description: 'Alamat email (harus unik)' }), email: t.String({ format: 'email', description: 'Alamat email (harus unik)' }),
password: t.String({ minLength: 6, description: 'Password (minimal 6 karakter)' }), 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: { detail: {
summary: 'Create Operator', summary: 'Create Operator',
@@ -494,7 +609,7 @@ export function createApp() {
body: t.Object({ body: t.Object({
name: t.Optional(t.String({ minLength: 1, description: 'Nama baru' })), name: t.Optional(t.String({ minLength: 1, description: 'Nama baru' })),
email: t.Optional(t.String({ format: 'email', description: 'Email 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' })), active: t.Optional(t.Boolean({ description: 'Status aktif operator' })),
}), }),
detail: { detail: {

View File

@@ -1,20 +1,25 @@
import { APP_CONFIGS } from '@/frontend/config/appMenus' import { APP_CONFIGS } from '@/frontend/config/appMenus'
import { useLogout, useSession } from '@/frontend/hooks/useAuth' import { useLogout, useSession } from '@/frontend/hooks/useAuth'
import React from 'react'
import { import {
ActionIcon, ActionIcon,
Alert,
AppShell, AppShell,
Avatar, Avatar,
Box, Box,
Burger, Burger,
Button, Button,
Center,
Group, Group,
Loader, Loader,
LoadingOverlay,
Menu, Menu,
NavLink, NavLink,
Select, Select,
Stack, Stack,
Text, Text,
ThemeIcon, ThemeIcon,
Title,
useComputedColorScheme, useComputedColorScheme,
useMantineColorScheme useMantineColorScheme
} from '@mantine/core' } from '@mantine/core'
@@ -26,6 +31,7 @@ import {
TbApps, TbApps,
TbArrowLeft, TbArrowLeft,
TbChevronRight, TbChevronRight,
TbClock,
TbDashboard, TbDashboard,
TbDeviceMobile, TbDeviceMobile,
TbHistory, TbHistory,
@@ -54,10 +60,17 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const currentPath = matches[matches.length - 1]?.pathname const currentPath = matches[matches.length - 1]?.pathname
// ─── Connect to auth system ────────────────────────── // ─── Connect to auth system ──────────────────────────
const { data: sessionData } = useSession() const { data: sessionData, isLoading: sessionLoading } = useSession()
const user = sessionData?.user const user = sessionData?.user
const logout = useLogout() 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 ───────────── // ─── Fetch registered apps from database ─────────────
const { data: appsData } = useQuery({ const { data: appsData } = useQuery({
queryKey: ['apps'], queryKey: ['apps'],
@@ -99,6 +112,15 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
logout.mutate() 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 ( return (
<AppShell <AppShell
header={{ height: 70 }} header={{ height: 70 }}

View File

@@ -1,13 +1,14 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
export type Role = | 'ADMIN' | 'DEVELOPER' export type Role = 'USER' | 'ADMIN' | 'DEVELOPER'
export interface User { export interface User {
id: string id: string
name: string name: string
email: string email: string
role: Role role: Role
image?: string | null
} }
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> { async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -41,7 +42,7 @@ export function useLogin() {
}), }),
onSuccess: (data) => { onSuccess: (data) => {
queryClient.setQueryData(['auth', 'session'], data) queryClient.setQueryData(['auth', 'session'], data)
navigate({ to: '/dashboard' }) navigate({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' })
}, },
}) })
} }

View File

@@ -27,7 +27,7 @@ export const Route = createFileRoute('/login')({
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()), queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
}) })
if (data?.user) { if (data?.user) {
throw redirect({ to: '/dashboard' }) throw redirect({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' })
} }
} catch (e) { } catch (e) {
if (e instanceof Error) return if (e instanceof Error) return
@@ -59,7 +59,14 @@ function LoginPage() {
{(login.isError || searchError) && ( {(login.isError || searchError) && (
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light"> <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> </Alert>
)} )}
@@ -89,6 +96,17 @@ function LoginPage() {
> >
Sign in Sign in
</Button> </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> </Stack>
</form> </form>
</Paper> </Paper>

View File

@@ -1,4 +1,5 @@
import { import {
Alert,
Avatar, Avatar,
Badge, Badge,
Button, Button,
@@ -10,7 +11,7 @@ import {
Title, Title,
} from '@mantine/core' } from '@mantine/core'
import { createFileRoute, redirect } from '@tanstack/react-router' 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' import { useLogout, useSession } from '@/frontend/hooks/useAuth'
export const Route = createFileRoute('/profile')({ export const Route = createFileRoute('/profile')({
@@ -30,6 +31,7 @@ export const Route = createFileRoute('/profile')({
}) })
const roleBadgeColor: Record<string, string> = { const roleBadgeColor: Record<string, string> = {
USER: 'gray',
ADMIN: 'violet', ADMIN: 'violet',
DEVELOPER: 'red', DEVELOPER: 'red',
} }
@@ -55,9 +57,26 @@ function ProfilePage() {
</Button> </Button>
</Group> </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"> <Paper withBorder p="xl" radius="md">
<Stack align="center" gap="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()} {user?.name?.charAt(0).toUpperCase()}
</Avatar> </Avatar>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>

View File

@@ -50,10 +50,8 @@ export const Route = createFileRoute('/users')({
const fetcher = (url: string) => fetch(url).then((res) => res.json()) const fetcher = (url: string) => fetch(url).then((res) => res.json())
const getRoleColor = (role: string) => { const getRoleColor = (role: string) => {
const r = (role || '').toLowerCase() if (role === 'DEVELOPER') return 'violet'
if (r.includes('super')) return 'red' if (role === 'ADMIN') return 'brand-blue'
if (r.includes('admin')) return 'brand-blue'
if (r.includes('developer')) return 'violet'
return 'gray' return 'gray'
} }
@@ -97,7 +95,7 @@ function UsersPage() {
name: '', name: '',
email: '', email: '',
password: '', password: '',
role: 'USER', role: 'ADMIN',
}) })
const handleCreateUser = async () => { const handleCreateUser = async () => {
@@ -119,7 +117,7 @@ function UsersPage() {
mutateOperators() mutateOperators()
mutateStats() mutateStats()
closeCreate() closeCreate()
setCreateForm({ name: '', email: '', password: '', role: 'USER' }) setCreateForm({ name: '', email: '', password: '', role: 'ADMIN' })
} else { } else {
const err = await res.json() const err = await res.json()
throw new Error(err.error || 'Failed to create user') throw new Error(err.error || 'Failed to create user')
@@ -457,11 +455,12 @@ function UsersPage() {
<Select <Select
label="Role" label="Role"
data={[ data={[
{ value: 'USER', label: 'User (Pending)' },
{ value: 'ADMIN', label: 'Admin' }, { value: 'ADMIN', label: 'Admin' },
{ value: 'DEVELOPER', label: 'Developer' }, { value: 'DEVELOPER', label: 'Developer' },
]} ]}
value={editForm.role} value={editForm.role}
onChange={(val) => setEditForm({ ...editForm, role: val || 'USER' })} onChange={(val) => setEditForm({ ...editForm, role: val || 'ADMIN' })}
/> />
<Button <Button
fullWidth fullWidth

View File

@@ -12,6 +12,7 @@ export const env = {
PORT: parseInt(optional('PORT', '3000'), 10), PORT: parseInt(optional('PORT', '3000'), 10),
NODE_ENV: optional('NODE_ENV', 'development'), NODE_ENV: optional('NODE_ENV', 'development'),
REACT_EDITOR: optional('REACT_EDITOR', 'code'), REACT_EDITOR: optional('REACT_EDITOR', 'code'),
BASE_URL: optional('BUN_PUBLIC_BASE_URL', 'http://localhost:3000'),
DATABASE_URL: required('DATABASE_URL'), DATABASE_URL: required('DATABASE_URL'),
GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'), GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'),
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'), GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),