fix first online resevation
This commit is contained in:
@@ -1,9 +1,21 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||
const apiUrl = new URL(apiOrigin)
|
||||
|
||||
const nextConfig = {
|
||||
images: {
|
||||
domains: ['res.cloudinary.com'],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'res.cloudinary.com',
|
||||
},
|
||||
{
|
||||
protocol: apiUrl.protocol.replace(':', ''),
|
||||
hostname: apiUrl.hostname,
|
||||
...(apiUrl.port ? { port: apiUrl.port } : {}),
|
||||
pathname: '/storage/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
transpilePackages: ['@rentaldrivego/types'],
|
||||
async rewrites() {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function AuthRedirectPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
||||
const token = params.get('token')
|
||||
const next = params.get('next') || '/dashboard'
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem('admin_token', token)
|
||||
// Clear the token from the URL
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
|
||||
router.replace(next)
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-950">
|
||||
<p className="text-sm text-zinc-400">Redirecting to admin dashboard…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
slug: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: string
|
||||
paymentProvider: string
|
||||
amanpayTransactionId?: string | null
|
||||
paypalCaptureId?: string | null
|
||||
paidAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function buildInvoiceNumber(invoiceId: string, createdAt: string) {
|
||||
const year = new Date(createdAt).getFullYear()
|
||||
const short = invoiceId.slice(-6).toUpperCase()
|
||||
return `INV-${year}-${short}`
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: string
|
||||
companyId: string
|
||||
company: Company
|
||||
plan: string
|
||||
billingPeriod: string
|
||||
status: string
|
||||
currency: string
|
||||
currentPeriodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
invoices: Invoice[]
|
||||
_count: { invoices: number }
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
mrr: number
|
||||
activeCount: number
|
||||
trialingCount: number
|
||||
pastDueCount: number
|
||||
cancelledCount: number
|
||||
}
|
||||
|
||||
const SUB_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||
PAST_DUE: 'text-amber-400 bg-amber-900/30',
|
||||
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||
UNPAID: 'text-red-400 bg-red-900/30',
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_COLORS: Record<string, string> = {
|
||||
PAID: 'text-emerald-400 bg-emerald-900/30',
|
||||
PENDING: 'text-amber-400 bg-amber-900/30',
|
||||
FAILED: 'text-red-400 bg-red-900/30',
|
||||
REFUNDED: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
const PLAN_COLORS: Record<string, string> = {
|
||||
STARTER: 'text-zinc-300',
|
||||
GROWTH: 'text-sky-400',
|
||||
PRO: 'text-violet-400',
|
||||
}
|
||||
|
||||
function fmt(amount: number, currency: string) {
|
||||
return `${(amount / 100).toFixed(2)} ${currency}`
|
||||
}
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
export default function AdminBillingPage() {
|
||||
const { language } = useAdminI18n()
|
||||
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Billing',
|
||||
eyebrow: 'Platform',
|
||||
mrr: 'MRR (MAD)',
|
||||
active: 'Active',
|
||||
trialing: 'Trialing',
|
||||
pastDue: 'Past Due',
|
||||
cancelled: 'Cancelled',
|
||||
company: 'Company',
|
||||
plan: 'Plan',
|
||||
period: 'Period',
|
||||
status: 'Status',
|
||||
currency: 'Currency',
|
||||
nextRenewal: 'Next Renewal',
|
||||
invoices: 'Invoices',
|
||||
actions: 'Actions',
|
||||
viewInvoices: 'Invoices',
|
||||
loading: 'Loading…',
|
||||
empty: 'No subscriptions found',
|
||||
filterAll: 'All',
|
||||
filterActive: 'Active',
|
||||
filterTrialing: 'Trialing',
|
||||
filterPastDue: 'Past Due',
|
||||
filterCancelled: 'Cancelled',
|
||||
closeModal: 'Close',
|
||||
invoicesFor: 'Invoices for',
|
||||
noInvoices: 'No invoices found',
|
||||
amount: 'Amount',
|
||||
invoiceStatus: 'Status',
|
||||
provider: 'Provider',
|
||||
paidAt: 'Paid At',
|
||||
date: 'Date',
|
||||
invoiceNo: 'Invoice #',
|
||||
download: 'PDF',
|
||||
cancelAtEnd: 'Cancels at period end',
|
||||
},
|
||||
fr: {
|
||||
title: 'Facturation',
|
||||
eyebrow: 'Plateforme',
|
||||
mrr: 'MRR (MAD)',
|
||||
active: 'Actifs',
|
||||
trialing: 'Essai',
|
||||
pastDue: 'En retard',
|
||||
cancelled: 'Annulés',
|
||||
company: 'Entreprise',
|
||||
plan: 'Plan',
|
||||
period: 'Période',
|
||||
status: 'Statut',
|
||||
currency: 'Devise',
|
||||
nextRenewal: 'Prochain renouvellement',
|
||||
invoices: 'Factures',
|
||||
actions: 'Actions',
|
||||
viewInvoices: 'Factures',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucun abonnement trouvé',
|
||||
filterAll: 'Tous',
|
||||
filterActive: 'Actifs',
|
||||
filterTrialing: 'Essai',
|
||||
filterPastDue: 'En retard',
|
||||
filterCancelled: 'Annulés',
|
||||
closeModal: 'Fermer',
|
||||
invoicesFor: 'Factures pour',
|
||||
noInvoices: 'Aucune facture trouvée',
|
||||
amount: 'Montant',
|
||||
invoiceStatus: 'Statut',
|
||||
provider: 'Fournisseur',
|
||||
paidAt: 'Payé le',
|
||||
date: 'Date',
|
||||
invoiceNo: 'N° Facture',
|
||||
download: 'PDF',
|
||||
cancelAtEnd: 'Annulé en fin de période',
|
||||
},
|
||||
ar: {
|
||||
title: 'الفوترة',
|
||||
eyebrow: 'المنصة',
|
||||
mrr: 'الإيراد الشهري (MAD)',
|
||||
active: 'نشط',
|
||||
trialing: 'تجريبي',
|
||||
pastDue: 'متأخر',
|
||||
cancelled: 'ملغى',
|
||||
company: 'الشركة',
|
||||
plan: 'الخطة',
|
||||
period: 'الفترة',
|
||||
status: 'الحالة',
|
||||
currency: 'العملة',
|
||||
nextRenewal: 'التجديد القادم',
|
||||
invoices: 'الفواتير',
|
||||
actions: 'الإجراءات',
|
||||
viewInvoices: 'الفواتير',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لا توجد اشتراكات',
|
||||
filterAll: 'الكل',
|
||||
filterActive: 'نشط',
|
||||
filterTrialing: 'تجريبي',
|
||||
filterPastDue: 'متأخر',
|
||||
filterCancelled: 'ملغى',
|
||||
closeModal: 'إغلاق',
|
||||
invoicesFor: 'فواتير',
|
||||
noInvoices: 'لا توجد فواتير',
|
||||
amount: 'المبلغ',
|
||||
invoiceStatus: 'الحالة',
|
||||
provider: 'المزود',
|
||||
paidAt: 'تاريخ الدفع',
|
||||
date: 'التاريخ',
|
||||
invoiceNo: 'رقم الفاتورة',
|
||||
download: 'PDF',
|
||||
cancelAtEnd: 'يُلغى في نهاية الفترة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
|
||||
const [invoiceModal, setInvoiceModal] = useState<{ companyId: string; companyName: string } | null>(null)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [invoicesLoading, setInvoicesLoading] = useState(false)
|
||||
const [invoicesError, setInvoicesError] = useState<string | null>(null)
|
||||
|
||||
async function fetchBilling(status?: string) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const params = new URLSearchParams({ pageSize: '50' })
|
||||
if (status) params.set('status', status)
|
||||
const res = await fetch(`${API_BASE}/admin/billing?${params}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
||||
setSubscriptions(json.data ?? [])
|
||||
setStats(json.stats ?? null)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchBilling(statusFilter || undefined) }, [statusFilter])
|
||||
|
||||
async function downloadInvoicePdf(invoiceId: string, createdAt: string) {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/billing/invoices/${invoiceId}/pdf`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to generate PDF')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${buildInvoiceNumber(invoiceId, createdAt)}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
// silent — no toast system available here
|
||||
}
|
||||
}
|
||||
|
||||
async function openInvoices(companyId: string, companyName: string) {
|
||||
setInvoiceModal({ companyId, companyName })
|
||||
setInvoices([])
|
||||
setInvoicesError(null)
|
||||
setInvoicesLoading(true)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/billing/${companyId}/invoices?pageSize=50`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? `Error ${res.status}`)
|
||||
setInvoices(json.data ?? [])
|
||||
} catch (err: any) {
|
||||
setInvoicesError(err.message)
|
||||
} finally {
|
||||
setInvoicesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: copy.filterAll },
|
||||
{ key: 'ACTIVE', label: copy.filterActive },
|
||||
{ key: 'TRIALING', label: copy.filterTrialing },
|
||||
{ key: 'PAST_DUE', label: copy.filterPastDue },
|
||||
{ key: 'CANCELLED', label: copy.filterCancelled },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{copy.eyebrow}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
|
||||
<div className="col-span-2 sm:col-span-1 panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.mrr}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-emerald-400">{(stats.mrr / 100).toFixed(0)}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.active}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-zinc-100">{stats.activeCount}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.trialing}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-sky-400">{stats.trialingCount}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.pastDue}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-amber-400">{stats.pastDueCount}</p>
|
||||
</div>
|
||||
<div className="panel p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.cancelled}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-zinc-400">{stats.cancelledCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setStatusFilter(f.key)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
statusFilter === f.key
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.company}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.plan}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.period}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.status}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.currency}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.nextRenewal}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.invoices}</th>
|
||||
<th className="px-6 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={8} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td></tr>
|
||||
) : subscriptions.length === 0 ? (
|
||||
<tr><td colSpan={8} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td></tr>
|
||||
) : subscriptions.map((sub) => (
|
||||
<tr key={sub.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-zinc-100">{sub.company.name}</p>
|
||||
<p className="text-xs text-zinc-500">{sub.company.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`font-semibold ${PLAN_COLORS[sub.plan] ?? 'text-zinc-300'}`}>{sub.plan}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{sub.billingPeriod}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium w-fit ${SUB_STATUS_COLORS[sub.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{sub.status}
|
||||
</span>
|
||||
{sub.cancelAtPeriodEnd && (
|
||||
<span className="text-xs text-amber-500">{copy.cancelAtEnd}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{sub.currency}</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{fmtDate(sub.currentPeriodEnd)}</td>
|
||||
<td className="px-6 py-4 text-zinc-400 text-xs">{sub._count.invoices}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => openInvoices(sub.company.id, sub.company.name)}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-xs font-medium text-zinc-200 transition-colors"
|
||||
>
|
||||
{copy.viewInvoices}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{invoiceModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-zinc-900 border border-zinc-800 shadow-2xl flex flex-col max-h-[80vh]">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.invoicesFor}</p>
|
||||
<p className="font-semibold text-zinc-100">{invoiceModal.companyName}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setInvoiceModal(null); setInvoicesError(null) }}
|
||||
className="p-2 rounded-lg text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{invoicesLoading ? (
|
||||
<div className="px-6 py-12 text-center text-zinc-500">{copy.loading}</div>
|
||||
) : invoicesError ? (
|
||||
<div className="px-6 py-12 text-center text-red-400 text-sm">{invoicesError}</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-zinc-500">{copy.noInvoices}</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-zinc-900">
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.invoiceNo}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.date}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.amount}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.invoiceStatus}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.provider}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.paidAt}</th>
|
||||
<th className="px-6 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-3 text-zinc-300 text-xs font-mono">{buildInvoiceNumber(inv.id, inv.createdAt)}</td>
|
||||
<td className="px-6 py-3 text-zinc-400 text-xs">{fmtDate(inv.createdAt)}</td>
|
||||
<td className="px-6 py-3 font-medium text-zinc-100">{fmt(inv.amount, inv.currency)}</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS_COLORS[inv.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{inv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-zinc-500 text-xs">{inv.paymentProvider}</td>
|
||||
<td className="px-6 py-3 text-zinc-400 text-xs">{fmtDate(inv.paidAt)}</td>
|
||||
<td className="px-6 py-3">
|
||||
<button
|
||||
onClick={() => downloadInvoicePdf(inv.id, inv.createdAt)}
|
||||
title="Download PDF"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-xs font-medium text-zinc-300 transition-colors"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
{copy.download}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex justify-end">
|
||||
<button
|
||||
onClick={() => { setInvoiceModal(null); setInvoicesError(null) }}
|
||||
className="px-4 py-2 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-sm font-medium text-zinc-200 transition-colors"
|
||||
>
|
||||
{copy.closeModal}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
|
||||
|
||||
interface CompanyContainer {
|
||||
id: string
|
||||
companyId: string
|
||||
dockerId: string | null
|
||||
containerName: string
|
||||
status: ContainerStatus
|
||||
port: number
|
||||
image: string
|
||||
errorMessage: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
company: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : ''
|
||||
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ContainerStatus }) {
|
||||
const map: Record<ContainerStatus, { label: string; className: string }> = {
|
||||
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
|
||||
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
|
||||
RUNNING: { label: 'Running', className: 'bg-emerald-900 text-emerald-300' },
|
||||
STOPPED: { label: 'Stopped', className: 'bg-yellow-900 text-yellow-300' },
|
||||
RESTARTING: { label: 'Restarting…',className: 'bg-orange-900 text-orange-300 animate-pulse' },
|
||||
REMOVING: { label: 'Removing…', className: 'bg-red-900 text-red-300 animate-pulse' },
|
||||
ERROR: { label: 'Error', className: 'bg-red-950 text-red-400' },
|
||||
}
|
||||
const { label, className } = map[status] ?? map.ERROR
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${status === 'RUNNING' ? 'bg-emerald-400' : 'bg-current opacity-60'}`} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LogsModal({ companyId, companyName, onClose }: { companyId: string; companyName: string; onClose: () => void }) {
|
||||
const [logs, setLogs] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tail, setTail] = useState(150)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const fetchLogs = useCallback(async (lines: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders() })
|
||||
const json = await res.json()
|
||||
setLogs(json.data?.logs ?? '')
|
||||
} catch {
|
||||
setLogs('Failed to fetch logs.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [companyId])
|
||||
|
||||
useEffect(() => { fetchLogs(tail) }, [fetchLogs, tail])
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-100">Container Logs</p>
|
||||
<p className="text-xs text-zinc-400">{companyName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={tail}
|
||||
onChange={(e) => setTail(Number(e.target.value))}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-200 focus:outline-none"
|
||||
>
|
||||
<option value={50}>Last 50 lines</option>
|
||||
<option value={150}>Last 150 lines</option>
|
||||
<option value={500}>Last 500 lines</option>
|
||||
<option value={1000}>Last 1000 lines</option>
|
||||
</select>
|
||||
<button onClick={() => fetchLogs(tail)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
||||
Refresh
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<p className="text-xs text-zinc-500">Loading…</p>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-relaxed text-zinc-300">
|
||||
{logs || 'No logs available.'}
|
||||
</pre>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ProvisionResult = { companyId: string; name: string; status: 'created' | 'error'; error?: string }
|
||||
|
||||
export default function ContainersPage() {
|
||||
const [containers, setContainers] = useState<CompanyContainer[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
const [logsFor, setLogsFor] = useState<{ companyId: string; companyName: string } | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [provisioning, setProvisioning] = useState(false)
|
||||
const [provisionResults, setProvisionResults] = useState<ProvisionResult[] | null>(null)
|
||||
|
||||
const fetchContainers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers`, { headers: authHeaders() })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? 'Failed to load containers.')
|
||||
}
|
||||
setContainers(json.data ?? [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load containers.')
|
||||
/* silent — keep old data */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchContainers()
|
||||
const id = setInterval(fetchContainers, 8000)
|
||||
return () => clearInterval(id)
|
||||
}, [fetchContainers])
|
||||
|
||||
async function provisionAll() {
|
||||
setProvisioning(true)
|
||||
setProvisionResults(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders() })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
|
||||
setProvisionResults(json.data?.results ?? [])
|
||||
setError(null)
|
||||
await fetchContainers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Provisioning failed.')
|
||||
} finally {
|
||||
setProvisioning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function act(companyId: string, action: 'start' | 'stop' | 'restart' | 'deploy' | 'remove') {
|
||||
setBusy((b) => ({ ...b, [companyId]: true }))
|
||||
try {
|
||||
const method = action === 'remove' ? 'DELETE' : 'POST'
|
||||
const url =
|
||||
action === 'remove'
|
||||
? `${API_BASE}/admin/containers/${companyId}`
|
||||
: `${API_BASE}/admin/containers/${companyId}/${action}`
|
||||
const res = await fetch(url, { method, headers: authHeaders() })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? `Failed to ${action} container.`)
|
||||
}
|
||||
setError(null)
|
||||
await fetchContainers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to ${action} container.`)
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [companyId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = containers.filter(
|
||||
(c) =>
|
||||
c.company.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.containerName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.company.slug.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
|
||||
const stats = {
|
||||
running: containers.filter((c) => c.status === 'RUNNING').length,
|
||||
stopped: containers.filter((c) => c.status === 'STOPPED').length,
|
||||
error: containers.filter((c) => c.status === 'ERROR').length,
|
||||
total: containers.length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full p-8">
|
||||
{logsFor && (
|
||||
<LogsModal
|
||||
companyId={logsFor.companyId}
|
||||
companyName={logsFor.companyName}
|
||||
onClose={() => setLogsFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Containers</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">Manage isolated Docker Compose services for each company workspace.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={provisionAll}
|
||||
disabled={provisioning}
|
||||
className="flex items-center gap-2 rounded-xl bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{provisioning ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
Provisioning…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
Provision All Accounts
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-xl border border-red-900 bg-red-950/70 px-4 py-3 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provisionResults !== null && (
|
||||
<div className="mb-6 rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-zinc-200">
|
||||
Provisioning complete —{' '}
|
||||
<span className="text-emerald-400">{provisionResults.filter((r) => r.status === 'created').length} created</span>
|
||||
{provisionResults.some((r) => r.status === 'error') && (
|
||||
<>, <span className="text-red-400">{provisionResults.filter((r) => r.status === 'error').length} failed</span></>
|
||||
)}
|
||||
</p>
|
||||
<button onClick={() => setProvisionResults(null)} className="text-xs text-zinc-500 hover:text-zinc-300">Dismiss</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{provisionResults.map((r) => (
|
||||
<div key={r.companyId} className="flex items-center gap-3 rounded-lg px-3 py-2 bg-zinc-800/60">
|
||||
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${r.status === 'created' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
||||
<span className="text-sm text-zinc-300 flex-1">{r.name}</span>
|
||||
{r.status === 'error' && <span className="text-xs text-red-400 truncate max-w-xs">{r.error}</span>}
|
||||
{r.status === 'created' && <span className="text-xs text-emerald-500">Service created</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mb-6 grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'text-zinc-100' },
|
||||
{ label: 'Running', value: stats.running, color: 'text-emerald-400' },
|
||||
{ label: 'Stopped', value: stats.stopped, color: 'text-yellow-400' },
|
||||
{ label: 'Error', value: stats.error, color: 'text-red-400' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-xl border border-zinc-800 bg-zinc-900 p-4">
|
||||
<p className="text-xs text-zinc-500">{s.label}</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by company name or container…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full max-w-sm rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-zinc-500">
|
||||
{search ? 'No containers match your search.' : 'No containers yet. They are created automatically on company signup.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-left text-xs text-zinc-500">
|
||||
<th className="px-5 py-3 font-medium">Company</th>
|
||||
<th className="px-5 py-3 font-medium">Container</th>
|
||||
<th className="px-5 py-3 font-medium">Status</th>
|
||||
<th className="px-5 py-3 font-medium">Port</th>
|
||||
<th className="px-5 py-3 font-medium">Image</th>
|
||||
<th className="px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{filtered.map((c) => {
|
||||
const isBusy = busy[c.companyId] ?? false
|
||||
const isRunning = c.status === 'RUNNING'
|
||||
const isStopped = c.status === 'STOPPED' || c.status === 'ERROR'
|
||||
const isTransitioning = ['CREATING', 'RESTARTING', 'REMOVING'].includes(c.status)
|
||||
|
||||
return (
|
||||
<tr key={c.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-5 py-4">
|
||||
<p className="font-medium text-zinc-100">{c.company.name}</p>
|
||||
<p className="text-xs text-zinc-500">{c.company.slug}</p>
|
||||
{c.errorMessage && (
|
||||
<p className="mt-1 text-xs text-red-400" title={c.errorMessage}>
|
||||
{c.errorMessage.slice(0, 60)}{c.errorMessage.length > 60 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-400">
|
||||
{c.containerName}
|
||||
{c.dockerId && (
|
||||
<p className="mt-0.5 text-zinc-600">{c.dockerId.slice(0, 12)}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<StatusBadge status={c.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-400">:{c.port}</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-500">{c.image}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStopped && (
|
||||
<ActionButton
|
||||
label="Start"
|
||||
color="emerald"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'start')}
|
||||
/>
|
||||
)}
|
||||
{isRunning && (
|
||||
<ActionButton
|
||||
label="Stop"
|
||||
color="yellow"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'stop')}
|
||||
/>
|
||||
)}
|
||||
{(isRunning || isStopped) && (
|
||||
<ActionButton
|
||||
label="Restart"
|
||||
color="blue"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'restart')}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
label="Redeploy"
|
||||
color="purple"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'deploy')}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Logs"
|
||||
color="zinc"
|
||||
disabled={isBusy || !c.dockerId}
|
||||
onClick={() => setLogsFor({ companyId: c.companyId, companyName: c.company.name })}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Remove"
|
||||
color="red"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => {
|
||||
if (confirm(`Remove container for ${c.company.name}? This cannot be undone.`)) {
|
||||
act(c.companyId, 'remove')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
color,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
color: 'emerald' | 'yellow' | 'blue' | 'purple' | 'zinc' | 'red'
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
emerald: 'border-emerald-800 text-emerald-400 hover:bg-emerald-900/40',
|
||||
yellow: 'border-yellow-800 text-yellow-400 hover:bg-yellow-900/40',
|
||||
blue: 'border-blue-800 text-blue-400 hover:bg-blue-900/40',
|
||||
purple: 'border-purple-800 text-purple-400 hover:bg-purple-900/40',
|
||||
zinc: 'border-zinc-700 text-zinc-400 hover:bg-zinc-700/40',
|
||||
red: 'border-red-900 text-red-400 hover:bg-red-900/30',
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${colorMap[color]}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider'
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
|
||||
const DASHBOARD_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
|
||||
function buildUnifiedLoginUrl(nextPath: string) {
|
||||
const params = new URLSearchParams({
|
||||
portal: 'admin',
|
||||
next: nextPath || '/dashboard',
|
||||
})
|
||||
return `${DASHBOARD_URL}/sign-in?${params.toString()}`
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/dashboard', key: 'overview', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||
@@ -12,26 +26,26 @@ const navLinks = [
|
||||
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
|
||||
{ href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
|
||||
]
|
||||
|
||||
export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { dict } = useAdminI18n()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
router.replace('/login')
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
} else {
|
||||
setReady(true)
|
||||
}
|
||||
}, [router])
|
||||
}, [pathname])
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('admin_token')
|
||||
router.push('/login')
|
||||
window.location.href = buildUnifiedLoginUrl('/dashboard')
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
@@ -43,8 +57,8 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<aside className="w-60 flex-shrink-0 flex flex-col border-r border-zinc-800 bg-zinc-900">
|
||||
<div className="flex h-screen bg-zinc-950 text-zinc-100 transition-colors">
|
||||
<aside className="w-60 flex-shrink-0 flex flex-col border-r border-zinc-800 bg-zinc-900 transition-colors">
|
||||
<Link href="/" className="block px-5 py-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-zinc-100">RentalDriveGo</p>
|
||||
@@ -57,9 +71,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-zinc-800 text-zinc-100'
|
||||
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
|
||||
active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -73,19 +85,20 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
|
||||
<div className="px-3 py-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-zinc-500 hover:text-red-400 hover:bg-zinc-800/50 transition-colors"
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-zinc-500 transition-colors hover:bg-zinc-800/50 hover:text-red-400"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{dict.logout}
|
||||
</button>
|
||||
<div className="mt-4 flex justify-center border-t border-zinc-800 pt-4">
|
||||
<div className="mt-4 flex flex-col items-center gap-3 border-t border-zinc-800 pt-4">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto bg-zinc-950">{children}</main>
|
||||
<main className="flex-1 overflow-y-auto bg-zinc-950 transition-colors">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import PublicShell from '@/components/PublicShell'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
export default function AdminForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
setSent(true)
|
||||
} catch {
|
||||
setError('Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex flex-1 items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight">Forgot password</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">Enter your admin email to receive a reset link.</p>
|
||||
</div>
|
||||
|
||||
<div className="panel p-8">
|
||||
{sent ? (
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900/40">
|
||||
<svg className="h-6 w-6 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300">Check your email</p>
|
||||
<p className="text-xs text-zinc-500">If that email is registered, a reset link has been sent. It expires in 60 minutes.</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@rentaldrivego.com"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Sending…' : 'Send reset link'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link href="/login" className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,16 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html.light {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-zinc-950 text-zinc-50 antialiased;
|
||||
@apply bg-zinc-950 text-zinc-50 antialiased transition-colors;
|
||||
}
|
||||
|
||||
.shell {
|
||||
@@ -13,3 +21,55 @@ body {
|
||||
.panel {
|
||||
@apply rounded-2xl border border-zinc-800 bg-zinc-900 shadow-sm;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.light body {
|
||||
background-color: rgb(248 250 252);
|
||||
color: rgb(15 23 42);
|
||||
}
|
||||
|
||||
.light .bg-zinc-950 {
|
||||
background-color: rgb(248 250 252);
|
||||
}
|
||||
|
||||
.light .bg-zinc-950\/90 {
|
||||
background-color: rgb(255 255 255 / 0.9);
|
||||
}
|
||||
|
||||
.light .bg-zinc-900 {
|
||||
background-color: rgb(255 255 255);
|
||||
}
|
||||
|
||||
.light .bg-zinc-800,
|
||||
.light .bg-zinc-800\/50 {
|
||||
background-color: rgb(241 245 249);
|
||||
}
|
||||
|
||||
.light .border-zinc-800,
|
||||
.light .border-zinc-700 {
|
||||
border-color: rgb(226 232 240);
|
||||
}
|
||||
|
||||
.light .text-zinc-100,
|
||||
.light .text-zinc-200 {
|
||||
color: rgb(15 23 42);
|
||||
}
|
||||
|
||||
.light .text-zinc-500 {
|
||||
color: rgb(100 116 139);
|
||||
}
|
||||
|
||||
.light .text-zinc-400,
|
||||
.light .text-zinc-300 {
|
||||
color: rgb(71 85 105);
|
||||
}
|
||||
|
||||
.light .hover\:bg-zinc-800:hover,
|
||||
.light .hover\:bg-zinc-800\/50:hover {
|
||||
background-color: rgb(241 245 249);
|
||||
}
|
||||
|
||||
.light .hover\:text-zinc-200:hover {
|
||||
color: rgb(15 23 42);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,16 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body><AdminI18nProvider>{children}</AdminI18nProvider></body>
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var theme=localStorage.getItem('admin-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning><AdminI18nProvider>{children}</AdminI18nProvider></body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,212 +1,7 @@
|
||||
'use client'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/PublicShell'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
const DASHBOARD_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const { language } = useAdminI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
loginFailed: 'Login failed',
|
||||
verifyFailed: '2FA verification failed',
|
||||
brand: 'Admin console',
|
||||
access: 'Platform operations access',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
signIn: 'Sign in',
|
||||
signingIn: 'Signing in…',
|
||||
enterCode: 'Enter the 6-digit code from your authenticator app',
|
||||
authCode: 'Authentication code',
|
||||
verify: 'Verify',
|
||||
verifying: 'Verifying…',
|
||||
back: 'Back to login',
|
||||
},
|
||||
fr: {
|
||||
loginFailed: 'Échec de connexion',
|
||||
verifyFailed: 'Échec de la vérification 2FA',
|
||||
brand: 'Console admin',
|
||||
access: 'Accès opérations plateforme',
|
||||
email: 'Email',
|
||||
password: 'Mot de passe',
|
||||
signIn: 'Connexion',
|
||||
signingIn: 'Connexion…',
|
||||
enterCode: 'Entrez le code à 6 chiffres de votre application d’authentification',
|
||||
authCode: 'Code d’authentification',
|
||||
verify: 'Vérifier',
|
||||
verifying: 'Vérification…',
|
||||
back: 'Retour à la connexion',
|
||||
},
|
||||
ar: {
|
||||
loginFailed: 'فشل تسجيل الدخول',
|
||||
verifyFailed: 'فشل التحقق الثنائي',
|
||||
brand: 'لوحة الإدارة',
|
||||
access: 'وصول عمليات المنصة',
|
||||
email: 'البريد الإلكتروني',
|
||||
password: 'كلمة المرور',
|
||||
signIn: 'تسجيل الدخول',
|
||||
signingIn: 'جارٍ تسجيل الدخول…',
|
||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة',
|
||||
authCode: 'رمز المصادقة',
|
||||
verify: 'تحقق',
|
||||
verifying: 'جارٍ التحقق…',
|
||||
back: 'العودة إلى تسجيل الدخول',
|
||||
},
|
||||
}[language]
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totp, setTotp] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleCredentials(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (res.status === 401 && json?.error === 'totp_required') {
|
||||
setStep('totp')
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(json?.message ?? dict.loginFailed)
|
||||
if (json.data?.token) {
|
||||
localStorage.setItem('admin_token', json.data.token)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotp(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, totpCode: totp }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? dict.verifyFailed)
|
||||
if (json.data?.token) {
|
||||
localStorage.setItem('admin_token', json.data.token)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex flex-1 items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight">{dict.brand}</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">{dict.access}</p>
|
||||
</div>
|
||||
|
||||
<div className="panel p-8">
|
||||
{error && (
|
||||
<div className="mb-6 p-3 rounded-xl border border-red-900/50 bg-red-950/50 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'credentials' ? (
|
||||
<form onSubmit={handleCredentials} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
placeholder="admin@rentaldrivego.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.password}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? dict.signingIn : dict.signIn}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleTotp} className="space-y-5">
|
||||
<div className="text-center mb-2">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900/40 mb-3">
|
||||
<svg className="h-6 w-6 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300">{dict.enterCode}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.authCode}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
required
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-center text-2xl tracking-[0.5em] placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? dict.verifying : dict.verify}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('credentials'); setTotp(''); setError(null) }}
|
||||
className="w-full text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{dict.back}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
redirect(`${DASHBOARD_URL}/sign-in?portal=admin&next=/dashboard`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import PublicShell from '@/components/PublicShell'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
export default function AdminResetPasswordPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AdminResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminResetPasswordContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (password.length < 8) { setError('Password must be at least 8 characters.'); return }
|
||||
if (password !== confirm) { setError('Passwords do not match.'); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
if (json?.error === 'invalid_token') throw new Error('This reset link is invalid or has expired.')
|
||||
throw new Error('Something went wrong. Please try again.')
|
||||
}
|
||||
setDone(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Something went wrong.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<AdminResetShell>
|
||||
<p className="text-sm text-zinc-400 text-center">No reset token found.</p>
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/forgot-password" className="text-sm text-emerald-400 hover:text-emerald-300">
|
||||
Request a new reset link
|
||||
</Link>
|
||||
</div>
|
||||
</AdminResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<AdminResetShell>
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900/40">
|
||||
<svg className="h-6 w-6 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300 font-semibold">Password updated</p>
|
||||
<p className="text-xs text-zinc-500">You can now sign in with your new password.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/login')}
|
||||
className="mt-2 w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</AdminResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminResetShell>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-900/50 bg-red-950/50 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Resetting…' : 'Reset password'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link href="/login" className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</AdminResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminResetShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex flex-1 items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight">Reset password</h1>
|
||||
</div>
|
||||
<div className="panel p-8">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -3,11 +3,15 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export type AdminLanguage = 'en' | 'fr' | 'ar'
|
||||
export type AdminTheme = 'light' | 'dark'
|
||||
|
||||
type AdminDictionary = {
|
||||
nav: Record<string, string>
|
||||
logout: string
|
||||
language: string
|
||||
theme: string
|
||||
light: string
|
||||
dark: string
|
||||
overview: string
|
||||
admin: string
|
||||
platform: string
|
||||
@@ -23,9 +27,13 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
renters: 'Renters',
|
||||
auditLogs: 'Audit Logs',
|
||||
adminUsers: 'Admin Users',
|
||||
billing: 'Billing',
|
||||
},
|
||||
logout: 'Logout',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
overview: 'Platform overview',
|
||||
admin: 'Admin',
|
||||
platform: 'Platform',
|
||||
@@ -33,15 +41,19 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
},
|
||||
fr: {
|
||||
nav: {
|
||||
overview: 'Vue d’ensemble',
|
||||
overview: "Vue d'ensemble",
|
||||
companies: 'Entreprises',
|
||||
siteConfig: 'Config site',
|
||||
renters: 'Locataires',
|
||||
auditLogs: 'Journaux d’audit',
|
||||
auditLogs: "Journaux d'audit",
|
||||
adminUsers: 'Utilisateurs admin',
|
||||
billing: 'Facturation',
|
||||
},
|
||||
logout: 'Déconnexion',
|
||||
language: 'Langue',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
overview: 'Vue plateforme',
|
||||
admin: 'Admin',
|
||||
platform: 'Plateforme',
|
||||
@@ -55,9 +67,13 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
renters: 'المستأجرون',
|
||||
auditLogs: 'سجلات التدقيق',
|
||||
adminUsers: 'مستخدمو الإدارة',
|
||||
billing: 'الفوترة',
|
||||
},
|
||||
logout: 'تسجيل الخروج',
|
||||
language: 'اللغة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
dark: 'داكن',
|
||||
overview: 'نظرة عامة على المنصة',
|
||||
admin: 'الإدارة',
|
||||
platform: 'المنصة',
|
||||
@@ -68,6 +84,8 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
type AdminI18nContext = {
|
||||
language: AdminLanguage
|
||||
setLanguage: (value: AdminLanguage) => void
|
||||
theme: AdminTheme
|
||||
setTheme: (value: AdminTheme) => void
|
||||
dict: AdminDictionary
|
||||
}
|
||||
|
||||
@@ -75,12 +93,23 @@ const Context = createContext<AdminI18nContext | null>(null)
|
||||
|
||||
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<AdminLanguage>('en')
|
||||
const [theme, setTheme] = useState<AdminTheme>('dark')
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('admin-language')
|
||||
const storedTheme = window.localStorage.getItem('admin-theme')
|
||||
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||
setLanguage(stored)
|
||||
}
|
||||
|
||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||
setTheme(storedTheme)
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('light')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +118,18 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
window.localStorage.setItem('admin-language', language)
|
||||
}, [language])
|
||||
|
||||
const value = useMemo(() => ({ language, setLanguage, dict: dictionaries[language] }), [language])
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(theme)
|
||||
document.documentElement.style.colorScheme = theme
|
||||
document.body.dataset.theme = theme
|
||||
window.localStorage.setItem('admin-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }),
|
||||
[language, theme],
|
||||
)
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
@@ -110,7 +150,7 @@ export function AdminLanguageSwitcher() {
|
||||
if (embedded) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm">
|
||||
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm transition-colors">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">{dict.language}</span>
|
||||
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
|
||||
const active = value === language
|
||||
@@ -130,3 +170,37 @@ export function AdminLanguageSwitcher() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminThemeSwitcher() {
|
||||
const { theme, setTheme, dict } = useAdminI18n()
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm transition-colors">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
{dict.theme}
|
||||
</span>
|
||||
{(['light', 'dark'] as AdminTheme[]).map((value) => {
|
||||
const active = value === theme
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{value === 'light' ? dict.light : dict.dark}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider'
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
|
||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useAdminI18n()
|
||||
@@ -24,8 +28,8 @@ export default function PublicShell({ children }: { children: React.ReactNode })
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<header className="sticky top-0 z-30 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur-md">
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 transition-colors">
|
||||
<header className="sticky top-0 z-30 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur-md transition-colors">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-400">RentalDriveGo</span>
|
||||
@@ -39,13 +43,14 @@ export default function PublicShell({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors">
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-zinc-800 dark:bg-zinc-950/90">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-zinc-500">
|
||||
{dict.preferences}
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user