From 8e50aff69e55615a639d85c86e9331a51c803780 Mon Sep 17 00:00:00 2001 From: nico Date: Tue, 2 Sep 2025 11:28:48 +0800 Subject: [PATCH] Login & User Role - 1 --- bun.lockb | Bin 333808 -> 344180 bytes next.config.ts | 35 ++- package.json | 11 +- prisma/data/user/role.json | 6 - prisma/data/user/roles.json | 30 +++ prisma/data/user/users.json | 36 +++ prisma/schema.prisma | 50 ++-- prisma/seed.ts | 47 ++++ scripts/list-users.ts | 33 +++ scripts/reset-passwords.ts | 39 ++++ .../(dashboard)/_state/user/user-state.ts | 110 ++++----- src/app/admin/(dashboard)/layout.tsx | 28 +++ src/app/admin/dashboard/page.tsx | 78 +++++++ src/app/admin/page.tsx | 11 +- src/app/api/[[...slugs]]/_lib/user/create.ts | 15 +- src/app/api/[[...slugs]]/_lib/user/del.ts | 27 +-- .../api/[[...slugs]]/_lib/user/findMany.ts | 26 +-- .../api/[[...slugs]]/_lib/user/findUnique.ts | 27 +-- src/app/api/[[...slugs]]/_lib/user/index.ts | 4 +- src/app/api/[[...slugs]]/_lib/user/login.ts | 102 ++++----- .../api/[[...slugs]]/_lib/user/register.ts | 111 +++------ .../api/[[...slugs]]/_lib/user/role/create.ts | 1 + src/app/api/[[...slugs]]/_lib/user/updt.ts | 56 +++-- src/app/api/auth/[...nextauth]/route.ts | 6 + src/app/layout.tsx | 25 +- src/app/login/page.tsx | 213 ++++++++++++------ src/app/registrasi/page.tsx | 56 ++--- src/app/unauthorized/page.tsx | 40 ++++ src/components/admin/admin-provider.tsx | 41 ++++ src/components/admin/header.tsx | 44 ++++ src/components/admin/navbar.tsx | 155 +++++++++++++ src/components/auth/protected-route.tsx | 100 ++++++++ src/components/auth/user-menu.tsx | 115 ++++++++++ src/components/providers/session-provider.tsx | 18 ++ src/lib/auth/config.ts | 71 ++++++ src/lib/auth/options.ts | 202 +++++++++++++++++ src/lib/auth/utils.tsx | 95 ++++++++ src/middleware.ts | 141 +++++++++--- 38 files changed, 1746 insertions(+), 459 deletions(-) delete mode 100644 prisma/data/user/role.json create mode 100644 prisma/data/user/roles.json create mode 100644 prisma/data/user/users.json create mode 100644 scripts/list-users.ts create mode 100644 scripts/reset-passwords.ts create mode 100644 src/app/admin/(dashboard)/layout.tsx create mode 100644 src/app/admin/dashboard/page.tsx create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/unauthorized/page.tsx create mode 100644 src/components/admin/admin-provider.tsx create mode 100644 src/components/admin/header.tsx create mode 100644 src/components/admin/navbar.tsx create mode 100644 src/components/auth/protected-route.tsx create mode 100644 src/components/auth/user-menu.tsx create mode 100644 src/components/providers/session-provider.tsx create mode 100644 src/lib/auth/config.ts create mode 100644 src/lib/auth/options.ts create mode 100644 src/lib/auth/utils.tsx diff --git a/bun.lockb b/bun.lockb index c59dbad6016aa7da58be7e90f607afd61880b45e..6d3d1e76aeb0a3c4d9192e703881fbf86737acdd 100755 GIT binary patch delta 70223 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 RGc(#!j0xP)A4-^g`9G+muzvsm delta 64649 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 diff --git a/next.config.ts b/next.config.ts index ac27ff93..0d4e98c2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,17 +4,46 @@ const nextConfig: NextConfig = { async headers() { return [ { - source: '/assets/:path*', // Path ke folder gambar + source: '/assets/:path*', headers: [ { key: 'Cache-Control', - value: 'public, max-age=3600, stale-while-revalidate=600', // Cache selama 1 jam, validasi ulang setelah 10 menit + value: 'public, max-age=3600, stale-while-revalidate=600', + }, + ], + }, + // Security headers + { + source: '/(.*)', + headers: [ + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'X-Frame-Options', + value: 'DENY', + }, + { + key: 'X-XSS-Protection', + value: '1; mode=block', }, ], }, ]; }, - + // Enable React Strict Mode for development + reactStrictMode: true, + // Enable experimental features + experimental: { + serverActions: { + bodySizeLimit: '2mb', + }, + }, + // Configure images + images: { + domains: ['localhost'], + }, }; export default nextConfig; diff --git a/package.json b/package.json index ca12628a..19b7fd3c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "seed": "bun run prisma/seed.ts" }, "dependencies": { + "@auth/prisma-adapter": "^2.10.0", "@cubejs-client/core": "^0.31.0", "@elysiajs/cookie": "^0.8.0", "@elysiajs/cors": "^1.2.0", @@ -29,8 +30,9 @@ "@mantine/form": "^8.1.0", "@mantine/hooks": "^7.17.4", "@mantine/tiptap": "^7.17.4", + "@next-auth/prisma-adapter": "^1.0.7", "@paljs/types": "^8.1.0", - "@prisma/client": "^6.3.1", + "@prisma/client": "^6.15.0", "@tabler/icons-react": "^3.30.0", "@tiptap/extension-highlight": "^2.11.7", "@tiptap/extension-link": "^2.11.7", @@ -41,11 +43,13 @@ "@tiptap/pm": "^2.11.7", "@tiptap/react": "^2.11.7", "@tiptap/starter-kit": "^2.11.7", + "@types/bcrypt": "^6.0.0", "@types/bun": "^1.2.2", "@types/leaflet": "^1.9.20", "@types/lodash": "^4.17.16", "add": "^2.0.6", "animate.css": "^4.1.1", + "bcrypt": "^6.0.0", "bcryptjs": "^3.0.2", "bun": "^1.2.2", "chart.js": "^4.4.8", @@ -65,6 +69,7 @@ "motion": "^12.4.1", "nanoid": "^5.1.5", "next": "15.1.6", + "next-auth": "^4.24.11", "next-view-transitions": "^0.3.4", "node-fetch": "^3.3.2", "p-limit": "^6.2.0", @@ -87,8 +92,8 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@types/jsonwebtoken": "^9.0.10", - "@types/node": "^20", - "@types/react": "^19", + "@types/node": "^24.3.0", + "@types/react": "^19.1.12", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.1.6", diff --git a/prisma/data/user/role.json b/prisma/data/user/role.json deleted file mode 100644 index 6091de17..00000000 --- a/prisma/data/user/role.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - { - "id": "cmdpm429r0000vnndkcwslt0h", - "name": "warga" - } -] \ No newline at end of file diff --git a/prisma/data/user/roles.json b/prisma/data/user/roles.json new file mode 100644 index 00000000..266d3fdf --- /dev/null +++ b/prisma/data/user/roles.json @@ -0,0 +1,30 @@ +[ + { + "id": "role_admin_desa", + "name": "ADMIN_DESA", + "description": "Administrator Desa", + "permissions": ["manage_users", "manage_content", "view_reports"], + "isActive": true, + "createdAt": "2025-09-01T00:00:00.000Z", + "updatedAt": "2025-09-01T00:00:00.000Z" + }, + { + "id": "role_admin_kesehatan", + "name": "ADMIN_KESEHATAN", + "description": "Administrator Bidang Kesehatan", + "permissions": ["manage_health_data", "view_reports"], + "isActive": true, + "createdAt": "2025-09-01T00:00:00.000Z", + "updatedAt": "2025-09-01T00:00:00.000Z" + }, + { + "id": "role_admin_sekolah", + "name": "ADMIN_SEKOLAH", + "description": "Administrator Sekolah", + "permissions": ["manage_school_data", "view_reports"], + "isActive": true, + "createdAt": "2025-09-01T00:00:00.000Z", + "updatedAt": "2025-09-01T00:00:00.000Z" + } + ] + \ No newline at end of file diff --git a/prisma/data/user/users.json b/prisma/data/user/users.json new file mode 100644 index 00000000..f9ab996a --- /dev/null +++ b/prisma/data/user/users.json @@ -0,0 +1,36 @@ +[ + { + "id": "user_admin_desa", + "nama": "Admin Desa", + "email": "admin.desa@example.com", + "password": "$2b$10$XFDWYOJFxQ7ZM5bA0N4Z0O8u0eKYv58wLsaR7h6XK9bqWJ1YQJQ9q", + "roleId": "role_admin_desa", + "isActive": true, + "lastLogin": "2025-08-31T10:00:00.000Z", + "createdAt": "2025-09-01T00:00:00.000Z", + "updatedAt": "2025-09-01T00:00:00.000Z" + }, + { + "id": "user_admin_puskesmas", + "nama": "Admin Kesehatan", + "email": "admin.kesehatan@example.com", + "password": "$2b$10$XFDWYOJFxQ7ZM5bA0N4Z0O8u0eKYv58wLsaR7h6XK9bqWJ1YQJQ9q", + "roleId": "role_admin_kesehatan", + "isActive": true, + "lastLogin": null, + "createdAt": "2025-09-01T00:00:00.000Z", + "updatedAt": "2025-09-01T00:00:00.000Z" + }, + { + "id": "user_admin_sekolah", + "nama": "Admin Sekolah", + "email": "admin.sekolah@example.com", + "password": "$2b$10$XFDWYOJFxQ7ZM5bA0N4Z0O8u0eKYv58wLsaR7h6XK9bqWJ1YQJQ9q", + "roleId": "role_admin_sekolah", + "isActive": true, + "lastLogin": null, + "createdAt": "2025-09-01T00:00:00.000Z", + "updatedAt": "2025-09-01T00:00:00.000Z" + } + ] + \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d2e4cc06..2e997e47 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2103,25 +2103,43 @@ model KategoriBuku { } model User { - id String @id @default(cuid()) - nama String - email String @unique - password String - role Role @relation(fields: [roleId], references: [id]) - roleId String - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + nama String + email String @unique + password String + role Role @relation(fields: [roleId], references: [id]) + roleId String + instansi String? // Nama instansi (Puskesmas, Sekolah, dll) + isActive Boolean @default(true) + lastLogin DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? } model Role { - id String @id @default(cuid()) - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime @default(now()) - isActive Boolean @default(true) - User User[] + id String @id @default(cuid()) + name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH + description String? + permissions Json // Menyimpan permission dalam format JSON + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + users User[] + + @@map("roles") +} + +// Tabel untuk menyimpan permission +model Permission { + id String @id @default(cuid()) + name String @unique + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("permissions") } // ========================================= DATA PENDIDIKAN ========================================= // diff --git a/prisma/seed.ts b/prisma/seed.ts index 4d3fabc6..a21ef4ad 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -52,8 +52,55 @@ import tempatKegiatan from "./data/pendidikan/pendidikan-non-formal/tempat-kegia import tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-program2.json"; import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json"; import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json"; +import roles from "./data/user/roles.json"; +import users from "./data/user/users.json"; (async () => { + //roles + for (const r of roles) { + await prisma.role.upsert({ + where: { id: r.id }, + update: { + name: r.name, + description: r.description, + permissions: r.permissions, + isActive: true, + }, + create: { + id: r.id, + name: r.name, + description: r.description, + permissions: r.permissions, + isActive: true, + }, + }); + } + console.log("✅ Roles seeded"); + + //users + for (const u of users) { + await prisma.user.upsert({ + where: { id: u.id }, + update: { + nama: u.nama, + email: u.email, + password: u.password, + roleId: u.roleId, + isActive: true, + }, + create: { + id: u.id, + nama: u.nama, + email: u.email, + password: u.password, + roleId: u.roleId, + isActive: true, + }, + }); + } + console.log("✅ Users seeded"); + + // =========== LANDING PAGE =========== // =========== SUBMENU PROFILE =========== // =========== PROFILE PEJABAT DESA =========== diff --git a/scripts/list-users.ts b/scripts/list-users.ts new file mode 100644 index 00000000..bcf820f9 --- /dev/null +++ b/scripts/list-users.ts @@ -0,0 +1,33 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function listUsers() { + try { + const users = await prisma.user.findMany({ + include: { + role: true + } + }); + + console.log('Daftar Pengguna:'); + console.log('================'); + + users.forEach((user, index) => { + console.log(`\n[${index + 1}] ${user.nama} (${user.email})`); + console.log(` Role: ${user.role.name} (${user.role.id})`); + console.log(` Status: ${user.isActive ? 'Aktif' : 'Tidak Aktif'}`); + console.log(` Terakhir Login: ${user.lastLogin || 'Belum pernah login'}`); + console.log(` Dibuat pada: ${user.createdAt}`); + }); + + console.log('\nTotal pengguna:', users.length); + + } catch (error) { + console.error('Error:', error); + } finally { + await prisma.$disconnect(); + } +} + +listUsers(); diff --git a/scripts/reset-passwords.ts b/scripts/reset-passwords.ts new file mode 100644 index 00000000..aad090c4 --- /dev/null +++ b/scripts/reset-passwords.ts @@ -0,0 +1,39 @@ +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; + +const prisma = new PrismaClient(); + +async function resetPasswords() { + try { + // Password to set for all users + const newPassword = 'password123'; + const hashedPassword = await bcrypt.hash(newPassword, 10); + + // Get all users + const users = await prisma.user.findMany(); + + console.log('Resetting passwords for all users...'); + + // Update each user's password + for (const user of users) { + await prisma.user.update({ + where: { id: user.id }, + data: { + password: hashedPassword, + isActive: true // Ensure all users are active + } + }); + console.log(`✅ Reset password for ${user.email}`); + } + + console.log('\nSemua password telah direset ke: password123'); + console.log('Silakan login dengan email yang ada dan password: password123'); + + } catch (error) { + console.error('Error:', error); + } finally { + await prisma.$disconnect(); + } +} + +resetPasswords(); diff --git a/src/app/admin/(dashboard)/_state/user/user-state.ts b/src/app/admin/(dashboard)/_state/user/user-state.ts index f13e46a1..f79afc56 100644 --- a/src/app/admin/(dashboard)/_state/user/user-state.ts +++ b/src/app/admin/(dashboard)/_state/user/user-state.ts @@ -16,62 +16,62 @@ const defaultForm = { nama: '', email: '', password: '', roleId: '' } // 2. State Valtio const userState = proxy({ - // Register - register: { - form: { ...defaultForm }, - loading: false, - async submit() { - const valid = userSchema.omit({ roleId: true }).safeParse(userState.register.form) - if (!valid.success) { - const err = valid.error.issues.map(i => i.message).join(', ') - return toast.error(err) - } - try { - userState.register.loading = true - const res = await ApiFetch.api.user.register.post(userState.register.form) - if (res.status === 200) { - toast.success('Registrasi berhasil, silakan login') - userState.register.form = { ...defaultForm } // reset - } else { - toast.error(res.data?.message || 'Gagal registrasi') - } - } catch (e) { - console.error(e) - toast.error('Terjadi kesalahan saat registrasi') - } finally { - userState.register.loading = false - } - }, - }, + // // Register + // register: { + // form: { ...defaultForm }, + // loading: false, + // async submit() { + // const valid = userSchema.omit({ roleId: true }).safeParse(userState.register.form) + // if (!valid.success) { + // const err = valid.error.issues.map(i => i.message).join(', ') + // return toast.error(err) + // } + // try { + // userState.register.loading = true + // const res = await ApiFetch.api.user.register.post(userState.register.form) + // if (res.status === 200) { + // toast.success('Registrasi berhasil, silakan login') + // userState.register.form = { ...defaultForm } // reset + // } else { + // toast.error(res.data?.message || 'Gagal registrasi') + // } + // } catch (e) { + // console.error(e) + // toast.error('Terjadi kesalahan saat registrasi') + // } finally { + // userState.register.loading = false + // } + // }, + // }, - // Login - login: { - form: { email: '', password: '' }, - loading: false, - async submit() { - try { - userState.login.loading = true - const res = await ApiFetch.api.user.login.post(userState.login.form) - if (res.status === 200) { - toast.success('Login berhasil') - const token = res.data?.data?.token - if (typeof token === 'string') { - localStorage.setItem('token', token) - // Optional: simpan user role untuk otorisasi - const user = res.data?.data?.user - localStorage.setItem('user', JSON.stringify(user)) - } - } else { - toast.error(res.data?.message || 'Login gagal') - } - } catch (e) { - console.error(e) - toast.error('Terjadi kesalahan saat login') - } finally { - userState.login.loading = false - } - }, - }, + // // Login + // login: { + // form: { email: '', password: '' }, + // loading: false, + // async submit() { + // try { + // userState.login.loading = true + // const res = await ApiFetch.api.user.login.post(userState.login.form) + // if (res.status === 200) { + // toast.success('Login berhasil') + // const token = res.data?.data?.token + // if (typeof token === 'string') { + // localStorage.setItem('token', token) + // // Optional: simpan user role untuk otorisasi + // const user = res.data?.data?.user + // localStorage.setItem('user', JSON.stringify(user)) + // } + // } else { + // toast.error(res.data?.message || 'Login gagal') + // } + // } catch (e) { + // console.error(e) + // toast.error('Terjadi kesalahan saat login') + // } finally { + // userState.login.loading = false + // } + // }, + // }, // CRUD User (untuk admin) create: { diff --git a/src/app/admin/(dashboard)/layout.tsx b/src/app/admin/(dashboard)/layout.tsx new file mode 100644 index 00000000..6bab9b05 --- /dev/null +++ b/src/app/admin/(dashboard)/layout.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from 'react'; +import { AdminProvider } from '@/components/admin/admin-provider'; +import { AdminRoute } from '@/components/auth/protected-route'; +import { Box } from '@mantine/core'; +import { AdminNavbar } from '@/components/admin/navbar'; +import { AdminHeader } from '@/components/admin/header'; + +export default function AdminLayout({ + children, +}: { + children: ReactNode; +}) { + return ( + + + + + + + + {children} + + + + + + ); +} diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx new file mode 100644 index 00000000..89bfdb0a --- /dev/null +++ b/src/app/admin/dashboard/page.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Title, Text, Card, SimpleGrid, Container, Group, Stack } from '@mantine/core'; +import { IconUsers, IconBuildingHospital, IconSchool, IconNews, IconCalendarEvent } from '@tabler/icons-react'; +import { useSession } from 'next-auth/react'; +import { ROLES } from '@/lib/auth/config'; + +const stats = [ + { title: 'Total Penduduk', value: '15,234', diff: 34, icon: IconUsers }, + { title: 'Fasilitas Kesehatan', value: '4', diff: -13, icon: IconBuildingHospital }, + { title: 'Sekolah', value: '8', diff: 18, icon: IconSchool }, + { title: 'Berita', value: '156', diff: 30, icon: IconNews }, + { title: 'Kegiatan Mendatang', value: '7', diff: 8, icon: IconCalendarEvent }, +]; + +export default function DashboardPage() { + const { data: session } = useSession(); + const userRole = session?.user?.role?.name; + + return ( + + + Dashboard + + + + Selamat datang kembali, {session?.user?.name || 'Admin'} + + + + {stats.map((stat) => ( + + + + {stat.title} + + + + + + {stat.value} + + 0 ? 'teal' : 'red'} fz="sm" fw={500}> + {stat.diff > 0 ? '+' : ''}{stat.diff}% + + + + Dibandingkan bulan sebelumnya + + + ))} + + + + {userRole === ROLES.ADMIN_DESA && ( + + Admin Desa + Anda memiliki akses penuh untuk mengelola konten dan data desa. + + )} + + {userRole === ROLES.ADMIN_KESEHATAN && ( + + Admin Kesehatan + Kelola data kesehatan dan layanan kesehatan di desa. + + )} + + {userRole === ROLES.ADMIN_SEKOLAH && ( + + Admin Sekolah + Kelola data pendidikan dan aktivitas sekolah di desa. + + )} + + + ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 06a78cd1..55d0d1df 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,9 +1,6 @@ -import { Text } from "@mantine/core"; +// /admin/page.tsx +import { redirect } from 'next/navigation'; -export default function Page() { - return( - - Test - - ) +export default function AdminPage() { + redirect('/admin'); } \ No newline at end of file diff --git a/src/app/api/[[...slugs]]/_lib/user/create.ts b/src/app/api/[[...slugs]]/_lib/user/create.ts index 1365fbcc..8b043329 100644 --- a/src/app/api/[[...slugs]]/_lib/user/create.ts +++ b/src/app/api/[[...slugs]]/_lib/user/create.ts @@ -7,6 +7,7 @@ type FormCreateUser = { email: string; password: string; roleId: string; + instansi?: string; isActive?: boolean; }; @@ -18,15 +19,11 @@ export default async function userCreate(context: Context) { } try { - // Cek apakah email sudah terdaftar const existing = await prisma.user.findUnique({ where: { email: body.email }, }); - if (existing) { - throw new Error("Email sudah terdaftar"); - } + if (existing) throw new Error("Email sudah terdaftar"); - // Hash password sebelum simpan const hashedPassword = await bcrypt.hash(body.password, 10); const result = await prisma.user.create({ @@ -35,15 +32,13 @@ export default async function userCreate(context: Context) { email: body.email, password: hashedPassword, roleId: body.roleId, + instansi: body.instansi ?? null, isActive: body.isActive ?? true, }, + include: { role: true }, }); - return { - success: true, - message: "User berhasil dibuat", - data: result, - }; + return { success: true, message: "User berhasil dibuat", data: result }; } catch (error) { console.error("Error creating user:", error); throw new Error("Gagal membuat user: " + (error as Error).message); diff --git a/src/app/api/[[...slugs]]/_lib/user/del.ts b/src/app/api/[[...slugs]]/_lib/user/del.ts index 021960a0..fdabf5f2 100644 --- a/src/app/api/[[...slugs]]/_lib/user/del.ts +++ b/src/app/api/[[...slugs]]/_lib/user/del.ts @@ -1,28 +1,23 @@ -// /api/user/delete.ts -import prisma from '@/lib/prisma'; -import { Context } from 'elysia'; +import { Context } from "elysia"; +import prisma from "@/lib/prisma"; export default async function userDelete(context: Context) { const { id } = context.params as { id: string }; + if (!id) throw new Error("ID user wajib diisi"); + try { + const existing = await prisma.user.findUnique({ where: { id } }); + if (!existing) throw new Error("User tidak ditemukan"); + const deleted = await prisma.user.update({ where: { id }, - data: { - isActive: false, - }, + data: { deletedAt: new Date(), isActive: false }, }); - return { - success: true, - message: 'User berhasil dinonaktifkan', - data: deleted, - }; + return { success: true, message: "User berhasil dihapus", data: deleted }; } catch (error) { - console.error(error); - return { - success: false, - message: 'Gagal menghapus user', - }; + console.error("Error deleting user:", error); + throw new Error("Gagal menghapus user: " + (error as Error).message); } } diff --git a/src/app/api/[[...slugs]]/_lib/user/findMany.ts b/src/app/api/[[...slugs]]/_lib/user/findMany.ts index 88c74834..75d77c84 100644 --- a/src/app/api/[[...slugs]]/_lib/user/findMany.ts +++ b/src/app/api/[[...slugs]]/_lib/user/findMany.ts @@ -2,27 +2,15 @@ import prisma from "@/lib/prisma"; export default async function userFindMany() { try { - const data = await prisma.user.findMany({ - include: { - role: true, - }, - orderBy: { - createdAt: "desc", - }, + const users = await prisma.user.findMany({ + where: { deletedAt: null }, + include: { role: true }, + orderBy: { createdAt: "desc" }, }); - return { - success: true, - message: "Success get all user", - data, - }; + return { success: true, data: users }; } catch (error) { - console.error("Find many error:", error); - return { - success: false, - message: - "Gagal mengambil data: " + - (error instanceof Error ? error.message : "Unknown error"), - }; + console.error("Error fetching users:", error); + throw new Error("Gagal mengambil data user"); } } diff --git a/src/app/api/[[...slugs]]/_lib/user/findUnique.ts b/src/app/api/[[...slugs]]/_lib/user/findUnique.ts index 7d0adc7f..1e27606e 100644 --- a/src/app/api/[[...slugs]]/_lib/user/findUnique.ts +++ b/src/app/api/[[...slugs]]/_lib/user/findUnique.ts @@ -1,31 +1,22 @@ -import prisma from '@/lib/prisma'; -import { Context } from 'elysia'; +import { Context } from "elysia"; +import prisma from "@/lib/prisma"; export default async function userFindUnique(context: Context) { const { id } = context.params as { id: string }; + if (!id) throw new Error("ID user wajib diisi"); + try { const user = await prisma.user.findUnique({ where: { id }, - include: { - role: true, - }, + include: { role: true }, }); - if (!user) { - return { success: false, message: 'User tidak ditemukan' }; - } + if (!user) throw new Error("User tidak ditemukan"); - return { - success: true, - message: 'Berhasil mendapatkan user', - data: user, - }; + return { success: true, data: user }; } catch (error) { - console.error(error); - return { - success: false, - message: 'Gagal mengambil data user', - }; + console.error("Error finding user:", error); + throw new Error("Gagal menemukan user: " + (error as Error).message); } } diff --git a/src/app/api/[[...slugs]]/_lib/user/index.ts b/src/app/api/[[...slugs]]/_lib/user/index.ts index 51f80f13..af84090f 100644 --- a/src/app/api/[[...slugs]]/_lib/user/index.ts +++ b/src/app/api/[[...slugs]]/_lib/user/index.ts @@ -6,8 +6,8 @@ import userFindMany from "./findMany"; import userFindUnique from "./findUnique"; import userUpdate from "./updt"; import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsung, jadi biasanya `del.ts` -import userLogin from "./login"; -import userRegister from "./register"; +import { login as userLogin } from "./login"; +import { register as userRegister } from "./register"; const User = new Elysia({ prefix: "/api/user" }) .post("/register", userRegister, { diff --git a/src/app/api/[[...slugs]]/_lib/user/login.ts b/src/app/api/[[...slugs]]/_lib/user/login.ts index c4938a26..e0164b64 100644 --- a/src/app/api/[[...slugs]]/_lib/user/login.ts +++ b/src/app/api/[[...slugs]]/_lib/user/login.ts @@ -1,81 +1,67 @@ -import { Context } from "elysia"; +/* eslint-disable @typescript-eslint/no-explicit-any */ import prisma from "@/lib/prisma"; import bcrypt from "bcryptjs"; import jwt from "jsonwebtoken"; -// ENV atau secret key untuk token -const JWT_SECRET = process.env.JWT_SECRET || "super-secret-key"; // ganti di env production - -type LoginForm = { - email: string; - password: string; +export const base64 = (str: string): string => { + return Buffer.from(str).toString('base64').replace(/\//g, '_').replace(/\+/g, '-'); }; -export default async function userLogin(context: Context) { - const body = (await context.body) as LoginForm; +export const login = async ({ body, set }: any) => { + const { email, password } = body; try { - // 1. Cari user berdasarkan email - const user = await prisma.user.findUnique({ - where: { email: body.email }, - include: { role: true }, // include role untuk otorisasi + console.log('Login attempt for email:', email); + + const user = await prisma.user.findUnique({ + where: { email }, + include: { role: true } }); - - // 2. Jika tidak ada user + if (!user) { - return { - success: false, - message: "Email tidak ditemukan", - }; + console.log('User not found:', email); + set.status = 401; + return { error: "Email atau password salah" }; } - // 3. Cek apakah user aktif - if (!user.isActive) { - return { - success: false, - message: "Akun tidak aktif", - }; + console.log('User found, comparing password...'); + const isPasswordValid = await bcrypt.compare(password, user.password); + + if (!isPasswordValid) { + console.log('Invalid password for user:', email); + set.status = 401; + return { error: "Email atau password salah" }; } - // 4. Verifikasi password - const isMatch = await bcrypt.compare(body.password, user.password); - if (!isMatch) { - return { - success: false, - message: "Password salah", - }; - } - - // 5. Buat JWT token + // Generate JWT token const token = jwt.sign( - { - id: user.id, + { + id: user.id, email: user.email, - role: user.role.name, + role: user.role?.name || 'user', + name: user.nama }, - JWT_SECRET, - { expiresIn: "7d" } // expire 7 hari + process.env.NEXTAUTH_SECRET || 'your-secret-key', + { expiresIn: '7d' } ); - // 6. Kirim response - return { - success: true, - message: "Login berhasil", - data: { - user: { - id: user.id, - nama: user.nama, - email: user.email, - role: user.role.name, - }, - token, + // Set secure, HTTP-only cookies + set.headers['Set-Cookie'] = `__Secure-next-auth.session-token=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=604800`; + + console.log('Login successful for user:', email); + return { + message: "Login berhasil", + user: { + id: user.id, + email: user.email, + name: user.nama, + role: user.role?.name }, + token }; } catch (error) { - console.error("Login error:", error); - return { - success: false, - message: "Terjadi kesalahan saat login", - }; + console.error('Login error:', error); + set.status = 500; + return { error: "Terjadi kesalahan saat login" }; } -} +}; diff --git a/src/app/api/[[...slugs]]/_lib/user/register.ts b/src/app/api/[[...slugs]]/_lib/user/register.ts index af88b575..f153907a 100644 --- a/src/app/api/[[...slugs]]/_lib/user/register.ts +++ b/src/app/api/[[...slugs]]/_lib/user/register.ts @@ -1,88 +1,31 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import prisma from "@/lib/prisma"; -import bcrypt from "bcryptjs"; -import { Context } from "elysia"; +import bcrypt from "bcrypt"; -interface RegisterBody { - nama: string; - email: string; - password: string; -} +export const register = async ({ body, set }: any) => { + const { email, password, nama } = body; -export default async function userRegister(context: Context) { - try { - const body = (await context.body) as RegisterBody; - - // Validasi input - if (!body.nama || !body.email || !body.password) { - context.set.status = 400; - return { - success: false, - message: "Semua field harus diisi", - data: null - }; - } - - // Cek email sudah terdaftar - const existingUser = await prisma.user.findUnique({ - where: { email: body.email }, - }); - - if (existingUser) { - context.set.status = 400; - return { - success: false, - message: "Email sudah terdaftar", - data: null - }; - } - - // Dapatkan role warga - const role = await prisma.role.findFirst({ - where: { name: "warga" } - }); - - if (!role) { - context.set.status = 500; - return { - success: false, - message: "Role warga tidak ditemukan", - data: null - }; - } - - // Hash password - const hashedPassword = await bcrypt.hash(body.password, 10); - - // Buat user baru - const user = await prisma.user.create({ - data: { - nama: body.nama, - email: body.email, - password: hashedPassword, - roleId: role.id, - }, - select: { - id: true, - nama: true, - email: true, - roleId: true, - createdAt: true, - updatedAt: true - } - }); - - return { - success: true, - message: "Berhasil mendaftar", - data: user, - }; - } catch (error) { - console.error("Registration error:", error); - context.set.status = 500; - return { - success: false, - message: "Terjadi kesalahan saat mendaftar", - data: null - }; + // cek email udah ada belum + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + set.status = 400; + return { error: "Email sudah terdaftar" }; } -} + + // hash password + const hashed = await bcrypt.hash(password, 10); + + // Default role ID for regular users (you might want to get this from config or database) + const defaultRoleId = 'YOUR_DEFAULT_ROLE_ID'; // Replace with actual default role ID + + const user = await prisma.user.create({ + data: { + email, + password: hashed, + nama, + roleId: defaultRoleId, + }, + }); + + return { message: "Registrasi berhasil", user: { id: user.id, email: user.email } }; +}; diff --git a/src/app/api/[[...slugs]]/_lib/user/role/create.ts b/src/app/api/[[...slugs]]/_lib/user/role/create.ts index 6b74c529..791968f2 100644 --- a/src/app/api/[[...slugs]]/_lib/user/role/create.ts +++ b/src/app/api/[[...slugs]]/_lib/user/role/create.ts @@ -12,6 +12,7 @@ export default async function roleCreate(context: Context) { const result = await prisma.role.create({ data: { name: body.name, + permissions: [], }, }); return { diff --git a/src/app/api/[[...slugs]]/_lib/user/updt.ts b/src/app/api/[[...slugs]]/_lib/user/updt.ts index 60855a7d..41edbce3 100644 --- a/src/app/api/[[...slugs]]/_lib/user/updt.ts +++ b/src/app/api/[[...slugs]]/_lib/user/updt.ts @@ -1,35 +1,47 @@ -// /api/user/update.ts -import prisma from '@/lib/prisma'; -import { Context } from 'elysia'; +import { Context } from "elysia"; +import prisma from "@/lib/prisma"; +import bcrypt from "bcryptjs"; -export default async function userUpdate(context: Context) { +type FormEditUser = { + nama?: string; + email?: string; + password?: string; + roleId?: string; + instansi?: string; + isActive?: boolean; +}; + +export default async function userEdit(context: Context) { const { id } = context.params as { id: string }; - const body = await context.body as { - nama?: string; - email?: string; - password?: string; - roleId?: string; - isActive?: boolean; - }; + const body = (await context.body) as FormEditUser; + + if (!id) throw new Error("ID user wajib diisi"); try { + const existing = await prisma.user.findUnique({ where: { id } }); + if (!existing) throw new Error("User tidak ditemukan"); + + let hashedPassword: string | undefined; + if (body.password) { + hashedPassword = await bcrypt.hash(body.password, 10); + } + const updated = await prisma.user.update({ where: { id }, data: { - ...body, + nama: body.nama ?? existing.nama, + email: body.email ?? existing.email, + password: hashedPassword ?? existing.password, + roleId: body.roleId ?? existing.roleId, + instansi: body.instansi ?? existing.instansi, + isActive: body.isActive ?? existing.isActive, }, + include: { role: true }, }); - return { - success: true, - message: 'User berhasil diupdate', - data: updated, - }; + return { success: true, message: "User berhasil diperbarui", data: updated }; } catch (error) { - console.error(error); - return { - success: false, - message: 'Gagal mengupdate user', - }; + console.error("Error updating user:", error); + throw new Error("Gagal mengedit user: " + (error as Error).message); } } diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..968e7150 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from 'next-auth'; +import { authOptions } from '@/lib/auth/options'; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a12ceacd..95e0c17c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,7 +13,10 @@ import '@mantine/tiptap/styles.css'; import "primereact/resources/themes/lara-light-blue/theme.css"; import "primereact/resources/primereact.min.css"; import "primeicons/primeicons.css"; +import 'react-toastify/dist/ReactToastify.css'; +import { AuthProvider } from '@/components/providers/session-provider'; +import { authOptions } from '@/lib/auth/options'; import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient"; @@ -25,6 +28,7 @@ import { } from "@mantine/core"; import { ViewTransitions } from "next-view-transitions"; import { ToastContainer } from "react-toastify"; +import { getServerSession } from "next-auth"; export const metadata = { title: "Desa Darmasaba", @@ -39,11 +43,13 @@ const theme = createTheme({ headings: { fontFamily: "San Francisco, sans-serif" }, }); -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const session = await getServerSession(authOptions); + return ( @@ -56,15 +62,16 @@ export default function RootLayout({ /> - - {children} - - - + + + + + {children} + + - ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ce841179..d8e46b33 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,83 +1,150 @@ -'use client' -import colors from '@/con/colors'; -import { Box, Button, Center, Flex, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; -import { IconUserFilled } from '@tabler/icons-react'; -import Link from 'next/link'; -import BackButton from '../darmasaba/(pages)/desa/layanan/_com/BackButto'; -import { useSnapshot } from 'valtio'; -import userState from '../admin/(dashboard)/_state/user/user-state'; -import { useRouter } from 'next/navigation'; +'use client'; -function Page() { - const router = useRouter() - const snap = useSnapshot(userState.userState) - const handleSubmit = async () => { - router.push("/darmasaba/pendidikan/perpustakaan-digital") - await snap.login.submit() +import colors from '@/con/colors'; +import { Box, Button, Center, Image, Notification, Paper, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core'; +import { IconAlertCircle, IconLock, IconUser } from '@tabler/icons-react'; +import { signIn } from 'next-auth/react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useState } from 'react'; + +function LoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get('callbackUrl') || '/admin'; + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + console.log('Login attempt with:', { email, password: '***' }); + + if (!email || !password) { + const errorMsg = 'Email dan password harus diisi'; + console.log('Validation error:', errorMsg); + setError(errorMsg); + setLoading(false); + return; + } + + try { + console.log('Calling signIn...'); + const result = await signIn('credentials', { + redirect: false, + email, + password, + callbackUrl, + }); + console.log('signIn result:', result); + + if (result?.error) { + const errorMsg = 'Email atau password salah'; + console.log('Authentication error:', errorMsg, result.error); + setError(errorMsg); + } else if (result?.ok) { + console.log('Login successful, redirecting to:', callbackUrl); + router.push(callbackUrl); + router.refresh(); + } else { + const errorMsg = 'Gagal masuk. Silakan coba lagi.'; + console.log('Unexpected result from signIn:', result); + setError(errorMsg); + } + } catch (error) { + console.error('Login error:', error); + setError('Terjadi kesalahan. Silakan coba lagi.'); + } finally { + setLoading(false); + } } + return ( - - - - - -
- -
- - - E-Book Desa Darmasaba - - - Silahkan masukkan akun anda untuk menjelajahi berbagai macam buku di perpustakaan digital - - -
- - - - - - Login - - - - Masuk Untuk Akses Lebih Banyak Buku + +
+ + + Desa Darmasaba + + + Sistem Informasi Desa Darmasaba + + + + Silakan masuk dengan akun Anda + + + {error && ( + } + color="red" + onClose={() => setError('')} + styles={{ root: { width: '100%' } }} + > + {error} + + )} + +
+ { - userState.userState.login.form.email = e.target.value - }} + label="Email" + placeholder="Masukkan email" required + leftSection={} + value={email} + onChange={(e) => setEmail(e.target.value)} + disabled={loading} /> - { - userState.userState.login.form.password = e.target.value - }} + + } + value={password} + onChange={(e) => setPassword(e.target.value)} + disabled={loading} /> - - - - - Belum punya akun? - - - - - - - - + + + +
+ + + Lupa password?{' '} + + +
+
+
+
); } -export default Page; +export default LoginPage; diff --git a/src/app/registrasi/page.tsx b/src/app/registrasi/page.tsx index 4e24fbff..e9d63261 100644 --- a/src/app/registrasi/page.tsx +++ b/src/app/registrasi/page.tsx @@ -2,19 +2,19 @@ import colors from '@/con/colors'; import { Box, Button, Center, Checkbox, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; import BackButton from '../darmasaba/(pages)/desa/layanan/_com/BackButto'; -import { useRouter } from 'next/navigation'; -import { useSnapshot } from 'valtio'; -import userState from '../admin/(dashboard)/_state/user/user-state'; +// import { useRouter } from 'next/navigation'; +// import { useSnapshot } from 'valtio'; +// import userState from '../admin/(dashboard)/_state/user/user-state'; function Page() { - const router = useRouter() - const registrerState = useSnapshot(userState.userState) - const handleSubmit = async () => { - router.push("/login") - await registrerState.register.submit() - } + // const router = useRouter() + // const registrerState = useSnapshot(userState.userState) + // const handleSubmit = async () => { + // router.push("/login") + // await registrerState.register.submit() + // } return ( @@ -43,29 +43,29 @@ function Page() { { - userState.userState.register.form.nama = e.target.value - }} - required + // value={registrerState.register.form.nama} + // onChange={(e) => { + // userState.userState.register.form.nama = e.target.value + // }} + // required /> { - userState.userState.register.form.email = e.target.value - }} - required + // value={registrerState.register.form.email} + // onChange={(e) => { + // userState.userState.register.form.email = e.target.value + // }} + // required /> { - userState.userState.register.form.password = e.target.value - }} - required + // value={registrerState.register.form.password} + // onChange={(e) => { + // userState.userState.register.form.password = e.target.value + // }} + // required /> - + diff --git a/src/app/unauthorized/page.tsx b/src/app/unauthorized/page.tsx new file mode 100644 index 00000000..86ad7eaf --- /dev/null +++ b/src/app/unauthorized/page.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { Button, Center, Stack, Text, Title } from '@mantine/core'; +import { IconLockOff } from '@tabler/icons-react'; +import { useRouter } from 'next/navigation'; +import colors from '@/con/colors'; + +export default function UnauthorizedPage() { + const router = useRouter(); + + return ( +
+ + + + Akses Ditolak + + + Maaf, Anda tidak memiliki izin untuk mengakses halaman ini. + Silakan hubungi administrator jika Anda merasa ini adalah kesalahan. + + + + +
+ ); +} diff --git a/src/components/admin/admin-provider.tsx b/src/components/admin/admin-provider.tsx new file mode 100644 index 00000000..c2d32f0a --- /dev/null +++ b/src/components/admin/admin-provider.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { createContext, useContext, useState, ReactNode } from 'react'; + +interface AdminContextType { + sidebarOpened: boolean; + toggleSidebar: () => void; + mobileOpened: boolean; + toggleMobile: () => void; +} + +const AdminContext = createContext(undefined); + +export function AdminProvider({ children }: { children: ReactNode }) { + const [sidebarOpened, setSidebarOpened] = useState(false); + const [mobileOpened, setMobileOpened] = useState(false); + + const toggleSidebar = () => setSidebarOpened((o) => !o); + const toggleMobile = () => setMobileOpened((o) => !o); + + return ( + + {children} + + ); +} + +export function useAdmin() { + const context = useContext(AdminContext); + if (context === undefined) { + throw new Error('useAdmin must be used within an AdminProvider'); + } + return context; +} diff --git a/src/components/admin/header.tsx b/src/components/admin/header.tsx new file mode 100644 index 00000000..09c5c2f9 --- /dev/null +++ b/src/components/admin/header.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { ActionIcon, Box, Burger, Group, Title } from '@mantine/core'; +import { useAdmin } from './admin-provider'; +import { UserMenu } from '../auth/user-menu'; + +export function AdminHeader() { + const { toggleMobile, mobileOpened } = useAdmin(); + + return ( + + + + + + + + Dashboard Admin + + + + + + + + + ); +} diff --git a/src/components/admin/navbar.tsx b/src/components/admin/navbar.tsx new file mode 100644 index 00000000..3f3ac7e0 --- /dev/null +++ b/src/components/admin/navbar.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { Box, NavLink, ScrollArea, rem } from '@mantine/core'; +import { IconHome, IconUsers, IconBuildingHospital, IconSchool, IconSettings } from '@tabler/icons-react'; +import { usePathname } from 'next/navigation'; +import { useSession } from 'next-auth/react'; +import { ROLES } from '@/lib/auth/config'; +import { useAdmin } from './admin-provider'; + +const mainLinks = [ + { icon: IconHome, label: 'Dashboard', href: '/admin/dashboard' }, +]; + +const adminDesaLinks = [ + { label: 'Berita', href: '/admin/desa/berita' }, + { label: 'Pengumuman', href: '/admin/desa/pengumuman' }, + { label: 'Layanan', href: '/admin/desa/layanan' }, + { label: 'Galeri', href: '/admin/desa/gallery' }, + { label: 'Potensi', href: '/admin/desa/potensi' }, + { label: 'Profil', href: '/admin/desa/profile' }, +]; + +const adminKesehatanLinks = [ + { label: 'Data Kesehatan', href: '/admin/kesehatan/data' }, + { label: 'Laporan', href: '/admin/kesehatan/laporan' }, + { label: 'Jadwal', href: '/admin/kesehatan/jadwal' }, +]; + +const adminSekolahLinks = [ + { label: 'Data Siswa', href: '/admin/sekolah/siswa' }, + { label: 'Data Guru', href: '/admin/sekolah/guru' }, + { label: 'Jadwal Pelajaran', href: '/admin/sekolah/jadwal' }, + { label: 'Nilai', href: '/admin/sekolah/nilai' }, +]; + +const settingsLinks = [ + { icon: IconSettings, label: 'Pengaturan', href: '/admin/settings' }, +]; + +export function AdminNavbar() { + const pathname = usePathname(); + const { data: session } = useSession(); + const { mobileOpened, toggleMobile } = useAdmin(); + + const userRole = session?.user?.role?.name; + + const isActive = (href: string) => { + return pathname.startsWith(href) && href !== '/' ? true : pathname === href; + }; + + return ( + + + + {mainLinks.map((link) => ( + } + active={isActive(link.href)} + onClick={toggleMobile} + style={{ borderRadius: 'var(--mantine-radius-sm)', marginBottom: rem(4) }} + /> + ))} + + {(userRole === ROLES.ADMIN_DESA || userRole === ROLES.ADMIN_KESEHATAN || userRole === ROLES.ADMIN_SEKOLAH) && ( + } + defaultOpened={pathname.startsWith('/admin/desa')} + > + {adminDesaLinks.map((link) => ( + + ))} + + )} + + {(userRole === ROLES.ADMIN_KESEHATAN || userRole === ROLES.ADMIN_DESA) && ( + } + defaultOpened={pathname.startsWith('/admin/kesehatan')} + > + {adminKesehatanLinks.map((link) => ( + + ))} + + )} + + {(userRole === ROLES.ADMIN_SEKOLAH || userRole === ROLES.ADMIN_DESA) && ( + } + defaultOpened={pathname.startsWith('/admin/sekolah')} + > + {adminSekolahLinks.map((link) => ( + + ))} + + )} + + {settingsLinks.map((link) => ( + } + active={isActive(link.href)} + onClick={toggleMobile} + style={{ marginTop: rem(8) }} + /> + ))} + + + + ); +} diff --git a/src/components/auth/protected-route.tsx b/src/components/auth/protected-route.tsx new file mode 100644 index 00000000..085933b0 --- /dev/null +++ b/src/components/auth/protected-route.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useSession } from 'next-auth/react'; +import { useRouter, usePathname } from 'next/navigation'; +import { useEffect } from 'react'; +import { Loader } from '@mantine/core'; +import { ROLES } from '@/lib/auth/config'; + +type ProtectedRouteProps = { + children: React.ReactNode; + allowedRoles?: string[]; + redirectPath?: string; + loadingComponent?: React.ReactNode; +}; + +export function ProtectedRoute({ + children, + allowedRoles = [], + redirectPath = '/unauthorized', + loadingComponent = ( +
+ +
+ ), +}: ProtectedRouteProps) { + const { data: session, status } = useSession(); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + if (status === 'unauthenticated') { + // Redirect to login with callback URL + const callbackUrl = encodeURIComponent(pathname); + router.push(`/login?callbackUrl=${callbackUrl}`); + } else if (status === 'authenticated' && allowedRoles.length > 0) { + // Check if user has required role + const userRole = session?.user?.role?.name; + const hasAccess = allowedRoles.some(role => role === userRole); + + if (!hasAccess) { + router.push(redirectPath); + } + } + }, [status, session, allowedRoles, router, pathname, redirectPath]); + + // Show loading state while checking authentication + if (status === 'loading') { + return <>{loadingComponent}; + } + + // If authenticated and has access (or no role required), render children + if (status === 'authenticated' && + (allowedRoles.length === 0 || allowedRoles.includes(session?.user?.role?.name || ''))) { + return <>{children}; + } + + // Otherwise, show loading (will redirect) + return <>{loadingComponent}; +} + +// Role-based route components +export function AdminDesaRoute({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function AdminKesehatanRoute({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function AdminSekolahRoute({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +// Example of a route that allows multiple roles +export function AdminRoute({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/components/auth/user-menu.tsx b/src/components/auth/user-menu.tsx new file mode 100644 index 00000000..c709379d --- /dev/null +++ b/src/components/auth/user-menu.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { Avatar, Box, Button, Group, Menu, Text, UnstyledButton, rem } from '@mantine/core'; +import { IconChevronDown, IconLogout, IconSettings, IconUser } from '@tabler/icons-react'; +import { signOut, useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +interface UserMenuProps { + collapsed?: boolean; +} + +export function UserMenu({ collapsed = false }: UserMenuProps) { + const { data: session } = useSession(); + const router = useRouter(); + const [userMenuOpened, setUserMenuOpened] = useState(false); + + if (!session?.user) { + return ( + + + + ); + } + + const user = session.user; + const userInitial = user.name ? user.name.charAt(0).toUpperCase() : 'U'; + + return ( + + + + + + {userInitial} + + + {!collapsed && ( + + + {user.name || 'User'} + + + {user.role?.name?.replace('_', ' ') || 'No Role'} + + + )} + + {!collapsed && ( + + )} + + + + + + Akun + + } + onClick={() => router.push('/profile')} + > + Profil Saya + + + + } + onClick={() => router.push('/settings')} + > + Pengaturan + + + + + + } + onClick={() => signOut({ callbackUrl: '/login' })} + > + Keluar + + + + ); +} diff --git a/src/components/providers/session-provider.tsx b/src/components/providers/session-provider.tsx new file mode 100644 index 00000000..df346e40 --- /dev/null +++ b/src/components/providers/session-provider.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { SessionProvider } from 'next-auth/react'; +import { ReactNode } from 'react'; +import { Session } from 'next-auth'; + +type Props = { + children: ReactNode; + session?: Session | null; +}; + +export function AuthProvider({ children, session }: Props) { + return ( + + {children} + + ); +} diff --git a/src/lib/auth/config.ts b/src/lib/auth/config.ts new file mode 100644 index 00000000..47a8c91a --- /dev/null +++ b/src/lib/auth/config.ts @@ -0,0 +1,71 @@ +declare module 'next-auth' { + interface User { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + role: { + id: string; + name: string; + permissions: string[]; + }; + } + + interface Session { + user: User; + } +} + +declare module 'next-auth/jwt' { + interface JWT { + id: string; + role: { + id: string; + name: string; + permissions: string[]; + }; + } +} + +export const ROLES = { + ADMIN_DESA: 'ADMIN_DESA', + ADMIN_KESEHATAN: 'ADMIN_KESEHATAN', + ADMIN_SEKOLAH: 'ADMIN_SEKOLAH', +} as const; + +export type UserRole = keyof typeof ROLES; + +export const PERMISSIONS = { + // Admin Desa + VIEW_DASHBOARD: 'view_dashboard', + MANAGE_USERS: 'manage_users', + + // Admin Kesehatan + VIEW_KESEHATAN: 'view_kesehatan', + MANAGE_KESEHATAN: 'manage_kesehatan', + + // Admin Sekolah + VIEW_PENDIDIKAN: 'view_pendidikan', + MANAGE_PENDIDIKAN: 'manage_pendidikan', +} as const + +export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS] + +export const ROLE_PERMISSIONS: Record = { + [ROLES.ADMIN_DESA]: [ + PERMISSIONS.VIEW_DASHBOARD, + PERMISSIONS.MANAGE_USERS, + PERMISSIONS.VIEW_KESEHATAN, + PERMISSIONS.VIEW_PENDIDIKAN, + ], + [ROLES.ADMIN_KESEHATAN]: [ + PERMISSIONS.VIEW_DASHBOARD, + PERMISSIONS.VIEW_KESEHATAN, + PERMISSIONS.MANAGE_KESEHATAN, + ], + [ROLES.ADMIN_SEKOLAH]: [ + PERMISSIONS.VIEW_DASHBOARD, + PERMISSIONS.VIEW_PENDIDIKAN, + PERMISSIONS.MANAGE_PENDIDIKAN, + ], +} diff --git a/src/lib/auth/options.ts b/src/lib/auth/options.ts new file mode 100644 index 00000000..d651fd05 --- /dev/null +++ b/src/lib/auth/options.ts @@ -0,0 +1,202 @@ +import { PrismaAdapter } from "@auth/prisma-adapter"; +import { PrismaClient } from "@prisma/client"; +import { AuthOptions, DefaultSession } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import bcrypt from 'bcryptjs'; + +interface UserRole { + id: string; + name: string; + permissions: string[]; +} + +type UserSession = { + id: string; + name?: string | null; + email?: string | null; + role: UserRole; +}; + +declare module 'next-auth' { + interface Session extends DefaultSession { + User: UserSession; + } + + interface User extends UserSession { + id: string; + } +} + +declare module 'next-auth/jwt' { + interface JWT { + id: string; + name?: string | null; + email?: string | null; + role: UserRole; + instansi?: string | null; + } +} + +const prisma = new PrismaClient(); + +// Create a type-safe adapter +const adapter = PrismaAdapter(prisma); + +// Fix type issues with the adapter +const safeAdapter = { + ...adapter, + // Add any missing methods with proper types + getUser: async (id: string) => { + const user = await prisma.user.findUnique({ + where: { id }, + include: { role: true }, + }); + + if (!user) return null; + + return { + id: user.id, + name: user.nama, + email: user.email, + role: { + id: user.role.id, + name: user.role.name, + permissions: Array.isArray(user.role.permissions) + ? user.role.permissions + : typeof user.role.permissions === 'string' + ? JSON.parse(user.role.permissions) + : [], + }, + }; + }, +} as const; + +export const authOptions: AuthOptions = { + // @ts-expect-error - We've provided a type-safe adapter + adapter: safeAdapter, + session: { + strategy: 'jwt', + maxAge: 7 * 24 * 60 * 60, // 7 days + updateAge: 24 * 60 * 60, // 24 hours + }, + pages: { + signIn: "/login", + error: "/login", + }, + providers: [ + CredentialsProvider({ + name: 'credentials', + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Password', type: 'password' }, + }, + type: 'credentials', + async authorize(credentials) { + console.log('Authorize called with credentials:', credentials?.email ? { ...credentials, password: '***' } : 'No credentials'); + + if (!credentials?.email || !credentials?.password) { + console.log('Missing email or password'); + return null; + } + + let user; + try { + user = await prisma.user.findUnique({ + where: { email: credentials.email }, + include: { role: true }, + }); + console.log('User found:', user ? { ...user, password: '***' } : 'No user found'); + } catch (error) { + console.error('Database error:', error); + throw error; + } + + if (!user?.password) { + console.log('User found but no password set'); + return null; + } + + const isCorrectPassword = await bcrypt.compare( + credentials.password, + user.password + ); + + if (!isCorrectPassword) { + console.log('Incorrect password'); + return null; + } + + // Ensure permissions is always an array + const permissions = Array.isArray(user.role.permissions) + ? user.role.permissions + : typeof user.role.permissions === 'string' + ? JSON.parse(user.role.permissions) + : []; + + return { + id: user.id, + email: user.email, + name: user.nama, // Using 'nama' instead of 'name' to match Prisma schema + role: { + id: user.role.id, + name: user.role.name, + permissions, + }, + instansi: user.instansi || null, + } as UserSession; + }, + }), + ], + callbacks: { + async session({ session, token }) { + if (token) { + session.user = { + ...session.user, + id: token.id, + name: token.name ?? null, + email: token.email ?? null, + role: token.role, + }; + } + return session; + }, + async jwt({ token, user }) { + if (user) { + token.id = user.id; + token.name = user.name ?? null; + token.email = user.email ?? null; + token.role = (user as UserSession).role; + } + return token; + }, + }, + secret: process.env.NEXTAUTH_SECRET, + debug: process.env.NODE_ENV === "development", + jwt: { + secret: process.env.NEXTAUTH_SECRET, + maxAge: 7 * 24 * 60 * 60, // 7 days + }, + logger: { + error(code: string, metadata: unknown) { + console.error('Auth error:', code, metadata); + }, + warn(code: string) { + console.warn('Auth warning:', code); + }, + debug(code: string, metadata: unknown) { + console.log('Auth debug:', code, metadata); + } + }, + useSecureCookies: process.env.NODE_ENV === 'production', + cookies: { + sessionToken: { + name: `__Secure-next-auth.session-token`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: process.env.NODE_ENV === 'production', + }, + }, + }, +}; diff --git a/src/lib/auth/utils.tsx b/src/lib/auth/utils.tsx new file mode 100644 index 00000000..b3ad93e8 --- /dev/null +++ b/src/lib/auth/utils.tsx @@ -0,0 +1,95 @@ +import { authOptions } from '@/lib/auth/options'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import type { Session } from 'next-auth'; +import { getServerSession } from 'next-auth/next'; +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { type UserRole } from './config'; + +type User = Session['user'] & { + role?: { + name: string; + permissions: string[]; + }; +}; + +export function hasPermission(user: User | null | undefined, requiredPermission: string): boolean { + if (!user?.role?.permissions) return false; + return user.role.permissions.includes(requiredPermission); +} + +export function hasRole(user: User | null | undefined, requiredRole: UserRole): boolean { + if (!user?.role?.name) return false; + return user.role.name === requiredRole; +} + +export function hasAnyRole(user: User | null | undefined, ...roles: UserRole[]): boolean { + if (!user?.role?.name) return false; + return roles.includes(user.role.name as UserRole); +} + +export function hasAllPermissions(user: User | null | undefined, ...requiredPermissions: string[]): boolean { + if (!user?.role?.permissions) return false; + return requiredPermissions.every(permission => + user.role?.permissions.includes(permission) + ); +} + +export function hasAnyPermission(user: User | null | undefined, ...requiredPermissions: string[]): boolean { + if (!user?.role?.permissions) return false; + return requiredPermissions.some(permission => + user.role?.permissions.includes(permission) + ); +} + +// API route handler type +type ApiHandler = (req: NextApiRequest, res: NextApiResponse) => Promise; + +// Middleware for Next.js API routes +export function withAuth(handler: ApiHandler, requiredPermission?: string) { + return async (req: NextApiRequest, res: NextApiResponse) => { + try { + const session = await getServerSession(authOptions); + + if (!session) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + if (requiredPermission && !hasPermission(session.user as User, requiredPermission)) { + res.status(403).json({ message: 'Forbidden' }); + return; + } + + return handler(req, res); + } catch (error) { + console.error('Auth error:', error); + res.status(500).json({ message: 'Internal server error' }); + } + }; +} + +// Helper for React components +interface WithPermissionProps { + requiredPermission: string; + children: React.ReactNode; +} + +export function WithPermission({ requiredPermission, children }: WithPermissionProps) { + const { data: session, status } = useSession(); + const router = useRouter(); + + useEffect(() => { + if (status === 'unauthenticated' || + (status === 'authenticated' && !hasPermission(session?.user as User, requiredPermission))) { + router.push('/unauthorized'); + } + }, [session, status, router, requiredPermission]); + + if (status !== 'authenticated' || !hasPermission(session?.user as User, requiredPermission)) { + return null; + } + + return <>{children}; +} \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts index 3a6f0da3..2a4f2d42 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,46 +1,123 @@ -// app/middleware.js -import { NextResponse, NextRequest } from 'next/server'; +import { NextResponse, type NextRequest } from 'next/server'; +import { getToken } from 'next-auth/jwt'; +import { ROLES } from './lib/auth/config'; -// Daftar route yang diizinkan tanpa login (public routes) -const publicRoutes = [ - '/*', // Home page - '/about', // About page - '/public/*', // Wildcard untuk semua route di bawah /public - '/login', // Halaman login +type RouteConfig = { + path: string; + roles?: string[]; + public?: boolean; +}; + +// Konfigurasi route +const routeConfigs: RouteConfig[] = [ + // Public routes + { path: '/', public: true }, + { path: '/about', public: true }, + { path: '/login', public: true }, + { path: '/api/auth/**', public: true }, + + // Admin Desa routes + { + path: '/admin/desa/**', + roles: [ROLES.ADMIN_DESA] + }, + + // Admin Kesehatan routes + { + path: '/admin/kesehatan/**', + roles: [ROLES.ADMIN_KESEHATAN] + }, + + // Admin Sekolah routes + { + path: '/admin/sekolah/**', + roles: [ROLES.ADMIN_SEKOLAH] + }, + + // Shared admin routes + { + path: '/admin/dashboard', + roles: [ROLES.ADMIN_DESA, ROLES.ADMIN_KESEHATAN, ROLES.ADMIN_SEKOLAH] + }, ]; // Fungsi untuk memeriksa apakah route saat ini adalah route publik -function isPublicRoute(pathname: string) { - return publicRoutes.some((route) => { - // Jika route mengandung wildcard (*), gunakan regex untuk mencocokkan - if (route.endsWith('*')) { - const baseRoute = route.replace('*', ''); // Hapus wildcard - return pathname.startsWith(baseRoute); // Cocokkan dengan pathname - } - return pathname === route; // Cocokkan exact path - }); +function isPublicRoute(pathname: string): boolean { + return routeConfigs.some(route => { + if (route.public) { + if (route.path.endsWith('**')) { + const basePath = route.path.replace(/\*\*$/, ''); + return pathname.startsWith(basePath); + } + return pathname === route.path; + } + return false; + }); } -export function middleware(request: NextRequest) { - const { pathname } = request.nextUrl; - - // Jika route adalah public, izinkan akses - if (isPublicRoute(pathname)) { - return NextResponse.next(); +// Fungsi untuk memeriksa apakah user memiliki akses ke route +function hasAccess(pathname: string, userRole: string | null): boolean { + if (!userRole) return false; + + const routeConfig = routeConfigs.find(config => { + if (config.path.endsWith('**')) { + const basePath = config.path.replace(/\*\*$/, ''); + return pathname.startsWith(basePath); } + return pathname === config.path; + }); - // Jika bukan public route, periksa apakah pengguna sudah login - const isLoggedIn = request.cookies.get('darmasaba-auth-token'); // Contoh: cek cookie auth-token - if (!isLoggedIn) { - // Redirect ke halaman login jika belum login - return NextResponse.redirect(new URL('/login', request.url)); - } + // Jika tidak ada konfigurasi khusus, tolak akses + if (!routeConfig) return false; + + // Jika route public, izinkan akses + if (routeConfig.public) return true; + + // Jika route membutuhkan role, periksa apakah user memiliki role yang sesuai + if (routeConfig.roles) { + return routeConfig.roles.includes(userRole); + } + + return false; +} - // Jika sudah login, izinkan akses +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + const token = await getToken({ req: request }); + + // Skip middleware for API routes and static files + if ( + pathname.startsWith('/api/') || + pathname.startsWith('/_next/') || + pathname.startsWith('/static/') || + pathname.includes('.') + ) { return NextResponse.next(); + } + + // Handle public routes + if (isPublicRoute(pathname)) { + return NextResponse.next(); + } + + // Check authentication + if (!token) { + const url = new URL('/login', request.url); + url.searchParams.set('callbackUrl', pathname); + return NextResponse.redirect(url); + } + + // Check authorization + const userRole = token.role?.name; + if (!hasAccess(pathname, userRole)) { + return NextResponse.redirect(new URL('/unauthorized', request.url)); + } + + return NextResponse.next(); } -// Konfigurasi untuk menentukan path mana yang akan dijalankan middleware export const config = { - matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], // Jalankan middleware untuk semua route kecuali file statis + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|images/).*)', + ], }; \ No newline at end of file