feat: implement user authentication and dashboard

Adds a complete user authentication system and a protected dashboard.

- Implements JWT-based authentication using ElysiaJS.
- Integrates Prisma for database access and user management.
- Creates a login page and protected routes for the dashboard.
- Adds a dashboard layout with pages for API key management.
- Includes necessary UI components from Mantine.
This commit is contained in:
bipproduction
2025-10-07 16:53:00 +08:00
parent 35caccdd44
commit 2159a86b5d
49 changed files with 12534 additions and 12 deletions

View File

@@ -1,11 +1,14 @@
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
import { Notifications } from '@mantine/notifications';
import { MantineProvider } from '@mantine/core';
import AppRoutes from './AppRoutes';
export function App() {
return <MantineProvider>
<Notifications />
<AppRoutes />
</MantineProvider>;
}

View File

@@ -2,12 +2,26 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import NotFound from "./pages/NotFound";
import Login from "./pages/Login";
import ProtectedRoute from "./components/ProtectedRoute";
import Dashboard from "./pages/dashboard/dashboard_page";
import DashboardLayout from "./pages/dashboard/dashboard_layout";
import ApiKeyPage from "./pages/dashboard/apikey/apikey_page";
export default function AppRoutes() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<Dashboard />} />
<Route path="landing" element={<Dashboard />} />
<Route path="apikey" element={<ApiKeyPage />} />
</Route>
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>

11
src/clientRoutes.ts Normal file
View File

@@ -0,0 +1,11 @@
// AUTO-GENERATED FILE
const clientRoutes = {
"/": "/",
"/login": "/login",
"/dashboard": "/dashboard",
"/dashboard/landing": "/dashboard/landing",
"/dashboard/apikey": "/dashboard/apikey",
"/*": "/*"
} as const;
export default clientRoutes;

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react'
import { Navigate, Outlet } from 'react-router-dom'
import clientRoutes from '@/clientRoutes'
import apiFetch from '@/lib/apiFetch'
export default function ProtectedRoute() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null)
useEffect(() => {
async function checkSession() {
try {
// backend otomatis baca cookie JWT dari request
const res = await apiFetch.api.user.find.get()
setIsAuthenticated(res.status === 200)
} catch {
setIsAuthenticated(false)
}
}
checkSession()
}, [])
if (isAuthenticated === null) return null // or loading spinner
if (!isAuthenticated) return <Navigate to={clientRoutes['/login']} replace />
return <Outlet />
}

View File

@@ -1,28 +1,45 @@
import Elysia from "elysia";
import Elysia, { t } from "elysia";
import Swagger from "@elysiajs/swagger";
import html from "./index.html"
import Dashboard from "./server/routes/darmasaba";
import apiAuth from "./server/middlewares/apiAuth";
import Auth from "./server/routes/auth_route";
import ApiKeyRoute from "./server/routes/apikey_route";
import type { User } from "generated/prisma";
const Docs = new Elysia({})
const Docs = new Elysia()
.use(Swagger({
path: "/docs",
}))
const ApiUser = new Elysia({
prefix: "/user",
})
.get('/find', (ctx) => {
const { user } = ctx as any
return {
user: user as User
}
})
const Api = new Elysia({
prefix: "/api",
})
.use(Docs)
.post("/hello", () => "Hello, world!")
.use(apiAuth)
.use(ApiKeyRoute)
.use(Dashboard)
.use(ApiUser)
const app = new Elysia()
.use(Api)
.get("/*", html)
.use(Docs)
.use(Auth)
.get("*", html)
.listen(3000, () => {
console.log("Server running at http://localhost:3000");
});
export type Server = typeof app;
export type ServerApp = typeof app;

11
src/lib/apiFetch.ts Normal file
View File

@@ -0,0 +1,11 @@
import { treaty } from '@elysiajs/eden'
import type { ServerApp } from '..'
const URL = process.env.BUN_PUBLIC_BASE_URL
if (!URL) {
throw new Error('BUN_PUBLIC_BASE_URL is not defined')
}
const apiFetch = treaty<ServerApp>(URL)
export default apiFetch

46
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { Button, Container, Group, Stack, Text, TextInput } from "@mantine/core";
import { useState } from "react";
import apiFetch from "../lib/apiFetch";
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
setLoading(true)
try {
const response = await apiFetch.auth.login.post({
email,
password,
})
if (response.data?.token) {
localStorage.setItem('token', response.data.token)
window.location.href = '/dashboard'
return
}
if (response.error) {
alert(JSON.stringify(response.error))
}
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
return (
<Container>
<Stack>
<Text>Login</Text>
<TextInput placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
<TextInput placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} />
<Group justify="right">
<Button onClick={handleSubmit} disabled={loading}>Login</Button>
</Group>
</Stack>
</Container>
)
}

