Compare commits

...

1 Commits

Author SHA1 Message Date
8e50aff69e Login & User Role - 1 2025-09-02 11:28:48 +08:00
38 changed files with 1746 additions and 459 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -4,17 +4,46 @@ const nextConfig: NextConfig = {
async headers() { async headers() {
return [ return [
{ {
source: '/assets/:path*', // Path ke folder gambar source: '/assets/:path*',
headers: [ headers: [
{ {
key: 'Cache-Control', 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; export default nextConfig;

View File

@@ -13,6 +13,7 @@
"seed": "bun run prisma/seed.ts" "seed": "bun run prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.10.0",
"@cubejs-client/core": "^0.31.0", "@cubejs-client/core": "^0.31.0",
"@elysiajs/cookie": "^0.8.0", "@elysiajs/cookie": "^0.8.0",
"@elysiajs/cors": "^1.2.0", "@elysiajs/cors": "^1.2.0",
@@ -29,8 +30,9 @@
"@mantine/form": "^8.1.0", "@mantine/form": "^8.1.0",
"@mantine/hooks": "^7.17.4", "@mantine/hooks": "^7.17.4",
"@mantine/tiptap": "^7.17.4", "@mantine/tiptap": "^7.17.4",
"@next-auth/prisma-adapter": "^1.0.7",
"@paljs/types": "^8.1.0", "@paljs/types": "^8.1.0",
"@prisma/client": "^6.3.1", "@prisma/client": "^6.15.0",
"@tabler/icons-react": "^3.30.0", "@tabler/icons-react": "^3.30.0",
"@tiptap/extension-highlight": "^2.11.7", "@tiptap/extension-highlight": "^2.11.7",
"@tiptap/extension-link": "^2.11.7", "@tiptap/extension-link": "^2.11.7",
@@ -41,11 +43,13 @@
"@tiptap/pm": "^2.11.7", "@tiptap/pm": "^2.11.7",
"@tiptap/react": "^2.11.7", "@tiptap/react": "^2.11.7",
"@tiptap/starter-kit": "^2.11.7", "@tiptap/starter-kit": "^2.11.7",
"@types/bcrypt": "^6.0.0",
"@types/bun": "^1.2.2", "@types/bun": "^1.2.2",
"@types/leaflet": "^1.9.20", "@types/leaflet": "^1.9.20",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"add": "^2.0.6", "add": "^2.0.6",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"bun": "^1.2.2", "bun": "^1.2.2",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
@@ -65,6 +69,7 @@
"motion": "^12.4.1", "motion": "^12.4.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "15.1.6", "next": "15.1.6",
"next-auth": "^4.24.11",
"next-view-transitions": "^0.3.4", "next-view-transitions": "^0.3.4",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"p-limit": "^6.2.0", "p-limit": "^6.2.0",
@@ -87,8 +92,8 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20", "@types/node": "^24.3.0",
"@types/react": "^19", "@types/react": "^19.1.12",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.1.6", "eslint-config-next": "15.1.6",

View File

@@ -1,6 +0,0 @@
[
{
"id": "cmdpm429r0000vnndkcwslt0h",
"name": "warga"
}
]

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -2103,25 +2103,43 @@ model KategoriBuku {
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
nama String nama String
email String @unique email String @unique
password String password String
role Role @relation(fields: [roleId], references: [id]) role Role @relation(fields: [roleId], references: [id])
roleId String roleId String
isActive Boolean @default(true) instansi String? // Nama instansi (Puskesmas, Sekolah, dll)
createdAt DateTime @default(now()) isActive Boolean @default(true)
updatedAt DateTime @updatedAt lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
} }
model Role { model Role {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
createdAt DateTime @default(now()) description String?
updatedAt DateTime @updatedAt permissions Json // Menyimpan permission dalam format JSON
deletedAt DateTime @default(now()) isActive Boolean @default(true)
isActive Boolean @default(true) createdAt DateTime @default(now())
User User[] 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 ========================================= // // ========================================= DATA PENDIDIKAN ========================================= //

View File

@@ -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 tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-program2.json";
import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json"; import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json";
import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.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 () => { (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 =========== // =========== LANDING PAGE ===========
// =========== SUBMENU PROFILE =========== // =========== SUBMENU PROFILE ===========
// =========== PROFILE PEJABAT DESA =========== // =========== PROFILE PEJABAT DESA ===========

33
scripts/list-users.ts Normal file
View File

@@ -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();

View File

@@ -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();

View File

@@ -16,62 +16,62 @@ const defaultForm = { nama: '', email: '', password: '', roleId: '' }
// 2. State Valtio // 2. State Valtio
const userState = proxy({ const userState = proxy({
// Register // // Register
register: { // register: {
form: { ...defaultForm }, // form: { ...defaultForm },
loading: false, // loading: false,
async submit() { // async submit() {
const valid = userSchema.omit({ roleId: true }).safeParse(userState.register.form) // const valid = userSchema.omit({ roleId: true }).safeParse(userState.register.form)
if (!valid.success) { // if (!valid.success) {
const err = valid.error.issues.map(i => i.message).join(', ') // const err = valid.error.issues.map(i => i.message).join(', ')
return toast.error(err) // return toast.error(err)
} // }
try { // try {
userState.register.loading = true // userState.register.loading = true
const res = await ApiFetch.api.user.register.post(userState.register.form) // const res = await ApiFetch.api.user.register.post(userState.register.form)
if (res.status === 200) { // if (res.status === 200) {
toast.success('Registrasi berhasil, silakan login') // toast.success('Registrasi berhasil, silakan login')
userState.register.form = { ...defaultForm } // reset // userState.register.form = { ...defaultForm } // reset
} else { // } else {
toast.error(res.data?.message || 'Gagal registrasi') // toast.error(res.data?.message || 'Gagal registrasi')
} // }
} catch (e) { // } catch (e) {
console.error(e) // console.error(e)
toast.error('Terjadi kesalahan saat registrasi') // toast.error('Terjadi kesalahan saat registrasi')
} finally { // } finally {
userState.register.loading = false // userState.register.loading = false
} // }
}, // },
}, // },
// Login // // Login
login: { // login: {
form: { email: '', password: '' }, // form: { email: '', password: '' },
loading: false, // loading: false,
async submit() { // async submit() {
try { // try {
userState.login.loading = true // userState.login.loading = true
const res = await ApiFetch.api.user.login.post(userState.login.form) // const res = await ApiFetch.api.user.login.post(userState.login.form)
if (res.status === 200) { // if (res.status === 200) {
toast.success('Login berhasil') // toast.success('Login berhasil')
const token = res.data?.data?.token // const token = res.data?.data?.token
if (typeof token === 'string') { // if (typeof token === 'string') {
localStorage.setItem('token', token) // localStorage.setItem('token', token)
// Optional: simpan user role untuk otorisasi // // Optional: simpan user role untuk otorisasi
const user = res.data?.data?.user // const user = res.data?.data?.user
localStorage.setItem('user', JSON.stringify(user)) // localStorage.setItem('user', JSON.stringify(user))
} // }
} else { // } else {
toast.error(res.data?.message || 'Login gagal') // toast.error(res.data?.message || 'Login gagal')
} // }
} catch (e) { // } catch (e) {
console.error(e) // console.error(e)
toast.error('Terjadi kesalahan saat login') // toast.error('Terjadi kesalahan saat login')
} finally { // } finally {
userState.login.loading = false // userState.login.loading = false
} // }
}, // },
}, // },
// CRUD User (untuk admin) // CRUD User (untuk admin)
create: { create: {

View File

@@ -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 (
<AdminRoute>
<AdminProvider>
<Box style={{ display: 'flex', minHeight: '100vh' }}>
<AdminNavbar />
<Box style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<AdminHeader />
<Box style={{ flex: 1, padding: '1.5rem' }}>
{children}
</Box>
</Box>
</Box>
</AdminProvider>
</AdminRoute>
);
}

View File

@@ -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 (
<Container size="xl" p="md">
<Title order={2} mb="md">
Dashboard
</Title>
<Text c="dimmed" mb="xl">
Selamat datang kembali, {session?.user?.name || 'Admin'}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mb="xl">
{stats.map((stat) => (
<Card key={stat.title} withBorder p="xl" radius="md">
<Group justify="space-between">
<Text size="xs" c="dimmed" fw={700}>
{stat.title}
</Text>
<stat.icon size={20} />
</Group>
<Group align="flex-end" gap="xs" mt={25}>
<Text fz="xl" fw={700}>
{stat.value}
</Text>
<Text c={stat.diff > 0 ? 'teal' : 'red'} fz="sm" fw={500}>
{stat.diff > 0 ? '+' : ''}{stat.diff}%
</Text>
</Group>
<Text fz="xs" c="dimmed" mt={7}>
Dibandingkan bulan sebelumnya
</Text>
</Card>
))}
</SimpleGrid>
<Stack gap="xl">
{userRole === ROLES.ADMIN_DESA && (
<Card withBorder radius="md" p="xl">
<Title order={3} mb="md">Admin Desa</Title>
<Text>Anda memiliki akses penuh untuk mengelola konten dan data desa.</Text>
</Card>
)}
{userRole === ROLES.ADMIN_KESEHATAN && (
<Card withBorder radius="md" p="xl">
<Title order={3} mb="md">Admin Kesehatan</Title>
<Text>Kelola data kesehatan dan layanan kesehatan di desa.</Text>
</Card>
)}
{userRole === ROLES.ADMIN_SEKOLAH && (
<Card withBorder radius="md" p="xl">
<Title order={3} mb="md">Admin Sekolah</Title>
<Text>Kelola data pendidikan dan aktivitas sekolah di desa.</Text>
</Card>
)}
</Stack>
</Container>
);
}

View File

@@ -1,9 +1,6 @@
import { Text } from "@mantine/core"; // /admin/page.tsx
import { redirect } from 'next/navigation';
export default function Page() { export default function AdminPage() {
return( redirect('/admin');
<Text>
Test
</Text>
)
} }

View File

@@ -7,6 +7,7 @@ type FormCreateUser = {
email: string; email: string;
password: string; password: string;
roleId: string; roleId: string;
instansi?: string;
isActive?: boolean; isActive?: boolean;
}; };
@@ -18,15 +19,11 @@ export default async function userCreate(context: Context) {
} }
try { try {
// Cek apakah email sudah terdaftar
const existing = await prisma.user.findUnique({ const existing = await prisma.user.findUnique({
where: { email: body.email }, where: { email: body.email },
}); });
if (existing) { if (existing) throw new Error("Email sudah terdaftar");
throw new Error("Email sudah terdaftar");
}
// Hash password sebelum simpan
const hashedPassword = await bcrypt.hash(body.password, 10); const hashedPassword = await bcrypt.hash(body.password, 10);
const result = await prisma.user.create({ const result = await prisma.user.create({
@@ -35,15 +32,13 @@ export default async function userCreate(context: Context) {
email: body.email, email: body.email,
password: hashedPassword, password: hashedPassword,
roleId: body.roleId, roleId: body.roleId,
instansi: body.instansi ?? null,
isActive: body.isActive ?? true, isActive: body.isActive ?? true,
}, },
include: { role: true },
}); });
return { return { success: true, message: "User berhasil dibuat", data: result };
success: true,
message: "User berhasil dibuat",
data: result,
};
} catch (error) { } catch (error) {
console.error("Error creating user:", error); console.error("Error creating user:", error);
throw new Error("Gagal membuat user: " + (error as Error).message); throw new Error("Gagal membuat user: " + (error as Error).message);

View File

@@ -1,28 +1,23 @@
// /api/user/delete.ts import { Context } from "elysia";
import prisma from '@/lib/prisma'; import prisma from "@/lib/prisma";
import { Context } from 'elysia';
export default async function userDelete(context: Context) { export default async function userDelete(context: Context) {
const { id } = context.params as { id: string }; const { id } = context.params as { id: string };
if (!id) throw new Error("ID user wajib diisi");
try { try {
const existing = await prisma.user.findUnique({ where: { id } });
if (!existing) throw new Error("User tidak ditemukan");
const deleted = await prisma.user.update({ const deleted = await prisma.user.update({
where: { id }, where: { id },
data: { data: { deletedAt: new Date(), isActive: false },
isActive: false,
},
}); });
return { return { success: true, message: "User berhasil dihapus", data: deleted };
success: true,
message: 'User berhasil dinonaktifkan',
data: deleted,
};
} catch (error) { } catch (error) {
console.error(error); console.error("Error deleting user:", error);
return { throw new Error("Gagal menghapus user: " + (error as Error).message);
success: false,
message: 'Gagal menghapus user',
};
} }
} }

View File

@@ -2,27 +2,15 @@ import prisma from "@/lib/prisma";
export default async function userFindMany() { export default async function userFindMany() {
try { try {
const data = await prisma.user.findMany({ const users = await prisma.user.findMany({
include: { where: { deletedAt: null },
role: true, include: { role: true },
}, orderBy: { createdAt: "desc" },
orderBy: {
createdAt: "desc",
},
}); });
return { return { success: true, data: users };
success: true,
message: "Success get all user",
data,
};
} catch (error) { } catch (error) {
console.error("Find many error:", error); console.error("Error fetching users:", error);
return { throw new Error("Gagal mengambil data user");
success: false,
message:
"Gagal mengambil data: " +
(error instanceof Error ? error.message : "Unknown error"),
};
} }
} }

View File

@@ -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) { export default async function userFindUnique(context: Context) {
const { id } = context.params as { id: string }; const { id } = context.params as { id: string };
if (!id) throw new Error("ID user wajib diisi");
try { try {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id }, where: { id },
include: { include: { role: true },
role: true,
},
}); });
if (!user) { if (!user) throw new Error("User tidak ditemukan");
return { success: false, message: 'User tidak ditemukan' };
}
return { return { success: true, data: user };
success: true,
message: 'Berhasil mendapatkan user',
data: user,
};
} catch (error) { } catch (error) {
console.error(error); console.error("Error finding user:", error);
return { throw new Error("Gagal menemukan user: " + (error as Error).message);
success: false,
message: 'Gagal mengambil data user',
};
} }
} }

View File

@@ -6,8 +6,8 @@ import userFindMany from "./findMany";
import userFindUnique from "./findUnique"; import userFindUnique from "./findUnique";
import userUpdate from "./updt"; import userUpdate from "./updt";
import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsung, jadi biasanya `del.ts` import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsung, jadi biasanya `del.ts`
import userLogin from "./login"; import { login as userLogin } from "./login";
import userRegister from "./register"; import { register as userRegister } from "./register";
const User = new Elysia({ prefix: "/api/user" }) const User = new Elysia({ prefix: "/api/user" })
.post("/register", userRegister, { .post("/register", userRegister, {

View File

@@ -1,81 +1,67 @@
import { Context } from "elysia"; /* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
// ENV atau secret key untuk token export const base64 = (str: string): string => {
const JWT_SECRET = process.env.JWT_SECRET || "super-secret-key"; // ganti di env production return Buffer.from(str).toString('base64').replace(/\//g, '_').replace(/\+/g, '-');
type LoginForm = {
email: string;
password: string;
}; };
export default async function userLogin(context: Context) { export const login = async ({ body, set }: any) => {
const body = (await context.body) as LoginForm; const { email, password } = body;
try { try {
// 1. Cari user berdasarkan email console.log('Login attempt for email:', email);
const user = await prisma.user.findUnique({
where: { email: body.email }, const user = await prisma.user.findUnique({
include: { role: true }, // include role untuk otorisasi where: { email },
include: { role: true }
}); });
// 2. Jika tidak ada user
if (!user) { if (!user) {
return { console.log('User not found:', email);
success: false, set.status = 401;
message: "Email tidak ditemukan", return { error: "Email atau password salah" };
};
} }
// 3. Cek apakah user aktif console.log('User found, comparing password...');
if (!user.isActive) { const isPasswordValid = await bcrypt.compare(password, user.password);
return {
success: false, if (!isPasswordValid) {
message: "Akun tidak aktif", console.log('Invalid password for user:', email);
}; set.status = 401;
return { error: "Email atau password salah" };
} }
// 4. Verifikasi password // Generate JWT token
const isMatch = await bcrypt.compare(body.password, user.password);
if (!isMatch) {
return {
success: false,
message: "Password salah",
};
}
// 5. Buat JWT token
const token = jwt.sign( const token = jwt.sign(
{ {
id: user.id, id: user.id,
email: user.email, email: user.email,
role: user.role.name, role: user.role?.name || 'user',
name: user.nama
}, },
JWT_SECRET, process.env.NEXTAUTH_SECRET || 'your-secret-key',
{ expiresIn: "7d" } // expire 7 hari { expiresIn: '7d' }
); );
// 6. Kirim response // Set secure, HTTP-only cookies
return { set.headers['Set-Cookie'] = `__Secure-next-auth.session-token=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=604800`;
success: true,
message: "Login berhasil", console.log('Login successful for user:', email);
data: { return {
user: { message: "Login berhasil",
id: user.id, user: {
nama: user.nama, id: user.id,
email: user.email, email: user.email,
role: user.role.name, name: user.nama,
}, role: user.role?.name
token,
}, },
token
}; };
} catch (error) { } catch (error) {
console.error("Login error:", error); console.error('Login error:', error);
return { set.status = 500;
success: false, return { error: "Terjadi kesalahan saat login" };
message: "Terjadi kesalahan saat login",
};
} }
} };

View File

@@ -1,88 +1,31 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import bcrypt from "bcryptjs"; import bcrypt from "bcrypt";
import { Context } from "elysia";
interface RegisterBody { export const register = async ({ body, set }: any) => {
nama: string; const { email, password, nama } = body;
email: string;
password: string;
}
export default async function userRegister(context: Context) { // cek email udah ada belum
try { const existing = await prisma.user.findUnique({ where: { email } });
const body = (await context.body) as RegisterBody; if (existing) {
set.status = 400;
// Validasi input return { error: "Email sudah terdaftar" };
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
};
} }
}
// 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 } };
};

View File

@@ -12,6 +12,7 @@ export default async function roleCreate(context: Context) {
const result = await prisma.role.create({ const result = await prisma.role.create({
data: { data: {
name: body.name, name: body.name,
permissions: [],
}, },
}); });
return { return {

View File

@@ -1,35 +1,47 @@
// /api/user/update.ts import { Context } from "elysia";
import prisma from '@/lib/prisma'; import prisma from "@/lib/prisma";
import { Context } from 'elysia'; 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 { id } = context.params as { id: string };
const body = await context.body as { const body = (await context.body) as FormEditUser;
nama?: string;
email?: string; if (!id) throw new Error("ID user wajib diisi");
password?: string;
roleId?: string;
isActive?: boolean;
};
try { 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({ const updated = await prisma.user.update({
where: { id }, where: { id },
data: { 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 { return { success: true, message: "User berhasil diperbarui", data: updated };
success: true,
message: 'User berhasil diupdate',
data: updated,
};
} catch (error) { } catch (error) {
console.error(error); console.error("Error updating user:", error);
return { throw new Error("Gagal mengedit user: " + (error as Error).message);
success: false,
message: 'Gagal mengupdate user',
};
} }
} }

View File

@@ -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 };

View File

@@ -13,7 +13,10 @@ import '@mantine/tiptap/styles.css';
import "primereact/resources/themes/lara-light-blue/theme.css"; import "primereact/resources/themes/lara-light-blue/theme.css";
import "primereact/resources/primereact.min.css"; import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.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"; import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
@@ -25,6 +28,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { ViewTransitions } from "next-view-transitions"; import { ViewTransitions } from "next-view-transitions";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { getServerSession } from "next-auth";
export const metadata = { export const metadata = {
title: "Desa Darmasaba", title: "Desa Darmasaba",
@@ -39,11 +43,13 @@ const theme = createTheme({
headings: { fontFamily: "San Francisco, sans-serif" }, headings: { fontFamily: "San Francisco, sans-serif" },
}); });
export default function RootLayout({ export default async function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const session = await getServerSession(authOptions);
return ( return (
<ViewTransitions> <ViewTransitions>
<html lang="en" {...mantineHtmlProps}> <html lang="en" {...mantineHtmlProps}>
@@ -56,15 +62,16 @@ export default function RootLayout({
/> />
</head> </head>
<body> <body>
<MantineProvider theme={theme}> <AuthProvider session={session}>
{children} <MantineProvider theme={theme}>
<LoadDataFirstClient />
</MantineProvider> <ToastContainer position="bottom-center" hideProgressBar style={{
<ToastContainer position="bottom-center" hideProgressBar style={{ zIndex: 9999
zIndex: 9999 }} />
}} /> {children}
</MantineProvider>
</AuthProvider>
</body> </body>
<LoadDataFirstClient />
</html> </html>
</ViewTransitions> </ViewTransitions>
); );

View File

@@ -1,83 +1,150 @@
'use client' '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';
function Page() { import colors from '@/con/colors';
const router = useRouter() import { Box, Button, Center, Image, Notification, Paper, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core';
const snap = useSnapshot(userState.userState) import { IconAlertCircle, IconLock, IconUser } from '@tabler/icons-react';
const handleSubmit = async () => { import { signIn } from 'next-auth/react';
router.push("/darmasaba/pendidikan/perpustakaan-digital") import { useRouter, useSearchParams } from 'next/navigation';
await snap.login.submit() 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 ( return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}> <Box style={{ minHeight: '100vh', backgroundColor: colors.Bg }} p="md">
<Box px={{ base: 'md', md: 100 }}> <Center h="100vh">
<BackButton /> <Paper shadow="md" p="xl" radius="md" style={{ width: '100%', maxWidth: 400 }}>
</Box> <Stack align="center" gap="lg">
<Box px={{ base: 'md', md: 100 }} > <Image
<Center> src="/darmasaba-icon.png"
<Image src={"/darmasaba-icon.png"} alt="" w={80} /> alt="Desa Darmasaba"
</Center> width={80}
<Box> height={80}
<Title ta={'center'} order={1} fw={'bold'} c={colors['blue-button']}> style={{ objectFit: 'contain' }}
E-Book Desa Darmasaba />
</Title>
<Text ta={'center'} fz={'h4'} fw={'bold'} c={colors['blue-button']}> <Title order={2} c={colors['blue-button']}>
Silahkan masukkan akun anda untuk menjelajahi berbagai macam buku di perpustakaan digital Sistem Informasi Desa Darmasaba
</Text> </Title>
</Box>
</Box> <Text c="dimmed" size="sm" ta="center">
<Box px={{ base: 'md', md: 100 }} pb={50}> Silakan masuk dengan akun Anda
<Group justify='center'> </Text>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center'> {error && (
<Title order={2} fw={'bold'} c={colors['blue-button']}> <Notification
Login icon={<IconAlertCircle size={18} />}
</Title> color="red"
<IconUserFilled size={80} color={colors['blue-button']} /> onClose={() => setError('')}
<Box> styles={{ root: { width: '100%' } }}
<Text c={colors['blue-button']} fw={'bold'}>Masuk Untuk Akses Lebih Banyak Buku</Text> >
{error}
</Notification>
)}
<form onSubmit={handleSubmit} style={{ width: '100%' }}>
<Stack gap="md">
<TextInput <TextInput
type='email' label="Email"
label='Email' placeholder="Masukkan email"
placeholder='Email'
value={snap.login.form.email}
onChange={(e) => {
userState.userState.login.form.email = e.target.value
}}
required required
leftSection={<IconUser size={16} />}
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
/> />
<TextInput py={20}
type='password' <PasswordInput
label='Password' label="Password"
placeholder='Password' placeholder="Masukkan password"
value={snap.login.form.password} required
onChange={(e) => { leftSection={<IconLock size={16} />}
userState.userState.login.form.password = e.target.value value={password}
}} onChange={(e) => setPassword(e.target.value)}
disabled={loading}
/> />
<Box pb={20} >
<Button onClick={handleSubmit} fullWidth bg={colors['blue-button']} radius={'xl'}>Masuk</Button> <Button
</Box> type="submit"
<Flex justify={'center'} align={'center'}> fullWidth
<Text>Belum punya akun? </Text> loading={loading}
<Button variant='transparent' component={Link} href={'/registrasi'}> style={{ marginTop: '1rem' }}
<Text c={colors['blue-button']} fw={'bold'}>Registrasi</Text> bg={colors['blue-button']}
</Button> >
</Flex> Masuk
</Box> </Button>
</Stack> </Stack>
</Paper> </form>
</Group>
</Box> <Text size="sm" c="dimmed" mt="md">
</Stack> Lupa password?{' '}
<Button
variant='transparent'
component="a"
c={colors['blue-button']}
style={{ cursor: 'pointer' }}
onClick={() => router.push('/registrasi')}
>
Register
</Button>
</Text>
</Stack>
</Paper>
</Center>
</Box>
); );
} }
export default Page; export default LoginPage;

View File

@@ -2,19 +2,19 @@
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Checkbox, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core'; 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 BackButton from '../darmasaba/(pages)/desa/layanan/_com/BackButto';
import { useRouter } from 'next/navigation'; // import { useRouter } from 'next/navigation';
import { useSnapshot } from 'valtio'; // import { useSnapshot } from 'valtio';
import userState from '../admin/(dashboard)/_state/user/user-state'; // import userState from '../admin/(dashboard)/_state/user/user-state';
function Page() { function Page() {
const router = useRouter() // const router = useRouter()
const registrerState = useSnapshot(userState.userState) // const registrerState = useSnapshot(userState.userState)
const handleSubmit = async () => { // const handleSubmit = async () => {
router.push("/login") // router.push("/login")
await registrerState.register.submit() // await registrerState.register.submit()
} // }
return ( return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}> <Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}>
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
@@ -43,29 +43,29 @@ function Page() {
<Box> <Box>
<TextInput placeholder='Nama Lengkap' <TextInput placeholder='Nama Lengkap'
label='Nama Lengkap' label='Nama Lengkap'
value={registrerState.register.form.nama} // value={registrerState.register.form.nama}
onChange={(e) => { // onChange={(e) => {
userState.userState.register.form.nama = e.target.value // userState.userState.register.form.nama = e.target.value
}} // }}
required // required
/> />
<TextInput py={10} placeholder='Email' <TextInput py={10} placeholder='Email'
label='Email' label='Email'
value={registrerState.register.form.email} // value={registrerState.register.form.email}
onChange={(e) => { // onChange={(e) => {
userState.userState.register.form.email = e.target.value // userState.userState.register.form.email = e.target.value
}} // }}
required // required
/> />
<TextInput pb={10} placeholder='Password' <TextInput pb={10} placeholder='Password'
type='password' type='password'
label='Password' label='Password'
value={registrerState.register.form.password} // value={registrerState.register.form.password}
onChange={(e) => { // onChange={(e) => {
userState.userState.register.form.password = e.target.value // userState.userState.register.form.password = e.target.value
}} // }}
required // required
/> />
<Box pb={10}> <Box pb={10}>
<Checkbox <Checkbox
@@ -73,7 +73,11 @@ function Page() {
/> />
</Box> </Box>
<Box pb={20} > <Box pb={20} >
<Button onClick={handleSubmit} fullWidth bg={colors['blue-button']} radius={'xl'}>Daftar</Button> <Button
// onClick={handleSubmit}
fullWidth
bg={colors['blue-button']}
radius={'xl'}>Daftar</Button>
</Box> </Box>
</Box> </Box>
</Stack> </Stack>

View File

@@ -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 (
<Center style={{ minHeight: '100vh', backgroundColor: colors.Bg }}>
<Stack align="center" gap="md">
<IconLockOff size={80} color={colors['blue-button']} />
<Title order={2} c={colors['blue-button']}>
Akses Ditolak
</Title>
<Text c="dimmed" ta="center" maw={500} px="md">
Maaf, Anda tidak memiliki izin untuk mengakses halaman ini.
Silakan hubungi administrator jika Anda merasa ini adalah kesalahan.
</Text>
<Button
onClick={() => router.back()}
variant="outline"
color={colors['blue-button']}
mt="md"
>
Kembali ke Halaman Sebelumnya
</Button>
<Button
onClick={() => router.push('/')}
variant="subtle"
color={colors['blue-button']}
>
Kembali ke Beranda
</Button>
</Stack>
</Center>
);
}

View File

@@ -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<AdminContextType | undefined>(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 (
<AdminContext.Provider
value={{
sidebarOpened,
toggleSidebar,
mobileOpened,
toggleMobile,
}}
>
{children}
</AdminContext.Provider>
);
}
export function useAdmin() {
const context = useContext(AdminContext);
if (context === undefined) {
throw new Error('useAdmin must be used within an AdminProvider');
}
return context;
}

View File

@@ -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 (
<Box
component="header"
style={{
padding: '1rem',
borderBottom: '1px solid var(--mantine-color-gray-3)',
backgroundColor: 'white',
position: 'sticky',
top: 0,
zIndex: 100,
}}
>
<Group justify="space-between" align="center">
<Group>
<ActionIcon
variant="subtle"
onClick={toggleMobile}
aria-label="Toggle sidebar"
visibleFrom="sm"
style={{ display: mobileOpened ? 'none' : 'block' }}
>
<Burger opened={mobileOpened} size="sm" />
</ActionIcon>
<Title order={4} fw={500}>
Dashboard Admin
</Title>
</Group>
<Group>
<UserMenu />
</Group>
</Group>
</Box>
);
}

View File

@@ -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 (
<Box
component="nav"
style={{
width: mobileOpened ? rem(300) : rem(260),
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
backgroundColor: 'white',
borderRight: '1px solid var(--mantine-color-gray-3)',
zIndex: 1000,
transition: 'transform 0.2s ease',
transform: mobileOpened ? 'translateX(0)' : 'translateX(-100%)',
}}
hiddenFrom="sm"
>
<ScrollArea h="100%" type="scroll">
<Box p="md" style={{ paddingTop: 'calc(var(--mantine-spacing-xl) * 2)' }}>
{mainLinks.map((link) => (
<NavLink
key={link.href}
href={link.href}
label={link.label}
leftSection={<link.icon size={20} />}
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) && (
<NavLink
label="Admin Desa"
leftSection={<IconUsers size={20} />}
defaultOpened={pathname.startsWith('/admin/desa')}
>
{adminDesaLinks.map((link) => (
<NavLink
key={link.href}
href={link.href}
label={link.label}
active={isActive(link.href)}
onClick={toggleMobile}
style={{ paddingLeft: rem(30) }}
/>
))}
</NavLink>
)}
{(userRole === ROLES.ADMIN_KESEHATAN || userRole === ROLES.ADMIN_DESA) && (
<NavLink
label="Kesehatan"
leftSection={<IconBuildingHospital size={20} />}
defaultOpened={pathname.startsWith('/admin/kesehatan')}
>
{adminKesehatanLinks.map((link) => (
<NavLink
key={link.href}
href={link.href}
label={link.label}
active={isActive(link.href)}
onClick={toggleMobile}
style={{ paddingLeft: rem(30) }}
/>
))}
</NavLink>
)}
{(userRole === ROLES.ADMIN_SEKOLAH || userRole === ROLES.ADMIN_DESA) && (
<NavLink
label="Sekolah"
leftSection={<IconSchool size={20} />}
defaultOpened={pathname.startsWith('/admin/sekolah')}
>
{adminSekolahLinks.map((link) => (
<NavLink
key={link.href}
href={link.href}
label={link.label}
active={isActive(link.href)}
onClick={toggleMobile}
style={{ paddingLeft: rem(30) }}
/>
))}
</NavLink>
)}
{settingsLinks.map((link) => (
<NavLink
key={link.href}
href={link.href}
label={link.label}
leftSection={<link.icon size={20} />}
active={isActive(link.href)}
onClick={toggleMobile}
style={{ marginTop: rem(8) }}
/>
))}
</Box>
</ScrollArea>
</Box>
);
}

View File

@@ -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 = (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}>
<Loader size="xl" />
</div>
),
}: 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 (
<ProtectedRoute allowedRoles={[ROLES.ADMIN_DESA]}>
{children}
</ProtectedRoute>
);
}
export function AdminKesehatanRoute({ children }: { children: React.ReactNode }) {
return (
<ProtectedRoute allowedRoles={[ROLES.ADMIN_KESEHATAN]}>
{children}
</ProtectedRoute>
);
}
export function AdminSekolahRoute({ children }: { children: React.ReactNode }) {
return (
<ProtectedRoute allowedRoles={[ROLES.ADMIN_SEKOLAH]}>
{children}
</ProtectedRoute>
);
}
// Example of a route that allows multiple roles
export function AdminRoute({ children }: { children: React.ReactNode }) {
return (
<ProtectedRoute
allowedRoles={[ROLES.ADMIN_DESA, ROLES.ADMIN_KESEHATAN, ROLES.ADMIN_SEKOLAH]}
>
{children}
</ProtectedRoute>
);
}

View File

@@ -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 (
<Group justify="center" pb="sm">
<Button
variant="default"
onClick={() => router.push('/login')}
fullWidth={!collapsed}
>
Masuk
</Button>
</Group>
);
}
const user = session.user;
const userInitial = user.name ? user.name.charAt(0).toUpperCase() : 'U';
return (
<Menu
width={200}
position="right-start"
offset={10}
opened={userMenuOpened}
onChange={setUserMenuOpened}
withinPortal={false}
>
<Menu.Target>
<UnstyledButton
style={{
padding: 'var(--mantine-spacing-xs)',
borderRadius: 'var(--mantine-radius-sm)',
color: 'var(--mantine-color-text)',
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)',
},
}}
>
<Group>
<Avatar size={collapsed ? 36 : 40} radius="xl" color="blue">
{userInitial}
</Avatar>
{!collapsed && (
<Box style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{user.name || 'User'}
</Text>
<Text c="dimmed" size="xs">
{user.role?.name?.replace('_', ' ') || 'No Role'}
</Text>
</Box>
)}
{!collapsed && (
<IconChevronDown
style={{ width: rem(18), height: rem(18) }}
stroke={1.5}
/>
)}
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Akun</Menu.Label>
<Menu.Item
leftSection={
<IconUser style={{ width: rem(16), height: rem(16) }} stroke={1.5} />
}
onClick={() => router.push('/profile')}
>
Profil Saya
</Menu.Item>
<Menu.Item
leftSection={
<IconSettings style={{ width: rem(16), height: rem(16) }} stroke={1.5} />
}
onClick={() => router.push('/settings')}
>
Pengaturan
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={
<IconLogout style={{ width: rem(16), height: rem(16) }} stroke={1.5} />
}
onClick={() => signOut({ callbackUrl: '/login' })}
>
Keluar
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -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 (
<SessionProvider session={session}>
{children}
</SessionProvider>
);
}

71
src/lib/auth/config.ts Normal file
View File

@@ -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<UserRole, Permission[]> = {
[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,
],
}

202
src/lib/auth/options.ts Normal file
View File

@@ -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',
},
},
},
};

95
src/lib/auth/utils.tsx Normal file
View File

@@ -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<void>;
// 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}</>;
}

View File

@@ -1,46 +1,123 @@
// app/middleware.js import { NextResponse, type NextRequest } from 'next/server';
import { NextResponse, NextRequest } from 'next/server'; import { getToken } from 'next-auth/jwt';
import { ROLES } from './lib/auth/config';
// Daftar route yang diizinkan tanpa login (public routes) type RouteConfig = {
const publicRoutes = [ path: string;
'/*', // Home page roles?: string[];
'/about', // About page public?: boolean;
'/public/*', // Wildcard untuk semua route di bawah /public };
'/login', // Halaman login
// 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 // Fungsi untuk memeriksa apakah route saat ini adalah route publik
function isPublicRoute(pathname: string) { function isPublicRoute(pathname: string): boolean {
return publicRoutes.some((route) => { return routeConfigs.some(route => {
// Jika route mengandung wildcard (*), gunakan regex untuk mencocokkan if (route.public) {
if (route.endsWith('*')) { if (route.path.endsWith('**')) {
const baseRoute = route.replace('*', ''); // Hapus wildcard const basePath = route.path.replace(/\*\*$/, '');
return pathname.startsWith(baseRoute); // Cocokkan dengan pathname return pathname.startsWith(basePath);
} }
return pathname === route; // Cocokkan exact path return pathname === route.path;
}); }
return false;
});
} }
export function middleware(request: NextRequest) { // Fungsi untuk memeriksa apakah user memiliki akses ke route
const { pathname } = request.nextUrl; function hasAccess(pathname: string, userRole: string | null): boolean {
if (!userRole) return false;
// Jika route adalah public, izinkan akses
if (isPublicRoute(pathname)) { const routeConfig = routeConfigs.find(config => {
return NextResponse.next(); 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 // Jika tidak ada konfigurasi khusus, tolak akses
const isLoggedIn = request.cookies.get('darmasaba-auth-token'); // Contoh: cek cookie auth-token if (!routeConfig) return false;
if (!isLoggedIn) {
// Redirect ke halaman login jika belum login // Jika route public, izinkan akses
return NextResponse.redirect(new URL('/login', request.url)); 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(); 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 = { 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/).*)',
],
}; };