Compare commits

..

12 Commits

32 changed files with 606 additions and 219 deletions

View File

@@ -1,6 +1,5 @@
'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 { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
import { useRouter } from 'next/navigation';

View File

@@ -1,7 +1,7 @@
// app/registrasi/page.tsx
'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 colors from '@/con/colors';
import {

View File

@@ -19,7 +19,7 @@ import { authStore } from '@/store/authStore';
export default function Validasi() {
const router = useRouter();
const [nomor, setNomor] = useState<string | null>(null);
const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false);
@@ -31,11 +31,11 @@ export default function Validasi() {
useEffect(() => {
const checkFlow = async () => {
try {
const res = await fetch('/api/get-flow', {
const res = await fetch('/api/auth/get-flow', {
credentials: 'include'
});
const data = await res.json();
if (data.success) {
setIsRegistrationFlow(data.flow === 'register');
console.log('🔍 Flow detected from cookie:', data.flow);
@@ -45,7 +45,7 @@ export default function Validasi() {
setIsRegistrationFlow(false);
}
};
checkFlow();
}, []);
@@ -60,7 +60,7 @@ export default function Validasi() {
setKodeId(storedKodeId);
const loadOtpData = async () => {
try {
const res = await fetch(`/api/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
const res = await fetch(`/api/auth/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
const result = await res.json();
if (res.ok && result.data?.nomor) {
@@ -110,7 +110,8 @@ export default function Validasi() {
return;
}
const verifyRes = await fetch('/api/verify-otp-register', {
// ✅ Verify OTP
const verifyRes = await fetch('/api/auth/verify-otp-register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
@@ -123,26 +124,32 @@ export default function Validasi() {
return;
}
const finalizeRes = await fetch('/api/finalize-registration', {
// ✅ Finalize registration
const finalizeRes = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, kodeId }),
body: JSON.stringify({ nomor: cleanNomor, username, kodeId }),
credentials: 'include'
});
const data = await finalizeRes.json();
if (data.success || finalizeRes.redirected) {
// ✅ Cleanup setelah registrasi sukses
// ✅ Check JSON response (bukan redirect)
if (data.success) {
toast.success('Registrasi berhasil! Menunggu persetujuan admin.');
await cleanupStorage();
window.location.href = '/waiting-room';
// ✅ Client-side redirect
setTimeout(() => {
window.location.href = '/waiting-room';
}, 1000);
} else {
toast.error(data.message || 'Registrasi gagal');
}
};
const handleLoginVerification = async () => {
const loginRes = await fetch('/api/verify-otp-login', {
const loginRes = await fetch('/api/auth/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
@@ -197,10 +204,10 @@ export default function Validasi() {
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username');
// Clear cookie
try {
await fetch('/api/clear-flow', {
await fetch('/api/auth/clear-flow', {
method: 'POST',
credentials: 'include'
});
@@ -212,7 +219,7 @@ export default function Validasi() {
const handleResend = async () => {
if (!nomor) return;
try {
const res = await fetch('/api/resend', {
const res = await fetch('/api/auth/resend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }),

View File

@@ -0,0 +1,25 @@
// src/app/admin/_com/getMenuIdsByRoleId.ts
import { navBar, role1, role2, role3 } from '@/app/admin/_com/list_PageAdmin';
/**
* Mengembalikan daftar ID menu (string[]) berdasarkan roleId
*/
export function getMenuIdsByRoleId(roleId: string | number): string[] {
const id = typeof roleId === 'string' ? parseInt(roleId, 10) : roleId;
switch (id) {
case 0:
// Asumsikan devBar ada dan punya struktur sama
return []; // atau sesuaikan jika ada devBar
case 1:
return navBar.map(section => section.id);
case 2:
return role1.map(section => section.id);
case 3:
return role2.map(section => section.id);
case 4:
return role3.map(section => section.id);
default:
return [];
}
}

View File

@@ -93,6 +93,7 @@ function ListUser({ search }: { search: string }) {
const success = await stateUser.update.submit({
id: userId,
roleId: newRoleId,
});
if (success) {
@@ -136,9 +137,10 @@ function ListUser({ search }: { search: string }) {
}
};
const filteredData = (data || []).filter(
(item) => item.roleId !== "0" // asumsikan id role SUPERADMIN = "0"
);
const filteredData = (data || []).filter((item) => {
return item.roleId !== "0" && item.roleId !== "1";
});
if (loading || !data) {
return (
@@ -183,7 +185,7 @@ function ListUser({ search }: { search: string }) {
<Select
placeholder="Pilih role"
data={stateRole.findMany.data
.filter(r => r.id !== "0") // ❌ Sembunyikan SUPERADMIN
.filter(r => r.id !== "0" && r.id !== "1") // ❌ Sembunyikan SUPERADMIN dan DEVELOPER
.map(r => ({
label: r.name,
value: r.id,

View File

@@ -1,3 +1,399 @@
// 'use client'
// import colors from "@/con/colors";
// import { authStore } from "@/store/authStore";
// import {
// ActionIcon,
// AppShell,
// AppShellHeader,
// AppShellMain,
// AppShellNavbar,
// Burger,
// Center,
// Flex,
// Group,
// Image,
// Loader,
// NavLink,
// ScrollArea,
// Text,
// Tooltip,
// rem
// } from "@mantine/core";
// import { useDisclosure } from "@mantine/hooks";
// import {
// IconChevronLeft,
// IconChevronRight,
// IconLogout2
// } from "@tabler/icons-react";
// import _ from "lodash";
// import Link from "next/link";
// import { useRouter, useSelectedLayoutSegments } from "next/navigation";
// import { useEffect, useState } from "react";
// // import { useSnapshot } from "valtio";
// import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
// export default function Layout({ children }: { children: React.ReactNode }) {
// const [opened, { toggle }] = useDisclosure();
// const [loading, setLoading] = useState(true);
// const [isLoggingOut, setIsLoggingOut] = useState(false);
// const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
// const router = useRouter();
// const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// // const { user } = useSnapshot(authStore);
// // console.log("Current user in store:", user);
// // ✅ FIX: Selalu fetch user data setiap kali komponen mount
// useEffect(() => {
// const fetchUser = async () => {
// try {
// const res = await fetch('/api/auth/me');
// const data = await res.json();
// if (data.user) {
// // ✅ Check if user is NOT active → redirect to waiting room
// if (!data.user.isActive) {
// authStore.setUser(null);
// router.replace('/waiting-room');
// return;
// }
// // ✅ Fetch menuIds
// const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`);
// const menuData = await menuRes.json();
// const menuIds = menuData.success && Array.isArray(menuData.menuIds)
// ? [...menuData.menuIds]
// : null;
// // ✅ Set user dengan menuIds yang fresh
// authStore.setUser({
// id: data.user.id,
// name: data.user.name,
// roleId: Number(data.user.roleId),
// menuIds,
// isActive: data.user.isActive
// });
// // ✅ TAMBAHKAN INI: Redirect ke dashboard sesuai roleId
// const currentPath = window.location.pathname;
// const expectedPath = getRedirectPath(Number(data.user.roleId));
// // Jika user di halaman /admin tapi bukan di path yang sesuai roleId
// if (currentPath === '/admin' || !currentPath.startsWith(expectedPath)) {
// router.replace(expectedPath);
// }
// } else {
// authStore.setUser(null);
// router.replace('/login');
// }
// } catch (error) {
// console.error('Gagal memuat data pengguna:', error);
// authStore.setUser(null);
// router.replace('/login');
// } finally {
// setLoading(false);
// }
// };
// fetchUser();
// }, [router]);
// // ✅ Fungsi helper untuk get redirect path
// const getRedirectPath = (roleId: number): string => {
// switch (roleId) {
// case 0: // DEVELOPER
// case 1: // SUPERADMIN
// case 2: // ADMIN_DESA
// return '/admin/landing-page/profil/program-inovasi';
// case 3: // ADMIN_KESEHATAN
// return '/admin/kesehatan/posyandu';
// case 4: // ADMIN_PENDIDIKAN
// return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
// default:
// return '/admin';
// }
// };
// if (loading) {
// return (
// <AppShell>
// <AppShellMain>
// <Center h="100vh">
// <Loader />
// </Center>
// </AppShellMain>
// </AppShell>
// );
// }
// // ✅ Ambil menu berdasarkan roleId dan menuIds
// const currentNav = authStore.user
// ? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
// : [];
// const handleLogout = async () => {
// try {
// setIsLoggingOut(true);
// // ✅ Panggil API logout untuk clear session di server
// const response = await fetch('/api/auth/logout', { method: 'POST' });
// const result = await response.json();
// if (result.success) {
// // Clear user data dari store
// authStore.setUser(null);
// // Clear localStorage
// localStorage.removeItem('auth_nomor');
// localStorage.removeItem('auth_kodeId');
// // Force reload untuk reset semua state
// window.location.href = '/login';
// } else {
// console.error('Logout failed:', result.message);
// // Tetap redirect meskipun gagal
// authStore.setUser(null);
// window.location.href = '/login';
// }
// } catch (error) {
// console.error('Error during logout:', error);
// // Tetap clear store dan redirect jika error
// authStore.setUser(null);
// window.location.href = '/login';
// } finally {
// setIsLoggingOut(false);
// }
// };
// return (
// <AppShell
// suppressHydrationWarning
// header={{ height: 64 }}
// navbar={{
// width: { base: 260, sm: 280, lg: 300 },
// breakpoint: 'sm',
// collapsed: {
// mobile: !opened,
// desktop: !desktopOpened,
// },
// }}
// padding="md"
// >
// <AppShellHeader
// style={{
// background: "linear-gradient(90deg, #ffffff, #f9fbff)",
// borderBottom: `1px solid ${colors["blue-button"]}20`,
// padding: '0 16px',
// }}
// px={{ base: 'sm', sm: 'md' }}
// py={{ base: 'xs', sm: 'sm' }}
// >
// <Group w="100%" h="100%" justify="space-between" wrap="nowrap">
// <Flex align="center" gap="sm">
// <Image
// src="/assets/images/darmasaba-icon.png"
// alt="Logo Darmasaba"
// w={{ base: 32, sm: 40 }}
// h={{ base: 32, sm: 40 }}
// radius="md"
// loading="lazy"
// style={{
// minWidth: '32px',
// height: 'auto',
// }}
// />
// <Text
// fw={700}
// c={colors["blue-button"]}
// fz={{ base: 'md', sm: 'xl' }}
// >
// Admin Darmasaba
// </Text>
// </Flex>
// <Group gap="xs">
// {!desktopOpened && (
// <Tooltip label="Buka Navigasi" position="bottom" withArrow>
// <ActionIcon
// variant="light"
// radius="xl"
// size="lg"
// onClick={toggleDesktop}
// color={colors["blue-button"]}
// >
// <IconChevronRight />
// </ActionIcon>
// </Tooltip>
// )}
// <Burger
// opened={opened}
// onClick={toggle}
// hiddenFrom="sm"
// size="md"
// color={colors["blue-button"]}
// mr="xs"
// />
// <Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
// <ActionIcon
// onClick={() => {
// router.push("/darmasaba");
// }}
// color={colors["blue-button"]}
// radius="xl"
// size="lg"
// variant="gradient"
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
// >
// <Image
// src="/assets/images/darmasaba-icon.png"
// alt="Logo Darmasaba"
// w={20}
// h={20}
// radius="md"
// loading="lazy"
// style={{
// minWidth: '20px',
// height: 'auto',
// }}
// />
// </ActionIcon>
// </Tooltip>
// <Tooltip label="Keluar" position="bottom" withArrow>
// <ActionIcon
// onClick={handleLogout}
// color={colors["blue-button"]}
// radius="xl"
// size="lg"
// variant="gradient"
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
// loading={isLoggingOut}
// disabled={isLoggingOut}
// >
// <IconLogout2 size={22} />
// </ActionIcon>
// </Tooltip>
// </Group>
// </Group>
// </AppShellHeader>
// <AppShellNavbar
// component={ScrollArea}
// style={{
// background: "#ffffff",
// borderRight: `1px solid ${colors["blue-button"]}20`,
// }}
// p={{ base: 'xs', sm: 'sm' }}
// >
// <AppShell.Section p="sm">
// {currentNav.map((v, k) => {
// const isParentActive = segments.includes(_.lowerCase(v.name));
// return (
// <NavLink
// key={k}
// defaultOpened={isParentActive}
// c={isParentActive ? colors["blue-button"] : "gray"}
// label={
// <Text fw={isParentActive ? 600 : 400} fz="sm">
// {v.name}
// </Text>
// }
// style={{
// borderRadius: rem(10),
// marginBottom: rem(4),
// transition: "background 150ms ease",
// }}
// styles={{
// root: {
// '&:hover': {
// backgroundColor: 'rgba(25, 113, 194, 0.05)',
// },
// },
// }}
// variant="light"
// active={isParentActive}
// >
// {v.children.map((child, key) => {
// const isChildActive = segments.includes(
// _.lowerCase(child.name)
// );
// return (
// <NavLink
// key={key}
// href={child.path}
// c={isChildActive ? colors["blue-button"] : "gray"}
// label={
// <Text fw={isChildActive ? 600 : 400} fz="sm">
// {child.name}
// </Text>
// }
// styles={{
// root: {
// borderRadius: rem(8),
// marginBottom: rem(2),
// transition: 'background 150ms ease',
// padding: '6px 12px',
// '&:hover': {
// backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)',
// },
// ...(isChildActive && {
// backgroundColor: 'rgba(25, 113, 194, 0.1)',
// }),
// },
// }}
// active={isChildActive}
// component={Link}
// />
// );
// })}
// </NavLink>
// );
// })}
// </AppShell.Section>
// <AppShell.Section py="md">
// <Group justify="end" pr="sm">
// <Tooltip
// label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"}
// position="top"
// withArrow
// >
// <ActionIcon
// variant="light"
// radius="xl"
// size="lg"
// onClick={toggleDesktop}
// color={colors["blue-button"]}
// >
// <IconChevronLeft />
// </ActionIcon>
// </Tooltip>
// </Group>
// </AppShell.Section>
// </AppShellNavbar>
// <AppShellMain
// style={{
// background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)",
// minHeight: "100vh",
// }}
// >
// {children}
// </AppShellMain>
// </AppShell>
// );
// }
// app/admin/layout.tsx
'use client'
import colors from "@/con/colors";
@@ -30,7 +426,6 @@ import _ from "lodash";
import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
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 }) {
@@ -40,42 +435,53 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// const { user } = useSnapshot(authStore);
// console.log("Current user in store:", user);
// ✅ FIX: Selalu fetch user data setiap kali komponen mount
useEffect(() => {
const fetchUser = async () => {
try {
const res = await fetch('/api/me');
const res = await fetch('/api/auth/me', {
credentials: 'include' // ✅ ADD credentials
});
const data = await res.json();
if (data.user) {
// Check if user is active
// Check if user is NOT active → redirect to waiting room
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}`);
// ✅ Fetch menuIds
const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`, {
credentials: 'include' // ✅ ADD credentials
});
const menuData = await menuRes.json();
const menuIds = menuData.success && Array.isArray(menuData.menuIds)
? [...menuData.menuIds]
: null;
// ✅ Set user dengan menuIds yang fresh dari database
// ✅ Set user dengan menuIds yang fresh
authStore.setUser({
id: data.user.id,
name: data.user.name,
roleId: Number(data.user.roleId),
menuIds, // menuIds terbaru
menuIds,
isActive: data.user.isActive
});
// ✅ IMPROVED: Redirect ONLY if di root /admin
const currentPath = window.location.pathname;
if (currentPath === '/admin') {
const expectedPath = getRedirectPath(Number(data.user.roleId));
console.log('🔄 Redirecting from /admin to:', expectedPath);
router.replace(expectedPath);
}
// ✅ Jangan redirect jika user sudah di path yang valid
} else {
authStore.setUser(null);
router.replace('/login');
@@ -90,7 +496,22 @@ export default function Layout({ children }: { children: React.ReactNode }) {
};
fetchUser();
}, [router]); // ✅ Hapus dependency pada authStore.user
}, [router]); // ✅ Only depend on router
const getRedirectPath = (roleId: number): string => {
switch (roleId) {
case 0: // DEVELOPER
case 1: // SUPERADMIN
case 2: // ADMIN_DESA
return '/admin/landing-page/profil/program-inovasi';
case 3: // ADMIN_KESEHATAN
return '/admin/kesehatan/posyandu';
case 4: // ADMIN_PENDIDIKAN
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:
return '/admin';
}
};
if (loading) {
return (
@@ -104,7 +525,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
);
}
// ✅ Ambil menu berdasarkan roleId dan menuIds
const currentNav = authStore.user
? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
: [];
@@ -112,30 +532,26 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const handleLogout = async () => {
try {
setIsLoggingOut(true);
// ✅ Panggil API logout untuk clear session di server
const response = await fetch('/api/logout', { method: 'POST' });
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include' // ✅ ADD credentials
});
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
localStorage.removeItem('auth_username');
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 {
@@ -157,6 +573,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}}
padding="md"
>
{/* ... rest of your JSX (Header, Navbar, Main) sama seperti sebelumnya ... */}
<AppShellHeader
style={{
background: "linear-gradient(90deg, #ffffff, #f9fbff)",
@@ -175,16 +592,9 @@ export default function Layout({ children }: { children: React.ReactNode }) {
h={{ base: 32, sm: 40 }}
radius="md"
loading="lazy"
style={{
minWidth: '32px',
height: 'auto',
}}
style={{ minWidth: '32px', height: 'auto' }}
/>
<Text
fw={700}
c={colors["blue-button"]}
fz={{ base: 'md', sm: 'xl' }}
>
<Text fw={700} c={colors["blue-button"]} fz={{ base: 'md', sm: 'xl' }}>
Admin Darmasaba
</Text>
</Flex>
@@ -192,63 +602,22 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<Group gap="xs">
{!desktopOpened && (
<Tooltip label="Buka Navigasi" position="bottom" withArrow>
<ActionIcon
variant="light"
radius="xl"
size="lg"
onClick={toggleDesktop}
color={colors["blue-button"]}
>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
<IconChevronRight />
</ActionIcon>
</Tooltip>
)}
<Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="md"
color={colors["blue-button"]}
mr="xs"
/>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={colors["blue-button"]} mr="xs" />
<Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
<ActionIcon
onClick={() => {
router.push("/darmasaba");
}}
color={colors["blue-button"]}
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }}
>
<Image
src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba"
w={20}
h={20}
radius="md"
loading="lazy"
style={{
minWidth: '20px',
height: 'auto',
}}
/>
<ActionIcon onClick={() => router.push("/darmasaba")} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }}>
<Image src="/assets/images/darmasaba-icon.png" alt="Logo Darmasaba" w={20} h={20} radius="md" loading="lazy" style={{ minWidth: '20px', height: 'auto' }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon
onClick={handleLogout}
color={colors["blue-button"]}
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }}
loading={isLoggingOut}
disabled={isLoggingOut}
>
<ActionIcon onClick={handleLogout} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }} loading={isLoggingOut} disabled={isLoggingOut}>
<IconLogout2 size={22} />
</ActionIcon>
</Tooltip>
@@ -256,75 +625,17 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</Group>
</AppShellHeader>
<AppShellNavbar
component={ScrollArea}
style={{
background: "#ffffff",
borderRight: `1px solid ${colors["blue-button"]}20`,
}}
p={{ base: 'xs', sm: 'sm' }}
>
<AppShellNavbar component={ScrollArea} style={{ background: "#ffffff", borderRight: `1px solid ${colors["blue-button"]}20` }} p={{ base: 'xs', sm: 'sm' }}>
{/* ... Navbar content sama seperti sebelumnya ... */}
<AppShell.Section p="sm">
{currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name));
return (
<NavLink
key={k}
defaultOpened={isParentActive}
c={isParentActive ? colors["blue-button"] : "gray"}
label={
<Text fw={isParentActive ? 600 : 400} fz="sm">
{v.name}
</Text>
}
style={{
borderRadius: rem(10),
marginBottom: rem(4),
transition: "background 150ms ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'rgba(25, 113, 194, 0.05)',
},
},
}}
variant="light"
active={isParentActive}
>
<NavLink key={k} defaultOpened={isParentActive} c={isParentActive ? colors["blue-button"] : "gray"} label={<Text fw={isParentActive ? 600 : 400} fz="sm">{v.name}</Text>} style={{ borderRadius: rem(10), marginBottom: rem(4), transition: "background 150ms ease" }} styles={{ root: { '&:hover': { backgroundColor: 'rgba(25, 113, 194, 0.05)' } } }} variant="light" active={isParentActive}>
{v.children.map((child, key) => {
const isChildActive = segments.includes(
_.lowerCase(child.name)
);
const isChildActive = segments.includes(_.lowerCase(child.name));
return (
<NavLink
key={key}
href={child.path}
c={isChildActive ? colors["blue-button"] : "gray"}
label={
<Text fw={isChildActive ? 600 : 400} fz="sm">
{child.name}
</Text>
}
styles={{
root: {
borderRadius: rem(8),
marginBottom: rem(2),
transition: 'background 150ms ease',
padding: '6px 12px',
'&:hover': {
backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)',
},
...(isChildActive && {
backgroundColor: 'rgba(25, 113, 194, 0.1)',
}),
},
}}
active={isChildActive}
component={Link}
/>
<NavLink key={key} href={child.path} c={isChildActive ? colors["blue-button"] : "gray"} label={<Text fw={isChildActive ? 600 : 400} fz="sm">{child.name}</Text>} styles={{ root: { borderRadius: rem(8), marginBottom: rem(2), transition: 'background 150ms ease', padding: '6px 12px', '&:hover': { backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)' }, ...(isChildActive && { backgroundColor: 'rgba(25, 113, 194, 0.1)' }) } }} active={isChildActive} component={Link} />
);
})}
</NavLink>
@@ -334,18 +645,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<AppShell.Section py="md">
<Group justify="end" pr="sm">
<Tooltip
label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"}
position="top"
withArrow
>
<ActionIcon
variant="light"
radius="xl"
size="lg"
onClick={toggleDesktop}
color={colors["blue-button"]}
>
<Tooltip label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"} position="top" withArrow>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}>
<IconChevronLeft />
</ActionIcon>
</Tooltip>
@@ -353,12 +654,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</AppShell.Section>
</AppShellNavbar>
<AppShellMain
style={{
background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)",
minHeight: "100vh",
}}
>
<AppShellMain style={{ background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)", minHeight: "100vh" }}>
{children}
</AppShellMain>
</AppShell>

View File

@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { getMenuIdsByRoleId } from "@/app/admin/(dashboard)/user&role/_com/getMenuIdByRole";
import prisma from "@/lib/prisma";
import { Context } from "elysia";
@@ -23,7 +24,7 @@ export default async function userUpdate(context: Context) {
const currentUser = await prisma.user.findUnique({
where: { id },
select: { roleId: true, isActive: true }
select: { roleId: true, isActive: true },
});
if (!currentUser) {
@@ -31,7 +32,29 @@ export default async function userUpdate(context: Context) {
}
const isRoleChanged = roleId && currentUser.roleId !== roleId;
const isActiveChanged = isActive !== undefined && currentUser.isActive !== isActive;
const isActiveChanged =
isActive !== undefined && currentUser.isActive !== isActive;
// ✅ Jika role berubah, reset dan set ulang akses menu
if (isRoleChanged && roleId) {
// Hapus akses lama
await prisma.userMenuAccess.deleteMany({
where: { userId: id }
});
// Ambil menu default untuk role baru
const menuIds = getMenuIdsByRoleId(roleId);
if (menuIds.length > 0) {
// Buat akses baru
await prisma.userMenuAccess.createMany({
data: menuIds.map(menuId => ({
userId: id,
menuId
}))
});
}
}
// Update user
const updatedUser = await prisma.user.update({
@@ -48,10 +71,11 @@ export default async function userUpdate(context: Context) {
nomor: true,
isActive: true,
roleId: true,
role: { select: { name: true } }
}
role: { select: { name: true } },
},
});
// ✅ HAPUS SEMUA SESI USER DI DATABASE
if (isRoleChanged) {
await prisma.userSession.deleteMany({ where: { userId: id } });
@@ -62,11 +86,13 @@ export default async function userUpdate(context: Context) {
roleChanged: isRoleChanged,
isActiveChanged,
data: updatedUser,
message: isRoleChanged
message: isRoleChanged
? `Role ${updatedUser.username} diubah. User akan logout otomatis.`
: isActiveChanged
? `${updatedUser.username} ${isActive ? 'diaktifkan' : 'dinonaktifkan'}.`
: "User berhasil diupdate"
? `${updatedUser.username} ${
isActive ? "diaktifkan" : "dinonaktifkan"
}.`
: "User berhasil diupdate",
};
} catch (e: any) {
console.error("❌ Error update user:", e);
@@ -75,4 +101,4 @@ export default async function userUpdate(context: Context) {
message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
};
}
}
}

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// app/api/_lib/api_fetch_auth.ts
// app/api/auth/_lib/api_fetch_auth.ts
// app/api/_lib/api_fetch_auth.ts
// app/api/auth/_lib/api_fetch_auth.ts
export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
if (!nomor || nomor.replace(/\D/g, '').length < 10) {
@@ -10,7 +10,7 @@ export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
const cleanPhone = nomor.replace(/\D/g, '');
const response = await fetch("/api/login", {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomor: cleanPhone }),
@@ -22,7 +22,7 @@ export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
try {
data = await response.json();
} catch (e) {
console.error("Non-JSON response from /api/login:", await response.text());
console.error("Non-JSON response from /api/auth/login:", await response.text());
throw new Error('Respons server tidak valid');
}
@@ -55,7 +55,7 @@ export const apiFetchRegister = async ({
const cleanPhone = nomor.replace(/\D/g, '');
if (cleanPhone.length < 10) throw new Error('Nomor tidak valid');
const response = await fetch("/api/register", {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }),
@@ -73,7 +73,7 @@ export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
throw new Error('Kode ID tidak valid');
}
const response = await fetch("/api/otp-data", {
const response = await fetch("/api/auth/otp-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kodeId }),
@@ -90,7 +90,7 @@ export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
// Ganti endpoint ke verify-otp-login
export const apiFetchVerifyOtp = async ({ nomor, otp, kodeId }: { nomor: string; otp: string; kodeId: string }) => {
const response = await fetch('/api/verify-otp-login', {
const response = await fetch('/api/auth/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),

View File

@@ -30,7 +30,12 @@ export async function POST(req: Request) {
);
}
const otpRecord = await prisma.kodeOtp.findUnique({ where: { id: kodeId } });
// Verify OTP
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
select: { nomor: true, isActive: true }
});
if (!otpRecord?.isActive || otpRecord.nomor !== cleanNomor) {
return NextResponse.json(
{ success: false, message: "OTP tidak valid" },
@@ -38,6 +43,7 @@ export async function POST(req: Request) {
);
}
// Check duplicate username
if (await prisma.user.findFirst({ where: { username } })) {
return NextResponse.json(
{ success: false, message: "Username sudah digunakan" },
@@ -45,12 +51,20 @@ export async function POST(req: Request) {
);
}
// 🔥 Tentukan roleId sebagai STRING
const targetRoleId = "1"; // ✅ string, bukan number
// Check duplicate nomor
if (await prisma.user.findUnique({ where: { nomor: cleanNomor } })) {
return NextResponse.json(
{ success: false, message: "Nomor sudah terdaftar" },
{ status: 409 }
);
}
// Validasi role (gunakan string)
// 🔥 Tentukan roleId sebagai STRING
const targetRoleId = "2"; // ✅ Default ADMIN_DESA (roleId "2")
// Validasi role exists
const roleExists = await prisma.role.findUnique({
where: { id: targetRoleId }, // ✅ id bertipe string
where: { id: targetRoleId },
select: { id: true }
});
@@ -61,17 +75,17 @@ export async function POST(req: Request) {
);
}
// Buat user dengan roleId string
// ✅ Create user (inactive, waiting approval)
const newUser = await prisma.user.create({
data: {
username,
nomor,
roleId: targetRoleId, // ✅ string
isActive: false,
nomor: cleanNomor,
roleId: targetRoleId,
isActive: false, // Waiting for admin approval
},
});
// Berikan akses menu
// Berikan akses menu default based on role
const menuIds = DEFAULT_MENUS_BY_ROLE[targetRoleId] || [];
if (menuIds.length > 0) {
await prisma.userMenuAccess.createMany({
@@ -79,14 +93,17 @@ export async function POST(req: Request) {
userId: newUser.id,
menuId,
})),
skipDuplicates: true, // ✅ Avoid duplicate errors
});
}
// ✅ Mark OTP as used
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
// ✅ Create session token
const token = await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
@@ -95,25 +112,35 @@ export async function POST(req: Request) {
id: newUser.id,
nomor: newUser.nomor,
username: newUser.username,
roleId: newUser.roleId, // string
isActive: false,
roleId: newUser.roleId,
isActive: false, // User belum aktif
},
invalidatePrevious: false,
});
const response = NextResponse.redirect(new URL('/waiting-room', req.url));
response.cookies.set(process.env.BASE_SESSION_KEY!, token, {
// ✅ PENTING: Return JSON response (bukan redirect)
const response = NextResponse.json({
success: true,
message: "Registrasi berhasil. Menunggu persetujuan admin.",
userId: newUser.id,
});
// ✅ Set session cookie
const cookieName = process.env.BASE_SESSION_KEY || 'session';
response.cookies.set(cookieName, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: 'lax',
path: "/",
maxAge: 30 * 24 * 60 * 60,
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return response;
} catch (error) {
console.error("❌ Finalize Registration Error:", error);
return NextResponse.json(
{ success: false, message: "Registrasi gagal" },
{ success: false, message: "Registrasi gagal. Silakan coba lagi." },
{ status: 500 }
);
} finally {

View File

@@ -12,6 +12,7 @@ export async function GET() {
{ status: 401 }
);
}
const [dbUser, menuAccess] = await Promise.all([
prisma.user.findUnique({
@@ -50,7 +51,7 @@ export async function GET() {
},
});
} catch (error) {
console.error("❌ Error in /api/me:", error);
console.error("❌ Error in /api/auth/me:", error);
return NextResponse.json(
{ success: false, message: "Internal server error", user: null },
{ status: 500 }

View File

@@ -48,6 +48,7 @@ export default function RootLayout({
<ViewTransitions>
<html lang="en" {...mantineHtmlProps}>
<head>
<meta charSet="utf-8" />
<ColorSchemeScript />
<link
rel="icon"

View File

@@ -16,7 +16,9 @@ import { useEffect, useState } from 'react';
import { authStore } from '@/store/authStore'; // ✅ integrasi authStore
async function fetchUser() {
const res = await fetch('/api/me');
const res = await fetch('/api/auth/me', {
credentials: 'include'
});
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
@@ -32,6 +34,7 @@ export default function WaitingRoom() {
const [retryCount, setRetryCount] = useState(0);
const MAX_RETRIES = 2;
useEffect(() => {
let isMounted = true;
let interval: ReturnType<typeof setInterval>;
@@ -77,7 +80,7 @@ export default function WaitingRoom() {
// Force a session refresh
try {
const res = await fetch('/api/refresh-session', {
const res = await fetch('/api/auth/refresh-session', {
method: 'POST',
credentials: 'include'
});