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

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