diff --git a/bun.lockb b/bun.lockb index c59dbad6..6d3d1e76 100755 Binary files a/bun.lockb and b/bun.lockb differ 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