diff --git a/README.md b/README.md index e215bc4..530ecd4 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,118 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Portal Data Informatika (PODIF) -## Getting Started +Sistem Informasi Data Mahasiswa Jurusan Informatika dengan autentikasi admin dan dosen. -First, run the development server: +## Struktur Aplikasi + +### Landing Page (Root) + +- **URL**: `/` +- **Fitur**: Halaman landing dengan modal login/register +- **Layout**: Tanpa sidebar/navbar (clean auth interface) +- **Komponen**: + - Modal Login (Admin & Dosen) + - Modal Register (Dosen/Kajur) + +### Dashboard + +- **URL**: `/dashboard` +- **Fitur**: Dashboard utama dengan sidebar dan navbar +- **Layout**: Menggunakan ClientLayout dengan sidebar/navbar +- **Komponen**: + - Statistik mahasiswa + - Grafik dan chart + - Menu navigasi + +### Halaman Mahasiswa + +- **URL**: `/dashboard/mahasiswa/*` +- **Fitur**: Halaman data mahasiswa +- **Layout**: Menggunakan ClientLayout dengan sidebar/navbar + +## Autentikasi + +### Role User + +1. **Admin** + + - Login dengan username dan password + - Akses penuh ke semua fitur + +2. **Dosen** + + - Login dengan NIP dan password + - Akses terbatas sesuai role + +3. **Ketua Jurusan (Kajur)** + - Login dengan NIP dan password + - Akses khusus untuk kajur + +### API Endpoints + +- `POST /api/auth/login` - Login admin/dosen +- `POST /api/auth/register` - Register dosen/kajur +- `POST /api/auth/logout` - Logout +- `GET /api/auth/check` - Cek status autentikasi + +## Setup + +### 1. Install Dependencies + +```bash +npm install +``` + +### 2. Setup Environment Variables + +Buat file `.env.local`: + +```env +NEXT_PUBLIC_SUPABASE_URL=your_supabase_url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key +JWT_SECRET=your_jwt_secret +``` + +### 3. Setup Database + +Jalankan script setup database: + +```bash +node setup_database.js +``` + +### 4. Run Development Server ```bash npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Database Schema -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +### Tabel user_app -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +```sql +CREATE TABLE user_app ( + id_user SERIAL PRIMARY KEY, + username VARCHAR(50), -- hanya digunakan oleh admin + nip VARCHAR(20), -- hanya digunakan oleh dosen & kajur + password TEXT NOT NULL, + role_user role_enum NOT NULL, -- ENUM: 'admin', 'dosen', 'kajur' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` -## Learn More +## Middleware -To learn more about Next.js, take a look at the following resources: +Middleware mengatur: -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- Redirect user yang sudah login dari `/` ke `/dashboard` +- Protect route `/dashboard/*` (harus login) +- Clear invalid token dan redirect ke home -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Struktur File -## Deploy on Vercel +``` -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +``` diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 4b168da..bbae0b8 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -5,15 +5,14 @@ import { SignJWT } from 'jose'; interface User { id_user: number; - nim: string; - username: string; + username?: string; + nip?: string; password: string; - role: string; + role_user: string; } export async function POST(request: Request) { try { - console.log('Login request received'); // Test database connection first try { @@ -23,15 +22,12 @@ export async function POST(request: Request) { .limit(1); if (testError) { - console.error('Database connection error:', testError); return NextResponse.json( { error: 'Tidak dapat terhubung ke database' }, { status: 500 } ); } - console.log('Database connection successful'); } catch (dbError) { - console.error('Database connection error:', dbError); return NextResponse.json( { error: 'Tidak dapat terhubung ke database' }, { status: 500 } @@ -39,31 +35,45 @@ export async function POST(request: Request) { } const body = await request.json(); - console.log('Request body:', body); - const { nim, password } = body; - console.log('Extracted credentials:', { nim, password: '***' }); + const { username, nip, password, role } = body; - // Validate input - if (!nim || !password) { - console.log('Missing credentials:', { nim: !!nim, password: !!password }); + // Validate input based on role + if (role === 'admin') { + if (!username || !password) { + return NextResponse.json( + { error: 'Username dan password harus diisi' }, + { status: 400 } + ); + } + } else if (role === 'dosen' || role === 'kajur') { + if (!nip || !password) { + return NextResponse.json( + { error: 'NIP dan password harus diisi' }, + { status: 400 } + ); + } + } else { return NextResponse.json( - { error: 'NIM dan password harus diisi' }, + { error: 'Role tidak valid' }, { status: 400 } ); } - // Get user by NIM - console.log('Querying user with NIM:', nim); + // Get user by username (admin) or NIP (dosen/kajur) let users: User[]; try { - const { data, error } = await supabase - .from('user_app') - .select('*') - .eq('nim', nim); + let query = supabase.from('user_app').select('*'); + + if (role === 'admin') { + query = query.eq('username', username); + } else { + query = query.eq('nip', nip); + } + + const { data, error } = await query; if (error) { - console.error('Database query error:', error); return NextResponse.json( { error: 'Terjadi kesalahan saat memeriksa data pengguna' }, { status: 500 } @@ -71,9 +81,7 @@ export async function POST(request: Request) { } users = data || []; - console.log('Query result:', users.length > 0 ? 'User found' : 'User not found'); } catch (queryError) { - console.error('Database query error:', queryError); return NextResponse.json( { error: 'Terjadi kesalahan saat memeriksa data pengguna' }, { status: 500 } @@ -81,29 +89,40 @@ export async function POST(request: Request) { } if (users.length === 0) { - console.log('No user found with NIM:', nim); return NextResponse.json( - { error: 'NIM atau password salah' }, + { error: role === 'admin' ? 'Username atau password salah' : 'NIP atau password salah' }, { status: 401 } ); } const user = users[0]; - console.log('User found:', { - id: user.id_user, - nim: user.nim, - username: user.username, - role: user.role - }); - // Verify password - console.log('Verifying password...'); + // Check if user role matches + if (user.role_user !== role) { + return NextResponse.json( + { error: 'Role tidak sesuai' }, + { status: 401 } + ); + } + + // Verify password let isPasswordValid; try { - isPasswordValid = await bcrypt.compare(password, user.password); - console.log('Password verification result:', isPasswordValid ? 'Valid' : 'Invalid'); + // For admin, check if password is plain text (not hashed) + if (user.role_user === 'admin') { + // Check if stored password is plain text (not starting with $2a$ or $2b$) + if (!user.password.startsWith('$2a$') && !user.password.startsWith('$2b$')) { + // Plain text password - direct comparison + isPasswordValid = password === user.password; + } else { + // Hashed password - use bcrypt + isPasswordValid = await bcrypt.compare(password, user.password); + } + } else { + // For dosen/kajur, always use bcrypt (should be hashed) + isPasswordValid = await bcrypt.compare(password, user.password); + } } catch (bcryptError) { - console.error('Password verification error:', bcryptError); return NextResponse.json( { error: 'Terjadi kesalahan saat memverifikasi password' }, { status: 500 } @@ -111,28 +130,32 @@ export async function POST(request: Request) { } if (!isPasswordValid) { - console.log('Invalid password for user:', nim); return NextResponse.json( - { error: 'NIM atau password salah' }, + { error: role === 'admin' ? 'Username atau password salah' : 'NIP atau password salah' }, { status: 401 } ); } // Create JWT token - console.log('Creating JWT token...'); let token; try { - token = await new SignJWT({ + const tokenPayload: any = { id: user.id_user, - nim: user.nim, - role: user.role - }) + role: user.role_user + }; + + // Add username for admin, NIP for dosen/kajur + if (user.role_user === 'admin') { + tokenPayload.username = user.username; + } else { + tokenPayload.nip = user.nip; + } + + token = await new SignJWT(tokenPayload) .setProtectedHeader({ alg: 'HS256' }) .setExpirationTime('24h') .sign(new TextEncoder().encode(process.env.JWT_SECRET || 'your-secret-key')); - console.log('JWT token created'); } catch (jwtError) { - console.error('JWT creation error:', jwtError); return NextResponse.json( { error: 'Terjadi kesalahan saat membuat token' }, { status: 500 } @@ -140,14 +163,20 @@ export async function POST(request: Request) { } // Set cookie - console.log('Setting response...'); + const userResponse: any = { + id: user.id_user, + role: user.role_user + }; + + // Add username for admin, NIP for dosen/kajur + if (user.role_user === 'admin') { + userResponse.username = user.username; + } else { + userResponse.nip = user.nip; + } + const response = NextResponse.json({ - user: { - id: user.id_user, - nim: user.nim, - username: user.username, - role: user.role - } + user: userResponse }); response.cookies.set('token', token, { @@ -156,15 +185,11 @@ export async function POST(request: Request) { sameSite: 'lax', maxAge: 60 * 60 * 24 // 24 hours }); - console.log('Cookie set'); - console.log('Login process completed successfully'); return response; } catch (error) { - console.error('Login error details:', error); if (error instanceof Error) { console.error('Error message:', error.message); - console.error('Error stack:', error.stack); } return NextResponse.json( { error: 'Terjadi kesalahan saat login' }, diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index cb33cb0..72494de 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -4,48 +4,34 @@ import bcrypt from 'bcryptjs'; export async function POST(request: Request) { try { - const { username, nim, password } = await request.json(); + const { nip, password } = await request.json(); // Validate input - if (!username || !nim || !password) { + if (!nip || !password) { return NextResponse.json( - { error: 'Semua field harus diisi' }, + { error: 'NIP dan password harus diisi' }, { status: 400 } ); } - // Validate NIM format (11 characters) - if (nim.length !== 11) { + // Validate password length + if (password.length < 6) { return NextResponse.json( - { error: 'NIM harus 11 karakter' }, + { error: 'Password minimal 6 karakter' }, { status: 400 } ); } - // Check if NIM exists in mahasiswa table - const { data: mahasiswa, error: mahasiswaError } = await supabase - .from('mahasiswa') - .select('nim') - .eq('nim', nim) - .single(); - - if (mahasiswaError || !mahasiswa) { - return NextResponse.json( - { error: 'NIM tidak terdaftar sebagai mahasiswa' }, - { status: 400 } - ); - } - - // Check if NIM already exists in user_app table + // Check if NIP already exists in user_app table const { data: existingUsers, error: userError } = await supabase .from('user_app') - .select('nim') - .eq('nim', nim) + .select('nip') + .eq('nip', nip) .single(); if (!userError && existingUsers) { return NextResponse.json( - { error: 'NIM sudah terdaftar sebagai pengguna' }, + { error: 'NIP sudah terdaftar sebagai pengguna' }, { status: 400 } ); } @@ -53,14 +39,13 @@ export async function POST(request: Request) { // Hash password const hashedPassword = await bcrypt.hash(password, 10); - // Insert new user + // Insert new user with default role 'dosen' const { data: newUser, error: insertError } = await supabase .from('user_app') .insert({ - nim: nim, - username: username, + nip: nip, password: hashedPassword, - role: 'mahasiswa' + role_user: 'dosen' // Default role for registration }) .select() .single(); diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx new file mode 100644 index 0000000..3039a63 --- /dev/null +++ b/app/dashboard/layout.tsx @@ -0,0 +1,9 @@ +import ClientLayout from '@/components/ClientLayout'; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} \ No newline at end of file diff --git a/app/mahasiswa/beasiswa/page.tsx b/app/dashboard/mahasiswa/beasiswa/page.tsx similarity index 100% rename from app/mahasiswa/beasiswa/page.tsx rename to app/dashboard/mahasiswa/beasiswa/page.tsx diff --git a/app/mahasiswa/berprestasi/page.tsx b/app/dashboard/mahasiswa/berprestasi/page.tsx similarity index 100% rename from app/mahasiswa/berprestasi/page.tsx rename to app/dashboard/mahasiswa/berprestasi/page.tsx diff --git a/app/mahasiswa/lulustepatwaktu/page.tsx b/app/dashboard/mahasiswa/lulustepatwaktu/page.tsx similarity index 100% rename from app/mahasiswa/lulustepatwaktu/page.tsx rename to app/dashboard/mahasiswa/lulustepatwaktu/page.tsx diff --git a/app/mahasiswa/profile/page.tsx b/app/dashboard/mahasiswa/profile/page.tsx similarity index 100% rename from app/mahasiswa/profile/page.tsx rename to app/dashboard/mahasiswa/profile/page.tsx diff --git a/app/mahasiswa/status/page.tsx b/app/dashboard/mahasiswa/status/page.tsx similarity index 100% rename from app/mahasiswa/status/page.tsx rename to app/dashboard/mahasiswa/status/page.tsx diff --git a/app/mahasiswa/total/page.tsx b/app/dashboard/mahasiswa/total/page.tsx similarity index 100% rename from app/mahasiswa/total/page.tsx rename to app/dashboard/mahasiswa/total/page.tsx diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..e7e6b4d --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Users, GraduationCap, Trophy, BookOpen } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTheme } from "next-themes"; +import StatusMahasiswaChart from "@/components/StatusMahasiswaChart"; +import StatistikMahasiswaChart from "@/components/StatistikMahasiswaChart"; +import JenisPendaftaranChart from "@/components/JenisPendaftaranChart"; +import AsalDaerahChart from "@/components/AsalDaerahChart"; +import IPKChart from '@/components/IPKChart'; + +interface MahasiswaTotal { + total_mahasiswa: number; + mahasiswa_aktif: number; + total_lulus: number; + pria_lulus: number; + wanita_lulus: number; + total_berprestasi: number; + prestasi_akademik: number; + prestasi_non_akademik: number; + ipk_rata_rata_aktif: number; + ipk_rata_rata_lulus: number; + total_mahasiswa_aktif_lulus: number; +} + +// Skeleton loading component +const CardSkeleton = () => ( + + +
+
+
+ +
+
+
+
+
+
+
+); + +export default function DashboardPage() { + const { theme } = useTheme(); + const [mahasiswaData, setMahasiswaData] = useState({ + total_mahasiswa: 0, + mahasiswa_aktif: 0, + total_lulus: 0, + pria_lulus: 0, + wanita_lulus: 0, + total_berprestasi: 0, + prestasi_akademik: 0, + prestasi_non_akademik: 0, + ipk_rata_rata_aktif: 0, + ipk_rata_rata_lulus: 0, + total_mahasiswa_aktif_lulus: 0 + }); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchData = async () => { + try { + // Menggunakan cache API untuk mempercepat loading + const cacheKey = 'mahasiswa-total-data'; + const cachedData = sessionStorage.getItem(cacheKey); + const cachedTimestamp = sessionStorage.getItem(`${cacheKey}-timestamp`); + + // Cek apakah data cache masih valid (kurang dari 60 detik) + const isCacheValid = cachedTimestamp && + (Date.now() - parseInt(cachedTimestamp)) < 60000; + + if (cachedData && isCacheValid) { + setMahasiswaData(JSON.parse(cachedData)); + } + + // Fetch data total mahasiswa + const totalResponse = await fetch('/api/mahasiswa/total', { + cache: 'no-store', + }); + + if (!totalResponse.ok) { + throw new Error('Failed to fetch total data'); + } + + const totalData = await totalResponse.json(); + setMahasiswaData(totalData); + + // Menyimpan data dan timestamp ke sessionStorage + sessionStorage.setItem(cacheKey, JSON.stringify(totalData)); + sessionStorage.setItem(`${cacheKey}-timestamp`, Date.now().toString()); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + console.error('Error fetching data:', err); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + return ( +
+

Dashboard Portal Data Informatika

+ + {loading ? ( +
+ + + + +
+ ) : error ? ( +
+
+
+ + + +
+
+

{error}

+
+
+
+ ) : ( + <> +
+ {/* Kartu Total Mahasiswa */} + + + + Total Mahasiswa + + + + +
{mahasiswaData.total_mahasiswa}
+
+ Aktif: {mahasiswaData.mahasiswa_aktif} +
+
+
+ + {/* Kartu Total Kelulusan */} + + + + Total Kelulusan + + + + +
{mahasiswaData.total_lulus}
+
+ Laki-laki: {mahasiswaData.pria_lulus} + Perempuan: {mahasiswaData.wanita_lulus} +
+
+
+ + {/* Kartu Total Prestasi */} + + + + Mahasiswa Berprestasi + + + + +
{mahasiswaData.total_berprestasi}
+
+ Akademik: {mahasiswaData.prestasi_akademik} + Non-Akademik: {mahasiswaData.prestasi_non_akademik} +
+
+
+ + {/* Kartu Rata-rata IPK */} + + + + Rata-rata IPK + + + + +
{mahasiswaData.mahasiswa_aktif}
+
+ Aktif: {mahasiswaData.ipk_rata_rata_aktif} +
+
+
+
+ + {/* Diagram Statistik Mahasiswa */} + + + {/* Diagram Status Mahasiswa */} + + + {/* Diagram Jenis Pendaftaran */} + + + {/* Diagram Asal Daerah */} + + + {/* Diagram IPK */} + + + )} +
+ ); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 54bf9bd..b9719d5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; -import ClientLayout from '../components/ClientLayout'; +import { ThemeProvider } from '@/components/theme-provider'; const geistSans = Geist({ variable: '--font-geist-sans', @@ -15,7 +15,7 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: 'Portal Data Informatika', - description: 'Admin Dashboard', + description: 'Visualisasi Data Mahasiswa Jurusan Informatika', }; export default function RootLayout({ @@ -29,7 +29,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/app/page.tsx b/app/page.tsx index ebc3c71..c0a022e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,217 +1,374 @@ 'use client'; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Users, GraduationCap, Trophy, BookOpen } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useTheme } from "next-themes"; -import StatusMahasiswaChart from "@/components/StatusMahasiswaChart"; -import StatistikMahasiswaChart from "@/components/StatistikMahasiswaChart"; -import JenisPendaftaranChart from "@/components/JenisPendaftaranChart"; -import AsalDaerahChart from "@/components/AsalDaerahChart"; -import IPKChart from '@/components/IPKChart'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Loader2, Eye, EyeOff, LogIn, UserPlus } from "lucide-react"; +import { ThemeProvider } from '@/components/theme-provider'; +import { Toaster } from '@/components/ui/toaster'; -interface MahasiswaTotal { - total_mahasiswa: number; - mahasiswa_aktif: number; - total_lulus: number; - pria_lulus: number; - wanita_lulus: number; - total_berprestasi: number; - prestasi_akademik: number; - prestasi_non_akademik: number; - ipk_rata_rata_aktif: number; - ipk_rata_rata_lulus: number; - total_mahasiswa_aktif_lulus: number; -} - -// Skeleton loading component -const CardSkeleton = () => ( - - -
-
-
- -
-
-
-
-
-
-
-); - -export default function HomePage() { - const { theme } = useTheme(); - const [mahasiswaData, setMahasiswaData] = useState({ - total_mahasiswa: 0, - mahasiswa_aktif: 0, - total_lulus: 0, - pria_lulus: 0, - wanita_lulus: 0, - total_berprestasi: 0, - prestasi_akademik: 0, - prestasi_non_akademik: 0, - ipk_rata_rata_aktif: 0, - ipk_rata_rata_lulus: 0, - total_mahasiswa_aktif_lulus: 0 +export default function LandingPage() { + const router = useRouter(); + const [isLoginOpen, setIsLoginOpen] = useState(false); + const [isRegisterOpen, setIsRegisterOpen] = useState(false); + const [activeTab, setActiveTab] = useState('admin'); + const [loading, setLoading] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(''); + + // Admin form state + const [adminForm, setAdminForm] = useState({ + username: '', + password: '' }); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + // Dosen form state + const [dosenForm, setDosenForm] = useState({ + nip: '', + password: '' + }); - useEffect(() => { - const fetchData = async () => { - try { - // Menggunakan cache API untuk mempercepat loading - const cacheKey = 'mahasiswa-total-data'; - const cachedData = sessionStorage.getItem(cacheKey); - const cachedTimestamp = sessionStorage.getItem(`${cacheKey}-timestamp`); - - // Cek apakah data cache masih valid (kurang dari 60 detik) - const isCacheValid = cachedTimestamp && - (Date.now() - parseInt(cachedTimestamp)) < 60000; - - if (cachedData && isCacheValid) { - setMahasiswaData(JSON.parse(cachedData)); - } - - // Fetch data total mahasiswa - const totalResponse = await fetch('/api/mahasiswa/total', { - cache: 'no-store', - }); - - if (!totalResponse.ok) { - throw new Error('Failed to fetch total data'); - } - - const totalData = await totalResponse.json(); - setMahasiswaData(totalData); - - // Menyimpan data dan timestamp ke sessionStorage - sessionStorage.setItem(cacheKey, JSON.stringify(totalData)); - sessionStorage.setItem(`${cacheKey}-timestamp`, Date.now().toString()); + // Register form state + const [registerForm, setRegisterForm] = useState({ + nip: '', + password: '', + confirmPassword: '' + }); + + const handleAdminLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: adminForm.username, + password: adminForm.password, + role: 'admin' + }), + }); + + const data = await response.json(); + + if (response.ok) { + setIsLoginOpen(false); + router.push('/dashboard'); + } else { + setError(data.error || 'Login gagal'); + } + } catch (err) { + setError('Terjadi kesalahan saat login'); + } finally { + setLoading(false); + } + }; + + const handleDosenLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + nip: dosenForm.nip, + password: dosenForm.password, + role: 'dosen' + }), + }); + + const data = await response.json(); + + if (response.ok) { + setIsLoginOpen(false); + router.push('/dashboard'); + } else { + setError(data.error || 'Login gagal'); + } + } catch (err) { + setError('Terjadi kesalahan saat login'); + } finally { + setLoading(false); + } + }; + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + if (registerForm.password !== registerForm.confirmPassword) { + setError('Password dan konfirmasi password tidak cocok'); + setLoading(false); + return; + } + + try { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + nip: registerForm.nip, + password: registerForm.password + }), + }); + + const data = await response.json(); + + if (response.ok) { + setIsRegisterOpen(false); + setIsLoginOpen(true); + setActiveTab('dosen'); + setError(''); + } else { + setError(data.error || 'Registrasi gagal'); + } } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred'); - console.error('Error fetching data:', err); + setError('Terjadi kesalahan saat registrasi'); } finally { setLoading(false); } }; - fetchData(); - }, []); - return ( -
-

Dashboard Portal Data Informatika

- - {loading ? ( -
- - - - + +
+
+
+

+ Portal Data Informatika +

+

+ Visualisasi Data Akademik Mahasiswa Jurusan Informatika +

+
+ + + + + + + Login Portal Data Informatika + + Silakan login sesuai dengan role Anda + + + + + Admin + Dosen + + + +
+
+ + setAdminForm({ ...adminForm, username: e.target.value })} + required + /> +
+
+ +
+ setAdminForm({ ...adminForm, password: e.target.value })} + required + /> + +
+
+ +
+
+ + +
+
+ + setDosenForm({ ...dosenForm, nip: e.target.value })} + required + /> +
+
+ +
+ setDosenForm({ ...dosenForm, password: e.target.value })} + required + /> + +
+
+ +
+
+
+ + {error && ( + + + {error} + + + )} +
+
+ + + + + + + + Registrasi Dosen + + Daftar akun baru untuk dosen Portal Data Informatika + + +
+
+ + setRegisterForm({ ...registerForm, nip: e.target.value })} + required + /> +
+
+ +
+ setRegisterForm({ ...registerForm, password: e.target.value })} + required + /> + +
- ) : error ? ( -
-
-
- - - +
+ + setRegisterForm({ ...registerForm, confirmPassword: e.target.value })} + required + />
-
-

