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

@@ -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>

View File

@@ -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' }}>

View File

@@ -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