From e31b2afb84987dd05739a2c353e9deadbed1c774 Mon Sep 17 00:00:00 2001 From: Rio Date: Thu, 18 Jun 2026 15:04:09 +0700 Subject: [PATCH] feat: add profile page for both user and admin with ability to change password --- src/app/admin/layout.tsx | 9 +- src/app/admin/profile/AdminProfile.module.css | 91 ++++++ src/app/admin/profile/page.tsx | 253 +++++++++++++++++ src/app/api/user/profile/route.ts | 93 ++++++ src/app/dashboard/profile/page.tsx | 268 ++++++++++++++++++ src/app/dashboard/profile/profile.module.css | 160 +++++++++++ src/components/Header.tsx | 12 +- 7 files changed, 883 insertions(+), 3 deletions(-) create mode 100644 src/app/admin/profile/AdminProfile.module.css create mode 100644 src/app/admin/profile/page.tsx create mode 100644 src/app/api/user/profile/route.ts create mode 100644 src/app/dashboard/profile/page.tsx create mode 100644 src/app/dashboard/profile/profile.module.css diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 8e13f49..6043ff1 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; -import { FaBus, FaChartBar, FaMapMarkedAlt, FaCalendarAlt, FaTicketAlt, FaShuttleVan, FaCog, FaSignOutAlt } from 'react-icons/fa'; +import { FaBus, FaChartBar, FaMapMarkedAlt, FaCalendarAlt, FaTicketAlt, FaShuttleVan, FaCog, FaSignOutAlt, FaUser } from 'react-icons/fa'; import styles from './AdminLayout.module.css'; interface User { @@ -116,6 +116,9 @@ export default function AdminLayout({ Pengaturan Brand + + Profil Saya +
@@ -147,7 +150,9 @@ export default function AdminLayout({ ? 'Tata Letak Kursi Armada' : pathname.startsWith('/admin/settings') ? 'Pengaturan Brand & Pembayaran' - : 'Kelola Pemesanan Tiket'} + : pathname.startsWith('/admin/profile') + ? 'Pengaturan Profil Admin' + : 'Kelola Pemesanan Tiket'}
{new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} diff --git a/src/app/admin/profile/AdminProfile.module.css b/src/app/admin/profile/AdminProfile.module.css new file mode 100644 index 0000000..4433aca --- /dev/null +++ b/src/app/admin/profile/AdminProfile.module.css @@ -0,0 +1,91 @@ +.container { + animation: fadeIn 0.4s ease-out forwards; +} + +.profileGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; +} + +@media (max-width: 900px) { + .profileGrid { + grid-template-columns: 1fr; + } +} + +.sectionTitle { + font-size: 1.15rem; + font-weight: 600; + color: var(--text-white); + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--border-light); + padding-bottom: 12px; +} + +.form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.alert { + padding: 14px 16px; + border-radius: 8px; + font-size: 0.9rem; + margin-bottom: 25px; + display: flex; + align-items: center; + gap: 10px; +} + +.alertSuccess { + background: rgba(16, 185, 129, 0.15); + color: var(--accent-emerald); + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.alertError { + background: rgba(244, 63, 94, 0.15); + color: var(--accent-rose); + border: 1px solid rgba(244, 63, 94, 0.3); +} + +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + color: var(--text-secondary); +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-radius: 50%; + border-top-color: var(--primary); + animation: spin 1s ease-in-out infinite; + margin-bottom: 15px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/app/admin/profile/page.tsx b/src/app/admin/profile/page.tsx new file mode 100644 index 0000000..88e786d --- /dev/null +++ b/src/app/admin/profile/page.tsx @@ -0,0 +1,253 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { FaUser, FaLock, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa'; +import styles from './AdminProfile.module.css'; + +interface User { + id: number; + name: string; + email: string; + phone: string; + role: string; + createdAt: string; +} + +export default function AdminProfile() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + // Profile form state + const [name, setName] = useState(''); + const [phone, setPhone] = useState(''); + + // Password form state + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const [savingProfile, setSavingProfile] = useState(false); + const [savingPassword, setSavingPassword] = useState(false); + const [successMsg, setSuccessMsg] = useState(''); + const [errorMsg, setErrorMsg] = useState(''); + + useEffect(() => { + async function fetchProfile() { + try { + const res = await fetch('/api/user/profile'); + if (!res.ok) { + router.push('/login?redirect=/admin/profile'); + return; + } + const data = await res.json(); + if (data.user.role !== 'ADMIN') { + router.push('/'); + return; + } + setUser(data.user); + setName(data.user.name); + setPhone(data.user.phone); + } catch (err) { + console.error('Fetch profile error:', err); + setErrorMsg('Gagal memuat profil administrator.'); + } finally { + setLoading(false); + } + } + fetchProfile(); + }, [router]); + + const handleUpdateProfile = async (e: React.FormEvent) => { + e.preventDefault(); + setSavingProfile(true); + setSuccessMsg(''); + setErrorMsg(''); + + try { + const res = await fetch('/api/user/profile', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, phone }), + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Gagal memperbarui profil.'); + } + + setUser(data.user); + setSuccessMsg('Profil admin berhasil diperbarui!'); + setTimeout(() => setSuccessMsg(''), 3000); + } catch (err: any) { + setErrorMsg(err.message || 'Terjadi kesalahan.'); + } finally { + setSavingProfile(false); + } + }; + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setSavingPassword(true); + setSuccessMsg(''); + setErrorMsg(''); + + if (newPassword !== confirmPassword) { + setErrorMsg('Konfirmasi password baru tidak cocok.'); + setSavingPassword(false); + return; + } + + try { + const res = await fetch('/api/user/profile', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Gagal mengubah password.'); + } + + setSuccessMsg('Password admin berhasil diubah!'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setTimeout(() => setSuccessMsg(''), 3000); + } catch (err: any) { + setErrorMsg(err.message || 'Terjadi kesalahan.'); + } finally { + setSavingPassword(false); + } + }; + + if (loading) { + return ( +
+
+

Memuat profil admin...

+
+ ); + } + + if (!user) return null; + + return ( +
+ {successMsg && ( +
+ {successMsg} +
+ )} + + {errorMsg && ( +
+ {errorMsg} +
+ )} + +
+ {/* Profile Info Card */} +
+

+ Informasi Akun Admin +

+
+
+ + setName(e.target.value)} + required + /> +
+
+ + setPhone(e.target.value)} + required + /> +
+
+ + +
+
+ + +
+ + +
+
+ + {/* Change Password Card */} +
+