{error}

+ + + + {error && ( + + + {error} + + + )} + +
- ) : ( - <> -
- {/* Kartu Total Mahasiswa */} - - - - Total Mahasiswa - - - - -
{mahasiswaData.total_mahasiswa}
-
- Aktif: {mahasiswaData.mahasiswa_aktif}
-
-
- - {/* Kartu Total Kelulusan */} - - - - Total Kelulusan - - - - -
{mahasiswaData.total_lulus}
-
- Laki-laki: {mahasiswaData.pria_lulus} - Perempuan: {mahasiswaData.wanita_lulus} -
-
-
- - {/* Kartu Total Prestasi */} - - - - Mahasiswa Berprestasi - - - - -
{mahasiswaData.total_berprestasi}
-
- Akademik: {mahasiswaData.prestasi_akademik} - Non-Akademik: {mahasiswaData.prestasi_non_akademik} -
-
-
- - {/* Kartu Rata-rata IPK */} - - - - Rata-rata IPK - - - - -
{mahasiswaData.mahasiswa_aktif}
-
- Aktif: {mahasiswaData.ipk_rata_rata_aktif} -
-
-
-
- - {/* Diagram Statistik Mahasiswa */} - - - {/* Diagram Status Mahasiswa */} - - - {/* Diagram Jenis Pendaftaran */} - - - {/* Diagram Asal Daerah */} - - - {/* Diagram IPK */} - - - )} -
+ +
); } diff --git a/components/ClientLayout.tsx b/components/ClientLayout.tsx index e3c99f9..72319f2 100644 --- a/components/ClientLayout.tsx +++ b/components/ClientLayout.tsx @@ -13,7 +13,6 @@ interface ClientLayoutProps { export default function ClientLayout({ children }: ClientLayoutProps) { const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); - // Load sidebar state from localStorage on mount useEffect(() => { const savedState = localStorage.getItem('sidebarCollapsed'); if (savedState !== null) { diff --git a/components/ui/Navbar.tsx b/components/ui/Navbar.tsx index 6764fcc..425d767 100644 --- a/components/ui/Navbar.tsx +++ b/components/ui/Navbar.tsx @@ -1,11 +1,12 @@ 'use client'; import { ThemeToggle } from '@/components/theme-toggle'; -import { Menu, PanelLeftClose, PanelLeft } from 'lucide-react'; +import { Menu, PanelLeftClose, PanelLeft, LogOut } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import SidebarContent from '@/components/ui/SidebarContent'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; interface NavbarProps { onSidebarToggle: () => void; @@ -13,6 +14,22 @@ interface NavbarProps { } const Navbar = ({ onSidebarToggle, isSidebarCollapsed }: NavbarProps) => { + const router = useRouter(); + + const handleLogout = async () => { + try { + const response = await fetch('/api/auth/logout', { + method: 'POST', + }); + + if (response.ok) { + router.push('/'); + } + } catch (error) { + console.error('Logout error:', error); + } + }; + return (
@@ -48,7 +65,7 @@ const Navbar = ({ onSidebarToggle, isSidebarCollapsed }: NavbarProps) => {
- + PODIF Logo PODIF @@ -56,6 +73,15 @@ const Navbar = ({ onSidebarToggle, isSidebarCollapsed }: NavbarProps) => {
+
); diff --git a/components/ui/SidebarContent.tsx b/components/ui/SidebarContent.tsx index 7155aa6..e5b6998 100644 --- a/components/ui/SidebarContent.tsx +++ b/components/ui/SidebarContent.tsx @@ -27,7 +27,7 @@ const SidebarContent = () => { - + Dashboard @@ -46,23 +46,23 @@ const SidebarContent = () => {
- + Mahasiswa Total - + Mahasiswa Status - + Mahasiswa Lulus Tepat Waktu - + Mahasiswa Beasiswa - + Mahasiswa Berprestasi @@ -74,7 +74,7 @@ const SidebarContent = () => { - + Profile diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/middleware.ts b/middleware.ts index 1956510..d60627b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -7,7 +7,7 @@ export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // Define public paths that don't require authentication - const publicPaths = ['/', '/login', '/register']; + const publicPaths = ['/']; const isPublicPath = publicPaths.includes(pathname); // Check if the path is an API route or static file @@ -19,13 +19,35 @@ export async function middleware(request: NextRequest) { return NextResponse.next(); } - // If trying to access public route with token + // If trying to access public route with valid token, redirect to dashboard if (token && isPublicPath) { - return NextResponse.next(); + try { + await jwtVerify( + token, + new TextEncoder().encode(process.env.JWT_SECRET || 'your-secret-key') + ); + return NextResponse.redirect(new URL('/dashboard', request.url)); + } catch (error) { + // If token is invalid, clear it and stay on public page + const response = NextResponse.next(); + response.cookies.set('token', '', { + expires: new Date(0), + path: '/', + httpOnly: true, + secure: false, + sameSite: 'lax' + }); + return response; + } + } + + // If the path is protected (dashboard routes) and user is not logged in, redirect to home + if (pathname.startsWith('/dashboard') && !token) { + return NextResponse.redirect(new URL('/', request.url)); } // If the path is protected and user is logged in, verify token - if (!isPublicPath && token) { + if (pathname.startsWith('/dashboard') && token) { try { // Verify the token await jwtVerify(