Compare commits

...

16 Commits

Author SHA1 Message Date
091c33a73c Test Hapus Auth 2025-11-27 18:13:29 +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
a291bdfb51 Tampilan Layout sudah sesuai dengan roleIdnya
Sudah sessionnya
Sudah disesuaikan juga semisal superadmin ngubah role admin, maka admin tersebut akan logOut dan diarahkan ke halama login
sudah bisa logOut
2025-11-21 17:26:38 +08:00
0dff8f3254 Nico 20 Nov 25
Dibagian layout admin sudah disesuaikan dengan rolenya : supadmin, admin desa, admin kesehatan, admin pendidikan
Fix API User & Role Admin
2025-11-20 16:42:36 +08:00
78b8aa74cd Saat user baru registrasi maka akan diarahkan ke page waiting-room dan menunggu validasi admin 2025-11-20 14:07:26 +08:00
63 changed files with 4123 additions and 1274 deletions

BIN
bun.lockb

Binary file not shown.

View File

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

View File

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

View File

@@ -1,23 +1,10 @@
[ [
{ {
"id": "user-1", "id": "cmie1o0zh0002vn132vtzg7hh",
"nama": "Admin Desa", "username": "SuperAdmin-Nico",
"nomor": "089647037426", "nomor": "6289647037426",
"roleId": "role-1", "roleId": 0,
"isActive": true "isActive": true,
}, "sessionInvalid": false
{
"id": "user-2",
"nama": "Admin Kesehatan",
"nomor": "082339004198",
"roleId": "role-2",
"isActive": true
},
{
"id": "user-3",
"nama": "Admin Sekolah",
"nomor": "085237157222",
"roleId": "role-3",
"isActive": true
} }
] ]

View File