View File

@@ -0,0 +1,112 @@
import { Button, Card, Container, Group, Stack, Table, Text, TextInput } from "@mantine/core";
import { useEffect, useState } from "react";
import apiFetch from "@/lib/apiFetch";
import { showNotification } from "@mantine/notifications";
export default function ApiKeyPage() {
return (
<Container size="md" w={"100%"}>
<Stack>
<Text>API Key</Text>
<CreateApiKey />
</Stack>
</Container>
);
}
function CreateApiKey() {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [expiredAt, setExpiredAt] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const res = await apiFetch.api.apikey.create.post({ name, description, expiredAt });
if (res.status === 200) {
setName('');
setDescription('');
setExpiredAt('');
showNotification({
title: 'Success',
message: 'API key created successfully',
color: 'green',
})
}
setLoading(false);
}
return (
<Card >
<Stack>
<Text>API Create</Text>
<TextInput label="Name" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<TextInput label="Description" placeholder="Description" value={description} onChange={(e) => setDescription(e.target.value)} />
<TextInput label="Expired At" placeholder="Expired At" type="date" value={expiredAt} onChange={(e) => setExpiredAt(e.target.value)} />
<Group>
<Button variant="outline" onClick={() => { setName(''); setDescription(''); setExpiredAt(''); }}>Cancel</Button>
<Button onClick={handleSubmit} type="submit" loading={loading}>Save</Button>
</Group>
<ListApiKey />
</Stack>
</Card>
);
}
function ListApiKey() {
const [apiKeys, setApiKeys] = useState<any[]>([]);
useEffect(() => {
const fetchApiKeys = async () => {
const res = await apiFetch.api.apikey.list.get();
if (res.status === 200) {
setApiKeys(res.data?.apiKeys || []);
}
}
fetchApiKeys();
}, []);
return (
<Card>
<Stack>
<Text>API List</Text>
<Table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Expired At</th>
<th>Created At</th>
<th>Updated At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{apiKeys.map((apiKey: any, index: number) => (
<tr key={index}>
<td>{apiKey.name}</td>
<td>{apiKey.description}</td>
<td>{apiKey.expiredAt.toISOString().split('T')[0]}</td>
<td>{apiKey.createdAt.toISOString().split('T')[0]}</td>
<td>{apiKey.updatedAt.toISOString().split('T')[0]}</td>
<td>
<Button variant="outline" onClick={() => {
apiFetch.api.apikey.delete.delete({ id: apiKey.id })
setApiKeys(apiKeys.filter((api: any) => api.id !== apiKey.id))
}}>Delete</Button>
<Button variant="outline" onClick={() => {
navigator.clipboard.writeText(apiKey.key)
showNotification({
title: 'Success',
message: 'API key copied to clipboard',
color: 'green',
})
}}>Copy</Button>
</td>
</tr>
))}
</tbody>
</Table>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,180 @@
import { useEffect, useState } from 'react'
import {
ActionIcon,
AppShell,
Avatar,
Button,
Card,
Divider,
Flex,
Group,
NavLink,
Paper,
ScrollArea,
Stack,
Text,
Title,
Tooltip
} from '@mantine/core'
import { useLocalStorage } from '@mantine/hooks'
import {
IconChevronLeft,
IconChevronRight,
IconDashboard
} from '@tabler/icons-react'
import type { User } from 'generated/prisma'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { default as clientRoute, default as clientRoutes } from '@/clientRoutes'
import apiFetch from '@/lib/apiFetch'
function Logout() {
return <Group>
<Button variant='transparent' size='compact-xs' onClick={async () => {
await apiFetch.auth.logout.delete()
localStorage.removeItem('token')
window.location.href = '/login'
}}>Logout</Button>
</Group>
}
export default function DashboardLayout() {
const [opened, setOpened] = useLocalStorage({
key: 'nav_open',
defaultValue: true,
})
return (
<AppShell
padding="md"
navbar={{
width: 260,
breakpoint: 'sm',
collapsed: { mobile: !opened, desktop: !opened },
}}
>
<AppShell.Navbar>
<AppShell.Section>
<Group justify="flex-end" p="xs">
<Tooltip
label={opened ? 'Collapse navigation' : 'Expand navigation'}
withArrow
>
<ActionIcon
variant="light"
color="gray"
onClick={() => setOpened(v => !v)}
aria-label="Toggle navigation"
radius="xl"
>
{opened ? <IconChevronLeft /> : <IconChevronRight />}
</ActionIcon>
</Tooltip>
</Group>
</AppShell.Section>
<AppShell.Section grow component={ScrollArea} flex={1}>
<NavigationDashboard />
</AppShell.Section>
<AppShell.Section>
<HostView />
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main>
<Stack>
<Paper withBorder shadow="md" radius="lg" p="md">
<Flex align="center" gap="md">
{!opened && (
<Tooltip label="Open navigation menu" withArrow>
<ActionIcon
variant="light"
color="gray"
onClick={() => setOpened(true)}
aria-label="Open navigation"
radius="xl"
>
<IconChevronRight />
</ActionIcon>
</Tooltip>
)}
<Title order={3} fw={600}>
App Dashboard
</Title>
</Flex>
</Paper>
<Outlet />
</Stack>
</AppShell.Main>
</AppShell>
)
}
/* ----------------------- Host Info ----------------------- */
function HostView() {
const [host, setHost] = useState<User | null>(null)
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get()
setHost(data?.user ?? null)
}
fetchHost()
}, [])
return (
<Card radius="lg" withBorder shadow="sm" p="md">
{host ? (
<Stack>
<Flex gap="md" align="center">
<Avatar size="md" radius="xl" color="blue">
{host.name?.[0]}
</Avatar>
<Stack gap={2}>
<Text fw={600}>{host.name}</Text>
<Text size="sm" c="dimmed">{host.email}</Text>
</Stack>
</Flex>
<Divider />
<Logout />
</Stack>
) : (
<Text size="sm" c="dimmed" ta="center">
No host information available
</Text>
)}
</Card>
)
}
/* ----------------------- Navigation ----------------------- */
function NavigationDashboard() {
const navigate = useNavigate()
const location = useLocation()
const isActive = (path: keyof typeof clientRoute) =>
location.pathname.startsWith(clientRoute[path])
return (
<Stack gap="xs" p="sm">
<NavLink
active={isActive('/dashboard/landing')}
leftSection={<IconDashboard size={20} />}
label="Dashboard Overview"
description="Quick summary and activity highlights"
onClick={() => navigate(clientRoutes['/dashboard/landing'])}
/>
<NavLink
active={isActive('/dashboard/apikey')}
leftSection={<IconDashboard size={20} />}
label="Dashboard Overview"
description="Quick summary and activity highlights"
onClick={() => navigate(clientRoutes['/dashboard/apikey'])}
/>
</Stack>
)
}

