From 066180fc0eca2b0ad6cd4f345f0b3bf97a1a476f Mon Sep 17 00:00:00 2001 From: nico Date: Fri, 28 Nov 2025 11:13:20 +0800 Subject: [PATCH] Fix registrasi, waitong-room, & tampilan layout sesuai id --- .../(dashboard)/auth/validasi-admin/page.tsx | 25 +- src/app/admin/layout.tsx | 619 +++++++++++++----- .../api/auth/finalize-registration/route.ts | 59 +- src/app/waiting-room/page.tsx | 6 +- 4 files changed, 520 insertions(+), 189 deletions(-) diff --git a/src/app/admin/(dashboard)/auth/validasi-admin/page.tsx b/src/app/admin/(dashboard)/auth/validasi-admin/page.tsx index 5307bf87..e6fe0a4b 100644 --- a/src/app/admin/(dashboard)/auth/validasi-admin/page.tsx +++ b/src/app/admin/(dashboard)/auth/validasi-admin/page.tsx @@ -19,7 +19,7 @@ import { authStore } from '@/store/authStore'; export default function Validasi() { const router = useRouter(); - + const [nomor, setNomor] = useState(null); const [otp, setOtp] = useState(''); const [loading, setLoading] = useState(false); @@ -35,7 +35,7 @@ export default function Validasi() { 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(); }, []); @@ -110,6 +110,7 @@ export default function Validasi() { return; } + // ✅ Verify OTP const verifyRes = await fetch('/api/auth/verify-otp-register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -123,19 +124,25 @@ export default function Validasi() { return; } + // ✅ 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'); } @@ -197,7 +204,7 @@ export default function Validasi() { localStorage.removeItem('auth_kodeId'); localStorage.removeItem('auth_nomor'); localStorage.removeItem('auth_username'); - + // Clear cookie try { await fetch('/api/auth/clear-flow', { diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 9dc3b944..81a125a9 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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 ( +// +// +//
+// +//
+//
+//
+// ); +// } + +// // ✅ 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 ( +// +// +// +// +// Logo Darmasaba +// +// Admin Darmasaba +// +// + +// +// {!desktopOpened && ( +// +// +// +// +// +// )} + +// + +// +// { +// router.push("/darmasaba"); +// }} +// color={colors["blue-button"]} +// radius="xl" +// size="lg" +// variant="gradient" +// gradient={{ from: colors["blue-button"], to: "#228be6" }} +// > +// Logo Darmasaba +// +// +// +// +// +// +// +// +// +// + +// +// +// {currentNav.map((v, k) => { +// const isParentActive = segments.includes(_.lowerCase(v.name)); + +// return ( +// +// {v.name} +// +// } +// 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 ( +// +// {child.name} +// +// } +// 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} +// /> +// ); +// })} +// +// ); +// })} +// + +// +// +// +// +// +// +// +// +// +// + +// +// {children} +// +// +// ); +// } + + +// 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 }) { @@ -41,41 +436,51 @@ export default function Layout({ children }: { children: React.ReactNode }) { 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 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 +495,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 +524,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 +531,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/auth/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 +572,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { }} padding="md" > + {/* ... rest of your JSX (Header, Navbar, Main) sama seperti sebelumnya ... */} - + Admin Darmasaba @@ -192,63 +601,22 @@ export default function Layout({ children }: { children: React.ReactNode }) { {!desktopOpened && ( - + )} - + - { - router.push("/darmasaba"); - }} - color={colors["blue-button"]} - radius="xl" - size="lg" - variant="gradient" - gradient={{ from: colors["blue-button"], to: "#228be6" }} - > - Logo Darmasaba + router.push("/darmasaba")} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }}> + Logo Darmasaba + - + @@ -256,75 +624,17 @@ export default function Layout({ children }: { children: React.ReactNode }) { - + + {/* ... Navbar content sama seperti sebelumnya ... */} {currentNav.map((v, k) => { const isParentActive = segments.includes(_.lowerCase(v.name)); - return ( - - {v.name} - - } - 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.name}} 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 ( - - {child.name} - - } - 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} - /> + {child.name}} 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} /> ); })} @@ -334,18 +644,8 @@ export default function Layout({ children }: { children: React.ReactNode }) { - - + + @@ -353,12 +653,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { - + {children} diff --git a/src/app/api/auth/finalize-registration/route.ts b/src/app/api/auth/finalize-registration/route.ts index cc6b16cd..40faa181 100644 --- a/src/app/api/auth/finalize-registration/route.ts +++ b/src/app/api/auth/finalize-registration/route.ts @@ -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 { diff --git a/src/app/waiting-room/page.tsx b/src/app/waiting-room/page.tsx index 8e08c921..c725dd5b 100644 --- a/src/app/waiting-room/page.tsx +++ b/src/app/waiting-room/page.tsx @@ -16,7 +16,9 @@ import { useEffect, useState } from 'react'; import { authStore } from '@/store/authStore'; // ✅ integrasi authStore async function fetchUser() { - const res = await fetch('/api/auth/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,7 +34,7 @@ export default function WaitingRoom() { const [retryCount, setRetryCount] = useState(0); const MAX_RETRIES = 2; - + useEffect(() => { let isMounted = true; let interval: ReturnType;