@@ -2163,25 +2163,28 @@ enum StatusPeminjaman {
// ========================================= USER ========================================= // // ========================================= USER ========================================= //
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
username String @unique username String
nomor String @unique nomor String @unique
role Role @relation(fields: [roleId], references: [id]) roleId String @default("2")
roleId String @default("1") isActive Boolean @default(false)
instansi String? sessionInvalid Boolean @default(false)
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll) lastLogin DateTime?
isActive Boolean @default(true) createdAt DateTime @default(now())
lastLogin DateTime? updatedAt DateTime @default(now()) @updatedAt
createdAt DateTime @default(now()) permissions Json?
updatedAt DateTime @updatedAt sessions UserSession[] // ✅ Relasi one-to-many
deletedAt DateTime? role Role @relation(fields: [roleId], references: [id])
menuAccesses UserMenuAccess[]
@@map("users")
} }
model Role { model Role {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
description String? description String?
permissions Json // Menyimpan permission dalam format JSON permissions Json?
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2200,26 +2203,32 @@ model KodeOtp {
otp Int otp Int
} }
// Tabel untuk menyimpan permission model UserSession {
model Permission { id String @id @default(cuid())
id String @id @default(cuid()) token String @db.Text // ✅ JWT bisa panjang
name String @unique expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
description String? active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@map("permissions") 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 UserSession { model UserMenuAccess {
id String @id @default(cuid()) id String @id @default(cuid())
token String userId String
expires DateTime? menuId String // ID menu (misal: "Landing Page", "Kesehatan")
active Boolean @default(true) createdAt DateTime @default(now())
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
updatedAt DateTime @default(now()) @updatedAt
User User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId String @unique
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
} }
// ========================================= DATA PENDIDIKAN ========================================= // // ========================================= DATA PENDIDIKAN ========================================= //

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import profilePejabatDesa from "./data/landing-page/profile/profile.json"; import profilePejabatDesa from "./data/landing-page/profile/profile.json";
@@ -54,73 +55,110 @@ import tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-progr
import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json"; import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json";
import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json"; import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json";
import roles from "./data/user/roles.json"; import roles from "./data/user/roles.json";
import users from "./data/user/users.json";
import fileStorage from "./data/file-storage.json"; import fileStorage from "./data/file-storage.json";
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json"; import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
import seedAssets from "./seed_assets"; import seedAssets from "./seed_assets";
import users from "./data/user/users.json";
import { safeSeedUnique } from "./safeseedUnique"; import { safeSeedUnique } from "./safeseedUnique";
(async () => { (async () => {
// =========== USER & ROLE ===========
// In your seed.ts
// =========== ROLES ===========
console.log("🔄 Seeding roles..."); console.log("🔄 Seeding roles...");
for (const r of roles) { for (const r of roles) {
await safeSeedUnique("role", { id: r.id }, { try {
name: r.name, // ✅ Destructure to remove permissions if exists
description: r.description, const { permissions, ...roleData } = r as any;
permissions: r.permissions,
isActive: r.isActive, 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");
console.log("✅ Roles seeded"); // =========== USER ===========
// =========== USERS ===========
console.log("🔄 Seeding users..."); console.log("🔄 Seeding users...");
for (const u of users) { for (const u of users) {
// First verify the role exists try {
const roleExists = await prisma.role.findUnique({ // Verify role exists first
where: { id: u.roleId }, 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.nama}`);
continue;
}
await safeSeedUnique("user", { id: u.id }, {
username: u.nama,
nomor: u.nomor,
roleId: u.roleId,
isActive: u.isActive,
}); });
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 seeded"); console.log("✅ Users seeding completed");
// =========== FILE STORAGE =========== // =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage..."); console.log("🔄 Seeding file storage...");
for (const f of fileStorage) { for (const f of fileStorage) {
await prisma.fileStorage.upsert({ try {
where: { id: f.id }, await prisma.fileStorage.upsert({
update: { where: { id: f.id },
name: f.name, update: {
realName: f.realName, name: f.name,
path: f.path, realName: f.realName,
mimeType: f.mimeType, path: f.path,
link: f.link, mimeType: f.mimeType,
category: f.category, link: f.link,
}, category: f.category,
create: { },
id: f.id, create: {
name: f.name, id: f.id,
realName: f.realName, name: f.name,
path: f.path, realName: f.realName,
mimeType: f.mimeType, path: f.path,
link: f.link, mimeType: f.mimeType,
category: f.category, 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"); console.log("✅ File storage seeded");
// =========== LANDING PAGE =========== // =========== LANDING PAGE ===========
@@ -539,15 +577,40 @@ import { safeSeedUnique } from "./safeseedUnique";
console.log("posisi organisasi berhasil"); console.log("posisi organisasi berhasil");
// =========== PEGAWAI PPID =========== // =========== PEGAWAI PPID ===========
console.log("🔄 Seeding pegawai PPID...");
const flattenedPegawai = pegawaiPPID.flat(); const flattenedPegawai = pegawaiPPID.flat();
// Check for duplicate emails
const emails = new Set();
for (const p of flattenedPegawai) { for (const p of flattenedPegawai) {
await prisma.pegawaiPPID.upsert({ if (emails.has(p.email)) {
where: { id: p.id }, console.warn(`⚠️ Duplicate email found in pegawaiPPID: ${p.email}`);
update: p, }
create: p, 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 =========== // =========== SUBMENU VISI MISI PPID ===========
@@ -811,7 +874,9 @@ import { safeSeedUnique } from "./safeseedUnique";
const flattenedPosisiBumdes = posisiOrganisasi.flat(); const flattenedPosisiBumdes = posisiOrganisasi.flat();
// ✅ Urutkan berdasarkan hierarki // ✅ Urutkan berdasarkan hierarki
const sortedPosisiBumdes = flattenedPosisiBumdes.sort((a, b) => a.hierarki - b.hierarki); const sortedPosisiBumdes = flattenedPosisiBumdes.sort(
(a, b) => a.hierarki - b.hierarki
);
for (const p of sortedPosisiBumdes) { for (const p of sortedPosisiBumdes) {
console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`); console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`);
@@ -891,7 +956,7 @@ import { safeSeedUnique } from "./safeseedUnique";
// Add IDs to the kategoriKegiatan data // Add IDs to the kategoriKegiatan data
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({ const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
...k, ...k,
id: `kategori-${index + 1}` id: `kategori-${index + 1}`,
})); }));
for (const k of kategoriKegiatan) { for (const k of kategoriKegiatan) {
@@ -1183,7 +1248,6 @@ import { safeSeedUnique } from "./safeseedUnique";
// seed assets // seed assets
await seedAssets(); await seedAssets();
})() })()
.then(() => prisma.$disconnect()) .then(() => prisma.$disconnect())
.catch((e) => { .catch((e) => {

View File

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

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
import { apiFetchLogin } from '@/app/api/[auth]/_lib/api_fetch_auth';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core'; import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -15,7 +16,9 @@ function Login() {
// Login.tsx // Login.tsx
async function onLogin() { async function onLogin() {
const cleanPhone = phone.replace(/\D/g, ''); const cleanPhone = phone.replace(/\D/g, '');
console.log(cleanPhone);
if (cleanPhone.length < 10) { if (cleanPhone.length < 10) {
toast.error('Nomor telepon tidak valid'); toast.error('Nomor telepon tidak valid');
return; return;
@@ -25,6 +28,8 @@ function Login() {
setLoading(true); setLoading(true);
const response = await apiFetchLogin({ nomor: cleanPhone }); const response = await apiFetchLogin({ nomor: cleanPhone });
console.log(response);
if (!response.success) { if (!response.success) {
toast.error(response.message || 'Gagal memproses login'); toast.error(response.message || 'Gagal memproses login');
return; return;
@@ -32,11 +37,12 @@ function Login() {
// Simpan nomor untuk register // Simpan nomor untuk register
localStorage.setItem('auth_nomor', cleanPhone); localStorage.setItem('auth_nomor', cleanPhone);
if (response.isRegistered) { if (response.isRegistered) {
// ✅ User lama: simpan kodeId & ke validasi // ✅ User lama: simpan kodeId
localStorage.setItem('auth_kodeId', response.kodeId); localStorage.setItem('auth_kodeId', response.kodeId);
router.push('/validasi');
// ✅ Cookie sudah di-set oleh API, langsung redirect
router.push('/validasi'); // Clean URL
} else { } else {
// ❌ User baru: langsung ke registrasi (tanpa kodeId) // ❌ User baru: langsung ke registrasi (tanpa kodeId)
router.push('/registrasi'); router.push('/registrasi');

View File

@@ -1,7 +1,7 @@
// app/registrasi/page.tsx // app/registrasi/page.tsx
'use client'; 'use client';
import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth'; import { apiFetchRegister } from '@/app/api/[auth]/_lib/api_fetch_auth';
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto'; import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {

View File

@@ -1,107 +1,218 @@
// app/validasi/page.tsx
'use client'; 'use client';
import { apiFetchOtpData, apiFetchVerifyOtp } from '@/app/api/auth/_lib/api_fetch_auth';
import colors from '@/con/colors'; 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 { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { authStore } from '@/store/authStore';
export default function Validasi() { export default function Validasi() {
const router = useRouter(); const router = useRouter();
const [nomor, setNomor] = useState<string | null>(null); const [nomor, setNomor] = useState<string | null>(null);
const [otp, setOtp] = useState(''); const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [kodeId, setKodeId] = useState<string | null>(null); 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/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();
}, []);
useEffect(() => { useEffect(() => {
const storedKodeId = localStorage.getItem('auth_kodeId'); const storedKodeId = localStorage.getItem('auth_kodeId');
if (!storedKodeId) { if (!storedKodeId) {
toast.error('Akses tidak valid'); toast.error('Akses tidak valid');
router.push('/login'); router.replace('/login');
return; return;
} }
setKodeId(storedKodeId); setKodeId(storedKodeId);
const loadOtpData = async () => {
const fetchOtpData = async () => {
try { try {
const result = await apiFetchOtpData({ kodeId: storedKodeId }); const res = await fetch(`/api/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
if (result.success && result.data?.nomor) { const result = await res.json();
if (res.ok && result.data?.nomor) {
setNomor(result.data.nomor); setNomor(result.data.nomor);
} else { } else {
throw new Error('OTP tidak valid'); throw new Error('Data OTP tidak valid');
} }
} catch (error) { } catch (error) {
console.error('Gagal muat OTP:', error); console.error('Gagal memuat data OTP:', error);
toast.error('Kode verifikasi tidak valid'); toast.error('Kode verifikasi tidak valid');
router.push('/login'); router.replace('/login');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
loadOtpData();
fetchOtpData();
}, [router]); }, [router]);
const handleVerify = async () => { const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return; if (!kodeId || !nomor || otp.length < 4) return;
setLoading(true);
try { try {
setLoading(true); if (isRegistrationFlow) {
const verifyResult = await apiFetchVerifyOtp({ nomor, otp, kodeId }); await handleRegistrationVerification();
if (verifyResult.success) {
cleanupStorage();
router.push('/admin/landing-page/profil/program-inovasi');
return; // ✅ HENTIKAN eksekusi di sini
}
// Hanya coba registrasi jika akun tidak ditemukan
if (verifyResult.status === 404 && verifyResult.message?.includes('Akun tidak ditemukan')) {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi hilang');
return;
}
const regRes = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, otp, kodeId }),
});
const regData = await regRes.json();
if (regData.success) {
cleanupStorage();
router.push('/admin/landing-page/profil/program-inovasi');
} else {
toast.error(regData.message || 'Registrasi gagal');
}
} else { } else {
// Hanya tampilkan error jika bukan kasus "akun tidak ditemukan" await handleLoginVerification();
toast.error(verifyResult.message || 'Verifikasi gagal');
} }
} catch (error) { } catch (error) {
console.error('Verifikasi error:', error); console.error('Error saat verifikasi:', error);
toast.error('Terjadi kesalahan'); toast.error('Terjadi kesalahan sistem');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const cleanupStorage = () => { const handleRegistrationVerification = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan.');
return;
}
const cleanNomor = nomor?.replace(/\D/g, '') ?? '';
if (cleanNomor.length < 10 || username.trim().length < 5) {
toast.error('Data tidak valid');
return;
}
const verifyRes = await fetch('/api/verify-otp-register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
credentials: 'include'
});
const verifyData = await verifyRes.json();
if (!verifyRes.ok) {
toast.error(verifyData.message || 'Verifikasi OTP gagal');
return;
}
const finalizeRes = await fetch('/api/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, kodeId }),
credentials: 'include'
});
const data = await finalizeRes.json();
if (data.success || finalizeRes.redirected) {
// ✅ Cleanup setelah registrasi sukses
await cleanupStorage();
window.location.href = '/waiting-room';
} else {
toast.error(data.message || 'Registrasi gagal');
}
};
const handleLoginVerification = async () => {
const loginRes = await fetch('/api/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_kodeId');
localStorage.removeItem('auth_nomor'); localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username'); localStorage.removeItem('auth_username');
// Clear cookie
try {
await fetch('/api/clear-flow', {
method: 'POST',
credentials: 'include'
});
} catch (error) {
console.error('Error clearing flow cookie:', error);
}
}; };
const handleResend = async () => { const handleResend = async () => {
if (!nomor) return; if (!nomor) return;
try { try {
const res = await fetch('/api/auth/resend-otp', { const res = await fetch('/api/resend', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }), body: JSON.stringify({ nomor }),
@@ -110,9 +221,11 @@ export default function Validasi() {
if (data.success) { if (data.success) {
localStorage.setItem('auth_kodeId', data.kodeId); localStorage.setItem('auth_kodeId', data.kodeId);
toast.success('OTP baru dikirim'); toast.success('OTP baru dikirim');
} else {
toast.error(data.message || 'Gagal mengirim ulang OTP');
} }
} catch { } catch {
toast.error('Gagal kirim ulang'); toast.error('Gagal menghubungi server');
} }
}; };
@@ -134,25 +247,27 @@ export default function Validasi() {
<Stack align="center" gap="lg"> <Stack align="center" gap="lg">
<Box> <Box>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}> <Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
Kode Verifikasi {isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'}
</Title> </Title>
<Text ta="center" size="sm" c="dimmed" mt="xs"> <Text ta="center" size="sm" c="dimmed" mt="xs">
Kami telah mengirim kode ke nomor <strong>{nomor}</strong> Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
</Text> </Text>
</Box> </Box>
<Box> <Box w="100%">
<Box mb={20}> <Box mb={20}>
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold"> <Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
Masukkan Kode Verifikasi Masukkan Kode Verifikasi
</Text> </Text>
<PinInput <Center>
length={4} <PinInput
value={otp} length={4}
onChange={setOtp} value={otp}
onComplete={handleVerify} onChange={setOtp}
inputMode="numeric" onComplete={handleVerify}
size="lg" inputMode="numeric"
/> size="lg"
/>
</Center>
</Box> </Box>
<Button <Button
@@ -168,7 +283,14 @@ export default function Validasi() {
<Text ta="center" size="sm" mt="md"> <Text ta="center" size="sm" mt="md">
Tidak menerima kode?{' '} Tidak menerima kode?{' '}
<Button variant="subtle" onClick={handleResend} size="xs" p={0} h="auto" color={colors['blue-button']}> <Button
variant="subtle"
onClick={handleResend}
size="xs"
p={0}
h="auto"
color={colors['blue-button']}
>
Kirim Ulang Kirim Ulang
</Button> </Button>
</Text> </Text>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import colors from "@/con/colors"; import colors from "@/con/colors";
import { authStore } from "@/store/authStore";
import { import {
ActionIcon, ActionIcon,
AppShell, AppShell,
@@ -8,9 +9,11 @@ import {
AppShellMain, AppShellMain,
AppShellNavbar, AppShellNavbar,
Burger, Burger,
Center,
Flex, Flex,
Group, Group,
Image, Image,
Loader,
NavLink, NavLink,
ScrollArea, ScrollArea,
Text, Text,
@@ -26,14 +29,120 @@ import {
import _ from "lodash"; import _ from "lodash";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation"; import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { navBar } from "./_com/list_PageAdmin"; import { useEffect, useState } from "react";
// import { useSnapshot } from "valtio";
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const [opened, { toggle }] = useDisclosure(); const [opened, { toggle }] = useDisclosure();
const [loading, setLoading] = useState(true);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true); const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter(); const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s)); const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// const { user } = useSnapshot(authStore);
// console.log("Current user in store:", user);
// ✅ FIX: Selalu fetch user data setiap kali komponen mount
useEffect(() => {
const fetchUser = async () => {
try {
const res = await fetch('/api/me');
const data = await res.json();
if (data.user) {
// Check if user is active
if (!data.user.isActive) {
authStore.setUser(null);
router.replace('/waiting-room');
return;
}
// ✅ PENTING: Selalu fetch menuIds terbaru setiap login
const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`);
const menuData = await menuRes.json();
const menuIds = menuData.success && Array.isArray(menuData.menuIds)
? [...menuData.menuIds]
: null;
// ✅ Set user dengan menuIds yang fresh dari database
authStore.setUser({
id: data.user.id,
name: data.user.name,
roleId: Number(data.user.roleId),
menuIds, // menuIds terbaru
isActive: data.user.isActive
});
} else {
authStore.setUser(null);
router.replace('/login');
}
} catch (error) {
console.error('Gagal memuat data pengguna:', error);
authStore.setUser(null);
router.replace('/login');
} finally {
setLoading(false);
}
};
fetchUser();
}, [router]); // ✅ Hapus dependency pada authStore.user
if (loading) {
return (
<AppShell>
<AppShellMain>
<Center h="100vh">
<Loader />
</Center>
</AppShellMain>
</AppShell>
);
}
// ✅ Ambil menu berdasarkan roleId dan menuIds
const currentNav = authStore.user
? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
: [];
const handleLogout = async () => {
try {
setIsLoggingOut(true);
// ✅ Panggil API logout untuk clear session di server
const response = await fetch('/api/logout', { method: 'POST' });
const result = await response.json();
if (result.success) {
// Clear user data dari store
authStore.setUser(null);
// Clear localStorage
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_kodeId');
// Force reload untuk reset semua state
window.location.href = '/login';
} else {
console.error('Logout failed:', result.message);
// Tetap redirect meskipun gagal
authStore.setUser(null);
window.location.href = '/login';
}
} catch (error) {
console.error('Error during logout:', error);
// Tetap clear store dan redirect jika error
authStore.setUser(null);
window.location.href = '/login';
} finally {
setIsLoggingOut(false);
}
};
return ( return (
<AppShell <AppShell
suppressHydrationWarning suppressHydrationWarning
@@ -115,13 +224,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
variant="gradient" variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }} gradient={{ from: colors["blue-button"], to: "#228be6" }}
> >
<Image <Image
src="/assets/images/darmasaba-icon.png" src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba" alt="Logo Darmasaba"
w={20} w={20}
h={20} h={20}
radius="md" radius="md"
loading="lazy" loading="lazy"
style={{ style={{
minWidth: '20px', minWidth: '20px',
height: 'auto', height: 'auto',
@@ -131,14 +240,14 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</Tooltip> </Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow> <Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon <ActionIcon
onClick={() => { onClick={handleLogout}
router.push("/darmasaba");
}}
color={colors["blue-button"]} color={colors["blue-button"]}
radius="xl" radius="xl"
size="lg" size="lg"
variant="gradient" variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }} gradient={{ from: colors["blue-button"], to: "#228be6" }}
loading={isLoggingOut}
disabled={isLoggingOut}
> >
<IconLogout2 size={22} /> <IconLogout2 size={22} />
</ActionIcon> </ActionIcon>
@@ -156,7 +265,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
p={{ base: 'xs', sm: 'sm' }} p={{ base: 'xs', sm: 'sm' }}
> >
<AppShell.Section p="sm"> <AppShell.Section p="sm">
{navBar.map((v, k) => { {currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name)); const isParentActive = segments.includes(_.lowerCase(v.name));
return ( return (
@@ -254,4 +363,4 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</AppShellMain> </AppShellMain>
</AppShell> </AppShell>
); );
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,39 +2,77 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
// API update user
export default async function userUpdate(context: Context) { export default async function userUpdate(context: Context) {
try { try {
const { id, isActive } = await context.body as { id: string, isActive: boolean }; const { id, isActive, roleId } = (await context.body) as {
id: string;
isActive?: boolean;
roleId?: string;
};
if (!id) { if (!id) {
return { return { success: false, message: "ID user wajib ada" };
success: false,
message: "ID user wajib ada",
};
} }
// Validasi role
if (roleId) {
const role = await prisma.role.findUnique({ where: { id: roleId } });
if (!role) return { success: false, message: "Role tidak ditemukan" };
}
const currentUser = await prisma.user.findUnique({
where: { id },
select: { roleId: true, isActive: true }
});
if (!currentUser) {
return { success: false, message: "User tidak ditemukan" };
}
const isRoleChanged = roleId && currentUser.roleId !== roleId;
const isActiveChanged = isActive !== undefined && currentUser.isActive !== isActive;
// Update user
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
where: { id }, where: { id },
data: { isActive }, data: {
...(isActive !== undefined && { isActive }),
...(roleId && { roleId }),
// Force logout: invalidate semua sesi
...(isRoleChanged ? { sessionInvalid: true } : {}),
},
select: { select: {
id: true, id: true,
username: true, username: true,
nomor: true, nomor: true,
isActive: true, isActive: true,
updatedAt: true, roleId: true,
role: { select: { name: true } }
} }
}); });
// ✅ HAPUS SEMUA SESI USER DI DATABASE
if (isRoleChanged) {
await prisma.userSession.deleteMany({ where: { userId: id } });
}
return { return {
success: true, success: true,
message: `User berhasil ${isActive ? "diaktifkan" : "dinonaktifkan"}`, roleChanged: isRoleChanged,
isActiveChanged,
data: updatedUser, data: updatedUser,
message: isRoleChanged
? `Role ${updatedUser.username} diubah. User akan logout otomatis.`
: isActiveChanged
? `${updatedUser.username} ${isActive ? 'diaktifkan' : 'dinonaktifkan'}.`
: "User berhasil diupdate"
}; };
} catch (e: any) { } catch (e: any) {
console.error("Error update user:", e); console.error("Error update user:", e);
return { return {
success: false, success: false,
message: "Gagal mengupdate status user", message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
}; };
} }
} }

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
// app/api/auth/_lib/api_fetch_auth.ts // app/api/_lib/api_fetch_auth.ts
// app/api/auth/_lib/api_fetch_auth.ts // app/api/_lib/api_fetch_auth.ts
export const apiFetchLogin = async ({ nomor }: { nomor: string }) => { export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
if (!nomor || nomor.replace(/\D/g, '').length < 10) { if (!nomor || nomor.replace(/\D/g, '').length < 10) {
@@ -10,10 +10,11 @@ export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
const cleanPhone = nomor.replace(/\D/g, ''); const cleanPhone = nomor.replace(/\D/g, '');
const response = await fetch("/api/auth/login", { const response = await fetch("/api/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomor: cleanPhone }), body: JSON.stringify({ nomor: cleanPhone }),
credentials: 'include'
}); });
// Pastikan respons bisa di-parse sebagai JSON // Pastikan respons bisa di-parse sebagai JSON
@@ -21,7 +22,7 @@ export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
try { try {
data = await response.json(); data = await response.json();
} catch (e) { } catch (e) {
console.error("Non-JSON response from /api/auth/login:", await response.text()); console.error("Non-JSON response from /api/login:", await response.text());
throw new Error('Respons server tidak valid'); throw new Error('Respons server tidak valid');
} }
@@ -54,10 +55,11 @@ export const apiFetchRegister = async ({
const cleanPhone = nomor.replace(/\D/g, ''); const cleanPhone = nomor.replace(/\D/g, '');
if (cleanPhone.length < 10) throw new Error('Nomor tidak valid'); if (cleanPhone.length < 10) throw new Error('Nomor tidak valid');
const response = await fetch("/api/auth/send-otp-register", { const response = await fetch("/api/register", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }), body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }),
credentials: 'include',
}); });
const data = await response.json(); const data = await response.json();
@@ -71,7 +73,7 @@ export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
throw new Error('Kode ID tidak valid'); throw new Error('Kode ID tidak valid');
} }
const response = await fetch("/api/auth/otp-data", { const response = await fetch("/api/otp-data", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kodeId }), body: JSON.stringify({ kodeId }),
@@ -86,67 +88,57 @@ export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
return data; return data;
}; };
// export const apiFetchVerifyOtp = async ({ // Ganti endpoint ke verify-otp-login
// nomor, export const apiFetchVerifyOtp = async ({ nomor, otp, kodeId }: { nomor: string; otp: string; kodeId: string }) => {
// otp, const response = await fetch('/api/verify-otp-login', {
// kodeId
// }: {
// nomor: string;
// otp: string;
// kodeId: string;
// }) => {
// if (!nomor || !otp || !kodeId) {
// throw new Error('Data verifikasi tidak lengkap');
// }
// if (!/^\d{4,6}$/.test(otp)) {
// throw new Error('Kode OTP harus 4-6 digit angka');
// }
// const response = await fetch('/api/auth/verify-otp', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ nomor, otp, kodeId }),
// });
// const data = await response.json();
// if (!response.ok) {
// throw new Error(data.message || 'Verifikasi OTP gagal');
// }
// return data;
// };
export const apiFetchVerifyOtp = async ({
nomor,
otp,
kodeId
}: {
nomor: string;
otp: string;
kodeId: string;
}) => {
if (!nomor || !otp || !kodeId) {
throw new Error('Data verifikasi tidak lengkap');
}
if (!/^\d{4,6}$/.test(otp)) {
throw new Error('Kode OTP harus 4-6 digit angka');
}
const response = await fetch('/api/auth/verify-otp', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }), body: JSON.stringify({ nomor, otp, kodeId }),
}); });
const data = await response.json(); const data = await response.json();
// ✅ Jangan throw error untuk status 4xx — biarkan frontend handle
return { return {
success: response.ok, success: response.ok,
...data, ...data,
status: response.status, 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

@@ -0,0 +1,65 @@
// 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> & { id: string };
invalidatePrevious?: boolean; // default true untuk login, false untuk registrasi
}) {
// ✅ Validasi env vars
if (!sessionKey || sessionKey.length === 0) {
throw new Error("sessionKey tidak boleh kosong");
}
if (!jwtSecret || jwtSecret.length < 32) {
throw new Error("jwtSecret minimal 32 karakter");
}
const token = await encrypt({ exp, jwtSecret, user });
if (!token) {
throw new Error("Token generation failed");
}
// ✅ Hitung expiresAt
let expiresAt = add(new Date(), { days: 30 });
if (exp === "7 day") expiresAt = add(new Date(), { days: 7 });
// 🔐 Hanya nonaktifkan sesi aktif sebelumnya jika diminta (misal: saat login ulang)
if (invalidatePrevious) {
await prisma.userSession.updateMany({
where: { userId: user.id, active: true },
data: { active: false },
});
}
// ✅ Simpan sesi baru
await prisma.userSession.create({
data: {
token,
userId: user.id,
active: true,
expiresAt,
},
});
// ✅ Set cookie
(await cookies()).set(sessionKey, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
maxAge: 30 * 24 * 60 * 60, // 30 hari dalam detik
});
return token;
}

View File

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

View File

@@ -0,0 +1,39 @@
// app/api/auth/_lib/session_verify.ts
import { cookies } from "next/headers";
import { decrypt } from "./decrypt";
import prisma from "@/lib/prisma";
export async function verifySession() {
try {
const sessionKey = process.env.BASE_SESSION_KEY;
const jwtSecret = process.env.BASE_TOKEN_KEY;
if (!sessionKey || !jwtSecret) throw new Error('Env tidak lengkap');
const token = (await cookies()).get(sessionKey)?.value;
if (!token) return null;
const jwtUser = await decrypt({ token, jwtSecret });
if (!jwtUser?.id) return null;
// Cari session di DB berdasarkan token
const dbSession = await prisma.userSession.findFirst({
where: {
token,
active: true,
expiresAt: { gte: new Date() }
},
include: { user: true }
});
if (!dbSession) {
console.log('⚠️ Session tidak ditemukan di DB');
return null;
}
// Don't check isActive here, let the frontend handle it
return dbSession.user;
} catch (error) {
console.warn('Session verification failed:', error);
return null;
}
}

View File

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

@@ -0,0 +1,122 @@
// 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 } = await req.json();
const cleanNomor = nomor.replace(/\D/g, "");
if (!cleanNomor || !username || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
const otpRecord = await prisma.kodeOtp.findUnique({ where: { id: kodeId } });
if (!otpRecord?.isActive || otpRecord.nomor !== cleanNomor) {
return NextResponse.json(
{ success: false, message: "OTP tidak valid" },
{ status: 400 }
);
}
if (await prisma.user.findFirst({ where: { username } })) {
return NextResponse.json(
{ success: false, message: "Username sudah digunakan" },
{ status: 409 }
);
}
// 🔥 Tentukan roleId sebagai STRING
const targetRoleId = "1"; // ✅ string, bukan number
// Validasi role (gunakan string)
const roleExists = await prisma.role.findUnique({
where: { id: targetRoleId }, // ✅ id bertipe string
select: { id: true }
});
if (!roleExists) {
return NextResponse.json(
{ success: false, message: "Role tidak valid" },
{ status: 400 }
);
}
// Buat user dengan roleId string
const newUser = await prisma.user.create({
data: {
username,
nomor,
roleId: targetRoleId, // ✅ string
isActive: false,
},
});
// Berikan akses menu
const menuIds = DEFAULT_MENUS_BY_ROLE[targetRoleId] || [];
if (menuIds.length > 0) {
await prisma.userMenuAccess.createMany({
data: menuIds.map(menuId => ({
userId: newUser.id,
menuId,
})),
});
}
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
const token = await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: newUser.id,
nomor: newUser.nomor,
username: newUser.username,
roleId: newUser.roleId, // string
isActive: false,
},
invalidatePrevious: false,
});
const response = NextResponse.redirect(new URL('/waiting-room', req.url));
response.cookies.set(process.env.BASE_SESSION_KEY!, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 30 * 24 * 60 * 60,
});
return response;
} catch (error) {
console.error("❌ Finalize Registration Error:", error);
return NextResponse.json(
{ success: false, message: "Registrasi gagal" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -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 prisma from "@/lib/prisma";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP"; import { randomOTP } from "../_lib/randomOTP";
import { cookies } from "next/headers";
export async function POST(req: Request) { export async function POST(req: Request) {
if (req.method !== "POST") { if (req.method !== "POST") {
@@ -29,19 +30,37 @@ export async function POST(req: Request) {
const isRegistered = !!existingUser; const isRegistered = !!existingUser;
if (isRegistered) { if (isRegistered) {
// ✅ User terdaftar → kirim OTP
const codeOtp = randomOTP(); const codeOtp = randomOTP();
const otpNumber = Number(codeOtp); const otpNumber = Number(codeOtp);
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`; // ✅ PERBAIKAN: Gunakan format pesan yang lebih sederhana
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`; // 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 res = await fetch(waUrl);
const sendWa = await res.json(); const sendWa = await res.json();
console.log("📱 WA Response:", sendWa); // Debug response
if (sendWa.status !== "success") { if (sendWa.status !== "success") {
return NextResponse.json( 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 } { status: 400 }
); );
} }
@@ -50,6 +69,15 @@ export async function POST(req: Request) {
data: { nomor, otp: otpNumber, isActive: true }, 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({ return NextResponse.json({
success: true, success: true,
message: "Kode verifikasi dikirim", message: "Kode verifikasi dikirim",
@@ -57,16 +85,14 @@ export async function POST(req: Request) {
isRegistered: true, isRegistered: true,
}); });
} else { } else {
// ❌ User belum terdaftar → JANGAN kirim OTP
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: "Nomor belum terdaftar", message: "Nomor belum terdaftar",
isRegistered: false, isRegistered: false,
// Tidak ada kodeId
}); });
} }
} catch (error) { } catch (error) {
console.error("Error Login:", error); console.error("Error Login:", error);
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat login" }, { success: false, message: "Terjadi kesalahan saat login" },
{ status: 500 } { status: 500 }

View File

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

View File

@@ -0,0 +1,59 @@
// src/app/api/auth/me/route.ts
import { NextResponse } from 'next/server';
import { verifySession } from '../_lib/session_verify';
import prisma from '@/lib/prisma';
export async function GET() {
try {
const sessionUser = await verifySession();
if (!sessionUser) {
return NextResponse.json(
{ success: false, message: "Unauthorized", user: null },
{ status: 401 }
);
}
const [dbUser, menuAccess] = await Promise.all([
prisma.user.findUnique({
where: { id: sessionUser.id },
select: {
id: true,
username: true,
nomor: true,
roleId: true, // STRING!
isActive: true, // BOOLEAN!
},
}),
prisma.userMenuAccess.findMany({
where: { userId: sessionUser.id },
select: { menuId: true },
}),
]);
if (!dbUser) {
return NextResponse.json(
{ success: false, message: "User not found", user: null },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
user: {
id: dbUser.id,
name: dbUser.username,
username: dbUser.username,
nomor: dbUser.nomor,
roleId: dbUser.roleId, // STRING!
isActive: dbUser.isActive, // BOOLEAN!
menuIds: menuAccess.map(m => m.menuId),
},
});
} catch (error) {
console.error("❌ Error in /api/me:", error);
return NextResponse.json(
{ success: false, message: "Internal server error", user: null },
{ status: 500 }
);
}
}

View File

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

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import prisma from '@/lib/prisma';
import { randomOTP } from '../_lib/randomOTP';
export async function POST(req: Request) {
try {
const { username, nomor } = await req.json();
if (!username || !nomor) {
return NextResponse.json({ success: false, message: 'Data tidak lengkap' }, { status: 400 });
}
// Cek duplikat
if (await prisma.user.findUnique({ where: { nomor } })) {
return NextResponse.json({ success: false, message: 'Nomor sudah terdaftar' }, { status: 409 });
}
if (await prisma.user.findFirst({ where: { username } })) {
return NextResponse.json({ success: false, message: 'Username sudah digunakan' }, { status: 409 });
}
// ✅ Generate dan kirim OTP
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
const waRes = await fetch(waUrl);
const waData = await waRes.json();
if (waData.status !== "success") {
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP via WhatsApp' }, { status: 400 });
}
// ✅ Simpan OTP ke database
const otpRecord = await prisma.kodeOtp.create({
data: { nomor, otp: otpNumber, isActive: true }
});
// ✅ 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',
kodeId: otpRecord.id,
});
} catch (error) {
console.error('Register OTP Error:', error);
return NextResponse.json({ success: false, message: 'Gagal mengirim OTP' }, { status: 500 });
} finally {
await prisma.$disconnect();
}
}

View File

@@ -0,0 +1,58 @@
// src/app/api/auth/resend-otp/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
export async function POST(req: Request) {
try {
const { nomor } = await req.json();
if (!nomor || typeof nomor !== 'string') {
return NextResponse.json(
{ success: false, message: "Nomor tidak valid" },
{ status: 400 }
);
}
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
// Kirim OTP via WhatsApp
const waMessage = `Kode verifikasi Anda: ${codeOtp}`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
const waRes = await fetch(waUrl);
const waData = await waRes.json();
if (waData.status !== "success") {
return NextResponse.json(
{ success: false, message: "Gagal mengirim OTP via WhatsApp" },
{ status: 400 }
);
}
// Simpan OTP ke database
const otpRecord = await prisma.kodeOtp.create({
data: {
nomor,
otp: otpNumber,
isActive: true,
},
});
return NextResponse.json({
success: true,
message: "OTP baru dikirim",
kodeId: otpRecord.id,
});
} catch (error) {
console.error("Error Resend OTP:", error);
return NextResponse.json(
{ success: false, message: "Gagal mengirim ulang OTP" },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

@@ -14,7 +14,7 @@ export async function POST(req: Request) {
if (await prisma.user.findUnique({ where: { nomor } })) { if (await prisma.user.findUnique({ where: { nomor } })) {
return NextResponse.json({ success: false, message: 'Nomor sudah terdaftar' }, { status: 409 }); 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 }); 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

@@ -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

@@ -1,42 +0,0 @@
import { cookies } from "next/headers";
import { encrypt } from "./encrypt";
export async function sessionCreate({
sessionKey,
exp = "7 year",
jwtSecret,
user,
}: {
sessionKey: string;
exp?: string;
jwtSecret: string;
user: Record<string, unknown>;
}) {
// 🔒 Validasi kunci tidak kosong
if (!sessionKey || sessionKey.length === 0) {
throw new Error("sessionKey tidak boleh kosong");
}
if (!jwtSecret || jwtSecret.length === 0) {
throw new Error("jwtSecret tidak boleh kosong");
}
const token = await encrypt({
exp,
jwtSecret,
user,
});
if (token === null) {
throw new Error("Token generation failed");
}
const cookieStore = await cookies();
cookieStore.set(sessionKey, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
});
return token;
}

View File

@@ -1,40 +0,0 @@
// app/api/auth/finalize-registration/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { sessionCreate } from "../_lib/session_create";
export async function POST(req: Request) {
try {
const { nomor, username, kodeId } = await req.json();
// Verifikasi OTP (sama seperti verify-otp)
const otpRecord = await prisma.kodeOtp.findUnique({ where: { id: kodeId } });
if (!otpRecord?.isActive || otpRecord.nomor !== nomor) {
return NextResponse.json({ success: false, message: 'OTP tidak valid' }, { status: 400 });
}
// Buat user
const user = await prisma.user.create({
data: { username, nomor, isActive: true }
});
// Nonaktifkan OTP
await prisma.kodeOtp.update({ where: { id: kodeId }, data: { isActive: false } });
// Buat session
const token = await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
user: { id: user.id, nomor: user.nomor, username: user.username, roleId: user.roleId, isActive: true },
});
const response = NextResponse.json({ success: true, roleId: user.roleId });
response.cookies.set(process.env.BASE_SESSION_KEY!, token, { /* options */ });
return response;
} catch (error) {
console.error('Finalize Registration Error:', error);
return NextResponse.json({ success: false, message: 'Registrasi gagal' }, { status: 500 });
} finally {
await prisma.$disconnect();
}
}

View File

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

View File

@@ -1,122 +0,0 @@
// app/api/auth/register/route.ts
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function POST(req: Request) {
try {
// Terima langsung properti, bukan { data: { ... } }
const { username, nomor } = await req.json();
// Validasi input
if (!username || !nomor) {
return NextResponse.json(
{ success: false, message: 'Data tidak lengkap' },
{ status: 400 }
);
}
// // Validasi OTP: pastikan berisi digit saja
// const cleanOtp = otp.toString().trim();
// if (!/^\d{4,6}$/.test(cleanOtp)) {
// return NextResponse.json(
// { success: false, message: 'Kode OTP tidak valid' },
// { status: 400 }
// );
// }
// const receivedOtp = parseInt(cleanOtp, 10);
// if (isNaN(receivedOtp)) {
// return NextResponse.json(
// { success: false, message: 'Kode OTP tidak valid' },
// { 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 kadaluarsa' },
// { status: 400 }
// );
// }
// if (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 duplikat nomor
const existingUser = await prisma.user.findUnique({
where: { nomor },
});
if (existingUser) {
return NextResponse.json(
{ success: false, message: 'Nomor sudah terdaftar' },
{ status: 409 }
);
}
// Cek username unik (pastikan ada @unique di schema!)
const existingByUsername = await prisma.user.findUnique({
where: { username },
});
if (existingByUsername) {
return NextResponse.json(
{ success: false, message: 'Username sudah digunakan' },
{ status: 409 }
);
}
// Buat user
const newUser = await prisma.user.create({
data: {
username: username.trim(),
nomor,
isActive: false,
// roleId default "1"
},
});
// // Nonaktifkan OTP
// await prisma.kodeOtp.update({
// where: { id: kodeId },
// data: { isActive: false },
// });
return NextResponse.json({
success: true,
message: 'Pendaftaran berhasil. Menunggu persetujuan admin.',
userId: newUser.id,
});
} catch (error) {
console.error('Registration error:', error);
return NextResponse.json(
{ success: false, message: 'Terjadi kesalahan saat pendaftaran' },
{ status: 500 }
);
} finally {
await prisma.$disconnect();
}
}

View File

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

View File

@@ -1,139 +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) {
if (req.method !== "POST") {
return NextResponse.json(
{ success: false, message: "Method Not Allowed" },
{ status: 405 }
);
}
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 }
);
}
// Pastikan tipe data cocok (OTP di DB = number)
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 }
);
}
if (!user.isActive) {
return NextResponse.json(
{ success: false, message: "Akun belum disetujui oleh admin" },
{ status: 403 }
);
}
// Buat session
const token = await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!, // ✅
user: {
id: user.id,
nomor: user.nomor,
username: user.username,
roleId: user.roleId,
isActive: user.isActive,
},
});
if (!token) {
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
);
}
// Nonaktifkan OTP
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
// Set cookie & respons
const response = NextResponse.json(
{
success: true,
message: "Berhasil login",
roleId: user.roleId,
},
{ status: 200 }
);
response.cookies.set(process.env.BASE_SESSION_KEY!, token, {
path: "/",
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: true, // 🔒 lebih aman
maxAge: 30 * 24 * 60 * 60,
});
return response;
} 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

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

View File

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

View File

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

View File

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

View File

@@ -2,68 +2,173 @@
'use client'; 'use client';
import colors from '@/con/colors'; 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 { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { authStore } from '@/store/authStore'; // ✅ integrasi authStore
// Ganti ini jika tidak pakai next-auth
async function fetchUser() { async function fetchUser() {
const res = await fetch('/api/auth/me'); const res = await fetch('/api/me');
if (!res.ok) throw new Error('Unauthorized'); if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
}
return res.json(); return res.json();
} }
export default function WaitingRoom() { export default function WaitingRoom() {
const router = useRouter(); const router = useRouter();
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
// const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isRedirecting, setIsRedirecting] = useState(false);
const [retryCount, setRetryCount] = useState(0);
const MAX_RETRIES = 2;
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const interval = setInterval(async () => { let interval: ReturnType<typeof setInterval>;
const poll = async () => {
if (isRedirecting || !isMounted) return;
try { try {
const data = await fetchUser(); const data = await fetchUser();
if (!isMounted) return; if (!isMounted) return;
setUser(data.user); const currentUser = data.user;
setUser(currentUser);
// Jika sudah aktif, redirect ke dashboard admin // ✅ Update authStore
if (data.user.isActive) { 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); clearInterval(interval);
router.push('/admin'); // atau /dashboard
// 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/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) { } catch (err: any) {
if (!isMounted) return; if (!isMounted) return;
setError(err.message || 'Gagal memuat status');
clearInterval(interval); if (err.message.includes('401')) {
// Redirect ke login jika unauthorized if (retryCount < MAX_RETRIES) {
if (err.message === 'Unauthorized') { setRetryCount((prev) => prev + 1);
router.push('/login'); setTimeout(() => {
if (isMounted) interval = setInterval(poll, 3000);
}, 800);
} else {
setError('Sesi tidak valid. Silakan login ulang.');
clearInterval(interval);
authStore.setUser(null); // ✅ clear sesi
}
} else {
console.error('Error polling:', err);
} }
} }
}, 2000); // Cek setiap 2 detik };
// Cleanup interval = setInterval(poll, 3000);
return () => { return () => {
isMounted = false; isMounted = false;
clearInterval(interval); if (interval) clearInterval(interval);
}; };
}, [router]); }, [router, isRedirecting, retryCount]);
// ✅ UI Error
if (error) { if (error) {
return ( return (
<Center h="100vh"> <Center h="100vh">
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={400}> <Paper p="xl" radius="md" bg={colors['white-trans-1']} w={400}>
<Stack align="center" gap="md"> <Stack align="center" gap="md">
<Title order={3} c="red">Error</Title> <Title order={3} c="red">
Sesi Tidak Valid
</Title>
<Text>{error}</Text> <Text>{error}</Text>
<Button onClick={() => router.push('/login')}>
Login Ulang
</Button>
</Stack> </Stack>
</Paper> </Paper>
</Center> </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 ( return (
<Center h="100vh" bg={colors.Bg}> <Center h="100vh" bg={colors.Bg}>
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '90%', sm: 400 }}> <Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '90%', sm: 400 }}>
@@ -71,17 +176,13 @@ export default function WaitingRoom() {
<Title order={2} c={colors['blue-button']} ta="center"> <Title order={2} c={colors['blue-button']} ta="center">
Menunggu Persetujuan Menunggu Persetujuan
</Title> </Title>
<Text ta="center" c="dimmed"> <Text ta="center" c="dimmed">
Akun Anda sedang dalam proses verifikasi oleh Superadmin. Akun Anda sedang dalam proses verifikasi oleh Superadmin.
</Text> </Text>
<Text ta="center" size="sm" c="dimmed"> <Text ta="center" size="sm" c="dimmed">
Nomor: {user?.nomor || '...'} Nomor: {user?.nomor || '...'}
</Text> </Text>
<Loader size="sm" color={colors['blue-button']} /> <Loader size="sm" color={colors['blue-button']} />
<Text ta="center" size="xs" c="dimmed"> <Text ta="center" size="xs" c="dimmed">
Jangan tutup halaman ini. Anda akan dialihkan otomatis setelah disetujui. Jangan tutup halaman ini. Anda akan dialihkan otomatis setelah disetujui.
</Text> </Text>

View File

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

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

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