Compare commits
1 Commits
nico/13-ok
...
nico/2-sep
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e50aff69e |
@@ -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;
|
||||
|
||||
11
package.json
11
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",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "cmdpm429r0000vnndkcwslt0h",
|
||||
"name": "warga"
|
||||
}
|
||||
]
|
||||
30
prisma/data/user/roles.json
Normal file
30
prisma/data/user/roles.json
Normal 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"
|
||||
}
|
||||
]
|
||||
|
||||
36
prisma/data/user/users.json
Normal file
36
prisma/data/user/users.json
Normal 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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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 ========================================= //
|
||||
|
||||
@@ -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 ===========
|
||||
|
||||
33
scripts/list-users.ts
Normal file
33
scripts/list-users.ts
Normal 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();
|
||||
39
scripts/reset-passwords.ts
Normal file
39
scripts/reset-passwords.ts
Normal 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();
|
||||
@@ -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: {
|
||||
|
||||
28
src/app/admin/(dashboard)/layout.tsx
Normal file
28
src/app/admin/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/app/admin/dashboard/page.tsx
Normal file
78
src/app/admin/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Text } from "@mantine/core";
|
||||
// /admin/page.tsx
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
return(
|
||||
<Text>
|
||||
Test
|
||||
</Text>
|
||||
)
|
||||
export default function AdminPage() {
|
||||
redirect('/admin');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 } };
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export default async function roleCreate(context: Context) {
|
||||
const result = await prisma.role.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
permissions: [],
|
||||
},
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
@@ -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 (
|
||||
<ViewTransitions>
|
||||
<html lang="en" {...mantineHtmlProps}>
|
||||
@@ -56,15 +62,16 @@ export default function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<MantineProvider theme={theme}>
|
||||
{children}
|
||||
|
||||
</MantineProvider>
|
||||
<ToastContainer position="bottom-center" hideProgressBar style={{
|
||||
zIndex: 9999
|
||||
}} />
|
||||
<AuthProvider session={session}>
|
||||
<MantineProvider theme={theme}>
|
||||
<LoadDataFirstClient />
|
||||
<ToastContainer position="bottom-center" hideProgressBar style={{
|
||||
zIndex: 9999
|
||||
}} />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
<LoadDataFirstClient />
|
||||
</html>
|
||||
</ViewTransitions>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}>
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
<Box px={{ base: 'md', md: 100 }} >
|
||||
<Center>
|
||||
<Image src={"/darmasaba-icon.png"} alt="" w={80} />
|
||||
</Center>
|
||||
<Box>
|
||||
<Title ta={'center'} order={1} fw={'bold'} c={colors['blue-button']}>
|
||||
E-Book Desa Darmasaba
|
||||
</Title>
|
||||
<Text ta={'center'} fz={'h4'} fw={'bold'} c={colors['blue-button']}>
|
||||
Silahkan masukkan akun anda untuk menjelajahi berbagai macam buku di perpustakaan digital
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box px={{ base: 'md', md: 100 }} pb={50}>
|
||||
<Group justify='center'>
|
||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
||||
<Stack align='center'>
|
||||
<Title order={2} fw={'bold'} c={colors['blue-button']}>
|
||||
Login
|
||||
</Title>
|
||||
<IconUserFilled size={80} color={colors['blue-button']} />
|
||||
<Box>
|
||||
<Text c={colors['blue-button']} fw={'bold'}>Masuk Untuk Akses Lebih Banyak Buku</Text>
|
||||
<Box style={{ minHeight: '100vh', backgroundColor: colors.Bg }} p="md">
|
||||
<Center h="100vh">
|
||||
<Paper shadow="md" p="xl" radius="md" style={{ width: '100%', maxWidth: 400 }}>
|
||||
<Stack align="center" gap="lg">
|
||||
<Image
|
||||
src="/darmasaba-icon.png"
|
||||
alt="Desa Darmasaba"
|
||||
width={80}
|
||||
height={80}
|
||||
style={{ objectFit: 'contain' }}
|
||||
/>
|
||||
|
||||
<Title order={2} c={colors['blue-button']}>
|
||||
Sistem Informasi Desa Darmasaba
|
||||
</Title>
|
||||
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
Silakan masuk dengan akun Anda
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
icon={<IconAlertCircle size={18} />}
|
||||
color="red"
|
||||
onClose={() => setError('')}
|
||||
styles={{ root: { width: '100%' } }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ width: '100%' }}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
type='email'
|
||||
label='Email'
|
||||
placeholder='Email'
|
||||
value={snap.login.form.email}
|
||||
onChange={(e) => {
|
||||
userState.userState.login.form.email = e.target.value
|
||||
}}
|
||||
label="Email"
|
||||
placeholder="Masukkan email"
|
||||
required
|
||||
leftSection={<IconUser size={16} />}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<TextInput py={20}
|
||||
type='password'
|
||||
label='Password'
|
||||
placeholder='Password'
|
||||
value={snap.login.form.password}
|
||||
onChange={(e) => {
|
||||
userState.userState.login.form.password = e.target.value
|
||||
}}
|
||||
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Masukkan password"
|
||||
required
|
||||
leftSection={<IconLock size={16} />}
|
||||
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>
|
||||
</Box>
|
||||
<Flex justify={'center'} align={'center'}>
|
||||
<Text>Belum punya akun? </Text>
|
||||
<Button variant='transparent' component={Link} href={'/registrasi'}>
|
||||
<Text c={colors['blue-button']} fw={'bold'}>Registrasi</Text>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Group>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
style={{ marginTop: '1rem' }}
|
||||
bg={colors['blue-button']}
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
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;
|
||||
|
||||
@@ -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 (
|
||||
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"}>
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
@@ -43,29 +43,29 @@ function Page() {
|
||||
<Box>
|
||||
<TextInput placeholder='Nama Lengkap'
|
||||
label='Nama Lengkap'
|
||||
value={registrerState.register.form.nama}
|
||||
onChange={(e) => {
|
||||
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
|
||||
|
||||
/>
|
||||
<TextInput py={10} placeholder='Email'
|
||||
label='Email'
|
||||
value={registrerState.register.form.email}
|
||||
onChange={(e) => {
|
||||
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
|
||||
/>
|
||||
<TextInput pb={10} placeholder='Password'
|
||||
type='password'
|
||||
type='password'
|
||||
label='Password'
|
||||
value={registrerState.register.form.password}
|
||||
onChange={(e) => {
|
||||
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
|
||||
/>
|
||||
<Box pb={10}>
|
||||
<Checkbox
|
||||
@@ -73,7 +73,11 @@ function Page() {
|
||||
/>
|
||||
</Box>
|
||||
<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>
|
||||
</Stack>
|
||||
|
||||
40
src/app/unauthorized/page.tsx
Normal file
40
src/app/unauthorized/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/admin/admin-provider.tsx
Normal file
41
src/components/admin/admin-provider.tsx
Normal 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;
|
||||
}
|
||||
44
src/components/admin/header.tsx
Normal file
44
src/components/admin/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
src/components/admin/navbar.tsx
Normal file
155
src/components/admin/navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/components/auth/protected-route.tsx
Normal file
100
src/components/auth/protected-route.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/components/auth/user-menu.tsx
Normal file
115
src/components/auth/user-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/providers/session-provider.tsx
Normal file
18
src/components/providers/session-provider.tsx
Normal 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
71
src/lib/auth/config.ts
Normal 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
202
src/lib/auth/options.ts
Normal 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
95
src/lib/auth/utils.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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/).*)',
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user