From 09b0e3b55f4c9661581485880397c98c935b97ae Mon Sep 17 00:00:00 2001 From: root Date: Sat, 9 May 2026 20:01:51 -0400 Subject: [PATCH] fix first online resevation --- .env.docker.dev | 14 + DOCKER.md | 12 + apps/admin/next.config.js | 14 +- apps/admin/src/app/auth-redirect/page.tsx | 29 + apps/admin/src/app/dashboard/billing/page.tsx | 472 +++++++ .../src/app/dashboard/containers/page.tsx | 437 +++++++ apps/admin/src/app/dashboard/layout.tsx | 41 +- apps/admin/src/app/forgot-password/page.tsx | 91 ++ apps/admin/src/app/globals.css | 62 +- apps/admin/src/app/layout.tsx | 12 +- apps/admin/src/app/login/page.tsx | 211 +--- apps/admin/src/app/reset-password/page.tsx | 152 +++ apps/admin/src/components/I18nProvider.tsx | 82 +- apps/admin/src/components/PublicShell.tsx | 15 +- apps/api/package.json | 7 +- apps/api/src/index.ts | 13 +- apps/api/src/lib/cloudinary.ts | 31 - apps/api/src/lib/storage.ts | 35 + apps/api/src/lib/storage/.gitkeep | 0 .../1959e058bf32f7f672cbea6b7683f88b.jpg | Bin 0 -> 427537 bytes .../d9b8c7164544f5d971a1ea09dba1ba38.jpg | Bin 0 -> 170635 bytes .../eae81e21ca0017a9d22eb92b64ff0c42.jpg | Bin 0 -> 170635 bytes apps/api/src/middleware/requireCompanyAuth.ts | 38 +- apps/api/src/routes/admin.ts | 188 +++ apps/api/src/routes/auth.company.ts | 410 +------ apps/api/src/routes/auth.employee.ts | 139 +++ apps/api/src/routes/companies.ts | 6 +- apps/api/src/routes/marketplace.ts | 136 ++- apps/api/src/routes/vehicles.ts | 6 +- apps/api/src/routes/webhooks.ts | 38 +- apps/api/src/services/containerService.ts | 467 +++++++ apps/api/src/services/invoicePdfService.ts | 497 ++++++++ apps/api/src/services/notificationService.ts | 34 + apps/api/src/services/teamService.ts | 127 +- apps/dashboard/next.config.js | 32 +- apps/dashboard/package.json | 14 +- .../(dashboard)/dashboard/customers/page.tsx | 5 +- .../(dashboard)/dashboard/fleet/[id]/page.tsx | 460 ++++++- .../app/(dashboard)/dashboard/fleet/page.tsx | 260 +++- .../dashboard/online-reservations/page.tsx | 325 +++++ .../(dashboard)/dashboard/reports/page.tsx | 4 +- .../dashboard/reservations/page.tsx | 2 +- .../app/(dashboard)/dashboard/team/page.tsx | 25 +- apps/dashboard/src/app/(dashboard)/layout.tsx | 9 +- .../src/app/forgot-password/page.tsx | 129 ++ apps/dashboard/src/app/globals.css | 62 +- apps/dashboard/src/app/layout.tsx | 16 +- .../dashboard/src/app/reset-password/page.tsx | 202 +++ .../src/app/sign-in/[[...sign-in]]/page.tsx | 369 ++++-- .../src/app/sign-up/[[...sign-up]]/page.tsx | 430 +------ .../dashboard/src/components/I18nProvider.tsx | 96 +- .../src/components/layout/PublicShell.tsx | 23 +- .../src/components/layout/Sidebar.tsx | 124 +- .../src/components/layout/TopBar.tsx | 153 +-- .../src/components/team/EditMemberModal.tsx | 2 +- apps/dashboard/src/lib/api.ts | 18 +- apps/dashboard/src/lib/clerk.ts | 5 - apps/dashboard/src/middleware.ts | 33 +- .../src/app/explore/ExploreVehicleGrid.tsx | 337 +++++ apps/marketplace/src/app/explore/page.tsx | 134 +- apps/marketplace/src/lib/api.ts | 12 + apps/public-site/src/app/globals.css | 83 ++ apps/public-site/src/app/layout.tsx | 32 +- .../src/components/PublicThemeSwitcher.tsx | 67 + docker-compose.pgmanage.yml | 18 + memory/MEMORY.md | 3 + memory/project_auth_architecture.md | 27 + package-lock.json | 1085 ++++++++--------- package.json | 1 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 7 + .../prisma/migrations/migration_lock.toml | 1 + packages/database/prisma/schema.prisma | 47 +- scripts/setup-clerk-keys.sh | 112 ++ 75 files changed, 6394 insertions(+), 2190 deletions(-) create mode 100644 apps/admin/src/app/auth-redirect/page.tsx create mode 100644 apps/admin/src/app/dashboard/billing/page.tsx create mode 100644 apps/admin/src/app/dashboard/containers/page.tsx create mode 100644 apps/admin/src/app/forgot-password/page.tsx create mode 100644 apps/admin/src/app/reset-password/page.tsx delete mode 100644 apps/api/src/lib/cloudinary.ts create mode 100644 apps/api/src/lib/storage.ts create mode 100644 apps/api/src/lib/storage/.gitkeep create mode 100644 apps/api/src/lib/storage/companies/cmoyu36ii000112g8j5m84zgv/vehicles/1959e058bf32f7f672cbea6b7683f88b.jpg create mode 100644 apps/api/src/lib/storage/companies/cmoyu36ii000112g8j5m84zgv/vehicles/d9b8c7164544f5d971a1ea09dba1ba38.jpg create mode 100644 apps/api/src/lib/storage/companies/cmoyu36ii000112g8j5m84zgv/vehicles/eae81e21ca0017a9d22eb92b64ff0c42.jpg create mode 100644 apps/api/src/routes/auth.employee.ts create mode 100644 apps/api/src/services/containerService.ts create mode 100644 apps/api/src/services/invoicePdfService.ts create mode 100644 apps/dashboard/src/app/(dashboard)/dashboard/online-reservations/page.tsx create mode 100644 apps/dashboard/src/app/forgot-password/page.tsx create mode 100644 apps/dashboard/src/app/reset-password/page.tsx delete mode 100644 apps/dashboard/src/lib/clerk.ts create mode 100644 apps/marketplace/src/app/explore/ExploreVehicleGrid.tsx create mode 100644 apps/public-site/src/components/PublicThemeSwitcher.tsx create mode 100644 docker-compose.pgmanage.yml create mode 100644 memory/MEMORY.md create mode 100644 memory/project_auth_architecture.md create mode 100644 packages/database/prisma/migrations/20260507000000_add_company_containers/migration.sql create mode 100644 packages/database/prisma/migrations/20260508000000_add_employee_password_hash/migration.sql create mode 100644 packages/database/prisma/migrations/20260508000001_add_password_reset_tokens/migration.sql create mode 100644 packages/database/prisma/migrations/migration_lock.toml create mode 100755 scripts/setup-clerk-keys.sh diff --git a/.env.docker.dev b/.env.docker.dev index a730102..6885798 100644 --- a/.env.docker.dev +++ b/.env.docker.dev @@ -21,3 +21,17 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= CLERK_SECRET_KEY= NODE_ENV=development CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003 + +# Email - disable Resend (placeholder), use SMTP instead +RESEND_API_KEY=re_... +EMAIL_FROM=moulay.elabidi@gmail.com +EMAIL_FROM_NAME=RentalDriveGo +MAIL_HOST=smtp.gmail.com +MAIL_PORT=587 +MAIL_SCHEME=smtp +MAIL_USERNAME=moulay.elabidi@gmail.com +MAIL_PASSWORD=seld rlvl bzuy npar +MAIL_FROM_ADDRESS=moulay.elabidi@gmail.com +MAIL_FROM_NAME=RentalDriveGo +MAIL_REPLY_TO_ADDRESS=moulay.elabidi@gmail.com +MAIL_REPLY_TO_NAME=RentalDriveGo diff --git a/DOCKER.md b/DOCKER.md index aead9ca..3ae3e41 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -5,6 +5,7 @@ Three Docker environments are available: - `Dockerfile.dev` with `docker-compose.dev.yml` - `Dockerfile.test` with `docker-compose.test.yml` - `Dockerfile.production` with `docker-compose.production.yml` +- `docker-compose.pgmanage.yml` for a standalone pgManage container ### Development @@ -75,6 +76,17 @@ pgAdmin Postgres connection: - username: `postgres` - password: `password` +### Standalone pgManage + +If you want a standalone Postgres management UI without starting the full development stack: + +```bash +docker compose -f docker-compose.pgmanage.yml up -d +``` + +It publishes `http://localhost:8000` with a standard Docker port mapping and persists its data in the named Docker volume `pgmanage_data`. +From inside the container, connect to the local Postgres service through `host.docker.internal:5432`. + ### Test Use the test stack to run repeatable containerized verification: diff --git a/apps/admin/next.config.js b/apps/admin/next.config.js index ff15bca..46bba73 100644 --- a/apps/admin/next.config.js +++ b/apps/admin/next.config.js @@ -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() { diff --git a/apps/admin/src/app/auth-redirect/page.tsx b/apps/admin/src/app/auth-redirect/page.tsx new file mode 100644 index 0000000..43fb837 --- /dev/null +++ b/apps/admin/src/app/auth-redirect/page.tsx @@ -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 ( +
+

