+ {/* Backdrop */}
+
onOpenChange(false)}
+ />
+ {/* Content wrapper */}
+
+
+
+ )
+}
+
+export function DialogContent({ children, className = '' }: DialogContentProps) {
+ const { onOpenChange } = React.useContext(DialogContext)
+
+ return (
+
e.stopPropagation()}
+ >
+ onOpenChange(false)}
+ >
+
+ Close
+
+ {children}
+
+ )
+}
+
+export function DialogHeader({ children, className = '' }: DialogHeaderProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function DialogTitle({ children, className = '' }: DialogTitleProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function DialogDescription({ children, className = '' }: DialogDescriptionProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function DialogFooter({ children, className = '' }: DialogFooterProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..e6fe6c0
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+const Input = React.forwardRef
>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = 'Input'
+
+export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..340513e
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -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,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/src/hooks/use-customers.ts b/src/hooks/use-customers.ts
new file mode 100644
index 0000000..e84af96
--- /dev/null
+++ b/src/hooks/use-customers.ts
@@ -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,
+ })
+}
diff --git a/src/hooks/use-orders.ts b/src/hooks/use-orders.ts
new file mode 100644
index 0000000..d57a010
--- /dev/null
+++ b/src/hooks/use-orders.ts
@@ -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() })
+ },
+ })
+}
diff --git a/src/hooks/use-provisioning-logs.ts b/src/hooks/use-provisioning-logs.ts
new file mode 100644
index 0000000..56eeab1
--- /dev/null
+++ b/src/hooks/use-provisioning-logs.ts
@@ -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([])
+ const [isConnected, setIsConnected] = useState(false)
+ const [isComplete, setIsComplete] = useState(false)
+ const [currentStatus, setCurrentStatus] = useState(null)
+ const [error, setError] = useState(null)
+
+ const eventSourceRef = useRef(null)
+ const reconnectTimeoutRef = useRef(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,
+ }
+}
diff --git a/src/hooks/use-servers.ts b/src/hooks/use-servers.ts
new file mode 100644
index 0000000..959c7ff
--- /dev/null
+++ b/src/hooks/use-servers.ts
@@ -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 {
+ return apiGet('/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),
+ })
+}
diff --git a/src/hooks/use-stats.ts b/src/hooks/use-stats.ts
new file mode 100644
index 0000000..1b6c23d
--- /dev/null
+++ b/src/hooks/use-stats.ts
@@ -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
+ })
+}
diff --git a/src/lib/api/admin.ts b/src/lib/api/admin.ts
new file mode 100644
index 0000000..6207788
--- /dev/null
+++ b/src/lib/api/admin.ts
@@ -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 {
+ return apiGet(`${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 {
+ return apiGet(`${API_BASE}/orders/${id}`)
+}
+
+export async function createOrder(data: CreateOrderPayload): Promise {
+ return apiPost(`${API_BASE}/orders`, data)
+}
+
+export async function updateOrder(id: string, data: UpdateOrderPayload): Promise {
+ return apiPatch(`${API_BASE}/orders/${id}`, data)
+}
+
+export async function triggerProvisioning(orderId: string): Promise {
+ return apiPost(`${API_BASE}/orders/${orderId}/provision`)
+}
+
+// Customers API
+export async function getCustomers(filters: CustomerFilters = {}): Promise {
+ return apiGet(`${API_BASE}/customers`, {
+ params: {
+ status: filters.status,
+ search: filters.search,
+ page: filters.page,
+ limit: filters.limit,
+ },
+ })
+}
+
+export async function getCustomer(id: string): Promise {
+ return apiGet(`${API_BASE}/customers/${id}`)
+}
+
+// Dashboard Stats API
+export async function getDashboardStats(): Promise {
+ return apiGet(`${API_BASE}/stats`)
+}
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts
new file mode 100644
index 0000000..b0ce3ac
--- /dev/null
+++ b/src/lib/api/client.ts
@@ -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
+}
+
+async function handleResponse(response: Response): Promise {
+ 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 {
+ 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(path: string, options: FetchOptions = {}): Promise {
+ 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(response)
+}
+
+export async function apiPost(path: string, data?: unknown, options: FetchOptions = {}): Promise {
+ 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(response)
+}
+
+export async function apiPatch(path: string, data?: unknown, options: FetchOptions = {}): Promise {
+ 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(response)
+}
+
+export async function apiDelete(path: string, options: FetchOptions = {}): Promise {
+ 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(response)
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..4254a95
--- /dev/null
+++ b/src/lib/auth.ts
@@ -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
+ },
+ },
+})
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
new file mode 100644
index 0000000..0742c54
--- /dev/null
+++ b/src/lib/prisma.ts
@@ -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
diff --git a/src/lib/services/job-service.ts b/src/lib/services/job-service.ts
new file mode 100644
index 0000000..24e599c
--- /dev/null
+++ b/src/lib/services/job-service.ts
@@ -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 {
+ 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>`
+ 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 {
+ 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 {
+ 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> {
+ 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 {
+ 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 {
+ return prisma.provisioningJob.count({
+ where: {
+ status: JobStatus.PENDING,
+ OR: [
+ { nextRetryAt: null },
+ { nextRetryAt: { lte: new Date() } },
+ ],
+ },
+ })
+ }
+
+ /**
+ * Get running job count
+ */
+ async getRunningJobCount(): Promise {
+ 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 {
+ // 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()
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..d32b0fe
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/types/api.ts b/src/types/api.ts
new file mode 100644
index 0000000..413a1b9
--- /dev/null
+++ b/src/types/api.ts
@@ -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
+ 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 {
+ 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 {
+ pagination: {
+ page: number
+ limit: number
+ total: number
+ totalPages: number
+ }
+}
+
+export interface OrdersResponse extends PaginatedResponse {
+ orders: Order[]
+}
+
+export interface CustomersResponse extends PaginatedResponse {
+ customers: CustomerSummary[]
+}
+
+// Dashboard stats types
+export interface DashboardStats {
+ orders: {
+ total: number
+ pending: number
+ inProgress: number
+ completed: number
+ failed: number
+ byStatus: Record
+ }
+ customers: {
+ total: number
+ active: number
+ suspended: number
+ pending: number
+ }
+ subscriptions: {
+ total: number
+ trial: number
+ active: number
+ byPlan: Record
+ byTier: Record
+ }
+ 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
+}
diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts
new file mode 100644
index 0000000..94194e3
--- /dev/null
+++ b/src/types/next-auth.d.ts
@@ -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
+ }
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..ade7f8b
--- /dev/null
+++ b/tailwind.config.ts
@@ -0,0 +1,57 @@
+import type { Config } from 'tailwindcss'
+
+const config: Config = {
+ darkMode: ['class'],
+ content: [
+ './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/components/**/*.{js,ts,jsx,tsx,mdx}',
+ './src/app/**/*.{js,ts,jsx,tsx,mdx}',
+ ],
+ theme: {
+ extend: {
+ colors: {
+ border: 'hsl(var(--border))',
+ input: 'hsl(var(--input))',
+ ring: 'hsl(var(--ring))',
+ background: 'hsl(var(--background))',
+ foreground: 'hsl(var(--foreground))',
+ primary: {
+ DEFAULT: 'hsl(var(--primary))',
+ foreground: 'hsl(var(--primary-foreground))',
+ },
+ secondary: {
+ DEFAULT: 'hsl(var(--secondary))',
+ foreground: 'hsl(var(--secondary-foreground))',
+ },
+ destructive: {
+ DEFAULT: 'hsl(var(--destructive))',
+ foreground: 'hsl(var(--destructive-foreground))',
+ },
+ muted: {
+ DEFAULT: 'hsl(var(--muted))',
+ foreground: 'hsl(var(--muted-foreground))',
+ },
+ accent: {
+ DEFAULT: 'hsl(var(--accent))',
+ foreground: 'hsl(var(--accent-foreground))',
+ },
+ popover: {
+ DEFAULT: 'hsl(var(--popover))',
+ foreground: 'hsl(var(--popover-foreground))',
+ },
+ card: {
+ DEFAULT: 'hsl(var(--card))',
+ foreground: 'hsl(var(--card-foreground))',
+ },
+ },
+ borderRadius: {
+ lg: 'var(--radius)',
+ md: 'calc(var(--radius) - 2px)',
+ sm: 'calc(var(--radius) - 4px)',
+ },
+ },
+ },
+ plugins: [require('tailwindcss-animate')],
+}
+
+export default config
diff --git a/tests/__pycache__/test_activation.cpython-313.pyc b/tests/__pycache__/test_activation.cpython-313.pyc
new file mode 100644
index 0000000..ed60eac
Binary files /dev/null and b/tests/__pycache__/test_activation.cpython-313.pyc differ
diff --git a/tests/__pycache__/test_admin.cpython-313.pyc b/tests/__pycache__/test_admin.cpython-313.pyc
new file mode 100644
index 0000000..743aa58
Binary files /dev/null and b/tests/__pycache__/test_admin.cpython-313.pyc differ
diff --git a/tests/__pycache__/test_redactor.cpython-313.pyc b/tests/__pycache__/test_redactor.cpython-313.pyc
new file mode 100644
index 0000000..5f062a6
Binary files /dev/null and b/tests/__pycache__/test_redactor.cpython-313.pyc differ
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..5ac4e20
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "target": "ES2022"
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}