Initial commit: full-stack Bun + Elysia + React template
Elysia.js API with session-based auth (email/password + Google OAuth), role system (USER/ADMIN/SUPER_ADMIN), Prisma + PostgreSQL, React 19 with Mantine UI, TanStack Router, dark theme, and comprehensive test suite (unit, integration, E2E with Lightpanda). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
115
src/frontend/routes/login.tsx
Normal file
115
src/frontend/routes/login.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { FcGoogle } from 'react-icons/fc'
|
||||
import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb'
|
||||
import { useLogin } from '@/frontend/hooks/useAuth'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
error: (search.error as string) || undefined,
|
||||
}),
|
||||
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: data.user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' })
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) return
|
||||
throw e
|
||||
}
|
||||
},
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
function LoginPage() {
|
||||
const login = useLogin()
|
||||
const { error: searchError } = Route.useSearch()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
login.mutate({ email, password })
|
||||
}
|
||||
|
||||
return (
|
||||
<Center mih="100vh">
|
||||
<Paper shadow="md" p="xl" radius="md" w={400} withBorder>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Title order={2} ta="center">
|
||||
Login
|
||||
</Title>
|
||||
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
Demo: <strong>superadmin@example.com</strong> / <strong>superadmin123</strong>
|
||||
<br />
|
||||
atau: <strong>user@example.com</strong> / <strong>user123</strong>
|
||||
</Text>
|
||||
|
||||
{(login.isError || searchError) && (
|
||||
<Alert icon={<TbAlertCircle size={16} />} color="red" variant="light">
|
||||
{login.isError ? login.error.message : 'Login dengan Google gagal, coba lagi.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="email@example.com"
|
||||
leftSection={<TbMail size={16} />}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Password"
|
||||
leftSection={<TbLock size={16} />}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
leftSection={<TbLogin size={18} />}
|
||||
loading={login.isPending}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
<Divider label="atau" labelPosition="center" />
|
||||
|
||||
<Button
|
||||
component="a"
|
||||
href="/api/auth/google"
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<FcGoogle size={18} />}
|
||||
>
|
||||
Login dengan Google
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user