fix/admin/menu-desa/berita #68

Open
nicoarya20 wants to merge 98 commits from fix/admin/menu-desa/berita into main
682 changed files with 26297 additions and 12752 deletions
Showing only changes of commit a291bdfb51 - Show all commits

View File

@@ -90,40 +90,88 @@ const userState = proxy({
}
},
},
update: {
deleteUser: {
loading: false,
async submit(payload: { id: string; isActive?: boolean; roleId?: string }) {
this.loading = true;
async delete(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
const res = await fetch(`/api/user/updt`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
userState.deleteUser.loading = true;
const response = await fetch(`/api/user/delUser/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const data = await res.json();
if (res.status === 200 && data.success) {
toast.success(data.message);
// refresh list
userState.findMany.load(
userState.findMany.page,
10,
userState.findMany.search
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "User berhasil dihapus permanen");
await userState.findMany.load(); // refresh list user setelah delete
} else {
toast.error(data.message || "Gagal update user");
toast.error(result?.message || "Gagal menghapus user");
}
} catch (e) {
console.error(e);
toast.error("Gagal update user");
} catch (error) {
console.error("Gagal delete user:", error);
toast.error("Terjadi kesalahan saat menghapus user");
} finally {
this.loading = false;
userState.delete.loading = false;
}
},
},
// Di file userState.ts atau dimana state user berada
update: {
loading: false,
async submit(payload: { id: string; isActive?: boolean; roleId?: string }) {
this.loading = true;
try {
const res = await fetch(`/api/user/updt`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.status === 200 && data.success) {
// ✅ Tampilkan pesan yang berbeda jika role berubah
if (data.roleChanged) {
toast.success(
`${data.message}\n\nUser akan logout otomatis dalam beberapa detik.`,
{
autoClose: 5000,
}
);
} else {
toast.success(data.message);
}
// Refresh list
await userState.findMany.load(
userState.findMany.page,
10,
userState.findMany.search
);
return true; // ✅ Return success untuk handling di component
} else {
toast.error(data.message || "Gagal update user");
return false;
}
} catch (e) {
console.error("❌ Error update user:", e);
toast.error("Gagal update user");
return false;
} finally {
this.loading = false;
}
},
},
});
const templateRole = z.object({

View File

@@ -1,4 +1,5 @@
// app/validasi/page.tsx
//
'use client';
import { apiFetchOtpData, apiFetchVerifyOtp } from '@/app/api/auth/_lib/api_fetch_auth';
@@ -17,89 +18,151 @@ export default function Validasi() {
const [isLoading, setIsLoading] = useState(true);
const [kodeId, setKodeId] = useState<string | null>(null);
// Inisialisasi data OTP
useEffect(() => {
const storedKodeId = localStorage.getItem('auth_kodeId');
if (!storedKodeId) {
toast.error('Akses tidak valid');
router.push('/login');
router.replace('/login');
return;
}
setKodeId(storedKodeId);
const fetchOtpData = async () => {
const loadOtpData = async () => {
try {
const result = await apiFetchOtpData({ kodeId: storedKodeId });
if (result.success && result.data?.nomor) {
setNomor(result.data.nomor);
} else {
throw new Error('OTP tidak valid');
throw new Error('Data OTP tidak valid');
}
} catch (error) {
console.error('Gagal muat OTP:', error);
console.error('Gagal memuat data OTP:', error);
toast.error('Kode verifikasi tidak valid');
router.push('/login');
router.replace('/login');
} finally {
setIsLoading(false);
}
};
fetchOtpData();
loadOtpData();
}, [router]);
// Verifikasi OTP
const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return;
setLoading(true);
try {
setLoading(true);
const verifyResult = await apiFetchVerifyOtp({ nomor, otp, kodeId });
if (verifyResult.success && verifyResult.user) {
// ✅ SET USER KE STORE
authStore.setUser({
id: verifyResult.user.id,
name: verifyResult.user.name,
roleId: Number(verifyResult.user.roleId),
});
cleanupStorage();
router.push('/admin/landing-page/profil/program-inovasi');
return;
}
// 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');
if (!verifyResult.success) {
// Registrasi baru?
if (
verifyResult.status === 404 &&
verifyResult.message?.includes('Akun tidak ditemukan')
) {
await handleNewRegistration();
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('/waiting-room'); // ✅
} else {
toast.error(regData.message || 'Registrasi gagal');
}
} else {
// Hanya tampilkan error jika bukan kasus "akun tidak ditemukan"
// Error lain
toast.error(verifyResult.message || 'Verifikasi gagal');
return;
}
// ✅ Verifikasi sukses → simpan user ke store
const user = verifyResult.user;
console.log('=== DEBUG USER ===');
console.log('Full user object:', user);
if (!user || !user.id) {
toast.error('Data pengguna tidak lengkap');
return;
}
const roleId = Number(user.roleId);
authStore.setUser({
id: user.id,
name: user.name || user.username || 'User',
roleId: roleId,
});
cleanupStorage();
const isUserActive = user.isActive ?? user.is_active ?? true;
// Redirect berdasarkan status approval
if (!isUserActive) {
router.replace('/waiting-room');
return;
}
// ✅ Switch statement lebih clean
let redirectPath: string;
switch (roleId) {
case 0:
case 1:
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case 2:
redirectPath = '/admin/kesehatan/posyandu';
break;
case 3:
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
break;
default:
redirectPath = '/admin';
console.warn('Unknown roleId:', roleId);
}
console.log('Redirecting to:', redirectPath);
router.replace(redirectPath);
} catch (error) {
console.error('Verifikasi error:', error);
toast.error('Terjadi kesalahan');
console.error('Error saat verifikasi:', error);
toast.error('Terjadi kesalahan sistem');
} finally {
setLoading(false);
}
};
// Registrasi baru
const handleNewRegistration = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan');
return;
}
try {
const res = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, otp, kodeId }),
});
const data = await res.json();
if (data.success) {
// Set user sementara (tanpa roleId, akan diisi saat approve)
authStore.setUser({
id: 'pending',
name: username,
});
cleanupStorage();
router.replace('/waiting-room');
} else {
toast.error(data.message || 'Registrasi gagal');
}
} catch (error) {
console.error('Error registrasi:', error);
toast.error('Gagal menyelesaikan registrasi');
}
};
const cleanupStorage = () => {
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor');
@@ -118,12 +181,15 @@ export default function Validasi() {
if (data.success) {
localStorage.setItem('auth_kodeId', data.kodeId);
toast.success('OTP baru dikirim');
} else {
toast.error(data.message || 'Gagal mengirim ulang OTP');
}
} catch {
toast.error('Gagal kirim ulang');
toast.error('Gagal menghubungi server');
}
};
// Loading
if (isLoading) {
return (
<Stack pos="relative" bg={colors.Bg} align="center" justify="center" h="100vh">
@@ -148,7 +214,7 @@ export default function Validasi() {
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
</Text>
</Box>
<Box>
<Box w="100%">
<Box mb={20}>
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
Masukkan Kode Verifikasi
@@ -176,7 +242,14 @@ export default function Validasi() {
<Text ta="center" size="sm" mt="md">
Tidak menerima kode?{' '}
<Button variant="subtle" onClick={handleResend} size="xs" p={0} h="auto" color={colors['blue-button']}>
<Button
variant="subtle"
onClick={handleResend}
size="xs"
p={0}
h="auto"
color={colors['blue-button']}
>
Kirim Ulang
</Button>
</Text>

View File

@@ -2,14 +2,13 @@
import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Select, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconCheck, IconSearch, IconX } from '@tabler/icons-react';
import { IconCheck, IconSearch, IconTrash, IconX } from '@tabler/icons-react';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import user from '../../_state/user/user-state';
function User() {
const [search, setSearch] = useState("");
return (
@@ -27,10 +26,10 @@ function User() {
}
function ListUser({ search }: { search: string }) {
const stateUser = useProxy(user.userState)
const stateRole = useProxy(user.roleState)
const [modalHapus, setModalHapus] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const stateUser = useProxy(user.userState);
const stateRole = useProxy(user.roleState);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const {
data,
@@ -42,21 +41,80 @@ function ListUser({ search }: { search: string }) {
const handleDelete = () => {
if (selectedId) {
stateUser.delete.submit(selectedId)
setModalHapus(false)
setSelectedId(null)
stateUser.findMany.load()
stateUser.deleteUser.delete(selectedId);
setModalHapus(false);
setSelectedId(null);
stateUser.findMany.load();
}
}
};
useShallowEffect(() => {
stateRole.findMany.load()
load(page, 10, search)
}, [page, search])
stateRole.findMany.load();
load(page, 10, search);
}, [page, search]);
const filteredData = data || []
// ✅ Helper function untuk nama role
const getRoleName = (roleId: string) => {
// Cari dari data role yang sudah diload
const role = stateRole.findMany.data.find((r) => r.id === roleId);
return role?.name || "Unknown Role";
};
// ✅ Handler untuk perubahan role dengan konfirmasi
const handleRoleChange = async (
userId: string,
username: string,
oldRoleId: string,
newRoleId: string
) => {
// Skip jika sama
if (oldRoleId === newRoleId) {
return true;
}
// ✅ Konfirmasi perubahan role
const confirmed = window.confirm(
`⚠️ PERINGATAN\n\n` +
`Mengubah role untuk "${username}" akan:\n` +
`• Logout user otomatis dari semua device\n` +
`• Mengubah akses menu sesuai role baru\n\n` +
`Role: ${getRoleName(oldRoleId)}${getRoleName(newRoleId)}\n\n` +
`Lanjutkan?`
);
if (!confirmed) {
// Reload data untuk reset dropdown ke nilai lama
stateUser.findMany.load(page, 10, search);
return false;
}
// ✅ Submit update
const success = await stateUser.update.submit({
id: userId,
roleId: newRoleId,
});
if (success) {
// Reload data setelah berhasil update
stateUser.findMany.load(page, 10, search);
}
return success;
};
// ✅ Handler untuk toggle isActive
const handleToggleActive = async (userId: string, currentStatus: boolean) => {
const success = await stateUser.update.submit({
id: userId,
isActive: !currentStatus,
});
if (success) {
stateUser.findMany.load(page, 10, search);
}
};
const filteredData = data || [];
if (loading || !data) {
return (
@@ -80,16 +138,19 @@ function ListUser({ search }: { search: string }) {
<TableTh style={{ width: '20%' }}>Nomor</TableTh>
<TableTh style={{ width: '20%' }}>Role</TableTh>
<TableTh style={{ width: '15%' }}>Aktif / Nonaktif</TableTh>
<TableTh style={{ width: '15%' }}>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%', }}>
<Text fw={500} truncate="end" lineClamp={1}>{item.username}</Text>
<TableTd style={{ width: '25%' }}>
<Text fw={500} truncate="end" lineClamp={1}>
{item.username}
</Text>
</TableTd>
<TableTd style={{ width: '20%', }}>
<TableTd style={{ width: '20%' }}>
<Text truncate fz="sm" c="dimmed">
{item.nomor}
</Text>
@@ -101,21 +162,22 @@ function ListUser({ search }: { search: string }) {
label: r.name,
value: r.id,
}))}
value={item.roleId} // ⬅ role milik user ini
onChange={async (val) => {
value={item.roleId}
onChange={(val) => {
if (!val) return;
await stateUser.update.submit({
id: item.id,
roleId: val, // ⬅ kirim roleId
});
// reload data supaya UI up-to-date
stateUser.findMany.load(page, 10, search);
// ✅ Panggil handleRoleChange dengan konfirmasi
handleRoleChange(
item.id,
item.username,
item.roleId,
val
);
}}
searchable
clearable={false} // role harus ada
clearable={false}
nothingFoundMessage="Role tidak ditemukan"
disabled={stateUser.update.loading}
/>
</TableTd>
@@ -127,26 +189,34 @@ function ListUser({ search }: { search: string }) {
<Button
variant="light"
color={item.isActive ? "green" : "red"}
onClick={async () => {
await stateUser.update.submit({
id: item.id,
isActive: !item.isActive, // toggle
});
stateUser.findMany.load(page, 10, search);
}}
onClick={() => handleToggleActive(item.id, item.isActive)}
disabled={stateUser.update.loading}
>
{item.isActive ? <IconCheck size={20} /> : <IconX size={20} />}
</Button>
</Tooltip>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
variant="light"
color='red'
disabled={stateUser.deleteUser.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={20} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<TableTd colSpan={5}>
<Center py={20}>
<Text color="dimmed">Tidak ada data user yang cocok</Text>
<Text c="dimmed">Tidak ada data user yang cocok</Text>
</Center>
</TableTd>
</TableTr>
@@ -155,6 +225,7 @@ function ListUser({ search }: { search: string }) {
</Table>
</Box>
</Paper>
<Center>
<Pagination
value={page}
@@ -169,6 +240,7 @@ function ListUser({ search }: { search: string }) {
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
@@ -180,4 +252,4 @@ function ListUser({ search }: { search: string }) {
);
}
export default User;
export default User;

View File

@@ -1,3 +1,4 @@
// /* eslint-disable react-hooks/exhaustive-deps */
// 'use client'
// import colors from "@/con/colors";
@@ -257,9 +258,6 @@
// }
'use client'
import colors from "@/con/colors";
@@ -271,9 +269,11 @@ import {
AppShellMain,
AppShellNavbar,
Burger,
Center,
Flex,
Group,
Image,
Loader,
NavLink,
ScrollArea,
Text,
@@ -289,23 +289,164 @@ import {
import _ from "lodash";
import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { useSnapshot } from "valtio";
import { useEffect, useState } from "react";
import { navigationByRole } from "./_com/navigationByRole";
import { useSnapshot } from "valtio";
import { toast } from "react-toastify";
export default function Layout({ children }: { children: React.ReactNode }) {
const [opened, { toggle }] = useDisclosure();
const [loading, setLoading] = useState(true);
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
//ambil user dari authStore
const {user} = useSnapshot(authStore)
console.log("Current User:", user); // 👈 Tambahkan ini
const { user } = useSnapshot(authStore);
console.log("Current user in store:", user);
//ambil navigation berdasarkan role
// ✅ Fetch user dari backend jika belum ada di store
useEffect(() => {
if (user) {
setLoading(false);
return;
} // Sudah ada → jangan fetch
const fetchUser = async () => {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
if (data.user) {
authStore.setUser({
id: data.user.id,
name: data.user.name,
roleId: Number(data.user.roleId),
});
} 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();
}, [user, router]); // ✅ Sekarang 'user' terdefinisi
// ✅ Polling untuk cek perubahan role setiap 30 detik
// Di layout.tsx - useEffect polling
useEffect(() => {
if (!user?.id) return;
const checkRoleUpdate = async () => {
try {
const res = await fetch('/api/auth/me');
const data = await res.json();
// ✅ Session tidak valid (sudah dihapus karena role berubah)
if (!data.success || !data.user) {
console.log('⚠️ Session tidak valid, logout...');
authStore.setUser(null);
// Clear cookie manual (backup)
document.cookie = `${process.env.NEXT_PUBLIC_SESSION_KEY}=; Max-Age=0; path=/;`;
toast.info('Role Anda telah diubah. Silakan login kembali.', {
autoClose: 5000,
});
router.push('/login');
return;
}
// Cek perubahan roleId (seharusnya tidak sampai sini jika session dihapus)
const currentRoleId = Number(data.user.roleId);
if (currentRoleId !== user.roleId) {
console.log('🔄 Role berubah! Dari', user.roleId, 'ke', currentRoleId);
// Update store
authStore.setUser({
id: data.user.id,
name: data.user.name || data.user.username,
roleId: currentRoleId,
});
// Redirect ke halaman default role baru
let redirectPath = '/admin';
switch (currentRoleId) {
case 0:
case 1:
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case 2:
redirectPath = '/admin/kesehatan/posyandu';
break;
case 3:
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
break;
}
toast.info('Role Anda telah diubah. Mengalihkan halaman...', {
autoClose: 5000,
});
router.push(redirectPath);
// Reload untuk clear semua state
setTimeout(() => {
window.location.reload();
}, 500);
}
} catch (error) {
console.error('❌ Error checking role update:', error);
}
};
// ✅ Polling setiap 10 detik (lebih responsif dari 30 detik)
const interval = setInterval(checkRoleUpdate, 10000);
// Juga cek saat window focus (user kembali ke tab)
const handleFocus = () => {
checkRoleUpdate();
};
window.addEventListener('focus', handleFocus);
return () => {
clearInterval(interval);
window.removeEventListener('focus', handleFocus);
};
}, [user, router]);
if (loading) {
return (
<AppShell>
<AppShellMain>
<Center h="100vh">
<Loader />
</Center>
</AppShellMain>
</AppShell>
);
}
// ✅ Ambil menu berdasarkan roleId
const currentNav = user?.roleId !== undefined
? navigationByRole[user.roleId as keyof typeof navigationByRole] || []
: [];
? (navigationByRole[user.roleId as keyof typeof navigationByRole] || [])
: [];
const handleLogout = () => {
authStore.setUser(null);
document.cookie = `${process.env.BASE_SESSION_KEY}=; Max-Age=0; path=/;`;
router.push('/login');
};
return (
<AppShell
@@ -388,13 +529,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }}
>
<Image
src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba"
w={20}
h={20}
radius="md"
loading="lazy"
<Image
src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba"
w={20}
h={20}
radius="md"
loading="lazy"
style={{
minWidth: '20px',
height: 'auto',
@@ -404,9 +545,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon
onClick={() => {
router.push("/darmasaba");
}}
onClick={handleLogout}
color={colors["blue-button"]}
radius="xl"
size="lg"

View File

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

View File

@@ -24,6 +24,11 @@ const User = new Elysia({ prefix: "/api/user" })
roleId: t.Optional(t.String()),
})
}
);
)
.put("/delUser/:id", userDelete, {
params: t.Object({
id: t.String(),
}),
});
export default User;

View File

@@ -31,6 +31,27 @@ export default async function userUpdate(context: Context) {
}
}
// ✅ CEK: Apakah roleId berubah?
let isRoleChanged = false;
let oldRoleId: string | null = null;
if (roleId) {
const currentUser = await prisma.user.findUnique({
where: { id },
select: {
roleId: true,
username: true,
}
});
if (currentUser && currentUser.roleId !== roleId) {
isRoleChanged = true;
oldRoleId = currentUser.roleId;
console.log(`🔄 Role berubah untuk ${currentUser.username}: ${oldRoleId}${roleId}`);
}
}
// Update user
const updatedUser = await prisma.user.update({
where: { id },
data: {
@@ -53,17 +74,43 @@ export default async function userUpdate(context: Context) {
}
});
// ✅ FORCE LOGOUT: Hapus UserSession jika role berubah
if (isRoleChanged) {
try {
const deletedSessions = await prisma.userSession.deleteMany({
where: { userId: id }
});
console.log(`🔒 Force logout user ${updatedUser.username} (${id})`);
console.log(` Deleted ${deletedSessions.count} session(s)`);
console.log(` Role: ${oldRoleId}${roleId}`);
} catch (sessionError: any) {
// Jika UserSession tidak ditemukan (user belum pernah login), skip error
if (sessionError.code !== 'P2025') {
console.error("⚠️ Error menghapus session:", sessionError);
} else {
console.log(` User ${updatedUser.username} belum pernah login`);
}
}
}
// ✅ Response dengan info tambahan
return {
success: true,
message: `User berhasil diupdate`,
message: isRoleChanged
? `User berhasil diupdate. ${updatedUser.username} akan logout otomatis.`
: "User berhasil diupdate",
data: updatedUser,
roleChanged: isRoleChanged, // Info untuk frontend
oldRoleId: oldRoleId,
newRoleId: roleId,
};
} catch (e: any) {
console.error("Error update user:", e);
console.error("Error update user:", e);
return {
success: false,
message: "Gagal mengupdate user",
message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
};
}
}
}

View File

@@ -1,9 +1,11 @@
// app/api/auth/_lib/session_create.ts
import { cookies } from "next/headers";
import { encrypt } from "./encrypt";
import prisma from "@/lib/prisma";
export async function sessionCreate({
sessionKey,
exp = "7 year",
exp = "30 day",
jwtSecret,
user,
}: {
@@ -30,12 +32,59 @@ export async function sessionCreate({
throw new Error("Token generation failed");
}
// ✅ HYBRID: Simpan token ke database UserSession
const userId = user.id as string;
if (userId) {
try {
// Hapus session lama user ini (logout device lain)
await prisma.userSession.deleteMany({
where: { userId },
});
// Parse expiration
const expiresDate = new Date();
const expMatch = exp.match(/(\d+)\s*(day|year)/);
if (expMatch) {
const [, num, unit] = expMatch;
const amount = parseInt(num);
if (unit === 'year') {
expiresDate.setFullYear(expiresDate.getFullYear() + amount);
} else if (unit === 'day') {
expiresDate.setDate(expiresDate.getDate() + amount);
}
} else {
// Default 30 hari
expiresDate.setDate(expiresDate.getDate() + 30);
}
// Buat session baru di database
await prisma.userSession.create({
data: {
userId,
token, // JWT token disimpan
expires: expiresDate,
active: true,
},
});
console.log(`✅ Session created for user ${userId}`);
} catch (dbError) {
console.error("⚠️ Error menyimpan session ke database:", dbError);
// Tetap lanjut meski gagal simpan ke DB (fallback ke JWT only)
}
}
// Set cookie
const cookieStore = await cookies();
cookieStore.set(sessionKey, token, {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: process.env.NODE_ENV === "production",
maxAge: 30 * 24 * 60 * 60, // 30 hari dalam detik
});
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,90 @@
// app/api/auth/_lib/session_verify.ts
import { cookies } from 'next/headers';
import { decrypt } from './decrypt';
import prisma from '@/lib/prisma';
/**
* Verifikasi session hybrid:
* 1. Decrypt JWT token
* 2. Cek apakah token masih ada di database (untuk force logout)
* 3. Return data user terbaru dari database
*/
export async function verifySession(): Promise<Record<string, unknown> | null> {
try {
const sessionKey = process.env.BASE_SESSION_KEY;
if (!sessionKey) {
throw new Error('BASE_SESSION_KEY tidak ditemukan di environment');
}
const jwtSecret = process.env.BASE_TOKEN_KEY;
if (!jwtSecret) {
throw new Error('BASE_TOKEN_KEY tidak ditemukan di environment');
}
const cookieStore = await cookies();
const token = cookieStore.get(sessionKey)?.value;
if (!token) {
return null;
}
// Step 1: Decrypt JWT
const jwtUser = await decrypt({ token, jwtSecret });
if (!jwtUser || !jwtUser.id) {
console.log('⚠️ JWT decrypt failed atau tidak ada user ID');
return null;
}
// Step 2: Cek database UserSession (untuk force logout)
try {
const dbSession = await prisma.userSession.findFirst({
where: {
userId: jwtUser.id as string,
token: token,
active: true,
OR: [
{ expires: null },
{ expires: { gte: new Date() } },
],
},
include: {
User: {
select: {
id: true,
username: true,
nomor: true,
roleId: true,
isActive: true,
},
},
},
});
// Token tidak ditemukan di database = sudah dihapus (force logout)
if (!dbSession) {
console.log('⚠️ Token valid tapi sudah dihapus dari database (force logout)');
return null;
}
// Step 3: Return data user terbaru dari database
// Ini penting agar roleId selalu update
return {
id: dbSession.User.id,
username: dbSession.User.username,
nomor: dbSession.User.nomor,
roleId: dbSession.User.roleId,
isActive: dbSession.User.isActive,
};
} catch (dbError) {
console.error("⚠️ Error cek database session:", dbError);
// Fallback: jika database error, tetap pakai JWT
return jwtUser;
}
} catch (error) {
console.warn('❌ Session verification failed:', error);
return null;
}
}

View File

@@ -1,47 +1,99 @@
// app/api/auth/finalize-registration/route.ts
import prisma from "@/lib/prisma";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
// import { sessionCreate } from "../_lib/session_create";
import { sessionCreate } from "../_lib/session_create";
export async function POST(req: Request) {
try {
const { nomor, username, kodeId } = await req.json();
const { nomor, username, kodeId, roleId } = 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 });
// Validasi input
if (!nomor || !username || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
// Buat user
const user = await prisma.user.create({
data: { username, nomor, isActive: false }
// Verifikasi OTP
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
});
if (!otpRecord?.isActive || otpRecord.nomor !== nomor) {
return NextResponse.json(
{ success: false, message: "OTP tidak valid" },
{ status: 400 }
);
}
// Cek apakah username sudah dipakai
const existingUser = await prisma.user.findUnique({
where: { username },
});
if (existingUser) {
return NextResponse.json(
{ success: false, message: "Username sudah digunakan" },
{ status: 400 }
);
}
// Buat user baru
const newUser = await prisma.user.create({
data: {
username,
nomor,
roleId: roleId || "1", // Default role
isActive: false, // Menunggu approval
},
});
// Nonaktifkan OTP
await prisma.kodeOtp.update({ where: { id: kodeId }, data: { isActive: false } });
// 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 },
// });
(await cookies()).set('desadarmasaba_user_id', user.id, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 30 * 24 * 60 * 60, // 30 hari
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
// ✅ CREATE SESSION (JWT + Database)
try {
await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: newUser.id,
nomor: newUser.nomor,
username: newUser.username,
roleId: newUser.roleId,
isActive: false, // User baru belum aktif
},
});
} catch (sessionError) {
console.error("❌ Error creating session:", sessionError);
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: "Registrasi berhasil. Menunggu persetujuan admin.",
user: {
id: newUser.id,
name: newUser.username,
roleId: newUser.roleId,
isActive: false,
},
});
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 });
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,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

@@ -1,30 +1,45 @@
import prisma from "@/lib/prisma";
import { NextRequest } from "next/server";
// Jika pakai custom session (bukan next-auth), ganti dengan logic session-mu
// app/api/auth/me/route.ts
import { NextResponse } from 'next/server';
import { verifySession } from '../_lib/session_verify';
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
export async function GET() {
try {
// ✅ Verify session (hybrid: JWT + Database)
const user = await verifySession();
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
if (!user) {
return NextResponse.json(
{
success: false,
message: "Session tidak valid",
user: null
},
{ status: 401 }
);
}
// Data user sudah fresh dari database (via verifySession)
return NextResponse.json({
success: true,
user: {
id: user.id,
name: user.username,
username: user.username,
nomor: user.nomor,
roleId: user.roleId,
isActive: user.isActive,
},
});
} catch (error) {
console.error("❌ Error in /api/auth/me:", error);
return NextResponse.json(
{
success: false,
message: "Terjadi kesalahan",
user: null
},
{ status: 500 }
);
}
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

@@ -4,13 +4,6 @@ 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();
@@ -41,7 +34,7 @@ export async function POST(req: Request) {
);
}
// Pastikan tipe data cocok (OTP di DB = number)
// Validasi OTP
const receivedOtp = Number(otp);
if (isNaN(receivedOtp) || otpRecord.otp !== receivedOtp) {
return NextResponse.json(
@@ -76,26 +69,22 @@ export async function POST(req: Request) {
);
}
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) {
// ✅ CREATE SESSION (JWT + Database)
try {
await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: user.id,
nomor: user.nomor,
username: user.username,
roleId: user.roleId,
isActive: user.isActive,
},
});
} catch (sessionError) {
console.error("❌ Error creating session:", sessionError);
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
@@ -108,34 +97,25 @@ export async function POST(req: Request) {
data: { isActive: false },
});
const userData = {
id: user.id,
name: user.username, // atau user.nama jika ada kolom nama
roleId: user.roleId,
};
// Set cookie & respons
const response = NextResponse.json(
{
success: true,
message: "Berhasil login",
user: userData,
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,
// Update lastLogin
await prisma.user.update({
where: { id: user.id },
data: { lastLogin: new Date() },
});
return NextResponse.json({
success: true,
message: user.isActive ? "Berhasil login" : "Menunggu persetujuan",
user: {
id: user.id,
name: user.username,
roleId: user.roleId,
isActive: user.isActive,
},
});
return response;
} catch (error) {
console.error("Verify OTP Error:", error);
console.error("Verify OTP Error:", error);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat verifikasi" },
{ status: 500 }

View File

@@ -19,37 +19,42 @@ export default function WaitingRoom() {
// const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const interval = setInterval(async () => {
try {
const data = await fetchUser();
if (!isMounted) return;
setUser(data.user);
useEffect(() => {
let isMounted = true;
const interval = setInterval(async () => {
try {
const data = await fetchUser();
if (!isMounted) return;
// Jika sudah aktif, redirect ke dashboard admin
if (data.user.isActive) {
clearInterval(interval);
router.push('/admin'); // atau /dashboard
}
} catch (err: any) {
if (!isMounted) return;
setError(err.message || 'Gagal memuat status');
const currentUser = data.user;
setUser(currentUser);
// ✅ Sekarang isActive tersedia!
if (currentUser?.isActive) {
clearInterval(interval);
// Redirect ke login jika unauthorized
if (err.message === 'Unauthorized') {
router.push('/login');
// Redirect ke halaman admin sesuai role
if (currentUser.roleId === 0) {
router.push('/admin/landing-page/profil/program-inovasi');
} else {
router.push('/admin'); // atau halaman default role
}
}
}, 2000); // Cek setiap 2 detik
// Cleanup
return () => {
isMounted = false;
} catch (err: any) {
if (!isMounted) return;
setError(err.message || 'Gagal memuat status');
clearInterval(interval);
};
}, [router]);
if (err.message === 'Unauthorized') {
router.push('/login');
}
}
}, 2000);
return () => {
isMounted = false;
clearInterval(interval);
};
}, [router]);
if (error) {
return (

View File

@@ -3,7 +3,7 @@ import { proxy } from 'valtio';
export type User = {
id: string;
name: string;
roleId: number; // 0, 1, 2, 3
roleId?: number; // 0, 1, 2, 3
};
export const authStore = proxy<{