From 94724a508185d381f3d3c19a31091c838f509153 Mon Sep 17 00:00:00 2001 From: amaliadwiy Date: Tue, 28 Apr 2026 15:06:13 +0800 Subject: [PATCH] feat: add Google OAuth login with USER role and pending approval flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../migration.sql | 21 +++ prisma/schema.prisma | 6 +- src/app.ts | 125 +++++++++++++++++- src/frontend/components/DashboardLayout.tsx | 24 +++- src/frontend/hooks/useAuth.ts | 5 +- src/frontend/routes/login.tsx | 22 ++- src/frontend/routes/profile.tsx | 23 +++- src/frontend/routes/users.tsx | 13 +- src/lib/env.ts | 1 + 9 files changed, 219 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql diff --git a/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql b/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql new file mode 100644 index 0000000..b64087c --- /dev/null +++ b/prisma/migrations/20260428144413_add_google_oauth_user_role/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5667144..475e633 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,6 +9,7 @@ datasource db { } enum Role { + USER ADMIN DEVELOPER } @@ -41,8 +42,9 @@ model User { id String @id @default(uuid()) name String email String @unique - password String - role Role @default(ADMIN) + password String? + googleId String? @unique + role Role @default(USER) active Boolean @default(true) image String? createdAt DateTime @default(now()) diff --git a/src/app.ts b/src/app.ts index be47ff6..8eb4205 100644 --- a/src/app.ts +++ b/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 + 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: { diff --git a/src/frontend/components/DashboardLayout.tsx b/src/frontend/components/DashboardLayout.tsx index 6891f49..dfb3293 100644 --- a/src/frontend/components/DashboardLayout.tsx +++ b/src/frontend/components/DashboardLayout.tsx @@ -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 ( +
+ +
+ ) + } + return ( (path: string, init?: RequestInit): Promise { @@ -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' }) }, }) } diff --git a/src/frontend/routes/login.tsx b/src/frontend/routes/login.tsx index 5432337..f534cfc 100644 --- a/src/frontend/routes/login.tsx +++ b/src/frontend/routes/login.tsx @@ -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) && ( } 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.' + )} )} @@ -89,6 +96,17 @@ function LoginPage() { > Sign in + + + + diff --git a/src/frontend/routes/profile.tsx b/src/frontend/routes/profile.tsx index be019ed..2a99298 100644 --- a/src/frontend/routes/profile.tsx +++ b/src/frontend/routes/profile.tsx @@ -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 = { + USER: 'gray', ADMIN: 'violet', DEVELOPER: 'red', } @@ -55,9 +57,26 @@ function ProfilePage() { + {user?.role === 'USER' && ( + } + 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. + + )} + - + {user?.name?.charAt(0).toUpperCase()}
diff --git a/src/frontend/routes/users.tsx b/src/frontend/routes/users.tsx index 57ae6d6..00a7793 100644 --- a/src/frontend/routes/users.tsx +++ b/src/frontend/routes/users.tsx @@ -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() {