feat: implement app directory routing, authentication flow, and dynamic reporting module
This commit is contained in:
@@ -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,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'
|
||||
@@ -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)}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkUser, registerUser } from '@/app/actions'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, Sparkles, User, Image as ImageIcon } from 'lucide-react'
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isVerified, setIsVerified] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [userId, setUserId] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [photo, setPhoto] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function initAuth() {
|
||||
let id = localStorage.getItem('user_id')
|
||||
if (!id) {
|
||||
id = uuidv4()
|
||||
localStorage.setItem('user_id', id)
|
||||
}
|
||||
setUserId(id)
|
||||
|
||||
const user = await checkUser(id)
|
||||
if (user) {
|
||||
setIsVerified(true)
|
||||
} else {
|
||||
setShowModal(true)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
initAuth()
|
||||
}, [])
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
setError('Nama wajib diisi')
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
setSaving(true)
|
||||
const res = await registerUser(userId!, name.trim(), photo.trim() || undefined)
|
||||
|
||||
if (res.success) {
|
||||
setShowModal(false)
|
||||
setIsVerified(true)
|
||||
} else {
|
||||
setError(res.error || 'Terjadi kesalahan')
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open && !isVerified) {
|
||||
setShowModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-[#F4F6FB] dark:bg-[#0B0F19] gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-[#1B2CC1] flex items-center justify-center text-white shadow-xl shadow-[#1B2CC1]/30 animate-pulse">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-slate-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[#1B2CC1]" />
|
||||
<span>Memuat sesi Anda...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVerified && children}
|
||||
<Dialog open={showModal} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[440px] p-0 overflow-hidden rounded-3xl border-slate-200/90 dark:border-slate-800 shadow-2xl [&>button]:hidden">
|
||||
<div className="bg-gradient-to-br from-[#1B2CC1] to-[#121E85] p-6 text-white text-center relative overflow-hidden">
|
||||
<div className="absolute -right-8 -bottom-8 w-32 h-32 bg-white/10 rounded-full blur-2xl pointer-events-none" />
|
||||
<div className="w-14 h-14 rounded-2xl bg-white/15 backdrop-blur-md flex items-center justify-center mx-auto mb-3 text-white border border-white/20 shadow-inner">
|
||||
<Sparkles className="w-7 h-7" />
|
||||
</div>
|
||||
<DialogTitle className="text-2xl font-black tracking-tight text-white">
|
||||
Selamat Datang di TitipIn
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-blue-100/90 text-xs mt-1.5 max-w-xs mx-auto">
|
||||
Daftarkan nama Anda untuk mulai membuka atau menitip pesanan bersama teman.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRegister} className="p-6 space-y-4 bg-white dark:bg-slate-900">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reg-name" className="text-xs font-bold text-slate-700 dark:text-slate-300">
|
||||
Nama Tampilan <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="reg-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Contoh: Budi Santoso"
|
||||
className="rounded-xl h-11 border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1]"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">Nama harus unik agar pembuat PO mudah mengenali Anda.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="reg-photo" className="text-xs font-bold text-slate-700 dark:text-slate-300">
|
||||
URL Foto Profil (Opsional)
|
||||
</Label>
|
||||
<Input
|
||||
id="reg-photo"
|
||||
value={photo}
|
||||
onChange={(e) => setPhoto(e.target.value)}
|
||||
placeholder="https://images.unsplash.com/..."
|
||||
className="rounded-xl h-11 border-slate-200 dark:border-slate-800 focus-visible:ring-4 focus-visible:ring-[#1B2CC1]/15 focus-visible:border-[#1B2CC1]"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/60 rounded-xl text-xs text-red-600 dark:text-red-400 font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full h-11 rounded-xl bg-[#1B2CC1] hover:bg-[#15229E] text-white font-bold shadow-md shadow-[#1B2CC1]/25 transition-all mt-2"
|
||||
>
|
||||
{saving ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Menyimpan Profil...</span>
|
||||
</div>
|
||||
) : (
|
||||
'Mulai Gunakan TitipIn'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -54,6 +54,13 @@ export function Header({ onMenuClick }: { onMenuClick: () => void }) {
|
||||
badge: 'Laporan',
|
||||
Icon: BarChart2
|
||||
}
|
||||
case '/users':
|
||||
return {
|
||||
title: 'Manajemen Pengguna',
|
||||
subtitle: 'Kelola data pengguna, role, dan status aktif.',
|
||||
badge: 'Superadmin',
|
||||
Icon: User
|
||||
}
|
||||
default:
|
||||
if (pathname.startsWith('/my-orders/')) {
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { submitOrder, getUserSubmission } from '@/app/actions'
|
||||
import { submitOrder, getUserSubmission, getSessionUser } from '@/app/actions'
|
||||
import { PlusCircle, MinusCircle, User, CheckCircle2, ShoppingBag, Sparkles, Users } from 'lucide-react'
|
||||
|
||||
import { format } from 'date-fns'
|
||||
@@ -113,11 +113,14 @@ function OrderFormModal({ order, onSuccess }: { order: any, onSuccess: () => voi
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const id = localStorage.getItem('user_id')
|
||||
if (id) {
|
||||
setUserId(id)
|
||||
loadExistingSubmission(id)
|
||||
const init = async () => {
|
||||
const user = await getSessionUser()
|
||||
if (user?.id) {
|
||||
setUserId(user.id)
|
||||
loadExistingSubmission(user.id)
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [order.id])
|
||||
|
||||
const loadExistingSubmission = async (uid: string) => {
|
||||
|
||||
+76
-10
@@ -4,6 +4,12 @@ import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Store,
|
||||
ClipboardList,
|
||||
@@ -15,7 +21,6 @@ import {
|
||||
Info,
|
||||
BarChart2
|
||||
} from 'lucide-react'
|
||||
import { checkUser } from '@/app/actions'
|
||||
|
||||
const menuItems = [
|
||||
{ name: 'Open Order', href: '/', icon: Store, desc: 'Daftar PO live hari ini' },
|
||||
@@ -27,22 +32,38 @@ const menuItems = [
|
||||
export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () => void }) {
|
||||
const pathname = usePathname()
|
||||
const [userName, setUserName] = useState<string>('Pengguna')
|
||||
const [userNameAccount, setUserNameAccount] = useState<string>('Pengguna')
|
||||
const [userPhoto, setUserPhoto] = useState<string | null>(null)
|
||||
const [userRole, setUserRole] = useState<string | null>(null)
|
||||
const [userId, setUserId] = useState<string | null>(null)
|
||||
const [isLogoutOpen, setIsLogoutOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const id = localStorage.getItem('user_id')
|
||||
if (id) {
|
||||
setUserId(id)
|
||||
checkUser(id).then((u) => {
|
||||
import('@/app/actions').then(({ getSessionUser }) => {
|
||||
getSessionUser().then((u) => {
|
||||
if (u) {
|
||||
setUserId(u.id)
|
||||
setUserName(u.name)
|
||||
setUserNameAccount(u.username)
|
||||
setUserRole(u.role)
|
||||
if (u.photo) setUserPhoto(u.photo)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [pathname])
|
||||
|
||||
const handleLogout = async () => {
|
||||
const { logoutUser } = await import('@/app/actions')
|
||||
await logoutUser()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
// Dynamic Menu Items based on role
|
||||
const finalMenuItems = [...menuItems]
|
||||
if (userRole === 'superadmin') {
|
||||
finalMenuItems.push({ name: 'User Management', href: '/users', icon: UserCircle, desc: 'Kelola pengguna' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop */}
|
||||
@@ -106,9 +127,16 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
|
||||
</span>
|
||||
<ArrowUpRight className="w-3.5 h-3.5 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-slate-400 truncate">
|
||||
{userId ? `ID: ${userId.slice(0, 10)}...` : 'Aktif'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{userRole === 'superadmin' && (
|
||||
<span className="px-1.5 py-[2px] rounded-md bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 text-[8px] font-black uppercase tracking-wider">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium text-slate-500 dark:text-slate-400 truncate">
|
||||
{userNameAccount ? `@${userNameAccount}` : 'Aktif'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -118,7 +146,7 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
|
||||
Navigasi Utama
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
{finalMenuItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
@@ -153,6 +181,44 @@ export function Sidebar({ isOpen, onClose }: { isOpen?: boolean; onClose?: () =>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Logout Button */}
|
||||
{userId && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsLogoutOpen(true)}
|
||||
className="w-full flex items-center gap-3 px-3.5 py-3 rounded-2xl text-sm font-semibold transition-all duration-200 group relative text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 cursor-pointer"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-xl flex items-center justify-center transition-colors bg-rose-100 dark:bg-rose-900/40 text-rose-500 group-hover:bg-rose-200 dark:group-hover:bg-rose-800/60">
|
||||
<X className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 text-left">
|
||||
<span className="leading-tight">Logout</span>
|
||||
<span className="text-[10px] font-normal truncate mt-0.5 text-rose-400/80">Keluar dari akun</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Dialog open={isLogoutOpen} onOpenChange={setIsLogoutOpen}>
|
||||
<DialogContent className="sm:max-w-xs rounded-3xl p-0 overflow-hidden border-0">
|
||||
<div className="px-6 pt-6 pb-2 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-rose-100 text-rose-600 flex items-center justify-center mx-auto mb-3">
|
||||
<X className="w-6 h-6" />
|
||||
</div>
|
||||
<DialogTitle className="text-xl font-black text-slate-800">Konfirmasi Logout</DialogTitle>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
Apakah Anda yakin ingin keluar dari akun ini?
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 flex gap-3 bg-slate-50">
|
||||
<Button type="button" variant="outline" onClick={() => setIsLogoutOpen(false)} className="flex-1 h-11 rounded-xl">Batal</Button>
|
||||
<Button type="button" onClick={handleLogout} className="flex-1 h-11 rounded-xl bg-rose-600 hover:bg-rose-700 text-white font-bold cursor-pointer">
|
||||
Ya, Logout
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"cursor-pointer group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { SignJWT, jwtVerify } from 'jose'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
const secretKey = process.env.JWT_SECRET
|
||||
const key = new TextEncoder().encode(secretKey)
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
return await bcrypt.hash(password, 10)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string) {
|
||||
return await bcrypt.compare(password, hash)
|
||||
}
|
||||
|
||||
export async function encrypt(payload: any) {
|
||||
return await new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.sign(key) // No expiration as requested
|
||||
}
|
||||
|
||||
export async function decrypt(input: string): Promise<any> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(input, key, {
|
||||
algorithms: ['HS256'],
|
||||
})
|
||||
return payload
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSession() {
|
||||
const cookieStore = await cookies()
|
||||
const session = cookieStore.get('session')?.value
|
||||
if (!session) return null
|
||||
return await decrypt(session)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { decrypt } from '@/lib/auth'
|
||||
|
||||
const protectedRoutes = ['/', '/my-orders', '/my-purchases', '/reports', '/profile', '/users']
|
||||
const publicRoutes = ['/login', '/register']
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname
|
||||
|
||||
// Also protect dynamic routes like /my-orders/[id]
|
||||
const isProtectedRoute = protectedRoutes.includes(path) || path.startsWith('/my-orders/')
|
||||
const isPublicRoute = publicRoutes.includes(path)
|
||||
|
||||
const cookie = request.cookies.get('session')?.value
|
||||
const session = cookie ? await decrypt(cookie) : null
|
||||
|
||||
if (isProtectedRoute && !session) {
|
||||
return NextResponse.redirect(new URL('/login', request.nextUrl))
|
||||
}
|
||||
|
||||
if (isPublicRoute && session) {
|
||||
return NextResponse.redirect(new URL('/', request.nextUrl))
|
||||
}
|
||||
|
||||
if (path.startsWith('/users') && session?.role !== 'superadmin') {
|
||||
return NextResponse.redirect(new URL('/', request.nextUrl))
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next/static|_next/image|icon.tsx|icon).*)'],
|
||||
}
|
||||
Reference in New Issue
Block a user