+ Ubah Password Keamanan +

+
+
+ + setCurrentPassword(e.target.value)} + placeholder="Masukkan password admin saat ini" + required={newPassword.length > 0} + /> +
+
+ + setNewPassword(e.target.value)} + placeholder="Minimal 6 karakter" + required={currentPassword.length > 0} + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Ulangi password baru" + required={currentPassword.length > 0} + /> +
+ + +
+
+
+
+ ); +} diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts new file mode 100644 index 0000000..2d9d38e --- /dev/null +++ b/src/app/api/user/profile/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { getSessionUser } from '@/lib/auth'; +import bcrypt from 'bcryptjs'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const session = await getSessionUser(); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { + id: true, + name: true, + email: true, + phone: true, + role: true, + createdAt: true, + }, + }); + + if (!user) { + return NextResponse.json({ error: 'User tidak ditemukan.' }, { status: 404 }); + } + + return NextResponse.json({ user }); + } catch (error) { + console.error('Fetch profile error:', error); + return NextResponse.json({ error: 'Gagal mengambil data profil.' }, { status: 500 }); + } +} + +export async function PUT(request: Request) { + try { + const session = await getSessionUser(); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { name, phone, currentPassword, newPassword } = await request.json(); + + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + }); + + if (!user) { + return NextResponse.json({ error: 'User tidak ditemukan.' }, { status: 404 }); + } + + const updateData: any = {}; + + if (name) updateData.name = name; + if (phone) updateData.phone = phone; + + // Handle password change if requested + if (currentPassword && newPassword) { + const isMatch = await bcrypt.compare(currentPassword, user.password); + if (!isMatch) { + return NextResponse.json({ error: 'Password saat ini salah.' }, { status: 400 }); + } + + if (newPassword.length < 6) { + return NextResponse.json({ error: 'Password baru minimal harus 6 karakter.' }, { status: 400 }); + } + + updateData.password = await bcrypt.hash(newPassword, 10); + } else if (currentPassword || newPassword) { + return NextResponse.json({ error: 'Untuk mengubah password, isi kedua field password saat ini dan password baru.' }, { status: 400 }); + } + + const updatedUser = await prisma.user.update({ + where: { id: session.userId }, + data: updateData, + select: { + id: true, + name: true, + email: true, + phone: true, + role: true, + }, + }); + + return NextResponse.json({ success: true, user: updatedUser }); + } catch (error) { + console.error('Update profile error:', error); + return NextResponse.json({ error: 'Gagal memperbarui profil.' }, { status: 500 }); + } +} diff --git a/src/app/dashboard/profile/page.tsx b/src/app/dashboard/profile/page.tsx new file mode 100644 index 0000000..abd9519 --- /dev/null +++ b/src/app/dashboard/profile/page.tsx @@ -0,0 +1,268 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { FaUser, FaLock, FaCheckCircle, FaExclamationCircle } from 'react-icons/fa'; +import styles from './profile.module.css'; + +interface User { + id: number; + name: string; + email: string; + phone: string; + role: string; + createdAt: string; +} + +export default function UserProfile() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + // Profile update form state + const [name, setName] = useState(''); + const [phone, setPhone] = useState(''); + + // Password change form state + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const [savingProfile, setSavingProfile] = useState(false); + const [savingPassword, setSavingPassword] = useState(false); + const [successMsg, setSuccessMsg] = useState(''); + const [errorMsg, setErrorMsg] = useState(''); + + useEffect(() => { + async function fetchProfile() { + try { + const res = await fetch('/api/user/profile'); + if (!res.ok) { + router.push('/login?redirect=/dashboard/profile'); + return; + } + const data = await res.json(); + setUser(data.user); + setName(data.user.name); + setPhone(data.user.phone); + } catch (err) { + console.error('Fetch profile error:', err); + setErrorMsg('Gagal memuat profil pengguna.'); + } finally { + setLoading(false); + } + } + fetchProfile(); + }, [router]); + + const handleUpdateProfile = async (e: React.FormEvent) => { + e.preventDefault(); + setSavingProfile(true); + setSuccessMsg(''); + setErrorMsg(''); + + try { + const res = await fetch('/api/user/profile', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, phone }), + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Gagal memperbarui profil.'); + } + + setUser(data.user); + setSuccessMsg('Profil berhasil diperbarui!'); + setTimeout(() => setSuccessMsg(''), 3000); + } catch (err: any) { + setErrorMsg(err.message || 'Terjadi kesalahan.'); + } finally { + setSavingProfile(false); + } + }; + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setSavingPassword(true); + setSuccessMsg(''); + setErrorMsg(''); + + if (newPassword !== confirmPassword) { + setErrorMsg('Konfirmasi password baru tidak cocok.'); + setSavingPassword(false); + return; + } + + try { + const res = await fetch('/api/user/profile', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Gagal mengubah password.'); + } + + setSuccessMsg('Password berhasil diubah!'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setTimeout(() => setSuccessMsg(''), 3000); + } catch (err: any) { + setErrorMsg(err.message || 'Terjadi kesalahan.'); + } finally { + setSavingPassword(false); + } + }; + + if (loading) { + return ( +
+
+

