Compare commits

..

22 Commits

Author SHA1 Message Date
dcf195f54f Tambahan filter data sesuai tahun, di landing page apbdes 2025-12-01 17:11:24 +08:00
c03a6b3aed Tambah Term of Service di Registrasi 2025-12-01 14:01:03 +08:00
1bb9f239db Tambah Term of Service di Registrasi 2025-12-01 13:50:25 +08:00
a213ff7d37 Tambah Term of Service di Registrasi 2025-12-01 12:10:22 +08:00
0018bdc251 Fix Ganti Role, ganti role menunya sudah menyesuaikan 2025-11-28 15:03:18 +08:00
83fb39a957 Fix Ganti Role, ganti role menunya sudah menyesuaikan 2025-11-28 15:00:09 +08:00
7238692dd0 Push WebDesaDarmasabaSatging 2025-11-28 13:56:40 +08:00
8b50139d79 Push Staging 2025-11-28 12:03:07 +08:00
066180fc0e Fix registrasi, waitong-room, & tampilan layout sesuai id 2025-11-28 11:13:20 +08:00
67f29aabef Balik ke awal 2025-11-27 18:53:33 +08:00
dbf7c34228 Fix eror registrasi 2 2025-11-27 17:08:17 +08:00
036fc86fed Fix eror registrasi 1 2025-11-27 16:45:47 +08:00
2cecec733e Tambah cookies di bagian verifikasi, agar kedeteksi user sudah regis apa belom 2025-11-27 14:46:49 +08:00
c64a2e5457 Fix Seeder User, dan role 2025-11-27 12:18:15 +08:00
757911d7dd Fix Seeder 2025-11-26 15:32:49 +08:00
54232e4465 Menambahkan seed user
Fix Infinite reload di page ikm dan landing page
2025-11-26 15:01:34 +08:00
29a9a59bca saat tampilan user sudah diubah dan login ulan sudah menyesuaikan untuk menunya 2025-11-26 11:01:23 +08:00
2fb3666e57 User yang sudah registrasi sudah langsung diarahkan ke layout sesuai dengan roleIdnya
Superadmin sudah bisa menambah atau mengurangkan menu pad user yang diinginkan
Next-------------------------------
Ada bug saat tampilan menu sudah di edit superamin berhasil namun saat user logout tampilan menunya balik ke sebelumnya
2025-11-26 10:14:05 +08:00
e30b27f7a4 Fix Search 2025-11-25 17:30:41 +08:00
e941ed3893 Sudah fix menunya, superadmin bisa memilihkan menu untuk user 2025-11-25 16:21:15 +08:00
ace5aff1b6 Fix Kondisi Verify Otp Registrasi dan Login
Next mau fix eror saat user sudah terdaftar tetapi di redirect ke login, seharusnya redirect sesuai roleIdnya
2025-11-25 15:03:27 +08:00
716db0adca Fix Middleware
Fix Layout sesuai role, dan superadmin bisa menambahkan menu ke user jika diperlukan
Penambahan menu di user & role : menu access
2025-11-24 16:02:13 +08:00
56 changed files with 3898 additions and 1545 deletions

BIN
bun.lockb

Binary file not shown.

View File

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

View File

