Compare commits

..

10 Commits

Author SHA1 Message Date
e30b27f7a4 Fix Search 2025-11-25 17:30:41 +08:00
e941ed3893 Sudah fix menunya, superadmin bisa memilihkan menu untuk user 2025-11-25 16:21:15 +08:00
ace5aff1b6 Fix Kondisi Verify Otp Registrasi dan Login
Next mau fix eror saat user sudah terdaftar tetapi di redirect ke login, seharusnya redirect sesuai roleIdnya
2025-11-25 15:03:27 +08:00
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
b3c169a2d4 Fix create admin & progress bar persentase 2025-11-18 17:23:38 +08:00
2608a5ffdd Fix Edit di Admin APbdes, dan fix data real di apbdes user 2025-11-18 16:26:09 +08:00
59 changed files with 5621 additions and 1169 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -54,6 +54,7 @@
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"colors": "^1.4.0", "colors": "^1.4.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"elysia": "^1.3.5", "elysia": "^1.3.5",

View File

@@ -1,23 +1,32 @@
[ [
{ {
"id": "role-1", "id": "0",
"name": "ADMIN DESA", "name": "DEVELOPER",
"description": "Administrator Desa", "description": "Developer",
"permissions": ["manage_users", "manage_content", "view_reports"], "isActive": true
"isActive": true },
}, {
{ "id": "1",
"id": "role-2", "name": "SUPER ADMIN",
"name": "ADMIN KESEHATAN", "description": "Administrator",
"description": "Administrator Bidang Kesehatan", "isActive": true
"permissions": ["manage_health_data", "view_reports"], },
"isActive": true {
}, "id": "2",
{ "name": "ADMIN DESA",
"id": "role-3", "description": "Administrator Desa",
"name": "ADMIN SEKOLAH", "isActive": true
"description": "Administrator Sekolah", },
"permissions": ["manage_school_data", "view_reports"], {
"isActive": true "id": "3",
} "name": "ADMIN KESEHATAN",
] "description": "Administrator Bidang Kesehatan",
"isActive": true
},
{
"id": "4",
"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 ========================================= // //========================================= APBDes ========================================= //
model APBDes { model APBDes {
id String @id @default(cuid()) id String @id @default(cuid())
tahun Int? tahun Int?
name String? // misalnya: "APBDes Tahun 2025" name String? // misalnya: "APBDes Tahun 2025"
deskripsi String? deskripsi String?
jumlah String? // total keseluruhan (opsional, bisa juga dihitung dari items) jumlah String? // total keseluruhan (opsional, bisa juga dihitung dari items)
items APBDesItem[] items APBDesItem[]
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id]) image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
imageId String? imageId String?
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id]) file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
fileId String? fileId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? // opsional, tidak perlu default now() deletedAt DateTime? // opsional, tidak perlu default now()
isActive Boolean @default(true) isActive Boolean @default(true)
} }
model APBDesItem { model APBDesItem {
id String @id @default(cuid()) id String @id @default(cuid())
kode String // contoh: "4", "4.1", "4.1.2" kode String // contoh: "4", "4.1", "4.1.2"
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha" 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) anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
realisasi Float realisasi Float
selisih Float // realisasi - anggaran selisih Float // realisasi - anggaran
persentase Float persentase Float
tipe String? // (realisasi / anggaran) * 100 tipe String? // (realisasi / anggaran) * 100
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
parentId String? // untuk relasi hierarki parentId String? // untuk relasi hierarki
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id]) parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
children APBDesItem[] @relation("APBDesItemParent") children APBDesItem[] @relation("APBDesItemParent")
apbdesId String apbdesId String
apbdes APBDes @relation(fields: [apbdesId], references: [id]) apbdes APBDes @relation(fields: [apbdesId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime? deletedAt DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
@@index([kode]) @@index([kode])
@@index([level]) @@index([level])
@@ -2163,25 +2163,27 @@ enum StatusPeminjaman {
// ========================================= USER ========================================= // // ========================================= USER ========================================= //
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
username String username String
nomor String @unique nomor String @unique
role Role @relation(fields: [roleId], references: [id]) roleId String @default("2")
roleId String @default("1") isActive Boolean @default(false)
instansi String? sessionInvalid Boolean @default(false)
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll) lastLogin DateTime?
isActive Boolean @default(true) createdAt DateTime @default(now())
lastLogin DateTime? updatedAt DateTime @default(now()) @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt sessions UserSession[] // ✅ Relasi one-to-many
deletedAt DateTime? role Role @relation(fields: [roleId], references: [id])
menuAccesses UserMenuAccess[]
@@map("users")
} }
model Role { model Role {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
description String? description String?
permissions Json // Menyimpan permission dalam format JSON
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2200,26 +2202,32 @@ model KodeOtp {
otp Int 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 { model UserSession {
id String @id @default(cuid()) id String @id @default(cuid())
token String token String @db.Text // ✅ JWT bisa panjang
expires DateTime? expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
User User @relation(fields: [userId], references: [id])
userId String @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String // ✅ HAPUS @unique - user bisa punya multiple sessions
@@index([userId]) // ✅ Index untuk query cepat
@@index([token]) // ✅ Index untuk verify cepat
@@map("user_sessions")
}
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 ========================================= // // ========================================= DATA PENDIDIKAN ========================================= //

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 programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json";
import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json"; import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json";
import roles from "./data/user/roles.json"; import roles from "./data/user/roles.json";
import users from "./data/user/users.json";
import fileStorage from "./data/file-storage.json"; import fileStorage from "./data/file-storage.json";
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json"; import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
import seedAssets from "./seed_assets"; import seedAssets from "./seed_assets";
import { safeSeedUnique } from "./safeseedUnique"; import { safeSeedUnique } from "./safeseedUnique";
(async () => { (async () => {
// =========== USER & ROLE =========== // =========== ROLE ===========
// In your seed.ts // In your seed.ts
// =========== ROLES =========== // =========== ROLES ===========
console.log("🔄 Seeding roles..."); console.log("🔄 Seeding roles...");
@@ -69,35 +68,12 @@ import { safeSeedUnique } from "./safeseedUnique";
await safeSeedUnique("role", { id: r.id }, { await safeSeedUnique("role", { id: r.id }, {
name: r.name, name: r.name,
description: r.description, description: r.description,
permissions: r.permissions,
isActive: r.isActive, isActive: r.isActive,
}); });
} }
console.log("✅ Roles seeded"); 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 =========== // =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage..."); console.log("🔄 Seeding file storage...");
for (const f of fileStorage) { for (const f of fileStorage) {

View File

@@ -7,14 +7,14 @@ import { z } from "zod";
// --- Zod Schema --- // --- Zod Schema ---
const ApbdesItemSchema = z.object({ const ApbdesItemSchema = z.object({
kode: z.string().min(1), kode: z.string().min(1, "Kode wajib diisi"),
uraian: z.string().min(1), uraian: z.string().min(1, "Uraian wajib diisi"),
anggaran: z.number().min(0), anggaran: z.number().min(0),
realisasi: z.number().min(0), realisasi: z.number().min(0),
selisih: z.number(), selisih: z.number(),
persentase: z.number().min(0).max(1000), // allow >100% if overbudget persentase: z.number(),
level: z.number().int().min(1).max(3), level: z.number().int().min(1).max(3),
tipe: z.string().min(1), // "pendapatan" | "belanja" tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
}); });
const ApbdesFormSchema = z.object({ const ApbdesFormSchema = z.object({
@@ -37,6 +37,9 @@ const defaultApbdesForm = {
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> { function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
const anggaran = item.anggaran ?? 0; const anggaran = item.anggaran ?? 0;
const realisasi = item.realisasi ?? 0; const realisasi = item.realisasi ?? 0;
// ✅ Formula yang benar // ✅ Formula yang benar
const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget
@@ -50,64 +53,12 @@ function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer
selisih, selisih,
persentase, persentase,
level: item.level || 1, level: item.level || 1,
tipe: item.tipe || "pendapatan", tipe: item.tipe, // biarkan null jika memang null
}; };
} }
// --- State Utama --- // --- State Utama ---
const apbdes = proxy({ const apbdes = proxy({
// create: {
// form: { ...defaultApbdesForm },
// loading: false,
// addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
// const normalized = normalizeItem(item);
// this.form.items.push(normalized);
// },
// removeItem(index: number) {
// this.form.items.splice(index, 1);
// },
// updateItem(index: number, updates: Partial<z.infer<typeof ApbdesItemSchema>>) {
// const current = this.form.items[index];
// if (current) {
// const updated = normalizeItem({ ...current, ...updates });
// this.form.items[index] = updated;
// }
// },
// reset() {
// this.form = { ...defaultApbdesForm };
// },
// async create() {
// const parsed = ApbdesFormSchema.safeParse(this.form);
// if (!parsed.success) {
// const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
// toast.error(`Validasi gagal:\n${errors.join("\n")}`);
// return;
// }
// try {
// this.loading = true;
// const res = await ApiFetch.api.landingpage.apbdes["create"].post(parsed.data);
// if (res.data?.success) {
// toast.success("APBDes berhasil dibuat");
// apbdes.findMany.load();
// this.reset();
// } else {
// toast.error(res.data?.message || "Gagal membuat APBDes");
// }
// } catch (error: any) {
// console.error("Create APBDes error:", error);
// toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes");
// } finally {
// this.loading = false;
// }
// },
// },
create: { create: {
form: { ...defaultApbdesForm }, form: { ...defaultApbdesForm },
loading: false, loading: false,
@@ -305,7 +256,7 @@ const apbdes = proxy({
selisih: item.selisih, selisih: item.selisih,
persentase: item.persentase, persentase: item.persentase,
level: item.level, level: item.level,
tipe: item.tipe, tipe: item.tipe || 'pendapatan',
})), })),
}; };
return data; return data;

View File

@@ -90,42 +90,96 @@ const userState = proxy({
} }
}, },
}, },
updateActive: { deleteUser: {
loading: false, loading: false,
async submit(id: string, isActive: boolean) {
this.loading = true; async delete(id: string) {
if (!id) return toast.warn("ID tidak valid");
try { try {
const res = await fetch(`/api/user/updt`, { userState.deleteUser.loading = true;
method: "PUT",
headers: { "Content-Type": "application/json" }, const response = await fetch(`/api/user/delUser/${id}`, {
body: JSON.stringify({ id, isActive }), method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}); });
const data = await res.json(); const result = await response.json();
if (res.status === 200 && data.success) {
toast.success(data.message); if (response.ok && result?.success) {
userState.findMany.load(userState.findMany.page, 10, userState.findMany.search); toast.success(result.message || "User berhasil dihapus permanen");
await userState.findMany.load(); // refresh list user setelah delete
} else { } else {
toast.error(data.message || "Gagal update status user"); toast.error(result?.message || "Gagal menghapus user");
} }
} catch (e) { } catch (error) {
console.error(e); console.error("Gagal delete user:", error);
toast.error("Gagal update status user"); toast.error("Terjadi kesalahan saat menghapus user");
} finally { } finally {
this.loading = false; userState.deleteUser.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({ const templateRole = z.object({
name: z.string().min(1, "Nama harus diisi"), name: z.string().min(1, "Nama harus diisi"),
permissions: z.array(z.string()).min(1, "Permission harus diisi"),
}); });
const defaultRole = { const defaultRole = {
name: "", name: "",
permissions: [] as string[],
}; };
const roleState = proxy({ const roleState = proxy({
@@ -237,7 +291,7 @@ const roleState = proxy({
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
} }
try { try {
const response = await fetch(`/api/role/${id}`, { const response = await fetch(`/api/role/${id}`, {
method: "GET", method: "GET",
@@ -245,31 +299,25 @@ const roleState = proxy({
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json(); const result = await response.json();
if (result?.success) { if (result?.success) {
const data = result.data; 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, name: data.name,
permissions: data.permissions,
}; };
return data; // Return the loaded data
} else { return data;
throw new Error(result?.message || "Gagal memuat data");
} }
} catch (error) { } catch (error) {
console.error("Error loading role:", error); console.error("Error loading role:", error);
toast.error( toast.error("Gagal memuat data");
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
} }
}, },
async update() { async update() {
const cek = templateRole.safeParse(roleState.update.form); const cek = templateRole.safeParse(roleState.update.form);
if (!cek.success) { if (!cek.success) {
@@ -290,7 +338,6 @@ const roleState = proxy({
}, },
body: JSON.stringify({ body: JSON.stringify({
name: this.form.name, name: this.form.name,
permissions: this.form.permissions,
}), }),
}); });

View File

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

View File

@@ -1,31 +1,286 @@
'use client' 'use client';
import colors from '@/con/colors';
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core'; import colors from '@/con/colors';
import { useRouter } from 'next/navigation'; 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);
const [isRegistrationFlow, setIsRegistrationFlow] = useState(false); // Tambahkan flag
// Cek apakah ini alur registrasi
useEffect(() => {
const storedUsername = localStorage.getItem('auth_username');
setIsRegistrationFlow(!!storedUsername);
}, []);
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 res = await fetch(`/api/auth/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
const result = await res.json();
if (res.ok && 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]);
const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return;
setLoading(true);
try {
if (isRegistrationFlow) {
// 🔑 Alur REGISTRASI
await handleRegistrationVerification();
} else {
// 🔑 Alur LOGIN
await handleLoginVerification();
}
} catch (error) {
console.error('Error saat verifikasi:', error);
toast.error('Terjadi kesalahan sistem');
} finally {
setLoading(false);
}
};
// ✅ Verifikasi OTP untuk REGISTRASI
const handleRegistrationVerification = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan. Silakan ulangi dari awal.');
return;
}
// ✅ Validasi format
const cleanNomor = nomor?.replace(/\D/g, '') ?? '';
if (cleanNomor.length < 10) {
toast.error('Nomor tidak valid');
return;
}
if (username.trim().length < 5) {
toast.error('Username minimal 5 karakter');
return;
}
// 1. Verifikasi OTP via endpoint register
const verifyRes = await fetch('/api/auth/verify-otp-register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
});
const verifyData = await verifyRes.json();
if (!verifyRes.ok) {
toast.error(verifyData.message || 'Verifikasi OTP gagal');
return;
}
// 2. Finalisasi registrasi
const finalizeRes = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, kodeId }), // 🔴 Tidak perlu kirim `otp` ke sini
});
const finalizeData = await finalizeRes.json();
if (!finalizeRes.ok) {
toast.error(finalizeData.message || 'Registrasi gagal');
return;
}
// 3. Set user & redirect
authStore.setUser({
id: finalizeData.user.id,
name: finalizeData.user.name,
roleId: Number(finalizeData.user.roleId),
});
cleanupStorage();
window.location.href = '/waiting-room';
};
// ✅ Verifikasi OTP untuk LOGIN
const handleLoginVerification = async () => {
const loginRes = await fetch('/api/auth/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
});
const loginData = await loginRes.json();
if (!loginRes.ok) {
toast.error(loginData.message || 'Verifikasi gagal');
return;
}
const { id, name, roleId, isActive } = loginData.user;
authStore.setUser({
id,
name: name || 'User',
roleId: Number(roleId),
});
cleanupStorage();
if (!isActive) {
window.location.href = '/waiting-room';
return;
}
const redirectPath = getRedirectPath(Number(roleId));
router.replace(redirectPath);
};
const getRedirectPath = (roleId: number): string => {
switch (roleId) {
case 0: // DEVELOPER
case 1: // SUPERADMIN
case 2: // ADMIN_DESA
return '/admin/landing-page/profil/program-inovasi';
case 3: // ADMIN_KESEHATAN
return '/admin/kesehatan/posyandu';
case 4: // ADMIN_PENDIDIKAN
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:
return '/admin';
}
};
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', {
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');
}
};
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 ( return (
<Stack pos={"relative"} bg={colors.Bg}> <Stack pos="relative" bg={colors.Bg}>
<Box px={{ base: 'md', md: 100 }} pb={50}> <Box px={{ base: 'md', md: 100 }} pb={50}>
<Stack align='center' justify='center' h={"100vh"}> <Stack align="center" justify="center" h="100vh">
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}> <Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
<Stack align='center' gap={"lg"}> <Stack align="center" gap="lg">
<Box> <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 {isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'}
</Title> </Title>
<Text ta="center" size="sm" c="dimmed" mt="xs">
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
</Text>
</Box> </Box>
<Box> <Box w="100%">
<Box mb={10}> <Box mb={20}>
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text> <Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
<PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" /> Masukkan Kode Verifikasi
</Text>
<Center>
<PinInput
length={4}
value={otp}
onChange={setOtp}
onComplete={handleVerify}
inputMode="numeric"
size="lg"
/>
</Center>
</Box> </Box>
<Box py={20} >
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}> <Button
Page 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> </Button>
</Box> </Text>
</Box> </Box>
</Stack> </Stack>
</Paper> </Paper>
@@ -33,6 +288,4 @@ function Validasi() {
</Box> </Box>
</Stack> </Stack>
); );
} }
export default Validasi;

View File

@@ -116,6 +116,7 @@ function EditAPBDes() {
return toast.warn('Kode dan uraian wajib diisi'); return toast.warn('Kode dan uraian wajib diisi');
} }
const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran; const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
@@ -127,9 +128,10 @@ function EditAPBDes() {
selisih, selisih,
persentase, persentase,
level, level,
tipe, tipe: finalTipe, // ✅ Tidak akan undefined
}); });
setNewItem({ setNewItem({
kode: '', kode: '',
uraian: '', uraian: '',
@@ -374,6 +376,7 @@ function EditAPBDes() {
data={[ data={[
{ value: 'pendapatan', label: 'Pendapatan' }, { value: 'pendapatan', label: 'Pendapatan' },
{ value: 'belanja', label: 'Belanja' }, { value: 'belanja', label: 'Belanja' },
{ value: 'pembiayaan', label: 'Pembiayaan' },
]} ]}
value={newItem.tipe} value={newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: (val as any) || 'pendapatan' })} onChange={(val) => setNewItem({ ...newItem, tipe: (val as any) || 'pendapatan' })}
@@ -447,9 +450,13 @@ function EditAPBDes() {
</Badge> </Badge>
</td> </td>
<td> <td>
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}> {item.tipe ? (
{item.tipe} <Badge color={item.tipe === 'pendapatan' ? 'teal' : 'red'} size="sm">
</Badge> {item.tipe}
</Badge>
) : (
'-'
)}
</td> </td>
<td> <td>
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}> <ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>

View File

@@ -123,6 +123,7 @@ function CreateAPBDes() {
return toast.warn("Kode dan uraian wajib diisi"); return toast.warn("Kode dan uraian wajib diisi");
} }
const finalTipe = level === 1 ? null : tipe;
const selisih = realisasi - anggaran; const selisih = realisasi - anggaran;
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
@@ -134,7 +135,7 @@ function CreateAPBDes() {
selisih, selisih,
persentase, persentase,
level, level,
tipe, tipe: finalTipe,
}); });
// Reset form input // Reset form input
@@ -361,8 +362,9 @@ function CreateAPBDes() {
{ value: 'pendapatan', label: 'Pendapatan' }, { value: 'pendapatan', label: 'Pendapatan' },
{ value: 'belanja', label: 'Belanja' }, { value: 'belanja', label: 'Belanja' },
]} ]}
value={newItem.tipe} value={newItem.level === 1 ? null : newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: val as any })} onChange={(val) => setNewItem({ ...newItem, tipe: val as any })}
disabled={newItem.level === 1}
/> />
</Group> </Group>
<TextInput <TextInput

View File

@@ -0,0 +1,34 @@
// src/app/admin/(dashboard)/user&role/_com/dynamicNavbar.ts
import { devBar, navBar, role1, role2, role3 } from '@/app/admin/_com/list_PageAdmin';
// ✅ Helper: normalisasi ID menu agar konsisten
const normalizeMenuId = (id: string): string => {
return id.trim().toLowerCase();
};
export function getNavbar({
roleId,
menuIds,
}: {
roleId: number;
menuIds?: string[] | null;
}) {
// ✅ Jika menuIds tersedia, gunakan untuk filter — dengan normalisasi
if (menuIds && menuIds.length > 0) {
// Normalisasi semua menuIds dari DB/state
const normalizedMenuSet = new Set(menuIds.map(id => normalizeMenuId(id)));
return navBar.filter(section => {
const normalizedSectionId = normalizeMenuId(section.id);
return normalizedMenuSet.has(normalizedSectionId);
});
}
// 🔁 Fallback ke role-based navigation
if (roleId === 0) return devBar;
if (roleId === 1) return navBar;
if (roleId === 2) return role1;
if (roleId === 3) return role2;
if (roleId === 4) return role3;
return [];
}

View File

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

View File

@@ -0,0 +1,129 @@
/* 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'
import { useShallowEffect } from '@mantine/hooks'
// ✅ 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[]>([])
useShallowEffect(() => {
stateUser.findMany.load()
}, [])
// ✅ 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' 'use client'
import colors from '@/con/colors'; 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 { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
@@ -9,6 +10,7 @@ import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import user from '../../../_state/user/user-state'; import user from '../../../_state/user/user-state';
function EditRole() { function EditRole() {
const stateRole = useProxy(user.roleState); const stateRole = useProxy(user.roleState);
const router = useRouter(); const router = useRouter();
@@ -17,46 +19,37 @@ function EditRole() {
// Controlled local state // Controlled local state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
permissions: [] as string[],
}); });
const [originalData, setOriginalData] = useState({ const [originalData, setOriginalData] = useState({
name: '', name: '',
permissions: [] as string[],
}); });
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Load role data // Load role data
const loadRole = useCallback(async (id: string) => { const loadRole = useCallback(async (id: string) => {
try { const data = await stateRole.update.load(id);
const data = await stateRole.update.load(id);
if (data) { if (data) {
setFormData({ setFormData({
name: data.name || '', name: data.name ?? '',
permissions: data.permissions || [], });
});
setOriginalData({ setOriginalData({
name: data.name || '', 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');
} }
}, [stateRole.update]); }, []);
useEffect(() => { useEffect(() => {
stateRole.findMany.load(); // Load permissions/options stateRole.findMany.load(); // load permission
const id = params?.id as string; if (params?.id) loadRole(params.id as string);
if (id) loadRole(id); }, [params?.id]);
}, [params?.id, loadRole, stateRole.findMany]);
const handleResetForm = () => { const handleResetForm = () => {
setFormData({ setFormData({
name: originalData.name, name: originalData.name,
permissions: originalData.permissions,
}); });
toast.info("Form dikembalikan ke data awal"); toast.info("Form dikembalikan ke data awal");
}; };
@@ -66,10 +59,6 @@ function EditRole() {
toast.error('Nama role tidak boleh kosong'); toast.error('Nama role tidak boleh kosong');
return; return;
} }
if (!formData.permissions.length) {
toast.error('Pilih minimal satu permission');
return;
}
try { try {
setIsSubmitting(true); setIsSubmitting(true);
@@ -77,7 +66,6 @@ function EditRole() {
stateRole.update.form = { stateRole.update.form = {
...stateRole.update.form, ...stateRole.update.form,
name: formData.name, name: formData.name,
permissions: formData.permissions,
}; };
await stateRole.update.update(); await stateRole.update.update();
toast.success('Role berhasil diperbarui!'); toast.success('Role berhasil diperbarui!');
@@ -116,24 +104,7 @@ function EditRole() {
label={<Text fw="bold" fz="sm">Nama Role</Text>} label={<Text fw="bold" fz="sm">Nama Role</Text>}
placeholder="Masukkan nama role" placeholder="Masukkan nama role"
/> />
<MultiSelect <Group justify="right">
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">
{/* Tombol Batal */} {/* Tombol Batal */}
<Button <Button
variant="outline" variant="outline"

View File

@@ -5,9 +5,8 @@ import {
Box, Box,
Button, Button,
Group, Group,
MultiSelect,
Paper,
Loader, Loader,
Paper,
Stack, Stack,
TextInput, TextInput,
Title Title
@@ -15,9 +14,9 @@ import {
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import user from '../../../_state/user/user-state'; import user from '../../../_state/user/user-state';
import { toast } from 'react-toastify';
export default function CreateRole() { export default function CreateRole() {
@@ -31,8 +30,7 @@ export default function CreateRole() {
const resetForm = () => { const resetForm = () => {
stateRole.create.form = { stateRole.create.form = {
name: '', name: ''
permissions: [],
}; };
}; };
@@ -80,28 +78,6 @@ export default function CreateRole() {
required required
/> />
</Box> </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"> <Group justify="right">
<Button <Button

View File

@@ -1,14 +1,14 @@
'use client' 'use client'
import colors from '@/con/colors'; 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 { 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 { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header'; import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import user from '../../_state/user/user-state'; import user from '../../_state/user/user-state';
import { authStore } from '@/store/authStore';
function User() { function User() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -27,9 +27,10 @@ function User() {
} }
function ListUser({ search }: { search: string }) { function ListUser({ search }: { search: string }) {
const stateUser = useProxy(user.userState) const stateUser = useProxy(user.userState);
const [modalHapus, setModalHapus] = useState(false) const stateRole = useProxy(user.roleState);
const [selectedId, setSelectedId] = useState<string | null>(null) const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const { const {
data, data,
@@ -41,20 +42,103 @@ function ListUser({ search }: { search: string }) {
const handleDelete = () => { const handleDelete = () => {
if (selectedId) { if (selectedId) {
stateUser.delete.submit(selectedId) stateUser.deleteUser.delete(selectedId);
setModalHapus(false) setModalHapus(false);
setSelectedId(null) setSelectedId(null);
stateUser.findMany.load();
stateUser.findMany.load()
} }
} };
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search) stateRole.findMany.load();
}, [page, search]) 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) {
// ✅ Logout user jika sedang mengedit diri sendiri
const currentUserId = authStore.user?.id;
if (currentUserId === userId) {
authStore.setUser(null);
document.cookie = `${process.env.BASE_SESSION_KEY}=; Max-Age=0; path=/;`;
alert("Perubahan memerlukan login ulang");
window.location.href = "/login";
return;
}
// Reload data
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) {
// ✅ Logout user jika sedang mengedit diri sendiri
const currentUserId = authStore.user?.id;
if (currentUserId === userId) {
authStore.setUser(null);
document.cookie = `${process.env.BASE_SESSION_KEY}=; Max-Age=0; path=/;`;
alert("Perubahan memerlukan login ulang");
window.location.href = "/login";
return;
}
// Reload data
stateUser.findMany.load(page, 10, search);
}
};
const filteredData = (data || []).filter(
(item) => item.roleId !== "0" // asumsikan id role SUPERADMIN = "0"
);
if (loading || !data) { if (loading || !data) {
return ( return (
@@ -78,25 +162,51 @@ function ListUser({ search }: { search: string }) {
<TableTh style={{ width: '20%' }}>Nomor</TableTh> <TableTh style={{ width: '20%' }}>Nomor</TableTh>
<TableTh style={{ width: '20%' }}>Role</TableTh> <TableTh style={{ width: '20%' }}>Role</TableTh>
<TableTh style={{ width: '15%' }}>Aktif / Nonaktif</TableTh> <TableTh style={{ width: '15%' }}>Aktif / Nonaktif</TableTh>
<TableTh style={{ width: '15%' }}>Hapus</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{filteredData.length > 0 ? ( {filteredData.length > 0 ? (
filteredData.map((item) => ( filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd style={{ width: '25%', }}> <TableTd style={{ width: '25%' }}>
<Text fw={500} truncate="end" lineClamp={1}>{item.username}</Text> <Text fw={500} truncate="end" lineClamp={1}>
{item.username}
</Text>
</TableTd> </TableTd>
<TableTd style={{ width: '20%', }}> <TableTd style={{ width: '20%' }}>
<Text truncate fz="sm" c="dimmed"> <Text truncate fz="sm" c="dimmed">
{item.nomor} {item.nomor}
</Text> </Text>
</TableTd> </TableTd>
<TableTd style={{ width: '20%', }}> <TableTd style={{ width: '20%' }}>
<Text truncate fz="sm" c="dimmed"> <Select
{item.role.name} placeholder="Pilih role"
</Text> 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>
<TableTd style={{ width: '15%' }}> <TableTd style={{ width: '15%' }}>
<Tooltip <Tooltip
label={item.isActive ? "Nonaktifkan user" : "Aktifkan user"} label={item.isActive ? "Nonaktifkan user" : "Aktifkan user"}
@@ -105,22 +215,34 @@ function ListUser({ search }: { search: string }) {
<Button <Button
variant="light" variant="light"
color={item.isActive ? "green" : "red"} color={item.isActive ? "green" : "red"}
onClick={async () => { onClick={() => handleToggleActive(item.id, item.isActive)}
await stateUser.updateActive.submit(item.id, !item.isActive) disabled={stateUser.update.loading}
stateUser.findMany.load(page, 10, search)
}}
> >
{item.isActive ? <IconCheck size={20} /> : <IconX size={20} />} {item.isActive ? <IconCheck size={20} /> : <IconX size={20} />}
</Button> </Button>
</Tooltip> </Tooltip>
</TableTd> </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>
)) ))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={5}>
<Center py={20}> <Center py={20}>
<Text color="dimmed">Tidak ada data user yang cocok</Text> <Text c="dimmed">Tidak ada data user yang cocok</Text>
</Center> </Center>
</TableTd> </TableTd>
</TableTr> </TableTr>
@@ -129,6 +251,7 @@ function ListUser({ search }: { search: string }) {
</Table> </Table>
</Box> </Box>
</Paper> </Paper>
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
@@ -143,6 +266,7 @@ function ListUser({ search }: { search: string }) {
radius="md" radius="md"
/> />
</Center> </Center>
{/* Modal Konfirmasi Hapus */} {/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus <ModalKonfirmasiHapus
opened={modalHapus} opened={modalHapus}
@@ -154,4 +278,4 @@ function ListUser({ search }: { search: string }) {
); );
} }
export default User; export default User;

View File

@@ -1,4 +1,4 @@
export const navBar = [ export const devBar = [
{ {
id: "Landing Page", id: "Landing Page",
name: "Landing Page", name: "Landing Page",
@@ -84,7 +84,6 @@ export const navBar = [
] ]
}, },
{ {
id: "Desa", id: "Desa",
name: "Desa", name: "Desa",
@@ -336,7 +335,824 @@ export const navBar = [
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana" 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 navBar = [
{
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: "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"
}
]
},
{
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"
}
]
},
{
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", id: "Pendidikan",
name: "Pendidikan", name: "Pendidikan",
path: "", path: "",
@@ -378,4 +1194,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' 'use client'
import colors from "@/con/colors"; import colors from "@/con/colors";
import { authStore } from "@/store/authStore";
import { import {
ActionIcon, ActionIcon,
AppShell, AppShell,
@@ -8,9 +9,11 @@ import {
AppShellMain, AppShellMain,
AppShellNavbar, AppShellNavbar,
Burger, Burger,
Center,
Flex, Flex,
Group, Group,
Image, Image,
Loader,
NavLink, NavLink,
ScrollArea, ScrollArea,
Text, Text,
@@ -26,14 +29,86 @@ import {
import _ from "lodash"; import _ from "lodash";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation"; 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 }) { 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 [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter(); const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s)); 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 ( return (
<AppShell <AppShell
suppressHydrationWarning suppressHydrationWarning
@@ -115,13 +190,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
variant="gradient" variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }} gradient={{ from: colors["blue-button"], to: "#228be6" }}
> >
<Image <Image
src="/assets/images/darmasaba-icon.png" src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba" alt="Logo Darmasaba"
w={20} w={20}
h={20} h={20}
radius="md" radius="md"
loading="lazy" loading="lazy"
style={{ style={{
minWidth: '20px', minWidth: '20px',
height: 'auto', height: 'auto',
@@ -131,9 +206,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</Tooltip> </Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow> <Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon <ActionIcon
onClick={() => { onClick={handleLogout}
router.push("/darmasaba");
}}
color={colors["blue-button"]} color={colors["blue-button"]}
radius="xl" radius="xl"
size="lg" size="lg"
@@ -156,7 +229,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
p={{ base: 'xs', sm: 'sm' }} p={{ base: 'xs', sm: 'sm' }}
> >
<AppShell.Section p="sm"> <AppShell.Section p="sm">
{navBar.map((v, k) => { {currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name)); const isParentActive = segments.includes(_.lowerCase(v.name));
return ( return (

View File

@@ -1,5 +1,4 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP"; import { randomOTP } from "../_lib/randomOTP";
@@ -12,52 +11,70 @@ export async function POST(req: Request) {
} }
try { try {
const codeOtp = randomOTP();
const body = await req.json(); const body = await req.json();
const { nomor } = body; 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 (!nomor || typeof nomor !== "string") {
if (sendWa.status !== "success")
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Nomor Whatsapp Tidak Aktif" }, { success: false, message: "Nomor tidak valid" },
{ status: 400 } { status: 400 }
); );
}
const createOtpId = await prisma.kodeOtp.create({ // Cek apakah user sudah terdaftar
data: { const existingUser = await prisma.user.findUnique({
nomor: nomor, where: { nomor },
otp: codeOtp, 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( return NextResponse.json(
{ success: false, message: "Gagal mengirim kode OTP" }, { success: false, message: "Nomor WhatsApp tidak aktif" },
{ status: 400 } { 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( return NextResponse.json(
{ {
success: true, success: true,
message: "Kode verifikasi terkirim", message: "Kode verifikasi terkirim",
kodeId: createOtpId.id, kodeId: otpRecord.id,
isRegistered, // 🔑 Ini kunci untuk frontend tahu harus ke register atau verifikasi
}, },
{ status: 200 } { status: 200 }
); );
} catch (error) { } catch (error) {
console.log("Error Login", error); console.error("Error Login:", error);
return NextResponse.json( 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 } { status: 500 }
); );
} finally { } finally {
await prisma.$disconnect(); 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

@@ -12,7 +12,7 @@ type APBDesItemInput = {
selisih: number; selisih: number;
persentase: number; persentase: number;
level: number; level: number;
tipe: string; tipe?: string | null;
}; };
type FormCreate = { type FormCreate = {
@@ -66,8 +66,8 @@ export default async function apbdesCreate(context: Context) {
selisih: item.selisih, selisih: item.selisih,
persentase: item.persentase, persentase: item.persentase,
level: item.level, level: item.level,
tipe: item.tipe, // ✅ sertakan, biar null
apbdesId: apbdes.id, apbdesId: apbdes.id,
// Note: tipe field is not included as it doesn't exist in the model
}; };
return prisma.aPBDesItem.create({ return prisma.aPBDesItem.create({

View File

@@ -15,7 +15,7 @@ const ApbdesItemSchema = t.Object({
selisih: t.Number(), selisih: t.Number(),
persentase: t.Number(), persentase: t.Number(),
level: t.Number(), level: t.Number(),
tipe: t.String(), // misal: "pendapatan" atau "belanja" tipe: t.Optional(t.Union([t.String(), t.Null()])) // misal: "pendapatan" atau "belanja"
}); });
const APBDes = new Elysia({ const APBDes = new Elysia({

View File

@@ -10,7 +10,7 @@ type APBDesItemInput = {
selisih: number; selisih: number;
persentase: number; persentase: number;
level: number; level: number;
tipe: string; tipe?: string | null;
}; };
type FormUpdateBody = { type FormUpdateBody = {
@@ -54,7 +54,7 @@ export default async function apbdesUpdate(context: Context) {
selisih: item.anggaran - item.realisasi, selisih: item.anggaran - item.realisasi,
persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0, persentase: item.anggaran > 0 ? (item.realisasi / item.anggaran) * 100 : 0,
level: item.level, level: item.level,
tipe: item.tipe, tipe: item.tipe || null,
isActive: true, isActive: true,
})), })),
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
// /api/user/delUser.ts
import prisma from '@/lib/prisma';
import { Context } from 'elysia';
export default async function userDeleteAccount(context: Context) {
const { id } = context.params as { id: string };
try {
// 1. Cek user dulu
const existingUser = await prisma.user.findUnique({
where: { id },
});
if (!existingUser) {
return {
success: false,
message: 'User tidak ditemukan',
};
}
// ✅ 2. Hapus SEMUA relasi dalam TRANSACTION
const result = await prisma.$transaction(async (tx) => {
// Hapus UserSession
const deletedSessions = await tx.userSession.deleteMany({
where: { userId: id },
});
// ✅ Hapus UserMenuAccess
const deletedMenuAccess = await tx.userMenuAccess.deleteMany({
where: { userId: id },
});
// ✅ Tambahkan relasi lain jika ada (contoh):
// await tx.userLog.deleteMany({ where: { userId: id } });
// await tx.userNotification.deleteMany({ where: { userId: id } });
// await tx.userToken.deleteMany({ where: { userId: id } });
// Hapus user
const deletedUser = await tx.user.delete({
where: { id },
});
return {
user: deletedUser,
sessionsDeleted: deletedSessions.count,
menuAccessDeleted: deletedMenuAccess.count,
};
});
return {
success: true,
message: `User berhasil dihapus permanen (${result.sessionsDeleted} session, ${result.menuAccessDeleted} menu access)`,
data: result,
};
} catch (error) {
console.error('Error delete user:', error);
return {
success: false,
message: 'Terjadi kesalahan saat menghapus user',
};
}
}

View File

@@ -5,6 +5,7 @@ import userFindMany from "./findMany";
import userFindUnique from "./findUnique"; import userFindUnique from "./findUnique";
import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsung, jadi biasanya `del.ts` import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsung, jadi biasanya `del.ts`
import userUpdate from "./updt"; import userUpdate from "./updt";
import userDeleteAccount from "./delUser";
const User = new Elysia({ prefix: "/api/user" }) const User = new Elysia({ prefix: "/api/user" })
.get("/findMany", userFindMany) .get("/findMany", userFindMany)
@@ -14,6 +15,21 @@ const User = new Elysia({ prefix: "/api/user" })
id: t.String(), id: t.String(),
}), }),
}) // pakai PUT untuk soft delete }) // 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()),
})
}
)
.delete("/delUser/:id", userDeleteAccount, {
params: t.Object({
id: t.String(),
}),
});
export default User; export default User;

View File

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

View File

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

View File

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

View File

@@ -2,39 +2,77 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
// API update user
export default async function userUpdate(context: Context) { export default async function userUpdate(context: Context) {
try { 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) { if (!id) {
return { return { success: false, message: "ID user wajib ada" };
success: false,
message: "ID user wajib ada",
};
} }
// Validasi role
if (roleId) {
const role = await prisma.role.findUnique({ where: { id: roleId } });
if (!role) return { success: false, message: "Role tidak ditemukan" };
}
const currentUser = await prisma.user.findUnique({
where: { id },
select: { roleId: true, isActive: true }
});
if (!currentUser) {
return { success: false, message: "User tidak ditemukan" };
}
const isRoleChanged = roleId && currentUser.roleId !== roleId;
const isActiveChanged = isActive !== undefined && currentUser.isActive !== isActive;
// Update user
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { id }, where: { id },
data: { isActive }, data: {
...(isActive !== undefined && { isActive }),
...(roleId && { roleId }),
// Force logout: invalidate semua sesi
...(isRoleChanged ? { sessionInvalid: true } : {}),
},
select: { select: {
id: true, id: true,
username: true, username: true,
nomor: true, nomor: true,
isActive: true, isActive: true,
updatedAt: true, roleId: true,
role: { select: { name: true } }
} }
}); });
// ✅ HAPUS SEMUA SESI USER DI DATABASE
if (isRoleChanged) {
await prisma.userSession.deleteMany({ where: { userId: id } });
}
return { return {
success: true, success: true,
message: `User berhasil ${isActive ? "diaktifkan" : "dinonaktifkan"}`, roleChanged: isRoleChanged,
isActiveChanged,
data: updatedUser, data: updatedUser,
message: isRoleChanged
? `Role ${updatedUser.username} diubah. User akan logout otomatis.`
: isActiveChanged
? `${updatedUser.username} ${isActive ? 'diaktifkan' : 'dinonaktifkan'}.`
: "User berhasil diupdate"
}; };
} catch (e: any) { } catch (e: any) {
console.error("Error update user:", e); console.error("Error update user:", e);
return { return {
success: false, 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,142 @@
/* 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;
};
// Ganti endpoint ke verify-otp-login
export const apiFetchVerifyOtp = async ({ nomor, otp, kodeId }: { nomor: string; otp: string; kodeId: string }) => {
const response = await fetch('/api/auth/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
});
const data = await response.json();
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"; import { jwtVerify } from "jose";
export async function decrypt({ export async function decrypt({
token, token,
encodedKey, jwtSecret,
}: { }: {
token: string; token: string;
encodedKey: string; jwtSecret: string;
}): Promise<Record<string, any> | null> { }): Promise<Record<string, unknown> | null> {
if (!token || !encodedKey) { if (!token || !jwtSecret) return null;
console.error("Missing required parameters:", {
hasToken: !!token,
hasEncodedKey: !!encodedKey,
});
return null;
}
try { try {
const enc = new TextEncoder().encode(encodedKey); const secret = new TextEncoder().encode(jwtSecret);
const { payload } = await jwtVerify(token, enc, { const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"], algorithms: ["HS256"],
}); });
if (!payload || !payload.user) { if (
console.error("Invalid payload structure:", { typeof payload !== "object" ||
hasPayload: !!payload, payload === null ||
hasUser: payload ? !!payload.user : false, !("user" in payload) ||
}); typeof payload.user !== "object"
) {
return null; return null;
} }
// Logging untuk debug return payload.user as Record<string, unknown>;
// console.log("Decrypt successful:", {
// payloadExists: !!payload,
// userExists: !!payload.user,
// tokenPreview: token.substring(0, 10) + "...",
// });
return payload.user as Record<string, any>;
} catch (error) { } catch (error) {
console.error("Token verification failed:", { console.error("JWT Decrypt failed:", error);
error,
tokenLength: token?.length,
errorName: error instanceof Error ? error.name : "Unknown error",
errorMessage: error instanceof Error ? error.message : String(error),
});
return null; return null;
} }
} }

View File

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

View File

@@ -1,36 +1,62 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ // app/api/auth/_lib/session_create.ts
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { encrypt } from "./encrypt"; import { encrypt } from "./encrypt";
import prisma from "@/lib/prisma";
import { add } from "date-fns";
export async function sessionCreate({ export async function sessionCreate({
sessionKey, sessionKey,
exp = "7 year", exp = "30 day",
encodedKey, jwtSecret,
user, user,
}: { }: {
sessionKey: string; sessionKey: string;
exp?: string; exp?: string;
encodedKey: string; jwtSecret: string;
user: Record<string, unknown>; user: Record<string, unknown> & { id: string };
}) { }) {
const token = await encrypt({ // ✅ Validasi env vars
exp, if (!sessionKey || sessionKey.length === 0) {
encodedKey, throw new Error("sessionKey tidak boleh kosong");
user, }
if (!jwtSecret || jwtSecret.length < 32) {
throw new Error("jwtSecret minimal 32 karakter");
}
const token = await encrypt({ exp, jwtSecret, user });
if (!token) {
throw new Error("Token generation failed");
}
// ✅ Hitung expiresAt sesuai exp
let expiresAt = add(new Date(), { days: 30 });
if (exp === "7 day") expiresAt = add(new Date(), { days: 7 });
// tambahkan opsi lain jika perlu
// Sebelum create session baru, nonaktifkan session aktif sebelumnya
await prisma.userSession.updateMany({
where: { userId: user.id, active: true },
data: { active: false },
}); });
const cookie: any = { // ✅ Simpan ke database
key: sessionKey, await prisma.userSession.create({
value: token, data: {
options: { token,
httpOnly: true, userId: user.id,
sameSite: "lax", active: true,
path: "/", expiresAt,
}, },
}; });
// ✅ Set cookie
(await cookies()).set(sessionKey, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
maxAge: 30 * 24 * 60 * 60, // seconds
});
(await cookies()).set(cookie.key, cookie.value, { ...cookie.options });
return token; 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,45 @@
// 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('Env tidak lengkap');
const token = (await cookies()).get(sessionKey)?.value;
if (!token) return null;
const jwtUser = await decrypt({ token, jwtSecret });
if (!jwtUser?.id) return null;
// Cari session di DB berdasarkan token
const dbSession = await prisma.userSession.findFirst({
where: {
token,
active: true,
expiresAt: { gte: new Date() }
},
include: { user: true }
});
if (!dbSession) {
console.log('⚠️ Session tidak ditemukan di DB');
return null;
}
// ❌ Hanya tolak jika sessionInvalid = true
if (dbSession.user.sessionInvalid) {
console.log('⚠️ Session di-invalidate');
return null;
}
// ✅ Return user, meskipun isActive = false
return dbSession.user;
} catch (error) {
console.warn('Session verification failed:', error);
return null;
}
}

View File

@@ -0,0 +1,114 @@
// src/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 } = await req.json();
const cleanNomor = nomor.replace(/\D/g, "");
if (!cleanNomor || !username || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
// Di awal fungsi POST
console.log("📦 Received payload:", { nomor, username, kodeId });
// Validasi OTP
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
});
if (!otpRecord?.isActive || otpRecord.nomor !== cleanNomor) {
return NextResponse.json(
{ success: false, message: "OTP tidak valid" },
{ status: 400 }
);
}
// Cek duplikat username
if (await prisma.user.findFirst({ where: { username } })) {
return NextResponse.json(
{ success: false, message: "Username sudah digunakan" },
{ status: 409 }
);
}
// ✅ Gunakan username dari input user
const defaultRole = await prisma.role.findFirst({
where: { name: "ADMIN DESA" },
select: { id: true },
});
if (!defaultRole) {
return NextResponse.json(
{ success: false, message: "Role default tidak ditemukan" },
{ status: 500 }
);
}
// ✅ Buat user dengan username yang diinput
const newUser = await prisma.user.create({
data: {
username, // ✅ Ini yang benar
nomor,
roleId: defaultRole.id,
isActive: false,
},
});
// Nonaktifkan OTP
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
// ✅ BUAT SESI untuk user baru (meski isActive = false)
const token = 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, // ✅ Pastikan sesuai
roleId: newUser.roleId,
isActive: false,
},
});
// Set cookie
const response = NextResponse.json({
success: true,
message: "Registrasi berhasil. Menunggu persetujuan admin.",
user: {
id: newUser.id,
name: newUser.username,
roleId: newUser.roleId,
isActive: false,
},
});
response.cookies.set(process.env.BASE_SESSION_KEY!, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 30 * 24 * 60 * 60,
});
return response;
} 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 prisma from "@/lib/prisma";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP"; import { randomOTP } from "../_lib/randomOTP";
@@ -12,52 +12,66 @@ export async function POST(req: Request) {
} }
try { try {
const codeOtp = randomOTP(); const { nomor } = await req.json();
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 (!nomor || typeof nomor !== "string") {
if (sendWa.status !== "success")
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Nomor Whatsapp Tidak Aktif" }, { success: false, message: "Nomor tidak valid" },
{ status: 400 } { status: 400 }
); );
}
const createOtpId = await prisma.kodeOtp.create({ const existingUser = await prisma.user.findUnique({
data: { where: { nomor },
nomor: nomor, select: { id: true, isActive: true },
otp: codeOtp,
},
}); });
if (!createOtpId) const isRegistered = !!existingUser;
return NextResponse.json(
{ success: false, message: "Gagal mengirim kode OTP" },
{ status: 400 }
);
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, success: true,
message: "Kode verifikasi terkirim", message: "Kode verifikasi dikirim",
kodeId: createOtpId.id, kodeId: createOtpId.id,
}, isRegistered: true,
{ status: 200 } });
); } else {
// ❌ User belum terdaftar → JANGAN kirim OTP
return NextResponse.json({
success: true,
message: "Nomor belum terdaftar",
isRegistered: false,
// Tidak ada kodeId
});
}
} catch (error) { } catch (error) {
console.log("Error Login", error); console.error("Error Login:", error);
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Terjadi masalah saat login" , reason: error as Error }, { success: false, message: "Terjadi kesalahan saat login" },
{ status: 500 } { status: 500 }
); );
} finally { } finally {
await prisma.$disconnect(); 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,59 @@
// 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 sessionUser = await verifySession();
if (!sessionUser) {
return NextResponse.json(
{ success: false, message: "Unauthorized", user: null },
{ status: 401 }
);
}
const [dbUser, menuAccess] = await Promise.all([
prisma.user.findUnique({
where: { id: sessionUser.id },
select: {
id: true,
username: true,
nomor: true,
roleId: true, // STRING!
isActive: true, // BOOLEAN!
},
}),
prisma.userMenuAccess.findMany({
where: { userId: sessionUser.id },
select: { menuId: true },
}),
]);
if (!dbUser) {
return NextResponse.json(
{ success: false, message: "User not found", user: null },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
user: {
id: dbUser.id,
name: dbUser.username,
username: dbUser.username,
nomor: dbUser.nomor,
roleId: dbUser.roleId, // STRING!
isActive: dbUser.isActive, // BOOLEAN!
menuIds: menuAccess.map(m => m.menuId),
},
});
} catch (error) {
console.error("❌ Error in /api/auth/me:", error);
return NextResponse.json(
{ success: false, message: "Internal server error", user: null },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,42 @@
// app/api/auth/otp-data/route.ts
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const kodeId = searchParams.get("kodeId");
if (!kodeId) {
return NextResponse.json(
{ success: false, message: "Kode ID tidak diberikan" },
{ status: 400 }
);
}
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
select: { nomor: true, isActive: 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: { nomor: otpRecord.nomor },
});
} catch (error) {
console.error("❌ Gagal mengambil data OTP:", error);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan internal" },
{ 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) { export async function POST(req: Request) {
if (req.method !== "POST") {
return NextResponse.json(
{ success: false, message: "Method Not Allowed" },
{ status: 405 }
);
}
try { try {
const { data } = await req.json(); const { username, nomor } = await req.json();
const cekUsername = await prisma.user.findUnique({ if (!username || !nomor) {
where: { return NextResponse.json({ success: false, message: 'Data tidak lengkap' }, { status: 400 });
username: data.username, }
nomor: data.nomor,
}, // Cek duplikat
if (await prisma.user.findUnique({ where: { nomor } })) {
return NextResponse.json({ success: false, message: 'Nomor sudah terdaftar' }, { status: 409 });
}
if (await prisma.user.findFirst({ 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) // ✅ Kembalikan kodeId (jangan buat user di sini!)
return NextResponse.json({ return NextResponse.json({
success: false, success: true,
message: "Username sudah digunakan", message: 'Kode verifikasi dikirim',
}); kodeId: otpRecord.id,
const createUser = await prisma.user.create({
data: {
username: data.username,
nomor: data.nomor,
},
}); });
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) { } catch (error) {
console.error("Error registrasi:", error); console.error('Register OTP Error:', error);
return NextResponse.json( return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 500 });
{
success: false,
message: "Maaf, Terjadi Keselahan",
reason: (error as Error).message,
},
{ status: 500 }
);
} finally { } finally {
await prisma.$disconnect(); await prisma.$disconnect();
} }
} }

View File

@@ -0,0 +1,58 @@
// src/app/api/auth/resend-otp/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
export async function POST(req: Request) {
try {
const { nomor } = await req.json();
if (!nomor || typeof nomor !== 'string') {
return NextResponse.json(
{ success: false, message: "Nomor tidak valid" },
{ status: 400 }
);
}
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
// Kirim OTP via WhatsApp
const waMessage = `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,
},
});
return NextResponse.json({
success: true,
message: "OTP baru dikirim",
kodeId: otpRecord.id,
});
} catch (error) {
console.error("Error Resend OTP:", error);
return NextResponse.json(
{ success: false, message: "Gagal mengirim ulang OTP" },
{ 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.findFirst({ 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,101 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/app/api/auth/verify-otp-login/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();
if (!nomor || !otp || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
const otpRecord = await prisma.kodeOtp.findUnique({ where: { id: kodeId } });
if (!otpRecord || !otpRecord.isActive || otpRecord.nomor !== nomor) {
return NextResponse.json(
{ success: false, message: "Kode verifikasi tidak valid" },
{ status: 400 }
);
}
const receivedOtp = Number(otp);
if (isNaN(receivedOtp) || otpRecord.otp !== receivedOtp) {
return NextResponse.json(
{ success: false, message: "Kode OTP salah" },
{ status: 400 }
);
}
// 🔍 CARI USER — JANGAN BUAT BARU!
const user = await prisma.user.findUnique({
where: { nomor },
select: { id: true, nomor: true, username: true, roleId: true, isActive: true },
});
if (!user) {
// ❌ Nomor belum terdaftar → suruh registrasi
return NextResponse.json(
{ success: false, message: "Akun tidak ditemukan. Silakan registrasi terlebih dahulu." },
{ status: 404 }
);
}
// ✅ Buat sesi
const token = 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,
},
});
await prisma.$transaction([
prisma.kodeOtp.update({ where: { id: kodeId }, data: { isActive: false } }),
prisma.user.update({ where: { id: user.id }, data: { lastLogin: new Date() } }),
]);
const response = NextResponse.json({
success: true,
message: user.isActive ? "Berhasil login" : "Menunggu persetujuan",
user: {
id: user.id,
name: user.username,
roleId: user.roleId,
isActive: user.isActive,
},
});
response.cookies.set(process.env.BASE_SESSION_KEY!, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 30 * 24 * 60 * 60,
});
return response;
} catch (error: any) {
console.error("❌ Verify OTP Login Error:", error);
if (error.message.includes("sessionKey") || error.message.includes("jwtSecret")) {
return NextResponse.json(
{ success: false, message: "Konfigurasi server tidak lengkap" },
{ status: 500 }
);
}
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat login" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -0,0 +1,61 @@
// src/app/api/auth/verify-otp-register/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
try {
const { nomor, otp, kodeId } = await req.json();
if (!nomor || !otp || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
});
if (!otpRecord || !otpRecord.isActive) {
return NextResponse.json(
{ success: false, message: "Kode verifikasi tidak valid atau sudah kadaluarsa" },
{ status: 400 }
);
}
if (otpRecord.nomor !== nomor) {
return NextResponse.json(
{ success: false, message: "Nomor tidak sesuai dengan kode verifikasi" },
{ status: 400 }
);
}
const receivedOtp = Number(otp);
if (isNaN(receivedOtp) || otpRecord.otp !== receivedOtp) {
return NextResponse.json(
{ success: false, message: "Kode OTP salah" },
{ status: 400 }
);
}
// ✅ Hanya validasi — jangan update isActive!
return NextResponse.json({
success: true,
message: "OTP valid. Lanjutkan ke finalisasi registrasi.",
data: {
nomor,
kodeId,
},
});
} catch (error) {
console.error("❌ Verify OTP Register Error:", error);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat verifikasi OTP" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -1,25 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes' import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress'
import colors from '@/con/colors' import colors from '@/con/colors'
import { BarChart } from '@mantine/charts' import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core'
import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react' import { IconDownload } from '@tabler/icons-react'
import Link from 'next/link' import Link from 'next/link'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils' import { useProxy } from 'valtio/utils'
import parseJumlah from './lib/convert'
function Apbdes() { function Apbdes() {
type APBDes = {
id: string
name: string
jumlah: number
};
const [chartData, setChartData] = useState<APBDes[]>([])
const [mounted, setMounted] = useState(false);
const state = useProxy(apbdes) const state = useProxy(apbdes)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -28,22 +18,9 @@ function Apbdes() {
des: 'Transparansi APBDes Darmasaba adalah langkah nyata menuju tata kelola desa yang bersih, terbuka, dan bertanggung jawab.' des: 'Transparansi APBDes Darmasaba adalah langkah nyata menuju tata kelola desa yang bersih, terbuka, dan bertanggung jawab.'
} }
useEffect(() => {
if (state.findMany.data) {
setChartData(
state.findMany.data.map((item: any) => ({
id: item.id,
name: item.name,
jumlah: parseJumlah(item.jumlah),
}))
);
}
}, [state.findMany.data]);
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
setMounted(true);
setLoading(true) setLoading(true)
await state.findMany.load() await state.findMany.load()
} catch (error) { } catch (error) {
@@ -70,56 +47,23 @@ function Apbdes() {
</Stack> </Stack>
</Box> </Box>
{/* Chart */} <Group justify="center">
<Box mt={30} style={{ width: '100%', minHeight: 400 }}> <Button
<Paper bg={colors['white-1']} py={50} px={90} mb={"xl"} radius="md" withBorder> component={Link}
<Stack gap={"xs"}> href="/darmasaba/apbdes"
<Title ta={"center"} pb={10} order={2}> radius="xl"
Grafik APBDes size="lg"
</Title> variant="gradient"
{mounted && chartData.length > 0 ? ( gradient={{ from: "#26667F", to: "#124170" }}
<BarChart >
orientation="vertical" Lihat Semua Data
h={450} </Button>
barProps={{ radius: 50 }} </Group>
data={chartData}
dataKey="name"
type="stacked"
valueFormatter={(value: number) => {
if (value >= 1_000_000_000_000)
return `Rp ${(value / 1_000_000_000_000).toFixed(1)} T`;
if (value >= 1_000_000_000)
return `Rp ${(value / 1_000_000_000).toFixed(1)} M`;
if (value >= 1_000_000)
return `Rp ${(value / 1_000_000).toFixed(1)} JT`;
if (value >= 1_000)
return `Rp ${(value / 1_000).toFixed(1)} RB`;
return `Rp ${value}`;
}}
series={[
{
name: 'jumlah',
color: colors['blue-button'],
label: 'Jumlah',
},
]}
/>
) : (
<Text c="dimmed">Belum ada data untuk ditampilkan dalam grafik</Text>
)}
<Box py={10}>
<Group justify='center'>
<Flex align="center" gap={10}>
<Box bg={colors['blue-button']} w={20} h={20} />
<Text>Jumlah</Text>
</Flex>
</Group>
</Box>
</Stack>
</Paper>
</Box>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="lg"> {/* Chart */}
<APBDesProgress />
<SimpleGrid mx={{ base: 'md', md: 100 }} cols={{ base: 1, sm: 3 }} spacing="lg" pb={"xl"}>
{loading ? ( {loading ? (
<Center mih={200}> <Center mih={200}>
<Loader size="lg" color="blue" /> <Loader size="lg" color="blue" />
@@ -185,18 +129,7 @@ function Apbdes() {
)} )}
</SimpleGrid> </SimpleGrid>
<Group justify="center" pb={"xl"}>
<Button
component={Link}
href="/darmasaba/apbdes"
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: "#26667F", to: "#124170" }}
>
Lihat Semua Data
</Button>
</Group>
</Stack> </Stack>
) )
} }

View File

@@ -0,0 +1,133 @@
/* 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';
async function fetchUser() {
const res = await fetch('/api/auth/me');
if (!res.ok) {
// Jangan throw error — biarkan handle status code
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
}
return res.json();
}
export default function WaitingRoom() {
const router = useRouter();
const [user, setUser] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const [isRedirecting, setIsRedirecting] = useState(false);
useEffect(() => {
let isMounted = true;
const interval = setInterval(async () => {
if (isRedirecting || !isMounted) return;
try {
const data = await fetchUser();
if (!isMounted) return;
const currentUser = data.user;
setUser(currentUser);
// ✅ Periksa isActive dan redirect
if (currentUser?.isActive === true) {
setIsRedirecting(true);
clearInterval(interval);
// ✅ roleId adalah STRING → gunakan string literal
let redirectPath = '/admin';
switch (currentUser.roleId) {
case "0": // DEVELOPER
case "1": // SUPERADMIN
case "2": // ADMIN_DESA
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case "3": // ADMIN_KESEHATAN
redirectPath = '/admin/kesehatan/posyandu';
break;
case "4": // ADMIN_PENDIDIKAN
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
break;
}
setTimeout(() => router.push(redirectPath), 500);
}
} catch (err: any) {
if (!isMounted) return;
// ❌ Hanya redirect ke /login jika benar-benar tidak ada sesi
if (err.message.includes('401')) {
setError('Sesi tidak valid');
clearInterval(interval);
router.push('/login');
} else {
console.error('Error polling:', err);
}
}
}, 3000);
return () => {
isMounted = false;
clearInterval(interval);
};
}, [router, isRedirecting]);
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>
);
}
if (isRedirecting) {
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">
Akun Disetujui!
</Title>
<Text ta="center" c="green">
Mengalihkan ke dashboard...
</Text>
<Loader size="sm" color="green" />
</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 // // app/middleware.js
import { NextResponse, NextRequest } from 'next/server'; // import { NextResponse, NextRequest } from 'next/server';
// Daftar route yang diizinkan tanpa login (public routes) // // Daftar route yang diizinkan tanpa login (public routes)
const publicRoutes = [ // const publicRoutes = [
'/*', // Home page // '/*', // Home page
'/about', // About page // '/about', // About page
'/public/*', // Wildcard untuk semua route di bawah /public // '/public/*', // Wildcard untuk semua route di bawah /public
'/login', // Halaman login // '/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 export async function middleware(request: NextRequest) {
function isPublicRoute(pathname: string) { const path = request.nextUrl.pathname;
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) { // Izinkan akses ke route publik di /admin
const { pathname } = request.nextUrl; if (PUBLIC_ADMIN_ROUTES.some(route => path.startsWith(route))) {
// 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(); 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 = { 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;
},
});