Merge pull request 'nico/26-nov-25' (#16) from nico/26-nov-25 into staging

Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/16
This commit is contained in:
2025-11-26 15:03:34 +08:00
57 changed files with 5788 additions and 1044 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 +1,10 @@
[ [
{ {
"id": "user-1", "id": "cmie1o0zh0002vn132vtzg7hh",
"nama": "Admin Desa", "username": "SuperAdmin-Nico",
"nomor": "089647037426", "nomor": "6289647037426",
"roleId": "role-1", "roleId": 0,
"isActive": true "isActive": true,
}, "sessionInvalid": false
{
"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,50 +54,48 @@ 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 users from "./data/user/users.json";
import { safeSeedUnique } from "./safeseedUnique"; import { safeSeedUnique } from "./safeseedUnique";
(async () => { (async () => {
// =========== USER & ROLE =========== // =========== USER ===========
console.log("🔄 Seeding user...");
for (const u of users) {
await safeSeedUnique(
"user",
{ id: u.id },
{
username: u.username,
nomor: u.nomor,
roleId: u.roleId.toString(),
isActive: u.isActive,
sessionInvalid: false,
}
);
}
console.log("✅ Roles seeded");
// =========== ROLE ===========
// In your seed.ts // In your seed.ts
// =========== ROLES =========== // =========== ROLES ===========
console.log("🔄 Seeding roles..."); console.log("🔄 Seeding roles...");
for (const r of roles) { for (const r of roles) {
await safeSeedUnique("role", { id: r.id }, { await safeSeedUnique(
name: r.name, "role",
description: r.description, { id: r.id },
permissions: r.permissions, {
isActive: r.isActive, name: r.name,
}); description: r.description,
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) {
@@ -811,7 +809,9 @@ import { safeSeedUnique } from "./safeseedUnique";
const flattenedPosisiBumdes = posisiOrganisasi.flat(); const flattenedPosisiBumdes = posisiOrganisasi.flat();
// ✅ Urutkan berdasarkan hierarki // ✅ Urutkan berdasarkan hierarki
const sortedPosisiBumdes = flattenedPosisiBumdes.sort((a, b) => a.hierarki - b.hierarki); const sortedPosisiBumdes = flattenedPosisiBumdes.sort(
(a, b) => a.hierarki - b.hierarki
);
for (const p of sortedPosisiBumdes) { for (const p of sortedPosisiBumdes) {
console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`); console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`);
@@ -891,7 +891,7 @@ import { safeSeedUnique } from "./safeseedUnique";
// Add IDs to the kategoriKegiatan data // Add IDs to the kategoriKegiatan data
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({ const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
...k, ...k,
id: `kategori-${index + 1}` id: `kategori-${index + 1}`,
})); }));
for (const k of kategoriKegiatan) { for (const k of kategoriKegiatan) {
@@ -1183,7 +1183,6 @@ import { safeSeedUnique } from "./safeseedUnique";
// seed assets // seed assets
await seedAssets(); await seedAssets();
})() })()
.then(() => prisma.$disconnect()) .then(() => prisma.$disconnect())
.catch((e) => { .catch((e) => {

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,270 @@
'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);
}
};
const handleRegistrationVerification = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan.');
return;
}
const cleanNomor = nomor?.replace(/\D/g, '') ?? '';
if (cleanNomor.length < 10 || username.trim().length < 5) {
toast.error('Data tidak valid');
return;
}
// Verifikasi OTP dulu
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;
}
// ✅ Kirim ke finalize-registration → akan redirect ke /waiting-room
const finalizeRes = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, kodeId }),
credentials: 'include'
});
if (finalizeRes.redirected) {
// ✅ Redirect otomatis oleh server
window.location.href = finalizeRes.url;
} else {
const data = await finalizeRes.json();
toast.error(data.message || 'Registrasi gagal');
}
};
// ✅ 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 +272,4 @@ function Validasi() {
</Box> </Box>
</Stack> </Stack>
); );
} }
export default Validasi;

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,120 @@ 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 [isLoggingOut, setIsLoggingOut] = useState(false);
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);
// ✅ FIX: Selalu fetch user data setiap kali komponen mount
useEffect(() => {
const fetchUser = async () => {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
if (data.user) {
// Check if user is active
if (!data.user.isActive) {
authStore.setUser(null);
router.replace('/waiting-room');
return;
}
// ✅ PENTING: Selalu fetch menuIds terbaru setiap login
const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`);
const menuData = await menuRes.json();
const menuIds = menuData.success && Array.isArray(menuData.menuIds)
? [...menuData.menuIds]
: null;
// ✅ Set user dengan menuIds yang fresh dari database
authStore.setUser({
id: data.user.id,
name: data.user.name,
roleId: Number(data.user.roleId),
menuIds, // menuIds terbaru
isActive: data.user.isActive
});
} 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]); // ✅ Hapus dependency pada authStore.user
if (loading) {
return (
<AppShell>
<AppShellMain>
<Center h="100vh">
<Loader />
</Center>
</AppShellMain>
</AppShell>
);
}
// ✅ Ambil menu berdasarkan roleId dan menuIds
const currentNav = authStore.user
? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
: [];
const handleLogout = async () => {
try {
setIsLoggingOut(true);
// ✅ Panggil API logout untuk clear session di server
const response = await fetch('/api/auth/logout', { method: 'POST' });
const result = await response.json();
if (result.success) {
// Clear user data dari store
authStore.setUser(null);
// Clear localStorage
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_kodeId');
// Force reload untuk reset semua state
window.location.href = '/login';
} else {
console.error('Logout failed:', result.message);
// Tetap redirect meskipun gagal
authStore.setUser(null);
window.location.href = '/login';
}
} catch (error) {
console.error('Error during logout:', error);
// Tetap clear store dan redirect jika error
authStore.setUser(null);
window.location.href = '/login';
} finally {
setIsLoggingOut(false);
}
};
return ( return (
<AppShell <AppShell
suppressHydrationWarning suppressHydrationWarning
@@ -115,13 +224,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,14 +240,14 @@ 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"
variant="gradient" variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }} gradient={{ from: colors["blue-button"], to: "#228be6" }}
loading={isLoggingOut}
disabled={isLoggingOut}
> >
<IconLogout2 size={22} /> <IconLogout2 size={22} />
</ActionIcon> </ActionIcon>
@@ -156,7 +265,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 (
@@ -254,4 +363,4 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</AppShellMain> </AppShellMain>
</AppShell> </AppShell>
); );
} }

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();
// }
// }

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,65 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ // src/app/api/auth/_lib/sessionCreate.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,
invalidatePrevious = true, // 🔑 kontrol apakah sesi lama di-nonaktifkan
}: { }: {
sessionKey: string; sessionKey: string;
exp?: string; exp?: string;
encodedKey: string; jwtSecret: string;
user: Record<string, unknown>; user: Record<string, unknown> & { id: string };
invalidatePrevious?: boolean; // default true untuk login, false untuk registrasi
}) { }) {
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
let expiresAt = add(new Date(), { days: 30 });
if (exp === "7 day") expiresAt = add(new Date(), { days: 7 });
// 🔐 Hanya nonaktifkan sesi aktif sebelumnya jika diminta (misal: saat login ulang)
if (invalidatePrevious) {
await prisma.userSession.updateMany({
where: { userId: user.id, active: true },
data: { active: false },
});
}
// ✅ Simpan sesi baru
await prisma.userSession.create({
data: {
token,
userId: user.id,
active: true,
expiresAt,
},
}); });
const cookie: any = { // ✅ Set cookie
key: sessionKey, (await cookies()).set(sessionKey, token, {
value: token, httpOnly: true,
options: { sameSite: "lax",
httpOnly: true, path: "/",
sameSite: "lax", secure: process.env.NODE_ENV === "production",
path: "/", maxAge: 30 * 24 * 60 * 60, // 30 hari dalam detik
}, });
};
(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,39 @@
// 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;
}
// Don't check isActive here, let the frontend handle it
return dbSession.user;
} catch (error) {
console.warn('Session verification failed:', error);
return null;
}
}

View File

@@ -0,0 +1,92 @@
// 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 }
);
}
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 }
);
}
if (await prisma.user.findFirst({ where: { username } })) {
return NextResponse.json(
{ success: false, message: "Username sudah digunakan" },
{ status: 409 }
);
}
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 }
);
}
const newUser = await prisma.user.create({
data: {
username,
nomor,
roleId: defaultRole.id,
isActive: false,
},
});
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { 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,
roleId: newUser.roleId,
isActive: false,
},
invalidatePrevious: false,
});
// ✅ REDIRECT DARI SERVER — cookie pasti tersedia
const response = NextResponse.redirect(new URL('/waiting-room', req.url));
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

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { verifySession } from '../_lib/session_verify';
import { sessionCreate } from '../_lib/session_create';
import prisma from '@/lib/prisma';
export async function POST() {
try {
const sessionUser = await verifySession();
if (!sessionUser) {
return NextResponse.json(
{ success: false, message: "Unauthorized" },
{ status: 401 }
);
}
// Get fresh user data
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: {
id: true,
username: true,
roleId: true,
isActive: true,
},
});
if (!user) {
return NextResponse.json(
{ success: false, message: "User not found" },
{ status: 404 }
);
}
// Create new session with updated data
await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
user: {
id: user.id,
username: user.username,
roleId: user.roleId,
isActive: user.isActive,
},
invalidatePrevious: false, // Keep existing sessions
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error refreshing session:', error);
return NextResponse.json(
{ success: false, message: "Internal server error" },
{ status: 500 }
);
}
}

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

@@ -41,7 +41,7 @@ const state = useProxy(indeksKepuasanState.responden);
indeksKepuasanState.jenisKelaminResponden.findMany.load() indeksKepuasanState.jenisKelaminResponden.findMany.load()
indeksKepuasanState.pilihanRatingResponden.findMany.load() indeksKepuasanState.pilihanRatingResponden.findMany.load()
indeksKepuasanState.kelompokUmurResponden.findMany.load() indeksKepuasanState.kelompokUmurResponden.findMany.load()
}) },[])
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {

View File

@@ -41,7 +41,7 @@ function Kepuasan() {
indeksKepuasanState.jenisKelaminResponden.findMany.load() indeksKepuasanState.jenisKelaminResponden.findMany.load()
indeksKepuasanState.pilihanRatingResponden.findMany.load() indeksKepuasanState.pilihanRatingResponden.findMany.load()
indeksKepuasanState.kelompokUmurResponden.findMany.load() indeksKepuasanState.kelompokUmurResponden.findMany.load()
}) },[])
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {

View File

@@ -59,6 +59,35 @@ const getWorkStatus = (day: string, currentTime: string): { status: string; mess
: { status: "Tutup", message: "08:00 - 17:00" }; : { status: "Tutup", message: "08:00 - 17:00" };
}; };
// Skeleton component untuk Social Media
const SosmedSkeleton = () => (
<Flex gap="md" justify="center" wrap="wrap">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} height={56} width={56} circle />
))}
</Flex>
);
// Skeleton component untuk Profile
const ProfileSkeleton = () => (
<Card
radius="xl"
bg={colors.grey[1]}
p="lg"
shadow="xl"
w={{ base: "100%", md: "35%" }}
style={{ height: "fit-content" }}
>
<Stack gap="lg" align="center">
<Skeleton height={300} width="100%" radius="lg" />
<Stack gap="xs" w="100%" align="center">
<Skeleton height={20} width="60%" />
<Skeleton height={32} width="80%" />
</Stack>
</Stack>
</Card>
);
function LandingPage() { function LandingPage() {
const [socialMedia, setSocialMedia] = useState< const [socialMedia, setSocialMedia] = useState<
Prisma.MediaSosialGetPayload<{ include: { image: true } }>[] Prisma.MediaSosialGetPayload<{ include: { image: true } }>[]
@@ -66,9 +95,8 @@ function LandingPage() {
const [profile, setProfile] = useState< const [profile, setProfile] = useState<
Prisma.PejabatDesaGetPayload<{ include: { image: true } }> | null Prisma.PejabatDesaGetPayload<{ include: { image: true } }> | null
>(null); >(null);
const [isLoading, setIsLoading] = useState(true); const [isLoadingSosmed, setIsLoadingSosmed] = useState(true);
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
useEffect(() => { useEffect(() => {
const fetchSocialMedia = async () => { const fetchSocialMedia = async () => {
@@ -86,7 +114,7 @@ function LandingPage() {
} catch { } catch {
setSocialMedia([]); setSocialMedia([]);
} finally { } finally {
setIsLoading(false); setIsLoadingSosmed(false);
} }
}; };
@@ -98,6 +126,8 @@ function LandingPage() {
setProfile(result.data || null); setProfile(result.data || null);
} catch { } catch {
setProfile(null); setProfile(null);
} finally {
setIsLoadingProfile(false);
} }
}; };
@@ -189,8 +219,8 @@ function LandingPage() {
<ModuleView /> <ModuleView />
{isLoading ? ( {isLoadingSosmed ? (
<Skeleton height={32} width="100%" /> <SosmedSkeleton />
) : socialMedia.length > 0 ? ( ) : socialMedia.length > 0 ? (
<SosmedView data={socialMedia} /> <SosmedView data={socialMedia} />
) : ( ) : (
@@ -207,19 +237,27 @@ function LandingPage() {
</Card> </Card>
</Stack> </Stack>
{isLoading ? ( {isLoadingProfile ? (
<Skeleton height={300} width="100%" radius="lg" /> <ProfileSkeleton />
) : profile ? ( ) : profile ? (
<ProfileView data={profile} /> <ProfileView data={profile} />
) : ( ) : (
<Center w="100%"> <Card
<Text c="dimmed">Informasi profil belum tersedia</Text> radius="xl"
</Center> bg={colors.grey[1]}
p="lg"
shadow="xl"
w={{ base: "100%", md: "35%" }}
style={{ height: "fit-content" }}
>
<Center h={300}>
<Text c="dimmed">Informasi profil belum tersedia</Text>
</Center>
</Card>
)} )}
</Flex> </Flex>
</Stack> </Stack>
); );
} }
export default LandingPage; export default LandingPage;

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi"; import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi";
import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan"; import Kepuasan from "@/app/darmasaba/_com/main-page/kepuasan";
@@ -13,32 +14,34 @@ import Apbdes from "./_com/main-page/apbdes";
import Prestasi from "./_com/main-page/prestasi"; import Prestasi from "./_com/main-page/prestasi";
import ScrollToTopButton from "./_com/scrollToTopButton"; import ScrollToTopButton from "./_com/scrollToTopButton";
import NewsReaderLanding from "./_com/NewsReaderalanding"; import { useEffect, useMemo } from "react";
import ModernNewsNotification from "./_com/ModernNeewsNotification"; import { useSnapshot } from "valtio";
import { useMemo } from "react";
import { useProxy } from "valtio/utils";
import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita"; import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita";
import stateDesaPengumuman from "../admin/(dashboard)/_state/desa/pengumuman"; import stateDesaPengumuman from "../admin/(dashboard)/_state/desa/pengumuman";
import { useEffect } from "react"; import ModernNewsNotification from "./_com/ModernNeewsNotification";
import NewsReaderLanding from "./_com/NewsReaderalanding";
export default function Page() { export default function Page() {
const featured = useProxy(stateDashboardBerita.berita.findFirst); const snap1 = useSnapshot(stateDashboardBerita.berita.findFirst);
const snap2 = useSnapshot(stateDesaPengumuman.pengumuman.findFirst);
const featured = snap1;
const pengumuman = snap2;
const loadingFeatured = featured.loading; const loadingFeatured = featured.loading;
const pengumuman = useProxy(stateDesaPengumuman.pengumuman.findFirst);
const loadingPengumuman = pengumuman.loading; const loadingPengumuman = pengumuman.loading;
useEffect(() => { useEffect(() => {
if (!featured.data && !loadingFeatured) { if (!featured.data && !loadingFeatured) {
stateDashboardBerita.berita.findFirst.load(); stateDashboardBerita.berita.findFirst.load();
} }
}, [featured.data, loadingFeatured]); }, []);
useEffect(() => { useEffect(() => {
if (!pengumuman.data && !loadingPengumuman) { if (!pengumuman.data && !loadingPengumuman) {
stateDesaPengumuman.pengumuman.findFirst.load(); stateDesaPengumuman.pengumuman.findFirst.load();
} }
}, [pengumuman.data, loadingPengumuman]); }, []);
const newsData = useMemo(() => { const newsData = useMemo(() => {

View File

@@ -0,0 +1,193 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import {
Button,
Center,
Loader,
Paper,
Stack,
Text,
Title,
} from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { authStore } from '@/store/authStore'; // ✅ integrasi authStore
async function fetchUser() {
const res = await fetch('/api/auth/me');
if (!res.ok) {
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);
const [retryCount, setRetryCount] = useState(0);
const MAX_RETRIES = 2;
useEffect(() => {
let isMounted = true;
let interval: ReturnType<typeof setInterval>;
const poll = async () => {
if (isRedirecting || !isMounted) return;
try {
const data = await fetchUser();
if (!isMounted) return;
const currentUser = data.user;
setUser(currentUser);
// ✅ Update authStore
if (currentUser) {
authStore.setUser({
id: currentUser.id,
name: currentUser.name,
roleId: Number(currentUser.roleId),
menuIds: currentUser.menuIds || null,
});
}
// In the poll function
if (currentUser?.isActive === true) {
setIsRedirecting(true);
clearInterval(interval);
// Update authStore with the current user data
authStore.setUser({
id: currentUser.id,
name: currentUser.name || 'User',
roleId: Number(currentUser.roleId),
menuIds: currentUser.menuIds || null,
isActive: true
});
// Clean up storage
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username');
// Force a session refresh
try {
const res = await fetch('/api/auth/refresh-session', {
method: 'POST',
credentials: 'include'
});
if (res.ok) {
// Redirect based on role
let redirectPath = '/admin';
switch (String(currentUser.roleId)) {
case "0": case "1": case "2":
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case "3":
redirectPath = '/admin/kesehatan/posyandu';
break;
case "4":
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
break;
}
window.location.href = redirectPath; // Use window.location to force full page reload
}
} catch (error) {
console.error('Error refreshing session:', error);
router.refresh(); // Fallback to client-side refresh
}
}
} catch (err: any) {
if (!isMounted) return;
if (err.message.includes('401')) {
if (retryCount < MAX_RETRIES) {
setRetryCount((prev) => prev + 1);
setTimeout(() => {
if (isMounted) interval = setInterval(poll, 3000);
}, 800);
} else {
setError('Sesi tidak valid. Silakan login ulang.');
clearInterval(interval);
authStore.setUser(null); // ✅ clear sesi
}
} else {
console.error('Error polling:', err);
}
}
};
interval = setInterval(poll, 3000);
return () => {
isMounted = false;
if (interval) clearInterval(interval);
};
}, [router, isRedirecting, retryCount]);
// ✅ UI Error
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">
Sesi Tidak Valid
</Title>
<Text>{error}</Text>
<Button onClick={() => router.push('/login')}>
Login Ulang
</Button>
</Stack>
</Paper>
</Center>
);
}
// ✅ UI Redirecting
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>
);
}
// ✅ UI Default (MENUNGGU) — INI YANG KAMU HILANGKAN!
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*'],
}; };

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

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