Compare commits

...

5 Commits

Author SHA1 Message Date
716db0adca Fix Middleware
Fix Layout sesuai role, dan superadmin bisa menambahkan menu ke user jika diperlukan
Penambahan menu di user & role : menu access
2025-11-24 16:02:13 +08:00
a291bdfb51 Tampilan Layout sudah sesuai dengan roleIdnya
Sudah sessionnya
Sudah disesuaikan juga semisal superadmin ngubah role admin, maka admin tersebut akan logOut dan diarahkan ke halama login
sudah bisa logOut
2025-11-21 17:26:38 +08:00
0dff8f3254 Nico 20 Nov 25
Dibagian layout admin sudah disesuaikan dengan rolenya : supadmin, admin desa, admin kesehatan, admin pendidikan
Fix API User & Role Admin
2025-11-20 16:42:36 +08:00
78b8aa74cd Saat user baru registrasi maka akan diarahkan ke page waiting-room dan menunggu validasi admin 2025-11-20 14:07:26 +08:00
a0537810e8 Login, Register, Verifkasi Code Admin V1 2025-11-20 02:42:39 +08:00
48 changed files with 4258 additions and 704 deletions

View File

@@ -1,23 +1,26 @@
[
{
"id": "role-1",
"name": "ADMIN DESA",
"description": "Administrator Desa",
"permissions": ["manage_users", "manage_content", "view_reports"],
"isActive": true
},
{
"id": "role-2",
"name": "ADMIN KESEHATAN",
"description": "Administrator Bidang Kesehatan",
"permissions": ["manage_health_data", "view_reports"],
"isActive": true
},
{
"id": "role-3",
"name": "ADMIN SEKOLAH",
"description": "Administrator Sekolah",
"permissions": ["manage_school_data", "view_reports"],
"isActive": true
}
]
{
"id": "0",
"name": "SUPER ADMIN",
"description": "Administrator",
"isActive": true
},
{
"id": "1",
"name": "ADMIN DESA",
"description": "Administrator Desa",
"isActive": true
},
{
"id": "2",
"name": "ADMIN KESEHATAN",
"description": "Administrator Bidang Kesehatan",
"isActive": true
},
{
"id": "3",
"name": "ADMIN PENDIDIKAN",
"description": "Administrator Bidang Pendidikan",
"isActive": true
}
]

View File