View File

@@ -0,0 +1,11 @@
import apiFetch from "@/lib/apiFetch";
import { Button } from "@mantine/core";
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
</div>
);
}

11
src/server/lib/prisma.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from 'generated/prisma'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}

View File

@@ -0,0 +1,54 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import jwt, { type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia from 'elysia'
import { prisma } from '../lib/prisma'
const secret = process.env.JWT_SECRET
export default function apiAuth(app: Elysia) {
if (!secret) {
throw new Error('JWT_SECRET is not defined')
}
return app
.use(
jwt({
name: 'jwt',
secret,
})
)
.derive(async ({ cookie, headers, jwt }) => {
let token: string | undefined
if (cookie?.token?.value) {
token = cookie.token.value as any
}
if (headers['x-token']?.startsWith('Bearer ')) {
token = (headers['x-token'] as string).slice(7)
}
if (headers['authorization']?.startsWith('Bearer ')) {
token = (headers['authorization'] as string).slice(7)
}
let user: null | Awaited<ReturnType<typeof prisma.user.findUnique>> = null
if (token) {
try {
const decoded = (await jwt.verify(token)) as JWTPayloadSpec
if (decoded.sub) {
user = await prisma.user.findUnique({
where: { id: decoded.sub as string },
})
}
} catch (err) {
console.warn('[SERVER][apiAuth] Invalid token', err)
}
}
return { user }
})
.onBeforeHandle(({ user, set }) => {
if (!user) {
set.status = 401
return { error: 'Unauthorized' }
}
})
}

View File

@@ -0,0 +1,105 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia, { t } from 'elysia'
import { type User } from 'generated/prisma'
import { prisma } from '../lib/prisma'
const NINETY_YEARS = 60 * 60 * 24 * 365 * 90 // in seconds
type JWT = {
sign(data: Record<string, string | number> & JWTPayloadSpec): Promise<string>
verify(
jwt?: string
): Promise<false | (Record<string, string | number> & JWTPayloadSpec)>
}
const ApiKeyRoute = new Elysia({
prefix: '/apikey',
detail: { tags: ['apikey'] },
})
.post(
'/create',
async ctx => {
const { user }: { user: User } = ctx as any
const { name, description, expiredAt } = ctx.body
const { sign } = (ctx as any).jwt as JWT
// hitung expiredAt
const exp = expiredAt
? Math.floor(new Date(expiredAt).getTime() / 1000) // jika dikirim
: Math.floor(Date.now() / 1000) + NINETY_YEARS // default 90 tahun
const token = await sign({
sub: user.id,
aud: 'host',
exp,
payload: JSON.stringify({
name,
description,
expiredAt,
}),
})
const apiKey = await prisma.apiKey.create({
data: {
name,
description,
key: token,
userId: user.id,
expiredAt: new Date(exp * 1000), // simpan juga di DB biar gampang query
},
})
return { message: 'success', token, apiKey }
},
{
detail: {
summary: 'create api key',
},
body: t.Object({
name: t.String(),
description: t.String(),
expiredAt: t.Optional(t.String({ format: 'date-time' })), // ISO date string
}),
}
)
.get(
'/list',
async ctx => {
const { user }: { user: User } = ctx as any
const apiKeys = await prisma.apiKey.findMany({
where: {
userId: user.id,
},
})
return { message: 'success', apiKeys }
},
{
detail: {
summary: 'get api key list',
},
}
)
.delete(
'/delete',
async ctx => {
const { id } = ctx.body as { id: string }
const apiKey = await prisma.apiKey.delete({
where: {
id,
},
})
return { message: 'success', apiKey }
},
{
detail: {
summary: 'delete api key',
},
body: t.Object({
id: t.String(),
}),
}
)
export default ApiKeyRoute

View File

@@ -0,0 +1,155 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { jwt as jwtPlugin, type JWTPayloadSpec } from '@elysiajs/jwt'
import Elysia, { t, type Cookie, type HTTPHeaders, type StatusMap } from 'elysia'
import { type ElysiaCookie } from 'elysia/cookies'
import { prisma } from '@/server/lib/prisma'
import type { User } from 'generated/prisma'
const secret = process.env.JWT_SECRET
if (!secret) {
throw new Error('Missing JWT_SECRET in environment variables')
}
const isProd = process.env.NODE_ENV === 'production'
const NINETY_YEARS = 60 * 60 * 24 * 365 * 90
type JWT = {
sign(data: Record<string, string | number> & JWTPayloadSpec): Promise<string>
verify(
jwt?: string
): Promise<false | (Record<string, string | number> & JWTPayloadSpec)>
}
type COOKIE = Record<string, Cookie<string | undefined>>
type SET = {
headers: HTTPHeaders
status?: number | keyof StatusMap
redirect?: string
cookie?: Record<string, ElysiaCookie>
}
async function issueToken({
jwt,
cookie,
userId,
role,
expiresAt,
}: {
jwt: JWT
cookie: COOKIE
userId: string
role: 'host' | 'user'
expiresAt: number
}) {
const token = await jwt.sign({
sub: userId,
aud: role,
exp: expiresAt,
})
cookie.token?.set({
value: token,
httpOnly: true,
secure: isProd, // aktifkan hanya di production (HTTPS)
sameSite: 'strict',
maxAge: NINETY_YEARS,
path: '/',
})
return token
}
async function login({
body,
cookie,
set,
jwt,
}: {
body: { email: string; password: string }
cookie: COOKIE
set: SET
jwt: JWT
}) {
try {
const { email, password } = body
const user = await prisma.user.findUnique({
where: { email },
})
if (!user) {
set.status = 401
return { message: 'User not found' }
}
if (user.password !== password) {
set.status = 401
return { message: 'Invalid password' }
}
const token = await issueToken({
jwt,
cookie,
userId: user.id,
role: 'user',
expiresAt: Math.floor(Date.now() / 1000) + NINETY_YEARS,
})
return { token }
} catch (error) {
console.error('Error logging in:', error)
return {
message: 'Login failed',
error:
error instanceof Error ? error.message : JSON.stringify(error ?? null),
}
}
}
const Auth = new Elysia({
prefix: '/auth',
detail: { description: 'Auth API', summary: 'Auth API', tags: ['auth'] },
})
.use(
jwtPlugin({
name: 'jwt',
secret,
})
)
.post(
'/login',
async ({ jwt, body, cookie, set }) => {
return await login({
jwt: jwt as JWT,
body,
cookie: cookie as any,
set: set as any,
})
},
{
body: t.Object({
email: t.String(),
password: t.String(),
}),
detail: {
description: 'Login with phone; auto-register if not found',
summary: 'login',
},
}
)
.delete(
'/logout',
({ cookie }) => {
cookie.token?.remove()
return { message: 'Logout successful' }
},
{
detail: {
description: 'Logout (clear token cookie)',
summary: 'logout',
},
}
)
export default Auth

View File

@@ -0,0 +1,8 @@
import Elysia from "elysia";
const Dashboard = new Elysia({
prefix: "/dashboard"
})
.get("/apa", () => "Hello World")
export default Dashboard