feat: Complete rewrite as Next.js admin dashboard
Some checks failed
Build and Push Docker Image / test (push) Failing after 34s
Build and Push Docker Image / build (push) Has been skipped

Major transformation from FastAPI telemetry service to Next.js admin dashboard:

- Next.js 15 App Router with TypeScript
- Prisma ORM with PostgreSQL (same schema, new client)
- TanStack Query for data fetching
- Tailwind CSS + shadcn/ui components
- Admin dashboard with:
  - Dashboard stats overview
  - Customer management (list, detail, edit)
  - Order management (list, create, detail, logs)
  - Server monitoring (grid view)
  - Subscription management

Pages implemented:
- /admin (dashboard)
- /admin/customers (list + [id] detail)
- /admin/orders (list + [id] detail with SSE logs)
- /admin/servers (grid view)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-06 12:35:01 +01:00
parent 02fc18f009
commit a79b79efd2
85 changed files with 19070 additions and 1869 deletions

View File

@@ -0,0 +1,133 @@
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { signIn } from 'next-auth/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get('callbackUrl') || '/'
const error = searchParams.get('error')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [userType, setUserType] = useState<'customer' | 'staff'>('staff')
const [isLoading, setIsLoading] = useState(false)
const [loginError, setLoginError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setLoginError(null)
try {
const result = await signIn('credentials', {
email,
password,
userType,
redirect: false,
callbackUrl,
})
if (result?.error) {
setLoginError(result.error)
} else if (result?.ok) {
router.push(userType === 'staff' ? '/admin' : '/')
router.refresh()
}
} catch {
setLoginError('An unexpected error occurred')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold text-center">
LetsBe Hub
</CardTitle>
<CardDescription className="text-center">
Sign in to your account
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{(error || loginError) && (
<div className="p-3 text-sm text-red-500 bg-red-50 rounded-md">
{error === 'CredentialsSignin'
? 'Invalid email or password'
: loginError || error}
</div>
)}
<div className="flex gap-2">
<Button
type="button"
variant={userType === 'staff' ? 'default' : 'outline'}
className="flex-1"
onClick={() => setUserType('staff')}
>
Staff Login
</Button>
<Button
type="button"
variant={userType === 'customer' ? 'default' : 'outline'}
className="flex-1"
onClick={() => setUserType('customer')}
>
Customer Login
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,541 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useCustomer } from '@/hooks/use-customers'
import { useQueryClient } from '@tanstack/react-query'
import { customerKeys } from '@/hooks/use-customers'
import {
ArrowLeft,
User,
Mail,
Building2,
Calendar,
Server,
Loader2,
AlertCircle,
RefreshCw,
Edit,
Ban,
CheckCircle,
ExternalLink,
CreditCard,
Activity,
Package,
X,
Save,
} from 'lucide-react'
type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION'
type SubscriptionStatus = 'TRIAL' | 'ACTIVE' | 'CANCELED' | 'PAST_DUE'
type OrderStatus = 'PAYMENT_CONFIRMED' | 'AWAITING_SERVER' | 'SERVER_READY' | 'DNS_PENDING' | 'DNS_READY' | 'PROVISIONING' | 'FULFILLED' | 'EMAIL_CONFIGURED' | 'FAILED'
// Status badge components
function UserStatusBadge({ status }: { status: UserStatus }) {
const statusConfig: Record<UserStatus, { label: string; className: string }> = {
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
SUSPENDED: { label: 'Suspended', className: 'bg-red-100 text-red-800' },
PENDING_VERIFICATION: { label: 'Pending', className: 'bg-yellow-100 text-yellow-800' },
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${config.className}`}>
{config.label}
</span>
)
}
function SubscriptionBadge({ status }: { status: SubscriptionStatus }) {
const statusConfig: Record<SubscriptionStatus, { label: string; className: string }> = {
TRIAL: { label: 'Trial', className: 'bg-blue-100 text-blue-800' },
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
CANCELED: { label: 'Canceled', className: 'bg-gray-100 text-gray-800' },
PAST_DUE: { label: 'Past Due', className: 'bg-red-100 text-red-800' },
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
{config.label}
</span>
)
}
function OrderStatusBadge({ status }: { status: OrderStatus }) {
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
PAYMENT_CONFIRMED: { label: 'Payment Confirmed', className: 'bg-blue-100 text-blue-800' },
AWAITING_SERVER: { label: 'Awaiting Server', className: 'bg-yellow-100 text-yellow-800' },
SERVER_READY: { label: 'Server Ready', className: 'bg-cyan-100 text-cyan-800' },
DNS_PENDING: { label: 'DNS Pending', className: 'bg-orange-100 text-orange-800' },
DNS_READY: { label: 'DNS Ready', className: 'bg-teal-100 text-teal-800' },
PROVISIONING: { label: 'Provisioning', className: 'bg-purple-100 text-purple-800' },
FULFILLED: { label: 'Fulfilled', className: 'bg-green-100 text-green-800' },
EMAIL_CONFIGURED: { label: 'Email Configured', className: 'bg-emerald-100 text-emerald-800' },
FAILED: { label: 'Failed', className: 'bg-red-100 text-red-800' },
}
const config = statusConfig[status] || { label: status, className: 'bg-gray-100 text-gray-800' }
return (
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
{config.label}
</span>
)
}
export default function CustomerDetailPage() {
const params = useParams()
const router = useRouter()
const queryClient = useQueryClient()
const customerId = params.id as string
const [isEditing, setIsEditing] = useState(false)
const [editForm, setEditForm] = useState({ name: '', company: '' })
const [isUpdating, setIsUpdating] = useState(false)
const { data: customer, isLoading, isError, error, refetch, isFetching } = useCustomer(customerId)
const handleEdit = () => {
if (customer) {
setEditForm({
name: customer.name || '',
company: customer.company || '',
})
setIsEditing(true)
}
}
const handleSave = async () => {
setIsUpdating(true)
try {
const response = await fetch(`/api/v1/admin/customers/${customerId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm),
})
if (!response.ok) {
throw new Error('Failed to update customer')
}
await queryClient.invalidateQueries({ queryKey: customerKeys.detail(customerId) })
setIsEditing(false)
} catch (err) {
console.error('Error updating customer:', err)
} finally {
setIsUpdating(false)
}
}
const handleStatusChange = async (newStatus: UserStatus) => {
setIsUpdating(true)
try {
const response = await fetch(`/api/v1/admin/customers/${customerId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (!response.ok) {
throw new Error('Failed to update status')
}
await queryClient.invalidateQueries({ queryKey: customerKeys.detail(customerId) })
} catch (err) {
console.error('Error updating status:', err)
} finally {
setIsUpdating(false)
}
}
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading customer details...</p>
</div>
</div>
)
}
// Error state
if (isError) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium text-destructive">Failed to load customer</p>
<p className="text-sm text-muted-foreground">
{error instanceof Error ? error.message : 'An error occurred'}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
</div>
</div>
)
}
if (!customer) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-8 w-8 text-muted-foreground" />
<p className="text-muted-foreground">Customer not found</p>
<Button variant="outline" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
</div>
</div>
)
}
const currentSubscription = customer.subscriptions?.[0]
const totalTokensUsed = customer.totalTokensUsed || 0
const tokenUsagePercent = currentSubscription
? Math.min((totalTokensUsed / currentSubscription.tokenLimit) * 100, 100)
: 0
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">{customer.name || customer.email}</h1>
<p className="text-muted-foreground">{customer.email}</p>
</div>
<UserStatusBadge status={customer.status as UserStatus} />
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
{customer.status === 'ACTIVE' ? (
<Button
variant="destructive"
size="sm"
onClick={() => handleStatusChange('SUSPENDED')}
disabled={isUpdating}
>
<Ban className="h-4 w-4 mr-2" />
Suspend
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={() => handleStatusChange('ACTIVE')}
disabled={isUpdating}
>
<CheckCircle className="h-4 w-4 mr-2" />
Activate
</Button>
)}
</div>
</div>
{/* Stats Row */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Package className="h-8 w-8 text-primary" />
<div>
<div className="text-2xl font-bold">{customer._count?.orders || 0}</div>
<p className="text-sm text-muted-foreground">Total Orders</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Server className="h-8 w-8 text-green-600" />
<div>
<div className="text-2xl font-bold">
{customer.orders?.filter((o: { status: string }) => o.status === 'FULFILLED').length || 0}
</div>
<p className="text-sm text-muted-foreground">Active Servers</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Activity className="h-8 w-8 text-blue-600" />
<div>
<div className="text-2xl font-bold">{totalTokensUsed.toLocaleString()}</div>
<p className="text-sm text-muted-foreground">Tokens Used</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<CreditCard className="h-8 w-8 text-purple-600" />
<div>
<div className="text-2xl font-bold capitalize">
{currentSubscription?.plan.toLowerCase() || 'None'}
</div>
<p className="text-sm text-muted-foreground">Current Plan</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Customer Profile Card */}
<Card className="lg:col-span-1">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Profile</CardTitle>
{!isEditing && (
<Button variant="ghost" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{isEditing ? (
<>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="company">Company</Label>
<Input
id="company"
value={editForm.company}
onChange={(e) => setEditForm({ ...editForm, company: e.target.value })}
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleSave} disabled={isUpdating}>
<Save className="h-4 w-4 mr-2" />
Save
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
</div>
</>
) : (
<>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<User className="h-6 w-6 text-primary" />
</div>
<div>
<p className="font-medium">{customer.name || 'No name'}</p>
<p className="text-sm text-muted-foreground">Name</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-50">
<Mail className="h-6 w-6 text-blue-600" />
</div>
<div>
<p className="font-medium">{customer.email}</p>
<p className="text-sm text-muted-foreground">Email</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-purple-50">
<Building2 className="h-6 w-6 text-purple-600" />
</div>
<div>
<p className="font-medium">{customer.company || 'Not set'}</p>
<p className="text-sm text-muted-foreground">Company</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-50">
<Calendar className="h-6 w-6 text-green-600" />
</div>
<div>
<p className="font-medium">
{new Date(customer.createdAt).toLocaleDateString()}
</p>
<p className="text-sm text-muted-foreground">Member Since</p>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* Subscription Card */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Subscription</CardTitle>
<CardDescription>Current plan and token usage</CardDescription>
</CardHeader>
<CardContent>
{currentSubscription ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="text-lg font-semibold capitalize">
{currentSubscription.plan.toLowerCase()} Plan
</p>
<p className="text-sm text-muted-foreground capitalize">
{currentSubscription.tier.replace('_', ' ').toLowerCase()} tier
</p>
</div>
<SubscriptionBadge status={currentSubscription.status as SubscriptionStatus} />
</div>
{currentSubscription.trialEndsAt && (
<div className="rounded-lg bg-blue-50 p-4">
<p className="text-sm font-medium text-blue-800">
Trial ends {new Date(currentSubscription.trialEndsAt).toLocaleDateString()}
</p>
</div>
)}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Token Usage</span>
<span className="font-medium">
{totalTokensUsed.toLocaleString()} / {currentSubscription.tokenLimit.toLocaleString()}
</span>
</div>
<div className="h-3 w-full overflow-hidden rounded-full bg-gray-200">
<div
className={`h-full transition-all ${
tokenUsagePercent > 90 ? 'bg-red-500' : tokenUsagePercent > 70 ? 'bg-yellow-500' : 'bg-primary'
}`}
style={{ width: `${tokenUsagePercent}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
{(100 - tokenUsagePercent).toFixed(1)}% remaining
</p>
</div>
</div>
) : (
<div className="text-center py-8">
<p className="text-muted-foreground">No active subscription</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Orders History */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Orders History</CardTitle>
<CardDescription>
{customer.orders?.length || 0} order{(customer.orders?.length || 0) !== 1 ? 's' : ''}
</CardDescription>
</div>
<Link href={`/admin/orders?customer=${customerId}`}>
<Button variant="outline" size="sm">
View All
<ExternalLink className="h-4 w-4 ml-2" />
</Button>
</Link>
</div>
</CardHeader>
<CardContent>
{customer.orders && customer.orders.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="px-4 py-3 font-medium">Domain</th>
<th className="px-4 py-3 font-medium">Tier</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Server IP</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{customer.orders.map((order: {
id: string
domain: string
tier: string
status: OrderStatus
serverIp: string | null
createdAt: Date | string
}) => (
<tr key={order.id} className="border-b hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{order.domain}</td>
<td className="px-4 py-3 capitalize">
{order.tier.replace('_', ' ').toLowerCase()}
</td>
<td className="px-4 py-3">
<OrderStatusBadge status={order.status} />
</td>
<td className="px-4 py-3 font-mono text-sm">
{order.serverIp || '-'}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(order.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<Link href={`/admin/orders/${order.id}`}>
<Button variant="ghost" size="sm">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8">
<p className="text-muted-foreground">No orders yet</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,444 @@
'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCustomers } from '@/hooks/use-customers'
import { UserStatus as ApiUserStatus } from '@/types/api'
import {
Search,
Plus,
MoreHorizontal,
User,
Mail,
Building2,
Calendar,
Server,
ExternalLink,
ChevronLeft,
ChevronRight,
Loader2,
AlertCircle,
RefreshCw,
} from 'lucide-react'
type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION'
type SubscriptionStatus = 'TRIAL' | 'ACTIVE' | 'CANCELED' | 'PAST_DUE'
interface Customer {
id: string
name: string
email: string
company: string | null
status: UserStatus
subscription: {
plan: string
tier: string
status: SubscriptionStatus
tokensUsed: number
tokenLimit: number
} | null
activeServers: number
createdAt: string
}
// Status badge component
function UserStatusBadge({ status }: { status: UserStatus }) {
const statusConfig: Record<UserStatus, { label: string; className: string }> = {
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
SUSPENDED: { label: 'Suspended', className: 'bg-red-100 text-red-800' },
PENDING_VERIFICATION: { label: 'Pending', className: 'bg-yellow-100 text-yellow-800' },
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
{config.label}
</span>
)
}
function SubscriptionBadge({ status }: { status: SubscriptionStatus }) {
const statusConfig: Record<SubscriptionStatus, { label: string; className: string }> = {
TRIAL: { label: 'Trial', className: 'bg-blue-100 text-blue-800' },
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
CANCELED: { label: 'Canceled', className: 'bg-gray-100 text-gray-800' },
PAST_DUE: { label: 'Past Due', className: 'bg-red-100 text-red-800' },
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
{config.label}
</span>
)
}
// Customer table row component
function CustomerRow({ customer }: { customer: Customer }) {
return (
<tr className="border-b hover:bg-gray-50">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<User className="h-5 w-5 text-primary" />
</div>
<div>
<Link
href={`/admin/customers/${customer.id}`}
className="font-medium hover:underline"
>
{customer.name}
</Link>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{customer.email}
</div>
</div>
</div>
</td>
<td className="px-4 py-3">
{customer.company ? (
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span>{customer.company}</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="px-4 py-3">
<UserStatusBadge status={customer.status} />
</td>
<td className="px-4 py-3">
{customer.subscription ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium capitalize">{customer.subscription.plan.toLowerCase()}</span>
<SubscriptionBadge status={customer.subscription.status} />
</div>
<span className="text-xs text-muted-foreground capitalize">
{customer.subscription.tier.replace('_', ' ').toLowerCase()}
</span>
</div>
) : (
<span className="text-muted-foreground">No subscription</span>
)}
</td>
<td className="px-4 py-3">
{customer.subscription ? (
<div className="space-y-1">
<div className="text-sm">
{customer.subscription.tokensUsed.toLocaleString()} /{' '}
{customer.subscription.tokenLimit.toLocaleString()}
</div>
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-gray-200">
<div
className="h-full bg-primary"
style={{
width: `${Math.min(
(customer.subscription.tokensUsed / customer.subscription.tokenLimit) * 100,
100
)}%`,
}}
/>
</div>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<span>{customer.activeServers}</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
{new Date(customer.createdAt).toLocaleDateString()}
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<Link href={`/admin/customers/${customer.id}`}>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
)
}
export default function CustomersPage() {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 10
// Fetch customers from API
const {
data,
isLoading,
isError,
error,
refetch,
isFetching,
} = useCustomers({
search: search || undefined,
status: statusFilter !== 'all' ? statusFilter as ApiUserStatus : undefined,
page: currentPage,
limit: itemsPerPage,
})
// Map API customers to component format
const customers = useMemo<Customer[]>(() => {
if (!data?.customers) return []
return data.customers.map((c) => ({
id: c.id,
name: c.name || c.email,
email: c.email,
company: c.company,
status: c.status as UserStatus,
subscription: c.subscriptions?.[0] ? {
plan: c.subscriptions[0].plan,
tier: c.subscriptions[0].tier,
status: c.subscriptions[0].status as SubscriptionStatus,
tokensUsed: 0, // Not included in list response
tokenLimit: 0, // Not included in list response
} : null,
activeServers: c._count?.orders || 0,
createdAt: String(c.createdAt),
}))
}, [data?.customers])
// Calculate stats from API data
const stats = useMemo(() => ({
total: data?.pagination?.total || 0,
active: customers.filter((c) => c.status === 'ACTIVE').length,
trial: customers.filter((c) => c.subscription?.status === 'TRIAL').length,
totalServers: customers.reduce((acc, c) => acc + c.activeServers, 0),
}), [customers, data?.pagination?.total])
const totalPages = data?.pagination?.totalPages || 1
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading customers...</p>
</div>
</div>
)
}
// Error state
if (isError) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium text-destructive">Failed to load customers</p>
<p className="text-sm text-muted-foreground">
{error instanceof Error ? error.message : 'An error occurred'}
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Customers</h1>
<p className="text-muted-foreground">
Manage customer accounts and subscriptions
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Customer
</Button>
</div>
</div>
{/* Stats cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-sm text-muted-foreground">Total Customers</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{stats.active}</div>
<p className="text-sm text-muted-foreground">Active</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{stats.trial}</div>
<p className="text-sm text-muted-foreground">On Trial</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{stats.totalServers}</div>
<p className="text-sm text-muted-foreground">Total Servers</p>
</CardContent>
</Card>
</div>
{/* Filters and table */}
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>All Customers</CardTitle>
<CardDescription>
{data?.pagination?.total || 0} customer{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search customers..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setCurrentPage(1) // Reset to first page on search
}}
className="pl-9 w-full sm:w-64"
/>
</div>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value)
setCurrentPage(1) // Reset to first page on filter change
}}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
>
<option value="all">All Status</option>
<option value="ACTIVE">Active</option>
<option value="SUSPENDED">Suspended</option>
<option value="PENDING_VERIFICATION">Pending</option>
</select>
</div>
</div>
</CardHeader>
<CardContent>
{customers.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">No customers found</p>
{(search || statusFilter !== 'all') && (
<Button
variant="link"
onClick={() => {
setSearch('')
setStatusFilter('all')
setCurrentPage(1)
}}
>
Clear filters
</Button>
)}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="px-4 py-3 font-medium">Customer</th>
<th className="px-4 py-3 font-medium">Company</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Subscription</th>
<th className="px-4 py-3 font-medium">Token Usage</th>
<th className="px-4 py-3 font-medium">Servers</th>
<th className="px-4 py-3 font-medium">Joined</th>
<th className="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{customers.map((customer) => (
<CustomerRow key={customer.id} customer={customer} />
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between border-t pt-4">
<p className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
)
}

37
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { AdminSidebar } from '@/components/admin/sidebar'
import { AdminHeader } from '@/components/admin/header'
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
// Redirect to login if not authenticated
if (!session) {
redirect('/login')
}
// Redirect if not a staff member
if (session.user.userType !== 'staff') {
redirect('/')
}
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<AdminSidebar />
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
<AdminHeader />
<main className="flex-1 overflow-y-auto bg-gray-50 p-6">
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,813 @@
'use client'
import { useState, useMemo, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useOrder, useUpdateOrder, useTriggerProvisioning } from '@/hooks/use-orders'
import { useProvisioningLogs, StreamedLog } from '@/hooks/use-provisioning-logs'
import { OrderStatus, SubscriptionTier, LogLevel } from '@/types/api'
import {
ArrowLeft,
Globe,
User,
Server,
Clock,
CheckCircle,
AlertCircle,
Loader2,
Eye,
EyeOff,
RefreshCw,
ExternalLink,
Terminal,
Zap,
Wifi,
WifiOff,
} from 'lucide-react'
// Status badge component
function StatusBadge({ status }: { status: OrderStatus }) {
const statusConfig: Record<OrderStatus, { label: string; className: string }> = {
PAYMENT_CONFIRMED: { label: 'Payment Confirmed', className: 'bg-blue-100 text-blue-800' },
AWAITING_SERVER: { label: 'Awaiting Server', className: 'bg-yellow-100 text-yellow-800' },
SERVER_READY: { label: 'Server Ready', className: 'bg-purple-100 text-purple-800' },
DNS_PENDING: { label: 'DNS Pending', className: 'bg-orange-100 text-orange-800' },
DNS_READY: { label: 'DNS Ready', className: 'bg-cyan-100 text-cyan-800' },
PROVISIONING: { label: 'Provisioning', className: 'bg-indigo-100 text-indigo-800' },
FULFILLED: { label: 'Fulfilled', className: 'bg-green-100 text-green-800' },
EMAIL_CONFIGURED: { label: 'Complete', className: 'bg-emerald-100 text-emerald-800' },
FAILED: { label: 'Failed', className: 'bg-red-100 text-red-800' },
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${config.className}`}>
{config.label}
</span>
)
}
// Server credentials form component
function ServerCredentialsForm({
initialIp,
initialPort,
hasCredentials,
onSubmit,
isLoading,
}: {
initialIp?: string
initialPort?: number
hasCredentials: boolean
onSubmit: (ip: string, password: string, port: number) => void
isLoading: boolean
}) {
const [ip, setIp] = useState(initialIp || '')
const [password, setPassword] = useState('')
const [port, setPort] = useState(String(initialPort || 22))
const [showPassword, setShowPassword] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
const handleTestConnection = async () => {
setTesting(true)
setTestResult(null)
// TODO: Call API to test SSH connection
await new Promise(resolve => setTimeout(resolve, 2000))
setTestResult({ success: true, message: 'Connection successful! SSH version: OpenSSH_8.4' })
setTesting(false)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit(ip, password, parseInt(port))
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Server Credentials
</CardTitle>
<CardDescription>
{hasCredentials
? 'Server credentials have been entered. You can update them if needed.'
: 'Enter the server credentials received from the hosting provider.'}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ip">Server IP Address</Label>
<Input
id="ip"
type="text"
placeholder="123.45.67.89"
value={ip}
onChange={(e) => setIp(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="port">SSH Port</Label>
<Input
id="port"
type="number"
placeholder="22"
value={port}
onChange={(e) => setPort(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Root Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter root password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={!hasCredentials}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Password will be encrypted and deleted after provisioning completes.
</p>
</div>
{testResult && (
<div
className={`flex items-center gap-2 rounded-md p-3 ${
testResult.success ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
}`}
>
{testResult.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<span className="text-sm">{testResult.message}</span>
</div>
)}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={!ip || !password || testing}
>
{testing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Test Connection
</>
)}
</Button>
<Button type="submit" disabled={isLoading || !ip}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save & Mark Ready'
)}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}
// Provisioning logs component
function ProvisioningLogs({ logs, isLive, isConnected, onReconnect }: {
logs: Array<{
id: string
timestamp: Date
level: LogLevel
step: string | null
message: string
}>
isLive: boolean
isConnected?: boolean
onReconnect?: () => void
}) {
const levelColors: Record<LogLevel, string> = {
INFO: 'text-blue-400',
WARN: 'text-yellow-400',
ERROR: 'text-red-400',
DEBUG: 'text-gray-400',
}
const formatTime = (date: Date) => {
const d = new Date(date)
return d.toLocaleTimeString('en-US', { hour12: false })
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
Provisioning Logs
</CardTitle>
<CardDescription>Real-time output from the provisioning process</CardDescription>
</div>
<div className="flex items-center gap-3">
{isLive && (
<>
{isConnected ? (
<div className="flex items-center gap-2">
<span className="relative flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
</span>
<span className="text-sm text-muted-foreground">Live</span>
<Wifi className="h-4 w-4 text-green-500" />
</div>
) : (
<div className="flex items-center gap-2">
<WifiOff className="h-4 w-4 text-red-500" />
<span className="text-sm text-red-500">Disconnected</span>
{onReconnect && (
<Button variant="ghost" size="sm" onClick={onReconnect}>
<RefreshCw className="h-4 w-4" />
</Button>
)}
</div>
)}
</>
)}
{!isLive && logs.length > 0 && (
<span className="text-sm text-muted-foreground">
{logs.length} log{logs.length !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="h-96 overflow-y-auto rounded-lg bg-gray-900 p-4 font-mono text-sm" id="log-container">
{logs.length === 0 ? (
<p className="text-gray-500">
{isLive && isConnected ? 'Waiting for logs...' : 'No logs available yet.'}
</p>
) : (
logs.map((log) => (
<div key={log.id} className="mb-2">
<span className="text-gray-500">[{formatTime(log.timestamp)}]</span>{' '}
<span className={levelColors[log.level]}>[{log.level}]</span>{' '}
{log.step && <span className="text-purple-400">[{log.step}]</span>}{' '}
<span className="text-gray-300">{log.message}</span>
</div>
))
)}
</div>
</CardContent>
</Card>
)
}
// Order timeline component
function OrderTimeline({
status,
timestamps,
}: {
status: OrderStatus
timestamps: {
createdAt?: Date | null
serverReadyAt?: Date | null
provisioningStartedAt?: Date | null
completedAt?: Date | null
}
}) {
const stages = [
{ key: 'payment_confirmed', label: 'Payment Confirmed', status: 'PAYMENT_CONFIRMED' },
{ key: 'awaiting_server', label: 'Server Ordered', status: 'AWAITING_SERVER' },
{ key: 'server_ready', label: 'Server Ready', status: 'SERVER_READY' },
{ key: 'dns_ready', label: 'DNS Configured', status: 'DNS_READY' },
{ key: 'provisioning', label: 'Provisioning', status: 'PROVISIONING' },
{ key: 'fulfilled', label: 'Fulfilled', status: 'FULFILLED' },
]
const statusOrder = stages.map((s) => s.status)
const currentIndex = statusOrder.indexOf(status)
const formatDate = (date: Date | null | undefined) => {
if (!date) return null
return new Date(date).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}
const getTimestamp = (key: string): string | null => {
switch (key) {
case 'payment_confirmed':
return formatDate(timestamps.createdAt)
case 'server_ready':
return formatDate(timestamps.serverReadyAt)
case 'provisioning':
return formatDate(timestamps.provisioningStartedAt)
case 'fulfilled':
return formatDate(timestamps.completedAt)
default:
return null
}
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Order Timeline
</CardTitle>
</CardHeader>
<CardContent>
<div className="relative">
{stages.map((stage, index) => {
const isComplete = index < currentIndex || (index === currentIndex && status !== 'FAILED')
const isCurrent = index === currentIndex
const timestamp = getTimestamp(stage.key)
return (
<div key={stage.key} className="flex gap-4 pb-8 last:pb-0">
<div className="relative flex flex-col items-center">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full border-2 ${
isComplete
? 'border-green-500 bg-green-500 text-white'
: isCurrent
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white'
}`}
>
{isComplete ? (
<CheckCircle className="h-4 w-4" />
) : (
<span className="text-sm font-medium">{index + 1}</span>
)}
</div>
{index < stages.length - 1 && (
<div
className={`absolute top-8 h-full w-0.5 ${
isComplete ? 'bg-green-500' : 'bg-gray-200'
}`}
/>
)}
</div>
<div className="flex-1 pt-1">
<p className={`font-medium ${isCurrent ? 'text-blue-600' : ''}`}>
{stage.label}
</p>
{timestamp && (
<p className="text-sm text-muted-foreground">{timestamp}</p>
)}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
export default function OrderDetailPage() {
const params = useParams()
const router = useRouter()
const orderId = params.id as string
// Fetch order data
const {
data: order,
isLoading,
isError,
error,
refetch,
isFetching,
} = useOrder(orderId)
// Mutations
const updateOrder = useUpdateOrder()
const triggerProvision = useTriggerProvisioning()
// Check if we should enable SSE streaming
const isProvisioning = order?.status === OrderStatus.PROVISIONING
// SSE for live log streaming
const {
logs: streamedLogs,
isConnected,
isComplete,
reconnect,
} = useProvisioningLogs({
orderId,
enabled: isProvisioning,
onStatusChange: useCallback((newStatus: OrderStatus) => {
// Refetch order data when status changes
refetch()
}, [refetch]),
onComplete: useCallback((success: boolean) => {
// Refetch order data when provisioning completes
refetch()
}, [refetch]),
})
// Computed values
const tierLabel = useMemo(() => {
if (!order) return ''
return order.tier === SubscriptionTier.HUB_DASHBOARD ? 'Hub Dashboard' : 'Control Panel'
}, [order?.tier])
// Merge historical logs with streamed logs, avoiding duplicates
const allLogs = useMemo(() => {
const historicalLogs = order?.provisioningLogs || []
const historicalLogIds = new Set(historicalLogs.map(l => l.id))
// Add only new streamed logs that aren't in historical
const newStreamedLogs = streamedLogs.filter(l => !historicalLogIds.has(l.id))
// Combine and sort by timestamp
const combined = [
...historicalLogs.map(l => ({
id: l.id,
level: l.level,
step: l.step,
message: l.message,
timestamp: new Date(l.timestamp),
})),
...newStreamedLogs,
]
return combined.sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
)
}, [order?.provisioningLogs, streamedLogs])
// Auto-scroll logs to bottom when new logs come in
useEffect(() => {
if (isProvisioning && allLogs.length > 0) {
const container = document.getElementById('log-container')
if (container) {
container.scrollTop = container.scrollHeight
}
}
}, [allLogs.length, isProvisioning])
const handleCredentialsSubmit = async (ip: string, password: string, port: number) => {
await updateOrder.mutateAsync({
id: orderId,
data: {
serverIp: ip,
serverPassword: password,
sshPort: port,
},
})
}
const handleTriggerProvisioning = async () => {
try {
await triggerProvision.mutateAsync(orderId)
} catch (err) {
console.error('Failed to trigger provisioning:', err)
}
}
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading order details...</p>
</div>
</div>
)
}
// Error state
if (isError || !order) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium text-destructive">Failed to load order</p>
<p className="text-sm text-muted-foreground">
{error instanceof Error ? error.message : 'Order not found'}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4 mr-2" />
Go Back
</Button>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
</div>
</div>
)
}
const showCredentialsForm = order.status === OrderStatus.AWAITING_SERVER || order.status === OrderStatus.SERVER_READY
const showProvisionButton = order.status === OrderStatus.DNS_READY || order.status === OrderStatus.FAILED
const showLogs = order.status === OrderStatus.PROVISIONING ||
order.status === OrderStatus.FULFILLED ||
order.status === OrderStatus.EMAIL_CONFIGURED ||
order.status === OrderStatus.FAILED
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/admin/orders">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{order.domain}</h1>
<StatusBadge status={order.status} />
</div>
<p className="text-muted-foreground">Order #{orderId.slice(0, 8)}...</p>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
{showProvisionButton && (
<Button
onClick={handleTriggerProvisioning}
disabled={triggerProvision.isPending}
>
{triggerProvision.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Starting...
</>
) : (
<>
<Zap className="mr-2 h-4 w-4" />
{order.status === OrderStatus.FAILED ? 'Retry Provisioning' : 'Start Provisioning'}
</>
)}
</Button>
)}
{order.portainerUrl && (
<Button variant="outline" asChild>
<a href={order.portainerUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Portainer
</a>
</Button>
)}
{order.dashboardUrl && (
<Button variant="outline" asChild>
<a href={order.dashboardUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Dashboard
</a>
</Button>
)}
</div>
</div>
{/* Failure reason banner */}
{order.status === OrderStatus.FAILED && order.failureReason && (
<div className="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4">
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5" />
<div>
<p className="font-medium text-red-800">Provisioning Failed</p>
<p className="text-sm text-red-700">{order.failureReason}</p>
</div>
</div>
)}
{/* Order info cards */}
<div className="grid gap-6 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Customer
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="font-medium">{order.user.name || order.user.company || 'N/A'}</p>
<p className="text-sm text-muted-foreground">{order.user.email}</p>
{order.user.company && order.user.name && (
<p className="text-sm text-muted-foreground">{order.user.company}</p>
)}
<Link href={`/admin/customers/${order.user.id}`}>
<Button variant="link" className="px-0 h-auto">
View Customer Profile
</Button>
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Domain & Tier
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="font-medium">{order.domain}</p>
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary">
{tierLabel}
</span>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Server
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{order.serverIp ? (
<>
<p className="font-mono font-medium">{order.serverIp}</p>
<p className="text-sm text-muted-foreground">SSH Port: {order.sshPort || 22}</p>
</>
) : (
<p className="text-sm text-muted-foreground">Not configured</p>
)}
</CardContent>
</Card>
</div>
{/* Tools list */}
<Card>
<CardHeader>
<CardTitle>Selected Tools</CardTitle>
<CardDescription>Tools to be deployed on this server</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{order.tools.map((tool) => (
<span
key={tool}
className="rounded-full bg-gray-100 px-3 py-1 text-sm font-medium capitalize"
>
{tool}
</span>
))}
</div>
</CardContent>
</Card>
{/* Server credentials form (show for AWAITING_SERVER or SERVER_READY status) */}
{showCredentialsForm && (
<ServerCredentialsForm
initialIp={order.serverIp || undefined}
initialPort={order.sshPort}
hasCredentials={!!order.serverIp}
onSubmit={handleCredentialsSubmit}
isLoading={updateOrder.isPending}
/>
)}
{/* Two column layout for timeline and logs */}
<div className="grid gap-6 lg:grid-cols-2">
<OrderTimeline
status={order.status}
timestamps={{
createdAt: order.createdAt,
serverReadyAt: order.serverReadyAt,
provisioningStartedAt: order.provisioningStartedAt,
completedAt: order.completedAt,
}}
/>
{/* Show logs for provisioning/completed/failed status */}
{showLogs && (
<ProvisioningLogs
logs={allLogs}
isLive={isProvisioning}
isConnected={isConnected}
onReconnect={reconnect}
/>
)}
</div>
{/* Jobs history */}
{order.jobs && order.jobs.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Job History</CardTitle>
<CardDescription>Recent provisioning job attempts</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{order.jobs.map((job) => (
<div
key={job.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<div
className={`h-2 w-2 rounded-full ${
job.status === 'COMPLETED'
? 'bg-green-500'
: job.status === 'FAILED'
? 'bg-red-500'
: job.status === 'RUNNING'
? 'bg-blue-500'
: 'bg-gray-400'
}`}
/>
<div>
<p className="text-sm font-medium">
Attempt {job.attempt} of {job.maxAttempts}
</p>
<p className="text-xs text-muted-foreground">
{new Date(job.createdAt).toLocaleString()}
</p>
</div>
</div>
<div className="text-right">
<span
className={`text-xs font-medium ${
job.status === 'COMPLETED'
? 'text-green-600'
: job.status === 'FAILED'
? 'text-red-600'
: job.status === 'RUNNING'
? 'text-blue-600'
: 'text-gray-600'
}`}
>
{job.status}
</span>
{job.error && (
<p className="text-xs text-red-500 max-w-xs truncate">{job.error}</p>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,343 @@
'use client'
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
OrderKanban,
OrderPipelineCompact,
} from '@/components/admin/order-kanban'
import { CreateOrderDialog } from '@/components/admin/create-order-dialog'
import type { Order as OrderCardType, OrderStatus, OrderTier } from '@/components/admin/order-card'
import { useOrders } from '@/hooks/use-orders'
import { OrderStatus as ApiOrderStatus, SubscriptionTier } from '@/types/api'
import {
Search,
Filter,
RefreshCw,
LayoutGrid,
List,
Download,
Plus,
Loader2,
AlertCircle,
} from 'lucide-react'
// View modes
type ViewMode = 'kanban' | 'list'
// Filter options
interface FilterOptions {
search: string
tier: OrderTier | 'all'
status: OrderStatus | 'all'
}
// Map API tier to component tier
function mapTier(tier: SubscriptionTier): OrderTier {
return tier === 'HUB_DASHBOARD' ? 'hub-dashboard' : 'control-panel'
}
// Map API tier back for filtering
function mapTierToApi(tier: OrderTier | 'all'): SubscriptionTier | undefined {
if (tier === 'all') return undefined
return tier === 'hub-dashboard' ? SubscriptionTier.HUB_DASHBOARD : SubscriptionTier.ADVANCED
}
// Map API order to component order format
function mapApiOrderToCardOrder(apiOrder: {
id: string
domain: string
tier: SubscriptionTier
status: ApiOrderStatus
serverIp: string | null
failureReason: string | null
createdAt: Date | string
updatedAt: Date | string
user: {
id: string
name: string | null
email: string
company: string | null
}
}): OrderCardType {
return {
id: apiOrder.id,
domain: apiOrder.domain,
customerName: apiOrder.user.name || apiOrder.user.company || apiOrder.user.email,
customerEmail: apiOrder.user.email,
tier: mapTier(apiOrder.tier),
status: apiOrder.status as OrderStatus,
createdAt: new Date(apiOrder.createdAt),
updatedAt: new Date(apiOrder.updatedAt),
serverIp: apiOrder.serverIp || undefined,
failureReason: apiOrder.failureReason || undefined,
}
}
export default function OrdersPage() {
const router = useRouter()
const [viewMode, setViewMode] = useState<ViewMode>('kanban')
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [filters, setFilters] = useState<FilterOptions>({
search: '',
tier: 'all',
status: 'all',
})
// Fetch orders from API
const {
data,
isLoading,
isError,
error,
refetch,
isFetching,
} = useOrders({
search: filters.search || undefined,
tier: mapTierToApi(filters.tier),
status: filters.status !== 'all' ? filters.status as ApiOrderStatus : undefined,
limit: 100,
})
// Map API orders to component format
const orders = useMemo(() => {
if (!data?.orders) return []
return data.orders.map(mapApiOrderToCardOrder)
}, [data?.orders])
// Handle order action
const handleOrderAction = (order: OrderCardType, action: string) => {
console.log(`Action "${action}" triggered for order:`, order)
// Navigate to order detail page for actions
router.push(`/admin/orders/${order.id}`)
}
// Handle view order details
const handleViewDetails = (order: OrderCardType) => {
router.push(`/admin/orders/${order.id}`)
}
// Handle refresh
const handleRefresh = async () => {
await refetch()
}
// Handle export
const handleExport = () => {
// TODO: Implement CSV export
console.log('Exporting orders...')
alert('Export functionality coming soon!')
}
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading orders...</p>
</div>
</div>
)
}
// Error state
if (isError) {
return (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium text-destructive">Failed to load orders</p>
<p className="text-sm text-muted-foreground">
{error instanceof Error ? error.message : 'An error occurred'}
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
</div>
)
}
return (
<div className="flex flex-col h-full space-y-6">
{/* Page header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Order Pipeline</h1>
<p className="text-muted-foreground">
Manage and track customer provisioning orders
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
<Button size="sm" onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
New Order
</Button>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* Search and filters */}
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by domain, customer..."
value={filters.search}
onChange={(e) =>
setFilters((prev) => ({ ...prev, search: e.target.value }))
}
className="pl-9"
/>
</div>
{/* Tier filter */}
<select
value={filters.tier}
onChange={(e) =>
setFilters((prev) => ({
...prev,
tier: e.target.value as OrderTier | 'all',
}))
}
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="all">All Tiers</option>
<option value="hub-dashboard">Hub Dashboard</option>
<option value="control-panel">Control Panel</option>
</select>
{/* Status filter */}
<select
value={filters.status}
onChange={(e) =>
setFilters((prev) => ({
...prev,
status: e.target.value as OrderStatus | 'all',
}))
}
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="all">All Statuses</option>
<option value="PAYMENT_CONFIRMED">Payment Confirmed</option>
<option value="AWAITING_SERVER">Awaiting Server</option>
<option value="SERVER_READY">Server Ready</option>
<option value="DNS_PENDING">DNS Pending</option>
<option value="DNS_READY">DNS Ready</option>
<option value="PROVISIONING">Provisioning</option>
<option value="FULFILLED">Fulfilled</option>
<option value="EMAIL_CONFIGURED">Complete</option>
<option value="FAILED">Failed</option>
</select>
</div>
{/* View controls */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isFetching}
>
<RefreshCw
className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
<div className="flex items-center border rounded-md">
<Button
variant={viewMode === 'kanban' ? 'secondary' : 'ghost'}
size="sm"
className="rounded-r-none"
onClick={() => setViewMode('kanban')}
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
className="rounded-l-none"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Active filters indicator */}
{(filters.search || filters.tier !== 'all' || filters.status !== 'all') && (
<div className="flex items-center gap-2 text-sm">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">
Showing {orders.length} orders
{data?.pagination && ` of ${data.pagination.total}`}
</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setFilters({ search: '', tier: 'all', status: 'all' })
}
>
Clear filters
</Button>
</div>
)}
{/* Empty state */}
{orders.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground">No orders found</p>
{(filters.search || filters.tier !== 'all' || filters.status !== 'all') && (
<Button
variant="link"
onClick={() => setFilters({ search: '', tier: 'all', status: 'all' })}
>
Clear filters to see all orders
</Button>
)}
</div>
</div>
)}
{/* Main content */}
{orders.length > 0 && (
<div className="flex-1 min-h-0">
{viewMode === 'kanban' ? (
<OrderKanban
orders={orders}
onAction={handleOrderAction}
onViewDetails={handleViewDetails}
/>
) : (
<OrderPipelineCompact
orders={orders}
onViewDetails={handleViewDetails}
/>
)}
</div>
)}
{/* Create Order Dialog */}
<CreateOrderDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
onSuccess={() => refetch()}
/>
</div>
)
}

327
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,327 @@
'use client'
import Link from 'next/link'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useDashboardStats } from '@/hooks/use-stats'
import {
ShoppingCart,
Users,
Server,
TrendingUp,
Clock,
CheckCircle,
AlertCircle,
ArrowRight,
Loader2,
RefreshCw,
} from 'lucide-react'
// Stats card component
function StatsCard({
title,
value,
description,
icon: Icon,
isLoading,
}: {
title: string
value: string | number
description: string
icon: React.ElementType
isLoading?: boolean
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoading ? (
<div className="h-8 w-16 bg-muted animate-pulse rounded" />
) : (
<div className="text-2xl font-bold">{value}</div>
)}
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
)
}
// Order status badge
function OrderStatusBadge({ status }: { status: string }) {
const statusStyles: Record<string, string> = {
PAYMENT_CONFIRMED: 'bg-blue-100 text-blue-800',
AWAITING_SERVER: 'bg-yellow-100 text-yellow-800',
SERVER_READY: 'bg-purple-100 text-purple-800',
DNS_PENDING: 'bg-orange-100 text-orange-800',
DNS_READY: 'bg-cyan-100 text-cyan-800',
PROVISIONING: 'bg-indigo-100 text-indigo-800',
FULFILLED: 'bg-green-100 text-green-800',
EMAIL_CONFIGURED: 'bg-emerald-100 text-emerald-800',
FAILED: 'bg-red-100 text-red-800',
}
const statusLabels: Record<string, string> = {
PAYMENT_CONFIRMED: 'Payment Confirmed',
AWAITING_SERVER: 'Awaiting Server',
SERVER_READY: 'Server Ready',
DNS_PENDING: 'DNS Pending',
DNS_READY: 'DNS Ready',
PROVISIONING: 'Provisioning',
FULFILLED: 'Fulfilled',
EMAIL_CONFIGURED: 'Complete',
FAILED: 'Failed',
}
return (
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
statusStyles[status] || 'bg-gray-100 text-gray-800'
}`}
>
{statusLabels[status] || status}
</span>
)
}
// Format time since
function formatTimeSince(date: Date | string): string {
const now = new Date()
const then = new Date(date)
const diffMs = now.getTime() - then.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return then.toLocaleDateString()
}
// Recent orders component
function RecentOrders({ orders, isLoading }: {
orders: Array<{
id: string
domain: string
status: string
createdAt: Date | string
user: { name: string | null; email: string; company: string | null }
}>
isLoading: boolean
}) {
return (
<Card className="col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Recent Orders</CardTitle>
<CardDescription>Latest customer provisioning orders</CardDescription>
</div>
<Link href="/admin/orders">
<Button variant="ghost" size="sm">
View All <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</Link>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-2">
<div className="h-4 w-32 bg-muted animate-pulse rounded" />
<div className="h-3 w-24 bg-muted animate-pulse rounded" />
</div>
<div className="h-6 w-20 bg-muted animate-pulse rounded-full" />
</div>
))}
</div>
) : orders.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No orders yet
</div>
) : (
<div className="space-y-4">
{orders.map((order) => (
<Link
key={order.id}
href={`/admin/orders/${order.id}`}
className="flex items-center justify-between rounded-lg border p-4 hover:bg-muted/50 transition-colors"
>
<div className="space-y-1">
<p className="font-medium">{order.domain}</p>
<p className="text-sm text-muted-foreground">
{order.user.name || order.user.company || order.user.email}
</p>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
{formatTimeSince(order.createdAt)}
</span>
<OrderStatusBadge status={order.status} />
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
)
}
// Pipeline overview component
function PipelineOverview({ stats, isLoading }: {
stats: {
pending: number
inProgress: number
completed: number
failed: number
} | null
isLoading: boolean
}) {
const stages = [
{ name: 'Payment & Server', count: stats?.pending || 0, icon: Clock, color: 'text-yellow-500' },
{ name: 'Provisioning', count: stats?.inProgress || 0, icon: TrendingUp, color: 'text-indigo-500' },
{ name: 'Completed', count: stats?.completed || 0, icon: CheckCircle, color: 'text-green-500' },
{ name: 'Failed', count: stats?.failed || 0, icon: AlertCircle, color: 'text-red-500' },
]
return (
<Card>
<CardHeader>
<CardTitle>Order Pipeline</CardTitle>
<CardDescription>Orders by current stage</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{stages.map((stage) => (
<div key={stage.name} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<stage.icon className={`h-5 w-5 ${stage.color}`} />
<span className="text-sm">{stage.name}</span>
</div>
{isLoading ? (
<div className="h-5 w-8 bg-muted animate-pulse rounded" />
) : (
<span className="font-medium">{stage.count}</span>
)}
</div>
))}
</div>
<div className="mt-4 pt-4 border-t">
<Link href="/admin/orders">
<Button variant="outline" className="w-full">
View Pipeline
</Button>
</Link>
</div>
</CardContent>
</Card>
)
}
export default function AdminDashboard() {
const { data: stats, isLoading, isError, refetch, isFetching } = useDashboardStats()
// Error state
if (isError) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium text-destructive">Failed to load dashboard</p>
<p className="text-sm text-muted-foreground">Could not fetch statistics</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Overview of your LetsBe Hub platform
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Stats grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Total Orders"
value={stats?.orders.total || 0}
description="All time orders"
icon={ShoppingCart}
isLoading={isLoading}
/>
<StatsCard
title="Active Customers"
value={stats?.customers.active || 0}
description="Verified customers"
icon={Users}
isLoading={isLoading}
/>
<StatsCard
title="Completed Deployments"
value={stats?.orders.completed || 0}
description="Successfully provisioned"
icon={Server}
isLoading={isLoading}
/>
<StatsCard
title="Pending Actions"
value={stats?.orders.pending || 0}
description="Orders needing attention"
icon={Clock}
isLoading={isLoading}
/>
</div>
{/* Main content grid */}
<div className="grid gap-6 lg:grid-cols-3">
<RecentOrders
orders={stats?.recentOrders || []}
isLoading={isLoading}
/>
<PipelineOverview
stats={stats?.orders ? {
pending: stats.orders.pending,
inProgress: stats.orders.inProgress,
completed: stats.orders.completed,
failed: stats.orders.failed,
} : null}
isLoading={isLoading}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,404 @@
'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useServers, ServerStatus } from '@/hooks/use-servers'
import {
Search,
Server,
Globe,
User,
Calendar,
RefreshCw,
ExternalLink,
ChevronLeft,
ChevronRight,
Loader2,
AlertCircle,
CheckCircle,
XCircle,
Clock,
Cpu,
Package,
} from 'lucide-react'
// Status badge component
function ServerStatusBadge({ status }: { status: ServerStatus }) {
const config: Record<ServerStatus, { label: string; className: string; icon: React.ReactNode }> = {
online: {
label: 'Online',
className: 'bg-green-100 text-green-800',
icon: <CheckCircle className="h-3 w-3" />,
},
provisioning: {
label: 'Provisioning',
className: 'bg-blue-100 text-blue-800',
icon: <Clock className="h-3 w-3 animate-pulse" />,
},
offline: {
label: 'Offline',
className: 'bg-red-100 text-red-800',
icon: <XCircle className="h-3 w-3" />,
},
pending: {
label: 'Pending',
className: 'bg-yellow-100 text-yellow-800',
icon: <Clock className="h-3 w-3" />,
},
}
const statusConfig = config[status]
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${statusConfig.className}`}>
{statusConfig.icon}
{statusConfig.label}
</span>
)
}
// Server card component
function ServerCard({ server }: { server: {
id: string
domain: string
tier: string
serverStatus: ServerStatus
serverIp: string
sshPort: number
tools: string[]
createdAt: Date | string
customer: {
id: string
name: string | null
email: string
company: string | null
}
}}) {
return (
<Card className="hover:shadow-md transition-shadow">
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${
server.serverStatus === 'online' ? 'bg-green-100' :
server.serverStatus === 'provisioning' ? 'bg-blue-100' :
server.serverStatus === 'offline' ? 'bg-red-100' : 'bg-gray-100'
}`}>
<Server className={`h-6 w-6 ${
server.serverStatus === 'online' ? 'text-green-600' :
server.serverStatus === 'provisioning' ? 'text-blue-600' :
server.serverStatus === 'offline' ? 'text-red-600' : 'text-gray-600'
}`} />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold">{server.domain}</h3>
<ServerStatusBadge status={server.serverStatus} />
</div>
<p className="text-sm text-muted-foreground font-mono">{server.serverIp}</p>
</div>
</div>
<Link href={`/admin/orders/${server.id}`}>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
</div>
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<div>
<p className="font-medium">{server.customer.name || server.customer.email}</p>
{server.customer.company && (
<p className="text-xs text-muted-foreground">{server.customer.company}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="capitalize">{server.tier.replace('_', ' ').toLowerCase()}</span>
</div>
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span>SSH Port: {server.sshPort}</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>{new Date(server.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<Package className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Tools ({server.tools.length})</span>
</div>
<div className="flex flex-wrap gap-1">
{server.tools.map((tool) => (
<span
key={tool}
className="px-2 py-0.5 text-xs bg-gray-100 rounded-full"
>
{tool}
</span>
))}
</div>
</div>
</CardContent>
</Card>
)
}
export default function ServersPage() {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<ServerStatus | 'all'>('all')
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 12
// Fetch servers from API
const {
data,
isLoading,
isError,
error,
refetch,
isFetching,
} = useServers({
search: search || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
page: currentPage,
limit: itemsPerPage,
})
// Calculate stats from data
const stats = useMemo(() => {
const servers = data?.servers || []
return {
total: data?.pagination?.total || 0,
online: servers.filter((s) => s.serverStatus === 'online').length,
provisioning: servers.filter((s) => s.serverStatus === 'provisioning').length,
offline: servers.filter((s) => s.serverStatus === 'offline').length,
}
}, [data])
const totalPages = data?.pagination?.totalPages || 1
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading servers...</p>
</div>
</div>
)
}
// Error state
if (isError) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium text-destructive">Failed to load servers</p>
<p className="text-sm text-muted-foreground">
{error instanceof Error ? error.message : 'An error occurred'}
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Servers</h1>
<p className="text-muted-foreground">
Manage deployed infrastructure servers
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Stats cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Server className="h-8 w-8 text-primary" />
<div>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-sm text-muted-foreground">Total Servers</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<CheckCircle className="h-8 w-8 text-green-600" />
<div>
<div className="text-2xl font-bold">{stats.online}</div>
<p className="text-sm text-muted-foreground">Online</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Clock className="h-8 w-8 text-blue-600" />
<div>
<div className="text-2xl font-bold">{stats.provisioning}</div>
<p className="text-sm text-muted-foreground">Provisioning</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<XCircle className="h-8 w-8 text-red-600" />
<div>
<div className="text-2xl font-bold">{stats.offline}</div>
<p className="text-sm text-muted-foreground">Offline</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>All Servers</CardTitle>
<CardDescription>
{data?.pagination?.total || 0} server{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search by domain, IP..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setCurrentPage(1)
}}
className="pl-9 w-full sm:w-64"
/>
</div>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as ServerStatus | 'all')
setCurrentPage(1)
}}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
>
<option value="all">All Status</option>
<option value="online">Online</option>
<option value="provisioning">Provisioning</option>
<option value="offline">Offline</option>
</select>
</div>
</div>
</CardHeader>
<CardContent>
{data?.servers && data.servers.length > 0 ? (
<>
{/* Server grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.servers.map((server) => (
<ServerCard key={server.id} server={server} />
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-6 flex items-center justify-between border-t pt-4">
<p className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
) : (
<div className="text-center py-12">
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-lg font-medium">No servers found</p>
<p className="text-muted-foreground">
{search || statusFilter !== 'all'
? 'Try adjusting your search or filters'
: 'Servers will appear here once orders are provisioned'}
</p>
{(search || statusFilter !== 'all') && (
<Button
variant="link"
onClick={() => {
setSearch('')
setStatusFilter('all')
setCurrentPage(1)
}}
>
Clear filters
</Button>
)}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,3 @@
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers

View File

@@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { UserStatus } from '@prisma/client'
/**
* GET /api/v1/admin/customers/[id]
* Get customer details with orders and subscriptions
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: customerId } = await params
const customer = await prisma.user.findUnique({
where: { id: customerId },
include: {
subscriptions: {
orderBy: { createdAt: 'desc' },
},
orders: {
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: { provisioningLogs: true },
},
},
},
tokenUsage: {
orderBy: { createdAt: 'desc' },
take: 100,
},
_count: {
select: {
orders: true,
subscriptions: true,
tokenUsage: true,
},
},
},
})
if (!customer) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
// Calculate total token usage
const totalTokensUsed = customer.tokenUsage.reduce(
(acc, usage) => acc + usage.tokensInput + usage.tokensOutput,
0
)
// Get current subscription's token limit
const currentSubscription = customer.subscriptions[0]
const tokenLimit = currentSubscription?.tokenLimit || 0
return NextResponse.json({
...customer,
totalTokensUsed,
tokenLimit,
})
} catch (error) {
console.error('Error getting customer:', error)
return NextResponse.json(
{ error: 'Failed to get customer' },
{ status: 500 }
)
}
}
/**
* PATCH /api/v1/admin/customers/[id]
* Update customer details
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: customerId } = await params
const body = await request.json()
// Validate customer exists
const existingCustomer = await prisma.user.findUnique({
where: { id: customerId },
})
if (!existingCustomer) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
// Build update data
const updateData: {
name?: string
company?: string
status?: UserStatus
} = {}
if (body.name !== undefined) {
updateData.name = body.name
}
if (body.company !== undefined) {
updateData.company = body.company
}
if (body.status !== undefined) {
updateData.status = body.status as UserStatus
}
const customer = await prisma.user.update({
where: { id: customerId },
data: updateData,
include: {
subscriptions: {
orderBy: { createdAt: 'desc' },
take: 1,
},
_count: {
select: {
orders: true,
subscriptions: true,
},
},
},
})
return NextResponse.json(customer)
} catch (error) {
console.error('Error updating customer:', error)
return NextResponse.json(
{ error: 'Failed to update customer' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { UserStatus, Prisma } from '@prisma/client'
/**
* GET /api/v1/admin/customers
* List all customers with optional filters
*/
export async function GET(request: NextRequest) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const searchParams = request.nextUrl.searchParams
const status = searchParams.get('status') as UserStatus | null
const search = searchParams.get('search')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '50')
const where: Prisma.UserWhereInput = {}
if (status) {
where.status = status
}
if (search) {
where.OR = [
{ email: { contains: search, mode: 'insensitive' } },
{ name: { contains: search, mode: 'insensitive' } },
{ company: { contains: search, mode: 'insensitive' } },
]
}
const [customers, total] = await Promise.all([
prisma.user.findMany({
where,
select: {
id: true,
email: true,
name: true,
company: true,
status: true,
createdAt: true,
subscriptions: {
select: {
id: true,
plan: true,
tier: true,
status: true,
},
take: 1,
orderBy: { createdAt: 'desc' },
},
_count: {
select: {
orders: true,
subscriptions: true,
},
},
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.user.count({ where }),
])
return NextResponse.json({
customers,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
} catch (error) {
console.error('Error listing customers:', error)
return NextResponse.json(
{ error: 'Failed to list customers' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,148 @@
import { NextRequest } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { OrderStatus } from '@prisma/client'
/**
* GET /api/v1/admin/orders/[id]/logs/stream
* Stream provisioning logs via Server-Sent Events
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return new Response('Unauthorized', { status: 401 })
}
const { id: orderId } = await params
// Verify order exists
const order = await prisma.order.findUnique({
where: { id: orderId },
select: { id: true, status: true },
})
if (!order) {
return new Response('Order not found', { status: 404 })
}
// Create SSE response
const encoder = new TextEncoder()
let lastLogId: string | null = null
let isActive = true
const stream = new ReadableStream({
async start(controller) {
// Send initial connection message
controller.enqueue(
encoder.encode(`event: connected\ndata: ${JSON.stringify({ orderId })}\n\n`)
)
// Poll for new logs
const poll = async () => {
if (!isActive) return
try {
// Get current order status
const currentOrder = await prisma.order.findUnique({
where: { id: orderId },
select: { status: true },
})
if (!currentOrder) {
controller.enqueue(
encoder.encode(`event: error\ndata: ${JSON.stringify({ error: 'Order not found' })}\n\n`)
)
controller.close()
return
}
// Build query for new logs
const query: Parameters<typeof prisma.provisioningLog.findMany>[0] = {
where: {
orderId,
...(lastLogId ? { id: { gt: lastLogId } } : {}),
},
orderBy: { timestamp: 'asc' as const },
take: 50,
}
const newLogs = await prisma.provisioningLog.findMany(query)
if (newLogs.length > 0) {
// Update last seen log ID
lastLogId = newLogs[newLogs.length - 1].id
// Send each log as an event
for (const log of newLogs) {
controller.enqueue(
encoder.encode(`event: log\ndata: ${JSON.stringify({
id: log.id,
level: log.level,
step: log.step,
message: log.message,
timestamp: log.timestamp.toISOString(),
})}\n\n`)
)
}
}
// Send status update
controller.enqueue(
encoder.encode(`event: status\ndata: ${JSON.stringify({ status: currentOrder.status })}\n\n`)
)
// Check if provisioning is complete
const terminalStatuses: OrderStatus[] = [
OrderStatus.FULFILLED,
OrderStatus.EMAIL_CONFIGURED,
OrderStatus.FAILED,
]
if (terminalStatuses.includes(currentOrder.status)) {
controller.enqueue(
encoder.encode(`event: complete\ndata: ${JSON.stringify({
status: currentOrder.status,
success: currentOrder.status !== OrderStatus.FAILED
})}\n\n`)
)
controller.close()
return
}
// Continue polling if still provisioning
setTimeout(poll, 2000) // Poll every 2 seconds
} catch (err) {
console.error('SSE polling error:', err)
controller.enqueue(
encoder.encode(`event: error\ndata: ${JSON.stringify({ error: 'Polling error' })}\n\n`)
)
controller.close()
}
}
// Start polling
poll()
},
cancel() {
isActive = false
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx buffering
},
})
} catch (error) {
console.error('Error setting up SSE stream:', error)
return new Response('Internal Server Error', { status: 500 })
}
}

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { OrderStatus } from '@prisma/client'
import { jobService } from '@/lib/services/job-service'
/**
* POST /api/v1/admin/orders/[id]/provision
* Trigger provisioning for an order
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: orderId } = await params
// Check if order exists and is ready for provisioning
const order = await prisma.order.findUnique({
where: { id: orderId },
})
if (!order) {
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
}
// Validate order status - can only provision from DNS_READY or FAILED
const validStatuses: OrderStatus[] = [OrderStatus.DNS_READY, OrderStatus.FAILED]
if (!validStatuses.includes(order.status)) {
return NextResponse.json(
{
error: `Cannot provision order in status ${order.status}. Must be DNS_READY or FAILED.`,
},
{ status: 400 }
)
}
// Validate server credentials
if (!order.serverIp || !order.serverPasswordEncrypted) {
return NextResponse.json(
{ error: 'Server credentials not configured' },
{ status: 400 }
)
}
// Create provisioning job
const result = await jobService.createJobForOrder(orderId)
const { jobId } = JSON.parse(result)
return NextResponse.json({
success: true,
message: 'Provisioning job created',
jobId,
})
} catch (error) {
console.error('Error triggering provisioning:', error)
return NextResponse.json(
{ error: 'Failed to trigger provisioning' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,175 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { OrderStatus } from '@prisma/client'
import crypto from 'crypto'
/**
* GET /api/v1/admin/orders/[id]
* Get order details
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: orderId } = await params
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
company: true,
},
},
provisioningLogs: {
orderBy: { timestamp: 'desc' },
take: 100,
},
jobs: {
orderBy: { createdAt: 'desc' },
take: 5,
select: {
id: true,
status: true,
attempt: true,
maxAttempts: true,
createdAt: true,
completedAt: true,
error: true,
},
},
},
})
if (!order) {
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
}
return NextResponse.json(order)
} catch (error) {
console.error('Error getting order:', error)
return NextResponse.json(
{ error: 'Failed to get order' },
{ status: 500 }
)
}
}
/**
* PATCH /api/v1/admin/orders/[id]
* Update order (status, server credentials, etc.)
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id: orderId } = await params
const body = await request.json()
// Find existing order
const existingOrder = await prisma.order.findUnique({
where: { id: orderId },
})
if (!existingOrder) {
return NextResponse.json({ error: 'Order not found' }, { status: 404 })
}
// Build update data
const updateData: {
status?: OrderStatus
serverIp?: string
serverPasswordEncrypted?: string
sshPort?: number
serverReadyAt?: Date
failureReason?: string
} = {}
// Handle status update
if (body.status) {
updateData.status = body.status as OrderStatus
}
// Handle server credentials
if (body.serverIp) {
updateData.serverIp = body.serverIp
}
if (body.serverPassword) {
// Encrypt the password before storing
// TODO: Use proper encryption with environment-based key
const encrypted = encryptPassword(body.serverPassword)
updateData.serverPasswordEncrypted = encrypted
}
if (body.sshPort) {
updateData.sshPort = body.sshPort
}
// If server credentials are being set and status is AWAITING_SERVER, move to SERVER_READY
if (
(body.serverIp || body.serverPassword) &&
existingOrder.status === OrderStatus.AWAITING_SERVER
) {
updateData.status = OrderStatus.SERVER_READY
updateData.serverReadyAt = new Date()
}
// Update order
const order = await prisma.order.update({
where: { id: orderId },
data: updateData,
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
})
return NextResponse.json(order)
} catch (error) {
console.error('Error updating order:', error)
return NextResponse.json(
{ error: 'Failed to update order' },
{ status: 500 }
)
}
}
// Helper function to encrypt password
function encryptPassword(password: string): string {
// TODO: Implement proper encryption using environment-based key
// For now, use a simple encryption for development
const key = crypto.scryptSync(
process.env.ENCRYPTION_KEY || 'dev-key-change-in-production',
'salt',
32
)
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
let encrypted = cipher.update(password, 'utf8', 'hex')
encrypted += cipher.final('hex')
return iv.toString('hex') + ':' + encrypted
}

View File

@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { OrderStatus, SubscriptionTier, Prisma } from '@prisma/client'
/**
* GET /api/v1/admin/orders
* List all orders with optional filters
*/
export async function GET(request: NextRequest) {
try {
const session = await auth()
// Check authentication and authorization
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Parse query parameters
const searchParams = request.nextUrl.searchParams
const status = searchParams.get('status') as OrderStatus | null
const tier = searchParams.get('tier')
const search = searchParams.get('search')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '50')
// Build where clause
const where: Prisma.OrderWhereInput = {}
if (status) {
where.status = status
}
if (tier && Object.values(SubscriptionTier).includes(tier as SubscriptionTier)) {
where.tier = tier as SubscriptionTier
}
if (search) {
where.OR = [
{ domain: { contains: search, mode: 'insensitive' } },
{ user: { email: { contains: search, mode: 'insensitive' } } },
]
}
// Get orders with pagination
const [orders, total] = await Promise.all([
prisma.order.findMany({
where,
include: {
user: {
select: {
id: true,
name: true,
email: true,
company: true,
},
},
_count: {
select: { provisioningLogs: true },
},
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.order.count({ where }),
])
return NextResponse.json({
orders,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
} catch (error) {
console.error('Error listing orders:', error)
return NextResponse.json(
{ error: 'Failed to list orders' },
{ status: 500 }
)
}
}
/**
* POST /api/v1/admin/orders
* Create a new order (admin-initiated)
*/
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { userId, domain, tier, tools } = body
if (!userId || !domain || !tier || !tools) {
return NextResponse.json(
{ error: 'userId, domain, tier, and tools are required' },
{ status: 400 }
)
}
// Verify user exists
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Create order
const order = await prisma.order.create({
data: {
userId,
domain,
tier,
tools,
status: OrderStatus.PAYMENT_CONFIRMED,
configJson: { tools, tier, domain },
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
})
return NextResponse.json(order, { status: 201 })
} catch (error) {
console.error('Error creating order:', error)
return NextResponse.json(
{ error: 'Failed to create order' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { OrderStatus, Prisma } from '@prisma/client'
/**
* GET /api/v1/admin/servers
* List all servers (orders with server credentials)
*/
export async function GET(request: NextRequest) {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const searchParams = request.nextUrl.searchParams
const status = searchParams.get('status')
const search = searchParams.get('search')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '50')
// Build where clause - only orders with serverIp are considered servers
const where: Prisma.OrderWhereInput = {
serverIp: { not: null },
}
// Filter by server status (derived from order status)
if (status === 'online') {
where.status = { in: [OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED] }
} else if (status === 'provisioning') {
where.status = { in: [OrderStatus.PROVISIONING, OrderStatus.DNS_READY, OrderStatus.SERVER_READY] }
} else if (status === 'offline') {
where.status = OrderStatus.FAILED
}
// Search by domain or serverIp
if (search) {
where.OR = [
{ domain: { contains: search, mode: 'insensitive' } },
{ serverIp: { contains: search, mode: 'insensitive' } },
{ user: { name: { contains: search, mode: 'insensitive' } } },
{ user: { company: { contains: search, mode: 'insensitive' } } },
]
}
const [orders, total] = await Promise.all([
prisma.order.findMany({
where,
select: {
id: true,
domain: true,
tier: true,
status: true,
serverIp: true,
sshPort: true,
tools: true,
createdAt: true,
serverReadyAt: true,
completedAt: true,
user: {
select: {
id: true,
name: true,
email: true,
company: true,
},
},
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.order.count({ where }),
])
// Map orders to server format
const servers = orders.map((order) => ({
id: order.id,
domain: order.domain,
tier: order.tier,
orderStatus: order.status,
serverStatus: deriveServerStatus(order.status),
serverIp: order.serverIp,
sshPort: order.sshPort,
tools: order.tools,
createdAt: order.createdAt,
serverReadyAt: order.serverReadyAt,
completedAt: order.completedAt,
customer: order.user,
}))
return NextResponse.json({
servers,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
} catch (error) {
console.error('Error listing servers:', error)
return NextResponse.json(
{ error: 'Failed to list servers' },
{ status: 500 }
)
}
}
function deriveServerStatus(orderStatus: OrderStatus): 'online' | 'provisioning' | 'offline' | 'pending' {
switch (orderStatus) {
case OrderStatus.FULFILLED:
case OrderStatus.EMAIL_CONFIGURED:
return 'online'
case OrderStatus.PROVISIONING:
case OrderStatus.DNS_READY:
case OrderStatus.SERVER_READY:
return 'provisioning'
case OrderStatus.FAILED:
return 'offline'
default:
return 'pending'
}
}

View File

@@ -0,0 +1,170 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { OrderStatus, SubscriptionPlan, SubscriptionTier, UserStatus, SubscriptionStatus } from '@prisma/client'
/**
* GET /api/v1/admin/stats
* Get dashboard statistics
*/
export async function GET() {
try {
const session = await auth()
if (!session || session.user.userType !== 'staff') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get order counts by status
const ordersByStatus = await prisma.order.groupBy({
by: ['status'],
_count: { status: true },
})
const orderStatusCounts: Record<OrderStatus, number> = Object.fromEntries(
Object.values(OrderStatus).map((status) => [status, 0])
) as Record<OrderStatus, number>
ordersByStatus.forEach((item) => {
orderStatusCounts[item.status] = item._count.status
})
// Calculate order statistics
const pendingStatuses = [
OrderStatus.PAYMENT_CONFIRMED,
OrderStatus.AWAITING_SERVER,
OrderStatus.SERVER_READY,
OrderStatus.DNS_PENDING,
OrderStatus.DNS_READY,
]
const inProgressStatuses = [OrderStatus.PROVISIONING]
const completedStatuses = [OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED]
const failedStatuses = [OrderStatus.FAILED]
const ordersPending = pendingStatuses.reduce(
(sum, status) => sum + orderStatusCounts[status],
0
)
const ordersInProgress = inProgressStatuses.reduce(
(sum, status) => sum + orderStatusCounts[status],
0
)
const ordersCompleted = completedStatuses.reduce(
(sum, status) => sum + orderStatusCounts[status],
0
)
const ordersFailed = failedStatuses.reduce(
(sum, status) => sum + orderStatusCounts[status],
0
)
// Get customer counts by status
const customersByStatus = await prisma.user.groupBy({
by: ['status'],
_count: { status: true },
})
const customerStatusCounts: Record<UserStatus, number> = Object.fromEntries(
Object.values(UserStatus).map((status) => [status, 0])
) as Record<UserStatus, number>
customersByStatus.forEach((item) => {
customerStatusCounts[item.status] = item._count.status
})
// Get subscription counts
const subscriptionsByPlan = await prisma.subscription.groupBy({
by: ['plan'],
_count: { plan: true },
})
const subscriptionsByTier = await prisma.subscription.groupBy({
by: ['tier'],
_count: { tier: true },
})
const subscriptionsByStatusRaw = await prisma.subscription.groupBy({
by: ['status'],
_count: { status: true },
})
const planCounts: Record<SubscriptionPlan, number> = Object.fromEntries(
Object.values(SubscriptionPlan).map((plan) => [plan, 0])
) as Record<SubscriptionPlan, number>
subscriptionsByPlan.forEach((item) => {
planCounts[item.plan] = item._count.plan
})
const tierCounts: Record<SubscriptionTier, number> = Object.fromEntries(
Object.values(SubscriptionTier).map((tier) => [tier, 0])
) as Record<SubscriptionTier, number>
subscriptionsByTier.forEach((item) => {
tierCounts[item.tier] = item._count.tier
})
const subscriptionStatusCounts: Record<SubscriptionStatus, number> = Object.fromEntries(
Object.values(SubscriptionStatus).map((status) => [status, 0])
) as Record<SubscriptionStatus, number>
subscriptionsByStatusRaw.forEach((item) => {
subscriptionStatusCounts[item.status] = item._count.status
})
// Get recent orders
const recentOrders = await prisma.order.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
name: true,
email: true,
company: true,
},
},
_count: {
select: { provisioningLogs: true },
},
},
})
// Calculate totals
const ordersTotal = Object.values(orderStatusCounts).reduce((a, b) => a + b, 0)
const customersTotal = Object.values(customerStatusCounts).reduce((a, b) => a + b, 0)
const subscriptionsTotal = Object.values(planCounts).reduce((a, b) => a + b, 0)
return NextResponse.json({
orders: {
total: ordersTotal,
pending: ordersPending,
inProgress: ordersInProgress,
completed: ordersCompleted,
failed: ordersFailed,
byStatus: orderStatusCounts,
},
customers: {
total: customersTotal,
active: customerStatusCounts[UserStatus.ACTIVE],
suspended: customerStatusCounts[UserStatus.SUSPENDED],
pending: customerStatusCounts[UserStatus.PENDING_VERIFICATION],
},
subscriptions: {
total: subscriptionsTotal,
trial: subscriptionStatusCounts[SubscriptionStatus.TRIAL],
active: subscriptionStatusCounts[SubscriptionStatus.ACTIVE],
byPlan: planCounts,
byTier: tierCounts,
},
recentOrders,
})
} catch (error) {
console.error('Error getting dashboard stats:', error)
return NextResponse.json(
{ error: 'Failed to get dashboard stats' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server'
import { jobService } from '@/lib/services/job-service'
// Verify runner token from header
async function verifyRunnerAuth(
request: NextRequest,
jobId: string
): Promise<{ authorized: boolean; error?: string }> {
const runnerToken = request.headers.get('X-Runner-Token')
if (!runnerToken) {
return { authorized: false, error: 'Missing X-Runner-Token header' }
}
const isValid = await jobService.verifyRunnerToken(jobId, runnerToken)
if (!isValid) {
return { authorized: false, error: 'Invalid runner token' }
}
return { authorized: true }
}
/**
* GET /api/v1/jobs/[id]/logs
* Get job logs
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: jobId } = await params
const auth = await verifyRunnerAuth(request, jobId)
if (!auth.authorized) {
return NextResponse.json({ error: auth.error }, { status: 401 })
}
// Optional: filter logs since a timestamp
const sinceParam = request.nextUrl.searchParams.get('since')
const since = sinceParam ? new Date(sinceParam) : undefined
const logs = await jobService.getLogs(jobId, since)
return NextResponse.json({ logs })
} catch (error) {
console.error('Error getting job logs:', error)
return NextResponse.json(
{ error: 'Failed to get job logs' },
{ status: 500 }
)
}
}
/**
* POST /api/v1/jobs/[id]/logs
* Add a log entry
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: jobId } = await params
const auth = await verifyRunnerAuth(request, jobId)
if (!auth.authorized) {
return NextResponse.json({ error: auth.error }, { status: 401 })
}
const body = await request.json()
const { level, message, step, progress } = body
if (!level || !message) {
return NextResponse.json(
{ error: 'level and message are required' },
{ status: 400 }
)
}
if (!['info', 'warn', 'error'].includes(level)) {
return NextResponse.json(
{ error: 'level must be info, warn, or error' },
{ status: 400 }
)
}
await jobService.addLog(
jobId,
level as 'info' | 'warn' | 'error',
message,
step,
progress
)
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error adding job log:', error)
return NextResponse.json(
{ error: 'Failed to add job log' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server'
import { jobService } from '@/lib/services/job-service'
// Verify runner token from header
async function verifyRunnerAuth(
request: NextRequest,
jobId: string
): Promise<{ authorized: boolean; error?: string }> {
const runnerToken = request.headers.get('X-Runner-Token')
if (!runnerToken) {
return { authorized: false, error: 'Missing X-Runner-Token header' }
}
const isValid = await jobService.verifyRunnerToken(jobId, runnerToken)
if (!isValid) {
return { authorized: false, error: 'Invalid runner token' }
}
return { authorized: true }
}
/**
* GET /api/v1/jobs/[id]
* Get job status
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: jobId } = await params
const auth = await verifyRunnerAuth(request, jobId)
if (!auth.authorized) {
return NextResponse.json({ error: auth.error }, { status: 401 })
}
const status = await jobService.getJobStatus(jobId)
if (!status) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
}
return NextResponse.json(status)
} catch (error) {
console.error('Error getting job status:', error)
return NextResponse.json(
{ error: 'Failed to get job status' },
{ status: 500 }
)
}
}
/**
* PATCH /api/v1/jobs/[id]
* Update job status (complete or fail)
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: jobId } = await params
const auth = await verifyRunnerAuth(request, jobId)
if (!auth.authorized) {
return NextResponse.json({ error: auth.error }, { status: 401 })
}
const body = await request.json()
const { status, error, result } = body
if (status === 'completed') {
await jobService.completeJob(jobId, result)
return NextResponse.json({ success: true, message: 'Job completed successfully' })
} else if (status === 'failed') {
if (!error) {
return NextResponse.json(
{ error: 'Error message required for failed status' },
{ status: 400 }
)
}
const retryInfo = await jobService.failJob(jobId, error)
return NextResponse.json({
success: true,
willRetry: retryInfo.willRetry,
nextRetryAt: retryInfo.nextRetryAt?.toISOString(),
})
} else {
return NextResponse.json(
{ error: 'Invalid status. Must be "completed" or "failed"' },
{ status: 400 }
)
}
} catch (error) {
console.error('Error updating job status:', error)
return NextResponse.json(
{ error: 'Failed to update job status' },
{ status: 500 }
)
}
}

59
src/app/globals.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

25
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { Providers } from '@/components/providers'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'LetsBe Hub',
description: 'Central platform for LetsBe Cloud management',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
)
}

17
src/app/page.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
export default async function HomePage() {
const session = await auth()
if (!session) {
redirect('/login')
}
if (session.user.userType === 'staff') {
redirect('/admin')
}
// Customer users get redirected to their dashboard (future)
redirect('/login')
}

View File

@@ -0,0 +1,473 @@
'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useCreateOrder } from '@/hooks/use-orders'
import { useCustomers } from '@/hooks/use-customers'
import { SubscriptionTier } from '@/types/api'
import {
Loader2,
Search,
User,
Building2,
Check,
ChevronRight,
ChevronLeft,
Globe,
Package,
Layers,
AlertCircle,
} from 'lucide-react'
interface CreateOrderDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess?: () => void
}
type Step = 'customer' | 'domain' | 'tier' | 'tools' | 'review'
const TOOLS_BY_TIER: Record<SubscriptionTier, string[]> = {
HUB_DASHBOARD: ['nextcloud', 'keycloak', 'minio', 'poste'],
ADVANCED: ['nextcloud', 'keycloak', 'minio', 'poste', 'n8n', 'filebrowser', 'portainer', 'grafana'],
}
const TOOL_LABELS: Record<string, { name: string; description: string }> = {
nextcloud: { name: 'Nextcloud', description: 'File sync & collaboration' },
keycloak: { name: 'Keycloak', description: 'Identity & access management' },
minio: { name: 'MinIO', description: 'S3-compatible object storage' },
poste: { name: 'Poste.io', description: 'Email server' },
n8n: { name: 'n8n', description: 'Workflow automation' },
filebrowser: { name: 'File Browser', description: 'Web-based file manager' },
portainer: { name: 'Portainer', description: 'Container management' },
grafana: { name: 'Grafana', description: 'Monitoring dashboards' },
}
export function CreateOrderDialog({
open,
onOpenChange,
onSuccess,
}: CreateOrderDialogProps) {
const [step, setStep] = useState<Step>('customer')
const [customerSearch, setCustomerSearch] = useState('')
const [selectedCustomer, setSelectedCustomer] = useState<{
id: string
name: string | null
email: string
company: string | null
} | null>(null)
const [domain, setDomain] = useState('')
const [tier, setTier] = useState<SubscriptionTier>(SubscriptionTier.HUB_DASHBOARD)
const [selectedTools, setSelectedTools] = useState<string[]>([])
const [error, setError] = useState<string | null>(null)
const { data: customersData, isLoading: isLoadingCustomers } = useCustomers({
search: customerSearch || undefined,
limit: 10,
})
const createOrder = useCreateOrder()
// Reset state when dialog opens/closes
useEffect(() => {
if (!open) {
setTimeout(() => {
setStep('customer')
setCustomerSearch('')
setSelectedCustomer(null)
setDomain('')
setTier(SubscriptionTier.HUB_DASHBOARD)
setSelectedTools([])
setError(null)
}, 300)
}
}, [open])
// Auto-select default tools when tier changes
useEffect(() => {
setSelectedTools(TOOLS_BY_TIER[tier])
}, [tier])
const validateDomain = (domain: string): boolean => {
const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
return domainRegex.test(domain)
}
const handleNext = () => {
setError(null)
if (step === 'customer') {
if (!selectedCustomer) {
setError('Please select a customer')
return
}
setStep('domain')
} else if (step === 'domain') {
if (!domain) {
setError('Please enter a domain name')
return
}
if (!validateDomain(domain)) {
setError('Please enter a valid domain name')
return
}
setStep('tier')
} else if (step === 'tier') {
setStep('tools')
} else if (step === 'tools') {
if (selectedTools.length === 0) {
setError('Please select at least one tool')
return
}
setStep('review')
}
}
const handleBack = () => {
setError(null)
if (step === 'domain') setStep('customer')
else if (step === 'tier') setStep('domain')
else if (step === 'tools') setStep('tier')
else if (step === 'review') setStep('tools')
}
const handleSubmit = async () => {
if (!selectedCustomer) return
try {
await createOrder.mutateAsync({
userId: selectedCustomer.id,
domain,
tier,
tools: selectedTools,
})
onOpenChange(false)
onSuccess?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create order')
}
}
const toggleTool = (tool: string) => {
setSelectedTools((prev) =>
prev.includes(tool) ? prev.filter((t) => t !== tool) : [...prev, tool]
)
}
const steps: { key: Step; label: string; icon: React.ReactNode }[] = [
{ key: 'customer', label: 'Customer', icon: <User className="h-4 w-4" /> },
{ key: 'domain', label: 'Domain', icon: <Globe className="h-4 w-4" /> },
{ key: 'tier', label: 'Tier', icon: <Layers className="h-4 w-4" /> },
{ key: 'tools', label: 'Tools', icon: <Package className="h-4 w-4" /> },
{ key: 'review', label: 'Review', icon: <Check className="h-4 w-4" /> },
]
const currentStepIndex = steps.findIndex((s) => s.key === step)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create New Order</DialogTitle>
<DialogDescription>
Create a new infrastructure order for a customer
</DialogDescription>
</DialogHeader>
{/* Steps indicator */}
<div className="flex items-center justify-between px-2 py-4">
{steps.map((s, index) => (
<div key={s.key} className="flex items-center">
<div
className={`flex items-center justify-center h-8 w-8 rounded-full border-2 transition-colors ${
index <= currentStepIndex
? 'border-primary bg-primary text-white'
: 'border-gray-300 text-gray-400'
}`}
>
{s.icon}
</div>
{index < steps.length - 1 && (
<div
className={`w-12 h-0.5 mx-2 ${
index < currentStepIndex ? 'bg-primary' : 'bg-gray-300'
}`}
/>
)}
</div>
))}
</div>
{/* Step content */}
<div className="min-h-[300px] py-4">
{step === 'customer' && (
<div className="space-y-4">
<div>
<Label>Search Customer</Label>
<div className="relative mt-2">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search by name, email, or company..."
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="max-h-64 overflow-y-auto space-y-2">
{isLoadingCustomers ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : customersData?.customers && customersData.customers.length > 0 ? (
customersData.customers.map((customer) => (
<button
key={customer.id}
onClick={() => setSelectedCustomer(customer)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
selectedCustomer?.id === customer.id
? 'border-primary bg-primary/5'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}`}
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<User className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 text-left">
<p className="font-medium">{customer.name || customer.email}</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{customer.email}</span>
{customer.company && (
<>
<span>-</span>
<span className="flex items-center gap-1">
<Building2 className="h-3 w-3" />
{customer.company}
</span>
</>
)}
</div>
</div>
{selectedCustomer?.id === customer.id && (
<Check className="h-5 w-5 text-primary" />
)}
</button>
))
) : (
<div className="text-center py-8 text-muted-foreground">
{customerSearch ? 'No customers found' : 'Start typing to search customers'}
</div>
)}
</div>
</div>
)}
{step === 'domain' && (
<div className="space-y-4">
<div>
<Label htmlFor="domain">Domain Name</Label>
<Input
id="domain"
type="text"
placeholder="example.com"
value={domain}
onChange={(e) => setDomain(e.target.value.toLowerCase())}
className="mt-2"
/>
<p className="text-sm text-muted-foreground mt-2">
Enter the primary domain for this deployment. Services will be configured as subdomains.
</p>
</div>
{selectedCustomer && (
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm font-medium">Customer</p>
<p className="text-sm text-muted-foreground">
{selectedCustomer.name || selectedCustomer.email}
{selectedCustomer.company && ` - ${selectedCustomer.company}`}
</p>
</div>
)}
</div>
)}
{step === 'tier' && (
<div className="space-y-4">
<Label>Select Tier</Label>
<div className="grid gap-4">
<button
onClick={() => setTier(SubscriptionTier.HUB_DASHBOARD)}
className={`p-4 rounded-lg border-2 text-left transition-colors ${
tier === SubscriptionTier.HUB_DASHBOARD
? 'border-primary bg-primary/5'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="font-semibold">Hub Dashboard</p>
<p className="text-sm text-muted-foreground">
Essential tools: Nextcloud, Keycloak, MinIO, Poste
</p>
</div>
{tier === SubscriptionTier.HUB_DASHBOARD && (
<Check className="h-5 w-5 text-primary" />
)}
</div>
</button>
<button
onClick={() => setTier(SubscriptionTier.ADVANCED)}
className={`p-4 rounded-lg border-2 text-left transition-colors ${
tier === SubscriptionTier.ADVANCED
? 'border-primary bg-primary/5'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="font-semibold">Advanced</p>
<p className="text-sm text-muted-foreground">
All tools including n8n, File Browser, Portainer, Grafana
</p>
</div>
{tier === SubscriptionTier.ADVANCED && (
<Check className="h-5 w-5 text-primary" />
)}
</div>
</button>
</div>
</div>
)}
{step === 'tools' && (
<div className="space-y-4">
<Label>Select Tools</Label>
<p className="text-sm text-muted-foreground">
Choose which tools to deploy. All tools are pre-selected based on your tier.
</p>
<div className="grid gap-2 max-h-64 overflow-y-auto">
{TOOLS_BY_TIER[tier].map((tool) => {
const toolInfo = TOOL_LABELS[tool]
const isSelected = selectedTools.includes(tool)
return (
<button
key={tool}
onClick={() => toggleTool(tool)}
className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div
className={`h-5 w-5 rounded border-2 flex items-center justify-center ${
isSelected ? 'border-primary bg-primary' : 'border-gray-300'
}`}
>
{isSelected && <Check className="h-3 w-3 text-white" />}
</div>
<div className="flex-1 text-left">
<p className="font-medium">{toolInfo.name}</p>
<p className="text-sm text-muted-foreground">{toolInfo.description}</p>
</div>
</button>
)
})}
</div>
</div>
)}
{step === 'review' && (
<div className="space-y-4">
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Customer</p>
<p className="font-medium">
{selectedCustomer?.name || selectedCustomer?.email}
{selectedCustomer?.company && ` - ${selectedCustomer.company}`}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Domain</p>
<p className="font-medium">{domain}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Tier</p>
<p className="font-medium capitalize">
{tier.replace('_', ' ').toLowerCase()}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Tools ({selectedTools.length})</p>
<div className="flex flex-wrap gap-2 mt-1">
{selectedTools.map((tool) => (
<span
key={tool}
className="px-2 py-1 text-xs font-medium bg-primary/10 text-primary rounded"
>
{TOOL_LABELS[tool]?.name || tool}
</span>
))}
</div>
</div>
</div>
</div>
)}
</div>
{/* Error message */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
)}
<DialogFooter className="mt-4">
<div className="flex w-full justify-between">
<Button
variant="outline"
onClick={handleBack}
disabled={step === 'customer' || createOrder.isPending}
>
<ChevronLeft className="h-4 w-4 mr-2" />
Back
</Button>
{step === 'review' ? (
<Button onClick={handleSubmit} disabled={createOrder.isPending}>
{createOrder.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
Create Order
<Check className="h-4 w-4 ml-2" />
</>
)}
</Button>
) : (
<Button onClick={handleNext}>
Next
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,50 @@
'use client'
import { useSession } from 'next-auth/react'
import { Bell, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export function AdminHeader() {
const { data: session } = useSession()
return (
<header className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-4 border-b bg-background px-6">
{/* Search */}
<div className="flex flex-1 items-center gap-4">
<div className="relative w-full max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search orders, customers..."
className="pl-9"
/>
</div>
</div>
{/* Right side */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
<span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
3
</span>
</Button>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center">
<span className="text-sm font-medium text-primary-foreground">
{session?.user?.name?.charAt(0)?.toUpperCase() || 'A'}
</span>
</div>
<div className="hidden sm:block">
<p className="text-sm font-medium">{session?.user?.name || 'Admin'}</p>
<p className="text-xs text-muted-foreground">
{session?.user?.role === 'ADMIN' ? 'Administrator' : 'Support'}
</p>
</div>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,333 @@
'use client'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
Globe,
User,
Clock,
Play,
RefreshCw,
AlertTriangle,
ChevronRight,
Server,
Mail,
CheckCircle,
XCircle,
} from 'lucide-react'
// Order status type
export type OrderStatus =
| 'PAYMENT_CONFIRMED'
| 'AWAITING_SERVER'
| 'SERVER_READY'
| 'DNS_PENDING'
| 'DNS_READY'
| 'PROVISIONING'
| 'FULFILLED'
| 'EMAIL_CONFIGURED'
| 'FAILED'
// Order tier type
export type OrderTier = 'hub-dashboard' | 'control-panel'
// Order interface
export interface Order {
id: string
domain: string
customerName: string
customerEmail: string
tier: OrderTier
status: OrderStatus
createdAt: Date
updatedAt: Date
serverId?: string
serverIp?: string
failureReason?: string
}
// Status configuration
const statusConfig: Record<
OrderStatus,
{
label: string
color: string
bgColor: string
borderColor: string
}
> = {
PAYMENT_CONFIRMED: {
label: 'Payment Confirmed',
color: 'text-blue-700',
bgColor: 'bg-blue-50',
borderColor: 'border-blue-200',
},
AWAITING_SERVER: {
label: 'Awaiting Server',
color: 'text-yellow-700',
bgColor: 'bg-yellow-50',
borderColor: 'border-yellow-200',
},
SERVER_READY: {
label: 'Server Ready',
color: 'text-purple-700',
bgColor: 'bg-purple-50',
borderColor: 'border-purple-200',
},
DNS_PENDING: {
label: 'DNS Pending',
color: 'text-orange-700',
bgColor: 'bg-orange-50',
borderColor: 'border-orange-200',
},
DNS_READY: {
label: 'DNS Ready',
color: 'text-cyan-700',
bgColor: 'bg-cyan-50',
borderColor: 'border-cyan-200',
},
PROVISIONING: {
label: 'Provisioning',
color: 'text-indigo-700',
bgColor: 'bg-indigo-50',
borderColor: 'border-indigo-200',
},
FULFILLED: {
label: 'Fulfilled',
color: 'text-green-700',
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
},
EMAIL_CONFIGURED: {
label: 'Complete',
color: 'text-emerald-700',
bgColor: 'bg-emerald-50',
borderColor: 'border-emerald-200',
},
FAILED: {
label: 'Failed',
color: 'text-red-700',
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
},
}
// Tier configuration
const tierConfig: Record<OrderTier, { label: string; color: string }> = {
'hub-dashboard': {
label: 'Hub Dashboard',
color: 'bg-blue-100 text-blue-800',
},
'control-panel': {
label: 'Control Panel',
color: 'bg-purple-100 text-purple-800',
},
}
// Action button configuration based on status
function getActionConfig(status: OrderStatus): {
label: string
icon: React.ElementType
variant: 'default' | 'outline' | 'secondary' | 'destructive'
} | null {
switch (status) {
case 'PAYMENT_CONFIRMED':
return { label: 'Provision Server', icon: Server, variant: 'default' }
case 'AWAITING_SERVER':
return { label: 'Check Status', icon: RefreshCw, variant: 'outline' }
case 'SERVER_READY':
return { label: 'Setup DNS', icon: Globe, variant: 'default' }
case 'DNS_PENDING':
return { label: 'Verify DNS', icon: RefreshCw, variant: 'outline' }
case 'DNS_READY':
return { label: 'Start Provisioning', icon: Play, variant: 'default' }
case 'PROVISIONING':
return { label: 'View Progress', icon: RefreshCw, variant: 'outline' }
case 'FULFILLED':
return { label: 'Configure Email', icon: Mail, variant: 'default' }
case 'EMAIL_CONFIGURED':
return null // No action needed - complete
case 'FAILED':
return { label: 'Retry', icon: RefreshCw, variant: 'destructive' }
default:
return null
}
}
// Helper function to format time since creation
function formatTimeSince(date: Date): string {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins}m ago`
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays < 7) return `${diffDays}d ago`
return date.toLocaleDateString()
}
interface OrderCardProps {
order: Order
onAction?: (order: Order, action: string) => void
onViewDetails?: (order: Order) => void
}
export function OrderCard({ order, onAction, onViewDetails }: OrderCardProps) {
const config = statusConfig[order.status]
const tierConf = tierConfig[order.tier]
const actionConfig = getActionConfig(order.status)
const handleAction = () => {
if (onAction && actionConfig) {
onAction(order, actionConfig.label)
}
}
const handleViewDetails = () => {
if (onViewDetails) {
onViewDetails(order)
}
}
return (
<Card
className={cn(
'transition-all duration-200 hover:shadow-md cursor-pointer border-l-4',
config.borderColor
)}
onClick={handleViewDetails}
>
<CardContent className="p-4">
{/* Domain and tier */}
<div className="flex items-start justify-between gap-2 mb-3">
<div className="flex items-center gap-2 min-w-0">
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm truncate">{order.domain}</span>
</div>
<span
className={cn(
'text-xs font-medium px-2 py-0.5 rounded-full shrink-0',
tierConf.color
)}
>
{tierConf.label}
</span>
</div>
{/* Customer */}
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<User className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{order.customerName}</span>
</div>
{/* Time since creation */}
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-3">
<Clock className="h-3 w-3 shrink-0" />
<span>{formatTimeSince(order.createdAt)}</span>
</div>
{/* Server info (if available) */}
{order.serverIp && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-3">
<Server className="h-3 w-3 shrink-0" />
<span className="font-mono">{order.serverIp}</span>
</div>
)}
{/* Failure reason (if failed) */}
{order.status === 'FAILED' && order.failureReason && (
<div className="flex items-start gap-2 text-xs text-red-600 bg-red-50 rounded p-2 mb-3">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span className="line-clamp-2">{order.failureReason}</span>
</div>
)}
{/* Status indicator */}
<div
className={cn(
'flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-md w-fit',
config.bgColor,
config.color
)}
>
{order.status === 'EMAIL_CONFIGURED' ? (
<CheckCircle className="h-3 w-3" />
) : order.status === 'FAILED' ? (
<XCircle className="h-3 w-3" />
) : null}
{config.label}
</div>
</CardContent>
{/* Action buttons */}
{(actionConfig || onViewDetails) && (
<CardFooter className="p-4 pt-0 flex gap-2">
{actionConfig && (
<Button
size="sm"
variant={actionConfig.variant}
className="flex-1"
onClick={(e) => {
e.stopPropagation()
handleAction()
}}
>
<actionConfig.icon className="h-3.5 w-3.5 mr-1.5" />
{actionConfig.label}
</Button>
)}
<Button
size="sm"
variant="ghost"
className="shrink-0"
onClick={(e) => {
e.stopPropagation()
handleViewDetails()
}}
>
<ChevronRight className="h-4 w-4" />
</Button>
</CardFooter>
)}
</Card>
)
}
// Compact version for smaller displays
export function OrderCardCompact({
order,
onAction,
onViewDetails,
}: OrderCardProps) {
const config = statusConfig[order.status]
return (
<div
className={cn(
'flex items-center justify-between p-3 rounded-lg border bg-card hover:shadow-sm transition-all cursor-pointer',
config.borderColor,
'border-l-4'
)}
onClick={() => onViewDetails?.(order)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{order.domain}</p>
<p className="text-xs text-muted-foreground truncate">
{order.customerName}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted-foreground">
{formatTimeSince(order.createdAt)}
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</div>
)
}

View File

@@ -0,0 +1,400 @@
'use client'
import { useMemo } from 'react'
import { cn } from '@/lib/utils'
import { OrderCard, type Order, type OrderStatus } from './order-card'
import {
CreditCard,
Server,
HardDrive,
Globe,
CheckCircle2,
Zap,
Package,
Mail,
AlertTriangle,
} from 'lucide-react'
// Column configuration
interface ColumnConfig {
id: OrderStatus
title: string
icon: React.ElementType
color: string
bgColor: string
borderColor: string
}
const columns: ColumnConfig[] = [
{
id: 'PAYMENT_CONFIRMED',
title: 'Payment Confirmed',
icon: CreditCard,
color: 'text-blue-600',
bgColor: 'bg-blue-50',
borderColor: 'border-blue-200',
},
{
id: 'AWAITING_SERVER',
title: 'Awaiting Server',
icon: Server,
color: 'text-yellow-600',
bgColor: 'bg-yellow-50',
borderColor: 'border-yellow-200',
},
{
id: 'SERVER_READY',
title: 'Server Ready',
icon: HardDrive,
color: 'text-purple-600',
bgColor: 'bg-purple-50',
borderColor: 'border-purple-200',
},
{
id: 'DNS_PENDING',
title: 'DNS Pending',
icon: Globe,
color: 'text-orange-600',
bgColor: 'bg-orange-50',
borderColor: 'border-orange-200',
},
{
id: 'DNS_READY',
title: 'DNS Ready',
icon: CheckCircle2,
color: 'text-cyan-600',
bgColor: 'bg-cyan-50',
borderColor: 'border-cyan-200',
},
{
id: 'PROVISIONING',
title: 'Provisioning',
icon: Zap,
color: 'text-indigo-600',
bgColor: 'bg-indigo-50',
borderColor: 'border-indigo-200',
},
{
id: 'FULFILLED',
title: 'Fulfilled',
icon: Package,
color: 'text-green-600',
bgColor: 'bg-green-50',
borderColor: 'border-green-200',
},
{
id: 'EMAIL_CONFIGURED',
title: 'Complete',
icon: Mail,
color: 'text-emerald-600',
bgColor: 'bg-emerald-50',
borderColor: 'border-emerald-200',
},
]
// Failed column is separate
const failedColumn: ColumnConfig = {
id: 'FAILED',
title: 'Failed',
icon: AlertTriangle,
color: 'text-red-600',
bgColor: 'bg-red-50',
borderColor: 'border-red-200',
}
interface KanbanColumnProps {
column: ColumnConfig
orders: Order[]
onAction?: (order: Order, action: string) => void
onViewDetails?: (order: Order) => void
}
function KanbanColumn({
column,
orders,
onAction,
onViewDetails,
}: KanbanColumnProps) {
const Icon = column.icon
return (
<div className="flex flex-col w-72 shrink-0">
{/* Column header */}
<div
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-t-lg border-b-2',
column.bgColor,
column.borderColor
)}
>
<Icon className={cn('h-4 w-4', column.color)} />
<h3 className={cn('font-medium text-sm', column.color)}>
{column.title}
</h3>
<span
className={cn(
'ml-auto text-xs font-medium px-2 py-0.5 rounded-full',
column.bgColor,
column.color
)}
>
{orders.length}
</span>
</div>
{/* Column content */}
<div
className={cn(
'flex-1 p-2 space-y-3 min-h-[200px] rounded-b-lg border border-t-0',
'bg-gray-50/50',
column.borderColor
)}
>
{orders.length === 0 ? (
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
No orders
</div>
) : (
orders.map((order) => (
<OrderCard
key={order.id}
order={order}
onAction={onAction}
onViewDetails={onViewDetails}
/>
))
)}
</div>
</div>
)
}
interface OrderKanbanProps {
orders: Order[]
onAction?: (order: Order, action: string) => void
onViewDetails?: (order: Order) => void
showFailedColumn?: boolean
}
export function OrderKanban({
orders,
onAction,
onViewDetails,
showFailedColumn = true,
}: OrderKanbanProps) {
// Group orders by status
const ordersByStatus = useMemo(() => {
const grouped: Record<OrderStatus, Order[]> = {
PAYMENT_CONFIRMED: [],
AWAITING_SERVER: [],
SERVER_READY: [],
DNS_PENDING: [],
DNS_READY: [],
PROVISIONING: [],
FULFILLED: [],
EMAIL_CONFIGURED: [],
FAILED: [],
}
orders.forEach((order) => {
if (grouped[order.status]) {
grouped[order.status].push(order)
}
})
// Sort orders within each column by creation date (newest first)
Object.keys(grouped).forEach((status) => {
grouped[status as OrderStatus].sort(
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
)
})
return grouped
}, [orders])
// Count of failed orders
const failedCount = ordersByStatus.FAILED.length
return (
<div className="flex flex-col h-full">
{/* Main Kanban board */}
<div className="flex-1 overflow-x-auto pb-4">
<div className="flex gap-4 min-w-max p-1">
{/* Main pipeline columns */}
{columns.map((column) => (
<KanbanColumn
key={column.id}
column={column}
orders={ordersByStatus[column.id]}
onAction={onAction}
onViewDetails={onViewDetails}
/>
))}
{/* Failed column (separate) */}
{showFailedColumn && (
<div className="flex flex-col">
{/* Separator */}
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 border-t border-dashed border-gray-300" />
</div>
<KanbanColumn
column={failedColumn}
orders={ordersByStatus.FAILED}
onAction={onAction}
onViewDetails={onViewDetails}
/>
</div>
)}
</div>
</div>
{/* Summary bar */}
<div className="flex items-center gap-4 pt-4 border-t bg-white">
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<span className="font-medium">Total:</span>
<span className="text-muted-foreground">{orders.length} orders</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">In Progress:</span>
<span className="text-muted-foreground">
{orders.filter(
(o) =>
o.status !== 'EMAIL_CONFIGURED' && o.status !== 'FAILED'
).length}
</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Completed:</span>
<span className="text-green-600">
{ordersByStatus.EMAIL_CONFIGURED.length}
</span>
</div>
{failedCount > 0 && (
<div className="flex items-center gap-2">
<span className="font-medium">Failed:</span>
<span className="text-red-600">{failedCount}</span>
</div>
)}
</div>
</div>
</div>
)
}
// Compact horizontal pipeline view (for smaller screens)
export function OrderPipelineCompact({
orders,
onViewDetails,
}: {
orders: Order[]
onViewDetails?: (order: Order) => void
}) {
const ordersByStatus = useMemo(() => {
const grouped: Record<OrderStatus, Order[]> = {
PAYMENT_CONFIRMED: [],
AWAITING_SERVER: [],
SERVER_READY: [],
DNS_PENDING: [],
DNS_READY: [],
PROVISIONING: [],
FULFILLED: [],
EMAIL_CONFIGURED: [],
FAILED: [],
}
orders.forEach((order) => {
if (grouped[order.status]) {
grouped[order.status].push(order)
}
})
return grouped
}, [orders])
return (
<div className="space-y-4">
{/* Pipeline summary */}
<div className="flex items-center gap-1 overflow-x-auto pb-2">
{columns.map((column, idx) => {
const Icon = column.icon
const count = ordersByStatus[column.id].length
return (
<div key={column.id} className="flex items-center">
<div
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium',
column.bgColor,
column.color
)}
>
<Icon className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{column.title}</span>
<span className="font-bold">{count}</span>
</div>
{idx < columns.length - 1 && (
<div className="w-4 h-px bg-gray-300 mx-1" />
)}
</div>
)
})}
</div>
{/* Orders list grouped by status */}
<div className="space-y-6">
{columns.map((column) => {
const columnOrders = ordersByStatus[column.id]
if (columnOrders.length === 0) return null
const Icon = column.icon
return (
<div key={column.id}>
<div className="flex items-center gap-2 mb-2">
<Icon className={cn('h-4 w-4', column.color)} />
<h3 className="font-medium text-sm">{column.title}</h3>
<span className="text-xs text-muted-foreground">
({columnOrders.length})
</span>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{columnOrders.map((order) => (
<OrderCard
key={order.id}
order={order}
onViewDetails={onViewDetails}
/>
))}
</div>
</div>
)
})}
{/* Failed orders */}
{ordersByStatus.FAILED.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4 text-red-600" />
<h3 className="font-medium text-sm text-red-600">Failed</h3>
<span className="text-xs text-muted-foreground">
({ordersByStatus.FAILED.length})
</span>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{ordersByStatus.FAILED.map((order) => (
<OrderCard
key={order.id}
order={order}
onViewDetails={onViewDetails}
/>
))}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,109 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
import {
LayoutDashboard,
Users,
ShoppingCart,
BarChart3,
Settings,
LogOut,
Server,
} from 'lucide-react'
import { signOut } from 'next-auth/react'
import { Button } from '@/components/ui/button'
const navigation = [
{
name: 'Dashboard',
href: '/admin',
icon: LayoutDashboard,
},
{
name: 'Orders',
href: '/admin/orders',
icon: ShoppingCart,
},
{
name: 'Customers',
href: '/admin/customers',
icon: Users,
},
{
name: 'Servers',
href: '/admin/servers',
icon: Server,
},
{
name: 'Analytics',
href: '/admin/analytics',
icon: BarChart3,
},
{
name: 'Settings',
href: '/admin/settings',
icon: Settings,
},
]
export function AdminSidebar() {
const pathname = usePathname()
return (
<div className="flex h-full w-64 flex-col bg-gray-900">
{/* Logo */}
<div className="flex h-16 items-center px-6">
<Link href="/admin" className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-sm">LB</span>
</div>
<span className="text-xl font-bold text-white">LetsBe Hub</span>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 px-3 py-4">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-gray-800 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
)}
>
<item.icon
className={cn(
'h-5 w-5 shrink-0',
isActive ? 'text-primary' : 'text-gray-400 group-hover:text-white'
)}
/>
{item.name}
</Link>
)
})}
</nav>
{/* User section */}
<div className="border-t border-gray-800 p-4">
<Button
variant="ghost"
className="w-full justify-start gap-3 text-gray-400 hover:bg-gray-800 hover:text-white"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="h-5 w-5" />
Sign Out
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { SessionProvider } from 'next-auth/react'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>{children}</SessionProvider>
</QueryClientProvider>
)
}

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,124 @@
'use client'
import * as React from 'react'
import { X } from 'lucide-react'
interface DialogProps {
open: boolean
onOpenChange: (open: boolean) => void
children: React.ReactNode
}
interface DialogContentProps {
children: React.ReactNode
className?: string
}
interface DialogHeaderProps {
children: React.ReactNode
className?: string
}
interface DialogTitleProps {
children: React.ReactNode
className?: string
}
interface DialogDescriptionProps {
children: React.ReactNode
className?: string
}
interface DialogFooterProps {
children: React.ReactNode
className?: string
}
const DialogContext = React.createContext<{
onOpenChange: (open: boolean) => void
}>({ onOpenChange: () => {} })
export function Dialog({ open, onOpenChange, children }: DialogProps) {
React.useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [open])
if (!open) return null
return (
<DialogContext.Provider value={{ onOpenChange }}>
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50"
onClick={() => onOpenChange(false)}
/>
{/* Content wrapper */}
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
{children}
</div>
</div>
</div>
</DialogContext.Provider>
)
}
export function DialogContent({ children, className = '' }: DialogContentProps) {
const { onOpenChange } = React.useContext(DialogContext)
return (
<div
className={`relative z-50 w-full max-w-lg rounded-lg border bg-white p-6 shadow-lg ${className}`}
onClick={(e) => e.stopPropagation()}
>
<button
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-gray-950 focus:ring-offset-2"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
{children}
</div>
)
}
export function DialogHeader({ children, className = '' }: DialogHeaderProps) {
return (
<div className={`flex flex-col space-y-1.5 text-center sm:text-left ${className}`}>
{children}
</div>
)
}
export function DialogTitle({ children, className = '' }: DialogTitleProps) {
return (
<h2 className={`text-lg font-semibold leading-none tracking-tight ${className}`}>
{children}
</h2>
)
}
export function DialogDescription({ children, className = '' }: DialogDescriptionProps) {
return (
<p className={`text-sm text-muted-foreground ${className}`}>
{children}
</p>
)
}
export function DialogFooter({ children, className = '' }: DialogFooterProps) {
return (
<div className={`flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 ${className}`}>
{children}
</div>
)
}

View File

@@ -0,0 +1,22 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,26 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,30 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { getCustomers, getCustomer } from '@/lib/api/admin'
import type { CustomerFilters } from '@/types/api'
// Query keys
export const customerKeys = {
all: ['customers'] as const,
lists: () => [...customerKeys.all, 'list'] as const,
list: (filters: CustomerFilters) => [...customerKeys.lists(), filters] as const,
details: () => [...customerKeys.all, 'detail'] as const,
detail: (id: string) => [...customerKeys.details(), id] as const,
}
// Hooks
export function useCustomers(filters: CustomerFilters = {}) {
return useQuery({
queryKey: customerKeys.list(filters),
queryFn: () => getCustomers(filters),
})
}
export function useCustomer(id: string) {
return useQuery({
queryKey: customerKeys.detail(id),
queryFn: () => getCustomer(id),
enabled: !!id,
})
}

76
src/hooks/use-orders.ts Normal file
View File

@@ -0,0 +1,76 @@
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
getOrders,
getOrder,
createOrder,
updateOrder,
triggerProvisioning,
} from '@/lib/api/admin'
import type {
OrderFilters,
UpdateOrderPayload,
CreateOrderPayload,
} from '@/types/api'
// Query keys
export const orderKeys = {
all: ['orders'] as const,
lists: () => [...orderKeys.all, 'list'] as const,
list: (filters: OrderFilters) => [...orderKeys.lists(), filters] as const,
details: () => [...orderKeys.all, 'detail'] as const,
detail: (id: string) => [...orderKeys.details(), id] as const,
}
// Hooks
export function useOrders(filters: OrderFilters = {}) {
return useQuery({
queryKey: orderKeys.list(filters),
queryFn: () => getOrders(filters),
})
}
export function useOrder(id: string) {
return useQuery({
queryKey: orderKeys.detail(id),
queryFn: () => getOrder(id),
enabled: !!id,
})
}
export function useCreateOrder() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreateOrderPayload) => createOrder(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: orderKeys.lists() })
},
})
}
export function useUpdateOrder() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateOrderPayload }) =>
updateOrder(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: orderKeys.detail(variables.id) })
queryClient.invalidateQueries({ queryKey: orderKeys.lists() })
},
})
}
export function useTriggerProvisioning() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (orderId: string) => triggerProvisioning(orderId),
onSuccess: (_, orderId) => {
queryClient.invalidateQueries({ queryKey: orderKeys.detail(orderId) })
queryClient.invalidateQueries({ queryKey: orderKeys.lists() })
},
})
}

View File

@@ -0,0 +1,168 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { LogLevel, OrderStatus } from '@/types/api'
export interface StreamedLog {
id: string
level: LogLevel
step: string | null
message: string
timestamp: Date
}
interface UseProvisioningLogsOptions {
orderId: string
enabled?: boolean
onStatusChange?: (status: OrderStatus) => void
onComplete?: (success: boolean) => void
onError?: (error: Error) => void
}
interface UseProvisioningLogsResult {
logs: StreamedLog[]
isConnected: boolean
isComplete: boolean
currentStatus: OrderStatus | null
error: Error | null
reconnect: () => void
}
export function useProvisioningLogs({
orderId,
enabled = true,
onStatusChange,
onComplete,
onError,
}: UseProvisioningLogsOptions): UseProvisioningLogsResult {
const [logs, setLogs] = useState<StreamedLog[]>([])
const [isConnected, setIsConnected] = useState(false)
const [isComplete, setIsComplete] = useState(false)
const [currentStatus, setCurrentStatus] = useState<OrderStatus | null>(null)
const [error, setError] = useState<Error | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const connect = useCallback(() => {
if (!enabled || !orderId) return
// Close existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
setError(null)
setIsComplete(false)
const eventSource = new EventSource(`/api/v1/admin/orders/${orderId}/logs/stream`)
eventSourceRef.current = eventSource
eventSource.addEventListener('connected', (event) => {
setIsConnected(true)
setError(null)
})
eventSource.addEventListener('log', (event) => {
try {
const data = JSON.parse(event.data)
const log: StreamedLog = {
id: data.id,
level: data.level,
step: data.step,
message: data.message,
timestamp: new Date(data.timestamp),
}
setLogs((prev) => {
// Avoid duplicates
if (prev.some((l) => l.id === log.id)) return prev
return [...prev, log]
})
} catch (err) {
console.error('Failed to parse log event:', err)
}
})
eventSource.addEventListener('status', (event) => {
try {
const data = JSON.parse(event.data)
if (data.status !== currentStatus) {
setCurrentStatus(data.status as OrderStatus)
onStatusChange?.(data.status as OrderStatus)
}
} catch (err) {
console.error('Failed to parse status event:', err)
}
})
eventSource.addEventListener('complete', (event) => {
try {
const data = JSON.parse(event.data)
setIsComplete(true)
setCurrentStatus(data.status as OrderStatus)
onComplete?.(data.success)
eventSource.close()
} catch (err) {
console.error('Failed to parse complete event:', err)
}
})
eventSource.addEventListener('error', (event) => {
if (eventSource.readyState === EventSource.CLOSED) {
setIsConnected(false)
// Only set error if not complete
if (!isComplete) {
const err = new Error('Connection to log stream lost')
setError(err)
onError?.(err)
// Attempt to reconnect after 5 seconds
reconnectTimeoutRef.current = setTimeout(() => {
connect()
}, 5000)
}
}
})
eventSource.onerror = () => {
setIsConnected(false)
}
}, [orderId, enabled, currentStatus, isComplete, onStatusChange, onComplete, onError])
// Connect on mount / when orderId changes
useEffect(() => {
if (enabled && orderId) {
connect()
}
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
}
}, [orderId, enabled, connect])
const reconnect = useCallback(() => {
setLogs([])
connect()
}, [connect])
return {
logs,
isConnected,
isComplete,
currentStatus,
error,
reconnect,
}
}

66
src/hooks/use-servers.ts Normal file
View File

@@ -0,0 +1,66 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { apiGet } from '@/lib/api/client'
import type { SubscriptionTier, OrderStatus, UserSummary } from '@/types/api'
export type ServerStatus = 'online' | 'provisioning' | 'offline' | 'pending'
export interface Server {
id: string
domain: string
tier: SubscriptionTier
orderStatus: OrderStatus
serverStatus: ServerStatus
serverIp: string
sshPort: number
tools: string[]
createdAt: Date
serverReadyAt: Date | null
completedAt: Date | null
customer: UserSummary
}
export interface ServersResponse {
servers: Server[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
export interface ServerFilters {
status?: ServerStatus
search?: string
page?: number
limit?: number
}
// API call
async function getServers(filters: ServerFilters = {}): Promise<ServersResponse> {
return apiGet<ServersResponse>('/api/v1/admin/servers', {
params: {
status: filters.status,
search: filters.search,
page: filters.page,
limit: filters.limit,
},
})
}
// Query keys
export const serverKeys = {
all: ['servers'] as const,
lists: () => [...serverKeys.all, 'list'] as const,
list: (filters: ServerFilters) => [...serverKeys.lists(), filters] as const,
}
// Hook
export function useServers(filters: ServerFilters = {}) {
return useQuery({
queryKey: serverKeys.list(filters),
queryFn: () => getServers(filters),
})
}

20
src/hooks/use-stats.ts Normal file
View File

@@ -0,0 +1,20 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { getDashboardStats } from '@/lib/api/admin'
// Query keys
export const statsKeys = {
all: ['stats'] as const,
dashboard: () => [...statsKeys.all, 'dashboard'] as const,
}
// Hooks
export function useDashboardStats() {
return useQuery({
queryKey: statsKeys.dashboard(),
queryFn: getDashboardStats,
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 60 * 1000, // 1 minute
})
}

67
src/lib/api/admin.ts Normal file
View File

@@ -0,0 +1,67 @@
import { apiGet, apiPost, apiPatch, apiDelete } from './client'
import type {
Order,
OrderDetail,
OrdersResponse,
OrderFilters,
UpdateOrderPayload,
CreateOrderPayload,
ProvisioningResult,
CustomerSummary,
Customer,
CustomersResponse,
CustomerFilters,
DashboardStats,
} from '@/types/api'
const API_BASE = '/api/v1/admin'
// Orders API
export async function getOrders(filters: OrderFilters = {}): Promise<OrdersResponse> {
return apiGet<OrdersResponse>(`${API_BASE}/orders`, {
params: {
status: filters.status,
tier: filters.tier,
search: filters.search,
page: filters.page,
limit: filters.limit,
},
})
}
export async function getOrder(id: string): Promise<OrderDetail> {
return apiGet<OrderDetail>(`${API_BASE}/orders/${id}`)
}
export async function createOrder(data: CreateOrderPayload): Promise<Order> {
return apiPost<Order>(`${API_BASE}/orders`, data)
}
export async function updateOrder(id: string, data: UpdateOrderPayload): Promise<Order> {
return apiPatch<Order>(`${API_BASE}/orders/${id}`, data)
}
export async function triggerProvisioning(orderId: string): Promise<ProvisioningResult> {
return apiPost<ProvisioningResult>(`${API_BASE}/orders/${orderId}/provision`)
}
// Customers API
export async function getCustomers(filters: CustomerFilters = {}): Promise<CustomersResponse> {
return apiGet<CustomersResponse>(`${API_BASE}/customers`, {
params: {
status: filters.status,
search: filters.search,
page: filters.page,
limit: filters.limit,
},
})
}
export async function getCustomer(id: string): Promise<Customer> {
return apiGet<Customer>(`${API_BASE}/customers/${id}`)
}
// Dashboard Stats API
export async function getDashboardStats(): Promise<DashboardStats> {
return apiGet<DashboardStats>(`${API_BASE}/stats`)
}

114
src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,114 @@
export class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public data?: unknown
) {
super(`API Error: ${status} ${statusText}`)
this.name = 'ApiError'
}
}
interface FetchOptions extends RequestInit {
params?: Record<string, string | number | boolean | undefined>
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let data: unknown
try {
data = await response.json()
} catch {
// Response is not JSON
}
throw new ApiError(response.status, response.statusText, data)
}
// Handle empty responses
const text = await response.text()
if (!text) {
return null as T
}
return JSON.parse(text) as T
}
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
const url = new URL(path, window.location.origin)
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, String(value))
}
})
}
return url.toString()
}
export async function apiGet<T>(path: string, options: FetchOptions = {}): Promise<T> {
const { params, ...fetchOptions } = options
const url = buildUrl(path, params)
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...fetchOptions.headers,
},
...fetchOptions,
})
return handleResponse<T>(response)
}
export async function apiPost<T>(path: string, data?: unknown, options: FetchOptions = {}): Promise<T> {
const { params, ...fetchOptions } = options
const url = buildUrl(path, params)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...fetchOptions.headers,
},
body: data ? JSON.stringify(data) : undefined,
...fetchOptions,
})
return handleResponse<T>(response)
}
export async function apiPatch<T>(path: string, data?: unknown, options: FetchOptions = {}): Promise<T> {
const { params, ...fetchOptions } = options
const url = buildUrl(path, params)
const response = await fetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...fetchOptions.headers,
},
body: data ? JSON.stringify(data) : undefined,
...fetchOptions,
})
return handleResponse<T>(response)
}
export async function apiDelete<T>(path: string, options: FetchOptions = {}): Promise<T> {
const { params, ...fetchOptions } = options
const url = buildUrl(path, params)
const response = await fetch(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...fetchOptions.headers,
},
...fetchOptions,
})
return handleResponse<T>(response)
}

139
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,139 @@
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { compare } from 'bcryptjs'
import { prisma } from './prisma'
export const { handlers, auth, signIn, signOut } = NextAuth({
session: {
strategy: 'jwt',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
pages: {
signIn: '/login',
error: '/login',
},
providers: [
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
userType: { label: 'User Type', type: 'text' }, // 'customer' or 'staff'
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password || !credentials?.userType) {
throw new Error('Missing credentials')
}
const email = credentials.email as string
const password = credentials.password as string
const userType = credentials.userType as 'customer' | 'staff'
if (userType === 'customer') {
const user = await prisma.user.findUnique({
where: { email },
include: {
subscriptions: {
where: { status: { not: 'CANCELED' } },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
})
if (!user) {
throw new Error('Invalid email or password')
}
if (user.status === 'SUSPENDED') {
throw new Error('Account suspended')
}
const isValidPassword = await compare(password, user.passwordHash)
if (!isValidPassword) {
throw new Error('Invalid email or password')
}
return {
id: user.id,
email: user.email,
name: user.name,
userType: 'customer' as const,
company: user.company,
subscription: user.subscriptions[0] || null,
}
} else if (userType === 'staff') {
const staff = await prisma.staff.findUnique({
where: { email },
})
if (!staff) {
throw new Error('Invalid email or password')
}
const isValidPassword = await compare(password, staff.passwordHash)
if (!isValidPassword) {
throw new Error('Invalid email or password')
}
return {
id: staff.id,
email: staff.email,
name: staff.name,
userType: 'staff' as const,
role: staff.role,
}
}
throw new Error('Invalid user type')
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.userType = user.userType
if (user.userType === 'staff') {
token.role = user.role
}
if (user.userType === 'customer') {
token.company = user.company
token.subscription = user.subscription
}
}
return token
},
async session({ session, token }) {
if (token) {
session.user.id = token.id as string
session.user.userType = token.userType as 'customer' | 'staff'
if (token.userType === 'staff') {
session.user.role = token.role as 'ADMIN' | 'SUPPORT'
}
if (token.userType === 'customer') {
session.user.company = token.company as string | undefined
session.user.subscription = token.subscription as {
id: string
plan: string
tier: string
status: string
} | null
}
}
return session
},
async authorized({ auth, request }) {
const isLoggedIn = !!auth?.user
const isAdminRoute = request.nextUrl.pathname.startsWith('/admin')
const isApiAdminRoute = request.nextUrl.pathname.startsWith('/api/v1/admin')
if (isAdminRoute || isApiAdminRoute) {
if (!isLoggedIn) return false
return auth.user.userType === 'staff'
}
return true
},
},
})

11
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
export default prisma

View File

@@ -0,0 +1,413 @@
import { prisma } from '@/lib/prisma'
import { JobStatus, OrderStatus, LogLevel } from '@prisma/client'
import crypto from 'crypto'
const toLogLevel = (level: 'info' | 'warn' | 'error'): LogLevel => {
const map: Record<'info' | 'warn' | 'error', LogLevel> = {
info: LogLevel.INFO,
warn: LogLevel.WARN,
error: LogLevel.ERROR,
}
return map[level]
}
// Retry delays in seconds: 1min, 5min, 15min
const RETRY_DELAYS = [60, 300, 900]
export interface JobConfig {
server: {
ip: string
port: number
rootPassword: string
}
customer: string
domain: string
companyName: string
licenseKey: string
dashboardTier: string
tools: string[]
keycloak?: {
realm: string
clients: Array<{ clientId: string; public: boolean }>
}
}
export class JobService {
/**
* Create a new provisioning job for an order
*/
async createJobForOrder(orderId: string): Promise<string> {
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
user: {
include: {
subscriptions: {
where: { status: 'ACTIVE' },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
},
},
})
if (!order) {
throw new Error(`Order ${orderId} not found`)
}
if (!order.serverIp || !order.serverPasswordEncrypted) {
throw new Error(`Order ${orderId} missing server credentials`)
}
// Build config snapshot
const configSnapshot: JobConfig = {
server: {
ip: order.serverIp,
port: order.sshPort,
rootPassword: this.decryptPassword(order.serverPasswordEncrypted),
},
customer: order.user.email.split('@')[0],
domain: order.domain,
companyName: order.user.company || order.user.name || 'Customer',
licenseKey: await this.generateLicenseKey(order.id),
dashboardTier: order.tier,
tools: order.tools,
keycloak: {
realm: 'letsbe',
clients: [{ clientId: 'dashboard', public: true }],
},
}
// Generate runner token
const runnerToken = crypto.randomBytes(32).toString('hex')
const runnerTokenHash = crypto.createHash('sha256').update(runnerToken).digest('hex')
const job = await prisma.provisioningJob.create({
data: {
orderId,
jobType: 'provision',
configSnapshot: configSnapshot as object,
runnerTokenHash,
},
})
// Update order status
await prisma.order.update({
where: { id: orderId },
data: {
status: OrderStatus.PROVISIONING,
provisioningStartedAt: new Date(),
},
})
// Return job ID with runner token (token is only returned once)
return JSON.stringify({ jobId: job.id, runnerToken })
}
/**
* Claim the next available job for processing
* Uses SELECT FOR UPDATE SKIP LOCKED pattern for concurrent workers
*/
async claimNextJob(workerId: string): Promise<{
jobId: string
config: JobConfig
runnerToken: string
} | null> {
// Use a transaction with row-level locking
const result = await prisma.$transaction(async (tx) => {
// Find next pending job, skip locked rows
const jobs = await tx.$queryRaw<Array<{ id: string }>>`
SELECT id FROM "ProvisioningJob"
WHERE status = 'PENDING'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY priority DESC, created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
`
if (jobs.length === 0) {
return null
}
const jobId = jobs[0].id
// Generate new runner token for this claim
const runnerToken = crypto.randomBytes(32).toString('hex')
const runnerTokenHash = crypto.createHash('sha256').update(runnerToken).digest('hex')
// Update job as claimed
const job = await tx.provisioningJob.update({
where: { id: jobId },
data: {
status: JobStatus.RUNNING,
claimedAt: new Date(),
claimedBy: workerId,
runnerTokenHash,
},
})
return {
jobId: job.id,
config: job.configSnapshot as unknown as JobConfig,
runnerToken,
}
})
return result
}
/**
* Verify a runner token for a job
*/
async verifyRunnerToken(jobId: string, token: string): Promise<boolean> {
const job = await prisma.provisioningJob.findUnique({
where: { id: jobId },
select: { runnerTokenHash: true },
})
if (!job || !job.runnerTokenHash) {
return false
}
const providedHash = crypto.createHash('sha256').update(token).digest('hex')
return crypto.timingSafeEqual(
Buffer.from(job.runnerTokenHash),
Buffer.from(providedHash)
)
}
/**
* Add a log entry for a job
*/
async addLog(
jobId: string,
level: 'info' | 'warn' | 'error',
message: string,
step?: string,
progress?: number
): Promise<void> {
const dbLevel = toLogLevel(level)
await prisma.jobLog.create({
data: {
jobId,
level: dbLevel,
message,
step,
progress,
},
})
// Also create a provisioning log on the order for easy access
const job = await prisma.provisioningJob.findUnique({
where: { id: jobId },
select: { orderId: true },
})
if (job) {
await prisma.provisioningLog.create({
data: {
orderId: job.orderId,
level: dbLevel,
message,
step,
},
})
}
}
/**
* Get logs for a job
*/
async getLogs(jobId: string, since?: Date): Promise<Array<{
id: string
timestamp: Date
level: string
message: string
step: string | null
progress: number | null
}>> {
const where: { jobId: string; timestamp?: { gt: Date } } = { jobId }
if (since) {
where.timestamp = { gt: since }
}
return prisma.jobLog.findMany({
where,
orderBy: { timestamp: 'asc' },
select: {
id: true,
timestamp: true,
level: true,
message: true,
step: true,
progress: true,
},
})
}
/**
* Complete a job successfully
*/
async completeJob(jobId: string, result?: object): Promise<void> {
const job = await prisma.provisioningJob.update({
where: { id: jobId },
data: {
status: JobStatus.COMPLETED,
completedAt: new Date(),
result: result || {},
},
})
// Update order status
await prisma.order.update({
where: { id: job.orderId },
data: {
status: OrderStatus.FULFILLED,
completedAt: new Date(),
// Clear sensitive data
serverPasswordEncrypted: null,
},
})
}
/**
* Fail a job - will retry if attempts remaining
*/
async failJob(jobId: string, error: string): Promise<{ willRetry: boolean; nextRetryAt?: Date }> {
const job = await prisma.provisioningJob.findUnique({
where: { id: jobId },
})
if (!job) {
throw new Error(`Job ${jobId} not found`)
}
const nextAttempt = job.attempt + 1
const willRetry = nextAttempt <= job.maxAttempts
if (willRetry) {
// Schedule retry with exponential backoff
const delaySeconds = RETRY_DELAYS[Math.min(job.attempt - 1, RETRY_DELAYS.length - 1)]
const nextRetryAt = new Date(Date.now() + delaySeconds * 1000)
await prisma.provisioningJob.update({
where: { id: jobId },
data: {
status: JobStatus.PENDING,
attempt: nextAttempt,
nextRetryAt,
claimedAt: null,
claimedBy: null,
runnerTokenHash: null,
},
})
await this.addLog(jobId, 'warn', `Job failed, will retry (attempt ${nextAttempt}/${job.maxAttempts}): ${error}`, 'retry')
return { willRetry: true, nextRetryAt }
} else {
// Max retries exceeded - mark as dead
await prisma.provisioningJob.update({
where: { id: jobId },
data: {
status: JobStatus.DEAD,
completedAt: new Date(),
error,
},
})
// Update order status to failed
await prisma.order.update({
where: { id: job.orderId },
data: {
status: OrderStatus.FAILED,
failureReason: error,
},
})
await this.addLog(jobId, 'error', `Job failed permanently after ${job.maxAttempts} attempts: ${error}`, 'dead')
return { willRetry: false }
}
}
/**
* Get job status
*/
async getJobStatus(jobId: string): Promise<{
status: JobStatus
attempt: number
maxAttempts: number
progress?: number
error?: string
} | null> {
const job = await prisma.provisioningJob.findUnique({
where: { id: jobId },
select: {
status: true,
attempt: true,
maxAttempts: true,
error: true,
},
})
if (!job) {
return null
}
// Get latest progress from logs
const latestLog = await prisma.jobLog.findFirst({
where: { jobId, progress: { not: null } },
orderBy: { timestamp: 'desc' },
select: { progress: true },
})
return {
...job,
progress: latestLog?.progress || undefined,
error: job.error || undefined,
}
}
/**
* Get pending job count
*/
async getPendingJobCount(): Promise<number> {
return prisma.provisioningJob.count({
where: {
status: JobStatus.PENDING,
OR: [
{ nextRetryAt: null },
{ nextRetryAt: { lte: new Date() } },
],
},
})
}
/**
* Get running job count
*/
async getRunningJobCount(): Promise<number> {
return prisma.provisioningJob.count({
where: { status: JobStatus.RUNNING },
})
}
// Helper methods
private decryptPassword(encrypted: string): string {
// TODO: Implement proper decryption using environment-based key
// For now, return as-is (in production, use crypto.createDecipheriv)
return encrypted
}
private async generateLicenseKey(orderId: string): Promise<string> {
// Generate a unique license key for this order
const hash = crypto.createHash('sha256').update(orderId + Date.now()).digest('hex')
return `lb_inst_${hash.slice(0, 40)}`
}
}
// Singleton instance
export const jobService = new JobService()

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

213
src/types/api.ts Normal file
View File

@@ -0,0 +1,213 @@
import {
OrderStatus,
SubscriptionTier,
SubscriptionPlan,
SubscriptionStatus,
UserStatus,
LogLevel,
JobStatus,
} from '@prisma/client'
// Re-export enums for use as both types and values
export {
OrderStatus,
SubscriptionTier,
SubscriptionPlan,
SubscriptionStatus,
UserStatus,
LogLevel,
JobStatus,
}
// User types
export interface UserSummary {
id: string
name: string | null
email: string
company: string | null
}
export interface User extends UserSummary {
status: UserStatus
emailVerified: Date | null
createdAt: Date
updatedAt: Date
}
// Subscription types
export interface Subscription {
id: string
userId: string
plan: SubscriptionPlan
tier: SubscriptionTier
tokenLimit: number
tokensUsed: number
trialEndsAt: Date | null
stripeCustomerId: string | null
stripeSubscriptionId: string | null
status: SubscriptionStatus
createdAt: Date
updatedAt: Date
}
// Provisioning log types
export interface ProvisioningLog {
id: string
orderId: string
level: LogLevel
message: string
step: string | null
timestamp: Date
}
// Job types
export interface JobSummary {
id: string
status: JobStatus
attempt: number
maxAttempts: number
createdAt: Date
completedAt: Date | null
error: string | null
}
// Order types
export interface Order {
id: string
userId: string
status: OrderStatus
tier: SubscriptionTier
domain: string
tools: string[]
configJson: Record<string, unknown>
serverIp: string | null
sshPort: number
portainerUrl: string | null
dashboardUrl: string | null
failureReason: string | null
createdAt: Date
updatedAt: Date
serverReadyAt: Date | null
provisioningStartedAt: Date | null
completedAt: Date | null
user: UserSummary
_count?: {
provisioningLogs: number
}
}
export interface OrderDetail extends Omit<Order, '_count'> {
provisioningLogs: ProvisioningLog[]
jobs: JobSummary[]
}
// Customer types (User with subscriptions and orders)
export interface Customer extends User {
subscriptions: Subscription[]
orders: Order[]
_count: {
orders: number
subscriptions: number
tokenUsage?: number
}
// Computed fields from API
totalTokensUsed?: number
tokenLimit?: number
}
export interface CustomerSummary extends UserSummary {
status: UserStatus
createdAt: Date
_count: {
orders: number
subscriptions: number
}
subscriptions: {
id: string
plan: SubscriptionPlan
tier: SubscriptionTier
status: SubscriptionStatus
}[]
}
// API response types
export interface PaginatedResponse<T> {
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
export interface OrdersResponse extends PaginatedResponse<Order> {
orders: Order[]
}
export interface CustomersResponse extends PaginatedResponse<CustomerSummary> {
customers: CustomerSummary[]
}
// Dashboard stats types
export interface DashboardStats {
orders: {
total: number
pending: number
inProgress: number
completed: number
failed: number
byStatus: Record<OrderStatus, number>
}
customers: {
total: number
active: number
suspended: number
pending: number
}
subscriptions: {
total: number
trial: number
active: number
byPlan: Record<SubscriptionPlan, number>
byTier: Record<SubscriptionTier, number>
}
recentOrders: Order[]
}
// API filter types
export interface OrderFilters {
status?: OrderStatus
tier?: SubscriptionTier
search?: string
page?: number
limit?: number
}
export interface CustomerFilters {
status?: UserStatus
search?: string
page?: number
limit?: number
}
// Mutation types
export interface UpdateOrderPayload {
status?: OrderStatus
serverIp?: string
serverPassword?: string
sshPort?: number
failureReason?: string
}
export interface CreateOrderPayload {
userId: string
domain: string
tier: SubscriptionTier
tools: string[]
}
export interface ProvisioningResult {
success: boolean
message: string
jobId?: string
}

39
src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
import { DefaultSession, DefaultUser } from 'next-auth'
import { DefaultJWT } from '@auth/core/jwt'
declare module 'next-auth' {
interface User extends DefaultUser {
userType: 'customer' | 'staff'
role?: 'ADMIN' | 'SUPPORT'
company?: string | null
subscription?: {
id: string
plan: string
tier: string
status: string
} | null
}
interface Session extends DefaultSession {
user: User & {
id: string
email: string
name?: string | null
}
}
}
declare module '@auth/core/jwt' {
interface JWT extends DefaultJWT {
id: string
userType: 'customer' | 'staff'
role?: 'ADMIN' | 'SUPPORT'
company?: string | null
subscription?: {
id: string
plan: string
tier: string
status: string
} | null
}
}