@@ -1,23 +0,0 @@
[
{
"id": "user-1",
"nama": "Admin Desa",
"nomor": "089647037426",
"roleId": "role-1",
"isActive": true
},
{
"id": "user-2",
"nama": "Admin Kesehatan",
"nomor": "082339004198",
"roleId": "role-2",
"isActive": true
},
{
"id": "user-3",
"nama": "Admin Sekolah",
"nomor": "085237157222",
"roleId": "role-3",
"isActive": true
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -183,41 +183,41 @@ model SdgsDesa {
//========================================= APBDes ========================================= //
model APBDes {
id String @id @default(cuid())
tahun Int?
name String? // misalnya: "APBDes Tahun 2025"
deskripsi String?
jumlah String? // total keseluruhan (opsional, bisa juga dihitung dari items)
items APBDesItem[]
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
imageId String?
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
fileId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? // opsional, tidak perlu default now()
isActive Boolean @default(true)
id String @id @default(cuid())
tahun Int?
name String? // misalnya: "APBDes Tahun 2025"
deskripsi String?
jumlah String? // total keseluruhan (opsional, bisa juga dihitung dari items)
items APBDesItem[]
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
imageId String?
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
fileId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? // opsional, tidak perlu default now()
isActive Boolean @default(true)
}
model APBDesItem {
id String @id @default(cuid())
kode String // contoh: "4", "4.1", "4.1.2"
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
realisasi Float
selisih Float // realisasi - anggaran
persentase Float
tipe String? // (realisasi / anggaran) * 100
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
parentId String? // untuk relasi hierarki
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
children APBDesItem[] @relation("APBDesItemParent")
apbdesId String
apbdes APBDes @relation(fields: [apbdesId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
isActive Boolean @default(true)
id String @id @default(cuid())
kode String // contoh: "4", "4.1", "4.1.2"
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
realisasi Float
selisih Float // realisasi - anggaran
persentase Float
tipe String? // (realisasi / anggaran) * 100
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
parentId String? // untuk relasi hierarki
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
children APBDesItem[] @relation("APBDesItemParent")
apbdesId String
apbdes APBDes @relation(fields: [apbdesId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
isActive Boolean @default(true)
@@index([kode])
@@index([level])
@@ -2163,25 +2163,26 @@ enum StatusPeminjaman {
// ========================================= USER ========================================= //
model User {
id String @id @default(cuid())
username String
nomor String @unique
role Role @relation(fields: [roleId], references: [id])
roleId String @default("1")
instansi String?
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
isActive Boolean @default(true)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
id String @id @default(cuid())
username String @unique
nomor String @unique
role Role @relation(fields: [roleId], references: [id])
roleId String @default("1")
instansi String?
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
isActive Boolean @default(true)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
sessionInvalid Boolean @default(false)
UserMenuAccess UserMenuAccess[]
}
model Role {
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
@@ -2200,17 +2201,6 @@ model KodeOtp {
otp Int
}
// 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")
}
model UserSession {
id String @id @default(cuid())
token String
@@ -2222,6 +2212,18 @@ model UserSession {
userId String @unique
}
model UserMenuAccess {
id String @id @default(cuid())
userId String
menuId String // ID menu (misal: "Landing Page", "Kesehatan")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
}
// ========================================= DATA PENDIDIKAN ========================================= //
model DataPendidikan {
id String @id @default(cuid())

View File

@@ -54,14 +54,13 @@ import tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-progr
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";
import fileStorage from "./data/file-storage.json";
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
import seedAssets from "./seed_assets";
import { safeSeedUnique } from "./safeseedUnique";
(async () => {
// =========== USER & ROLE ===========
// =========== ROLE ===========
// In your seed.ts
// =========== ROLES ===========
console.log("🔄 Seeding roles...");
@@ -69,35 +68,12 @@ import { safeSeedUnique } from "./safeseedUnique";
await safeSeedUnique("role", { id: r.id }, {
name: r.name,
description: r.description,
permissions: r.permissions,
isActive: r.isActive,
});
}
console.log("✅ Roles seeded");
// =========== USERS ===========
console.log("🔄 Seeding users...");
for (const u of users) {
// First verify the role exists
const roleExists = await prisma.role.findUnique({
where: { id: u.roleId },
});
if (!roleExists) {
console.error(`❌ Role with id ${u.roleId} not found for user ${u.nama}`);
continue;
}
await safeSeedUnique("user", { id: u.id }, {
username: u.nama,
nomor: u.nomor,
roleId: u.roleId,
isActive: u.isActive,
});
}
console.log("✅ Users seeded");
// =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage...");
for (const f of fileStorage) {

View File

@@ -90,42 +90,96 @@ const userState = proxy({
}
},
},
updateActive: {
deleteUser: {
loading: false,
async submit(id: string, isActive: boolean) {
this.loading = true;
async delete(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
const res = await fetch(`/api/user/updt`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, isActive }),
userState.deleteUser.loading = true;
const response = await fetch(`/api/user/delUser/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const data = await res.json();
if (res.status === 200 && data.success) {
toast.success(data.message);
userState.findMany.load(userState.findMany.page, 10, userState.findMany.search);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "User berhasil dihapus permanen");
await userState.findMany.load(); // refresh list user setelah delete
} else {
toast.error(data.message || "Gagal update status user");
toast.error(result?.message || "Gagal menghapus user");
}
} catch (e) {
console.error(e);
toast.error("Gagal update status user");
} catch (error) {
console.error("Gagal delete user:", error);
toast.error("Terjadi kesalahan saat menghapus user");
} finally {
this.loading = false;
userState.delete.loading = false;
}
},
},
// Di file userState.ts atau dimana state user berada
update: {
loading: false,
async submit(payload: { id: string; isActive?: boolean; roleId?: string }) {
this.loading = true;
try {
const res = await fetch(`/api/user/updt`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.status === 200 && data.success) {
// ✅ Tampilkan pesan yang berbeda jika role berubah
if (data.roleChanged) {
toast.success(
`${data.message}\n\nUser akan logout otomatis dalam beberapa detik.`,
{
autoClose: 5000,
}
);
} else {
toast.success(data.message);
}
// Refresh list
await userState.findMany.load(
userState.findMany.page,
10,
userState.findMany.search
);
return true; // ✅ Return success untuk handling di component
} else {
toast.error(data.message || "Gagal update user");
return false;
}
} catch (e) {
console.error("❌ Error update user:", e);
toast.error("Gagal update user");
return false;
} finally {
this.loading = false;
}
},
},
});
const templateRole = z.object({
name: z.string().min(1, "Nama harus diisi"),
permissions: z.array(z.string()).min(1, "Permission harus diisi"),
});
const defaultRole = {
name: "",
permissions: [] as string[],
};
const roleState = proxy({
@@ -237,7 +291,7 @@ const roleState = proxy({
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(`/api/role/${id}`, {
method: "GET",
@@ -245,31 +299,25 @@ const roleState = proxy({
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
// langsung set melalui root state, bukan this
roleState.update.id = data.id;
roleState.update.form = {
name: data.name,
permissions: data.permissions,
};
return data; // Return the loaded data
} else {
throw new Error(result?.message || "Gagal memuat data");
return data;
}
} catch (error) {
console.error("Error loading role:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
toast.error("Gagal memuat data");
}
},
},
async update() {
const cek = templateRole.safeParse(roleState.update.form);
if (!cek.success) {
@@ -290,7 +338,6 @@ const roleState = proxy({
},
body: JSON.stringify({
name: this.form.name,
permissions: this.form.permissions,
}),
});

View File

@@ -1,104 +1,98 @@
'use client'
import { apiFetchLogin } from '@/app/admin/auth/_lib/api_fetch_auth';
'use client';
import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
import colors from '@/con/colors';
import { Box, Button, Center, Flex, Image, Paper, Stack, Text, Title } from '@mantine/core';
import Link from 'next/link';
import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { PhoneInput } from "react-international-phone";
import "react-international-phone/style.css";
import { PhoneInput } from 'react-international-phone';
import 'react-international-phone/style.css';
import { toast } from 'react-toastify';
function Login() {
const router = useRouter()
const [phone, setPhone] = useState("")
const [isError, setError] = useState(false)
const [loading, setLoading] = useState(false)
const router = useRouter();
const [phone, setPhone] = useState('');
const [loading, setLoading] = useState(false);
// Login.tsx
async function onLogin() {
const nomor = phone.substring(1);
if (nomor.length <= 4) return setError(true)
const cleanPhone = phone.replace(/\D/g, '');
if (cleanPhone.length < 10) {
toast.error('Nomor telepon tidak valid');
return;
}
try {
setLoading(true);
const response = await apiFetchLogin({ nomor: nomor })
if (response && response.success) {
localStorage.setItem("hipmi_auth_code_id", response.kodeId);
toast.success(response.message);
router.push("/validasi", { scroll: false });
const response = await apiFetchLogin({ nomor: cleanPhone });
if (!response.success) {
toast.error(response.message || 'Gagal memproses login');
return;
}
// Simpan nomor untuk register
localStorage.setItem('auth_nomor', cleanPhone);
if (response.isRegistered) {
// ✅ User lama: simpan kodeId & ke validasi
localStorage.setItem('auth_kodeId', response.kodeId);
router.push('/validasi');
} else {
setLoading(false);
toast.error(response?.message);
// ❌ User baru: langsung ke registrasi (tanpa kodeId)
router.push('/registrasi');
}
} catch (error) {
setLoading(false)
console.log("Error Login", error)
toast.error("Terjadi kesalahan saat login")
console.error('Error Login:', error);
toast.error('Terjadi kesalahan saat login');
} finally {
setLoading(false);
}
}
return (
<Stack pos={"relative"} bg={colors.Bg}>
<Stack pos="relative" bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align='center' justify='center' h={"100vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center' gap={"lg"}>
<Stack align="center" justify="center" h="100vh">
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align="center" gap="lg">
<Box>
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
Login
</Title>
<Center>
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
<Image
loading="lazy"
src="/darmasaba-icon.png"
alt="Logo"
w={80}
h={80}
/>
</Center>
</Box>
<Box>
{/* <Box mb={10}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masuk Untuk Akses Admin</Text>
<TextInput
label='Username'
placeholder='Username'
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</Box> */}
<Box w="100%">
<PhoneInput
countrySelectorStyleProps={{
buttonStyle: {
backgroundColor: colors['blue-button'],
},
}}
inputStyle={{ width: "100%"}}
inputStyle={{ width: '100%' }}
defaultCountry="id"
onChange={(val) => {
setPhone(val);
}}
value={phone}
onChange={(val) => setPhone(val)}
/>
{isError ? (
toast.error("Masukan nomor telepon anda")
) : (
""
)}
<Box py={20} >
<Box py={20}>
<Button
fullWidth
bg={colors['blue-button']}
radius={'xl'}
radius="xl"
onClick={onLogin}
loading={loading ? true : false}
>Masuk
loading={loading}
>
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>
@@ -108,4 +102,4 @@ function Login() {
);
}
export default Login;
export default Login;

View File

@@ -1,113 +1,127 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
'use client'
import { apiFetchRegister } from '@/app/admin/auth/_lib/api_fetch_auth';
// app/registrasi/page.tsx
'use client';
import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth';
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors';
import { Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import {
Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title,
} from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { PhoneInput } from "react-international-phone";
import "react-international-phone/style.css";
import { useEffect, useState } from 'react';
import { PhoneInput } from 'react-international-phone';
import 'react-international-phone/style.css';
import { toast } from 'react-toastify';
function Registrasi() {
const [phone, setPhone] = useState("")
const router = useRouter()
const [value, setValue] = useState("")
const [isValue, setIsValue] = useState(false);
export default function Registrasi() {
const router = useRouter();
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
async function onRegistarsi() {
if (value.length < 5) {
toast.error("Username minimal 5 karakter!");
// Ambil data dari localStorage (dari login)
useEffect(() => {
const storedNomor = localStorage.getItem('auth_nomor');
if (!storedNomor) {
toast.error('Akses tidak valid');
router.push('/login');
return;
}
if (value.includes(" ")) {
toast.error("Username tidak boleh ada spasi!");
setPhone(storedNomor);
}, [router]);
const handleRegister = async () => {
if (!username || username.trim().length < 5) {
toast.error('Username minimal 5 karakter!');
return;
}
if (!phone) {
toast.error("Nomor telepon wajib diisi!");
if (username.includes(' ')) {
toast.error('Username tidak boleh ada spasi!');
return;
}
const cleanPhone = phone.replace(/\D/g, '');
if (cleanPhone.length < 10) {
toast.error('Nomor tidak valid!');
return;
}
try {
setLoading(true);
const respone = await apiFetchRegister({ nomor: phone, username: value });
// ✅ Hanya kirim username & nomor → dapat kodeId
const response = await apiFetchRegister({ username, nomor: cleanPhone });
if (respone.success) {
router.push("/login", { scroll: false });
toast.success(respone.message);
if (response.success) {
// Simpan sementara
localStorage.setItem('auth_kodeId', response.kodeId);
localStorage.setItem('auth_username', username); // simpan username
} else {
setLoading(false);
toast.error(respone.message);
toast.success('Kode verifikasi dikirim!');
router.push('/validasi'); // ✅ ke halaman validasi
}
} catch (error) {
console.error('Error Registrasi:', error);
toast.error('Gagal mengirim OTP');
} finally {
setLoading(false);
console.log("Error Registrasi", error);
}
}
};
return (
<Stack pos={"relative"} bg={colors.Bg} gap={"22"} py={"xl"} h={"100vh"}>
<Stack pos="relative" bg={colors.Bg} gap="22" py="xl" h="100vh">
<Box px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
<Box px={{ base: 'md', md: 100 }}>
<Stack justify='center' align='center' h={"80vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center'>
<Title order={2} fw={'bold'} c={colors['blue-button']}>
<Stack justify="center" align="center" h="80vh">
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align="center">
<Title order={2} fw="bold" c={colors['blue-button']}>
Registrasi
</Title>
<Center>
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
<Image loading="lazy" src="/darmasaba-icon.png" alt="" w={80} />
</Center>
<Box>
<TextInput placeholder='Username'
label='Username'
maxLength={50}
<Box w="100%">
<TextInput
label="Username"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.currentTarget.value)}
error={
value.length > 0 && value.length < 5
? "Minimal 5 karakter !"
: value.includes(" ")
? "Tidak boleh ada spasi"
: isValue
? "Masukan username anda"
: ""
username.length > 0 && username.length < 5
? 'Minimal 5 karakter!'
: username.includes(' ')
? 'Tidak boleh ada spasi'
: ''
}
onChange={(val) => {
val.currentTarget.value.length > 0 ? setIsValue(false) : "";
setValue(val.currentTarget.value);
}}
required
/>
<Box py={10}>
<Text fz={"sm"} >Nomor Telepon</Text>
<Box pt="md">
<Text fz="sm">Nomor Telepon</Text>
<PhoneInput
countrySelectorStyleProps={{
buttonStyle: {
backgroundColor: colors['blue-button'],
},
}}
inputStyle={{ width: "100%" }}
defaultCountry="id"
onChange={(val) => {
setPhone(val);
}}
value={phone}
disabled
/>
</Box>
<Box pb={10}>
<Checkbox
label="Saya menyetujui syarat dan ketentuan yang berlaku"
/>
<Box pt="md">
<Checkbox label="Saya menyetujui syarat dan ketentuan" defaultChecked />
</Box>
<Box pb={20} >
<Button fullWidth bg={colors['blue-button']} radius={'xl'} onClick={onRegistarsi} loading={loading ? true : false}>Daftar</Button>
<Box pt="xl">
<Button
fullWidth
bg={colors['blue-button']}
radius="xl"
onClick={handleRegister}
loading={loading}
disabled={username.length < 5}
>
Kirim Kode Verifikasi
</Button>
</Box>
</Box>
</Stack>
@@ -116,6 +130,4 @@ function Registrasi() {
</Box>
</Stack>
);
}
export default Registrasi;
}

View File

@@ -1,31 +1,261 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
//
'use client';
import { apiFetchOtpData, apiFetchVerifyOtp } from '@/app/api/auth/_lib/api_fetch_auth';
import colors from '@/con/colors';
import { Box, Button, Center, Loader, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { authStore } from '@/store/authStore';
export default function Validasi() {
const router = useRouter();
const [nomor, setNomor] = useState<string | null>(null);
const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [kodeId, setKodeId] = useState<string | null>(null);
// Inisialisasi data OTP
useEffect(() => {
const storedKodeId = localStorage.getItem('auth_kodeId');
if (!storedKodeId) {
toast.error('Akses tidak valid');
router.replace('/login');
return;
}
setKodeId(storedKodeId);
const loadOtpData = async () => {
try {
const result = await apiFetchOtpData({ kodeId: storedKodeId });
if (result.success && result.data?.nomor) {
setNomor(result.data.nomor);
} else {
throw new Error('Data OTP tidak valid');
}
} catch (error) {
console.error('Gagal memuat data OTP:', error);
toast.error('Kode verifikasi tidak valid');
router.replace('/login');
} finally {
setIsLoading(false);
}
};
loadOtpData();
}, [router]);
// Verifikasi OTP
const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return;
setLoading(true);
try {
const verifyResult = await apiFetchVerifyOtp({ nomor, otp, kodeId });
if (!verifyResult.success) {
// Registrasi baru?
if (
verifyResult.status === 404 &&
verifyResult.message?.includes('Akun tidak ditemukan')
) {
await handleNewRegistration();
return;
}
// Error lain
toast.error(verifyResult.message || 'Verifikasi gagal');
return;
}
// ✅ Verifikasi sukses → simpan user ke store
const user = verifyResult.user;
console.log('=== DEBUG USER ===');
console.log('Full user object:', user);
if (!user || !user.id) {
toast.error('Data pengguna tidak lengkap');
return;
}
const roleId = Number(user.roleId);
authStore.setUser({
id: user.id,
name: user.name || user.username || 'User',
roleId: roleId,
});
cleanupStorage();
const isUserActive = user.isActive ?? user.is_active ?? true;
// Redirect berdasarkan status approval
if (!isUserActive) {
router.replace('/waiting-room');
return;
}
// ✅ Switch statement lebih clean
let redirectPath: string;
switch (roleId) {
case 0:
case 1:
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case 2:
redirectPath = '/admin/kesehatan/posyandu';
break;
case 3:
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
break;
default:
redirectPath = '/admin';
console.warn('Unknown roleId:', roleId);
}
console.log('Redirecting to:', redirectPath);
router.replace(redirectPath);
} catch (error) {
console.error('Error saat verifikasi:', error);
toast.error('Terjadi kesalahan sistem');
} finally {
setLoading(false);
}
};
// Registrasi baru
const handleNewRegistration = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan');
return;
}
try {
const res = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, otp, kodeId }),
});
const data = await res.json();
if (data.success) {
// Set user sementara (tanpa roleId, akan diisi saat approve)
authStore.setUser({
id: 'pending',
name: username,
roleId: 1,
});
cleanupStorage();
router.replace('/waiting-room');
} else {
toast.error(data.message || 'Registrasi gagal');
}
} catch (error) {
console.error('Error registrasi:', error);
toast.error('Gagal menyelesaikan registrasi');
}
};
const cleanupStorage = () => {
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username');
};
const handleResend = async () => {
if (!nomor) return;
try {
const res = await fetch('/api/auth/resend-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }),
});
const data = await res.json();
if (data.success) {
localStorage.setItem('auth_kodeId', data.kodeId);
toast.success('OTP baru dikirim');
} else {
toast.error(data.message || 'Gagal mengirim ulang OTP');
}
} catch {
toast.error('Gagal menghubungi server');
}
};
// Loading
if (isLoading) {
return (
<Stack pos="relative" bg={colors.Bg} align="center" justify="center" h="100vh">
<Loader size="md" color={colors['blue-button']} />
</Stack>
);
}
if (!nomor) return null;
function Validasi() {
const router = useRouter()
return (
<Stack pos={"relative"} bg={colors.Bg}>
<Stack pos="relative" bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align='center' justify='center' h={"100vh"}>
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
<Stack align='center' gap={"lg"}>
<Stack align="center" justify="center" h="100vh">
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align="center" gap="lg">
<Box>
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
Kode Verifikasi
</Title>
<Text ta="center" size="sm" c="dimmed" mt="xs">
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
</Text>
</Box>
<Box>
<Box mb={10}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text>
<PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" />
<Box w="100%">
<Box mb={20}>
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
Masukkan Kode Verifikasi
</Text>
<Center>
<PinInput
length={4}
value={otp}
onChange={setOtp}
onComplete={handleVerify}
inputMode="numeric"
size="lg"
/>
</Center>
</Box>
<Box py={20} >
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}>
Page
<Button
fullWidth
onClick={handleVerify}
loading={loading}
disabled={otp.length < 4}
bg={colors['blue-button']}
radius="xl"
>
Verifikasi
</Button>
<Text ta="center" size="sm" mt="md">
Tidak menerima kode?{' '}
<Button
variant="subtle"
onClick={handleResend}
size="xs"
p={0}
h="auto"
color={colors['blue-button']}
>
Kirim Ulang
</Button>
</Box>
</Text>
</Box>
</Stack>
</Paper>
@@ -33,6 +263,4 @@ function Validasi() {
</Box>
</Stack>
);
}
export default Validasi;
}

View File

@@ -0,0 +1,22 @@
// src/app/admin/(dashboard)/user&role/_com/dynamicNavbar.ts
import { navBar, role1, role2, role3 } from '@/app/admin/_com/list_PageAdmin';
export function getNavbar({
roleId,
menuIds,
}: {
roleId: number; // pastikan number
menuIds?: string[] | null; // opsional
}) {
// Prioritas: menuIds > roleId
if (menuIds && menuIds.length > 0) {
return navBar.filter(section => menuIds.includes(section.id));
}
// Fallback ke role-based
if (roleId === 0) return navBar;
if (roleId === 1) return role1;
if (roleId === 2) return role2;
if (roleId === 3) return role3;
return [];
}

View File

@@ -2,7 +2,7 @@
'use client'
import colors from '@/con/colors';
import { Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
import { IconForms, IconUser } from '@tabler/icons-react';
import { IconBrush, IconForms, IconUser } from '@tabler/icons-react';
import { usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
@@ -23,6 +23,12 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
href: "/admin/user&role/role",
icon: <IconForms size={18} stroke={1.8} />,
},
{
label: "Menu Access",
value: "menu-access",
href: "/admin/user&role/menu-access",
icon: <IconBrush size={18} stroke={1.8} />,
}
];
const currentTab = tabs.find(tab => tab.href === pathname);

View File

@@ -0,0 +1,124 @@
/* eslint-disable react-hooks/exhaustive-deps */
// src/app/admin/user&role/menu-access/page.tsx
'use client'
import { navBar } from '@/app/admin/_com/list_PageAdmin'
import { Button, Checkbox, Group, Paper, Select, Stack, Text, Title } from '@mantine/core'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import user from '../../_state/user/user-state'
// ✅ Helper: ekstrak semua menu ID dari struktur navBar
const extractMenuIds = (navSections: typeof navBar) => {
return navSections.map(section => ({
value: section.id, // "Landing Page", "Kesehatan", dll
label: section.name // "Landing Page", "Kesehatan", dll
}));
};
function MenuAccessPage() {
const stateUser = useProxy(user.userState)
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
const [userMenus, setUserMenus] = useState<string[]>([])
// ✅ Gunakan helper untuk ekstrak menu
const availableMenus = extractMenuIds(navBar);
// Ambil data menu akses user
const loadUserMenuAccess = async () => {
if (!selectedUserId) return
try {
// ✅ Perbaiki URL: gunakan query string bukan dynamic route
const res = await fetch(`/api/admin/user-menu-access?userId=${selectedUserId}`)
const data = await res.json()
if (data.success) {
setUserMenus(data.menuIds || [])
}
} catch (error) {
console.error('Gagal memuat menu akses:', error)
}
}
useEffect(() => {
if (selectedUserId) {
loadUserMenuAccess()
}
}, [selectedUserId])
const handleToggleMenu = (menuId: string) => {
setUserMenus(prev =>
prev.includes(menuId)
? prev.filter(id => id !== menuId)
: [...prev, menuId]
)
}
const handleSave = async () => {
if (!selectedUserId) return
try {
const res = await fetch('/api/admin/user-menu-access', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: selectedUserId, menuIds: userMenus }),
})
const data = await res.json()
if (data.success) {
alert('Menu akses berhasil disimpan')
}
} catch (error) {
console.error('Gagal menyimpan menu akses:', error)
alert('Terjadi kesalahan')
}
}
return (
<Stack>
<Title order={2}>Tampilan Menu</Title>
<Paper p="xl" shadow="md" radius="md">
<Stack gap="lg">
<Group>
<Text fw={500}>Pilih User:</Text>
<Select
placeholder="Pilih user"
data={stateUser.findMany.data.map(u => ({
value: u.id,
label: `${u.username} (${u.nomor})`,
}))}
value={selectedUserId}
onChange={setSelectedUserId}
w={300}
/>
</Group>
{selectedUserId && (
<>
<Text fw={500}>Menu yang Bisa Diakses:</Text>
<Stack>
{availableMenus.map(menu => (
<Checkbox
key={menu.value}
label={menu.label}
checked={userMenus.includes(menu.value)}
onChange={() => handleToggleMenu(menu.value)}
/>
))}
</Stack>
<Button onClick={handleSave} mt="md">
Simpan Perubahan
</Button>
</>
)}
</Stack>
</Paper>
</Stack>
)
}
export default MenuAccessPage

View File

@@ -1,7 +1,8 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { Box, Button, Loader, Group, MultiSelect, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { Box, Button, Group, Loader, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
@@ -9,6 +10,7 @@ import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import user from '../../../_state/user/user-state';
function EditRole() {
const stateRole = useProxy(user.roleState);
const router = useRouter();
@@ -17,46 +19,37 @@ function EditRole() {
// Controlled local state
const [formData, setFormData] = useState({
name: '',
permissions: [] as string[],
});
const [originalData, setOriginalData] = useState({
name: '',
permissions: [] as string[],
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Load role data
const loadRole = useCallback(async (id: string) => {
try {
const data = await stateRole.update.load(id);
if (data) {
setFormData({
name: data.name || '',
permissions: data.permissions || [],
});
setOriginalData({
name: data.name || '',
permissions: data.permissions || [],
});
}
} catch (error) {
console.error('Error loading role:', error);
toast.error(error instanceof Error ? error.message : 'Gagal mengambil data role');
const data = await stateRole.update.load(id);
if (data) {
setFormData({
name: data.name ?? '',
});
setOriginalData({
name: data.name ?? '',
});
}
}, [stateRole.update]);
}, []);
useEffect(() => {
stateRole.findMany.load(); // Load permissions/options
const id = params?.id as string;
if (id) loadRole(id);
}, [params?.id, loadRole, stateRole.findMany]);
stateRole.findMany.load(); // load permission
if (params?.id) loadRole(params.id as string);
}, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
permissions: originalData.permissions,
});
toast.info("Form dikembalikan ke data awal");
};
@@ -66,10 +59,6 @@ function EditRole() {
toast.error('Nama role tidak boleh kosong');
return;
}
if (!formData.permissions.length) {
toast.error('Pilih minimal satu permission');
return;
}
try {
setIsSubmitting(true);
@@ -77,7 +66,6 @@ function EditRole() {
stateRole.update.form = {
...stateRole.update.form,
name: formData.name,
permissions: formData.permissions,
};
await stateRole.update.update();
toast.success('Role berhasil diperbarui!');
@@ -116,24 +104,7 @@ function EditRole() {
label={<Text fw="bold" fz="sm">Nama Role</Text>}
placeholder="Masukkan nama role"
/>
<MultiSelect
value={formData.permissions}
onChange={(val) => setFormData({ ...formData, permissions: val })}
label={<Text fw="bold" fz="sm">Permission</Text>}
placeholder="Pilih permission"
data={
stateRole.findMany.data?.map((v) => ({
value: v.id,
label: v.name,
})) || []
}
clearable
searchable
required
error={!formData.permissions.length ? 'Pilih minimal satu permission' : undefined}
/>
<Group justify="right">
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"

View File

@@ -5,9 +5,8 @@ import {
Box,
Button,
Group,
MultiSelect,
Paper,
Loader,
Paper,
Stack,
TextInput,
Title
@@ -15,9 +14,9 @@ import {
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import user from '../../../_state/user/user-state';
import { toast } from 'react-toastify';
export default function CreateRole() {
@@ -31,8 +30,7 @@ export default function CreateRole() {
const resetForm = () => {
stateRole.create.form = {
name: '',
permissions: [],
name: ''
};
};
@@ -80,28 +78,6 @@ export default function CreateRole() {
required
/>
</Box>
<Box>
<MultiSelect
label="Permission"
placeholder="Pilih permission"
data={
Array.from(
new Set(
stateRole.findMany.data
?.map((item) => item.permissions)
.flat()
)
)
.filter((p): p is string => typeof p === 'string')
.map((p) => ({ label: p, value: p }))
}
value={stateRole.create.form.permissions}
onChange={(value) => (stateRole.create.form.permissions = value)}
required
/>
</Box>
<Group justify="right">
<Button

View File

@@ -1,15 +1,14 @@
'use client'
import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core';
import { Box, Button, Center, Group, Pagination, Paper, Select, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconCheck, IconSearch, IconX } from '@tabler/icons-react';
import { IconCheck, IconSearch, IconTrash, IconX } from '@tabler/icons-react';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import user from '../../_state/user/user-state';
function User() {
const [search, setSearch] = useState("");
return (
@@ -27,9 +26,10 @@ function User() {
}
function ListUser({ search }: { search: string }) {
const stateUser = useProxy(user.userState)
const [modalHapus, setModalHapus] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const stateUser = useProxy(user.userState);
const stateRole = useProxy(user.roleState);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const {
data,
@@ -41,20 +41,99 @@ function ListUser({ search }: { search: string }) {
const handleDelete = () => {
if (selectedId) {
stateUser.delete.submit(selectedId)
setModalHapus(false)
setSelectedId(null)
stateUser.findMany.load()
stateUser.deleteUser.delete(selectedId);
setModalHapus(false);
setSelectedId(null);
stateUser.findMany.load();
}
}
};
useShallowEffect(() => {
load(page, 10, search)
}, [page, search])
stateRole.findMany.load();
load(page, 10, search);
}, [page, search]);
const filteredData = data || []
// ✅ Helper function untuk nama role
const getRoleName = (roleId: string) => {
// Cari dari data role yang sudah diload
const role = stateRole.findMany.data.find((r) => r.id === roleId);
return role?.name || "Unknown Role";
};
// ✅ Handler untuk perubahan role dengan konfirmasi
const handleRoleChange = async (
userId: string,
username: string,
oldRoleId: string,
newRoleId: string
) => {
// Skip jika sama
if (oldRoleId === newRoleId) {
return true;
}
// ✅ Konfirmasi perubahan role
const confirmed = window.confirm(
`⚠️ PERINGATAN\n\n` +
`Mengubah role untuk "${username}" akan:\n` +
`• Logout user otomatis dari semua device\n` +
`• Mengubah akses menu sesuai role baru\n\n` +
`Role: ${getRoleName(oldRoleId)}${getRoleName(newRoleId)}\n\n` +
`Lanjutkan?`
);
if (!confirmed) {
// Reload data untuk reset dropdown ke nilai lama
stateUser.findMany.load(page, 10, search);
return false;
}
// ✅ Submit update
const success = await stateUser.update.submit({
id: userId,
roleId: newRoleId,
});
if (success) {
// Cek apakah role berubah
const res = await fetch('/api/user/updt', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: userId,
roleId: newRoleId,
}),
});
const data = await res.json();
if (data.roleChanged) {
// Tampilkan notifikasi
alert(`User ${username} akan logout otomatis!`);
}
stateUser.findMany.load(page, 10, search);
}
return success;
};
// ✅ Handler untuk toggle isActive
const handleToggleActive = async (userId: string, currentStatus: boolean) => {
const success = await stateUser.update.submit({
id: userId,
isActive: !currentStatus,
});
if (success) {
stateUser.findMany.load(page, 10, search);
}
};
const filteredData = (data || []).filter(
(item) => item.roleId !== "0" // asumsikan id role SUPERADMIN = "0"
);
if (loading || !data) {
return (
@@ -78,25 +157,51 @@ function ListUser({ search }: { search: string }) {
<TableTh style={{ width: '20%' }}>Nomor</TableTh>
<TableTh style={{ width: '20%' }}>Role</TableTh>
<TableTh style={{ width: '15%' }}>Aktif / Nonaktif</TableTh>
<TableTh style={{ width: '15%' }}>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%', }}>
<Text fw={500} truncate="end" lineClamp={1}>{item.username}</Text>
<TableTd style={{ width: '25%' }}>
<Text fw={500} truncate="end" lineClamp={1}>
{item.username}
</Text>
</TableTd>
<TableTd style={{ width: '20%', }}>
<TableTd style={{ width: '20%' }}>
<Text truncate fz="sm" c="dimmed">
{item.nomor}
</Text>
</TableTd>
<TableTd style={{ width: '20%', }}>
<Text truncate fz="sm" c="dimmed">
{item.role.name}
</Text>
<TableTd style={{ width: '20%' }}>
<Select
placeholder="Pilih role"
data={stateRole.findMany.data
.filter(r => r.id !== "0") // ❌ Sembunyikan SUPERADMIN
.map(r => ({
label: r.name,
value: r.id,
}))}
value={item.roleId}
onChange={(val) => {
if (!val) return;
// ✅ Panggil handleRoleChange dengan konfirmasi
handleRoleChange(
item.id,
item.username,
item.roleId,
val
);
}}
searchable
clearable={false}
nothingFoundMessage="Role tidak ditemukan"
disabled={stateUser.update.loading}
/>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Tooltip
label={item.isActive ? "Nonaktifkan user" : "Aktifkan user"}
@@ -105,22 +210,34 @@ function ListUser({ search }: { search: string }) {
<Button
variant="light"
color={item.isActive ? "green" : "red"}
onClick={async () => {
await stateUser.updateActive.submit(item.id, !item.isActive)
stateUser.findMany.load(page, 10, search)
}}
onClick={() => handleToggleActive(item.id, item.isActive)}
disabled={stateUser.update.loading}
>
{item.isActive ? <IconCheck size={20} /> : <IconX size={20} />}
</Button>
</Tooltip>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
variant="light"
color='red'
disabled={stateUser.deleteUser.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={20} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<TableTd colSpan={5}>
<Center py={20}>
<Text color="dimmed">Tidak ada data user yang cocok</Text>
<Text c="dimmed">Tidak ada data user yang cocok</Text>
</Center>
</TableTd>
</TableTr>
@@ -129,6 +246,7 @@ function ListUser({ search }: { search: string }) {
</Table>
</Box>
</Paper>
<Center>
<Pagination
value={page}
@@ -143,6 +261,7 @@ function ListUser({ search }: { search: string }) {
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
@@ -154,4 +273,4 @@ function ListUser({ search }: { search: string }) {
);
}
export default User;
export default User;

View File

@@ -84,7 +84,6 @@ export const navBar = [
]
},
{
id: "Desa",
name: "Desa",
@@ -336,7 +335,420 @@ export const navBar = [
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
}
]
}, {
},
{
id: "Pendidikan",
name: "Pendidikan",
path: "",
children: [
{
id: "Pendidikan_1",
name: "Info Sekolah",
path: "/admin/pendidikan/info-sekolah/jenjang-pendidikan"
},
{
id: "Pendidikan_2",
name: "Beasiswa Desa",
path: "/admin/pendidikan/beasiswa-desa/beasiswa-pendaftar"
},
{
id: "Pendidikan_3",
name: "Program Pendidikan Anak",
path: "/admin/pendidikan/program-pendidikan-anak/program-unggulan"
},
{
id: "Pendidikan_4",
name: "Bimbingan Belajar Desa",
path: "/admin/pendidikan/bimbingan-belajar-desa/tujuan-program"
},
{
id: "Pendidikan_5",
name: "Pendidikan Non Formal",
path: "/admin/pendidikan/pendidikan-non-formal/tujuan-program"
},
{
id: "Pendidikan_6",
name: "Perpustakaan Digital",
path: "/admin/pendidikan/perpustakaan-digital/data-perpustakaan"
},
{
id: "Pendidikan_7",
name: "Data Pendidikan",
path: "/admin/pendidikan/data-pendidikan"
}
]
},
{
id: "User & Role",
name: "User & Role",
path: "",
children: [
{
id: "User",
name: "User",
path: "/admin/user&role/user"
},
{
id: "Role",
name: "Role",
path: "/admin/user&role/role"
},
{
id: "Menu Access",
name: "Menu Access",
path: "/admin/user&role/menu-access"
}
]
}
]
export const role1 = [
{
id: "Landing Page",
name: "Landing Page",
path: "",
children: [
{
id: "Landing_Page_1",
name: "Profil",
path: "/admin/landing-page/profil/program-inovasi"
},
{
id: "Landing_Page_2",
name: "Desa Anti Korupsi",
path: "/admin/landing-page/desa-anti-korupsi/list-desa-anti-korupsi"
},
{
id: "Landing_Page_3",
name: "Indeks Kepuasan Masyarakat",
path: "/admin/landing-page/indeks-kepuasan-masyarakat/grafik-kepuasan-masyarakat"
},
{
id: "Landing_Page_4",
name: "SDGs",
path: "/admin/landing-page/SDGs"
},
{
id: "Landing_Page_5",
name: "APBDes",
path: "/admin/landing-page/apbdes"
},
{
id: "Landing_Page_6",
name: "Prestasi Desa",
path: "/admin/landing-page/prestasi-desa/list-prestasi-desa"
}
]
},
{
id: "PPID",
name: "PPID",
path: "",
children: [
{
id: "PPID_1",
name: "Profil PPID",
path: "/admin/ppid/profil-ppid"
},
{
id: "PPID_2",
name: "Struktur PPID",
path: "/admin/ppid/struktur-ppid/pegawai"
},
{
id: "PPID_3",
name: "Visi Misi PPID",
path: "/admin/ppid/visi-misi-ppid"
},
{
id: "PPID_4",
name: "Dasar Hukum",
path: "/admin/ppid/dasar-hukum"
},
{
id: "PPID_5",
name: "Permohonan Informasi Publik",
path: "/admin/ppid/permohonan-informasi-publik"
},
{
id: "PPID_6",
name: "Permohonan Keberatan Informasi Publik",
path: "/admin/ppid/permohonan-keberatan-informasi-publik"
},
{
id: "PPID_7",
name: "Daftar Informasi Publik",
path: "/admin/ppid/daftar-informasi-publik"
},
{
id: "PPID_8",
name: "Indeks Kepuasan Masyarakat",
path: "/admin/ppid/indeks-kepuasan-masyarakat/grafik-kepuasan-masyarakat"
},
]
},
{
id: "Desa",
name: "Desa",
path: "",
children: [
{
id: "Desa_1",
name: "Profile",
path: "/admin/desa/profile/profile-desa"
},
{
id: "Desa_2",
name: "Potensi",
path: "/admin/desa/potensi/list-potensi"
},
{
id: "Desa_3",
name: "Berita",
path: "/admin/desa/berita/list-berita"
},
{
id: "Desa_4",
name: "Pengumuman",
path: "/admin/desa/pengumuman/list-pengumuman"
},
{
id: "Desa_5",
name: "Gallery",
path: "/admin/desa/gallery/foto"
},
{
id: "Desa_6",
name: "Layanan",
path: "/admin/desa/layanan/pelayanan_surat_keterangan"
},
{
id: "Desa_7",
name: "Penghargaan",
path: "/admin/desa/penghargaan"
}
]
},
{
id: "Keamanan",
name: "Keamanan",
path: "",
children: [
{
id: "Keamanan_1",
name: "Keamanan Lingkungan (Pecalang/Patwal)",
path: "/admin/keamanan/keamanan-lingkungan-pecalang-patwal"
},
{
id: "Keamanan_2",
name: "Polsek Terdekat",
path: "/admin/keamanan/polsek-terdekat"
},
{
id: "Keamanan_3",
name: "Kontak Darurat",
path: "/admin/keamanan/kontak-darurat/kontak-darurat-keamanan"
},
{
id: "Keamanan_4",
name: "Pencegahan Kriminalitas",
path: "/admin/keamanan/pencegahan-kriminalitas"
},
{
id: "Keamanan_5",
name: "Laporan Publik",
path: "/admin/keamanan/laporan-publik"
},
{
id: "Keamanan_6",
name: "Tips Keamanan",
path: "/admin/keamanan/tips-keamanan"
}
]
},
{
id: "Ekonomi",
name: "Ekonomi",
path: "",
children: [
{
id: "Ekonomi_1",
name: "Pasar Desa",
path: "/admin/ekonomi/pasar-desa/produk-pasar-desa"
},
{
id: "Ekonomi_2",
name: "Lowongan Kerja Lokal",
path: "/admin/ekonomi/lowongan-kerja-lokal"
},
{
id: "Ekonomi_3",
name: "Struktur Organisasi Dan Sk Pengurus Bumdesa",
path: "/admin/ekonomi/struktur-organisasi-dan-sk-pengurus-bumdesa/pegawai"
},
{
id: "Ekonomi_4",
name: "PADesa (Pendapatan Asli Desa)",
path: "/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa"
},
{
id: "Ekonomi_5",
name: "Jumlah Pengangguran",
path: "/admin/ekonomi/jumlah-pengangguran"
},
{
id: "Ekonomi_6",
name: "Jumlah penduduk usia kerja yang menganggur",
path: "/admin/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran_berdasarkan_usia"
},
{
id: "Ekonomi_7",
name: "Jumlah Penduduk Miskin",
path: "/admin/ekonomi/jumlah-penduduk-miskin"
},
{
id: "Ekonomi_8",
name: "Program Kemiskinan",
path: "/admin/ekonomi/program-kemiskinan"
},
{
id: "Ekonomi_9",
name: "Sektor Unggulan Desa",
path: "/admin/ekonomi/sektor-unggulan-desa"
},
{
id: "Ekonomi_10",
name: "Demografi Pekerjaan",
path: "/admin/ekonomi/demografi-pekerjaan"
}
]
},
{
id: "Inovasi",
name: "Inovasi",
path: "",
children: [
{
id: "Inovasi_1",
name: "Desa Digital/Smart Village",
path: "/admin/inovasi/desa-digital-smart-village"
},
{
id: "Inovasi_2",
name: "Layanan Online Desa",
path: "/admin/inovasi/layanan-online-desa/administrasi-online"
},
{
id: "Inovasi_3",
name: "Program Kreatif Desa",
path: "/admin/inovasi/program-kreatif-desa"
},
{
id: "Inovasi_4",
name: "Kolaborasi Inovasi",
path: "/admin/inovasi/kolaborasi-inovasi/list-kolaborasi-inovasi"
},
{
id: "Inovasi_5",
name: "Info Teknologi Tepat Guna",
path: "/admin/inovasi/info-teknologi-tepat-guna"
},
{
id: "Inovasi_6",
name: "Ajukan Ide Inovatif",
path: "/admin/inovasi/ajukan-ide-inovatif"
}
]
},
{
id: "Lingkungan",
name: "Lingkungan",
path: "",
children: [
{
id: "Lingkungan_1",
name: "Pengelolaan Sampah (Bank Sampah)",
path: "/admin/lingkungan/pengelolaan-sampah-bank-sampah/list-pengelolaan-sampah-bank-sampah"
},
{
id: "Lingkungan_2",
name: "Program Penghijauan",
path: "/admin/lingkungan/program-penghijauan"
},
{
id: "Lingkungan_3",
name: "Data Lingkungan Desa",
path: "/admin/lingkungan/data-lingkungan-desa"
},
{
id: "Lingkungan_4",
name: "Gotong Royong",
path: "/admin/lingkungan/gotong-royong/kegiatan-desa"
},
{
id: "Lingkungan_5",
name: "Edukasi Lingkungan",
path: "/admin/lingkungan/edukasi-lingkungan/tujuan-edukasi-lingkungan"
},
{
id: "Lingkungan_6",
name: "Konservasi Adat Bali",
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
}
]
}
]
export const role2 = [
{
id: "Kesehatan",
name: "Kesehatan",
path: "",
children: [
{
id: "Kesehatan_1",
name: "Posyandu",
path: "/admin/kesehatan/posyandu"
},
{
id: "Kesehatan_2",
name: "Data Kesehatan Warga",
path: "/admin/kesehatan/data-kesehatan-warga/persentase_data_kelahiran_kematian"
},
{
id: "Kesehatan_3",
name: "Puskesmas",
path: "/admin/kesehatan/puskesmas"
},
{
id: "Kesehatan_4",
name: "Program Kesehatan",
path: "/admin/kesehatan/program-kesehatan"
},
{
id: "Kesehatan_5",
name: "Penanganan Darurat",
path: "/admin/kesehatan/penanganan-darurat"
},
{
id: "Kesehatan_6",
name: "Kontak Darurat",
path: "/admin/kesehatan/kontak-darurat"
},
{
id: "Kesehatan_7",
name: "Info Wabah/Penyakit",
path: "/admin/kesehatan/info-wabah-penyakit"
}
]
}
]
export const role3 = [
{
id: "Pendidikan",
name: "Pendidikan",
path: "",
@@ -378,4 +790,4 @@ export const navBar = [
}
]
}
]
]

View File

@@ -0,0 +1,9 @@
// src/app/(admin)/_com/navigationByRole.ts
import { navBar, role1, role2, role3 } from './list_PageAdmin';
export const navigationByRole = {
0: navBar, // SUPERADMIN
1: role1, // ADMIN DESA
2: role2, // ADMIN KESEHATAN
3: role3, // ADMIN PENDIDIKAN
} as const;

View File

@@ -1,41 +0,0 @@
export {
apiFetchLogin,
apiFetchRegister
};
const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
const response = await fetch("/api/auth/login", {
method: "POST",
body: JSON.stringify({ nomor: nomor }),
headers: {
"Content-Type": "application/json",
},
});
return await response.json().catch(() => null);
};
const apiFetchRegister = async ({
nomor,
username,
}: {
nomor: string;
username: string;
}) => {
const data = {
username: username,
nomor: nomor,
};
const respone = await fetch("/api/auth/register", {
method: "POST",
body: JSON.stringify({ data }),
headers: {
"Content-Type": "application/json",
},
});
const result = await respone.json();
return result;
// return await respone.json().catch(() => null);
};

View File

@@ -1,6 +1,7 @@
'use client'
import colors from "@/con/colors";
import { authStore } from "@/store/authStore";
import {
ActionIcon,
AppShell,
@@ -8,9 +9,11 @@ import {
AppShellMain,
AppShellNavbar,
Burger,
Center,
Flex,
Group,
Image,
Loader,
NavLink,
ScrollArea,
Text,
@@ -26,14 +29,86 @@ import {
import _ from "lodash";
import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { navBar } from "./_com/list_PageAdmin";
import { useEffect, useState } from "react";
import { useSnapshot } from "valtio";
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
export default function Layout({ children }: { children: React.ReactNode }) {
const [opened, { toggle }] = useDisclosure();
const [opened, { toggle }] = useDisclosure();
const [loading, setLoading] = useState(true);
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
const { user } = useSnapshot(authStore);
console.log("Current user in store:", user);
useEffect(() => {
if (authStore.user) {
setLoading(false);
return;
}
const fetchUser = async () => {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
if (data.user) {
const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`);
const menuData = await menuRes.json();
// ✅ Clone ke array mutable
const menuIds = menuData.success && Array.isArray(menuData.menuIds)
? [...menuData.menuIds] // Converts readonly array to mutable
: null;
authStore.setUser({
id: data.user.id,
name: data.user.name,
roleId: Number(data.user.roleId),
menuIds,
});
} else {
authStore.setUser(null);
router.replace('/login');
}
} catch (error) {
console.error('Gagal memuat data pengguna:', error);
authStore.setUser(null);
router.replace('/login');
} finally {
setLoading(false);
}
};
fetchUser();
}, [router]);
if (loading) {
return (
<AppShell>
<AppShellMain>
<Center h="100vh">
<Loader />
</Center>
</AppShellMain>
</AppShell>
);
}
// ✅ Ambil menu berdasarkan roleId
const currentNav = authStore.user
? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
: [];
const handleLogout = () => {
authStore.setUser(null);
document.cookie = `${process.env.BASE_SESSION_KEY}=; Max-Age=0; path=/;`;
router.push('/login');
};
return (
<AppShell
suppressHydrationWarning
@@ -115,13 +190,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }}
>
<Image
src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba"
w={20}
h={20}
radius="md"
loading="lazy"
<Image
src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba"
w={20}
h={20}
radius="md"
loading="lazy"
style={{
minWidth: '20px',
height: 'auto',
@@ -131,9 +206,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon
onClick={() => {
router.push("/darmasaba");
}}
onClick={handleLogout}
color={colors["blue-button"]}
radius="xl"
size="lg"
@@ -156,7 +229,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
p={{ base: 'xs', sm: 'sm' }}
>
<AppShell.Section p="sm">
{navBar.map((v, k) => {
{currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name));
return (

View File

@@ -1,5 +1,4 @@
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
@@ -12,52 +11,70 @@ export async function POST(req: Request) {
}
try {
const codeOtp = randomOTP();
const body = await req.json();
const { nomor } = body;
const res = await fetch(
`https://wa.wibudev.com/code?nom=${nomor}&text=Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.
\n
>> Kode OTP anda: ${codeOtp}.
`
);
const sendWa = await res.json();
if (sendWa.status !== "success")
if (!nomor || typeof nomor !== "string") {
return NextResponse.json(
{ success: false, message: "Nomor Whatsapp Tidak Aktif" },
{ success: false, message: "Nomor tidak valid" },
{ status: 400 }
);
}
const createOtpId = await prisma.kodeOtp.create({
data: {
nomor: nomor,
otp: codeOtp,
},
// Cek apakah user sudah terdaftar
const existingUser = await prisma.user.findUnique({
where: { nomor },
select: { id: true }, // cukup cek ada/tidak
});
if (!createOtpId)
const isRegistered = !!existingUser;
// Generate OTP
const codeOtp = randomOTP(); // Pastikan ini menghasilkan number (sesuai tipe di KodeOtp.otp: Int)
// Kirim OTP via WA
const waRes = await fetch(
`https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.%0A%0A>> Kode OTP anda: ${codeOtp}.`
);
const sendWa = await waRes.json();
if (sendWa.status !== "success") {
return NextResponse.json(
{ success: false, message: "Gagal mengirim kode OTP" },
{ success: false, message: "Nomor WhatsApp tidak aktif" },
{ status: 400 }
);
}
// Simpan OTP ke database
const otpRecord = await prisma.kodeOtp.create({
data: {
nomor: nomor,
otp: codeOtp, // Pastikan tipe ini number (Int di Prisma = number di TS)
},
});
return NextResponse.json(
{
success: true,
message: "Kode verifikasi terkirim",
kodeId: createOtpId.id,
kodeId: otpRecord.id,
isRegistered, // 🔑 Ini kunci untuk frontend tahu harus ke register atau verifikasi
},
{ status: 200 }
);
} catch (error) {
console.log("Error Login", error);
console.error("Error Login:", error);
return NextResponse.json(
{ success: false, message: "Terjadi masalah saat login" , reason: error as Error },
{
success: false,
message: "Terjadi masalah saat login",
// Hindari mengirim error mentah ke client di production
// reason: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined,
},
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}
}

View File

@@ -0,0 +1,30 @@
import prisma from "@/lib/prisma";
import { NextRequest } from "next/server";
// Jika pakai custom session (bukan next-auth), ganti dengan logic session-mu
export async function GET(req: NextRequest) {
// 🔸 GANTI DENGAN LOGIC SESSION-MU
// Contoh: jika kamu simpan user.id di cookie atau JWT
const userId = req.cookies.get("hipmi_user_id")?.value; // sesuaikan
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
nomor: true,
isActive: true,
role: { select: { name: true } },
},
});
if (!user) {
return Response.json({ error: "User not found" }, { status: 404 });
}
return Response.json({ user });
}

View File

@@ -0,0 +1,104 @@
// import prisma from "@/lib/prisma";
// import { NextResponse } from "next/server";
// export async function POST(req: Request) {
// if (req.method !== "POST") {
// return NextResponse.json(
// { success: false, message: "Method Not Allowed" },
// { status: 405 }
// );
// }
// try {
// const { username, nomor, otp, kodeId } = await req.json();
// // Validasi input
// if (!username || !nomor || !otp || !kodeId) {
// return NextResponse.json(
// { success: false, message: "Data tidak lengkap" },
// { status: 400 }
// );
// }
// // 1. Verifikasi OTP
// const otpRecord = await prisma.kodeOtp.findUnique({
// where: { id: kodeId },
// });
// if (!otpRecord) {
// return NextResponse.json(
// { success: false, message: "Kode verifikasi tidak valid" },
// { status: 400 }
// );
// }
// if (!otpRecord.isActive) {
// return NextResponse.json(
// { success: false, message: "Kode verifikasi sudah digunakan atau kadaluarsa" },
// { status: 400 }
// );
// }
// if (otpRecord.otp !== otp) {
// return NextResponse.json(
// { success: false, message: "Kode OTP salah" },
// { status: 400 }
// );
// }
// if (otpRecord.nomor !== nomor) {
// return NextResponse.json(
// { success: false, message: "Nomor tidak sesuai dengan kode verifikasi" },
// { status: 400 }
// );
// }
// // 3. Cek apakah nomor sudah terdaftar
// const existingUserByNomor = await prisma.user.findUnique({
// where: { nomor },
// });
// if (existingUserByNomor) {
// return NextResponse.json(
// { success: false, message: "Nomor sudah terdaftar" },
// { status: 409 }
// );
// }
// // 4. Buat user
// const newUser = await prisma.user.create({
// data: {
// username,
// nomor,
// // roleId default "1" (sesuai model)
// },
// });
// // 5. Nonaktifkan OTP agar tidak bisa dipakai lagi
// await prisma.kodeOtp.update({
// where: { id: kodeId },
// data: { isActive: false },
// });
// return NextResponse.json(
// {
// success: true,
// message: "Registrasi berhasil",
// userId: newUser.id,
// },
// { status: 201 }
// );
// } catch (error) {
// console.error("Error registrasi:", error);
// return NextResponse.json(
// {
// success: false,
// message: "Terjadi kesalahan saat registrasi",
// // reason: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined,
// },
// { status: 500 }
// );
// } finally {
// await prisma.$disconnect();
// }
// }

View File

@@ -0,0 +1,38 @@
// /api/user/delete.ts
import prisma from '@/lib/prisma';
import { Context } from 'elysia';
export default async function userDelete(context: Context) {
const { id } = context.params as { id: string };
try {
// Cek user dulu
const existingUser = await prisma.user.findUnique({
where: { id },
});
if (!existingUser) {
return {
success: false,
message: 'User tidak ditemukan',
};
}
// Hard delete (hapus permanen)
const deletedUser = await prisma.user.delete({
where: { id },
});
return {
success: true,
message: 'User berhasil dihapus permanen',
data: deletedUser,
};
} catch (error) {
console.error('Error delete user:', error);
return {
success: false,
message: 'Terjadi kesalahan saat menghapus user',
};
}
}

View File

@@ -14,6 +14,21 @@ const User = new Elysia({ prefix: "/api/user" })
id: t.String(),
}),
}) // pakai PUT untuk soft delete
.put("/updt", userUpdate);
.put(
"/updt",
userUpdate,
{
body: t.Object({
id: t.String(),
isActive: t.Optional(t.Boolean()),
roleId: t.Optional(t.String()),
})
}
)
.put("/delUser/:id", userDelete, {
params: t.Object({
id: t.String(),
}),
});
export default User;

View File

@@ -3,7 +3,6 @@ import { Context } from "elysia";
type FormCreate = {
name: string;
permissions: string[];
}
export default async function roleCreate(context: Context) {
@@ -13,7 +12,6 @@ export default async function roleCreate(context: Context) {
const result = await prisma.role.create({
data: {
name: body.name,
permissions: body.permissions,
},
});
return {

View File

@@ -13,7 +13,6 @@ const Role = new Elysia({
.post("/create", roleCreate, {
body: t.Object({
name: t.String(),
permissions: t.Array(t.String()),
}),
})
@@ -27,7 +26,6 @@ const Role = new Elysia({
.put("/:id", roleUpdate, {
body: t.Object({
name: t.String(),
permissions: t.Array(t.String()),
}),
})
.delete("/del/:id", roleDelete);

View File

@@ -3,7 +3,6 @@ import { Context } from "elysia";
type FormUpdate = {
name: string;
permissions: string[];
}
export default async function roleUpdate(context: Context) {
@@ -15,7 +14,6 @@ export default async function roleUpdate(context: Context) {
where: { id },
data: {
name: body.name,
permissions: body.permissions,
},
});
return {

View File

@@ -1,10 +1,134 @@
// /* eslint-disable @typescript-eslint/no-explicit-any */
// import prisma from "@/lib/prisma";
// import { Context } from "elysia";
// export default async function userUpdate(context: Context) {
// try {
// const { id, isActive, roleId } = await context.body as {
// id: string,
// isActive?: boolean,
// roleId?: string
// };
// if (!id) {
// return {
// success: false,
// message: "ID user wajib ada",
// };
// }
// // Optional: cek apakah roleId valid
// if (roleId) {
// const cekRole = await prisma.role.findUnique({
// where: { id: roleId }
// });
// if (!cekRole) {
// return {
// success: false,
// message: "Role tidak ditemukan",
// };
// }
// }
// // ✅ CEK: Apakah roleId berubah?
// let isRoleChanged = false;
// let oldRoleId: string | null = null;
// if (roleId) {
// const currentUser = await prisma.user.findUnique({
// where: { id },
// select: {
// roleId: true,
// username: true,
// }
// });
// if (currentUser && currentUser.roleId !== roleId) {
// isRoleChanged = true;
// oldRoleId = currentUser.roleId;
// console.log(`🔄 Role berubah untuk ${currentUser.username}: ${oldRoleId} → ${roleId}`);
// }
// }
// // Update user
// const updatedUser = await prisma.user.update({
// where: { id },
// data: {
// ...(isActive !== undefined && { isActive }),
// ...(roleId && { roleId }),
// },
// select: {
// id: true,
// username: true,
// nomor: true,
// isActive: true,
// roleId: true,
// updatedAt: true,
// role: {
// select: {
// id: true,
// name: true,
// }
// }
// }
// });
// // ✅ FORCE LOGOUT: Hapus UserSession jika role berubah
// if (isRoleChanged) {
// try {
// const deletedSessions = await prisma.userSession.deleteMany({
// where: { userId: id }
// });
// console.log(`🔒 Force logout user ${updatedUser.username} (${id})`);
// console.log(` Deleted ${deletedSessions.count} session(s)`);
// console.log(` Role: ${oldRoleId} → ${roleId}`);
// } catch (sessionError: any) {
// // Jika UserSession tidak ditemukan (user belum pernah login), skip error
// if (sessionError.code !== 'P2025') {
// console.error("⚠️ Error menghapus session:", sessionError);
// } else {
// console.log(` User ${updatedUser.username} belum pernah login`);
// }
// }
// }
// // ✅ Response dengan info tambahan
// return {
// success: true,
// message: isRoleChanged
// ? `User berhasil diupdate. ${updatedUser.username} akan logout otomatis.`
// : "User berhasil diupdate",
// data: updatedUser,
// roleChanged: isRoleChanged, // Info untuk frontend
// oldRoleId: oldRoleId,
// newRoleId: roleId,
// };
// } catch (e: any) {
// console.error("❌ Error update user:", e);
// return {
// success: false,
// message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
// };
// }
// }
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
// API update user (Elysia atau Next.js API Route)
export default async function userUpdate(context: Context) {
try {
const { id, isActive } = await context.body as { id: string, isActive: boolean };
const { id, isActive, roleId } = await context.body as {
id: string,
isActive?: boolean,
roleId?: string
};
if (!id) {
return {
@@ -13,28 +137,72 @@ export default async function userUpdate(context: Context) {
};
}
// Cek apakah roleId valid
if (roleId) {
const cekRole = await prisma.role.findUnique({ where: { id: roleId } });
if (!cekRole) {
return {
success: false,
message: "Role tidak ditemukan",
};
}
}
// Deteksi perubahan role
let isRoleChanged = false;
if (roleId) {
const currentUser = await prisma.user.findUnique({
where: { id },
select: { roleId: true }
});
isRoleChanged = currentUser?.roleId !== roleId;
}
// ✅ UPDATE USER + INVALIDATE SESSION
const updatedUser = await prisma.user.update({
where: { id },
data: { isActive },
data: {
...(isActive !== undefined && { isActive }),
...(roleId && { roleId }),
// Force logout: set sessionInvalid = true
...(isRoleChanged && { sessionInvalid: true }),
},
select: {
id: true,
username: true,
nomor: true,
isActive: true,
updatedAt: true,
roleId: true,
role: { select: { name: true } }
}
});
// ✅ Reset sessionInvalid setelah 5 detik (opsional)
if (isRoleChanged) {
setTimeout(async () => {
try {
await prisma.user.update({
where: { id },
data: { sessionInvalid: false }
});
} catch (e) {
console.error('Gagal reset sessionInvalid:', e);
}
}, 5000);
}
return {
success: true,
message: `User berhasil ${isActive ? "diaktifkan" : "dinonaktifkan"}`,
message: isRoleChanged
? `User berhasil diupdate. ${updatedUser.username} akan logout otomatis.`
: "User berhasil diupdate",
data: updatedUser,
};
} catch (e: any) {
console.error("Error update user:", e);
console.error("Error update user:", e);
return {
success: false,
message: "Gagal mengupdate status user",
message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
};
}
}
}

View File

@@ -0,0 +1,65 @@
// src/app/api/admin/user-menu-access/route.ts
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
// ❌ HAPUS { params } karena tidak dipakai
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json(
{ success: false, message: 'User ID diperlukan' },
{ status: 400 }
)
}
const menuAccess = await prisma.userMenuAccess.findMany({
where: { userId },
select: { menuId: true },
})
return NextResponse.json({
success: true,
menuIds: menuAccess.map(m => m.menuId),
})
} catch (error) {
console.error('GET User Menu Access Error:', error)
return NextResponse.json(
{ success: false, message: 'Gagal memuat menu akses' },
{ status: 500 }
)
}
}
// POST tetap sama (tanpa perubahan)
export async function POST(request: Request) {
try {
const { userId, menuIds } = await request.json()
if (!userId || !Array.isArray(menuIds)) {
return NextResponse.json(
{ success: false, message: 'Data tidak valid' },
{ status: 400 }
)
}
await prisma.userMenuAccess.deleteMany({ where: { userId } })
if (menuIds.length > 0) {
await prisma.userMenuAccess.createMany({
data: menuIds.map((menuId: string) => ({ userId, menuId })),
})
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('POST User Menu Access Error:', error)
return NextResponse.json(
{ success: false, message: 'Gagal menyimpan menu akses' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,160 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// app/api/auth/_lib/api_fetch_auth.ts
// app/api/auth/_lib/api_fetch_auth.ts
export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
if (!nomor || nomor.replace(/\D/g, '').length < 10) {
throw new Error('Nomor tidak valid');
}
const cleanPhone = nomor.replace(/\D/g, '');
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomor: cleanPhone }),
});
// Pastikan respons bisa di-parse sebagai JSON
let data;
try {
data = await response.json();
} catch (e) {
console.error("Non-JSON response from /api/auth/login:", await response.text());
throw new Error('Respons server tidak valid');
}
if (!response.ok) {
throw new Error(data.message || 'Gagal memproses login');
}
// Validasi minimal respons
if (typeof data.success !== 'boolean' || typeof data.isRegistered !== 'boolean') {
throw new Error('Respons tidak sesuai format');
}
if (data.success) {
if (data.isRegistered && !data.kodeId) {
throw new Error('Kode verifikasi tidak ditemukan untuk user terdaftar');
}
return data; // { success, isRegistered, kodeId? }
} else {
throw new Error(data.message || 'Login gagal');
}
};
export const apiFetchRegister = async ({
username,
nomor,
}: {
username: string;
nomor: string;
}) => {
const cleanPhone = nomor.replace(/\D/g, '');
if (cleanPhone.length < 10) throw new Error('Nomor tidak valid');
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Gagal mengirim OTP');
return data;
};
export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
if (!kodeId) {
throw new Error('Kode ID tidak valid');
}
const response = await fetch("/api/auth/otp-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kodeId }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Gagal memuat data OTP');
}
return data;
};
export const apiFetchVerifyOtp = async ({
nomor,
otp,
kodeId
}: {
nomor: string;
otp: string;
kodeId: string;
}) => {
if (!nomor || !otp || !kodeId) {
throw new Error('Data verifikasi tidak lengkap');
}
if (!/^\d{4,6}$/.test(otp)) {
throw new Error('Kode OTP harus 4-6 digit angka');
}
const response = await fetch('/api/auth/verify-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
});
const data = await response.json();
// ✅ Jangan throw error untuk status 4xx — biarkan frontend handle
return {
success: response.ok,
...data,
status: response.status,
};
};
// Di dalam api_fetch_auth.ts
export async function apiFetchUserMenuAccess(userId: string): Promise<{
success: boolean;
menuIds?: string[];
message?: string;
}> {
try {
const res = await fetch(`/api/admin/user-menu-access/${userId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
return data;
} catch (error) {
console.error('API Fetch User Menu Access Error:', error);
return { success: false, message: 'Gagal memuat menu akses' };
}
}
export async function apiUpdateUserMenuAccess(
userId: string,
menuIds: string[]
): Promise<{ success: boolean; message?: string }> {
try {
const res = await fetch('/api/admin/user-menu-access', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, menuIds }),
});
const data = await res.json();
return data;
} catch (error) {
console.error('API Update User Menu Access Error:', error);
return { success: false, message: 'Gagal menyimpan menu akses' };
}
}

View File

@@ -1,50 +1,32 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { jwtVerify } from "jose";
export async function decrypt({
token,
encodedKey,
jwtSecret,
}: {
token: string;
encodedKey: string;
}): Promise<Record<string, any> | null> {
if (!token || !encodedKey) {
console.error("Missing required parameters:", {
hasToken: !!token,
hasEncodedKey: !!encodedKey,
});
return null;
}
jwtSecret: string;
}): Promise<Record<string, unknown> | null> {
if (!token || !jwtSecret) return null;
try {
const enc = new TextEncoder().encode(encodedKey);
const { payload } = await jwtVerify(token, enc, {
const secret = new TextEncoder().encode(jwtSecret);
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
});
if (!payload || !payload.user) {
console.error("Invalid payload structure:", {
hasPayload: !!payload,
hasUser: payload ? !!payload.user : false,
});
if (
typeof payload !== "object" ||
payload === null ||
!("user" in payload) ||
typeof payload.user !== "object"
) {
return null;
}
// Logging untuk debug
// console.log("Decrypt successful:", {
// payloadExists: !!payload,
// userExists: !!payload.user,
// tokenPreview: token.substring(0, 10) + "...",
// });
return payload.user as Record<string, any>;
return payload.user as Record<string, unknown>;
} catch (error) {
console.error("Token verification failed:", {
error,
tokenLength: token?.length,
errorName: error instanceof Error ? error.name : "Unknown error",
errorMessage: error instanceof Error ? error.message : String(error),
});
console.error("JWT Decrypt failed:", error);
return null;
}
}
}

View File

@@ -1,26 +1,23 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { SignJWT } from "jose";
export async function encrypt({
user,
exp = "7 year",
encodedKey,
exp = "7d",
jwtSecret,
}: {
user: Record<string, any>;
exp?: string;
encodedKey: string;
user: Record<string, unknown>;
exp?: string | number;
jwtSecret: string;
}): Promise<string | null> {
try {
const enc = new TextEncoder().encode(encodedKey);
const secret = new TextEncoder().encode(jwtSecret);
return new SignJWT({ user })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(exp)
.sign(enc);
.sign(secret);
} catch (error) {
console.error("Gagal mengenkripsi", error);
console.error("JWT Encrypt failed:", error);
return null;
}
}
// wibu:0.2.82
}

View File

@@ -1,36 +1,91 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// app/api/auth/_lib/session_create.ts
import { cookies } from "next/headers";
import { encrypt } from "./encrypt";
import prisma from "@/lib/prisma";
export async function sessionCreate({
sessionKey,
exp = "7 year",
encodedKey,
exp = "30 day",
jwtSecret,
user,
}: {
sessionKey: string;
exp?: string;
encodedKey: string;
jwtSecret: string;
user: Record<string, unknown>;
}) {
// 🔒 Validasi kunci tidak kosong
if (!sessionKey || sessionKey.length === 0) {
throw new Error("sessionKey tidak boleh kosong");
}
if (!jwtSecret || jwtSecret.length === 0) {
throw new Error("jwtSecret tidak boleh kosong");
}
const token = await encrypt({
exp,
encodedKey,
jwtSecret,
user,
});
const cookie: any = {
key: sessionKey,
value: token,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
},
};
if (token === null) {
throw new Error("Token generation failed");
}
// ✅ HYBRID: Simpan token ke database UserSession
const userId = user.id as string;
if (userId) {
try {
// Hapus session lama user ini (logout device lain)
await prisma.userSession.deleteMany({
where: { userId },
});
// Parse expiration
const expiresDate = new Date();
const expMatch = exp.match(/(\d+)\s*(day|year)/);
if (expMatch) {
const [, num, unit] = expMatch;
const amount = parseInt(num);
if (unit === 'year') {
expiresDate.setFullYear(expiresDate.getFullYear() + amount);
} else if (unit === 'day') {
expiresDate.setDate(expiresDate.getDate() + amount);
}
} else {
// Default 30 hari
expiresDate.setDate(expiresDate.getDate() + 30);
}
// Buat session baru di database
await prisma.userSession.create({
data: {
userId,
token, // JWT token disimpan
expires: expiresDate,
active: true,
},
});
console.log(`✅ Session created for user ${userId}`);
} catch (dbError) {
console.error("⚠️ Error menyimpan session ke database:", dbError);
// Tetap lanjut meski gagal simpan ke DB (fallback ke JWT only)
}
}
// Set cookie
const cookieStore = await cookies();
cookieStore.set(sessionKey, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
maxAge: 30 * 24 * 60 * 60, // 30 hari dalam detik
});
(await cookies()).set(cookie.key, cookie.value, { ...cookie.options });
return token;
}
// wibu:0.2.82
}

View File

@@ -0,0 +1,42 @@
// app/api/auth/_lib/session_delete.ts
import { cookies } from "next/headers";
import prisma from "@/lib/prisma";
/**
* Hapus session dari database dan cookie
*/
export async function sessionDelete({
sessionKey,
userId,
}: {
sessionKey: string;
userId?: string;
}): Promise<boolean> {
try {
const cookieStore = await cookies();
const token = cookieStore.get(sessionKey)?.value;
// Hapus dari database
if (token) {
const deleted = await prisma.userSession.deleteMany({
where: { token },
});
console.log(`🗑️ Deleted ${deleted.count} session(s) by token`);
} else if (userId) {
// Fallback: hapus berdasarkan userId
const deleted = await prisma.userSession.deleteMany({
where: { userId },
});
console.log(`🗑️ Deleted ${deleted.count} session(s) for user ${userId}`);
}
// Hapus cookie
cookieStore.delete(sessionKey);
console.log('✅ Session deleted successfully');
return true;
} catch (error) {
console.error("❌ Error deleting session:", error);
return false;
}
}

View File

@@ -0,0 +1,136 @@
// // app/api/auth/_lib/session_verify.ts
// import { cookies } from 'next/headers';
// import { decrypt } from './decrypt';
// import prisma from '@/lib/prisma';
// /**
// * Verifikasi session hybrid:
// * 1. Decrypt JWT token
// * 2. Cek apakah token masih ada di database (untuk force logout)
// * 3. Return data user terbaru dari database
// */
// export async function verifySession(): Promise<Record<string, unknown> | null> {
// try {
// const sessionKey = process.env.BASE_SESSION_KEY;
// if (!sessionKey) {
// throw new Error('BASE_SESSION_KEY tidak ditemukan di environment');
// }
// const jwtSecret = process.env.BASE_TOKEN_KEY;
// if (!jwtSecret) {
// throw new Error('BASE_TOKEN_KEY tidak ditemukan di environment');
// }
// const cookieStore = await cookies();
// const token = cookieStore.get(sessionKey)?.value;
// if (!token) {
// return null;
// }
// // Step 1: Decrypt JWT
// const jwtUser = await decrypt({ token, jwtSecret });
// if (!jwtUser || !jwtUser.id) {
// console.log('⚠️ JWT decrypt failed atau tidak ada user ID');
// return null;
// }
// // Step 2: Cek database UserSession (untuk force logout)
// try {
// const dbSession = await prisma.userSession.findFirst({
// where: {
// userId: jwtUser.id as string,
// token: token,
// active: true,
// OR: [
// { expires: null },
// { expires: { gte: new Date() } },
// ],
// },
// include: {
// User: {
// select: {
// id: true,
// username: true,
// nomor: true,
// roleId: true,
// isActive: true,
// },
// },
// },
// });
// // Token tidak ditemukan di database = sudah dihapus (force logout)
// if (!dbSession) {
// console.log('⚠️ Token valid tapi sudah dihapus dari database (force logout)');
// return null;
// }
// // Step 3: Return data user terbaru dari database
// // Ini penting agar roleId selalu update
// return {
// id: dbSession.User.id,
// username: dbSession.User.username,
// nomor: dbSession.User.nomor,
// roleId: dbSession.User.roleId,
// isActive: dbSession.User.isActive,
// };
// } catch (dbError) {
// console.error("⚠️ Error cek database session:", dbError);
// // Fallback: jika database error, tetap pakai JWT
// return jwtUser;
// }
// } catch (error) {
// console.warn('❌ Session verification failed:', error);
// return null;
// }
// }
// src/app/api/auth/_lib/session_verify.ts
import { cookies } from 'next/headers';
import { decrypt } from './decrypt';
import prisma from '@/lib/prisma';
export async function verifySession() {
try {
const sessionKey = process.env.BASE_SESSION_KEY;
const jwtSecret = process.env.BASE_TOKEN_KEY;
if (!sessionKey || !jwtSecret) {
throw new Error('Environment variables tidak lengkap');
}
const token = (await cookies()).get(sessionKey)?.value;
if (!token) return null;
// Decrypt JWT
const jwtUser = await decrypt({ token, jwtSecret });
if (!jwtUser || !jwtUser.id) return null;
// ✅ Cek apakah session di-invalidate
const user = await prisma.user.findUnique({
where: { id: jwtUser.id as string },
select: {
id: true,
username: true,
nomor: true,
roleId: true,
isActive: true,
sessionInvalid: true, // ← Tambahkan field ini
},
});
if (!user || user.sessionInvalid) {
console.log('⚠️ Session tidak valid (force logout)');
return null;
}
return user;
} catch (error) {
console.warn('Session verification failed:', error);
return null;
}
}

View File

@@ -0,0 +1,100 @@
// app/api/auth/finalize-registration/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { sessionCreate } from "../_lib/session_create";
export async function POST(req: Request) {
try {
const { nomor, username, kodeId, roleId } = await req.json();
// Validasi input
if (!nomor || !username || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
// Verifikasi OTP
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
});
if (!otpRecord?.isActive || otpRecord.nomor !== nomor) {
return NextResponse.json(
{ success: false, message: "OTP tidak valid" },
{ status: 400 }
);
}
// Cek apakah username sudah dipakai
const existingUser = await prisma.user.findUnique({
where: { username },
});
if (existingUser) {
return NextResponse.json(
{ success: false, message: "Username sudah digunakan" },
{ status: 400 }
);
}
// Buat user baru
const newUser = await prisma.user.create({
data: {
username,
nomor,
roleId: roleId || "1", // Default role
isActive: false, // Menunggu approval
},
});
// Nonaktifkan OTP
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
// ✅ CREATE SESSION (JWT + Database)
try {
await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: newUser.id,
nomor: newUser.nomor,
username: newUser.username,
roleId: newUser.roleId,
isActive: false, // User baru belum aktif
},
});
} catch (sessionError) {
console.error("❌ Error creating session:", sessionError);
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: "Registrasi berhasil. Menunggu persetujuan admin.",
user: {
id: newUser.id,
name: newUser.username,
roleId: newUser.roleId,
isActive: false,
},
});
} catch (error) {
console.error("❌ Finalize Registration Error:", error);
return NextResponse.json(
{ success: false, message: "Registrasi gagal" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -1,5 +1,5 @@
// app/api/auth/login/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
@@ -12,52 +12,66 @@ export async function POST(req: Request) {
}
try {
const codeOtp = randomOTP();
const body = await req.json();
const { nomor } = body;
const res = await fetch(
`https://wa.wibudev.com/code?nom=${nomor}&text=Website Desa Darmasaba - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun Admin lainnya.
\n
>> Kode OTP anda: ${codeOtp}.
`
);
const { nomor } = await req.json();
const sendWa = await res.json();
if (sendWa.status !== "success")
if (!nomor || typeof nomor !== "string") {
return NextResponse.json(
{ success: false, message: "Nomor Whatsapp Tidak Aktif" },
{ success: false, message: "Nomor tidak valid" },
{ status: 400 }
);
}
const createOtpId = await prisma.kodeOtp.create({
data: {
nomor: nomor,
otp: codeOtp,
},
const existingUser = await prisma.user.findUnique({
where: { nomor },
select: { id: true, isActive: true },
});
if (!createOtpId)
return NextResponse.json(
{ success: false, message: "Gagal mengirim kode OTP" },
{ status: 400 }
);
const isRegistered = !!existingUser;
return NextResponse.json(
{
if (isRegistered) {
// ✅ User terdaftar → kirim OTP
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
const res = await fetch(waUrl);
const sendWa = await res.json();
if (sendWa.status !== "success") {
return NextResponse.json(
{ success: false, message: "Gagal mengirim OTP via WhatsApp" },
{ status: 400 }
);
}
const createOtpId = await prisma.kodeOtp.create({
data: { nomor, otp: otpNumber, isActive: true },
});
return NextResponse.json({
success: true,
message: "Kode verifikasi terkirim",
message: "Kode verifikasi dikirim",
kodeId: createOtpId.id,
},
{ status: 200 }
);
isRegistered: true,
});
} else {
// ❌ User belum terdaftar → JANGAN kirim OTP
return NextResponse.json({
success: true,
message: "Nomor belum terdaftar",
isRegistered: false,
// Tidak ada kodeId
});
}
} catch (error) {
console.log("Error Login", error);
console.error("Error Login:", error);
return NextResponse.json(
{ success: false, message: "Terjadi masalah saat login" , reason: error as Error },
{ success: false, message: "Terjadi kesalahan saat login" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}
}

View File

@@ -0,0 +1,35 @@
// app/api/auth/logout/route.ts
import { NextResponse } from "next/server";
import { sessionDelete } from "../_lib/session_delete";
export async function POST() {
try {
const deleted = await sessionDelete({
sessionKey: process.env.BASE_SESSION_KEY!,
});
if (deleted) {
return NextResponse.json({
success: true,
message: "Logout berhasil",
});
} else {
return NextResponse.json(
{
success: false,
message: "Gagal logout",
},
{ status: 500 }
);
}
} catch (error) {
console.error("❌ Logout Error:", error);
return NextResponse.json(
{
success: false,
message: "Terjadi kesalahan saat logout",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,41 @@
// src/app/api/auth/me/route.ts
import { NextResponse } from 'next/server';
import { verifySession } from '../_lib/session_verify';
import prisma from '@/lib/prisma';
export async function GET() {
try {
const user = await verifySession();
if (!user) {
return NextResponse.json(
{ success: false, message: "Session tidak valid", user: null },
{ status: 401 }
);
}
// ✅ Ambil menu akses kustom
const menuAccess = await prisma.userMenuAccess.findMany({
where: { userId: user.id },
select: { menuId: true },
});
return NextResponse.json({
success: true,
user: {
id: user.id,
name: user.username,
username: user.username,
nomor: user.nomor,
roleId: user.roleId,
isActive: user.isActive,
menuIds: menuAccess.map(m => m.menuId), // ✅ tambahkan ini
},
});
} catch (error) {
console.error("❌ Error in /api/auth/me:", error);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan", user: null },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,41 @@
// app/api/auth/otp-data/route.ts
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function POST(req: Request) {
try {
const { kodeId } = await req.json();
if (!kodeId) {
return NextResponse.json(
{ success: false, message: "Kode ID tidak diberikan" },
{ status: 400 }
);
}
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
select: { id: true, nomor: true, isActive: true, createdAt: true },
});
if (!otpRecord || !otpRecord.isActive) {
return NextResponse.json(
{ success: false, message: "Kode verifikasi tidak valid atau sudah digunakan" },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
data: otpRecord,
});
} catch (error) {
console.error("Error fetching OTP data:", error);
return NextResponse.json(
{ success: false, message: "Gagal mengambil data OTP" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -1,62 +1,51 @@
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { randomOTP } from '../_lib/randomOTP'; // pastikan ada
export async function POST(req: Request) {
if (req.method !== "POST") {
return NextResponse.json(
{ success: false, message: "Method Not Allowed" },
{ status: 405 }
);
}
try {
const { data } = await req.json();
const { username, nomor } = await req.json();
const cekUsername = await prisma.user.findUnique({
where: {
username: data.username,
nomor: data.nomor,
},
if (!username || !nomor) {
return NextResponse.json({ success: false, message: 'Data tidak lengkap' }, { status: 400 });
}
// Cek duplikat
if (await prisma.user.findUnique({ where: { nomor } })) {
return NextResponse.json({ success: false, message: 'Nomor sudah terdaftar' }, { status: 409 });
}
if (await prisma.user.findUnique({ where: { username } })) {
return NextResponse.json({ success: false, message: 'Username sudah digunakan' }, { status: 409 });
}
// ✅ Generate dan kirim OTP
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
const waRes = await fetch(waUrl);
const waData = await waRes.json();
if (waData.status !== "success") {
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP via WhatsApp' }, { status: 400 });
}
// ✅ Simpan OTP ke database
const otpRecord = await prisma.kodeOtp.create({
data: { nomor, otp: otpNumber, isActive: true }
});
if (cekUsername)
return NextResponse.json({
success: false,
message: "Username sudah digunakan",
});
const createUser = await prisma.user.create({
data: {
username: data.username,
nomor: data.nomor,
},
// ✅ Kembalikan kodeId (jangan buat user di sini!)
return NextResponse.json({
success: true,
message: 'Kode verifikasi dikirim',
kodeId: otpRecord.id,
});
if (!createUser)
return NextResponse.json(
{ success: false, message: "Gagal Registrasi" },
{ status: 500 }
);
return NextResponse.json(
{
success: true,
message: "Registrasi Berhasil, Anda Sedang Login",
// data: createUser,
},
{ status: 201 }
);
} catch (error) {
console.error("Error registrasi:", error);
return NextResponse.json(
{
success: false,
message: "Maaf, Terjadi Keselahan",
reason: (error as Error).message,
},
{ status: 500 }
);
console.error('Register OTP Error:', error);
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 500 });
} finally {
await prisma.$disconnect();
}
}
}

View File

@@ -0,0 +1,71 @@
import prisma from "@/lib/prisma";
import { randomOTP } from "../_lib/randomOTP";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
if (req.method !== "POST") {
return NextResponse.json(
{ success: false, message: "Method Not Allowed" },
{ status: 405 }
);
}
try {
const codeOtp = randomOTP();
const body = await req.json();
const { nomor } = body;
const res = await fetch(
`https://wa.wibudev.com/code?nom=${nomor}&text=HIPMI - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun pengurus HIPMI lainnya.
\n
>> Kode OTP anda: ${codeOtp}.
`
);
const sendWa = await res.json();
if (sendWa.status !== "success")
return NextResponse.json(
{
success: false,
message: "Nomor Whatsapp Tidak Aktif",
},
{ status: 400 }
);
const createOtpId = await prisma.kodeOtp.create({
data: {
nomor: nomor,
otp: codeOtp,
},
});
if (!createOtpId)
return NextResponse.json(
{
success: false,
message: "Gagal Membuat Kode OTP",
},
{ status: 400 }
);
return NextResponse.json(
{
success: true,
message: "Kode Verifikasi Dikirim",
kodeId: createOtpId.id,
},
{ status: 200 }
);
} catch (error) {
console.error(" Error Resend OTP", error);
return NextResponse.json(
{
success: false,
message: "Server Whatsapp Error !!",
},
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -0,0 +1,51 @@
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
export async function POST(req: Request) {
try {
const { username, nomor } = await req.json();
if (!username || !nomor) {
return NextResponse.json({ success: false, message: 'Data tidak lengkap' }, { status: 400 });
}
// Cek duplikat
if (await prisma.user.findUnique({ where: { nomor } })) {
return NextResponse.json({ success: false, message: 'Nomor sudah terdaftar' }, { status: 409 });
}
if (await prisma.user.findUnique({ where: { username } })) {
return NextResponse.json({ success: false, message: 'Username sudah digunakan' }, { status: 409 });
}
// Generate OTP
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
// Kirim WA
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
const res = await fetch(waUrl);
const sendWa = await res.json();
if (sendWa.status !== "success") {
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 400 });
}
// Simpan OTP
const otpRecord = await prisma.kodeOtp.create({
data: { nomor, otp: otpNumber, isActive: true }
});
return NextResponse.json({
success: true,
message: 'Kode verifikasi dikirim',
kodeId: otpRecord.id,
nomor,
});
} catch (error) {
console.error('Send OTP for Register Error:', error);
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 500 });
} finally {
await prisma.$disconnect();
}
}

View File

@@ -0,0 +1,78 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { sessionCreate } from "../_lib/session_create";
export async function POST(req: Request) {
if (req.method !== "POST") {
return NextResponse.json(
{ success: false, message: "Method Not Allowed" },
{ status: 405 }
);
}
try {
const { nomor } = await req.json();
const dataUser = await prisma.user.findUnique({
where: {
nomor: nomor,
},
select: {
id: true,
nomor: true,
username: true,
roleId: true,
},
});
if (dataUser == null)
return NextResponse.json(
{ success: false, message: "Nomor Belum Terdaftar" },
{ status: 200 }
);
const token = await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
user: dataUser as any,
});
if (!token) {
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
);
}
// Buat response dengan token dalam cookie
const response = NextResponse.json(
{
success: true,
message: "Berhasil Login",
roleId: dataUser.roleId,
},
{ status: 200 }
);
// Set cookie dengan token yang sudah dipastikan tidak null
response.cookies.set(process.env.NEXT_PUBLIC_BASE_SESSION_KEY!, token, {
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 30 * 24 * 60 * 60, // 30 hari dalam detik (1 bulan)
});
return response;
} catch (error) {
console.error("API Error or Server Error", error);
return NextResponse.json(
{
success: false,
message: "Maaf, Terjadi Keselahan",
reason: (error as Error).message,
},
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -0,0 +1,126 @@
// app/api/auth/verify-otp/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { sessionCreate } from "../_lib/session_create";
export async function POST(req: Request) {
try {
const { nomor, otp, kodeId } = await req.json();
// Validasi input
if (!nomor || !otp || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
// Cari OTP record
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
});
if (!otpRecord) {
return NextResponse.json(
{ success: false, message: "Kode verifikasi tidak valid" },
{ status: 400 }
);
}
if (!otpRecord.isActive) {
return NextResponse.json(
{ success: false, message: "Kode verifikasi sudah digunakan" },
{ status: 400 }
);
}
// Validasi OTP
const receivedOtp = Number(otp);
if (isNaN(receivedOtp) || otpRecord.otp !== receivedOtp) {
return NextResponse.json(
{ success: false, message: "Kode OTP salah" },
{ status: 400 }
);
}
if (otpRecord.nomor !== nomor) {
return NextResponse.json(
{ success: false, message: "Nomor tidak sesuai" },
{ status: 400 }
);
}
// Cek user berdasarkan nomor
const user = await prisma.user.findUnique({
where: { nomor },
select: {
id: true,
nomor: true,
username: true,
roleId: true,
isActive: true,
},
});
if (!user) {
return NextResponse.json(
{ success: false, message: "Akun tidak ditemukan" },
{ status: 404 }
);
}
// ✅ CREATE SESSION (JWT + Database)
try {
await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: user.id,
nomor: user.nomor,
username: user.username,
roleId: user.roleId,
isActive: user.isActive,
},
});
} catch (sessionError) {
console.error("❌ Error creating session:", sessionError);
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
);
}
// Nonaktifkan OTP
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
// Update lastLogin
await prisma.user.update({
where: { id: user.id },
data: { lastLogin: new Date() },
});
return NextResponse.json({
success: true,
message: user.isActive ? "Berhasil login" : "Menunggu persetujuan",
user: {
id: user.id,
name: user.username,
roleId: user.roleId,
isActive: user.isActive,
},
});
} catch (error) {
console.error("❌ Verify OTP Error:", error);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat verifikasi" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -0,0 +1,97 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import { Center, Loader, Paper, Stack, Text, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
// Ganti ini jika tidak pakai next-auth
async function fetchUser() {
const res = await fetch('/api/auth/me');
if (!res.ok) throw new Error('Unauthorized');
return res.json();
}
export default function WaitingRoom() {
const router = useRouter();
const [user, setUser] = useState<any>(null);
// const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const interval = setInterval(async () => {
try {
const data = await fetchUser();
if (!isMounted) return;
const currentUser = data.user;
setUser(currentUser);
// ✅ Sekarang isActive tersedia!
if (currentUser?.isActive) {
clearInterval(interval);
// Redirect ke halaman admin sesuai role
if (currentUser.roleId === 0) {
router.push('/admin/landing-page/profil/program-inovasi');
} else {
router.push('/admin'); // atau halaman default role
}
}
} catch (err: any) {
if (!isMounted) return;
setError(err.message || 'Gagal memuat status');
clearInterval(interval);
if (err.message === 'Unauthorized') {
router.push('/login');
}
}
}, 2000);
return () => {
isMounted = false;
clearInterval(interval);
};
}, [router]);
if (error) {
return (
<Center h="100vh">
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={400}>
<Stack align="center" gap="md">
<Title order={3} c="red">Error</Title>
<Text>{error}</Text>
</Stack>
</Paper>
</Center>
);
}
return (
<Center h="100vh" bg={colors.Bg}>
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '90%', sm: 400 }}>
<Stack align="center" gap="lg">
<Title order={2} c={colors['blue-button']} ta="center">
Menunggu Persetujuan
</Title>
<Text ta="center" c="dimmed">
Akun Anda sedang dalam proses verifikasi oleh Superadmin.
</Text>
<Text ta="center" size="sm" c="dimmed">
Nomor: {user?.nomor || '...'}
</Text>
<Loader size="sm" color={colors['blue-button']} />
<Text ta="center" size="xs" c="dimmed">
Jangan tutup halaman ini. Anda akan dialihkan otomatis setelah disetujui.
</Text>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -1,46 +1,98 @@
// app/middleware.js
import { NextResponse, NextRequest } from 'next/server';
// // app/middleware.js
// import { NextResponse, NextRequest } from 'next/server';
// 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
// // 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
// ];
// // 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
// });
// }
// export function middleware(request: NextRequest) {
// const { pathname } = request.nextUrl;
// // Jika route adalah public, izinkan akses
// if (isPublicRoute(pathname)) {
// return NextResponse.next();
// }
// // 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 sudah login, izinkan akses
// 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
// };
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/app/admin/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
// Route publik di dalam /admin (boleh diakses tanpa login penuh)
const PUBLIC_ADMIN_ROUTES = [
'/admin/login',
'/admin/registrasi',
'/admin/validasi',
'/admin/waiting-room',
];
// 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
});
}
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Jika route adalah public, izinkan akses
if (isPublicRoute(pathname)) {
return NextResponse.next();
}
// 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 sudah login, izinkan akses
// Izinkan akses ke route publik di /admin
if (PUBLIC_ADMIN_ROUTES.some(route => path.startsWith(route))) {
return NextResponse.next();
}
// Ambil token dari cookie
const token = request.cookies.get(process.env.BASE_SESSION_KEY!)?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
// Verifikasi JWT
const secret = new TextEncoder().encode(process.env.BASE_TOKEN_KEY!);
const { payload } = await jwtVerify(token, secret);
const user = (payload as any).user;
// Cek apakah user aktif
if (!user || !user.isActive) {
return NextResponse.redirect(new URL('/login', request.url));
}
// ✅ User valid → izinkan akses
return NextResponse.next();
} catch (error) {
console.error('Middleware auth error:', error);
return NextResponse.redirect(new URL('/login', request.url));
}
}
// Konfigurasi untuk menentukan path mana yang akan dijalankan middleware
// Hanya berlaku untuk /admin/*
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], // Jalankan middleware untuk semua route kecuali file statis
matcher: ['/admin/:path*'],
};

19
src/store/authStore.ts Normal file
View File

@@ -0,0 +1,19 @@
// src/store/authStore.ts
import { proxy } from 'valtio';
export type User = {
id: string;
name: string;
roleId: number;
menuIds?: string[] | null; // ✅ Pastikan pakai `string[]`
};
export const authStore = proxy<{
user: User | null;
setUser: (user: User | null) => void;
}>({
user: null,
setUser(user) {
authStore.user = user;
},
});