- 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>
116 lines
3.3 KiB
TypeScript
116 lines
3.3 KiB
TypeScript
import {
|
|
Alert,
|
|
Avatar,
|
|
Badge,
|
|
Button,
|
|
Container,
|
|
Group,
|
|
Paper,
|
|
Stack,
|
|
Text,
|
|
Title,
|
|
} from '@mantine/core'
|
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
import { TbClock, TbLogout, TbUser } from 'react-icons/tb'
|
|
import { useLogout, useSession } from '@/frontend/hooks/useAuth'
|
|
|
|
export const Route = createFileRoute('/profile')({
|
|
beforeLoad: async ({ context }) => {
|
|
try {
|
|
const data = await context.queryClient.ensureQueryData({
|
|
queryKey: ['auth', 'session'],
|
|
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
|
})
|
|
if (!data?.user) throw redirect({ to: '/login' })
|
|
} catch (e) {
|
|
if (e instanceof Error) throw redirect({ to: '/login' })
|
|
throw e
|
|
}
|
|
},
|
|
component: ProfilePage,
|
|
})
|
|
|
|
const roleBadgeColor: Record<string, string> = {
|
|
USER: 'gray',
|
|
ADMIN: 'violet',
|
|
DEVELOPER: 'red',
|
|
}
|
|
|
|
function ProfilePage() {
|
|
const { data } = useSession()
|
|
const logout = useLogout()
|
|
const user = data?.user
|
|
|
|
return (
|
|
<Container size="sm" py="xl">
|
|
<Stack gap="xl">
|
|
<Group justify="space-between">
|
|
<Title order={2}>Profile</Title>
|
|
<Button
|
|
variant="light"
|
|
color="red"
|
|
leftSection={<TbLogout size={16} />}
|
|
onClick={() => logout.mutate()}
|
|
loading={logout.isPending}
|
|
>
|
|
Logout
|
|
</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
|
|
src={user?.image ?? undefined}
|
|
color="blue"
|
|
radius="xl"
|
|
size={80}
|
|
>
|
|
{user?.name?.charAt(0).toUpperCase()}
|
|
</Avatar>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<Text fw={600} size="lg">{user?.name}</Text>
|
|
<Text c="dimmed" size="sm">{user?.email}</Text>
|
|
</div>
|
|
<Badge color={roleBadgeColor[user?.role ?? 'USER']} variant="light" size="lg">
|
|
{user?.role}
|
|
</Badge>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
<Paper withBorder p="lg" radius="md">
|
|
<Stack gap="sm">
|
|
<Group gap="xs">
|
|
<TbUser size={16} />
|
|
<Text fw={500} size="sm">Account Info</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Name</Text>
|
|
<Text size="sm">{user?.name}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Email</Text>
|
|
<Text size="sm">{user?.email}</Text>
|
|
</Group>
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">Role</Text>
|
|
<Text size="sm">{user?.role}</Text>
|
|
</Group>
|
|
</Stack>
|
|
</Paper>
|
|
</Stack>
|
|
</Container>
|
|
)
|
|
}
|