Redirecting to admin dashboard…

+
+ ) +} diff --git a/apps/admin/src/app/dashboard/billing/page.tsx b/apps/admin/src/app/dashboard/billing/page.tsx new file mode 100644 index 0000000..1191ff6 --- /dev/null +++ b/apps/admin/src/app/dashboard/billing/page.tsx @@ -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 = { + 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 = { + 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 = { + 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([]) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [statusFilter, setStatusFilter] = useState('') + + const [invoiceModal, setInvoiceModal] = useState<{ companyId: string; companyName: string } | null>(null) + const [invoices, setInvoices] = useState([]) + const [invoicesLoading, setInvoicesLoading] = useState(false) + const [invoicesError, setInvoicesError] = useState(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 ( +
+
+

{copy.eyebrow}

+

{copy.title}

+
+ + {stats && ( +
+
+

{copy.mrr}

+

{(stats.mrr / 100).toFixed(0)}

+
+
+

{copy.active}

+

{stats.activeCount}

+
+
+

{copy.trialing}

+

{stats.trialingCount}

+
+
+

{copy.pastDue}

+

{stats.pastDueCount}

+
+
+

{copy.cancelled}

+

{stats.cancelledCount}

+
+
+ )} + +
+ {filters.map((f) => ( + + ))} +
+ + {error &&
{error}
} + +
+
+ + + + + + + + + + + + + + {loading ? ( + + ) : subscriptions.length === 0 ? ( + + ) : subscriptions.map((sub) => ( + + + + + + + + + + + ))} + +
{copy.company}{copy.plan}{copy.period}{copy.status}{copy.currency}{copy.nextRenewal}{copy.invoices} +
{copy.loading}
{copy.empty}
+

{sub.company.name}

+

{sub.company.email}

+
+ {sub.plan} + {sub.billingPeriod} +
+ + {sub.status} + + {sub.cancelAtPeriodEnd && ( + {copy.cancelAtEnd} + )} +
+
{sub.currency}{fmtDate(sub.currentPeriodEnd)}{sub._count.invoices} + +
+
+
+ + {invoiceModal && ( +
+
+
+
+

{copy.invoicesFor}

+

{invoiceModal.companyName}

+
+ +
+
+ {invoicesLoading ? ( +
{copy.loading}
+ ) : invoicesError ? ( +
{invoicesError}
+ ) : invoices.length === 0 ? ( +
{copy.noInvoices}
+ ) : ( + + + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + + + ))} + +
{copy.invoiceNo}{copy.date}{copy.amount}{copy.invoiceStatus}{copy.provider}{copy.paidAt} +
{buildInvoiceNumber(inv.id, inv.createdAt)}{fmtDate(inv.createdAt)}{fmt(inv.amount, inv.currency)} + + {inv.status} + + {inv.paymentProvider}{fmtDate(inv.paidAt)} + +
+ )} +
+
+ +
+
+
+ )} +
+ ) +} diff --git a/apps/admin/src/app/dashboard/containers/page.tsx b/apps/admin/src/app/dashboard/containers/page.tsx new file mode 100644 index 0000000..ac77cec --- /dev/null +++ b/apps/admin/src/app/dashboard/containers/page.tsx @@ -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 = { + 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 ( + + + {label} + + ) +} + +function LogsModal({ companyId, companyName, onClose }: { companyId: string; companyName: string; onClose: () => void }) { + const [logs, setLogs] = useState('') + const [loading, setLoading] = useState(true) + const [tail, setTail] = useState(150) + const bottomRef = useRef(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 ( +
+
e.stopPropagation()}> +
+
+

Container Logs

+

{companyName}

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

Loading…

+ ) : ( +
+              {logs || 'No logs available.'}
+            
+ )} +
+
+
+
+ ) +} + +type ProvisionResult = { companyId: string; name: string; status: 'created' | 'error'; error?: string } + +export default function ContainersPage() { + const [containers, setContainers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [busy, setBusy] = useState>({}) + const [logsFor, setLogsFor] = useState<{ companyId: string; companyName: string } | null>(null) + const [search, setSearch] = useState('') + const [provisioning, setProvisioning] = useState(false) + const [provisionResults, setProvisionResults] = useState(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 ( +
+ {logsFor && ( + setLogsFor(null)} + /> + )} + +
+
+

Containers

+

Manage isolated Docker Compose services for each company workspace.

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {provisionResults !== null && ( +
+
+

+ Provisioning complete —{' '} + {provisionResults.filter((r) => r.status === 'created').length} created + {provisionResults.some((r) => r.status === 'error') && ( + <>, {provisionResults.filter((r) => r.status === 'error').length} failed + )} +

+ +
+
+ {provisionResults.map((r) => ( +
+ + {r.name} + {r.status === 'error' && {r.error}} + {r.status === 'created' && Service created} +
+ ))} +
+
+ )} + + {/* Stats */} +
+ {[ + { 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) => ( +
+

{s.label}

+

{s.value}

+
+ ))} +
+ + {/* Search */} +
+ 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" + /> +
+ + {/* Table */} +
+ {loading ? ( +
+
+
+ ) : filtered.length === 0 ? ( +
+ {search ? 'No containers match your search.' : 'No containers yet. They are created automatically on company signup.'} +
+ ) : ( + + + + + + + + + + + + + {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 ( + + + + + + + + + ) + })} + +
CompanyContainerStatusPortImageActions
+

{c.company.name}

+

{c.company.slug}

+ {c.errorMessage && ( +

+ {c.errorMessage.slice(0, 60)}{c.errorMessage.length > 60 ? '…' : ''} +

+ )} +
+ {c.containerName} + {c.dockerId && ( +

{c.dockerId.slice(0, 12)}

+ )} +
+ + :{c.port}{c.image} +
+ {isStopped && ( + act(c.companyId, 'start')} + /> + )} + {isRunning && ( + act(c.companyId, 'stop')} + /> + )} + {(isRunning || isStopped) && ( + act(c.companyId, 'restart')} + /> + )} + act(c.companyId, 'deploy')} + /> + setLogsFor({ companyId: c.companyId, companyName: c.company.name })} + /> + { + if (confirm(`Remove container for ${c.company.name}? This cannot be undone.`)) { + act(c.companyId, 'remove') + } + }} + /> +
+
+ )} +
+
+ ) +} + +function ActionButton({ + label, + color, + disabled, + onClick, +}: { + label: string + color: 'emerald' | 'yellow' | 'blue' | 'purple' | 'zinc' | 'red' + disabled: boolean + onClick: () => void +}) { + const colorMap: Record = { + 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 ( + + ) +} diff --git a/apps/admin/src/app/dashboard/layout.tsx b/apps/admin/src/app/dashboard/layout.tsx index 451657e..a14f6a9 100644 --- a/apps/admin/src/app/dashboard/layout.tsx +++ b/apps/admin/src/app/dashboard/layout.tsx @@ -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 ( -
-