feat: implement app directory routing, authentication flow, and dynamic reporting module

This commit is contained in:
Firman Ramdhani
2026-08-28 15:50:01 +07:00
parent 3e68d12ce4
commit 84e6599b23
28 changed files with 1869 additions and 478 deletions
+9
View File
@@ -0,0 +1,9 @@
import { AppLayout } from "@/components/AppLayout";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<AppLayout>
{children}
</AppLayout>
);
}
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getOrderDetail, updateSubmissionPayment, updateOrderStatus, updateOrder, deleteOrder } from '@/app/actions'
import { getOrderDetail, updateSubmissionPayment, updateOrderStatus, updateOrder, deleteOrder, getSessionUser } from '@/app/actions'
import { Card, CardContent } from '@/components/ui/card'
import { Button, buttonVariants } from '@/components/ui/button'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
@@ -47,9 +47,14 @@ export default function OrderDetailPage() {
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
const id = localStorage.getItem('user_id')
setUserId(id)
if (orderId) loadOrder()
const init = async () => {
const user = await getSessionUser()
if (user?.id) {
setUserId(user.id)
}
if (orderId) loadOrder()
}
init()
}, [orderId])
const loadOrder = async () => {
@@ -476,13 +481,13 @@ function SubmissionRow({
isClosed: boolean
onUpdate: () => void
}) {
const [bill, setBill] = useState(sub.bill || '')
const [bill, setBill] = useState(sub.bill ?? '')
const [status, setStatus] = useState(sub.payment_status || 'BELUM_BAYAR')
const [saving, setSaving] = useState(false)
const handleSave = async () => {
setSaving(true)
await updateSubmissionPayment(sub.id, bill ? parseInt(bill) : null, status)
await updateSubmissionPayment(sub.id, bill !== '' ? parseInt(bill as string) : null, status)
onUpdate()
setSaving(false)
}
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { getMyOrders, createOrder, updateOrder, duplicateOrder, updateOrderStatus, deleteOrder } from '@/app/actions'
import { getMyOrders, createOrder, updateOrder, duplicateOrder, updateOrderStatus, deleteOrder, getSessionUser } from '@/app/actions'
import { Button, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
@@ -46,11 +46,14 @@ export default function MyOrdersPage() {
const ITEMS_PER_PAGE = 5
useEffect(() => {
const id = localStorage.getItem('user_id')
if (id) {
setUserId(id)
loadOrders(id)
const init = async () => {
const user = await getSessionUser()
if (user?.id) {
setUserId(user.id)
loadOrders(user.id)
}
}
init()
}, [])
const loadOrders = async (id: string) => {
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { getMyPurchases, submitOrder, getUserSubmission } from '@/app/actions'
import { getMyPurchases, submitOrder, getUserSubmission, getSessionUser } from '@/app/actions'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button, buttonVariants } from '@/components/ui/button'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
@@ -38,11 +38,14 @@ export default function MyPurchasesPage() {
const ITEMS_PER_PAGE = 5
useEffect(() => {
const id = localStorage.getItem('user_id')
if (id) {
setUserId(id)
loadPurchases(id)
const init = async () => {
const user = await getSessionUser()
if (user?.id) {
setUserId(user.id)
loadPurchases(user.id)
}
}
init()
}, [])
const loadPurchases = async (id: string) => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { getAvailableOrders } from './actions'
import { getAvailableOrders } from '@/app/actions'
import { OrderCard } from '@/components/OrderCard'
import { Sparkles, Plus, Store } from 'lucide-react'
import Link from 'next/link'
+280
View File
@@ -0,0 +1,280 @@
'use client'
import { useEffect, useState } from 'react'
import { getSessionUser, updateProfile, changePassword } from '@/app/actions'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Loader2, Save, CheckCircle, ShieldCheck, UserCircle, KeyRound, LockKeyhole } from 'lucide-react'
export default function ProfilePage() {
const [user, setUser] = useState<any>(null)
const [name, setName] = useState('')
const [photo, setPhoto] = useState('')
const [loading, setLoading] = useState(true)
// Profile state
const [savingProfile, setSavingProfile] = useState(false)
const [profileError, setProfileError] = useState('')
const [profileSuccess, setProfileSuccess] = useState(false)
// Password state
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [savingPassword, setSavingPassword] = useState(false)
const [passwordError, setPasswordError] = useState('')
const [passwordSuccess, setPasswordSuccess] = useState(false)
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
useEffect(() => {
loadUser()
}, [])
const loadUser = async () => {
setLoading(true)
const sessionUser = await getSessionUser()
if (sessionUser) {
setUser(sessionUser)
setName(sessionUser.name)
setPhoto(sessionUser.photo || '')
}
setLoading(false)
}
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setProfileError('Nama wajib diisi')
return
}
setProfileError('')
setProfileSuccess(false)
setSavingProfile(true)
const res = await updateProfile(user.id, name.trim(), photo.trim() || null)
if (res.success) {
setUser(res.user)
setProfileSuccess(true)
setTimeout(() => setProfileSuccess(false), 3000)
} else {
setProfileError(res.error || 'Terjadi kesalahan')
}
setSavingProfile(false)
}
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
setPasswordError('')
setPasswordSuccess(false)
if (!oldPassword || !newPassword || !confirmPassword) {
setPasswordError('Semua field wajib diisi')
return
}
if (newPassword !== confirmPassword) {
setPasswordError('Konfirmasi password tidak cocok')
return
}
if (newPassword.length < 6) {
setPasswordError('Password baru minimal 6 karakter')
return
}
setSavingPassword(true)
const res = await changePassword(user.id, oldPassword, newPassword)
if (res.success) {
setPasswordSuccess(true)
setOldPassword('')
setNewPassword('')
setConfirmPassword('')
setTimeout(() => {
setPasswordSuccess(false)
setIsPasswordModalOpen(false)
}, 2000)
} else {
setPasswordError(res.error || 'Gagal mengubah password')
}
setSavingPassword(false)
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
<Loader2 className="w-8 h-8 animate-spin text-[#1B2CC1]" />
<span className="text-xs text-slate-500 font-semibold">Memuat profil...</span>
</div>
)
}
if (!user) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
<span className="text-xs text-slate-500 font-semibold">Anda belum login.</span>
</div>
)
}
return (
<div className="space-y-6 animate-in fade-in duration-500 pb-12 max-w-5xl">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left 2 Cols: Forms */}
<div className="lg:col-span-2 space-y-6">
{/* PROFILE FORM */}
<Card className="rounded-3xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden">
<div className="p-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between">
<div>
<h2 className="text-lg font-black text-slate-900 dark:text-white">Informasi Akun</h2>
<p className="text-xs text-slate-500 mt-0.5">Ubah nama tampilan dan foto profil Anda.</p>
</div>
<span className="text-[11px] font-bold text-[#1B2CC1] bg-[#1B2CC1]/10 px-2.5 py-1 rounded-full uppercase tracking-wider">
{user.role}
</span>
</div>
<form onSubmit={handleSaveProfile}>
<CardContent className="p-6 space-y-6">
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">Foto Profil</Label>
<div className="flex flex-col sm:flex-row items-center gap-5 p-5 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
{photo ? (
<img src={photo} alt={name} className="w-20 h-20 rounded-2xl object-cover ring-4 ring-white dark:ring-slate-700 shadow-md shrink-0" />
) : (
<div className="w-20 h-20 rounded-2xl bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center font-bold text-2xl ring-4 ring-white dark:ring-slate-700 shadow-sm shrink-0">
{name ? name.charAt(0).toUpperCase() : <UserCircle className="w-10 h-10" />}
</div>
)}
<div className="space-y-1.5 flex-1 w-full text-center sm:text-left">
<p className="text-xs font-bold text-slate-800 dark:text-slate-200">Pratinjau Avatar</p>
<p className="text-[11px] text-slate-400">Masukkan tautan URL foto gambar di bawah untuk memperbarui gambar profil.</p>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="prof-name" className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
Nama Tampilan <span className="text-red-500">*</span>
</Label>
<Input id="prof-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Contoh: Budi Santoso" className="h-11 rounded-xl" />
</div>
<div className="space-y-1.5">
<Label htmlFor="prof-photo" className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
URL Foto Profil
</Label>
<Input id="prof-photo" value={photo} onChange={(e) => setPhoto(e.target.value)} placeholder="https://..." className="h-11 rounded-xl" />
</div>
{profileError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-semibold">{profileError}</div>
)}
{profileSuccess && (
<div className="p-3 bg-emerald-50 text-emerald-700 rounded-xl text-xs font-bold flex items-center gap-2">
<CheckCircle className="w-4 h-4" /><span>Profil berhasil diperbarui!</span>
</div>
)}
</CardContent>
<div className="p-6 bg-slate-50/60 border-t border-slate-100 flex justify-end">
<Button type="submit" disabled={savingProfile} className="h-11 px-6 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold">
{savingProfile ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Save className="w-4 h-4 mr-2" />}
Simpan Profil
</Button>
</div>
</form>
</Card>
</div>
{/* Right 1 Col: Account Details */}
<div className="space-y-6">
<Card className="rounded-3xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-6 space-y-4">
<div className="flex items-center gap-2 text-slate-800 dark:text-slate-200 font-bold text-sm mb-0">
<ShieldCheck className="w-4 h-4 text-[#1B2CC1]" />
<span>Detail Akun</span>
</div>
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800">
<div className="space-y-1">
<span className="text-[10px] uppercase font-bold text-slate-400 block">Username</span>
<div className="font-semibold text-slate-800 text-sm">{user.username}</div>
</div>
<div className="space-y-1">
<span className="text-[10px] uppercase font-bold text-slate-400 block">Role Akses</span>
<div className="font-semibold text-[#1B2CC1] text-sm uppercase">{user.role}</div>
</div>
</div>
{/* PASSWORD CHANGE MODAL BUTTON */}
<div className="pt-2 border-t border-slate-100 dark:border-slate-800">
<Dialog open={isPasswordModalOpen} onOpenChange={setIsPasswordModalOpen}>
<DialogTrigger className="cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#1B2CC1] disabled:pointer-events-none disabled:opacity-50 h-11 px-4 py-2 rounded-xl gap-2 text-[#1B2CC1] hover:text-[#15229E] font-bold border border-[#1B2CC1]/20 bg-[#1B2CC1]/5 hover:bg-[#1B2CC1]/10 w-full">
<KeyRound className="w-4 h-4" />
Ganti Password
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] rounded-3xl p-0 overflow-hidden border-slate-200/90 dark:border-slate-800 shadow-xl">
<DialogHeader className="p-6 bg-slate-50 dark:bg-slate-900/50 border-b border-slate-100 dark:border-slate-800">
<DialogTitle className="text-xl font-black text-slate-900 dark:text-white">Ganti Password</DialogTitle>
<p className="text-xs text-slate-500 mt-1">Lindungi akun Anda dengan mengubah password secara berkala.</p>
</DialogHeader>
<form onSubmit={handleChangePassword}>
<div className="p-6 space-y-5 bg-white dark:bg-slate-900">
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700">Password Lama</Label>
<div className="relative">
<LockKeyhole className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} className="pl-9 h-11 rounded-xl" placeholder="Masukkan password lama" />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700">Password Baru</Label>
<div className="relative">
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="pl-9 h-11 rounded-xl" placeholder="Minimal 6 karakter" />
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700">Konfirmasi Password Baru</Label>
<div className="relative">
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="pl-9 h-11 rounded-xl" placeholder="Ketik ulang password baru" />
</div>
</div>
</div>
{passwordError && (
<div className="p-3 bg-red-50 text-red-600 rounded-xl text-xs font-semibold">{passwordError}</div>
)}
{passwordSuccess && (
<div className="p-3 bg-emerald-50 text-emerald-700 rounded-xl text-xs font-bold flex items-center gap-2">
<CheckCircle className="w-4 h-4" /><span>Password berhasil diubah!</span>
</div>
)}
</div>
<div className="p-6 bg-slate-50/60 border-t border-slate-100 flex justify-end gap-3">
<Button type="button" variant="ghost" onClick={() => setIsPasswordModalOpen(false)} className="h-11 rounded-xl font-bold">Batal</Button>
<Button type="submit" disabled={savingPassword} className="h-11 px-6 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold">
{savingPassword ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Save className="w-4 h-4 mr-2" />}
Ubah Password
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</Card>
</div>
</div>
</div>
)
}
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { getCreatorReport, getSubmittorReport } from '@/app/actions'
import { getCreatorReport, getSubmittorReport, getSessionUser } from '@/app/actions'
import { Card } from '@/components/ui/card'
import { Loader2, TrendingUp, TrendingDown, Wallet, ArrowRightLeft, Calendar as CalendarIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -51,7 +51,7 @@ function getIntervalFromFilter(type: 'WEEK' | 'MONTH', value: string): { start:
export default function ReportsPage() {
const [activeTab, setActiveTab] = useState<'SUBMITTOR' | 'CREATOR'>('SUBMITTOR')
const [creatorTab, setCreatorTab] = useState<'BY_ORDER' | 'BY_PERSON'>('BY_ORDER')
const [creatorTab, setCreatorTab] = useState<'BY_ORDER' | 'BY_PERSON'>('BY_PERSON')
const [filterType, setFilterType] = useState<'WEEK' | 'MONTH'>('WEEK')
const [filterValue, setFilterValue] = useState<string>(getWeekValue(new Date()))
@@ -62,10 +62,12 @@ export default function ReportsPage() {
const [creatorData, setCreatorData] = useState<any[]>([])
const [loading, setLoading] = useState(true)
// Initial load: get userId
useEffect(() => {
const id = localStorage.getItem('user_id')
if (id) setUserId(id)
const init = async () => {
const user = await getSessionUser()
if (user?.id) setUserId(user.id)
}
init()
}, [])
// Re-fetch every time userId, filterType, or filterValue changes
@@ -261,7 +263,7 @@ export default function ReportsPage() {
{/* Sub-Tabs */}
<div className="flex items-center bg-slate-100 dark:bg-slate-800/80 p-1 rounded-xl text-xs font-bold w-full sm:w-auto">
{(['BY_ORDER', 'BY_PERSON'] as const).map(tab => (
{([ 'BY_PERSON','BY_ORDER',] as const).map(tab => (
<button
key={tab}
onClick={() => setCreatorTab(tab)}
+316
View File
@@ -0,0 +1,316 @@
'use client'
import { useEffect, useState } from 'react'
import { getUsers, createUserByAdmin, toggleUserActive, deleteUser, resetUserPassword } from '@/app/actions'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Loader2, Plus, KeyRound, Ban, Trash2, CheckCircle2, ShieldCheck, User } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
export default function UsersPage() {
const [users, setUsers] = useState<any[]>([])
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(1)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
// Modals
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [isPassOpen, setIsPassOpen] = useState(false)
const [selectedUser, setSelectedUser] = useState<any>(null)
// Forms
const [name, setName] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [role, setRole] = useState('user')
const [newPass, setNewPass] = useState('')
const [actionLoading, setActionLoading] = useState(false)
useEffect(() => {
loadUsers(page)
}, [page])
const loadUsers = async (p: number) => {
setLoading(true)
const res = await getUsers(p, 10)
setUsers(res.users)
setTotal(res.total)
setTotalPages(res.totalPages)
setLoading(false)
}
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault()
if (!name || !username || !password) return alert('Lengkapi semua field')
setActionLoading(true)
const res = await createUserByAdmin(name, username, password, role)
if (res.success) {
setIsCreateOpen(false)
setName('')
setUsername('')
setPassword('')
setRole('user')
loadUsers(page)
} else {
alert(res.error)
}
setActionLoading(false)
}
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault()
if (!newPass || newPass.length < 6) return alert('Password minimal 6 karakter')
setActionLoading(true)
const res = await resetUserPassword(selectedUser.id, newPass)
if (res.success) {
setIsPassOpen(false)
setNewPass('')
alert('Password berhasil diubah')
} else {
alert(res.error)
}
setActionLoading(false)
}
const handleToggleActive = async (id: string, currentStatus: boolean) => {
if (!confirm(`Yakin ingin ${currentStatus ? 'menonaktifkan' : 'mengaktifkan'} user ini?`)) return
const res = await toggleUserActive(id, !currentStatus)
if (res.success) {
loadUsers(page)
} else {
alert(res.error)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Yakin ingin menghapus user ini secara permanen?')) return
const res = await deleteUser(id)
if (res.success) {
loadUsers(page)
} else {
alert(res.error)
}
}
return (
<div className="space-y-6 animate-in fade-in duration-500 pb-12">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-black text-slate-900 dark:text-white">Manajemen Pengguna</h1>
<p className="text-sm text-slate-500 mt-1">Kelola data seluruh pengguna TitipIn.</p>
</div>
<Button onClick={() => setIsCreateOpen(true)} className="bg-[#1B2CC1] hover:bg-[#15229E] text-white rounded-xl h-11 px-5 shadow-md">
<Plus className="w-4 h-4 mr-2" /> Tambah User
</Button>
</div>
<Card className="rounded-3xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-slate-50/50 dark:bg-slate-800/50 border-b border-slate-100 dark:border-slate-800 text-xs uppercase font-bold text-slate-500">
<tr>
<th className="px-6 py-4">Pengguna</th>
<th className="px-6 py-4">Role</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4">Terdaftar</th>
<th className="px-6 py-4 text-right">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{loading ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-[#1B2CC1]" />
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-slate-500">Belum ada pengguna lain.</td>
</tr>
) : (
users.map(u => (
<tr key={u.id} className="hover:bg-slate-50/50 dark:hover:bg-slate-800/20 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{u.photo ? (
<img src={u.photo} alt={u.name} className="w-9 h-9 rounded-full object-cover shrink-0" />
) : (
<div className="w-9 h-9 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center font-bold text-xs uppercase shrink-0">
{u.name.charAt(0)}
</div>
)}
<div>
<div className="font-bold text-slate-900 dark:text-slate-100">{u.name}</div>
<div className="text-xs text-slate-500">@{u.username}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
{u.role === 'superadmin' ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-md bg-purple-100 text-purple-700 uppercase">
<ShieldCheck className="w-3 h-3" /> Superadmin
</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-md bg-slate-100 text-slate-700 uppercase">
<User className="w-3 h-3" /> User
</span>
)}
</td>
<td className="px-6 py-4">
{u.is_active ? (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-md bg-emerald-100 text-emerald-700 uppercase">
<CheckCircle2 className="w-3 h-3" /> Aktif
</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-md bg-red-100 text-red-700 uppercase">
<Ban className="w-3 h-3" /> Nonaktif
</span>
)}
</td>
<td className="px-6 py-4 text-slate-500 text-xs">
{new Date(u.created_at).toLocaleDateString('id-ID')}
</td>
<td className="px-6 py-4 text-right">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedUser(u)
setIsPassOpen(true)
}}
className="h-8 px-2 rounded-lg bg-slate-100 text-slate-700 hover:bg-slate-200 hover:text-slate-900 font-medium"
title="Ubah Password"
>
<KeyRound className="w-4 h-4" />
</Button>
{u.role !== 'superadmin' && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => handleToggleActive(u.id, u.is_active)}
className={`h-8 px-2 rounded-lg font-medium ${u.is_active ? 'bg-amber-100 text-amber-700 hover:bg-amber-200 hover:text-amber-900' : 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 hover:text-emerald-900'}`}
title={u.is_active ? 'Nonaktifkan' : 'Aktifkan'}
>
{u.is_active ? <Ban className="w-4 h-4" /> : <CheckCircle2 className="w-4 h-4" />}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(u.id)}
className="h-8 px-2 rounded-lg bg-rose-100 text-rose-700 hover:bg-rose-200 hover:text-rose-900 font-medium"
title="Hapus"
>
<Trash2 className="w-4 h-4" />
</Button>
</>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="p-4 border-t border-slate-100 dark:border-slate-800 flex justify-between items-center bg-slate-50/50">
<span className="text-xs text-slate-500">Halaman {page} dari {totalPages}</span>
<div className="flex gap-2">
<Button disabled={page === 1} onClick={() => setPage(p => p - 1)} variant="outline" size="sm" className="h-8 rounded-lg text-xs">Sebelumnya</Button>
<Button disabled={page === totalPages} onClick={() => setPage(p => p + 1)} variant="outline" size="sm" className="h-8 rounded-lg text-xs">Selanjutnya</Button>
</div>
</div>
)}
</Card>
{/* CREATE MODAL */}
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogContent className="sm:max-w-md rounded-3xl p-0 overflow-hidden border-0">
<div className="px-6 pt-6 pb-4 border-b border-slate-100">
<DialogTitle className="text-xl font-black">Tambah User Baru</DialogTitle>
</div>
<form onSubmit={handleCreateUser} className="px-6 py-4 space-y-4">
<div className="space-y-1.5">
<Label className="text-xs font-bold uppercase text-slate-700">Nama Lengkap</Label>
<Input required value={name} onChange={e => setName(e.target.value)} className="h-11 rounded-xl" placeholder="Masukkan nama lengkap" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-bold uppercase text-slate-700">Username</Label>
<Input required value={username} onChange={e => setUsername(e.target.value)} className="h-11 rounded-xl" placeholder="Masukkan username unik" />
</div>
<div className="space-y-1.5">
<Label className="text-xs font-bold uppercase text-slate-700">Password</Label>
<Input required type="password" value={password} onChange={e => setPassword(e.target.value)} className="h-11 rounded-xl" placeholder="Buat kata sandi (min. 6 karakter)" />
</div>
<div className="space-y-2">
<Label className="text-xs font-bold uppercase text-slate-700">Role Akses</Label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setRole('user')}
className={`h-11 rounded-xl border flex items-center justify-center gap-2 text-sm font-bold transition-all ${
role === 'user'
? 'border-[#1B2CC1] bg-[#1B2CC1]/5 text-[#1B2CC1] ring-1 ring-[#1B2CC1]/20'
: 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}
>
<User className="w-4 h-4" /> User Biasa
</button>
<button
type="button"
onClick={() => setRole('superadmin')}
className={`h-11 rounded-xl border flex items-center justify-center gap-2 text-sm font-bold transition-all ${
role === 'superadmin'
? 'border-purple-500 bg-purple-50 text-purple-700 ring-1 ring-purple-500/20'
: 'border-slate-200 text-slate-500 hover:bg-slate-50'
}`}
>
<ShieldCheck className="w-4 h-4" /> Superadmin
</button>
</div>
</div>
<div className="pt-4 flex gap-3">
<Button type="button" variant="outline" onClick={() => setIsCreateOpen(false)} className="flex-1 h-11 rounded-xl">Batal</Button>
<Button type="submit" disabled={actionLoading} className="flex-1 h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white">
{actionLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Simpan User'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* RESET PASSWORD MODAL */}
<Dialog open={isPassOpen} onOpenChange={setIsPassOpen}>
<DialogContent className="sm:max-w-md rounded-3xl p-0 overflow-hidden border-0">
<div className="px-6 pt-6 pb-4 border-b border-slate-100">
<DialogTitle className="text-xl font-black">Ubah Password</DialogTitle>
<p className="text-xs text-slate-500 mt-1">Ubah password untuk user <span className="font-bold text-slate-900">@{selectedUser?.username}</span></p>
</div>
<form onSubmit={handleResetPassword} className="px-6 py-4 space-y-4">
<div className="space-y-1.5">
<Label className="text-xs font-bold uppercase text-slate-700">Password Baru</Label>
<Input required type="password" value={newPass} onChange={e => setNewPass(e.target.value)} className="h-11 rounded-xl" placeholder="Minimal 6 karakter" />
</div>
<div className="pt-4 flex gap-3">
<Button type="button" variant="outline" onClick={() => setIsPassOpen(false)} className="flex-1 h-11 rounded-xl">Batal</Button>
<Button type="submit" disabled={actionLoading} className="flex-1 h-11 rounded-xl bg-slate-900 hover:bg-slate-800 text-white">
{actionLoading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Simpan Password'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-[#F4F6FB] dark:bg-[#0B0F19]">
{children}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { loginUser } from '@/app/actions'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { LogIn, KeyRound, User, Loader2, Eye, EyeOff } from 'lucide-react'
export default function LoginPage() {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showPassword, setShowPassword] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setLoading(true)
setError('')
const formData = new FormData(e.currentTarget)
const username = (formData.get('username') as string).trim()
const password = formData.get('password') as string
if (!username || !password) {
setError('Username dan Password wajib diisi')
setLoading(false)
return
}
const res = await loginUser(username, password)
if (res.success) {
window.location.href = '/' // Force hard reload to update context/middleware
} else {
setError(res.error || 'Gagal login')
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4 lg:p-6 relative overflow-hidden">
{/* Elegant Ambient Background */}
<div className="absolute inset-0 z-0 pointer-events-none">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 h-[400px] w-[600px] rounded-full bg-[#1B2CC1] opacity-10 dark:opacity-20 blur-[120px]"></div>
</div>
<div className="w-full max-w-5xl bg-white dark:bg-slate-900 lg:rounded-[2.5rem] rounded-3xl shadow-2xl shadow-[#1B2CC1]/10 flex overflow-hidden border border-slate-100 dark:border-slate-800 relative z-10">
{/* Left Side: Branding (Hidden on Mobile) */}
<div className="hidden lg:flex w-1/2 bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-12 flex-col justify-between relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-full pointer-events-none">
<div className="absolute -top-[20%] -left-[10%] w-[60%] h-[60%] rounded-full bg-white/10 blur-3xl" />
<div className="absolute top-[60%] -right-[10%] w-[70%] h-[70%] rounded-full bg-blue-400/20 blur-3xl" />
</div>
<div className="relative z-10">
<div className="inline-flex items-center justify-center p-3 bg-white/10 rounded-2xl backdrop-blur-md border border-white/20 mb-8 shadow-sm">
<LogIn className="w-8 h-8 text-white" />
</div>
<h1 className="text-4xl font-black text-white leading-tight">
Selamat Datang <br /> di TitipIn
</h1>
<p className="text-blue-100 mt-4 text-base max-w-sm leading-relaxed">
Platform modern dan terpercaya untuk mengelola pesanan jasa titip Anda dengan rapi, efisien, dan transparan.
</p>
</div>
<div className="relative z-10 flex items-center gap-4 bg-white/10 p-4 rounded-2xl backdrop-blur-sm border border-white/10 w-fit">
<div className="flex -space-x-3">
<div className="w-10 h-10 rounded-full bg-blue-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-blue-700">A</div>
<div className="w-10 h-10 rounded-full bg-purple-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-purple-700">B</div>
<div className="w-10 h-10 rounded-full bg-amber-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-amber-700">C</div>
</div>
<p className="text-xs text-blue-100 font-medium">Bergabung dengan ribuan<br/>pengguna lainnya.</p>
</div>
</div>
{/* Right Side: Form */}
<div className="w-full lg:w-1/2 p-8 lg:p-14 flex flex-col justify-center">
<div className="lg:hidden flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#1B2CC1] to-[#121E85] flex items-center justify-center text-white shadow-lg shadow-[#1B2CC1]/25 mb-4">
<LogIn className="w-8 h-8" />
</div>
<h1 className="text-2xl font-black text-slate-900 dark:text-white">TitipIn</h1>
</div>
<div className="mb-8 text-center lg:text-left">
<h2 className="text-2xl font-black text-slate-900 dark:text-white hidden lg:block mb-2">Masuk ke Akun</h2>
<p className="text-sm text-slate-500">Silakan masukkan kredensial Anda untuk melanjutkan</p>
</div>
{error && (
<div className="bg-rose-50 text-rose-600 p-3 rounded-xl text-sm font-medium mb-6 border border-rose-100 text-center">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Username</label>
<div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
name="username"
className="pl-10 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]"
placeholder="Masukkan username"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Password</label>
<div className="relative flex items-center">
<KeyRound className="absolute left-3.5 w-4 h-4 text-slate-400" />
<Input
name="password"
type={showPassword ? 'text' : 'password'}
className="pl-10 pr-12 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]"
placeholder="Masukkan password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors cursor-pointer"
aria-label={showPassword ? 'Sembunyikan password' : 'Tampilkan password'}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<Button
type="submit"
disabled={loading}
className="w-full h-12 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold text-base shadow-md shadow-[#1B2CC1]/20 transition-all active:scale-[0.98] mt-2 cursor-pointer"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Masuk Sekarang'}
</Button>
</form>
<p className="text-center text-sm text-slate-500 mt-8">
Belum punya akun?{' '}
<Link href="/register" className="font-bold text-[#1B2CC1] hover:underline">
Daftar di sini
</Link>
</p>
</div>
</div>
</div>
)
}
+201
View File
@@ -0,0 +1,201 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { registerUser } from '@/app/actions'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { UserPlus, KeyRound, User, Loader2, BadgeCheck, Eye, EyeOff } from 'lucide-react'
export default function RegisterPage() {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setLoading(true)
setError('')
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const username = (formData.get('username') as string).trim()
const password = formData.get('password') as string
const confirm = formData.get('confirm') as string
if (!name || !username || !password || !confirm) {
setError('Semua field wajib diisi')
setLoading(false)
return
}
if (password !== confirm) {
setError('Password dan Konfirmasi Password tidak cocok')
setLoading(false)
return
}
if (password.length < 6) {
setError('Password minimal 6 karakter')
setLoading(false)
return
}
const res = await registerUser(name, username, password)
if (res.success) {
window.location.href = '/' // Force hard reload to update context/middleware
} else {
setError(res.error || 'Gagal mendaftar')
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4 lg:p-6 relative overflow-hidden">
{/* Elegant Ambient Background */}
<div className="absolute inset-0 z-0 pointer-events-none">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)]"></div>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 h-[400px] w-[600px] rounded-full bg-[#121E85] opacity-10 dark:opacity-20 blur-[120px]"></div>
</div>
<div className="w-full max-w-5xl bg-white dark:bg-slate-900 lg:rounded-[2.5rem] rounded-3xl shadow-2xl shadow-[#1B2CC1]/10 flex overflow-hidden border border-slate-100 dark:border-slate-800 relative z-10">
{/* Left Side: Branding (Hidden on Mobile) */}
<div className="hidden lg:flex w-1/2 bg-gradient-to-br from-[#121E85] to-[#1B2CC1] p-12 flex-col justify-between relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-full pointer-events-none">
<div className="absolute -top-[20%] -left-[10%] w-[60%] h-[60%] rounded-full bg-white/10 blur-3xl" />
<div className="absolute top-[60%] -right-[10%] w-[70%] h-[70%] rounded-full bg-indigo-400/20 blur-3xl" />
</div>
<div className="relative z-10">
<div className="inline-flex items-center justify-center p-3 bg-white/10 rounded-2xl backdrop-blur-md border border-white/20 mb-8 shadow-sm">
<UserPlus className="w-8 h-8 text-white" />
</div>
<h1 className="text-4xl font-black text-white leading-tight">
Mulai Perjalanan <br /> Anda di TitipIn
</h1>
<p className="text-indigo-100 mt-4 text-base max-w-sm leading-relaxed">
Buat akun secara gratis dan nikmati kemudahan mengelola PO jasa titip tanpa ribet.
</p>
</div>
<div className="relative z-10 flex items-center gap-4 bg-white/10 p-4 rounded-2xl backdrop-blur-sm border border-white/10 w-fit">
<div className="flex -space-x-3">
<div className="w-10 h-10 rounded-full bg-emerald-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-emerald-700"></div>
<div className="w-10 h-10 rounded-full bg-cyan-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-cyan-700"></div>
<div className="w-10 h-10 rounded-full bg-rose-200 border-2 border-[#121E85] flex items-center justify-center text-[10px] font-bold text-rose-700"></div>
</div>
<p className="text-xs text-indigo-100 font-medium">Aman, Cepat, dan<br/>Mudah digunakan.</p>
</div>
</div>
{/* Right Side: Form */}
<div className="w-full lg:w-1/2 p-8 lg:p-14 flex flex-col justify-center">
<div className="lg:hidden flex flex-col items-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#1B2CC1] to-[#121E85] flex items-center justify-center text-white shadow-lg shadow-[#1B2CC1]/25 mb-4">
<UserPlus className="w-8 h-8" />
</div>
<h1 className="text-2xl font-black text-slate-900 dark:text-white">Daftar TitipIn</h1>
</div>
<div className="mb-8 text-center lg:text-left">
<h2 className="text-2xl font-black text-slate-900 dark:text-white hidden lg:block mb-2">Buat Akun Baru</h2>
<p className="text-sm text-slate-500">Lengkapi form di bawah untuk membuat akun baru</p>
</div>
{error && (
<div className="bg-rose-50 text-rose-600 p-3 rounded-xl text-sm font-medium mb-6 border border-rose-100 text-center">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Nama Lengkap</label>
<div className="relative">
<BadgeCheck className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
name="name"
className="pl-10 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]"
placeholder="Masukkan nama lengkap"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Username</label>
<div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
name="username"
className="pl-10 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]"
placeholder="Buat username unik"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Password</label>
<div className="relative flex items-center">
<KeyRound className="absolute left-3.5 w-4 h-4 text-slate-400" />
<Input
name="password"
type={showPassword ? 'text' : 'password'}
className="pl-10 pr-12 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]"
placeholder="Minimal 6 karakter"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors cursor-pointer"
aria-label={showPassword ? 'Sembunyikan password' : 'Tampilkan password'}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider">Konfirmasi Password</label>
<div className="relative flex items-center">
<KeyRound className="absolute left-3.5 w-4 h-4 text-slate-400" />
<Input
name="confirm"
type={showConfirm ? 'text' : 'password'}
className="pl-10 pr-12 h-12 bg-slate-50 dark:bg-slate-800/50 rounded-xl border-slate-200 focus-visible:ring-[#1B2CC1]"
placeholder="Ketik ulang password"
/>
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className="absolute right-3.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors cursor-pointer"
aria-label={showConfirm ? 'Sembunyikan password' : 'Tampilkan password'}
>
{showConfirm ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<Button
type="submit"
disabled={loading}
className="w-full h-12 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold text-base shadow-md shadow-[#1B2CC1]/20 transition-all active:scale-[0.98] mt-2 cursor-pointer"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Buat Akun'}
</Button>
</form>
<p className="text-center text-sm text-slate-500 mt-8">
Sudah punya akun?{' '}
<Link href="/login" className="font-bold text-[#1B2CC1] hover:underline">
Masuk di sini
</Link>
</p>
</div>
</div>
</div>
)
}
+179 -12
View File
@@ -3,38 +3,204 @@
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
// === USER ACTIONS ===
export async function checkUser(id: string) {
return await prisma.user.findUnique({ where: { id } })
import { hashPassword, verifyPassword, encrypt, decrypt } from '@/lib/auth'
import { cookies } from 'next/headers'
// === AUTHENTICATION ACTIONS ===
export async function loginUser(username: string, password: string) {
try {
const user = await prisma.user.findUnique({ where: { username } })
if (!user) return { success: false, error: 'Username tidak ditemukan.' }
if (!user.is_active) return { success: false, error: 'Akun Anda dinonaktifkan.' }
const isMatch = await verifyPassword(password, user.password)
if (!isMatch) return { success: false, error: 'Password salah.' }
const sessionData = {
id: user.id,
username: user.username,
name: user.name,
role: user.role,
photo: user.photo
}
const sessionToken = await encrypt(sessionData);
(await cookies()).set('session', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/'
})
return { success: true, user: sessionData }
} catch (e) {
console.error("Login Error:", e)
return { success: false, error: 'Terjadi kesalahan saat login.' }
}
}
export async function registerUser(id: string, name: string, photo?: string) {
export async function registerUser(name: string, username: string, password: string) {
try {
const hashedPassword = await hashPassword(password)
const user = await prisma.user.create({
data: { id, name, photo }
data: { name, username, password: hashedPassword }
})
return { success: true, user }
const sessionData = {
id: user.id,
username: user.username,
name: user.name,
role: user.role,
photo: user.photo
}
const sessionToken = await encrypt(sessionData);
(await cookies()).set('session', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/'
})
return { success: true, user: sessionData }
} catch (error: any) {
if (error.code === 'P2002') return { success: false, error: 'Nama sudah digunakan.' }
console.error("Register Error:", error)
if (error.code === 'P2002') return { success: false, error: 'Username sudah digunakan.' }
return { success: false, error: 'Terjadi kesalahan.' }
}
}
export async function updateProfile(id: string, name: string, photo?: string) {
export async function logoutUser() {
(await cookies()).delete('session')
return { success: true }
}
export async function getSessionUser() {
const cookie = (await cookies()).get('session')?.value
if (!cookie) return null
return await decrypt(cookie)
}
export async function updateProfile(id: string, name: string, photo?: string | null) {
try {
const user = await prisma.user.update({
where: { id },
data: { name, photo }
data: { name, photo: photo === undefined ? undefined : (photo || null) }
})
// Update session cookie with new data
const sessionData = {
id: user.id,
username: user.username,
name: user.name,
role: user.role,
photo: user.photo
}
const sessionToken = await encrypt(sessionData);
(await cookies()).set('session', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/'
})
revalidatePath('/')
revalidatePath('/profile')
return { success: true, user }
return { success: true, user: sessionData }
} catch (error: any) {
if (error.code === 'P2002') return { success: false, error: 'Nama sudah digunakan.' }
return { success: false, error: 'Gagal memperbarui profil.' }
}
}
export async function changePassword(id: string, oldPass: string, newPass: string) {
try {
const user = await prisma.user.findUnique({ where: { id } })
if (!user) return { success: false, error: 'User tidak ditemukan.' }
const isMatch = await verifyPassword(oldPass, user.password)
if (!isMatch) return { success: false, error: 'Password lama salah.' }
const hashed = await hashPassword(newPass)
await prisma.user.update({
where: { id },
data: { password: hashed }
})
return { success: true }
} catch (e) {
return { success: false, error: 'Gagal mengganti password.' }
}
}
// === USER MANAGEMENT (SUPERADMIN) ===
export async function getUsers(page = 1, limit = 10) {
const skip = (page - 1) * limit
const [users, total] = await Promise.all([
prisma.user.findMany({ skip, take: limit, orderBy: { created_at: 'desc' } }),
prisma.user.count()
])
return { users, total, totalPages: Math.ceil(total / limit) }
}
export async function createUserByAdmin(name: string, username: string, password: string, role: string) {
try {
const hashedPassword = await hashPassword(password)
await prisma.user.create({
data: { name, username, password: hashedPassword, role }
})
revalidatePath('/users')
return { success: true }
} catch (e: any) {
if (e.code === 'P2002') return { success: false, error: 'Username sudah digunakan.' }
return { success: false, error: 'Gagal membuat user.' }
}
}
export async function toggleUserActive(id: string, is_active: boolean) {
try {
const user = await prisma.user.findUnique({ where: { id } })
if (user?.role === 'superadmin') return { success: false, error: 'Data Superadmin tidak bisa dinonaktifkan.' }
await prisma.user.update({
where: { id },
data: { is_active }
})
revalidatePath('/users')
return { success: true }
} catch (e) {
return { success: false, error: 'Gagal mengupdate status user.' }
}
}
export async function deleteUser(id: string) {
try {
const user = await prisma.user.findUnique({ where: { id } })
if (user?.role === 'superadmin') return { success: false, error: 'Data Superadmin tidak bisa dihapus.' }
await prisma.user.delete({ where: { id } })
revalidatePath('/users')
return { success: true }
} catch (e) {
return { success: false, error: 'Gagal menghapus user.' }
}
}
export async function resetUserPassword(id: string, newPass: string) {
try {
const hashed = await hashPassword(newPass)
await prisma.user.update({
where: { id },
data: { password: hashed }
})
revalidatePath('/users')
return { success: true }
} catch (e) {
return { success: false, error: 'Gagal mereset password.' }
}
}
// === ORDER ACTIONS (DASHBOARD & CREATE) ===
export async function getAvailableOrders() {
const startOfDay = new Date()
@@ -91,6 +257,7 @@ export async function createOrder(data: {
revalidatePath('/my-orders')
return { success: true, order }
} catch (error) {
console.error("Create Order Error:", error)
return { success: false, error: 'Gagal membuat order.' }
}
}
@@ -225,7 +392,7 @@ export async function updateSubmissionPayment(submission_id: string, bill: numbe
revalidatePath(`/reports`)
return { success: true }
} catch (e) {
return { success: false, error: 'Gagal menyimpan tagihan.' }
console.error(e); return { success: false, error: 'Gagal menyimpan tagihan.' }
}
}
+4 -1
View File
@@ -115,4 +115,7 @@
html {
font-family: var(--font-roboto), var(--font-open-sans), -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", "Open Sans", system-ui, sans-serif;
}
}
}
button {
cursor: pointer;
}
+2 -7
View File
@@ -1,8 +1,7 @@
import type { Metadata } from "next";
import { Roboto, Open_Sans } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/components/AuthProvider";
import { AppLayout } from "@/components/AppLayout";
const roboto = Roboto({
weight: ["300", "400", "500", "700", "900"],
@@ -37,11 +36,7 @@ export default function RootLayout({
suppressHydrationWarning
className="min-h-full flex flex-col bg-[#F4F6FB] dark:bg-[#0B0F19] font-sans antialiased"
>
<AuthProvider>
<AppLayout>
{children}
</AppLayout>
</AuthProvider>
{children}
</body>
</html>
);
-222
View File
@@ -1,222 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { checkUser, updateProfile } from '@/app/actions'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Loader2, UserCircle, Save, CheckCircle, Upload, ShieldCheck, Sparkles, Image as ImageIcon } from 'lucide-react'
export default function ProfilePage() {
const [userId, setUserId] = useState<string | null>(null)
const [name, setName] = useState('')
const [photo, setPhoto] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
useEffect(() => {
const id = localStorage.getItem('user_id')
if (id) {
setUserId(id)
loadProfile(id)
}
}, [])
const loadProfile = async (id: string) => {
setLoading(true)
const user = await checkUser(id)
if (user) {
setName(user.name)
setPhoto(user.photo || '')
}
setLoading(false)
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError('Nama wajib diisi')
return
}
setError('')
setSuccess(false)
setSaving(true)
const res = await updateProfile(userId!, name.trim(), photo.trim() || undefined)
if (res.success) {
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
} else {
setError(res.error || 'Terjadi kesalahan')
}
setSaving(false)
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
<Loader2 className="w-8 h-8 animate-spin text-[#1B2CC1]" />
<span className="text-xs text-slate-500 font-semibold">Memuat profil...</span>
</div>
)
}
return (
<div className="space-y-6 animate-in fade-in duration-500 pb-12 max-w-5xl">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left 2 Cols: Main Profile Form */}
<div className="lg:col-span-2 space-y-6">
<Card className="rounded-3xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm overflow-hidden">
<div className="p-6 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between">
<div>
<h2 className="text-lg font-black text-slate-900 dark:text-white">
Informasi Akun
</h2>
<p className="text-xs text-slate-500 mt-0.5">
Ubah nama tampilan dan foto avatar yang terlihat oleh teman-teman.
</p>
</div>
<span className="text-[11px] font-bold text-[#1B2CC1] bg-[#1B2CC1]/10 px-2.5 py-1 rounded-full">
Stateless Auth
</span>
</div>
<form onSubmit={handleSave}>
<CardContent className="p-6 space-y-6">
{/* Photo Preview & Dashed Container */}
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
Foto Profil
</Label>
<div className="flex flex-col sm:flex-row items-center gap-5 p-5 rounded-2xl border-2 border-dashed border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
{photo ? (
<img
src={photo}
alt={name}
className="w-20 h-20 rounded-2xl object-cover ring-4 ring-white dark:ring-slate-700 shadow-md shrink-0"
/>
) : (
<div className="w-20 h-20 rounded-2xl bg-[#1B2CC1]/10 text-[#1B2CC1] flex items-center justify-center font-bold text-2xl ring-4 ring-white dark:ring-slate-700 shadow-sm shrink-0">
{name ? name.charAt(0).toUpperCase() : <UserCircle className="w-10 h-10" />}
</div>
)}
<div className="space-y-1.5 flex-1 w-full text-center sm:text-left">
<p className="text-xs font-bold text-slate-800 dark:text-slate-200">
Pratinjau Avatar
</p>
<p className="text-[11px] text-slate-400">
Masukkan tautan URL foto gambar di bawah untuk memperbarui gambar profil.
</p>
</div>
</div>
</div>
{/* Form Inputs */}
<div className="space-y-1.5">
<Label htmlFor="prof-name" className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
Nama Layar (Wajib Unik) <span className="text-red-500">*</span>
</Label>
<Input
id="prof-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Contoh: Budi Santoso"
className="h-11 rounded-xl border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1] font-semibold"
/>
<p className="text-[11px] text-slate-400">Nama ini akan tercantum di setiap PO yang Anda buat atau ikuti.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="prof-photo" className="text-xs font-bold uppercase tracking-wider text-slate-700 dark:text-slate-300">
URL Foto Profil
</Label>
<Input
id="prof-photo"
value={photo}
onChange={(e) => setPhoto(e.target.value)}
placeholder="https://images.unsplash.com/photo-..."
className="h-11 rounded-xl border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1] text-xs font-mono"
/>
</div>
{error && (
<div className="p-3.5 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold">
{error}
</div>
)}
{success && (
<div className="p-3.5 bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-900 rounded-xl text-xs text-emerald-700 dark:text-emerald-400 font-bold flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-600" />
<span>Profil Anda berhasil diperbarui!</span>
</div>
)}
</CardContent>
{/* Card Footer with Dedicated Container & Padding */}
<div className="p-6 bg-slate-50/60 dark:bg-slate-800/40 border-t border-slate-100 dark:border-slate-800 flex justify-end items-center">
<Button
type="submit"
disabled={saving}
className="w-full sm:w-auto h-11 px-6 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/20 gap-2 cursor-pointer"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
<span>{saving ? 'Menyimpan...' : 'Simpan Perubahan'}</span>
</Button>
</div>
</form>
</Card>
</div>
{/* Right 1 Col: Device & ID Details */}
<div className="space-y-6">
<Card className="rounded-3xl border border-slate-200/90 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-6 space-y-4">
<div className="flex items-center gap-2 text-slate-800 dark:text-slate-200 font-bold text-sm">
<ShieldCheck className="w-4 h-4 text-[#1B2CC1]" />
<span>Identitas Stateless</span>
</div>
<p className="text-xs text-slate-500 leading-relaxed">
Akun Anda tersimpan secara lokal pada peramban ini menggunakan Device ID yang unik di bawah ini.
</p>
<div className="space-y-1.5 pt-2 border-t border-slate-100 dark:border-slate-800">
<div className="flex items-center justify-between">
<span className="text-[10px] uppercase font-bold text-slate-400 block">Unique User UUID</span>
<button
type="button"
onClick={() => {
if (userId) {
navigator.clipboard.writeText(userId)
}
}}
className="text-[10px] font-bold text-[#1B2CC1] hover:underline"
>
Salin ID
</button>
</div>
<div className="p-3 rounded-xl bg-slate-50 dark:bg-slate-800/60 border border-slate-200/80 dark:border-slate-700 text-xs font-mono text-slate-700 dark:text-slate-300 break-all select-all font-semibold">
{userId}
</div>
</div>
<div className="p-4 rounded-2xl bg-blue-50/60 dark:bg-blue-950/30 border border-blue-100 dark:border-blue-900/40 text-xs text-[#1B2CC1] dark:text-blue-300 space-y-1">
<div className="flex items-center gap-1.5 font-bold">
<Sparkles className="w-3.5 h-3.5" />
<span>Tips Penggunaan</span>
</div>
<p className="text-[11px] opacity-90 leading-tight">
Jangan bersihkan data peramban (localStorage) jika Anda ingin tetap login dengan ID yang sama.
</p>
</div>
</Card>
</div>
</div>
</div>
)
}