@@ -1,24 +1,30 @@
[
{
"id": "0",
"name": "DEVELOPER",
"description": "Developer",
"isActive": true
},
{
"id": "1",
"name": "SUPER ADMIN",
"description": "Administrator",
"isActive": true
},
{
"id": "1",
"id": "2",
"name": "ADMIN DESA",
"description": "Administrator Desa",
"isActive": true
},
{
"id": "2",
"id": "3",
"name": "ADMIN KESEHATAN",
"description": "Administrator Bidang Kesehatan",
"isActive": true
},
{
"id": "3",
"id": "4",
"name": "ADMIN PENDIDIKAN",
"description": "Administrator Bidang Pendidikan",
"isActive": true

View File

@@ -0,0 +1,10 @@
[
{
"id": "cmie1o0zh0002vn132vtzg7hh",
"username": "SuperAdmin-Nico",
"nomor": "6289647037426",
"roleId": 0,
"isActive": true,
"sessionInvalid": false
}
]

View File

@@ -2163,24 +2163,28 @@ enum StatusPeminjaman {
// ========================================= USER ========================================= //
model User {
id String @id @default(cuid())
username String @unique
nomor String @unique
role Role @relation(fields: [roleId], references: [id])
roleId String @default("1")
instansi String?
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
isActive Boolean @default(true)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
id String @id @default(cuid())
username String
nomor String @unique
roleId String @default("2")
isActive Boolean @default(false)
sessionInvalid Boolean @default(false)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
permissions Json?
sessions UserSession[] // ✅ Relasi one-to-many
role Role @relation(fields: [roleId], references: [id])
menuAccesses UserMenuAccess[]
@@map("users")
}
model Role {
id String @id @default(cuid())
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
description String?
permissions Json?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2200,14 +2204,31 @@ model KodeOtp {
}
model UserSession {
id String @id @default(cuid())
token String
expires DateTime?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
User User @relation(fields: [userId], references: [id])
userId String @unique
id String @id @default(cuid())
token String @db.Text // ✅ JWT bisa panjang
expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
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 ========================================= //

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import prisma from "@/lib/prisma";
import profilePejabatDesa from "./data/landing-page/profile/profile.json";
@@ -57,46 +58,107 @@ import roles from "./data/user/roles.json";
import fileStorage from "./data/file-storage.json";
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
import seedAssets from "./seed_assets";
import users from "./data/user/users.json";
import { safeSeedUnique } from "./safeseedUnique";
(async () => {
// =========== ROLE ===========
// In your seed.ts
// =========== ROLES ===========
console.log("🔄 Seeding roles...");
for (const r of roles) {
await safeSeedUnique("role", { id: r.id }, {
name: r.name,
description: r.description,
isActive: r.isActive,
});
}
console.log("✅ Roles seeded");
for (const r of roles) {
try {
// ✅ Destructure to remove permissions if exists
const { permissions, ...roleData } = r as any;
await safeSeedUnique(
"role",
{ name: roleData.name },
{
id: roleData.id,
name: roleData.name,
description: roleData.description,
permissions: roleData.permissions || {}, // ✅ Include permissions
isActive: roleData.isActive,
}
);
console.log(`✅ Seeded role -> ${roleData.name}`);
} catch (error: any) {
if (error.code === "P2002") {
console.warn(`⚠️ Role already exists (skipping): ${r.name}`);
} else {
console.error(`❌ Failed to seed role ${r.name}:`, error.message);
}
}
}
console.log("✅ Roles seeding completed");
// =========== USER ===========
console.log("🔄 Seeding users...");
for (const u of users) {
try {
// Verify role exists first
const roleExists = await prisma.role.findUnique({
where: { id: u.roleId.toString() },
select: { id: true }, // Only select id to minimize query
});
if (!roleExists) {
console.error(
`❌ Role with id ${u.roleId} not found for user ${u.username}`
);
continue;
}
await safeSeedUnique(
"user",
{ id: u.id },
{
username: u.username,
nomor: u.nomor,
roleId: u.roleId.toString(),
isActive: u.isActive,
sessionInvalid: false,
}
);
console.log(`✅ Seeded user -> ${u.username}`);
} catch (error: any) {
if (error.code === "P2003") {
console.error(
`❌ Foreign key constraint failed for user ${u.username}: Role ${u.roleId} does not exist`
);
} else {
console.error(`❌ Failed to seed user ${u.username}:`, error.message);
}
}
}
console.log("✅ Users seeding completed");
// =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage...");
for (const f of fileStorage) {
await prisma.fileStorage.upsert({
where: { id: f.id },
update: {
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
create: {
id: f.id,
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
});
try {
await prisma.fileStorage.upsert({
where: { id: f.id },
update: {
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
create: {
id: f.id,
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
});
} catch (error: any) {
console.error(`❌ Failed to seed file storage ${f.name}:`, error.message);
}
}
console.log("✅ File storage seeded");
// =========== LANDING PAGE ===========
@@ -515,15 +577,40 @@ import { safeSeedUnique } from "./safeseedUnique";
console.log("posisi organisasi berhasil");
// =========== PEGAWAI PPID ===========
console.log("🔄 Seeding pegawai PPID...");
const flattenedPegawai = pegawaiPPID.flat();
// Check for duplicate emails
const emails = new Set();
for (const p of flattenedPegawai) {
await prisma.pegawaiPPID.upsert({
where: { id: p.id },
update: p,
create: p,
});
if (emails.has(p.email)) {
console.warn(`⚠️ Duplicate email found in pegawaiPPID: ${p.email}`);
}
emails.add(p.email);
}
console.log("pegawai berhasil");
for (const p of flattenedPegawai) {
try {
await prisma.pegawaiPPID.upsert({
where: { id: p.id },
update: p,
create: p,
});
console.log(`✅ Seeded pegawai PPID -> ${p.namaLengkap}`);
} catch (error: any) {
if (error.code === "P2002") {
console.warn(
`⚠️ Pegawai PPID with duplicate email (skipping): ${p.email}`
);
} else {
console.error(
`❌ Failed to seed pegawai PPID ${p.namaLengkap}:`,
error.message
);
}
}
}
console.log("✅ pegawai PPID seeding completed");
// =========== SUBMENU VISI MISI PPID ===========
@@ -787,7 +874,9 @@ import { safeSeedUnique } from "./safeseedUnique";
const flattenedPosisiBumdes = posisiOrganisasi.flat();
// ✅ 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) {
console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`);
@@ -867,7 +956,7 @@ import { safeSeedUnique } from "./safeseedUnique";
// Add IDs to the kategoriKegiatan data
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
...k,
id: `kategori-${index + 1}`
id: `kategori-${index + 1}`,
}));
for (const k of kategoriKegiatan) {
@@ -1159,7 +1248,6 @@ import { safeSeedUnique } from "./safeseedUnique";
// seed assets
await seedAssets();
})()
.then(() => prisma.$disconnect())
.catch((e) => {

View File

@@ -118,7 +118,7 @@ const userState = proxy({
console.error("Gagal delete user:", error);
toast.error("Terjadi kesalahan saat menghapus user");
} finally {
userState.delete.loading = false;
userState.deleteUser.loading = false;
}
},
},

View File

@@ -15,7 +15,9 @@ function Login() {
// Login.tsx
async function onLogin() {
const cleanPhone = phone.replace(/\D/g, '');
console.log(cleanPhone);
if (cleanPhone.length < 10) {
toast.error('Nomor telepon tidak valid');
return;
@@ -25,6 +27,8 @@ function Login() {
setLoading(true);
const response = await apiFetchLogin({ nomor: cleanPhone });
console.log(response);
if (!response.success) {
toast.error(response.message || 'Gagal memproses login');
return;
@@ -32,11 +36,12 @@ function Login() {
// Simpan nomor untuk register
localStorage.setItem('auth_nomor', cleanPhone);
if (response.isRegistered) {
// ✅ User lama: simpan kodeId & ke validasi
// ✅ User lama: simpan kodeId
localStorage.setItem('auth_kodeId', response.kodeId);
router.push('/validasi');
// ✅ Cookie sudah di-set oleh API, langsung redirect
router.push('/validasi'); // Clean URL
} else {
// ❌ User baru: langsung ke registrasi (tanpa kodeId)
router.push('/registrasi');

View File

@@ -18,6 +18,7 @@ export default function Registrasi() {
const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false);
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
const [agree, setAgree] = useState(false)
// Ambil data dari localStorage (dari login)
useEffect(() => {
@@ -46,6 +47,11 @@ export default function Registrasi() {
return;
}
if (!agree) {
toast.error("Anda harus menyetujui syarat dan ketentuan!");
return;
}
try {
setLoading(true);
// ✅ Hanya kirim username & nomor → dapat kodeId
@@ -92,8 +98,8 @@ export default function Registrasi() {
username.length > 0 && username.length < 5
? 'Minimal 5 karakter!'
: username.includes(' ')
? 'Tidak boleh ada spasi'
: ''
? 'Tidak boleh ada spasi'
: ''
}
required
/>
@@ -108,9 +114,29 @@ export default function Registrasi() {
</Box>
<Box pt="md">
<Checkbox label="Saya menyetujui syarat dan ketentuan" defaultChecked />
<Checkbox
checked={agree}
onChange={(e) => setAgree(e.currentTarget.checked)}
label={
<Text fz="sm">
Saya menyetujui{" "}
<a
href="/terms-of-service"
target="_blank"
style={{
color: colors["blue-button"],
textDecoration: "underline",
fontWeight: 500,
}}
>
syarat dan ketentuan
</a>
</Text>
}
/>
</Box>
<Box pt="xl">
<Button
fullWidth

View File

@@ -1,10 +1,17 @@
//
'use client';
import { apiFetchOtpData, apiFetchVerifyOtp } from '@/app/api/auth/_lib/api_fetch_auth';
import colors from '@/con/colors';
import { Box, Button, Loader, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
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';
@@ -12,13 +19,36 @@ 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);
// ✅ Deteksi flow dari cookie via API
useEffect(() => {
const checkFlow = async () => {
try {
const res = await fetch('/api/auth/get-flow', {
credentials: 'include'
});
const data = await res.json();
if (data.success) {
setIsRegistrationFlow(data.flow === 'register');
console.log('🔍 Flow detected from cookie:', data.flow);
}
} catch (error) {
console.error('❌ Error getting flow:', error);
setIsRegistrationFlow(false);
}
};
checkFlow();
}, []);
// Inisialisasi data OTP
useEffect(() => {
const storedKodeId = localStorage.getItem('auth_kodeId');
if (!storedKodeId) {
@@ -28,11 +58,12 @@ export default function Validasi() {
}
setKodeId(storedKodeId);
const loadOtpData = async () => {
try {
const result = await apiFetchOtpData({ kodeId: storedKodeId });
if (result.success && result.data?.nomor) {
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');
@@ -45,82 +76,19 @@ export default function Validasi() {
setIsLoading(false);
}
};
loadOtpData();
}, [router]);
// Verifikasi OTP
const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return;
setLoading(true);
try {
const verifyResult = await apiFetchVerifyOtp({ nomor, otp, kodeId });
if (!verifyResult.success) {
// Registrasi baru?
if (
verifyResult.status === 404 &&
verifyResult.message?.includes('Akun tidak ditemukan')
) {
await handleNewRegistration();
return;
}
// Error lain
toast.error(verifyResult.message || 'Verifikasi gagal');
return;
if (isRegistrationFlow) {
await handleRegistrationVerification();
} else {
await handleLoginVerification();
}
// ✅ Verifikasi sukses → simpan user ke store
const user = verifyResult.user;
console.log('=== DEBUG USER ===');
console.log('Full user object:', user);
if (!user || !user.id) {
toast.error('Data pengguna tidak lengkap');
return;
}
const roleId = Number(user.roleId);
authStore.setUser({
id: user.id,
name: user.name || user.username || 'User',
roleId: roleId,
});
cleanupStorage();
const isUserActive = user.isActive ?? user.is_active ?? true;
// Redirect berdasarkan status approval
if (!isUserActive) {
router.replace('/waiting-room');
return;
}
// ✅ Switch statement lebih clean
let redirectPath: string;
switch (roleId) {
case 0:
case 1:
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case 2:
redirectPath = '/admin/kesehatan/posyandu';
break;
case 3:
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
break;
default:
redirectPath = '/admin';
console.warn('Unknown roleId:', roleId);
}
console.log('Redirecting to:', redirectPath);
router.replace(redirectPath);
} catch (error) {
console.error('Error saat verifikasi:', error);
toast.error('Terjadi kesalahan sistem');
@@ -129,50 +97,129 @@ export default function Validasi() {
}
};
// Registrasi baru
const handleNewRegistration = async () => {
const handleRegistrationVerification = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan');
toast.error('Data registrasi tidak ditemukan.');
return;
}
try {
const res = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, otp, kodeId }),
});
const cleanNomor = nomor?.replace(/\D/g, '') ?? '';
if (cleanNomor.length < 10 || username.trim().length < 5) {
toast.error('Data tidak valid');
return;
}
const data = await res.json();
// ✅ Verify OTP
const verifyRes = await fetch('/api/auth/verify-otp-register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
credentials: 'include'
});
if (data.success) {
// Set user sementara (tanpa roleId, akan diisi saat approve)
authStore.setUser({
id: 'pending',
name: username,
});
cleanupStorage();
router.replace('/waiting-room');
} else {
toast.error(data.message || 'Registrasi gagal');
}
} catch (error) {
console.error('Error registrasi:', error);
toast.error('Gagal menyelesaikan registrasi');
const verifyData = await verifyRes.json();
if (!verifyRes.ok) {
toast.error(verifyData.message || 'Verifikasi OTP gagal');
return;
}
// ✅ Finalize registration
const finalizeRes = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, username, kodeId }),
credentials: 'include'
});
const data = await finalizeRes.json();
// ✅ Check JSON response (bukan redirect)
if (data.success) {
toast.success('Registrasi berhasil! Menunggu persetujuan admin.');
await cleanupStorage();
// ✅ Client-side redirect
setTimeout(() => {
window.location.href = '/waiting-room';
}, 1000);
} else {
toast.error(data.message || 'Registrasi gagal');
}
};
const cleanupStorage = () => {
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 }),
credentials: 'include'
});
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),
});
// ✅ Cleanup setelah login sukses
await 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:
case 1:
case 2:
return '/admin/landing-page/profil/program-inovasi';
case 3:
return '/admin/kesehatan/posyandu';
case 4:
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:
return '/admin';
}
};
// ✅ CLEANUP FUNCTION - Hapus localStorage + Cookie
const cleanupStorage = async () => {
// Clear localStorage
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username');
// Clear cookie
try {
await fetch('/api/auth/clear-flow', {
method: 'POST',
credentials: 'include'
});
} catch (error) {
console.error('Error clearing flow cookie:', error);
}
};
const handleResend = async () => {
if (!nomor) return;
try {
const res = await fetch('/api/auth/resend-otp', {
const res = await fetch('/api/auth/resend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }),
@@ -189,7 +236,6 @@ export default function Validasi() {
}
};
// Loading
if (isLoading) {
return (
<Stack pos="relative" bg={colors.Bg} align="center" justify="center" h="100vh">
@@ -208,7 +254,7 @@ export default function Validasi() {
<Stack align="center" gap="lg">
<Box>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
Kode Verifikasi
{isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'}
</Title>
<Text ta="center" size="sm" c="dimmed" mt="xs">
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
@@ -219,14 +265,16 @@ export default function Validasi() {
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
Masukkan Kode Verifikasi
</Text>
<PinInput
length={4}
value={otp}
onChange={setOtp}
onComplete={handleVerify}
inputMode="numeric"
size="lg"
/>
<Center>
<PinInput
length={4}
value={otp}
onChange={setOtp}
onComplete={handleVerify}
inputMode="numeric"
size="lg"
/>
</Center>
</Box>
<Button

View File

@@ -361,6 +361,7 @@ function CreateAPBDes() {
data={[
{ value: 'pendapatan', label: 'Pendapatan' },
{ value: 'belanja', label: 'Belanja' },
{ value: 'pembiayaan', label: 'Pembiayaan' },
]}
value={newItem.level === 1 ? null : newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: val as any })}

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

View File

@@ -0,0 +1,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

@@ -8,6 +8,7 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import user from '../../_state/user/user-state';
import { authStore } from '@/store/authStore';
function User() {
const [search, setSearch] = useState("");
@@ -92,10 +93,21 @@ function ListUser({ search }: { search: string }) {
const success = await stateUser.update.submit({
id: userId,
roleId: newRoleId,
});
if (success) {
// Reload data setelah berhasil update
// ✅ 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);
}
@@ -110,11 +122,25 @@ function ListUser({ search }: { search: string }) {
});
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 || [];
const filteredData = (data || []).filter((item) => {
return item.roleId !== "0" && item.roleId !== "1";
});
if (loading || !data) {
return (
@@ -158,10 +184,12 @@ function ListUser({ search }: { search: string }) {
<TableTd style={{ width: '20%' }}>
<Select
placeholder="Pilih role"
data={stateRole.findMany.data.map((r) => ({
label: r.name,
value: r.id,
}))}
data={stateRole.findMany.data
.filter(r => r.id !== "0" && r.id !== "1") // ❌ Sembunyikan SUPERADMIN dan DEVELOPER
.map(r => ({
label: r.name,
value: r.id,
}))}
value={item.roleId}
onChange={(val) => {
if (!val) return;

View File

@@ -1,3 +1,407 @@
export const devBar = [
{
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 navBar = [
{
id: "Landing Page",
@@ -392,6 +796,11 @@ export const navBar = [
id: "Role",
name: "Role",
path: "/admin/user&role/role"
},
{
id: "Menu Access",
name: "Menu Access",
path: "/admin/user&role/menu-access"
}
]
}
@@ -785,4 +1194,4 @@ export const role3 = [
}
]
}
]
]

View File

@@ -1,7 +1,7 @@
// /* eslint-disable react-hooks/exhaustive-deps */
// 'use client'
// import colors from "@/con/colors";
// import { authStore } from "@/store/authStore";
// import {
// ActionIcon,
// AppShell,
@@ -9,9 +9,11 @@
// AppShellMain,
// AppShellNavbar,
// Burger,
// Center,
// Flex,
// Group,
// Image,
// Loader,
// NavLink,
// ScrollArea,
// Text,
@@ -27,14 +29,146 @@
// import _ from "lodash";
// import Link from "next/link";
// import { useRouter, useSelectedLayoutSegments } from "next/navigation";
// import { navBar } from "./_com/list_PageAdmin";
// import { useEffect, useState } from "react";
// // import { useSnapshot } from "valtio";
// import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
// export default function Layout({ children }: { children: React.ReactNode }) {
// const [opened, { toggle }] = useDisclosure();
// const [loading, setLoading] = useState(true);
// const [isLoggingOut, setIsLoggingOut] = useState(false);
// const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
// const router = useRouter();
// const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// // const { user } = useSnapshot(authStore);
// // console.log("Current user in store:", user);
// // ✅ 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 NOT active → redirect to waiting room
// if (!data.user.isActive) {
// authStore.setUser(null);
// router.replace('/waiting-room');
// return;
// }
// // ✅ Fetch menuIds
// 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
// authStore.setUser({
// id: data.user.id,
// name: data.user.name,
// roleId: Number(data.user.roleId),
// menuIds,
// isActive: data.user.isActive
// });
// // ✅ TAMBAHKAN INI: Redirect ke dashboard sesuai roleId
// const currentPath = window.location.pathname;
// const expectedPath = getRedirectPath(Number(data.user.roleId));
// // Jika user di halaman /admin tapi bukan di path yang sesuai roleId
// if (currentPath === '/admin' || !currentPath.startsWith(expectedPath)) {
// router.replace(expectedPath);
// }
// } 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]);
// // ✅ Fungsi helper untuk get redirect path
// 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';
// }
// };
// 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 (
// <AppShell
// suppressHydrationWarning
@@ -116,13 +250,13 @@
// variant="gradient"
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
// >
// <Image
// src="/assets/images/darmasaba-icon.png"
// alt="Logo Darmasaba"
// w={20}
// h={20}
// radius="md"
// loading="lazy"
// <Image
// src="/assets/images/darmasaba-icon.png"
// alt="Logo Darmasaba"
// w={20}
// h={20}
// radius="md"
// loading="lazy"
// style={{
// minWidth: '20px',
// height: 'auto',
@@ -132,14 +266,14 @@
// </Tooltip>
// <Tooltip label="Keluar" position="bottom" withArrow>
// <ActionIcon
// onClick={() => {
// router.push("/darmasaba");
// }}
// onClick={handleLogout}
// color={colors["blue-button"]}
// radius="xl"
// size="lg"
// variant="gradient"
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
// loading={isLoggingOut}
// disabled={isLoggingOut}
// >
// <IconLogout2 size={22} />
// </ActionIcon>
@@ -157,7 +291,7 @@
// p={{ base: 'xs', sm: 'sm' }}
// >
// <AppShell.Section p="sm">
// {navBar.map((v, k) => {
// {currentNav.map((v, k) => {
// const isParentActive = segments.includes(_.lowerCase(v.name));
// return (
@@ -258,6 +392,8 @@
// }
// app/admin/layout.tsx
'use client'
import colors from "@/con/colors";
@@ -290,38 +426,62 @@ import _ from "lodash";
import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { useEffect, useState } from "react";
import { navigationByRole } from "./_com/navigationByRole";
import { useSnapshot } from "valtio";
import { toast } from "react-toastify";
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
export default function Layout({ children }: { children: React.ReactNode }) {
const [opened, { toggle }] = useDisclosure();
const [loading, setLoading] = useState(true);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
const { user } = useSnapshot(authStore);
console.log("Current user in store:", user);
// ✅ Fetch user dari backend jika belum ada di store
useEffect(() => {
if (user) {
setLoading(false);
return;
} // Sudah ada → jangan fetch
const fetchUser = async () => {
try {
const res = await fetch('/api/auth/me');
const res = await fetch('/api/auth/me', {
credentials: 'include' // ✅ ADD credentials
});
const data = await res.json();
if (data.user) {
// ✅ Check if user is NOT active → redirect to waiting room
if (!data.user.isActive) {
authStore.setUser(null);
router.replace('/waiting-room');
return;
}
// ✅ Fetch menuIds
const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`, {
credentials: 'include' // ✅ ADD credentials
});
const menuData = await menuRes.json();
const menuIds = menuData.success && Array.isArray(menuData.menuIds)
? [...menuData.menuIds]
: null;
// ✅ Set user dengan menuIds yang fresh
authStore.setUser({
id: data.user.id,
name: data.user.name,
roleId: Number(data.user.roleId),
menuIds,
isActive: data.user.isActive
});
// ✅ IMPROVED: Redirect ONLY if di root /admin
const currentPath = window.location.pathname;
if (currentPath === '/admin') {
const expectedPath = getRedirectPath(Number(data.user.roleId));
console.log('🔄 Redirecting from /admin to:', expectedPath);
router.replace(expectedPath);
}
// ✅ Jangan redirect jika user sudah di path yang valid
} else {
authStore.setUser(null);
router.replace('/login');
@@ -336,95 +496,23 @@ export default function Layout({ children }: { children: React.ReactNode }) {
};
fetchUser();
}, [user, router]); // ✅ Sekarang 'user' terdefinisi
}, [router]); // ✅ Only depend on router
// ✅ Polling untuk cek perubahan role setiap 30 detik
// Di layout.tsx - useEffect polling
useEffect(() => {
if (!user?.id) return;
const checkRoleUpdate = async () => {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
// ✅ Session tidak valid (sudah dihapus karena role berubah)
if (!data.success || !data.user) {
console.log('⚠️ Session tidak valid, logout...');
authStore.setUser(null);
// Clear cookie manual (backup)
document.cookie = `${process.env.NEXT_PUBLIC_SESSION_KEY}=; Max-Age=0; path=/;`;
toast.info('Role Anda telah diubah. Silakan login kembali.', {
autoClose: 5000,
});
router.push('/login');
return;
}
// Cek perubahan roleId (seharusnya tidak sampai sini jika session dihapus)
const currentRoleId = Number(data.user.roleId);
if (currentRoleId !== user.roleId) {
console.log('🔄 Role berubah! Dari', user.roleId, 'ke', currentRoleId);
// Update store
authStore.setUser({
id: data.user.id,
name: data.user.name || data.user.username,
roleId: currentRoleId,
});
// Redirect ke halaman default role baru
let redirectPath = '/admin';
switch (currentRoleId) {
case 0:
case 1:
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case 2:
redirectPath = '/admin/kesehatan/posyandu';
break;
case 3:
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
break;
}
toast.info('Role Anda telah diubah. Mengalihkan halaman...', {
autoClose: 5000,
});
router.push(redirectPath);
// Reload untuk clear semua state
setTimeout(() => {
window.location.reload();
}, 500);
}
} catch (error) {
console.error('❌ Error checking role update:', error);
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';
}
};
// ✅ Polling setiap 10 detik (lebih responsif dari 30 detik)
const interval = setInterval(checkRoleUpdate, 10000);
// Juga cek saat window focus (user kembali ke tab)
const handleFocus = () => {
checkRoleUpdate();
};
window.addEventListener('focus', handleFocus);
return () => {
clearInterval(interval);
window.removeEventListener('focus', handleFocus);
};
}, [user, router]);
if (loading) {
return (
<AppShell>
@@ -437,15 +525,38 @@ useEffect(() => {
);
}
// ✅ Ambil menu berdasarkan roleId
const currentNav = user?.roleId !== undefined
? (navigationByRole[user.roleId as keyof typeof navigationByRole] || [])
const currentNav = authStore.user
? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
: [];
const handleLogout = () => {
authStore.setUser(null);
document.cookie = `${process.env.BASE_SESSION_KEY}=; Max-Age=0; path=/;`;
router.push('/login');
const handleLogout = async () => {
try {
setIsLoggingOut(true);
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include' // ✅ ADD credentials
});
const result = await response.json();
if (result.success) {
authStore.setUser(null);
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_username');
window.location.href = '/login';
} else {
console.error('Logout failed:', result.message);
authStore.setUser(null);
window.location.href = '/login';
}
} catch (error) {
console.error('Error during logout:', error);
authStore.setUser(null);
window.location.href = '/login';
} finally {
setIsLoggingOut(false);
}
};
return (
@@ -462,6 +573,7 @@ useEffect(() => {
}}
padding="md"
>
{/* ... rest of your JSX (Header, Navbar, Main) sama seperti sebelumnya ... */}
<AppShellHeader
style={{
background: "linear-gradient(90deg, #ffffff, #f9fbff)",
@@ -480,16 +592,9 @@ useEffect(() => {
h={{ base: 32, sm: 40 }}
radius="md"
loading="lazy"
style={{
minWidth: '32px',
height: 'auto',
}}
style={{ minWidth: '32px', height: 'auto' }}
/>
<Text
fw={700}
c={colors["blue-button"]}
fz={{ base: 'md', sm: 'xl' }}
>
<Text fw={700} c={colors["blue-button"]} fz={{ base: 'md', sm: 'xl' }}>
Admin Darmasaba
</Text>
</Flex>
@@ -497,61 +602,22 @@ useEffect(() => {
<Group gap="xs">
{!desktopOpened && (
<Tooltip label="Buka Navigasi" position="bottom" withArrow>
<ActionIcon
variant="light"
radius="xl"
size="lg"
onClick={toggleDesktop}
color={colors["blue-button"]}
>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
<IconChevronRight />
</ActionIcon>
</Tooltip>
)}
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="md"
color={colors["blue-button"]}
mr="xs"
/>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={colors["blue-button"]} mr="xs" />
<Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
<ActionIcon
onClick={() => {
router.push("/darmasaba");
}}
color={colors["blue-button"]}
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }}
>
<Image
src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba"
w={20}
h={20}
radius="md"
loading="lazy"
style={{
minWidth: '20px',
height: 'auto',
}}
/>
<ActionIcon onClick={() => router.push("/darmasaba")} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }}>
<Image src="/assets/images/darmasaba-icon.png" alt="Logo Darmasaba" w={20} h={20} radius="md" loading="lazy" style={{ minWidth: '20px', height: 'auto' }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon
onClick={handleLogout}
color={colors["blue-button"]}
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }}
>
<ActionIcon onClick={handleLogout} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }} loading={isLoggingOut} disabled={isLoggingOut}>
<IconLogout2 size={22} />
</ActionIcon>
</Tooltip>
@@ -559,75 +625,17 @@ useEffect(() => {
</Group>
</AppShellHeader>
<AppShellNavbar
component={ScrollArea}
style={{
background: "#ffffff",
borderRight: `1px solid ${colors["blue-button"]}20`,
}}
p={{ base: 'xs', sm: 'sm' }}
>
<AppShellNavbar component={ScrollArea} style={{ background: "#ffffff", borderRight: `1px solid ${colors["blue-button"]}20` }} p={{ base: 'xs', sm: 'sm' }}>
{/* ... Navbar content sama seperti sebelumnya ... */}
<AppShell.Section p="sm">
{currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name));
return (
<NavLink
key={k}
defaultOpened={isParentActive}
c={isParentActive ? colors["blue-button"] : "gray"}
label={
<Text fw={isParentActive ? 600 : 400} fz="sm">
{v.name}
</Text>
}
style={{
borderRadius: rem(10),
marginBottom: rem(4),
transition: "background 150ms ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'rgba(25, 113, 194, 0.05)',
},
},
}}
variant="light"
active={isParentActive}
>
<NavLink key={k} defaultOpened={isParentActive} c={isParentActive ? colors["blue-button"] : "gray"} label={<Text fw={isParentActive ? 600 : 400} fz="sm">{v.name}</Text>} style={{ borderRadius: rem(10), marginBottom: rem(4), transition: "background 150ms ease" }} styles={{ root: { '&:hover': { backgroundColor: 'rgba(25, 113, 194, 0.05)' } } }} variant="light" active={isParentActive}>
{v.children.map((child, key) => {
const isChildActive = segments.includes(
_.lowerCase(child.name)
);
const isChildActive = segments.includes(_.lowerCase(child.name));
return (
<NavLink
key={key}
href={child.path}
c={isChildActive ? colors["blue-button"] : "gray"}
label={
<Text fw={isChildActive ? 600 : 400} fz="sm">
{child.name}
</Text>
}
styles={{
root: {
borderRadius: rem(8),
marginBottom: rem(2),
transition: 'background 150ms ease',
padding: '6px 12px',
'&:hover': {
backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)',
},
...(isChildActive && {
backgroundColor: 'rgba(25, 113, 194, 0.1)',
}),
},
}}
active={isChildActive}
component={Link}
/>
<NavLink key={key} href={child.path} c={isChildActive ? colors["blue-button"] : "gray"} label={<Text fw={isChildActive ? 600 : 400} fz="sm">{child.name}</Text>} styles={{ root: { borderRadius: rem(8), marginBottom: rem(2), transition: 'background 150ms ease', padding: '6px 12px', '&:hover': { backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)' }, ...(isChildActive && { backgroundColor: 'rgba(25, 113, 194, 0.1)' }) } }} active={isChildActive} component={Link} />
);
})}
</NavLink>
@@ -637,18 +645,8 @@ useEffect(() => {
<AppShell.Section py="md">
<Group justify="end" pr="sm">
<Tooltip
label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"}
position="top"
withArrow
>
<ActionIcon
variant="light"
radius="xl"
size="lg"
onClick={toggleDesktop}
color={colors["blue-button"]}
>
<Tooltip label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"} position="top" withArrow>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
<IconChevronLeft />
</ActionIcon>
</Tooltip>
@@ -656,15 +654,9 @@ useEffect(() => {
</AppShell.Section>
</AppShellNavbar>
<AppShellMain
style={{
background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)",
minHeight: "100vh",
}}
>
<AppShellMain style={{ background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)", minHeight: "100vh" }}>
{children}
</AppShellMain>
</AppShell>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
// /api/user/delete.ts
// /api/user/delUser.ts
import prisma from '@/lib/prisma';
import { Context } from 'elysia';
export default async function userDelete(context: Context) {
export default async function userDeleteAccount(context: Context) {
const { id } = context.params as { id: string };
try {
// Cek user dulu
// 1. Cek user dulu
const existingUser = await prisma.user.findUnique({
where: { id },
});
@@ -18,15 +18,39 @@ export default async function userDelete(context: Context) {
};
}
// Hard delete (hapus permanen)
const deletedUser = await prisma.user.delete({
where: { id },
// ✅ 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',
data: deletedUser,
message: `User berhasil dihapus permanen (${result.sessionsDeleted} session, ${result.menuAccessDeleted} menu access)`,
data: result,
};
} catch (error) {
console.error('Error delete user:', error);
@@ -35,4 +59,4 @@ export default async function userDelete(context: Context) {
message: 'Terjadi kesalahan saat menghapus user',
};
}
}
}

View File

@@ -5,6 +5,7 @@ import userFindMany from "./findMany";
import userFindUnique from "./findUnique";
import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsung, jadi biasanya `del.ts`
import userUpdate from "./updt";
import userDeleteAccount from "./delUser";
const User = new Elysia({ prefix: "/api/user" })
.get("/findMany", userFindMany)
@@ -25,7 +26,7 @@ const User = new Elysia({ prefix: "/api/user" })
})
}
)
.put("/delUser/:id", userDelete, {
.delete("/delUser/:id", userDeleteAccount, {
params: t.Object({
id: t.String(),
}),

View File

@@ -2,53 +2,43 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
// API update user
export default async function userUpdate(context: Context) {
try {
const { id, isActive, roleId } = await context.body as {
id: string,
isActive?: boolean,
roleId?: string
const { id, isActive, roleId } = (await context.body) as {
id: string;
isActive?: boolean;
roleId?: string;
};
if (!id) {
return {
success: false,
message: "ID user wajib ada",
};
return { success: false, message: "ID user wajib ada" };
}
// Optional: cek apakah roleId valid
// Validasi role
if (roleId) {
const cekRole = await prisma.role.findUnique({
where: { id: roleId }
});
if (!cekRole) {
return {
success: false,
message: "Role tidak ditemukan",
};
}
const role = await prisma.role.findUnique({ where: { id: roleId } });
if (!role) return { success: false, message: "Role tidak ditemukan" };
}
// ✅ CEK: Apakah roleId berubah?
let isRoleChanged = false;
let oldRoleId: string | null = null;
if (roleId) {
const currentUser = await prisma.user.findUnique({
where: { id },
select: {
roleId: true,
username: true,
}
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;
// ✅ Jika role berubah, hapus semua akses menu yang ada
if (isRoleChanged) {
await prisma.userMenuAccess.deleteMany({
where: { userId: id }
});
if (currentUser && currentUser.roleId !== roleId) {
isRoleChanged = true;
oldRoleId = currentUser.roleId;
console.log(`🔄 Role berubah untuk ${currentUser.username}: ${oldRoleId}${roleId}`);
}
}
// Update user
@@ -57,6 +47,8 @@ export default async function userUpdate(context: Context) {
data: {
...(isActive !== undefined && { isActive }),
...(roleId && { roleId }),
// Force logout: invalidate semua sesi
...(isRoleChanged ? { sessionInvalid: true } : {}),
},
select: {
id: true,
@@ -64,48 +56,29 @@ export default async function userUpdate(context: Context) {
nomor: true,
isActive: true,
roleId: true,
updatedAt: true,
role: {
select: {
id: true,
name: true,
}
}
}
role: { select: { name: true } },
},
});
// ✅ FORCE LOGOUT: Hapus UserSession jika role berubah
// ✅ HAPUS SEMUA SESI USER DI DATABASE
if (isRoleChanged) {
try {
const deletedSessions = await prisma.userSession.deleteMany({
where: { userId: id }
});
console.log(`🔒 Force logout user ${updatedUser.username} (${id})`);
console.log(` Deleted ${deletedSessions.count} session(s)`);
console.log(` Role: ${oldRoleId}${roleId}`);
} catch (sessionError: any) {
// Jika UserSession tidak ditemukan (user belum pernah login), skip error
if (sessionError.code !== 'P2025') {
console.error("⚠️ Error menghapus session:", sessionError);
} else {
console.log(` User ${updatedUser.username} belum pernah login`);
}
}
await prisma.userSession.deleteMany({ where: { userId: id } });
}
// ✅ Response dengan info tambahan
return {
success: true,
message: isRoleChanged
? `User berhasil diupdate. ${updatedUser.username} akan logout otomatis.`
: "User berhasil diupdate",
roleChanged: isRoleChanged,
isActiveChanged,
data: updatedUser,
roleChanged: isRoleChanged, // Info untuk frontend
oldRoleId: oldRoleId,
newRoleId: roleId,
message: isRoleChanged
? `Role ${updatedUser.username} diubah. User akan logout otomatis.`
: isActiveChanged
? `${updatedUser.username} ${
isActive ? "diaktifkan" : "dinonaktifkan"
}.`
: "User berhasil diupdate",
};
} catch (e: any) {
console.error("❌ Error update user:", e);
return {
@@ -113,4 +86,4 @@ export default async function userUpdate(context: Context) {
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

@@ -14,6 +14,7 @@ export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomor: cleanPhone }),
credentials: 'include'
});
// Pastikan respons bisa di-parse sebagai JSON
@@ -58,6 +59,7 @@ export const apiFetchRegister = async ({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }),
credentials: 'include',
});
const data = await response.json();
@@ -86,35 +88,57 @@ export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
return data;
};
export const apiFetchVerifyOtp = async ({
nomor,
otp,
kodeId
}: {
nomor: string;
otp: string;
kodeId: string;
}) => {
if (!nomor || !otp || !kodeId) {
throw new Error('Data verifikasi tidak lengkap');
}
if (!/^\d{4,6}$/.test(otp)) {
throw new Error('Kode OTP harus 4-6 digit angka');
}
const response = await fetch('/api/auth/verify-otp', {
// 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();
// ✅ Jangan throw error untuk status 4xx — biarkan frontend handle
return {
success: response.ok,
...data,
status: response.status,
};
};
};
// Di dalam api_fetch_auth.ts
export async function apiFetchUserMenuAccess(userId: string): Promise<{
success: boolean;
menuIds?: string[];
message?: string;
}> {
try {
const res = await fetch(`/api/admin/user-menu-access/${userId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
return data;
} catch (error) {
console.error('API Fetch User Menu Access Error:', error);
return { success: false, message: 'Gagal memuat menu akses' };
}
}
export async function apiUpdateUserMenuAccess(
userId: string,
menuIds: string[]
): Promise<{ success: boolean; message?: string }> {
try {
const res = await fetch('/api/admin/user-menu-access', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, menuIds }),
});
const data = await res.json();
return data;
} catch (error) {
console.error('API Update User Menu Access Error:', error);
return { success: false, message: 'Gagal menyimpan menu akses' };
}
}

View File

@@ -1,85 +1,59 @@
// app/api/auth/_lib/session_create.ts
// src/app/api/auth/_lib/sessionCreate.ts
import { cookies } from "next/headers";
import { encrypt } from "./encrypt";
import prisma from "@/lib/prisma";
import { add } from "date-fns";
export async function sessionCreate({
sessionKey,
exp = "30 day",
jwtSecret,
user,
invalidatePrevious = true, // 🔑 kontrol apakah sesi lama di-nonaktifkan
}: {
sessionKey: string;
exp?: string;
jwtSecret: string;
user: Record<string, unknown>;
user: Record<string, unknown> & { id: string };
invalidatePrevious?: boolean; // default true untuk login, false untuk registrasi
}) {
// 🔒 Validasi kunci tidak kosong
// Validasi env vars
if (!sessionKey || sessionKey.length === 0) {
throw new Error("sessionKey tidak boleh kosong");
}
if (!jwtSecret || jwtSecret.length === 0) {
throw new Error("jwtSecret tidak boleh kosong");
if (!jwtSecret || jwtSecret.length < 32) {
throw new Error("jwtSecret minimal 32 karakter");
}
const token = await encrypt({
exp,
jwtSecret,
user,
});
if (token === null) {
const token = await encrypt({ exp, jwtSecret, user });
if (!token) {
throw new Error("Token generation failed");
}
// ✅ HYBRID: Simpan token ke database UserSession
const userId = user.id as string;
if (userId) {
try {
// Hapus session lama user ini (logout device lain)
await prisma.userSession.deleteMany({
where: { userId },
});
// ✅ Hitung expiresAt
let expiresAt = add(new Date(), { days: 30 });
if (exp === "7 day") expiresAt = add(new Date(), { days: 7 });
// Parse expiration
const expiresDate = new Date();
const expMatch = exp.match(/(\d+)\s*(day|year)/);
if (expMatch) {
const [, num, unit] = expMatch;
const amount = parseInt(num);
if (unit === 'year') {
expiresDate.setFullYear(expiresDate.getFullYear() + amount);
} else if (unit === 'day') {
expiresDate.setDate(expiresDate.getDate() + amount);
}
} else {
// Default 30 hari
expiresDate.setDate(expiresDate.getDate() + 30);
}
// Buat session baru di database
await prisma.userSession.create({
data: {
userId,
token, // JWT token disimpan
expires: expiresDate,
active: true,
},
});
console.log(`✅ Session created for user ${userId}`);
} catch (dbError) {
console.error("⚠️ Error menyimpan session ke database:", dbError);
// Tetap lanjut meski gagal simpan ke DB (fallback ke JWT only)
}
// 🔐 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 },
});
}
// Set cookie
const cookieStore = await cookies();
cookieStore.set(sessionKey, token, {
// ✅ Simpan sesi baru
await prisma.userSession.create({
data: {
token,
userId: user.id,
active: true,
expiresAt,
},
});
// ✅ Set cookie
(await cookies()).set(sessionKey, token, {
httpOnly: true,
sameSite: "lax",
path: "/",

View File

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

View File

@@ -0,0 +1,19 @@
// app/api/auth/clear-flow/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST() {
try {
// ✅ Next.js 15 syntax
const cookieStore = await cookies();
cookieStore.delete('auth_flow');
return NextResponse.json({ success: true });
} catch (error) {
console.error('❌ Error clearing flow cookie:', error);
return NextResponse.json(
{ success: false, message: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,97 +1,146 @@
// app/api/auth/finalize-registration/route.ts
// src/app/api/auth/finalize-registration/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { sessionCreate } from "../_lib/session_create";
// ✅ Gunakan STRING untuk roleId
const DEFAULT_MENUS_BY_ROLE: Record<string, string[]> = {
"0": [
"Landing Page", "PPID", "Desa", "Kesehatan", "Keamanan",
"Ekonomi", "Inovasi", "Lingkungan", "Pendidikan", "User & Role"
],
"1": [
"Landing Page", "PPID", "Desa", "Keamanan",
"Ekonomi", "Inovasi", "Lingkungan", "User & Role"
],
"2": ["Landing Page", "Desa", "Ekonomi", "Inovasi", "Lingkungan"],
"3": ["Kesehatan"],
"4": ["Pendidikan"],
};
export async function POST(req: Request) {
try {
const { nomor, username, kodeId, roleId } = await req.json();
const { nomor, username, kodeId } = await req.json();
const cleanNomor = nomor.replace(/\D/g, "");
// Validasi input
if (!nomor || !username || !kodeId) {
if (!cleanNomor || !username || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
// Verifikasi OTP
const otpRecord = await prisma.kodeOtp.findUnique({
// Verify OTP
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
select: { nomor: true, isActive: true }
});
if (!otpRecord?.isActive || otpRecord.nomor !== nomor) {
if (!otpRecord?.isActive || otpRecord.nomor !== cleanNomor) {
return NextResponse.json(
{ success: false, message: "OTP tidak valid" },
{ status: 400 }
);
}
// Cek apakah username sudah dipakai
const existingUser = await prisma.user.findUnique({
where: { username },
});
if (existingUser) {
// Check duplicate username
if (await prisma.user.findFirst({ where: { username } })) {
return NextResponse.json(
{ success: false, message: "Username sudah digunakan" },
{ status: 409 }
);
}
// Check duplicate nomor
if (await prisma.user.findUnique({ where: { nomor: cleanNomor } })) {
return NextResponse.json(
{ success: false, message: "Nomor sudah terdaftar" },
{ status: 409 }
);
}
// 🔥 Tentukan roleId sebagai STRING
const targetRoleId = "2"; // ✅ Default ADMIN_DESA (roleId "2")
// Validasi role exists
const roleExists = await prisma.role.findUnique({
where: { id: targetRoleId },
select: { id: true }
});
if (!roleExists) {
return NextResponse.json(
{ success: false, message: "Role tidak valid" },
{ status: 400 }
);
}
// Buat user baru
// ✅ Create user (inactive, waiting approval)
const newUser = await prisma.user.create({
data: {
username,
nomor,
roleId: roleId || "1", // Default role
isActive: false, // Menunggu approval
nomor: cleanNomor,
roleId: targetRoleId,
isActive: false, // Waiting for admin approval
},
});
// Nonaktifkan OTP
// ✅ Berikan akses menu default based on role
const menuIds = DEFAULT_MENUS_BY_ROLE[targetRoleId] || [];
if (menuIds.length > 0) {
await prisma.userMenuAccess.createMany({
data: menuIds.map(menuId => ({
userId: newUser.id,
menuId,
})),
skipDuplicates: true, // ✅ Avoid duplicate errors
});
}
// ✅ Mark OTP as used
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
// ✅ CREATE SESSION (JWT + Database)
try {
await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: newUser.id,
nomor: newUser.nomor,
username: newUser.username,
roleId: newUser.roleId,
isActive: false, // User baru belum aktif
},
});
} catch (sessionError) {
console.error("❌ Error creating session:", sessionError);
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: "Registrasi berhasil. Menunggu persetujuan admin.",
// ✅ Create session token
const token = await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: newUser.id,
name: newUser.username,
nomor: newUser.nomor,
username: newUser.username,
roleId: newUser.roleId,
isActive: false,
isActive: false, // User belum aktif
},
invalidatePrevious: false,
});
// ✅ PENTING: Return JSON response (bukan redirect)
const response = NextResponse.json({
success: true,
message: "Registrasi berhasil. Menunggu persetujuan admin.",
userId: newUser.id,
});
// ✅ Set session cookie
const cookieName = process.env.BASE_SESSION_KEY || 'session';
response.cookies.set(cookieName, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: 'lax',
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return response;
} catch (error) {
console.error("❌ Finalize Registration Error:", error);
return NextResponse.json(
{ success: false, message: "Registrasi gagal" },
{ success: false, message: "Registrasi gagal. Silakan coba lagi." },
{ status: 500 }
);
} finally {

View File

@@ -0,0 +1,22 @@
// app/api/auth/get-flow/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function GET() {
try {
// ✅ Next.js 15 syntax
const cookieStore = await cookies();
const flow = cookieStore.get('auth_flow')?.value || 'login';
return NextResponse.json({
success: true,
flow
});
} catch (error) {
console.error('❌ Error getting flow cookie:', error);
return NextResponse.json(
{ success: false, flow: 'login' },
{ status: 500 }
);
}
}

View File

@@ -2,6 +2,7 @@
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
import { cookies } from "next/headers";
export async function POST(req: Request) {
if (req.method !== "POST") {
@@ -29,19 +30,37 @@ export async function POST(req: Request) {
const isRegistered = !!existingUser;
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)}`;
// ✅ PERBAIKAN: Gunakan format pesan yang lebih sederhana
// Hapus karakter khusus yang bisa bikin masalah
const waMessage = `Website Desa Darmasaba\nKode verifikasi Anda ${codeOtp}`;
// // ✅ OPSI 1: Tanpa encoding (coba dulu ini)
// const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${waMessage}`;
// ✅ OPSI 2: Dengan encoding (kalau opsi 1 gagal)
const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${encodeURIComponent(waMessage)}`;
// ✅ OPSI 3: Encoding manual untuk URL-safe (alternatif terakhir)
// const encodedMessage = waMessage.replace(/\n/g, '%0A').replace(/ /g, '%20');
// const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${encodedMessage}`;
console.log("🔍 Debug WA URL:", waUrl); // Untuk debugging
const res = await fetch(waUrl);
const sendWa = await res.json();
console.log("📱 WA Response:", sendWa); // Debug response
if (sendWa.status !== "success") {
return NextResponse.json(
{ success: false, message: "Gagal mengirim OTP via WhatsApp" },
{
success: false,
message: "Gagal mengirim OTP via WhatsApp",
debug: sendWa // Tampilkan error detail
},
{ status: 400 }
);
}
@@ -50,6 +69,15 @@ export async function POST(req: Request) {
data: { nomor, otp: otpNumber, isActive: true },
});
const cookieStore = await cookies();
cookieStore.set('auth_flow', 'login', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 5, // 5 menit
path: '/'
});
return NextResponse.json({
success: true,
message: "Kode verifikasi dikirim",
@@ -57,16 +85,14 @@ export async function POST(req: Request) {
isRegistered: true,
});
} else {
// ❌ User belum terdaftar → JANGAN kirim OTP
return NextResponse.json({
success: true,
message: "Nomor belum terdaftar",
isRegistered: false,
// Tidak ada kodeId
});
}
} catch (error) {
console.error("Error Login:", error);
console.error("Error Login:", error);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat login" },
{ status: 500 }

View File

@@ -1,44 +1,59 @@
// app/api/auth/me/route.ts
// 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 {
// ✅ Verify session (hybrid: JWT + Database)
const user = await verifySession();
if (!user) {
const sessionUser = await verifySession();
if (!sessionUser) {
return NextResponse.json(
{
success: false,
message: "Session tidak valid",
user: null
},
{ 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 }
);
}
// Data user sudah fresh dari database (via verifySession)
return NextResponse.json({
success: true,
user: {
id: user.id,
name: user.username,
username: user.username,
nomor: user.nomor,
roleId: user.roleId,
isActive: user.isActive,
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: "Terjadi kesalahan",
user: null
},
{ success: false, message: "Internal server error", user: null },
{ status: 500 }
);
}

View File

@@ -2,9 +2,10 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function POST(req: Request) {
export async function GET(request: Request) {
try {
const { kodeId } = await req.json();
const { searchParams } = new URL(request.url);
const kodeId = searchParams.get("kodeId");
if (!kodeId) {
return NextResponse.json(
@@ -15,7 +16,7 @@ export async function POST(req: Request) {
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
select: { id: true, nomor: true, isActive: true, createdAt: true },
select: { nomor: true, isActive: true },
});
if (!otpRecord || !otpRecord.isActive) {
@@ -27,12 +28,12 @@ export async function POST(req: Request) {
return NextResponse.json({
success: true,
data: otpRecord,
data: { nomor: otpRecord.nomor },
});
} catch (error) {
console.error("Error fetching OTP data:", error);
console.error("❌ Gagal mengambil data OTP:", error);
return NextResponse.json(
{ success: false, message: "Gagal mengambil data OTP" },
{ success: false, message: "Terjadi kesalahan internal" },
{ status: 500 }
);
} finally {

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,6 +1,7 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import prisma from '@/lib/prisma';
import { randomOTP } from '../_lib/randomOTP'; // pastikan ada
import { randomOTP } from '../_lib/randomOTP';
export async function POST(req: Request) {
try {
@@ -14,7 +15,7 @@ export async function POST(req: Request) {
if (await prisma.user.findUnique({ where: { nomor } })) {
return NextResponse.json({ success: false, message: 'Nomor sudah terdaftar' }, { status: 409 });
}
if (await prisma.user.findUnique({ where: { username } })) {
if (await prisma.user.findFirst({ where: { username } })) {
return NextResponse.json({ success: false, message: 'Username sudah digunakan' }, { status: 409 });
}
@@ -36,7 +37,17 @@ export async function POST(req: Request) {
data: { nomor, otp: otpNumber, isActive: true }
});
// ✅ Kembalikan kodeId (jangan buat user di sini!)
// ✅ Set cookie flow=register (Next.js 15+ syntax)
const cookieStore = await cookies();
cookieStore.set('auth_flow', 'register', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 5, // 5 menit
path: '/'
});
// ✅ Kembalikan kodeId
return NextResponse.json({
success: true,
message: 'Kode verifikasi dikirim',

View File

@@ -1,71 +1,58 @@
// src/app/api/auth/resend-otp/route.ts
import prisma from "@/lib/prisma";
import { randomOTP } from "../_lib/randomOTP";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
export async function POST(req: Request) {
if (req.method !== "POST") {
return NextResponse.json(
{ success: false, message: "Method Not Allowed" },
{ status: 405 }
);
}
try {
const codeOtp = randomOTP();
const body = await req.json();
const { nomor } = body;
const { nomor } = await req.json();
const res = await fetch(
`https://wa.wibudev.com/code?nom=${nomor}&text=HIPMI - Kode ini bersifat RAHASIA dan JANGAN DI BAGIKAN KEPADA SIAPAPUN, termasuk anggota ataupun pengurus HIPMI lainnya.
\n
>> Kode OTP anda: ${codeOtp}.
`
);
const sendWa = await res.json();
if (sendWa.status !== "success")
if (!nomor || typeof nomor !== 'string') {
return NextResponse.json(
{
success: false,
message: "Nomor Whatsapp Tidak Aktif",
},
{ success: false, message: "Nomor tidak valid" },
{ status: 400 }
);
}
const createOtpId = await prisma.kodeOtp.create({
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: nomor,
otp: codeOtp,
nomor,
otp: otpNumber,
isActive: true,
},
});
if (!createOtpId)
return NextResponse.json(
{
success: false,
message: "Gagal Membuat Kode OTP",
},
{ status: 400 }
);
return NextResponse.json({
success: true,
message: "OTP baru dikirim",
kodeId: otpRecord.id,
});
return NextResponse.json(
{
success: true,
message: "Kode Verifikasi Dikirim",
kodeId: createOtpId.id,
},
{ status: 200 }
);
} catch (error) {
console.error(" Error Resend OTP", error);
console.error("Error Resend OTP:", error);
return NextResponse.json(
{
success: false,
message: "Server Whatsapp Error !!",
},
{ success: false, message: "Gagal mengirim ulang OTP" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}
}

View File

@@ -14,7 +14,7 @@ export async function POST(req: Request) {
if (await prisma.user.findUnique({ where: { nomor } })) {
return NextResponse.json({ success: false, message: 'Nomor sudah terdaftar' }, { status: 409 });
}
if (await prisma.user.findUnique({ where: { username } })) {
if (await prisma.user.findFirst({ where: { username } })) {
return NextResponse.json({ success: false, message: 'Username sudah digunakan' }, { status: 409 });
}

View File

@@ -0,0 +1,34 @@
// app/api/auth/set-flow/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST(request: Request) {
try {
const { flow } = await request.json();
if (!flow || !['login', 'register'].includes(flow)) {
return NextResponse.json(
{ success: false, message: 'Invalid flow parameter' },
{ status: 400 }
);
}
// ✅ Next.js 15 syntax
const cookieStore = await cookies();
cookieStore.set('auth_flow', flow, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 5,
path: '/'
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('❌ Error setting flow cookie:', error);
return NextResponse.json(
{ success: false, message: 'Internal server error' },
{ status: 500 }
);
}
}

View File

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

View File

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

View File

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

View File

@@ -151,7 +151,7 @@ function Page() {
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/darmasaba/ppid/daftar-informasi-publik-desa-darmasaba/${item.id}`)}
onClick={() => router.push(`/darmasaba/ppid/daftar-informasi-publik/${item.id}`)}
>
Detail
</Button>

View File

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

View File

@@ -1,13 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesProgress.tsx
'use client';
import { Box, Paper, Progress, Stack, Text, Title } from '@mantine/core';
import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors';
import { Box, Paper, Progress, Stack, Text, Title } from '@mantine/core';
import { APBDesData } from './types';
function formatRupiah(value: number) {
return new Intl.NumberFormat('id-ID', {
@@ -17,31 +12,33 @@ function formatRupiah(value: number) {
}).format(value);
}
function APBDesProgress() {
const state = useProxy(apbdes);
const data = state.findMany.data || [];
interface APBDesProgressProps {
apbdesData: APBDesData;
}
// Ambil APBDes pertama (misalnya, jika hanya satu tahun ditampilkan)
const apbdesItem = data[0]; // 👈 sesuaikan logika jika ada banyak APBDes
if (!apbdesItem) {
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
</Box>
);
function APBDesProgress({ apbdesData }: APBDesProgressProps) {
// Return null if apbdesData is not available yet
if (!apbdesData) {
return null;
}
const items = apbdesItem.items || [];
const items = apbdesData.items || [];
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// Kelompokkan berdasarkan tipe
const pendapatanItems = sortedItems.filter(item => item.tipe === 'pendapatan');
const belanjaItems = sortedItems.filter(item => item.tipe === 'belanja');
const pembiayaanItems = sortedItems.filter(item => item.tipe === 'pembiayaan'); // jika ada
const pembiayaanItems = sortedItems.filter(item => item.tipe === 'pembiayaan');
// Items without a type (should be filtered out from calculations)
const untypedItems = sortedItems.filter(item => !item.tipe);
if (untypedItems.length > 0) {
console.warn(`Found ${untypedItems.length} items without a type. These will be excluded from calculations.`);
}
// Hitung total per kategori
const calcTotal = (items: any[]) => {
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => {
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0);
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
@@ -50,10 +47,10 @@ function APBDesProgress() {
const pendapatan = calcTotal(pendapatanItems);
const belanja = calcTotal(belanjaItems);
const pembiayaan = calcTotal(pembiayaanItems); // bisa kosong
const pembiayaan = calcTotal(pembiayaanItems);
// Render satu progress bar
const renderProgress = (label: string, dataset: any) => {
const renderProgress = (label: string, dataset: { realisasi: number; anggaran: number; persen: number }) => {
const isPembiayaan = label.includes('Pembiayaan');
return (
@@ -71,8 +68,8 @@ function APBDesProgress() {
root: { backgroundColor: '#d7e3f1' },
section: {
backgroundColor: isPembiayaan
? 'green' // warna hijau untuk pembiayaan
: colors['blue-button'], // biru untuk pendapatan/belanja
? 'green'
: colors['blue-button'],
position: 'relative',
'&::after': {
content: `'${dataset.persen.toFixed(2)}%'`,
@@ -102,7 +99,7 @@ function APBDesProgress() {
>
<Stack gap="lg">
<Title order={4} c={colors['blue-button']} ta="center">
Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
Grafik Pelaksanaan APBDes Tahun {apbdesData.tahun}
</Title>
<Text ta="center" fw="bold" fz="sm" c="dimmed">
@@ -112,97 +109,9 @@ function APBDesProgress() {
{renderProgress('Pendapatan Desa', pendapatan)}
{renderProgress('Belanja Desa', belanja)}
{renderProgress('Pembiayaan Desa', pembiayaan)}
{pembiayaanItems.length > 0 && renderProgress('Pembiayaan Desa', pembiayaan)}
</Stack>
</Paper>
);
}
export default APBDesProgress;
// /* eslint-disable @typescript-eslint/no-explicit-any */
// 'use client';
// import { Box, Paper, Stack, Text, Title } from '@mantine/core';
// import { BarChart } from '@mantine/charts';
// import { useProxy } from 'valtio/utils';
// import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
// import colors from '@/con/colors';
// function APBDesProgress() {
// const state = useProxy(apbdes);
// const data = state.findMany.data || [];
// const apbdesItem = data[0];
// if (!apbdesItem) {
// return (
// <Box py="md" px={{ base: 'md', md: 100 }}>
// <Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
// </Box>
// );
// }
// const items = apbdesItem.items || [];
// const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// const pendapatanItems = sortedItems.filter(i => i.tipe === 'pendapatan');
// const belanjaItems = sortedItems.filter(i => i.tipe === 'belanja');
// const pembiayaanItems = sortedItems.filter(i => i.tipe === 'pembiayaan');
// const total = (rows: any[]) => {
// const anggaran = rows.reduce((s, i) => s + i.anggaran, 0);
// const realisasi = rows.reduce((s, i) => s + i.realisasi, 0);
// return anggaran === 0 ? 0 : (realisasi / anggaran) * 100;
// };
// const chartData = [
// { name: 'Pendapatan', persen: total(pendapatanItems) },
// { name: 'Belanja', persen: total(belanjaItems) },
// ];
// if (pembiayaanItems.length > 0) {
// chartData.push({ name: 'Pembiayaan', persen: total(pembiayaanItems) });
// }
// return (
// <Paper
// mx={{ base: 'md', md: 100 }}
// p="xl"
// radius="md"
// shadow="sm"
// withBorder
// bg={colors['white-1']}
// >
// <Stack gap="lg">
// <Title order={4} c={colors['blue-button']} ta="center">
// Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
// </Title>
// <Text ta="center" fw="bold" fz="sm" c="dimmed">
// Persentase Realisasi (%) dari Anggaran
// </Text>
// <BarChart
// h={200}
// data={chartData}
// orientation="vertical"
// dataKey="name"
// barProps={{ radius: 6 }}
// series={[
// {
// name: 'persen',
// label: 'Persentase',
// color: colors['blue-button'],
// },
// ]}
// yAxisProps={{
// domain: [0, 100],
// }}
// valueFormatter={(v) => `${v.toFixed(1)}%`}
// />
// </Stack>
// </Paper>
// );
// }
// export default APBDesProgress;
export default APBDesProgress;

View File

@@ -1,30 +1,8 @@
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesTable.tsx
'use client';
import { Box, Paper, Table, Text, Title, Badge, Group } from '@mantine/core';
import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors';
interface APBDesItem {
id: string;
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
}
interface APBDesData {
id: string;
tahun: number;
items: APBDesItem[];
image?: { id: string; url: string } | null;
file?: { id: string; url: string } | null;
}
import { APBDesData } from './types';
// Helper: Format Rupiah, tapi jika 0 → tampilkan '-'
function formatRupiahOrEmpty(value: number): string {
@@ -51,22 +29,12 @@ function getIndent(level: number) {
};
}
function APBDesTable() {
const state = useProxy(apbdes);
const data = state.findMany.data || [];
interface APBDesTableProps {
apbdesData: APBDesData;
}
// Get the first APBDes item
const apbdesItem = data[0] as unknown as APBDesData | undefined;
if (!apbdesItem) {
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
</Box>
);
}
const items = Array.isArray(apbdesItem.items) ? apbdesItem.items : [];
function APBDesTable({ apbdesData }: APBDesTableProps) {
const items = Array.isArray(apbdesData.items) ? apbdesData.items : [];
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// Calculate totals
@@ -76,13 +44,13 @@ function APBDesTable() {
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Box pt={"xs"} pb="md" px={{ base: 'md', md: 100 }}>
<Title order={4} c={colors['blue-button']} mb="sm">
Rincian APBDes Tahun {apbdesItem.tahun}
Rincian APBDes Tahun {apbdesData.tahun}
</Title>
<Paper withBorder radius="md" shadow="xs" p="md">
<Box style={{overflowY: 'auto' }}>
<Box style={{ overflowY: 'auto' }}>
<Table withColumnBorders highlightOnHover>
<Table.Thead bg="#2c5f78">
<Table.Tr>
@@ -109,9 +77,7 @@ function APBDesTable() {
<Table.Td style={getIndent(item.level)}>
<Group gap="xs" align="flex-start">
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
<Text fz="sm" >
{item.uraian}
</Text>
<Text fz="sm">{item.uraian}</Text>
</Group>
</Table.Td>
<Table.Td ta="right">{formatRupiahOrEmpty(item.anggaran)}</Table.Td>

View File

@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export type APBDesTipe = 'pendapatan' | 'belanja' | 'pembiayaan';
export function isAPBDesTipe(tipe: string | null | undefined): tipe is APBDesTipe {
return tipe === 'pendapatan' || tipe === 'belanja' || tipe === 'pembiayaan';
}
export interface APBDesItem {
id: string;
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe?: APBDesTipe | null;
// Additional fields from API
createdAt?: Date;
updatedAt?: Date;
deletedAt?: Date | null;
isActive?: boolean;
apbdesId?: string;
}
export interface APBDesData {
id: string;
tahun: number | null;
items: APBDesItem[];
image?: { id: string; url: string } | null;
file?: { id: string; url: string } | null;
}
export function transformAPBDesData(data: any): APBDesData {
return {
...data,
items: data.items.map((item: any) => ({
...item,
tipe: isAPBDesTipe(item.tipe) ? item.tipe : null
}))
};
}

View File

@@ -4,19 +4,21 @@
import PendapatanAsliDesa from '@/app/admin/(dashboard)/_state/ekonomi/PADesa'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import colors from '@/con/colors'
import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, SimpleGrid, Stack, Text, Title } from '@mantine/core'
import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, Select, SimpleGrid, Stack, Text, Title } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react'
import { Link } from 'next-view-transitions'
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import BackButton from '../../(pages)/desa/layanan/_com/BackButto'
import APBDesProgress from './lib/apbDesaProgress'
import APBDesTable from './lib/apbDesaTable'
import APBDesProgress from './lib/apbDesaProgress'
import { transformAPBDesData } from './lib/types'
function Page() {
const state = useProxy(apbdes)
const paDesaState = useProxy(PendapatanAsliDesa.ApbDesa)
const [loading, setLoading] = useState(false)
const [selectedYear, setSelectedYear] = useState<string | null>(null)
useEffect(() => {
const loadData = async () => {
try {
@@ -34,6 +36,23 @@ function Page() {
const dataAPBDes = state.findMany.data || []
// Buat daftar tahun unik dari data
const years = Array.from(new Set(dataAPBDes.map((item: any) => item.tahun)))
.sort((a, b) => b - a) // urutkan descending
.map(year => ({ value: year.toString(), label: `Tahun ${year}` }))
// Pilih tahun pertama sebagai default jika belum ada yang dipilih
useEffect(() => {
if (years.length > 0 && !selectedYear) {
setSelectedYear(years[0].value)
}
}, [years, selectedYear])
// Transform and filter data based on selected year
const currentApbdes = dataAPBDes.length > 0
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
: null
return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap={32}>
<Box px={{ base: 'md', md: 100 }}>
@@ -94,8 +113,31 @@ function Page() {
))}
</SimpleGrid>
)}
<APBDesTable />
<APBDesProgress />
{/* 🔥 COMBOBOX UNTUK PILIH TAHUN */}
<Box px={{ base: 'md', md: 100 }}>
<Select
label="Pilih Tahun APBDes"
placeholder="Pilih tahun"
value={selectedYear}
onChange={setSelectedYear}
data={years}
w={{ base: '100%', sm: 200 }}
searchable
clearable
nothingFoundMessage="Tidak ada tahun tersedia"
/>
</Box>
{/* ❗ Pass currentApbdes ke komponen anak */}
{currentApbdes ? (
<>
<APBDesTable apbdesData={currentApbdes} />
<APBDesProgress apbdesData={currentApbdes} />
</>
) : (
<Box px={{ base: 'md', md: 100 }} py="md">
<Text c="dimmed">Tidak ada data APBDes untuk tahun yang dipilih.</Text>
</Box>
)}
</Stack>
)
}

View File

@@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress'
import { transformAPBDesData } from '@/app/darmasaba/(tambahan)/apbdes/lib/types'
import colors from '@/con/colors'
import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core'
import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, Select, SimpleGrid, Stack, Text } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
@@ -12,6 +14,7 @@ import { useProxy } from 'valtio/utils'
function Apbdes() {
const state = useProxy(apbdes)
const [loading, setLoading] = useState(false)
const [selectedYear, setSelectedYear] = useState<string | null>(null)
const textHeading = {
title: 'APBDes',
@@ -32,6 +35,24 @@ function Apbdes() {
loadData()
}, [])
const dataAPBDes = state.findMany.data || []
const years = Array.from(new Set(dataAPBDes.map((item: any) => item.tahun)))
.sort((a, b) => b - a) // urutkan descending
.map(year => ({ value: year.toString(), label: `Tahun ${year}` }))
// Pilih tahun pertama sebagai default jika belum ada yang dipilih
useEffect(() => {
if (years.length > 0 && !selectedYear) {
setSelectedYear(years[0].value)
}
}, [years, selectedYear])
// Transform and filter data based on selected year
const currentApbdes = dataAPBDes.length > 0
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
: null
const data = (state.findMany.data || []).slice(0, 3)
return (
@@ -60,8 +81,30 @@ function Apbdes() {
</Button>
</Group>
{/* Chart */}
<APBDesProgress />
{/* 🔥 COMBOBOX UNTUK PILIH TAHUN */}
<Box px={{ base: 'md', md: 100 }}>
<Select
label="Pilih Tahun APBDes"
placeholder="Pilih tahun"
value={selectedYear}
onChange={setSelectedYear}
data={years}
w={{ base: '100%', sm: 200 }}
searchable
clearable
nothingFoundMessage="Tidak ada tahun tersedia"
/>
</Box>
{currentApbdes ? (
<>
<APBDesProgress apbdesData={currentApbdes} />
</>
) : (
<Box px={{ base: 'md', md: 100 }} py="md">
<Text c="dimmed">Tidak ada data APBDes untuk tahun yang dipilih.</Text>
</Box>
)}
<SimpleGrid mx={{ base: 'md', md: 100 }} cols={{ base: 1, sm: 3 }} spacing="lg" pb={"xl"}>
{loading ? (
@@ -129,7 +172,7 @@ function Apbdes() {
)}
</SimpleGrid>
</Stack>
)
}

View File

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

View File

@@ -59,6 +59,35 @@ const getWorkStatus = (day: string, currentTime: string): { status: string; mess
: { 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() {
const [socialMedia, setSocialMedia] = useState<
Prisma.MediaSosialGetPayload<{ include: { image: true } }>[]
@@ -66,9 +95,8 @@ function LandingPage() {
const [profile, setProfile] = useState<
Prisma.PejabatDesaGetPayload<{ include: { image: true } }> | null
>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingSosmed, setIsLoadingSosmed] = useState(true);
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
useEffect(() => {
const fetchSocialMedia = async () => {
@@ -86,7 +114,7 @@ function LandingPage() {
} catch {
setSocialMedia([]);
} finally {
setIsLoading(false);
setIsLoadingSosmed(false);
}
};
@@ -98,6 +126,8 @@ function LandingPage() {
setProfile(result.data || null);
} catch {
setProfile(null);
} finally {
setIsLoadingProfile(false);
}
};
@@ -189,8 +219,8 @@ function LandingPage() {
<ModuleView />
{isLoading ? (
<Skeleton height={32} width="100%" />
{isLoadingSosmed ? (
<SosmedSkeleton />
) : socialMedia.length > 0 ? (
<SosmedView data={socialMedia} />
) : (
@@ -207,19 +237,27 @@ function LandingPage() {
</Card>
</Stack>
{isLoading ? (
<Skeleton height={300} width="100%" radius="lg" />
{isLoadingProfile ? (
<ProfileSkeleton />
) : profile ? (
<ProfileView data={profile} />
) : (
<Center w="100%">
<Text c="dimmed">Informasi profil belum tersedia</Text>
</Center>
<Card
radius="xl"
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>
</Stack>
);
}
export default LandingPage;
export default LandingPage;

View File

@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Syarat & Ketentuan Penggunaan HIPMI Badung Connect</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #1e293b;
background-color: #f8fafc;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
background-color: white;
min-height: 100vh;
}
h1 {
font-size: 2rem;
font-weight: 700;
color: #1e3a5f;
margin-bottom: 1.5rem;
line-height: 1.3;
}
h2 {
font-size: 1.5rem;
font-weight: 700;
color: #1e3a5f;
margin-top: 2.5rem;
margin-bottom: 1rem;
}
p {
margin-bottom: 1rem;
color: #334155;
}
strong {
font-weight: 600;
color: #1e293b;
}
ul {
margin-left: 1.5rem;
margin-bottom: 1.5rem;
}
li {
margin-bottom: 0.5rem;
color: #334155;
}
.intro {
margin-bottom: 2rem;
padding: 1.25rem;
background-color: #f1f5f9;
border-radius: 8px;
border-left: 4px solid #1e3a5f;
}
.footer {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid #e2e8f0;
text-align: center;
color: #64748b;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.container {
padding: 24px 16px;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.25rem;
margin-top: 2rem;
}
}
@media (max-width: 480px) {
h1 {
font-size: 1.25rem;
}
h2 {
font-size: 1.125rem;
}
ul {
margin-left: 1.25rem;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Syarat & Ketentuan Penggunaan HIPMI Badung Connect</h1>
<div class="intro">
<p>Dengan menggunakan aplikasi <strong>HIPMI Badung Connect</strong> ("Aplikasi"), Anda setuju untuk mematuhi dan terikat oleh syarat dan ketentuan berikut. Jika Anda tidak setuju dengan ketentuan ini, harap jangan gunakan Aplikasi.</p>
</div>
<h2>1. Definisi</h2>
<p><strong>HIPMI Badung Connect</strong> adalah platform digital resmi untuk anggota Himpunan Pengusaha Muda Indonesia (HIPMI) Kabupaten Badung, yang bertujuan memfasilitasi jaringan, kolaborasi, dan pertumbuhan bisnis para pengusaha muda.</p>
<h2>2. Larangan Konten Tidak Pantas</h2>
<p>Anda <strong>dilarang keras</strong> memposting, mengirim, membagikan, atau mengunggah konten apa pun yang mengandung:</p>
<ul>
<li>Ujaran kebencian, diskriminasi, atau konten SARA (Suku, Agama, Ras, Antar-golongan)</li>
<li>Pornografi, konten seksual eksplisit, atau gambar tidak senonoh</li>
<li>Ancaman, pelecehan, bullying, atau perilaku melecehkan</li>
<li>Informasi palsu, hoaks, spam, atau konten menyesatkan</li>
<li>Konten ilegal, melanggar hukum, atau melanggar hak kekayaan intelektual pihak lain</li>
<li>Promosi narkoba, perjudian, atau aktivitas ilegal lainnya</li>
</ul>
<h2>3. Tanggung Jawab Pengguna</h2>
<p>Anda bertanggung jawab penuh atas setiap konten yang Anda unggah atau bagikan melalui fitur-fitur berikut:</p>
<ul>
<li>Profil (bio, foto, portofolio)</li>
<li>Forum diskusi</li>
<li>Chat pribadi atau grup</li>
<li>Lowongan kerja, investasi, dan donasi</li>
</ul>
<p>Konten yang melanggar ketentuan ini dapat dihapus kapan saja tanpa pemberitahuan.</p>
<h2>4. Tindakan terhadap Pelanggaran</h2>
<p>Jika kami menerima laporan atau menemukan konten yang melanggar ketentuan ini, kami akan:</p>
<ul>
<li>Segera menghapus konten tersebut</li>
<li>Memberikan peringatan atau memblokir akun pengguna</li>
<li>Dalam kasus berat, melaporkan ke pihak berwajib sesuai hukum yang berlaku</li>
</ul>
<p>Tim kami berkomitmen untuk menanggapi laporan konten tidak pantas <strong>dalam waktu 24 jam</strong>.</p>
<h2>5. Mekanisme Pelaporan</h2>
<p>Anda dapat melaporkan konten atau pengguna yang mencurigakan melalui:</p>
<ul>
<li>Tombol <strong>"Laporkan"</strong> di setiap posting forum atau pesan chat</li>
<li>Tombol <strong>"Blokir Pengguna"</strong> di profil pengguna</li>
</ul>
<p>Setiap laporan akan ditangani secara rahasia dan segera.</p>
<h2>6. Perubahan Ketentuan</h2>
<p>Kami berhak memperbarui Syarat & Ketentuan ini sewaktu-waktu. Versi terbaru akan dipublikasikan di halaman ini dengan tanggal revisi yang diperbarui.</p>
<h2>7. Kontak</h2>
<p>Jika Anda memiliki pertanyaan tentang ketentuan ini, silakan hubungi kami di:<br>
<strong>bip.baliinteraktifperkasa@gmail.com</strong></p>
<div class="footer">
© 2025 Bali Interaktif Perkasa. All rights reserved.
</div>
</div>
</body>
</html>

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import DesaAntiKorupsi from "@/app/darmasaba/_com/main-page/desaantikorupsi";
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 ScrollToTopButton from "./_com/scrollToTopButton";
import NewsReaderLanding from "./_com/NewsReaderalanding";
import ModernNewsNotification from "./_com/ModernNeewsNotification";
import { useMemo } from "react";
import { useProxy } from "valtio/utils";
import { useEffect, useMemo } from "react";
import { useSnapshot } from "valtio";
import stateDashboardBerita from "../admin/(dashboard)/_state/desa/berita";
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() {
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 pengumuman = useProxy(stateDesaPengumuman.pengumuman.findFirst);
const loadingPengumuman = pengumuman.loading;
useEffect(() => {
if (!featured.data && !loadingFeatured) {
stateDashboardBerita.berita.findFirst.load();
}
}, [featured.data, loadingFeatured]);
}, []);
useEffect(() => {
if (!pengumuman.data && !loadingPengumuman) {
stateDesaPengumuman.pengumuman.findFirst.load();
}
}, [pengumuman.data, loadingPengumuman]);
}, []);
const newsData = useMemo(() => {

View File

@@ -1,31 +1,105 @@
/* styles/globals.css */
/* ===================================
1. IMPORT CSS LIBRARIES
=================================== */
@import "@mantine/carousel/styles.css";
@import "@mantine/dropzone/styles.css";
@import "@mantine/charts/styles.css";
@import "@mantine/dates/styles.css";
@import "@mantine/tiptap/styles.css";
@import "animate.css";
@import "react-simple-toasts/dist/style.css";
@import "react-simple-toasts/dist/theme/dark.css";
@import "primereact/resources/themes/lara-light-blue/theme.css";
@import "primereact/resources/primereact.min.css";
@import "primeicons/primeicons.css";
/* ===================================
2. FONT FACE - OPTIMIZED
=================================== */
@font-face {
font-family: 'San Francisco';
src: url('/assets/fonts/font.otf') format('opentype');
font-weight: normal;
font-style: normal;
font-display: swap; /* ✅ TAMBAHKAN INI - Penting untuk PageSpeed! */
}
/* ===================================
3. RESET & BASE STYLES
=================================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%; /* Prevent font scaling in landscape */
-moz-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
font-family: 'San Francisco', -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ===================================
4. GLASS EFFECTS - OPTIMIZED
=================================== */
.glass {
background: rgba(255, 255, 255, 0.2);
-webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
position: fixed;
z-index: 50;
width: 100%;
height: 100vh;
will-change: transform; /* ✅ Hardware acceleration */
}
.glass2 {
background: rgba(255, 255, 255, 0.3);
-webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
position: fixed;
z-index: 1;
will-change: transform; /* ✅ Hardware acceleration */
}
.glass3 {
background: rgba(255, 255, 255, 0.3);
-webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
will-change: transform; /* ✅ Hardware acceleration */
}
/* ===================================
5. PERFORMANCE OPTIMIZATION
=================================== */
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
height: auto;
}
/* Reduce motion for accessibility */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -1,20 +1,5 @@
// Import styles of packages that you've installed.
// All packages except `@mantine/hooks` require styles imports
import "@mantine/carousel/styles.css";
import "@mantine/core/styles.css";
import '@mantine/dropzone/styles.css';
import "animate.css";
import 'react-simple-toasts/dist/style.css';
import 'react-simple-toasts/dist/theme/dark.css';
import "./globals.css";
import '@mantine/charts/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/tiptap/styles.css';
import "primereact/resources/themes/lara-light-blue/theme.css";
import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css";
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
import {
@@ -23,19 +8,83 @@ import {
createTheme,
mantineHtmlProps,
} from "@mantine/core";
import { Metadata, Viewport } from "next";
import { ViewTransitions } from "next-view-transitions";
import { ToastContainer } from "react-toastify";
export const metadata = {
title: "Desa Darmasaba",
description: "Desa Darmasaba Kabupaten Badung",
// ✅ Pisahkan viewport ke export tersendiri
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 5,
};
export const metadata: Metadata = {
// ✅ Tambahkan metadataBase
metadataBase: new URL("https://cld-dkr-staging-desa-darmasaba.wibudev.com"),
title: {
default: "Desa Darmasaba",
template: "%s | Desa Darmasaba",
},
description: "Website resmi Desa Darmasaba, Kabupaten Badung, Bali. Informasi layanan publik, berita, dan profil desa.",
// ❌ HAPUS viewport dari sini
keywords: [
"desa darmasaba",
"darmasaba",
"badung",
"bali",
"desa",
"pemerintah desa",
"layanan publik",
"abang batan desa",
],
authors: [{ name: "Pemerintah Desa Darmasaba" }],
creator: "Desa Darmasaba",
publisher: "Desa Darmasaba",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
icon: "/assets/images/darmasaba-icon.png",
apple: "/assets/images/darmasaba-icon.png",
},
manifest: "/manifest.json",
openGraph: {
type: "website",
locale: "id_ID",
url: "https://cld-dkr-staging-desa-darmasaba.wibudev.com",
siteName: "Desa Darmasaba",
title: "Desa Darmasaba - Kabupaten Badung, Bali",
description: "Website resmi Desa Darmasaba, Kabupaten Badung, Bali. Informasi layanan publik, berita, dan profil desa.",
images: [
{
url: "/assets/images/darmasaba-icon.png",
width: 1200,
height: 630,
alt: "Desa Darmasaba",
},
],
},
category: "government",
other: {
"msapplication-TileColor": "#ffffff",
"theme-color": "#ffffff",
},
};
const theme = createTheme({
fontFamily:
"San Francisco, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
fontFamilyMonospace:
"SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace",
fontFamily: "San Francisco, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif",
fontFamilyMonospace: "SFMono-Regular, Menlo, Monaco, Consolas, monospace",
headings: { fontFamily: "San Francisco, sans-serif" },
});
@@ -46,26 +95,23 @@ export default function RootLayout({
}) {
return (
<ViewTransitions>
<html lang="en" {...mantineHtmlProps}>
<html lang="id" {...mantineHtmlProps}>
<head>
<meta charSet="utf-8" />
<ColorSchemeScript />
<link
rel="icon"
href="/assets/images/darmasaba-icon.png"
sizes="any"
/>
</head>
<body>
<MantineProvider theme={theme}>
{children}
<LoadDataFirstClient />
<ToastContainer
position="bottom-center"
hideProgressBar
style={{ zIndex: 9999 }}
/>
</MantineProvider>
<ToastContainer position="bottom-center" hideProgressBar style={{
zIndex: 9999
}} />
</body>
<LoadDataFirstClient />
</html>
</ViewTransitions>
);
}
}

View File

@@ -0,0 +1,102 @@
import { Box, Container, Divider, List, ListItem, Paper, Stack, Text, Title } from '@mantine/core';
import React from 'react';
function Page() {
return (
<Container size="md" py={40}>
<Stack gap="xl">
<Title order={1} size="h1" fw={700} c="blue.9">
Syarat & Ketentuan Penggunaan Admin Desa Darmasaba
</Title>
<Paper p="lg" radius="md" withBorder bg="gray.0" style={{ borderLeft: '4px solid #1e3a5f' }}>
<Text c="gray.8">
Dengan menggunakan website <Text component="span" fw={600}>Admin Desa Darmasaba</Text> (&quot;Website&quot;),
Anda setuju untuk mematuhi dan terikat oleh syarat dan ketentuan berikut. Jika Anda tidak setuju
dengan ketentuan ini, harap jangan gunakan Website.
</Text>
</Paper>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
1. Definisi
</Title>
<Text c="gray.7">
<Text component="span" fw={600}>Admin Desa Darmasaba</Text> adalah website resmi untuk Admin Desa Darmasaba, yang bertujuan
menambahkan, menghapus, dan mengedit konten desa ke dalam website.
</Text>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
2. Larangan Konten Tidak Pantas
</Title>
<Text c="gray.7" mb="md">
Anda <Text component="span" fw={600}>dilarang keras</Text> menambahkan, menghapus, dan mengedit konten desa apa pun yang mengandung:
</Text>
<List spacing="xs" c="gray.7">
<ListItem>Ujaran kebencian, diskriminasi, atau konten SARA (Suku, Agama, Ras, Antar-golongan)</ListItem>
<ListItem>Pornografi, konten seksual eksplisit, atau gambar tidak senonoh</ListItem>
<ListItem>Ancaman, pelecehan, bullying, atau perilaku melecehkan</ListItem>
<ListItem>Informasi palsu, hoaks, spam, atau konten menyesatkan</ListItem>
<ListItem>Konten ilegal, melanggar hukum, atau melanggar hak kekayaan intelektual pihak lain</ListItem>
<ListItem>Promosi narkoba, perjudian, atau aktivitas ilegal lainnya</ListItem>
</List>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
3. Tanggung Jawab Pengguna
</Title>
<List spacing="xs" c="gray.7">
<ListItem>Anda bertanggung jawab penuh atas setiap konten yang Anda unggah atau bagikan.</ListItem>
<ListItem>Konten yang melanggar ketentuan ini dapat dihapus kapan saja tanpa pemberitahuan.</ListItem>
</List>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
4. Tindakan terhadap Pelanggaran
</Title>
<Text c="gray.7" mb="md">
Jika kami menerima laporan atau menemukan konten yang melanggar ketentuan ini, kami akan:
</Text>
<List spacing="xs" c="gray.7">
<ListItem>Segera menghapus konten tersebut</ListItem>
<ListItem>Menghapus akun pengguna</ListItem>
<ListItem>Dalam kasus berat, melaporkan ke pihak berwajib sesuai hukum yang berlaku</ListItem>
</List>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
5. Perubahan Ketentuan
</Title>
<Text c="gray.7">
Kami berhak memperbarui Syarat & Ketentuan ini sewaktu-waktu. Versi terbaru akan dipublikasikan di
halaman ini dengan tanggal revisi yang diperbarui.
</Text>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
6. Kontak
</Title>
<Text c="gray.7">
Jika Anda memiliki pertanyaan tentang ketentuan ini, silakan hubungi kami di:
</Text>
<Text c="gray.7" fw={600} mt="xs">
bip.baliinteraktifperkasa@gmail.com
</Text>
</Box>
<Divider my="xl" />
<Text ta="center" c="gray.6" size="sm">
© 2025 Bali Interaktif Perkasa. All rights reserved.
</Text>
</Stack>
</Container>
);
}
export default Page;

View File

@@ -2,73 +2,176 @@
'use client';
import colors from '@/con/colors';
import { Center, Loader, Paper, Stack, Text, Title } from '@mantine/core';
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
// Ganti ini jika tidak pakai next-auth
async function fetchUser() {
const res = await fetch('/api/auth/me');
if (!res.ok) throw new Error('Unauthorized');
const res = await fetch('/api/auth/me', {
credentials: 'include'
});
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 [loading, setLoading] = useState(true);
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;
const interval = setInterval(async () => {
try {
const data = await fetchUser();
if (!isMounted) return;
useEffect(() => {
let isMounted = true;
let interval: ReturnType<typeof setInterval>;
const currentUser = data.user;
setUser(currentUser);
const poll = async () => {
if (isRedirecting || !isMounted) return;
// ✅ Sekarang isActive tersedia!
if (currentUser?.isActive) {
clearInterval(interval);
// Redirect ke halaman admin sesuai role
if (currentUser.roleId === 0) {
router.push('/admin/landing-page/profil/program-inovasi');
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 {
router.push('/admin'); // atau halaman default role
console.error('Error polling:', err);
}
}
} catch (err: any) {
if (!isMounted) return;
setError(err.message || 'Gagal memuat status');
clearInterval(interval);
if (err.message === 'Unauthorized') {
router.push('/login');
}
}
}, 2000);
};
return () => {
isMounted = false;
clearInterval(interval);
};
}, [router]);
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">Error</Title>
<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 }}>
@@ -76,17 +179,13 @@ useEffect(() => {
<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>

View File

@@ -1,46 +1,98 @@
// app/middleware.js
import { NextResponse, NextRequest } from 'next/server';
// // app/middleware.js
// import { NextResponse, NextRequest } from 'next/server';
// Daftar route yang diizinkan tanpa login (public routes)
const publicRoutes = [
'/*', // Home page
'/about', // About page
'/public/*', // Wildcard untuk semua route di bawah /public
'/login', // Halaman login
// // Daftar route yang diizinkan tanpa login (public routes)
// const publicRoutes = [
// '/*', // Home page
// '/about', // About page
// '/public/*', // Wildcard untuk semua route di bawah /public
// '/login', // Halaman login
// ];
// // Fungsi untuk memeriksa apakah route saat ini adalah route publik
// function isPublicRoute(pathname: string) {
// return publicRoutes.some((route) => {
// // Jika route mengandung wildcard (*), gunakan regex untuk mencocokkan
// if (route.endsWith('*')) {
// const baseRoute = route.replace('*', ''); // Hapus wildcard
// return pathname.startsWith(baseRoute); // Cocokkan dengan pathname
// }
// return pathname === route; // Cocokkan exact path
// });
// }
// export function middleware(request: NextRequest) {
// const { pathname } = request.nextUrl;
// // Jika route adalah public, izinkan akses
// if (isPublicRoute(pathname)) {
// return NextResponse.next();
// }
// // Jika bukan public route, periksa apakah pengguna sudah login
// const isLoggedIn = request.cookies.get('darmasaba-auth-token'); // Contoh: cek cookie auth-token
// if (!isLoggedIn) {
// // Redirect ke halaman login jika belum login
// return NextResponse.redirect(new URL('/login', request.url));
// }
// // Jika sudah login, izinkan akses
// return NextResponse.next();
// }
// // Konfigurasi untuk menentukan path mana yang akan dijalankan middleware
// export const config = {
// matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], // Jalankan middleware untuk semua route kecuali file statis
// };
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/app/admin/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
// Route publik di dalam /admin (boleh diakses tanpa login penuh)
const PUBLIC_ADMIN_ROUTES = [
'/admin/login',
'/admin/registrasi',
'/admin/validasi',
'/admin/waiting-room',
];
// Fungsi untuk memeriksa apakah route saat ini adalah route publik
function isPublicRoute(pathname: string) {
return publicRoutes.some((route) => {
// Jika route mengandung wildcard (*), gunakan regex untuk mencocokkan
if (route.endsWith('*')) {
const baseRoute = route.replace('*', ''); // Hapus wildcard
return pathname.startsWith(baseRoute); // Cocokkan dengan pathname
}
return pathname === route; // Cocokkan exact path
});
}
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Jika route adalah public, izinkan akses
if (isPublicRoute(pathname)) {
return NextResponse.next();
}
// Jika bukan public route, periksa apakah pengguna sudah login
const isLoggedIn = request.cookies.get('darmasaba-auth-token'); // Contoh: cek cookie auth-token
if (!isLoggedIn) {
// Redirect ke halaman login jika belum login
return NextResponse.redirect(new URL('/login', request.url));
}
// Jika sudah login, izinkan akses
// Izinkan akses ke route publik di /admin
if (PUBLIC_ADMIN_ROUTES.some(route => path.startsWith(route))) {
return NextResponse.next();
}
// Ambil token dari cookie
const token = request.cookies.get(process.env.BASE_SESSION_KEY!)?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
// Verifikasi JWT
const secret = new TextEncoder().encode(process.env.BASE_TOKEN_KEY!);
const { payload } = await jwtVerify(token, secret);
const user = (payload as any).user;
// Cek apakah user aktif
if (!user || !user.isActive) {
return NextResponse.redirect(new URL('/login', request.url));
}
// ✅ User valid → izinkan akses
return NextResponse.next();
} catch (error) {
console.error('Middleware auth error:', error);
return NextResponse.redirect(new URL('/login', request.url));
}
}
// Konfigurasi untuk menentukan path mana yang akan dijalankan middleware
// Hanya berlaku untuk /admin/*
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], // Jalankan middleware untuk semua route kecuali file statis
matcher: ['/admin/:path*'],
};

View File

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