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

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:
Matt 2026-01-06 12:35:01 +01:00
parent 02fc18f009
commit a79b79efd2
85 changed files with 19070 additions and 1869 deletions

24
.env.local.example Normal file
View File

@ -0,0 +1,24 @@
# Database
DATABASE_URL="postgresql://letsbe:letsbe@localhost:5432/letsbe_hub"
# NextAuth.js
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key-here-change-in-production"
# Stripe (Phase 5)
# STRIPE_SECRET_KEY="sk_test_..."
# STRIPE_WEBHOOK_SECRET="whsec_..."
# Entri DNS API (Phase 3)
# ENTRI_APP_ID="..."
# ENTRI_SECRET="..."
# Runner Authentication
RUNNER_TOKEN="change-me-in-production"
# Admin Setup
ADMIN_EMAIL="admin@letsbe.solutions"
ADMIN_PASSWORD="change-me-in-production"
# Hub Internal URL (for runners)
HUB_INTERNAL_URL="http://localhost:3000"

58
.gitignore vendored
View File

@ -1,16 +1,25 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Dependencies
node_modules/
.pnp/
.pnp.js
# Virtual environments
.venv/
venv/
ENV/
# Build outputs
.next/
out/
dist/
build/
# Testing
coverage/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
!.env.example
!.env.local.example
# IDE
.idea/
@ -18,19 +27,26 @@ ENV/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build
dist/
build/
*.egg-info/
# Typescript
*.tsbuildinfo
next-env.d.ts
# Database
*.db
*.sqlite3
# Misc
.DS_Store
*.pem
Thumbs.db
# Logs
*.log
# Serena
.serena/
# Vercel
.vercel
# Prisma
prisma/*.db
prisma/*.db-journal

203
CLAUDE.md
View File

@ -2,114 +2,149 @@
## Purpose
You are the engineering assistant for the LetsBe Hub.
This is the central licensing and telemetry service for the LetsBe Cloud platform.
You are the engineering assistant for the LetsBe Hub Dashboard.
This is the admin dashboard and API for managing the LetsBe Cloud platform.
The Hub provides:
- **License Management**: Issue and validate per-instance license keys
- **Instance Activation**: Verify licenses during client installation
- **Telemetry Collection**: Receive anonymized usage data from instances
- **Client Management**: Track organizations and their deployments
## Privacy Guarantee
**CRITICAL**: The Hub NEVER stores sensitive client data.
Allowed data:
- Instance identifiers
- Tool names
- Duration metrics
- Aggregated counts
- Error codes (not messages)
NEVER stored:
- Environment variable values
- File contents
- Request/response payloads
- Screenshots
- Credentials
- Stack traces or error messages
The `app/services/redactor.py` enforces this with an ALLOW-LIST approach.
- **Admin Dashboard**: Next.js admin UI for platform management
- **Customer Management**: Create/manage customers and subscriptions
- **Order Management**: Process and track provisioning orders
- **Server Monitoring**: View and manage tenant servers
- **Token Usage Tracking**: Monitor AI token consumption
## Tech Stack
- Python 3.11
- FastAPI
- SQLAlchemy 2.0 (async)
- PostgreSQL
- Alembic migrations
- Pydantic v2
- **Next.js 15** (App Router)
- **TypeScript** (strict mode)
- **Prisma** (PostgreSQL ORM)
- **TanStack Query** (React Query v5)
- **Tailwind CSS** + shadcn/ui components
- **NextAuth.js** (authentication)
## API Endpoints
### Public Endpoints
## Project Structure
```
POST /api/v1/instances/activate
- Validates license key
- Returns hub_api_key for telemetry
- Called by client bootstrap scripts
src/
├── app/ # Next.js App Router
│ ├── admin/ # Admin dashboard pages
│ │ ├── customers/ # Customer management
│ │ ├── orders/ # Order management
│ │ ├── servers/ # Server monitoring
│ │ └── layout.tsx # Admin layout with sidebar
│ ├── api/v1/ # API routes
│ │ ├── admin/ # Admin API endpoints
│ │ └── public/ # Public API endpoints
│ └── (auth)/ # Authentication pages
├── components/
│ ├── admin/ # Admin-specific components
│ └── ui/ # Reusable UI components
├── hooks/ # React Query hooks
├── lib/ # Utilities and shared code
│ └── prisma.ts # Prisma client singleton
└── types/ # TypeScript type definitions
```
### Admin Endpoints (require X-Admin-Api-Key header)
## API Routes
### Admin Endpoints (authenticated)
```
# Clients
POST /api/v1/admin/clients
GET /api/v1/admin/clients
GET /api/v1/admin/clients/{id}
PATCH /api/v1/admin/clients/{id}
DELETE /api/v1/admin/clients/{id}
# Customers
GET /api/v1/admin/customers # List customers
GET /api/v1/admin/customers/[id] # Get customer detail
PATCH /api/v1/admin/customers/[id] # Update customer
# Instances
POST /api/v1/admin/clients/{id}/instances
GET /api/v1/admin/clients/{id}/instances
GET /api/v1/admin/instances/{instance_id}
POST /api/v1/admin/instances/{instance_id}/rotate-license
POST /api/v1/admin/instances/{instance_id}/rotate-hub-key
POST /api/v1/admin/instances/{instance_id}/suspend
POST /api/v1/admin/instances/{instance_id}/reactivate
DELETE /api/v1/admin/instances/{instance_id}
# Orders
GET /api/v1/admin/orders # List orders
POST /api/v1/admin/orders # Create order
GET /api/v1/admin/orders/[id] # Get order detail
PATCH /api/v1/admin/orders/[id] # Update order
GET /api/v1/admin/orders/[id]/logs # Get provisioning logs (SSE)
# Servers
GET /api/v1/admin/servers # List servers (derived from orders)
# Dashboard
GET /api/v1/admin/dashboard/stats # Dashboard statistics
```
## Key Types
### License Key
Format: `lb_inst_<32_hex_chars>`
Example: `lb_inst_a1b2c3d4e5f6789012345678901234567890abcd`
Stored as SHA-256 hash. Only visible once at creation.
### Hub API Key
Format: `hk_<24_hex_chars>`
Example: `hk_abc123def456789012345678901234567890abcd`
Used for telemetry authentication. Stored as SHA-256 hash.
## Development Commands
```bash
# Start services
docker compose up --build
# Install dependencies
npm install
# Run migrations
docker compose exec api alembic upgrade head
# Start development server
npm run dev
# Create new migration
docker compose exec api alembic revision --autogenerate -m "description"
# Run database migrations
npx prisma migrate dev
# Run tests
docker compose exec api pytest -v
# Generate Prisma client
npx prisma generate
# API available at http://localhost:8200
# Seed database
npm run db:seed
# Type checking
npm run typecheck
# Build for production
npm run build
# App available at http://localhost:3000
```
## Key Patterns
### React Query Hooks
All data fetching uses React Query hooks in `src/hooks/`:
- `useCustomers()`, `useCustomer(id)` - Customer data
- `useOrders()`, `useOrder(id)` - Order data
- `useServers()` - Server list
- `useDashboardStats()` - Dashboard metrics
Mutations follow the pattern:
- `useCreateOrder()`, `useUpdateOrder()`
- Automatic cache invalidation via `queryClient.invalidateQueries()`
### API Route Pattern
```typescript
export async function GET(request: NextRequest) {
// Parse query params
const searchParams = request.nextUrl.searchParams
// Database query with Prisma
const data = await prisma.model.findMany({...})
// Return JSON response
return NextResponse.json(data)
}
```
### Component Pattern
```typescript
'use client'
export function MyComponent() {
const { data, isLoading, error } = useMyData()
if (isLoading) return <Skeleton />
if (error) return <ErrorMessage error={error} />
return <div>...</div>
}
```
## Coding Conventions
- Everything async
- Use the redactor for ALL telemetry data
- Never log sensitive data
- All exceptions should be caught and return proper HTTP errors
- Use constant-time comparison for secrets (secrets.compare_digest)
- Use `'use client'` directive for client components
- All API routes return `NextResponse.json()`
- Use Prisma for all database operations
- Follow existing shadcn/ui component patterns
- Use React Query for server state management
- TypeScript strict mode - no `any` types

View File

@ -1,24 +1,59 @@
FROM python:3.11-slim
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm ci
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Generate Prisma Client
COPY prisma ./prisma/
RUN npx prisma generate
# Copy application code
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Create non-root user
RUN useradd -m -u 1000 hub && chown -R hub:hub /app
USER hub
# Next.js telemetry
ENV NEXT_TELEMETRY_DISABLED=1
EXPOSE 8000
RUN npm run build
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Copy Prisma
COPY --from=deps /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=deps /app/node_modules/@prisma ./node_modules/@prisma
COPY prisma ./prisma/
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@ -1,3 +0,0 @@
"""LetsBe Hub - Central licensing and telemetry service."""
__version__ = "0.1.0"

View File

@ -1,63 +0,0 @@
"""Hub configuration via environment variables."""
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Hub settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
frozen=True,
)
# Application
APP_NAME: str = Field(default="LetsBe Hub", description="Application name")
APP_VERSION: str = Field(default="0.1.0", description="Application version")
DEBUG: bool = Field(default=False, description="Debug mode")
# Database
DATABASE_URL: str = Field(
default="postgresql+asyncpg://hub:hub@db:5432/hub",
description="PostgreSQL connection URL"
)
DB_POOL_SIZE: int = Field(default=5, ge=1, le=20, description="Connection pool size")
DB_MAX_OVERFLOW: int = Field(default=10, ge=0, le=50, description="Max overflow connections")
DB_POOL_TIMEOUT: int = Field(default=30, ge=5, le=120, description="Pool timeout in seconds")
DB_POOL_RECYCLE: int = Field(default=1800, ge=300, le=7200, description="Connection recycle time")
# Admin authentication
ADMIN_API_KEY: str = Field(
default="change-me-in-production",
min_length=16,
description="Admin API key for management endpoints"
)
# Telemetry settings
TELEMETRY_RETENTION_DAYS: int = Field(
default=90,
ge=7,
le=365,
description="Days to retain telemetry data"
)
# Rate limiting for activation endpoint
ACTIVATION_RATE_LIMIT_PER_MINUTE: int = Field(
default=10,
ge=1,
le=100,
description="Max activation attempts per instance per minute"
)
@lru_cache
def get_settings() -> Settings:
"""Get cached settings instance."""
return Settings()
settings = get_settings()

View File

@ -1,52 +0,0 @@
"""Database configuration and session management."""
from collections.abc import AsyncGenerator
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.config import settings
# Create async engine with connection pooling
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=settings.DB_POOL_SIZE,
max_overflow=settings.DB_MAX_OVERFLOW,
pool_timeout=settings.DB_POOL_TIMEOUT,
pool_recycle=settings.DB_POOL_RECYCLE,
echo=settings.DEBUG,
)
# Create async session factory
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency that provides an async database session.
Yields a session and ensures proper cleanup via finally block.
"""
async with async_session_maker() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
# Type alias for dependency injection
AsyncSessionDep = Annotated[AsyncSession, Depends(get_db)]

View File

@ -1,5 +0,0 @@
"""Hub dependencies."""
from app.dependencies.admin_auth import validate_admin_key
__all__ = ["validate_admin_key"]

View File

@ -1,28 +0,0 @@
"""Admin authentication dependency."""
import secrets
from typing import Annotated
from fastapi import Header, HTTPException, status
from app.config import settings
def validate_admin_key(
x_admin_api_key: Annotated[str, Header(description="Admin API key")],
) -> str:
"""
Validate the admin API key.
Uses constant-time comparison to prevent timing attacks.
"""
if not secrets.compare_digest(x_admin_api_key, settings.ADMIN_API_KEY):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin API key",
)
return x_admin_api_key
# Type alias for dependency injection
AdminKeyDep = Annotated[str, validate_admin_key]

View File

@ -1,51 +0,0 @@
"""LetsBe Hub - Central licensing and telemetry service."""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app import __version__
from app.config import settings
from app.db import engine
from app.routes import activation_router, admin_router, health_router, telemetry_router
# Configure logging
logging.basicConfig(
level=logging.DEBUG if settings.DEBUG else logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
logger.info(f"Starting LetsBe Hub v{__version__}")
yield
logger.info("Shutting down LetsBe Hub")
await engine.dispose()
app = FastAPI(
title="LetsBe Hub",
description="Central licensing and telemetry service for LetsBe Cloud",
version=__version__,
lifespan=lifespan,
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(health_router)
app.include_router(admin_router)
app.include_router(activation_router)
app.include_router(telemetry_router)

View File

@ -1,16 +0,0 @@
"""Hub database models."""
from app.models.base import Base, TimestampMixin, UUIDMixin, utc_now
from app.models.client import Client
from app.models.instance import Instance
from app.models.usage_sample import UsageSample
__all__ = [
"Base",
"UUIDMixin",
"TimestampMixin",
"utc_now",
"Client",
"Instance",
"UsageSample",
]

View File

@ -1,44 +0,0 @@
"""Base model and mixins for SQLAlchemy ORM."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def utc_now() -> datetime:
"""Return current UTC datetime."""
return datetime.now(timezone.utc)
class Base(AsyncAttrs, DeclarativeBase):
"""Base class for all SQLAlchemy models."""
pass
class UUIDMixin:
"""Mixin that adds a UUID primary key."""
id: Mapped[uuid.UUID] = mapped_column(
primary_key=True,
default=uuid.uuid4,
)
class TimestampMixin:
"""Mixin that adds created_at and updated_at timestamps."""
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utc_now,
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utc_now,
onupdate=utc_now,
nullable=False,
)

View File

@ -1,38 +0,0 @@
"""Client model - represents a company/organization using LetsBe."""
from typing import TYPE_CHECKING, Optional
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.instance import Instance
class Client(UUIDMixin, TimestampMixin, Base):
"""
A client is a company or organization using LetsBe.
Clients can have multiple instances (orchestrator deployments).
"""
__tablename__ = "clients"
# Client identification
name: Mapped[str] = mapped_column(String(255), nullable=False)
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# Billing/plan info (for future use)
billing_plan: Mapped[str] = mapped_column(String(50), default="free")
# Status
status: Mapped[str] = mapped_column(String(50), default="active")
# "active", "suspended", "archived"
# Relationships
instances: Mapped[list["Instance"]] = relationship(
back_populates="client",
cascade="all, delete-orphan",
)

View File

@ -1,137 +0,0 @@
"""Instance model - represents a deployed orchestrator with licensing."""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from uuid import UUID
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from app.models.client import Client
class Instance(UUIDMixin, TimestampMixin, Base):
"""
A deployed orchestrator instance with licensing.
Each instance is tied to a client and requires a valid license to operate.
The Hub issues license keys and tracks activation status.
"""
__tablename__ = "instances"
# Client relationship
client_id: Mapped[UUID] = mapped_column(
ForeignKey("clients.id", ondelete="CASCADE"),
nullable=False,
)
# Instance identification
instance_id: Mapped[str] = mapped_column(
String(255),
unique=True,
nullable=False,
index=True,
)
# e.g., "acme-orchestrator"
# === LICENSING ===
license_key_hash: Mapped[str] = mapped_column(
String(64),
nullable=False,
)
# SHA-256 hash of the license key (lb_inst_...)
license_key_prefix: Mapped[str] = mapped_column(
String(12),
nullable=False,
)
# First 12 chars for display: "lb_inst_abc1"
license_status: Mapped[str] = mapped_column(
String(50),
default="active",
nullable=False,
)
# "active", "suspended", "expired", "revoked"
license_issued_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
license_expires_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
# None = no expiry (perpetual)
# === ACTIVATION STATE ===
activated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
# Set when instance first calls /activate
last_activation_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
# Updated on each activation call
activation_count: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
)
# === TELEMETRY ===
hub_api_key_hash: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
)
# Generated on activation, used for telemetry auth
# === METADATA ===
region: Mapped[Optional[str]] = mapped_column(
String(50),
nullable=True,
)
# e.g., "eu-west-1"
version: Mapped[Optional[str]] = mapped_column(
String(50),
nullable=True,
)
# Last reported orchestrator version
last_seen_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
# Last telemetry or heartbeat
status: Mapped[str] = mapped_column(
String(50),
default="pending",
nullable=False,
)
# "pending" (created, not yet activated), "active", "inactive", "suspended"
# Relationships
client: Mapped["Client"] = relationship(back_populates="instances")
def is_license_valid(self) -> bool:
"""Check if the license is currently valid."""
from app.models.base import utc_now
if self.license_status not in ("active",):
return False
if self.license_expires_at and self.license_expires_at < utc_now():
return False
return True

View File

@ -1,93 +0,0 @@
"""Telemetry sample model - stores aggregated metrics from orchestrators.
PRIVACY GUARANTEE: This model contains NO sensitive data fields.
Only aggregated counts, tool names, durations, and status metrics.
"""
from datetime import datetime
from uuid import UUID
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, UUIDMixin
class TelemetrySample(UUIDMixin, Base):
"""
Aggregated telemetry from an orchestrator instance.
PRIVACY: This model deliberately stores ONLY:
- Instance reference
- Time window boundaries
- Uptime counter
- Aggregated metrics (counts, durations, statuses)
It NEVER stores:
- Task payloads or results
- Environment variable values
- File contents
- Error messages or stack traces
- Any PII
De-duplication: The unique constraint on (instance_id, window_start)
prevents double-counting if the orchestrator retries submissions.
"""
__tablename__ = "telemetry_samples"
# Instance reference (FK to instances.id, not instance_id string)
instance_id: Mapped[UUID] = mapped_column(
ForeignKey("instances.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Time window for this sample
window_start: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
window_end: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
# Orchestrator uptime at time of submission
uptime_seconds: Mapped[int] = mapped_column(
Integer,
nullable=False,
)
# Aggregated metrics (stored as JSON for flexibility)
# Uses generic JSON type for SQLite test compatibility
# PostgreSQL will use native JSON support in production
# Structure matches TelemetryMetrics schema:
# {
# "agents": {"online_count": 1, "offline_count": 0, "total_count": 1},
# "tasks": {
# "by_status": {"completed": 10, "failed": 1},
# "by_type": {"SHELL": {"count": 5, "avg_duration_ms": 1200}}
# },
# "servers": {"total_count": 1}
# }
metrics: Mapped[dict] = mapped_column(
JSON,
nullable=False,
)
# Unique constraint for de-duplication
# If orchestrator retries a failed submission, this prevents duplicates
__table_args__ = (
UniqueConstraint(
"instance_id",
"window_start",
name="uq_telemetry_instance_window",
),
)
def __repr__(self) -> str:
return (
f"<TelemetrySample(instance_id={self.instance_id}, "
f"window_start={self.window_start})>"
)

View File

@ -1,72 +0,0 @@
"""Usage sample model - aggregated telemetry data.
PRIVACY GUARANTEE: This model contains NO sensitive data fields.
Only tool names, durations, and counts are stored.
"""
from datetime import datetime
from uuid import UUID
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, UUIDMixin
class UsageSample(UUIDMixin, Base):
"""
Aggregated usage statistics for an instance.
PRIVACY: This model deliberately has NO fields for:
- Environment values
- File contents
- Request/response payloads
- Screenshots
- Credentials
- Error messages or stack traces
Only metadata fields are allowed.
"""
__tablename__ = "usage_samples"
# Instance reference
instance_id: Mapped[UUID] = mapped_column(
ForeignKey("instances.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Time window
window_start: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
window_end: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
window_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
)
# "minute", "hour", "day"
# Tool (ONLY name, never payloads)
tool_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
)
# e.g., "sysadmin.env_update"
# Counts (aggregated)
call_count: Mapped[int] = mapped_column(Integer, default=0)
success_count: Mapped[int] = mapped_column(Integer, default=0)
error_count: Mapped[int] = mapped_column(Integer, default=0)
rate_limited_count: Mapped[int] = mapped_column(Integer, default=0)
# Duration stats (milliseconds)
total_duration_ms: Mapped[int] = mapped_column(Integer, default=0)
min_duration_ms: Mapped[int] = mapped_column(Integer, default=0)
max_duration_ms: Mapped[int] = mapped_column(Integer, default=0)

View File

@ -1,8 +0,0 @@
"""Hub API routes."""
from app.routes.activation import router as activation_router
from app.routes.admin import router as admin_router
from app.routes.health import router as health_router
from app.routes.telemetry import router as telemetry_router
__all__ = ["admin_router", "activation_router", "health_router", "telemetry_router"]

View File

@ -1,107 +0,0 @@
"""Instance activation endpoint.
This is the PUBLIC endpoint that client instances call to validate their license
and activate with the Hub.
"""
import hashlib
import secrets
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.db import AsyncSessionDep
from app.models.base import utc_now
from app.models.instance import Instance
from app.schemas.instance import ActivationRequest, ActivationResponse
router = APIRouter(prefix="/api/v1/instances", tags=["Activation"])
@router.post("/activate", response_model=ActivationResponse)
async def activate_instance(
request: ActivationRequest,
db: AsyncSessionDep,
) -> ActivationResponse:
"""
Activate an instance with its license key.
Called by local_bootstrap.sh before running migrations.
Returns:
- 200 + ActivationResponse on success
- 400 with error details on failure
Privacy guarantee:
- Only receives license_key and instance_id
- Never receives sensitive client data
"""
# Find instance by instance_id
result = await db.execute(
select(Instance).where(Instance.instance_id == request.instance_id)
)
instance = result.scalar_one_or_none()
if instance is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "Instance not found", "code": "instance_not_found"},
)
# Validate license key using constant-time comparison
provided_hash = hashlib.sha256(request.license_key.encode()).hexdigest()
if not secrets.compare_digest(provided_hash, instance.license_key_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "Invalid license key", "code": "invalid_license"},
)
# Check license status
if instance.license_status == "suspended":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "License suspended", "code": "suspended"},
)
if instance.license_status == "revoked":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "License revoked", "code": "revoked"},
)
# Check expiry
now = utc_now()
if instance.license_expires_at and instance.license_expires_at < now:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "License expired", "code": "expired"},
)
# Update activation state
if instance.activated_at is None:
instance.activated_at = now
instance.last_activation_at = now
instance.activation_count += 1
instance.status = "active"
# Generate hub_api_key if not already set
hub_api_key: str
if instance.hub_api_key_hash:
# Key was pre-generated, client should use existing key
hub_api_key = "USE_EXISTING"
else:
# Generate new hub_api_key
hub_api_key = f"hk_{secrets.token_hex(24)}"
instance.hub_api_key_hash = hashlib.sha256(hub_api_key.encode()).hexdigest()
await db.commit()
return ActivationResponse(
status="ok",
instance_id=instance.instance_id,
hub_api_key=hub_api_key,
config={
"telemetry_enabled": True,
"telemetry_interval_seconds": 60,
},
)

View File

@ -1,400 +0,0 @@
"""Admin routes for client and instance management."""
import hashlib
import secrets
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.config import settings
from app.db import AsyncSessionDep
from app.models.base import utc_now
from app.models.client import Client
from app.models.instance import Instance
from app.schemas.client import ClientCreate, ClientResponse, ClientUpdate
from app.schemas.instance import InstanceBriefResponse, InstanceCreate, InstanceResponse
def validate_admin_key(
x_admin_api_key: Annotated[str, Header(description="Admin API key")],
) -> str:
"""Validate the admin API key with constant-time comparison."""
if not secrets.compare_digest(x_admin_api_key, settings.ADMIN_API_KEY):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin API key",
)
return x_admin_api_key
AdminKeyDep = Annotated[str, Depends(validate_admin_key)]
router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
# ============ CLIENT MANAGEMENT ============
@router.post("/clients", response_model=ClientResponse, status_code=status.HTTP_201_CREATED)
async def create_client(
client: ClientCreate,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> Client:
"""Create a new client (company/organization)."""
db_client = Client(
name=client.name,
contact_email=client.contact_email,
billing_plan=client.billing_plan,
)
db.add(db_client)
await db.commit()
await db.refresh(db_client)
return db_client
@router.get("/clients", response_model=list[ClientResponse])
async def list_clients(
db: AsyncSessionDep,
_: AdminKeyDep,
) -> list[Client]:
"""List all clients."""
result = await db.execute(select(Client).order_by(Client.created_at.desc()))
return list(result.scalars().all())
@router.get("/clients/{client_id}", response_model=ClientResponse)
async def get_client(
client_id: UUID,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> Client:
"""Get a specific client by ID."""
result = await db.execute(select(Client).where(Client.id == client_id))
client = result.scalar_one_or_none()
if client is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Client not found",
)
return client
@router.patch("/clients/{client_id}", response_model=ClientResponse)
async def update_client(
client_id: UUID,
update: ClientUpdate,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> Client:
"""Update a client."""
result = await db.execute(select(Client).where(Client.id == client_id))
client = result.scalar_one_or_none()
if client is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Client not found",
)
update_data = update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(client, field, value)
await db.commit()
await db.refresh(client)
return client
@router.delete("/clients/{client_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_client(
client_id: UUID,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> None:
"""Delete a client and all associated instances."""
result = await db.execute(select(Client).where(Client.id == client_id))
client = result.scalar_one_or_none()
if client is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Client not found",
)
await db.delete(client)
await db.commit()
# ============ INSTANCE MANAGEMENT ============
@router.post(
"/clients/{client_id}/instances",
response_model=InstanceResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_instance(
client_id: UUID,
instance: InstanceCreate,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> dict:
"""
Create a new instance for a client.
Returns the license_key and hub_api_key in PLAINTEXT - this is the only time
they are visible. Store them securely and provide to client for their config.json.
"""
# Verify client exists
client_result = await db.execute(select(Client).where(Client.id == client_id))
client = client_result.scalar_one_or_none()
if client is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Client not found",
)
# Check instance_id uniqueness
existing = await db.execute(
select(Instance).where(Instance.instance_id == instance.instance_id)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Instance with id '{instance.instance_id}' already exists",
)
# Generate license key
license_key = f"lb_inst_{secrets.token_hex(32)}"
license_key_hash = hashlib.sha256(license_key.encode()).hexdigest()
license_key_prefix = license_key[:12]
# Generate hub API key
hub_api_key = f"hk_{secrets.token_hex(24)}"
hub_api_key_hash = hashlib.sha256(hub_api_key.encode()).hexdigest()
now = utc_now()
db_instance = Instance(
client_id=client_id,
instance_id=instance.instance_id,
license_key_hash=license_key_hash,
license_key_prefix=license_key_prefix,
license_status="active",
license_issued_at=now,
license_expires_at=instance.license_expires_at,
hub_api_key_hash=hub_api_key_hash,
region=instance.region,
status="pending",
)
db.add(db_instance)
await db.commit()
await db.refresh(db_instance)
# Return instance with plaintext keys (only time visible)
return {
"id": db_instance.id,
"instance_id": db_instance.instance_id,
"client_id": db_instance.client_id,
"license_key": license_key, # Plaintext, only time visible
"license_key_prefix": db_instance.license_key_prefix,
"license_status": db_instance.license_status,
"license_issued_at": db_instance.license_issued_at,
"license_expires_at": db_instance.license_expires_at,
"hub_api_key": hub_api_key, # Plaintext, only time visible
"activated_at": db_instance.activated_at,
"last_activation_at": db_instance.last_activation_at,
"activation_count": db_instance.activation_count,
"region": db_instance.region,
"version": db_instance.version,
"last_seen_at": db_instance.last_seen_at,
"status": db_instance.status,
"created_at": db_instance.created_at,
"updated_at": db_instance.updated_at,
}
@router.get("/clients/{client_id}/instances", response_model=list[InstanceBriefResponse])
async def list_client_instances(
client_id: UUID,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> list[Instance]:
"""List all instances for a client."""
# Verify client exists
client_result = await db.execute(select(Client).where(Client.id == client_id))
if client_result.scalar_one_or_none() is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Client not found",
)
result = await db.execute(
select(Instance)
.where(Instance.client_id == client_id)
.order_by(Instance.created_at.desc())
)
return list(result.scalars().all())
@router.get("/instances/{instance_id}", response_model=InstanceBriefResponse)
async def get_instance(
instance_id: str,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> Instance:
"""Get a specific instance by its instance_id."""
result = await db.execute(
select(Instance).where(Instance.instance_id == instance_id)
)
instance = result.scalar_one_or_none()
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Instance not found",
)
return instance
@router.post("/instances/{instance_id}/rotate-license", response_model=dict)
async def rotate_license_key(
instance_id: str,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> dict:
"""
Generate a new license key for an instance.
Invalidates the old key. Returns new key in plaintext (only time visible).
"""
result = await db.execute(
select(Instance).where(Instance.instance_id == instance_id)
)
instance = result.scalar_one_or_none()
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Instance not found",
)
new_license_key = f"lb_inst_{secrets.token_hex(32)}"
instance.license_key_hash = hashlib.sha256(new_license_key.encode()).hexdigest()
instance.license_key_prefix = new_license_key[:12]
instance.license_issued_at = utc_now()
await db.commit()
return {
"instance_id": instance.instance_id,
"license_key": new_license_key,
"license_key_prefix": instance.license_key_prefix,
"license_issued_at": instance.license_issued_at,
}
@router.post("/instances/{instance_id}/rotate-hub-key", response_model=dict)
async def rotate_hub_api_key(
instance_id: str,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> dict:
"""
Generate a new Hub API key for telemetry.
Invalidates the old key. Returns new key in plaintext (only time visible).
"""
result = await db.execute(
select(Instance).where(Instance.instance_id == instance_id)
)
instance = result.scalar_one_or_none()
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Instance not found",
)
new_hub_api_key = f"hk_{secrets.token_hex(24)}"
instance.hub_api_key_hash = hashlib.sha256(new_hub_api_key.encode()).hexdigest()
await db.commit()
return {
"instance_id": instance.instance_id,
"hub_api_key": new_hub_api_key,
}
@router.post("/instances/{instance_id}/suspend", response_model=dict)
async def suspend_instance(
instance_id: str,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> dict:
"""Suspend an instance license (blocks future activations)."""
result = await db.execute(
select(Instance).where(Instance.instance_id == instance_id)
)
instance = result.scalar_one_or_none()
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Instance not found",
)
instance.license_status = "suspended"
instance.status = "suspended"
await db.commit()
return {"instance_id": instance.instance_id, "status": "suspended"}
@router.post("/instances/{instance_id}/reactivate", response_model=dict)
async def reactivate_instance(
instance_id: str,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> dict:
"""Reactivate a suspended instance license."""
result = await db.execute(
select(Instance).where(Instance.instance_id == instance_id)
)
instance = result.scalar_one_or_none()
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Instance not found",
)
if instance.license_status == "revoked":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot reactivate a revoked license",
)
instance.license_status = "active"
instance.status = "active" if instance.activated_at else "pending"
await db.commit()
return {"instance_id": instance.instance_id, "status": instance.status}
@router.delete("/instances/{instance_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_instance(
instance_id: str,
db: AsyncSessionDep,
_: AdminKeyDep,
) -> None:
"""Delete an instance."""
result = await db.execute(
select(Instance).where(Instance.instance_id == instance_id)
)
instance = result.scalar_one_or_none()
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Instance not found",
)
await db.delete(instance)
await db.commit()

View File

@ -1,11 +0,0 @@
"""Health check endpoints."""
from fastapi import APIRouter
router = APIRouter(tags=["Health"])
@router.get("/health")
async def health_check() -> dict:
"""Basic health check endpoint."""
return {"status": "healthy"}

View File

@ -1,163 +0,0 @@
"""Telemetry endpoint for receiving metrics from orchestrators.
This endpoint receives aggregated telemetry from orchestrator instances.
It validates authentication, stores metrics, and updates instance state.
"""
import hashlib
import logging
import secrets
from fastapi import APIRouter, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError
from app.db import AsyncSessionDep
from app.models.base import utc_now
from app.models.instance import Instance
from app.models.telemetry_sample import TelemetrySample
from app.schemas.telemetry import TelemetryPayload, TelemetryResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/instances", tags=["Telemetry"])
@router.post("/{instance_id}/telemetry", response_model=TelemetryResponse)
async def receive_telemetry(
instance_id: str,
payload: TelemetryPayload,
db: AsyncSessionDep,
hub_api_key: str = Header(..., alias="X-Hub-Api-Key"),
) -> TelemetryResponse:
"""
Receive telemetry from an orchestrator instance.
Authentication:
- Requires valid X-Hub-Api-Key header matching the instance
Validation:
- instance_id in path must match payload.instance_id (prevents spoofing)
- Instance must exist and be active
- Schema uses extra="forbid" to reject unknown fields
De-duplication:
- Uses (instance_id, window_start) unique constraint
- Duplicate submissions are silently accepted (idempotent)
HTTP Semantics:
- 200 OK: Telemetry accepted
- 400 Bad Request: instance_id mismatch or invalid payload
- 401 Unauthorized: Invalid or missing hub_api_key
- 403 Forbidden: Instance suspended
- 404 Not Found: Instance not found
"""
# Validate instance_id in path matches payload
if instance_id != payload.instance_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "instance_id mismatch between path and payload",
"code": "instance_id_mismatch",
},
)
# Find instance by instance_id string (e.g., "letsbe-orchestrator")
result = await db.execute(select(Instance).where(Instance.instance_id == instance_id))
instance = result.scalar_one_or_none()
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": "Instance not found", "code": "instance_not_found"},
)
# Validate hub_api_key using constant-time comparison
if not instance.hub_api_key_hash:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"error": "Instance has no hub_api_key configured",
"code": "no_hub_key",
},
)
provided_hash = hashlib.sha256(hub_api_key.encode()).hexdigest()
if not secrets.compare_digest(provided_hash, instance.hub_api_key_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": "Invalid hub_api_key", "code": "invalid_hub_key"},
)
# Check instance status
if instance.license_status == "suspended":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Instance suspended", "code": "suspended"},
)
if instance.license_status == "revoked":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Instance revoked", "code": "revoked"},
)
# Check license expiry
now = utc_now()
if instance.license_expires_at and instance.license_expires_at < now:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "License expired", "code": "expired"},
)
# Store telemetry sample
# Use PostgreSQL upsert to handle duplicates gracefully
# Note: instance_id in DB is the UUID (instance.id), not the string instance_id
telemetry_data = {
"instance_id": instance.id,
"window_start": payload.window_start,
"window_end": payload.window_end,
"uptime_seconds": payload.uptime_seconds,
"metrics": payload.metrics.model_dump(),
}
try:
# PostgreSQL INSERT ... ON CONFLICT DO NOTHING
# If duplicate (instance_id, window_start), silently ignore
stmt = (
pg_insert(TelemetrySample)
.values(**telemetry_data)
.on_conflict_do_nothing(constraint="uq_telemetry_instance_window")
)
await db.execute(stmt)
except IntegrityError:
# Fallback for non-PostgreSQL (shouldn't happen in production)
logger.warning(
"telemetry_duplicate_submission",
extra={
"instance_id": str(instance_id),
"window_start": payload.window_start.isoformat(),
},
)
# Update instance last_seen_at
instance.last_seen_at = now
await db.commit()
logger.info(
"telemetry_received",
extra={
"instance_id": str(instance_id),
"window_start": payload.window_start.isoformat(),
"window_end": payload.window_end.isoformat(),
"uptime_seconds": payload.uptime_seconds,
},
)
return TelemetryResponse(
received=True,
next_interval_seconds=60,
message=None,
)

View File

@ -1,21 +0,0 @@
"""Hub API schemas."""
from app.schemas.client import ClientCreate, ClientResponse, ClientUpdate
from app.schemas.instance import (
ActivationError,
ActivationRequest,
ActivationResponse,
InstanceCreate,
InstanceResponse,
)
__all__ = [
"ClientCreate",
"ClientResponse",
"ClientUpdate",
"InstanceCreate",
"InstanceResponse",
"ActivationRequest",
"ActivationResponse",
"ActivationError",
]

View File

@ -1,38 +0,0 @@
"""Client schemas for API serialization."""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class ClientCreate(BaseModel):
"""Schema for creating a new client."""
name: str = Field(..., min_length=1, max_length=255, description="Client/company name")
contact_email: Optional[EmailStr] = Field(None, description="Primary contact email")
billing_plan: str = Field("free", description="Billing plan")
class ClientUpdate(BaseModel):
"""Schema for updating a client."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
contact_email: Optional[EmailStr] = None
billing_plan: Optional[str] = None
status: Optional[str] = Field(None, pattern="^(active|suspended|archived)$")
class ClientResponse(BaseModel):
"""Schema for client API responses."""
model_config = ConfigDict(from_attributes=True)
id: UUID
name: str
contact_email: Optional[str]
billing_plan: str
status: str
created_at: datetime
updated_at: datetime

View File

@ -1,127 +0,0 @@
"""Instance schemas for API serialization."""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class InstanceCreate(BaseModel):
"""Schema for creating a new instance."""
instance_id: str = Field(
...,
min_length=1,
max_length=255,
description="Unique instance identifier (e.g., 'acme-orchestrator')",
)
region: Optional[str] = Field(None, max_length=50, description="Deployment region")
license_expires_at: Optional[datetime] = Field(
None,
description="License expiry date (None = perpetual)",
)
class InstanceResponse(BaseModel):
"""Schema for instance API responses.
Note: license_key and hub_api_key are ONLY returned on creation.
"""
model_config = ConfigDict(from_attributes=True)
id: UUID
instance_id: str
client_id: UUID
# License info
license_key: Optional[str] = Field(
None,
description="ONLY returned on creation - store securely!",
)
license_key_prefix: str
license_status: str
license_issued_at: datetime
license_expires_at: Optional[datetime]
# Hub API key
hub_api_key: Optional[str] = Field(
None,
description="ONLY returned on creation - store securely!",
)
# Activation state
activated_at: Optional[datetime]
last_activation_at: Optional[datetime]
activation_count: int
# Metadata
region: Optional[str]
version: Optional[str]
last_seen_at: Optional[datetime]
status: str
created_at: datetime
updated_at: datetime
class InstanceBriefResponse(BaseModel):
"""Brief instance response for listings (no secrets)."""
model_config = ConfigDict(from_attributes=True)
id: UUID
instance_id: str
client_id: UUID
license_key_prefix: str
license_status: str
license_expires_at: Optional[datetime]
activated_at: Optional[datetime]
activation_count: int
region: Optional[str]
status: str
created_at: datetime
# === ACTIVATION SCHEMAS ===
class ActivationRequest(BaseModel):
"""
Activation request from a client instance.
PRIVACY: This schema ONLY accepts:
- license_key (credential for validation)
- instance_id (identifier)
It NEVER accepts sensitive data fields.
"""
license_key: str = Field(..., description="License key (lb_inst_...)")
instance_id: str = Field(..., description="Instance identifier")
class ActivationResponse(BaseModel):
"""Response to a successful activation."""
status: str = Field("ok", description="Activation status")
instance_id: str
hub_api_key: str = Field(
...,
description="API key for telemetry auth (or 'USE_EXISTING')",
)
config: dict = Field(
default_factory=dict,
description="Optional configuration from Hub",
)
class ActivationError(BaseModel):
"""Error response for failed activation."""
error: str = Field(..., description="Human-readable error message")
code: str = Field(
...,
description="Error code: invalid_license, expired, suspended, instance_not_found",
)

View File

@ -1,104 +0,0 @@
"""Telemetry schemas for orchestrator metrics collection.
PRIVACY GUARANTEE: These schemas use extra="forbid" to reject
unknown fields, preventing accidental PII leaks.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
# === Nested Metrics Schemas ===
class AgentMetrics(BaseModel):
"""Agent status counts."""
model_config = ConfigDict(extra="forbid")
online_count: int = Field(ge=0, description="Agents currently online")
offline_count: int = Field(ge=0, description="Agents currently offline")
total_count: int = Field(ge=0, description="Total registered agents")
class TaskTypeMetrics(BaseModel):
"""Per-task-type metrics."""
model_config = ConfigDict(extra="forbid")
count: int = Field(ge=0, description="Number of tasks of this type")
avg_duration_ms: Optional[float] = Field(
None,
ge=0,
description="Average duration in milliseconds",
)
class TaskMetrics(BaseModel):
"""Task execution metrics."""
model_config = ConfigDict(extra="forbid")
by_status: dict[str, int] = Field(
default_factory=dict,
description="Task counts by status (completed, failed, running, pending)",
)
by_type: dict[str, TaskTypeMetrics] = Field(
default_factory=dict,
description="Task metrics by type (SHELL, FILE_WRITE, etc.)",
)
class ServerMetrics(BaseModel):
"""Server metrics."""
model_config = ConfigDict(extra="forbid")
total_count: int = Field(ge=0, description="Total registered servers")
class TelemetryMetrics(BaseModel):
"""Top-level metrics container."""
model_config = ConfigDict(extra="forbid")
agents: AgentMetrics
tasks: TaskMetrics
servers: ServerMetrics
# === Request/Response Schemas ===
class TelemetryPayload(BaseModel):
"""
Telemetry payload from an orchestrator instance.
PRIVACY: This schema deliberately uses extra="forbid" to reject
any fields not explicitly defined. This prevents accidental
transmission of PII or sensitive data.
De-duplication: The Hub uses (instance_id, window_start) as a
unique constraint to handle duplicate submissions.
"""
model_config = ConfigDict(extra="forbid")
instance_id: str = Field(..., description="Instance ID string (must match path)")
window_start: datetime = Field(..., description="Start of telemetry window")
window_end: datetime = Field(..., description="End of telemetry window")
uptime_seconds: int = Field(ge=0, description="Orchestrator uptime in seconds")
metrics: TelemetryMetrics = Field(..., description="Aggregated metrics")
class TelemetryResponse(BaseModel):
"""Response to telemetry submission."""
received: bool = Field(True, description="Whether telemetry was accepted")
next_interval_seconds: int = Field(
60,
description="Suggested interval for next submission",
)
message: Optional[str] = Field(None, description="Optional status message")

View File

@ -1,5 +0,0 @@
"""Hub services."""
from app.services.redactor import redact_metadata, validate_tool_name
__all__ = ["redact_metadata", "validate_tool_name"]

View File

@ -1,142 +0,0 @@
"""
Strict ALLOW-LIST redaction for telemetry data.
PRIVACY GUARANTEE: If a field is not explicitly allowed, it is removed.
This module ensures NO sensitive data ever reaches the Hub database.
"""
from typing import Any
# ONLY these fields can be stored in metadata
ALLOWED_METADATA_FIELDS = frozenset({
"tool_name",
"duration_ms",
"status",
"error_code",
"component",
"version",
})
# Patterns that indicate sensitive data (defense in depth)
SENSITIVE_PATTERNS = frozenset({
"password",
"secret",
"token",
"key",
"credential",
"auth",
"cookie",
"session",
"bearer",
"content",
"body",
"payload",
"data",
"file",
"env",
"environment",
"config",
"setting",
"screenshot",
"image",
"base64",
"binary",
"private",
"cert",
"certificate",
})
def redact_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
"""
Filter metadata to ONLY allowed fields.
Uses allow-list approach: if not explicitly allowed, it's removed.
This provides defense against accidentally storing sensitive data.
Args:
metadata: Raw metadata from telemetry
Returns:
Filtered metadata with only safe fields
"""
if metadata is None:
return {}
redacted: dict[str, Any] = {}
for key, value in metadata.items():
# Must be in allow list
if key not in ALLOWED_METADATA_FIELDS:
continue
# Defense in depth: reject if key contains sensitive pattern
key_lower = key.lower()
if any(pattern in key_lower for pattern in SENSITIVE_PATTERNS):
continue
# Only primitive types (no nested objects that could hide data)
if isinstance(value, (str, int, float, bool)):
# String length limit to prevent large data blobs
if isinstance(value, str) and len(value) > 100:
continue
redacted[key] = value
return redacted
def validate_tool_name(tool_name: str) -> bool:
"""
Validate tool name format.
Tool names must:
- Start with a known prefix (sysadmin., browser., gateway.)
- Be reasonably short
- Not contain suspicious characters
Args:
tool_name: Tool name to validate
Returns:
True if valid, False otherwise
"""
# Must match known prefixes
valid_prefixes = ("sysadmin.", "browser.", "gateway.", "llm.")
if not tool_name.startswith(valid_prefixes):
return False
# Length limit
if len(tool_name) > 100:
return False
# No suspicious content
suspicious_chars = {";", "'", '"', "\\", "\n", "\r", "\t", "\0"}
if any(c in tool_name for c in suspicious_chars):
return False
return True
def sanitize_error_code(error_code: str | None) -> str | None:
"""
Sanitize an error code to ensure it doesn't contain sensitive data.
Args:
error_code: Raw error code
Returns:
Sanitized error code or None if invalid
"""
if error_code is None:
return None
# Length limit
if len(error_code) > 50:
return None
# Must be alphanumeric with underscores/dashes
allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")
if not all(c in allowed for c in error_code):
return None
return error_code

View File

@ -1,36 +1,38 @@
version: "3.8"
services:
api:
image: code.letsbe.solutions/letsbe/hub:latest
container_name: letsbe-hub-api
environment:
- DATABASE_URL=postgresql+asyncpg://hub:hub@db:5432/hub
- ADMIN_API_KEY=${ADMIN_API_KEY:-change-me-in-production}
- DEBUG=${DEBUG:-false}
ports:
- "8200:8000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:15-alpine
image: postgres:16-alpine
container_name: letsbe-hub-db
environment:
- POSTGRES_USER=hub
- POSTGRES_PASSWORD=hub
- POSTGRES_DB=hub
POSTGRES_USER: letsbe_hub
POSTGRES_PASSWORD: letsbe_hub_dev
POSTGRES_DB: letsbe_hub
ports:
- "5433:5432"
volumes:
- hub-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hub"]
test: ["CMD-SHELL", "pg_isready -U letsbe_hub -d letsbe_hub"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
hub:
build:
context: .
dockerfile: Dockerfile
container_name: letsbe-hub-app
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://letsbe_hub:letsbe_hub_dev@db:5432/letsbe_hub
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: dev-secret-change-in-production-min-32-chars
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
hub-db-data:
name: letsbe-hub-db

20
next.config.ts Normal file
View File

@ -0,0 +1,20 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
experimental: {
serverActions: {
bodySizeLimit: '2mb',
},
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.letsbe.solutions',
},
],
},
}
export default nextConfig

10572
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

69
package.json Normal file
View File

@ -0,0 +1,69 @@
{
"name": "letsbe-hub",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@auth/prisma-adapter": "^2.7.4",
"@hookform/resolvers": "^3.9.1",
"@prisma/client": "^6.2.1",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"@tanstack/react-query": "^5.64.2",
"@tanstack/react-table": "^8.20.6",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"next": "15.1.4",
"next-auth": "5.0.0-beta.25",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.5",
"@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "15.1.4",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prisma": "^6.2.1",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^2.1.8"
}
}

9
postcss.config.js Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
module.exports = config

263
prisma/schema.prisma Normal file
View File

@ -0,0 +1,263 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================================================
// ENUMS
// ============================================================================
enum UserStatus {
PENDING_VERIFICATION
ACTIVE
SUSPENDED
}
enum StaffRole {
ADMIN
SUPPORT
}
enum SubscriptionPlan {
TRIAL
STARTER
PRO
ENTERPRISE
}
enum SubscriptionTier {
HUB_DASHBOARD
ADVANCED
}
enum SubscriptionStatus {
TRIAL
ACTIVE
CANCELED
PAST_DUE
}
enum OrderStatus {
PAYMENT_CONFIRMED
AWAITING_SERVER
SERVER_READY
DNS_PENDING
DNS_READY
PROVISIONING
FULFILLED
EMAIL_CONFIGURED
FAILED
}
enum JobStatus {
PENDING
CLAIMED
RUNNING
COMPLETED
FAILED
DEAD
}
enum LogLevel {
DEBUG
INFO
WARN
ERROR
}
// ============================================================================
// USER & STAFF MODELS
// ============================================================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
name String?
company String?
status UserStatus @default(PENDING_VERIFICATION)
emailVerified DateTime? @map("email_verified")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
subscriptions Subscription[]
orders Order[]
tokenUsage TokenUsage[]
@@map("users")
}
model Staff {
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
name String
role StaffRole @default(SUPPORT)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("staff")
}
// ============================================================================
// SUBSCRIPTION & BILLING
// ============================================================================
model Subscription {
id String @id @default(cuid())
userId String @map("user_id")
plan SubscriptionPlan @default(TRIAL)
tier SubscriptionTier @default(HUB_DASHBOARD)
tokenLimit Int @default(10000) @map("token_limit")
tokensUsed Int @default(0) @map("tokens_used")
trialEndsAt DateTime? @map("trial_ends_at")
stripeCustomerId String? @map("stripe_customer_id")
stripeSubscriptionId String? @map("stripe_subscription_id")
status SubscriptionStatus @default(TRIAL)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("subscriptions")
}
// ============================================================================
// ORDERS & PROVISIONING
// ============================================================================
model Order {
id String @id @default(cuid())
userId String @map("user_id")
status OrderStatus @default(PAYMENT_CONFIRMED)
tier SubscriptionTier
domain String
tools String[]
configJson Json @map("config_json")
// Server credentials (entered by staff)
serverIp String? @map("server_ip")
serverPasswordEncrypted String? @map("server_password_encrypted")
sshPort Int @default(22) @map("ssh_port")
// Generated after provisioning
portainerUrl String? @map("portainer_url")
dashboardUrl String? @map("dashboard_url")
failureReason String? @map("failure_reason")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
serverReadyAt DateTime? @map("server_ready_at")
provisioningStartedAt DateTime? @map("provisioning_started_at")
completedAt DateTime? @map("completed_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provisioningLogs ProvisioningLog[]
jobs ProvisioningJob[]
@@map("orders")
}
model ProvisioningLog {
id String @id @default(cuid())
orderId String @map("order_id")
level LogLevel @default(INFO)
message String
step String?
timestamp DateTime @default(now())
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([orderId, timestamp])
@@map("provisioning_logs")
}
// ============================================================================
// JOB QUEUE
// ============================================================================
model ProvisioningJob {
id String @id @default(cuid())
orderId String @map("order_id")
jobType String @map("job_type")
status JobStatus @default(PENDING)
priority Int @default(0)
claimedAt DateTime? @map("claimed_at")
claimedBy String? @map("claimed_by")
containerName String? @map("container_name")
attempt Int @default(1)
maxAttempts Int @default(3) @map("max_attempts")
nextRetryAt DateTime? @map("next_retry_at")
configSnapshot Json @map("config_snapshot")
runnerTokenHash String? @map("runner_token_hash")
result Json?
error String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
completedAt DateTime? @map("completed_at")
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
logs JobLog[]
@@index([status, priority, createdAt])
@@index([orderId])
@@map("provisioning_jobs")
}
model JobLog {
id String @id @default(cuid())
jobId String @map("job_id")
level LogLevel @default(INFO)
message String
step String?
progress Int?
timestamp DateTime @default(now())
job ProvisioningJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
@@index([jobId, timestamp])
@@map("job_logs")
}
// ============================================================================
// TOKEN USAGE (AI Tracking)
// ============================================================================
model TokenUsage {
id String @id @default(cuid())
userId String @map("user_id")
instanceId String? @map("instance_id")
operation String // chat, analysis, setup
tokensInput Int @map("tokens_input")
tokensOutput Int @map("tokens_output")
model String
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@map("token_usage")
}
// ============================================================================
// RUNNER TOKENS
// ============================================================================
model RunnerToken {
id String @id @default(cuid())
tokenHash String @unique @map("token_hash")
name String
isActive Boolean @default(true) @map("is_active")
lastUsed DateTime? @map("last_used")
createdAt DateTime @default(now()) @map("created_at")
@@map("runner_tokens")
}

320
prisma/seed.ts Normal file
View File

@ -0,0 +1,320 @@
import { PrismaClient, OrderStatus, SubscriptionPlan, SubscriptionTier, SubscriptionStatus, UserStatus, LogLevel } from '@prisma/client'
import bcrypt from 'bcryptjs'
const { hash } = bcrypt
const prisma = new PrismaClient()
// Random data helpers
const companies = [
'Acme Corp', 'TechStart Inc', 'Digital Solutions', 'CloudFirst Ltd',
'InnovateTech', 'DataDriven Co', 'SmartBiz Solutions', 'FutureTech Labs',
'AgileWorks', 'NextGen Systems', null, null, null
]
const domains = [
'acme.letsbe.cloud', 'techstart.letsbe.cloud', 'digital.letsbe.cloud',
'cloudfirst.letsbe.cloud', 'innovate.letsbe.cloud', 'datadriven.letsbe.cloud',
'smartbiz.letsbe.cloud', 'futuretech.letsbe.cloud', 'agileworks.letsbe.cloud',
'nextgen.letsbe.cloud', 'startup.letsbe.cloud', 'enterprise.letsbe.cloud',
'demo.letsbe.cloud', 'test.letsbe.cloud', 'dev.letsbe.cloud'
]
const toolSets = {
basic: ['nextcloud', 'keycloak'],
standard: ['nextcloud', 'keycloak', 'minio', 'poste'],
advanced: ['nextcloud', 'keycloak', 'minio', 'poste', 'n8n', 'filebrowser'],
full: ['nextcloud', 'keycloak', 'minio', 'poste', 'n8n', 'filebrowser', 'portainer', 'grafana'],
}
const logMessages = {
PAYMENT_CONFIRMED: ['Payment received via Stripe', 'Order confirmed'],
AWAITING_SERVER: ['Waiting for server allocation', 'Server request submitted to provider'],
SERVER_READY: ['Server provisioned', 'SSH access verified', 'Root password received'],
DNS_PENDING: ['DNS records submitted', 'Waiting for DNS propagation'],
DNS_READY: ['DNS records verified', 'Domain is resolving correctly'],
PROVISIONING: [
'Starting provisioning process',
'Downloading Docker images',
'Configuring Nginx reverse proxy',
'Installing Keycloak',
'Configuring Nextcloud',
'Setting up MinIO storage',
'Configuring email server',
'Running health checks',
],
FULFILLED: ['Provisioning complete', 'All services healthy', 'Welcome email sent'],
EMAIL_CONFIGURED: ['SMTP credentials configured', 'Email sending verified'],
FAILED: ['Provisioning failed', 'See error details below'],
}
function randomDate(daysAgo: number): Date {
const date = new Date()
date.setDate(date.getDate() - Math.floor(Math.random() * daysAgo))
date.setHours(Math.floor(Math.random() * 24))
date.setMinutes(Math.floor(Math.random() * 60))
return date
}
function randomChoice<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]
}
async function main() {
console.log('Starting seed...')
// 1. Create admin user if not exists
const adminEmail = process.env.ADMIN_EMAIL || 'admin@letsbe.solutions'
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'
const existingAdmin = await prisma.staff.findUnique({
where: { email: adminEmail },
})
if (!existingAdmin) {
const passwordHash = await hash(adminPassword, 12)
await prisma.staff.create({
data: {
email: adminEmail,
passwordHash,
name: 'Admin',
role: 'ADMIN',
},
})
console.log(`Created admin user: ${adminEmail}`)
} else {
console.log(`Admin user ${adminEmail} already exists`)
}
// 2. Create support staff
const supportEmail = 'support@letsbe.solutions'
const existingSupport = await prisma.staff.findUnique({
where: { email: supportEmail },
})
if (!existingSupport) {
const passwordHash = await hash('support123', 12)
await prisma.staff.create({
data: {
email: supportEmail,
passwordHash,
name: 'Support Agent',
role: 'SUPPORT',
},
})
console.log(`Created support user: ${supportEmail}`)
}
// 3. Create test customers
const customerData = [
{ email: 'john@acme.com', name: 'John Smith', company: 'Acme Corp', status: UserStatus.ACTIVE },
{ email: 'sarah@techstart.io', name: 'Sarah Johnson', company: 'TechStart Inc', status: UserStatus.ACTIVE },
{ email: 'mike@cloudfirst.co', name: 'Mike Davis', company: 'CloudFirst Ltd', status: UserStatus.ACTIVE },
{ email: 'emma@digital.io', name: 'Emma Wilson', company: 'Digital Solutions', status: UserStatus.ACTIVE },
{ email: 'david@innovate.co', name: 'David Brown', company: 'InnovateTech', status: UserStatus.ACTIVE },
{ email: 'lisa@datadriven.io', name: 'Lisa Chen', company: 'DataDriven Co', status: UserStatus.ACTIVE },
{ email: 'james@smartbiz.com', name: 'James Miller', company: 'SmartBiz Solutions', status: UserStatus.ACTIVE },
{ email: 'amy@futuretech.io', name: 'Amy Taylor', company: 'FutureTech Labs', status: UserStatus.PENDING_VERIFICATION },
{ email: 'robert@agile.co', name: 'Robert Anderson', company: 'AgileWorks', status: UserStatus.ACTIVE },
{ email: 'jennifer@nextgen.io', name: 'Jennifer Lee', company: 'NextGen Systems', status: UserStatus.SUSPENDED },
{ email: 'freelancer@gmail.com', name: 'Alex Freelancer', company: null, status: UserStatus.ACTIVE },
{ email: 'startup@mail.com', name: 'Startup Founder', company: null, status: UserStatus.PENDING_VERIFICATION },
]
const customers: { id: string; email: string }[] = []
for (const customer of customerData) {
const existing = await prisma.user.findUnique({
where: { email: customer.email },
})
if (!existing) {
const passwordHash = await hash('customer123', 12)
const created = await prisma.user.create({
data: {
email: customer.email,
passwordHash,
name: customer.name,
company: customer.company,
status: customer.status,
emailVerified: customer.status === UserStatus.ACTIVE ? new Date() : null,
},
})
customers.push({ id: created.id, email: created.email })
console.log(`Created customer: ${customer.email}`)
} else {
customers.push({ id: existing.id, email: existing.email })
console.log(`Customer ${customer.email} already exists`)
}
}
// 4. Create subscriptions for customers
const subscriptionConfigs = [
{ plan: SubscriptionPlan.ENTERPRISE, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.PRO, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.PRO, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.PRO, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.PAST_DUE },
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.TRIAL, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.TRIAL },
{ plan: SubscriptionPlan.ENTERPRISE, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.CANCELED },
{ plan: SubscriptionPlan.STARTER, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.ACTIVE },
{ plan: SubscriptionPlan.TRIAL, tier: SubscriptionTier.ADVANCED, status: SubscriptionStatus.TRIAL },
{ plan: SubscriptionPlan.TRIAL, tier: SubscriptionTier.HUB_DASHBOARD, status: SubscriptionStatus.TRIAL },
]
for (let i = 0; i < customers.length; i++) {
const customer = customers[i]
const config = subscriptionConfigs[i] || subscriptionConfigs[0]
const existingSub = await prisma.subscription.findFirst({
where: { userId: customer.id },
})
if (!existingSub) {
await prisma.subscription.create({
data: {
userId: customer.id,
plan: config.plan,
tier: config.tier,
status: config.status,
tokenLimit: config.plan === SubscriptionPlan.ENTERPRISE ? 100000 :
config.plan === SubscriptionPlan.PRO ? 50000 :
config.plan === SubscriptionPlan.STARTER ? 20000 : 10000,
tokensUsed: Math.floor(Math.random() * 5000),
trialEndsAt: config.status === SubscriptionStatus.TRIAL ?
new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) : null,
},
})
console.log(`Created subscription for: ${customer.email}`)
}
}
// 5. Create orders with various statuses
const orderConfigs = [
// Orders in various pipeline stages
{ status: OrderStatus.PAYMENT_CONFIRMED, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.PAYMENT_CONFIRMED, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.AWAITING_SERVER, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.AWAITING_SERVER, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.SERVER_READY, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.DNS_PENDING, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.DNS_PENDING, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.DNS_READY, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.DNS_READY, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.PROVISIONING, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.FULFILLED, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.FULFILLED, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.FULFILLED, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.EMAIL_CONFIGURED, tier: SubscriptionTier.HUB_DASHBOARD },
{ status: OrderStatus.EMAIL_CONFIGURED, tier: SubscriptionTier.ADVANCED },
{ status: OrderStatus.FAILED, tier: SubscriptionTier.HUB_DASHBOARD },
]
for (let i = 0; i < orderConfigs.length; i++) {
const config = orderConfigs[i]
const customer = customers[i % customers.length]
const domain = domains[i % domains.length]
const existingOrder = await prisma.order.findFirst({
where: { domain },
})
if (!existingOrder) {
const tools = config.tier === SubscriptionTier.HUB_DASHBOARD
? toolSets.full
: randomChoice([toolSets.basic, toolSets.standard, toolSets.advanced])
const createdAt = randomDate(30)
const serverStatuses: OrderStatus[] = [
OrderStatus.SERVER_READY, OrderStatus.DNS_PENDING, OrderStatus.DNS_READY,
OrderStatus.PROVISIONING, OrderStatus.FULFILLED, OrderStatus.EMAIL_CONFIGURED,
OrderStatus.FAILED
]
const hasServer = serverStatuses.includes(config.status)
const order = await prisma.order.create({
data: {
userId: customer.id,
status: config.status,
tier: config.tier,
domain,
tools,
configJson: { tools, tier: config.tier, domain },
serverIp: hasServer ? `192.168.1.${100 + i}` : null,
serverPasswordEncrypted: hasServer ? 'encrypted_placeholder' : null,
sshPort: 22,
portainerUrl: config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
? `https://portainer.${domain}` : null,
dashboardUrl: config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
? `https://dashboard.${domain}` : null,
failureReason: config.status === OrderStatus.FAILED
? 'Connection timeout during Docker installation' : null,
createdAt,
serverReadyAt: hasServer ? new Date(createdAt.getTime() + 2 * 60 * 60 * 1000) : null,
provisioningStartedAt: config.status === OrderStatus.PROVISIONING ||
config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
? new Date(createdAt.getTime() + 4 * 60 * 60 * 1000) : null,
completedAt: config.status === OrderStatus.FULFILLED || config.status === OrderStatus.EMAIL_CONFIGURED
? new Date(createdAt.getTime() + 5 * 60 * 60 * 1000) : null,
},
})
// Add provisioning logs based on status
const statusIndex = Object.keys(logMessages).indexOf(config.status)
const statusesToLog = Object.keys(logMessages).slice(0, statusIndex + 1) as OrderStatus[]
let logTime = new Date(createdAt)
for (const logStatus of statusesToLog) {
const messages = logMessages[logStatus] || []
for (const message of messages) {
await prisma.provisioningLog.create({
data: {
orderId: order.id,
level: logStatus === OrderStatus.FAILED ? LogLevel.ERROR : LogLevel.INFO,
message,
step: logStatus,
timestamp: new Date(logTime),
},
})
logTime = new Date(logTime.getTime() + Math.random() * 5 * 60 * 1000) // 0-5 min later
}
}
console.log(`Created order: ${domain} (${config.status})`)
}
}
// 6. Create a runner token for testing
const runnerTokenHash = await hash('test-runner-token', 12)
const existingRunner = await prisma.runnerToken.findFirst({
where: { name: 'test-runner' },
})
if (!existingRunner) {
await prisma.runnerToken.create({
data: {
tokenHash: runnerTokenHash,
name: 'test-runner',
isActive: true,
},
})
console.log('Created test runner token')
}
console.log('\nSeed completed successfully!')
console.log('\nTest credentials:')
console.log(' Admin: admin@letsbe.solutions / admin123')
console.log(' Support: support@letsbe.solutions / support123')
console.log(' Customers: <email> / customer123')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

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
}
}

57
tailwind.config.ts Normal file
View File

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

27
tsconfig.json Normal file
View File

@ -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"]
}