Memuat profil Anda...

+
+ ); + } + + if (!user) return null; + + return ( +
+
+

Pengaturan Profil

+

Kelola informasi pribadi dan keamanan akun Anda.

+
+ + {successMsg && ( +
+ {successMsg} +
+ )} + + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {/* Profile Info Form */} +
+

+ Informasi Profil +

+
+
+
+ + setName(e.target.value)} + required + /> +
+
+ + setPhone(e.target.value)} + required + /> +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + Kembali ke Dashboard + + +
+
+
+ + {/* Password Change Form */} +
+

+ Ubah Password Keamanan +

+
+
+ + setCurrentPassword(e.target.value)} + placeholder="Masukkan password Anda saat ini" + required={newPassword.length > 0} + /> +
+ +
+
+ + setNewPassword(e.target.value)} + placeholder="Minimal 6 karakter" + required={currentPassword.length > 0} + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Ulangi password baru" + required={currentPassword.length > 0} + /> +
+
+ +
+ +
+
+
+
+ ); +} diff --git a/src/app/dashboard/profile/profile.module.css b/src/app/dashboard/profile/profile.module.css new file mode 100644 index 0000000..651d445 --- /dev/null +++ b/src/app/dashboard/profile/profile.module.css @@ -0,0 +1,160 @@ +.container { + max-width: 800px; + margin: 0 auto; + padding: 40px 20px; + animation: fadeIn 0.4s ease-out forwards; +} + +.header { + margin-bottom: 30px; +} + +.title { + font-size: 2rem; + font-weight: 700; + color: var(--text-white); + margin-bottom: 8px; +} + +.subtitle { + color: var(--text-secondary); + font-size: 0.95rem; +} + +.profileCard { + background: var(--bg-card); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--border-light); + border-radius: 16px; + padding: 30px; + margin-bottom: 30px; + box-shadow: var(--shadow-lg); +} + +.sectionTitle { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-white); + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; + border-bottom: 1px solid var(--border-light); + padding-bottom: 10px; +} + +.infoGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-bottom: 30px; +} + +@media (max-width: 600px) { + .infoGrid { + grid-template-columns: 1fr; + } +} + +.infoItem { + display: flex; + flex-direction: column; + gap: 6px; +} + +.infoLabel { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.infoValue { + font-size: 1rem; + font-weight: 500; + color: var(--text-primary); +} + +.form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.formGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +@media (max-width: 600px) { + .formGrid { + grid-template-columns: 1fr; + } +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 15px; + margin-top: 10px; +} + +.alert { + padding: 14px 16px; + border-radius: 8px; + font-size: 0.9rem; + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; +} + +.alertSuccess { + background: rgba(16, 185, 129, 0.15); + color: var(--accent-emerald); + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.alertError { + background: rgba(244, 63, 94, 0.15); + color: var(--accent-rose); + border: 1px solid rgba(244, 63, 94, 0.3); +} + +.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + color: var(--text-secondary); +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-radius: 50%; + border-top-color: var(--primary); + animation: spin 1s ease-in-out infinite; + margin-bottom: 15px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index c06f5dc..2150504 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -94,6 +94,11 @@ export default function Header({ settings }: { settings?: any }) { Dashboard Saya )} + {user && ( + + Profil Saya + + )} {user?.role === 'ADMIN' && ( Admin Panel @@ -104,7 +109,7 @@ export default function Header({ settings }: { settings?: any }) {
{user ? (
- + Halo, {user.name}