feat(dashboards): migrate admin + tracker dashboards to common-plat as product-agnostic

- Copy admin-dashboard-web → dashboards/admin-web
- Copy tracker-dashboard-web → dashboards/tracker-web
- Update pnpm-workspace.yaml to include dashboards/*
- Replace file: refs with workspace:* for @bytelyst/* packages
- Replace all hardcoded LysnrAI/lysnn.com branding with generic platform refs
- Make telemetry use NEXT_PUBLIC_PRODUCT_ID / PRODUCT_ID env vars
- Update mock credentials, seed data, invitation codes, placeholders
- Update READMEs, e2e tests, unit tests for product-agnostic content
- Both dashboards pass tsc --noEmit clean
This commit is contained in:
saravanakumardb1 2026-02-28 02:17:35 -08:00
parent 9a5e93bf05
commit 2d54795c30
218 changed files with 59945 additions and 4454 deletions

View File

@ -0,0 +1,29 @@
{
"files": [
{
"path": ".next/static/chunks/pages/_app-*.js",
"maxSize": "150kb",
"compression": "gzip"
},
{
"path": ".next/static/chunks/pages/_document-*.js",
"maxSize": "10kb",
"compression": "gzip"
},
{
"path": ".next/static/chunks/main-*.js",
"maxSize": "50kb",
"compression": "gzip"
},
{
"path": ".next/static/chunks/webpack-*.js",
"maxSize": "50kb",
"compression": "gzip"
},
{
"path": ".next/static/chunks/framework-*.js",
"maxSize": "100kb",
"compression": "gzip"
}
]
}

View File

@ -0,0 +1,4 @@
node_modules
.next
.env.local
.git

View File

@ -0,0 +1,40 @@
# Admin Dashboard — Environment Variables
# Copy this file to .env.local and fill in the values.
#
# This dashboard is product-agnostic. Set PRODUCT_ID to deploy for any ByteLyst product.
# ── Product Identity ──
PRODUCT_ID=lysnrai
NEXT_PUBLIC_PRODUCT_ID=lysnrai
# ── Cosmos DB (via @bytelyst/cosmos package) ──
COSMOS_ENDPOINT=https://your-account.documents.azure.com:443/
COSMOS_KEY=your-cosmos-key
COSMOS_DATABASE=lysnrai
# ── Auth ──
JWT_SECRET=your-jwt-secret
# ── Microservice URLs (consolidated platform-service) ──
PLATFORM_SERVICE_URL=http://localhost:4003
BILLING_INTERNAL_KEY=
# ── Stripe ──
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_PRO=price_...
STRIPE_PRICE_ENTERPRISE=price_...
# ── Seed (development only) ──
SEED_SECRET=your-seed-secret
# ── Optional: AI Chat ──
PERPLEXITY_API_KEY=
# ── Optional: Analytics ──
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
# ── Optional: Docs path ──
# DOCS_DIR=

View File

@ -0,0 +1,44 @@
# Admin dashboard env template (DO NOT COMMIT REAL VALUES)
# Azure Cosmos DB (Azure resource kept old name)
COSMOS_ENDPOINT=https://cosmos-mywisprai.documents.azure.com:443/
COSMOS_KEY=<cosmos-key>
COSMOS_DATABASE=lysnrai
COSMOS_REGION=westus2
# JWT Secret (must match across all apps/services)
JWT_SECRET=<jwt-secret>
# Seed secret (POST /api/seed?secret=<value> to init containers + default users)
SEED_SECRET=<seed-secret>
# Azure Key Vault (optional)
AZURE_KEYVAULT_URL=https://kv-mywisprai.vault.azure.net/
# Azure Speech Service
AZURE_SPEECH_KEY=<azure-speech-key>
AZURE_SPEECH_REGION=eastus
# Azure OpenAI
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/
AZURE_OPENAI_KEY=<azure-openai-key>
AZURE_OPENAI_DEPLOYMENT=gpt-4o-mini
# Stripe (for promo code management) — test/live
STRIPE_SECRET_KEY=sk_test_...
# Backend API (FastAPI)
API_BASE_URL=http://localhost:8000
# Microservice URLs (consolidated platform-service)
PLATFORM_SERVICE_URL=http://localhost:4003
BILLING_INTERNAL_KEY=<billing-internal-key>
# Azure Blob Storage
AZURE_BLOB_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=bytelystblobs;AccountKey=<blob-account-key>;EndpointSuffix=core.windows.net
AZURE_BLOB_ACCOUNT_NAME=bytelystblobs
AZURE_BLOB_ACCOUNT_KEY=<blob-account-key>
# Perplexity AI (admin docs chatbot / RAG)
PERPLEXITY_API_KEY=pplx-...

42
dashboards/admin-web/.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files — .env.local IS committed (user preference)
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
test-results/
playwright-report/

View File

@ -0,0 +1 @@
20

View File

@ -0,0 +1,40 @@
FROM node:20-alpine AS base
# Build
FROM base AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
# Copy pre-built @bytelyst/* packages (run scripts/docker-prep-dashboards.sh first)
# file: refs point to ../../learning_ai_common_plat/packages/* relative to /app
COPY .docker-deps/@bytelyst/ /learning_ai_common_plat/packages/
RUN npm ci
COPY . .
# Dummy env vars for Next.js build (page data collection requires these at build time)
ENV COSMOS_ENDPOINT=https://placeholder.documents.azure.com:443/
ENV COSMOS_KEY=placeholder==
ENV COSMOS_DATABASE=lysnrai
ENV JWT_SECRET=build-time-placeholder
RUN npm run build
# Production
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
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3001
ENV PORT=3001
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@ -0,0 +1,206 @@
# Platform Admin Dashboard
Product-agnostic admin console for the ByteLyst platform. Connects **directly** to Azure Cosmos DB via the `@azure/cosmos` SDK — no intermediate API server. Set `PRODUCT_ID` in `.env.local` to deploy for any product (e.g. `lysnrai`, `chronomind`, `nomgap`, `mindlyst`).
---
## Tech Stack
| Layer | Technology |
| ------------- | ------------------------------------------------ |
| **Framework** | Next.js 16 (App Router, React 19) |
| **Styling** | TailwindCSS v4 + shadcn/ui (New York style) |
| **Database** | Azure Cosmos DB (NoSQL, Serverless) — direct SDK |
| **Auth** | JWT (jose) + bcrypt (bcryptjs), server-side only |
| **Charts** | Recharts |
| **Icons** | Lucide React |
| **Language** | TypeScript 5 |
## Getting Started
```bash
npm install
cp .env.local.example .env.local # Fill in real Azure credentials
npm run dev # http://localhost:3001
npm run check # TypeScript + ESLint
npm run build # Production build
```
### Seed the Database
Creates all Cosmos containers + default admin/viewer users:
```bash
curl -X POST "http://localhost:3001/api/seed?secret=<SEED_SECRET>"
```
### Default Logins
| Email | Password | Role |
| ------------------ | ----------- | ----------- |
| `admin@example.com` | `Admin123!` | Super Admin |
| `viewer@example.com` | `viewer123` | Viewer |
## Environment Variables
Copy `.env.local.example``.env.local`:
| Variable | Required | Description |
| ------------------------- | -------- | ---------------------------------------- |
| `COSMOS_ENDPOINT` | Yes | Cosmos DB endpoint URL |
| `COSMOS_KEY` | Yes | Cosmos DB primary key |
| `COSMOS_DATABASE` | No | Database name (set per product) |
| `COSMOS_REGION` | No | Region (default: `westus2`) |
| `JWT_SECRET` | Yes | Shared JWT signing secret |
| `SEED_SECRET` | Yes | Secret for POST /api/seed |
| `AZURE_KEYVAULT_URL` | No | Azure Key Vault URL |
| `AZURE_SPEECH_KEY` | No | Speech service key (for future features) |
| `AZURE_SPEECH_REGION` | No | Speech region |
| `AZURE_OPENAI_ENDPOINT` | No | OpenAI endpoint |
| `AZURE_OPENAI_KEY` | No | OpenAI key |
| `AZURE_OPENAI_DEPLOYMENT` | No | Model deployment name |
## Pages
| Route | Description |
| ---------------- | ---------------------------------------------------------------- |
| `/` | Dashboard overview — KPIs, charts, recent activity |
| `/users` | User management — search, filter, detail dialogs |
| `/subscriptions` | Plan management — pricing, features, create plans |
| `/tokens` | API token management — create, revoke, scopes |
| `/usage` | Usage analytics — token/request charts, model costs |
| `/audit` | Audit log — admin actions, security events |
| `/settings` | Platform config — kill switch, Azure, rate limits, notifications |
| `/login` | Authentication page |
## API Routes
All routes are in `src/app/api/`:
| Endpoint | Method | Description |
| --------------------------- | -------------- | ------------------------------------------ |
| `/api/auth/login` | POST | Authenticate user (email + password → JWT) |
| `/api/auth/me` | GET | Get current user from Bearer token |
| `/api/users` | GET/POST | List/create users |
| `/api/users/[id]` | GET/PUT/DELETE | User CRUD by ID |
| `/api/tokens` | GET/POST | List/create API tokens |
| `/api/usage` | GET | Usage analytics |
| `/api/audit` | GET | Audit log entries |
| `/api/dashboard/stats` | GET | Dashboard KPI statistics |
| `/api/settings/kill-switch` | GET/PUT | Platform kill switch (read/toggle) |
| `/api/seed` | POST | Initialize containers + seed default users |
## Architecture
```
src/
├── app/
│ ├── (dashboard)/ # Protected routes (sidebar + auth guard)
│ │ ├── layout.tsx # Dashboard shell (sidebar, error boundary)
│ │ ├── loading.tsx # Skeleton loading state
│ │ ├── page.tsx # Dashboard overview
│ │ ├── users/page.tsx
│ │ ├── subscriptions/page.tsx
│ │ ├── tokens/page.tsx
│ │ ├── usage/page.tsx
│ │ ├── audit/page.tsx
│ │ └── settings/page.tsx # Includes kill switch toggle
│ ├── api/ # Next.js API routes → direct Cosmos DB
│ │ ├── auth/login/route.ts
│ │ ├── auth/me/route.ts
│ │ ├── users/route.ts
│ │ ├── users/[id]/route.ts
│ │ ├── tokens/route.ts
│ │ ├── usage/route.ts
│ │ ├── audit/route.ts
│ │ ├── dashboard/stats/route.ts
│ │ ├── settings/kill-switch/route.ts
│ │ └── seed/route.ts
│ ├── login/page.tsx # Public login page
│ ├── layout.tsx # Root layout (providers)
│ └── providers.tsx # Auth + Theme providers
├── components/
│ ├── ui/ # 16 shadcn/ui primitives (avatar, badge, button, card, etc.)
│ ├── sidebar-nav.tsx # Responsive sidebar (mobile hamburger)
│ ├── auth-guard.tsx # Route protection → redirect to /login
│ └── error-boundary.tsx # Catch page crashes gracefully
└── lib/
├── cosmos.ts # Cosmos DB client singleton + container registry
├── auth-server.ts # JWT create/verify + bcrypt (server-side only)
├── auth-context.tsx # Client auth provider (localStorage tokens)
├── theme-context.tsx # Dark/light/system mode
├── api.ts # Client-side API helper functions
├── mock-data.ts # Mock data + types (fallback when Cosmos unavailable)
├── utils.ts # cn() tailwind merge helper
└── repositories/ # Direct Cosmos DB CRUD
├── users.ts # getUserById, getUserByEmail, createUser, updateUser
├── tokens.ts # API token CRUD
├── audit.ts # Audit log read/write
└── usage.ts # Usage statistics
```
## Cosmos DB Integration
The dashboard connects **directly** to Cosmos DB — no intermediate backend server.
**Pattern:**
```
Page Component → fetch("/api/...") → API Route → Repository → Cosmos SDK → Azure
```
**Key files:**
- `lib/cosmos.ts` — singleton `CosmosClient`, `getContainer(name)`, `initializeAllContainers()`
- `lib/repositories/*.ts` — CRUD functions per container
- `lib/auth-server.ts``authenticateUser()`, `createAccessToken()`, `verifyToken()`
**Container definitions** (in `cosmos.ts`):
| Container | Partition Key | TTL |
| ------------- | ------------- | ------- |
| `users` | `/id` | — |
| `licenses` | `/userId` | — |
| `transcripts` | `/userId` | — |
| `usage_daily` | `/userId` | 1 year |
| `settings` | `/userId` | — |
| `audit_log` | `/category` | 90 days |
| `api_tokens` | `/userId` | — |
| `devices` | `/userId` | — |
## Kill Switch
The Settings page has a prominent kill switch card at the top:
- **Green** = platform active, **Red** = platform disabled
- Toggle writes to `settings` container: `{ id: "kill_switch", userId: "system", enabled: bool }`
- All apps (desktop, mobile, web) read this document to check platform status
## Features
- **Direct Cosmos DB** — no middleware, API routes call SDK directly
- **JWT auth** — bcrypt password hashing, HS256 JWT tokens
- **Auth guard** — redirects to `/login` if not authenticated
- **Mock fallback** — pages fall back to mock data if Cosmos is unavailable
- **Responsive** — sidebar collapses to hamburger on mobile
- **Dark mode** — toggle in sidebar footer, persisted to localStorage
- **Error boundary** — catches page crashes gracefully
- **Kill switch** — global platform toggle on Settings page
## Docker
```bash
docker build -t platform-admin .
docker run -p 3001:3000 --env-file .env.local platform-admin
# Or with docker-compose
docker compose up -d
```
## Development Notes
- **Port**: runs on 3001 in dev (3000 may conflict with Docker)
- **shadcn/ui**: components are in `src/components/ui/`, added via `npx shadcn@latest add <component>`
- **Adding a new API route**: create `src/app/api/<name>/route.ts`, import from `@/lib/repositories/*`
- **Adding a new page**: create `src/app/(dashboard)/<name>/page.tsx` — auto-protected by auth guard

View File

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -0,0 +1,45 @@
import { test, expect } from '@playwright/test';
test.describe('Admin Login Page', () => {
test('shows admin login form', async ({ page }) => {
await page.goto('/login');
await expect(page.getByText('Platform Admin')).toBeVisible();
await expect(page.getByText('Sign in to access the admin dashboard')).toBeVisible();
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
});
test('shows demo credentials hint', async ({ page }) => {
await page.goto('/login');
await expect(page.getByText(/admin@example\.com/)).toBeVisible();
});
test('Sign In button is disabled when form is empty', async ({ page }) => {
await page.goto('/login');
const btn = page.getByRole('button', { name: 'Sign In' });
await expect(btn).toBeDisabled();
});
test('shows validation hint for short password', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('short');
await expect(page.getByText(/at least 8 characters/i)).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('wrong@admin.com');
await page.getByLabel('Password').fill('WrongPassword123!');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page.getByText(/invalid|error/i)).toBeVisible({ timeout: 10000 });
});
test('enables Sign In when valid email and password entered', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('Admin123!');
const btn = page.getByRole('button', { name: 'Sign In' });
await expect(btn).toBeEnabled();
});
});

View File

@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';
test.describe('Admin Navigation & Protected Routes', () => {
test('redirects unauthenticated user to login', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
});
test('login page is responsive on mobile viewport', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/login');
await expect(page.getByText('Platform Admin')).toBeVisible();
await expect(page.getByLabel('Email')).toBeVisible();
});
test('admin login page has correct title', async ({ page }) => {
await page.goto('/login');
await expect(page).toHaveTitle(/admin/i);
});
});

View File

@ -0,0 +1,24 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
"react-hooks/set-state-in-effect": "off",
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }],
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@ -0,0 +1,56 @@
import type { NextConfig } from 'next';
const securityHeaders = [
{
key: 'X-Frame-Options',
value: 'DENY',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains',
},
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' https://*.documents.azure.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; '),
},
];
const nextConfig: NextConfig = {
...(process.env.VERCEL ? {} : { output: 'standalone' }),
async headers() {
return [
{
source: '/(.*)',
headers: securityHeaders,
},
];
},
};
export default nextConfig;

16948
dashboards/admin-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,83 @@
{
"name": "@bytelyst/admin-web",
"version": "0.1.0",
"private": true,
"engines": {
"node": "20.x"
},
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint",
"check": "tsc --noEmit && eslint",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"build:analyze": "ANALYZE=true next build",
"test:coverage": "vitest run --coverage",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md,yml,yaml}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md,yml,yaml}\"",
"size:check": "bundlesize",
"prepare": "husky install"
},
"dependencies": {
"@azure/cosmos": "^4.9.1",
"@azure/identity": "^4.13.0",
"@azure/keyvault-secrets": "^4.10.0",
"@bytelyst/api-client": "workspace:*",
"@bytelyst/auth": "workspace:*",
"@bytelyst/config": "workspace:*",
"@bytelyst/cosmos": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/extraction": "workspace:*",
"@bytelyst/logger": "workspace:*",
"@bytelyst/react-auth": "workspace:*",
"@radix-ui/react-slider": "^1.3.6",
"@tailwindcss/typography": "^0.5.19",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jose": "^6.1.3",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"posthog-js": "^1.196.0",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.0.18",
"bundlesize": "^0.18.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"prettier": "^3.0.0",
"shadcn": "^3.8.4",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.0.18"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{js,jsx,json,md,yml,yaml}": [
"prettier --write"
]
}
}

View File

@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3001',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3001',
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
});

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,102 @@
/**
* Tests for GET /api/audit
*
* Covers:
* - 401 when unauthenticated
* - 200 with entries list and total (default mode)
* - 200 with summary (total + failedLogins) when ?summary=true
* - Passes category, limit, offset query params
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockGetCurrentUser = vi.fn();
vi.mock('@/lib/auth-server', () => ({
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
}));
const mockQueryAudit = vi.fn();
const mockGetAuditStats = vi.fn();
vi.mock('@/lib/platform-client', () => ({
queryAudit: (...args: unknown[]) => mockQueryAudit(...args),
getAuditStats: (...args: unknown[]) => mockGetAuditStats(...args),
}));
import { GET } from '@/app/api/audit/route';
async function callAudit(qs = '') {
const { NextRequest } = await import('next/server');
return GET(
new NextRequest(
new Request(`http://localhost:3001/api/audit${qs}`, {
headers: { Authorization: 'Bearer test' },
})
)
);
}
const admin = { id: 'usr_a', role: 'admin' };
describe('GET /api/audit', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when unauthenticated', async () => {
mockGetCurrentUser.mockResolvedValue(null);
const res = await callAudit();
expect(res.status).toBe(401);
});
it('returns entries list with total by default', async () => {
mockGetCurrentUser.mockResolvedValue(admin);
mockQueryAudit.mockResolvedValue({
records: [{ id: 'aud_1', category: 'auth', action: 'login_success' }],
count: 1,
});
const res = await callAudit();
expect(res.status).toBe(200);
const data = await res.json();
expect(data.entries).toHaveLength(1);
expect(data.total).toBe(1);
});
it('returns summary when ?summary=true', async () => {
mockGetCurrentUser.mockResolvedValue(admin);
mockGetAuditStats.mockResolvedValue({
stats: { login_success: 485, login_failed: 15 },
days: 90,
});
const res = await callAudit('?summary=true');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.total).toBe(500);
expect(data.failedLogins).toBe(15);
// Should NOT call queryAudit in summary mode
expect(mockQueryAudit).not.toHaveBeenCalled();
});
it('passes category filter from query params', async () => {
mockGetCurrentUser.mockResolvedValue(admin);
mockQueryAudit.mockResolvedValue({ records: [], count: 0 });
await callAudit('?category=auth&limit=50&offset=10');
expect(mockQueryAudit).toHaveBeenCalledWith({
category: 'auth',
limit: 50,
offset: 10,
});
});
it('uses default limit=100 and offset=0', async () => {
mockGetCurrentUser.mockResolvedValue(admin);
mockQueryAudit.mockResolvedValue({ records: [], count: 0 });
await callAudit();
expect(mockQueryAudit).toHaveBeenCalledWith({
category: undefined,
limit: 100,
offset: 0,
});
});
});

View File

@ -0,0 +1,131 @@
/**
* Tests for POST /api/auth/login
*
* Covers:
* - 400 for missing email/password
* - 401 for invalid credentials (loginViaService rejects)
* - 200 with tokens and user data on success
* - Audit log entries for success and failure
* - 401 when loginViaService throws (inner catch returns 401)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────────────────────
const mockLoginViaService = vi.fn();
const mockLogAudit = vi.fn();
vi.mock('@/lib/platform-client', () => ({
loginViaService: (...args: unknown[]) => mockLoginViaService(...args),
logAudit: (...args: unknown[]) => mockLogAudit(...args),
}));
vi.mock('@/lib/product-config', () => ({
PRODUCT_ID: 'test-product',
}));
import { POST } from '@/app/api/auth/login/route';
// ── Helpers ────────────────────────────────────────────────────────────
async function callLogin(body: object) {
const { NextRequest } = await import('next/server');
return POST(
new NextRequest(
new Request('http://localhost:3001/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
)
);
}
const loginResult = {
accessToken: 'access_tok_123',
refreshToken: 'refresh_tok_456',
user: {
id: 'usr_admin_1',
email: 'admin@example.com',
displayName: 'Admin User',
role: 'super_admin',
plan: 'enterprise',
},
};
// ── Tests ──────────────────────────────────────────────────────────────
describe('POST /api/auth/login', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 400 when email is missing', async () => {
const res = await callLogin({ password: 'pass123' });
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('required');
});
it('returns 400 when password is missing', async () => {
const res = await callLogin({ email: 'admin@example.com' });
expect(res.status).toBe(400);
});
it('returns 400 when both are missing', async () => {
const res = await callLogin({});
expect(res.status).toBe(400);
});
it('returns 401 for invalid credentials', async () => {
mockLoginViaService.mockRejectedValue(new Error('Invalid credentials'));
const res = await callLogin({ email: 'bad@example.com', password: 'wrong' });
expect(res.status).toBe(401);
const data = await res.json();
expect(data.error).toContain('Invalid credentials');
});
it('logs audit entry on login failure', async () => {
mockLoginViaService.mockRejectedValue(new Error('Invalid credentials'));
await callLogin({ email: 'bad@example.com', password: 'wrong' });
expect(mockLogAudit).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'bad@example.com',
action: 'login_failed',
category: 'auth',
})
);
});
it('returns 200 with tokens and user data on success', async () => {
mockLoginViaService.mockResolvedValue(loginResult);
const res = await callLogin({ email: 'admin@example.com', password: 'admin123' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.accessToken).toBe('access_tok_123');
expect(data.refreshToken).toBe('refresh_tok_456');
expect(data.user.id).toBe('usr_admin_1');
expect(data.user.email).toBe('admin@example.com');
expect(data.user.role).toBe('super_admin');
});
it('logs audit entry on login success', async () => {
mockLoginViaService.mockResolvedValue(loginResult);
await callLogin({ email: 'admin@example.com', password: 'admin123' });
expect(mockLogAudit).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'usr_admin_1',
action: 'login_success',
category: 'auth',
})
);
});
it('returns 401 when loginViaService throws', async () => {
mockLoginViaService.mockRejectedValue(new Error('DB connection failed'));
const res = await callLogin({ email: 'admin@example.com', password: 'pass' });
// Inner catch returns 401 for any loginViaService failure
expect(res.status).toBe(401);
});
});

View File

@ -0,0 +1,59 @@
/**
* Tests for GET /api/auth/me
*
* Covers:
* - 401 when no auth header
* - 401 when invalid token
* - 200 with user profile (excludes passwordHash)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockGetMeViaService = vi.fn();
vi.mock('@/lib/platform-client', () => ({
getMeViaService: (...args: unknown[]) => mockGetMeViaService(...args),
}));
import { GET } from '@/app/api/auth/me/route';
async function callMe(token?: string) {
const { NextRequest } = await import('next/server');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = token;
return GET(new NextRequest(new Request('http://localhost:3001/api/auth/me', { headers })));
}
const serviceUser = {
id: 'usr_admin_1',
email: 'admin@example.com',
displayName: 'Admin User',
role: 'super_admin',
plan: 'enterprise',
};
describe('GET /api/auth/me', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 with no auth header', async () => {
const res = await callMe();
expect(res.status).toBe(401);
});
it('returns 401 with invalid token', async () => {
mockGetMeViaService.mockRejectedValue(new Error('Unauthorized'));
const res = await callMe('Bearer invalid_token');
expect(res.status).toBe(401);
});
it('returns user profile on valid token', async () => {
mockGetMeViaService.mockResolvedValue(serviceUser);
const res = await callMe('Bearer valid_token');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.id).toBe('usr_admin_1');
expect(data.email).toBe('admin@example.com');
expect(data.role).toBe('super_admin');
// passwordHash must NOT be returned
expect(data.passwordHash).toBeUndefined();
});
});

View File

@ -0,0 +1,105 @@
/**
* Tests for GET /api/dashboard/stats
*
* Covers:
* - 401 when unauthenticated
* - 200 returns aggregated stats from all repositories
* - Response shape includes users, tokens, usage, audit sections
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockRequireAdmin = vi.fn();
vi.mock('@/lib/auth-server', () => ({
requireAdmin: (...args: unknown[]) => mockRequireAdmin(...args),
}));
const mockCountActiveTokens = vi.fn();
vi.mock('@/lib/repositories/tokens', () => ({
countActiveTokens: (...args: unknown[]) => mockCountActiveTokens(...args),
}));
const mockGetUsageSummary = vi.fn();
vi.mock('@/lib/billing-client', () => ({
getUsageSummary: (...args: unknown[]) => mockGetUsageSummary(...args),
}));
const mockGetUserCounts = vi.fn();
const mockGetAuditStats = vi.fn();
vi.mock('@/lib/platform-client', () => ({
getUserCounts: (...args: unknown[]) => mockGetUserCounts(...args),
getAuditStats: (...args: unknown[]) => mockGetAuditStats(...args),
}));
import { GET } from '@/app/api/dashboard/stats/route';
async function callStats(token?: string) {
const { NextRequest } = await import('next/server');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = token;
return GET(
new NextRequest(new Request('http://localhost:3001/api/dashboard/stats', { headers }))
);
}
const admin = { id: 'usr_a', email: 'a@example.com', name: 'Admin', role: 'admin' };
describe('GET /api/dashboard/stats', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when unauthenticated', async () => {
mockRequireAdmin.mockResolvedValue(null);
const res = await callStats();
expect(res.status).toBe(401);
});
it('returns aggregated stats on success', async () => {
mockRequireAdmin.mockResolvedValue(admin);
mockGetUserCounts.mockResolvedValue({ total: 150, byPlan: { free: 100, pro: 40, enterprise: 10 } });
mockCountActiveTokens.mockResolvedValue(25);
mockGetUsageSummary.mockResolvedValue({
totalWords: 500000,
totalDictations: 12000,
totalCost: 245.5,
});
mockGetAuditStats.mockResolvedValue({
stats: { login_success: 8868, login_failed: 32 },
days: 90,
});
const res = await callStats('Bearer valid_token');
expect(res.status).toBe(200);
const data = await res.json();
// Users section
expect(data.users.total).toBe(150);
expect(data.users.byPlan.free).toBe(100);
expect(data.users.byPlan.pro).toBe(40);
expect(data.users.byPlan.enterprise).toBe(10);
// Tokens section
expect(data.tokens.active).toBe(25);
// Usage section
expect(data.usage.totalWords).toBe(500000);
expect(data.usage.totalDictations).toBe(12000);
expect(data.usage.totalCost).toBe(245.5);
// Audit section
expect(data.audit.total).toBe(8900);
expect(data.audit.failedLogins).toBe(32);
});
it('passes 30 days to getUsageSummary', async () => {
mockRequireAdmin.mockResolvedValue(admin);
mockGetUserCounts.mockResolvedValue({ total: 0, byPlan: {} });
mockCountActiveTokens.mockResolvedValue(0);
mockGetUsageSummary.mockResolvedValue({
totalWords: 0, totalDictations: 0, totalCost: 0,
});
mockGetAuditStats.mockResolvedValue({ stats: {}, days: 90 });
await callStats('Bearer valid_token');
expect(mockGetUsageSummary).toHaveBeenCalledWith(30);
});
});

View File

@ -0,0 +1,94 @@
/**
* Tests for GET/POST /api/flags
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockRequireAdmin = vi.fn();
vi.mock('@/lib/auth-server', () => ({
requireAdmin: (...args: unknown[]) => mockRequireAdmin(...args),
}));
const mockListFlags = vi.fn();
const mockCreateFlag = vi.fn();
vi.mock('@/lib/platform-client', () => ({
listFlags: (...args: unknown[]) => mockListFlags(...args),
createFlag: (...args: unknown[]) => mockCreateFlag(...args),
}));
import { GET, POST } from '@/app/api/flags/route';
async function makeGet() {
const { NextRequest } = await import('next/server');
return GET(new NextRequest(new Request('http://localhost:3001/api/flags')));
}
async function makePost(body: object) {
const { NextRequest } = await import('next/server');
return POST(
new NextRequest(
new Request('http://localhost:3001/api/flags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
)
);
}
describe('GET /api/flags', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when not admin', async () => {
mockRequireAdmin.mockResolvedValue(null);
const res = await makeGet();
expect(res.status).toBe(401);
});
it('returns flags list on success', async () => {
mockRequireAdmin.mockResolvedValue({ id: 'admin1', role: 'admin' });
mockListFlags.mockResolvedValue({ flags: [{ key: 'dark_mode', enabled: true }] });
const res = await makeGet();
expect(res.status).toBe(200);
const data = await res.json();
expect(data.flags).toHaveLength(1);
expect(data.flags[0].key).toBe('dark_mode');
});
it('returns 500 on error', async () => {
mockRequireAdmin.mockResolvedValue({ id: 'admin1' });
mockListFlags.mockRejectedValue(new Error('Service unavailable'));
const res = await makeGet();
expect(res.status).toBe(500);
});
});
describe('POST /api/flags', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when not admin', async () => {
mockRequireAdmin.mockResolvedValue(null);
const res = await makePost({ key: 'new_flag' });
expect(res.status).toBe(401);
});
it('creates flag and returns 201', async () => {
mockRequireAdmin.mockResolvedValue({ id: 'admin1' });
mockCreateFlag.mockResolvedValue({ key: 'new_flag', enabled: true, id: 'f1' });
const res = await makePost({ key: 'new_flag', enabled: true });
expect(res.status).toBe(201);
const data = await res.json();
expect(data.key).toBe('new_flag');
});
it('returns 500 on error', async () => {
mockRequireAdmin.mockResolvedValue({ id: 'admin1' });
mockCreateFlag.mockRejectedValue(new Error('Conflict'));
const res = await makePost({ key: 'dup' });
expect(res.status).toBe(500);
});
});

View File

@ -0,0 +1,235 @@
/**
* Tests for GET/POST /api/invitations and PATCH/DELETE /api/invitations/[id].
*
* Covers:
* - GET 401 unauthorized
* - GET 200 lists codes with total
* - POST 403 for non-admin
* - POST 400 for invalid grantPlan
* - POST 201 creates code with defaults
* - POST 201 creates code with custom code
* - PATCH 403 for non-admin
* - PATCH 404 for missing code
* - PATCH 200 toggles status
* - DELETE 403 for non-super_admin
* - DELETE 404 for missing code
* - DELETE 200 success
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────────────────────
const mockGetCurrentUser = vi.fn();
vi.mock('@/lib/auth-server', () => ({
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
}));
const mockListInvitations = vi.fn();
const mockCountInvitations = vi.fn();
const mockCreateInvitation = vi.fn();
const mockUpdateInvitation = vi.fn();
const mockDeleteInvitation = vi.fn();
vi.mock('@/lib/growth-client', () => ({
listInvitations: (...args: unknown[]) => mockListInvitations(...args),
countInvitations: (...args: unknown[]) => mockCountInvitations(...args),
createInvitation: (...args: unknown[]) => mockCreateInvitation(...args),
updateInvitation: (...args: unknown[]) => mockUpdateInvitation(...args),
deleteInvitation: (...args: unknown[]) => mockDeleteInvitation(...args),
}));
import { GET, POST } from '@/app/api/invitations/route';
import { PATCH, DELETE } from '@/app/api/invitations/[id]/route';
// ── Helpers ────────────────────────────────────────────────────────────
const adminUser = {
id: 'usr_admin_1',
email: 'admin@example.com',
name: 'Admin',
role: 'super_admin',
plan: 'enterprise',
};
const viewerUser = {
id: 'usr_viewer_1',
email: 'viewer@example.com',
name: 'Viewer',
role: 'viewer',
plan: 'free',
};
async function callGET(token = 'Bearer admin_tok') {
const { NextRequest } = await import('next/server');
return GET(
new NextRequest(
new Request('http://localhost:3001/api/invitations', {
headers: { Authorization: token },
})
)
);
}
async function callPOST(body: object, token = 'Bearer admin_tok') {
const { NextRequest } = await import('next/server');
return POST(
new NextRequest(
new Request('http://localhost:3001/api/invitations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
body: JSON.stringify(body),
})
)
);
}
async function callPATCH(id: string, body: object, token = 'Bearer admin_tok') {
const { NextRequest } = await import('next/server');
return PATCH(
new NextRequest(
new Request(`http://localhost:3001/api/invitations/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
body: JSON.stringify(body),
})
),
{ params: Promise.resolve({ id }) }
);
}
async function callDELETE(id: string, token = 'Bearer admin_tok') {
const { NextRequest } = await import('next/server');
return DELETE(
new NextRequest(
new Request(`http://localhost:3001/api/invitations/${id}`, {
method: 'DELETE',
headers: { Authorization: token },
})
),
{ params: Promise.resolve({ id }) }
);
}
// ── Tests ──────────────────────────────────────────────────────────────
describe('GET /api/invitations', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when not authenticated', async () => {
mockGetCurrentUser.mockResolvedValue(null);
const res = await callGET();
expect(res.status).toBe(401);
});
it('returns codes and total', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockListInvitations.mockResolvedValue({
invitations: [{ id: 'inv_1', code: 'LYSNR-ABC' }],
count: 1,
});
mockCountInvitations.mockResolvedValue({ count: 1 });
const res = await callGET();
expect(res.status).toBe(200);
const data = await res.json();
expect(data.codes).toHaveLength(1);
expect(data.total).toBe(1);
});
});
describe('POST /api/invitations', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 403 for non-admin', async () => {
mockGetCurrentUser.mockResolvedValue(viewerUser);
const res = await callPOST({});
expect(res.status).toBe(403);
});
it('returns 400 for invalid grantPlan', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
const res = await callPOST({ grantPlan: 'invalid' });
expect(res.status).toBe(400);
});
it('creates code with defaults', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockCreateInvitation.mockImplementation((body: Record<string, unknown>) => ({
...body,
id: 'inv_new',
status: 'active',
currentUses: 0,
redeemedBy: [],
}));
const res = await callPOST({ description: 'Beta invite' });
expect(res.status).toBe(201);
const data = await res.json();
expect(data.code).toMatch(/^LYSNR-/);
});
it('creates code with custom code', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockCreateInvitation.mockImplementation((body: Record<string, unknown>) => ({
...body,
id: 'inv_new',
}));
const res = await callPOST({ code: 'my-custom-code' });
expect(res.status).toBe(201);
const data = await res.json();
expect(data.code).toBe('MY-CUSTOM-CODE');
});
});
describe('PATCH /api/invitations/[id]', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 403 for non-admin', async () => {
mockGetCurrentUser.mockResolvedValue(viewerUser);
const res = await callPATCH('inv_1', { status: 'disabled' });
expect(res.status).toBe(403);
});
it('returns 500 when update fails', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockUpdateInvitation.mockResolvedValue(null);
const res = await callPATCH('inv_missing', { status: 'disabled' });
expect(res.status).toBe(500);
});
it('toggles status successfully', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockUpdateInvitation.mockResolvedValue({ id: 'inv_1', status: 'disabled' });
const res = await callPATCH('inv_1', { status: 'disabled' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.status).toBe('disabled');
});
});
describe('DELETE /api/invitations/[id]', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 403 for non-super_admin', async () => {
mockGetCurrentUser.mockResolvedValue({ ...adminUser, role: 'admin' });
const res = await callDELETE('inv_1');
expect(res.status).toBe(403);
});
it('deletes successfully', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockDeleteInvitation.mockResolvedValue(undefined);
const res = await callDELETE('inv_1');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
});
});

View File

@ -0,0 +1,156 @@
/**
* Tests for GET/PUT /api/settings/kill-switch
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockListFlags = vi.fn();
const mockCreateFlag = vi.fn();
const mockUpdateFlag = vi.fn();
vi.mock('@/lib/platform-client', () => ({
listFlags: (...args: unknown[]) => mockListFlags(...args),
createFlag: (...args: unknown[]) => mockCreateFlag(...args),
updateFlag: (...args: unknown[]) => mockUpdateFlag(...args),
}));
import { GET, PUT } from '@/app/api/settings/kill-switch/route';
async function makeGet() {
return GET();
}
async function makePut(body: object) {
const { NextRequest } = await import('next/server');
return PUT(
new NextRequest(
new Request('http://localhost:3001/api/settings/kill-switch', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
)
);
}
describe('GET /api/settings/kill-switch', () => {
beforeEach(() => vi.clearAllMocks());
it('returns default state when no kill_switch flag exists', async () => {
mockListFlags.mockResolvedValue({ flags: [] });
const res = await makeGet();
expect(res.status).toBe(200);
const data = await res.json();
expect(data.enabled).toBe(false);
expect(data.platforms.desktop).toBe(true);
expect(data.platforms.ios).toBe(true);
expect(data.platforms.android).toBe(true);
expect(data.platforms.web).toBe(true);
expect(data.reason).toBe('');
});
it('returns existing kill_switch flag state', async () => {
mockListFlags.mockResolvedValue({
flags: [{
key: 'kill_switch',
enabled: true,
platforms: ['desktop', 'ios'],
description: 'Maintenance window',
updatedAt: '2026-02-16T00:00:00Z',
}],
});
const res = await makeGet();
const data = await res.json();
expect(data.enabled).toBe(true);
expect(data.platforms.desktop).toBe(true);
expect(data.platforms.ios).toBe(true);
expect(data.platforms.android).toBe(false);
expect(data.platforms.web).toBe(false);
expect(data.reason).toBe('Maintenance window');
});
it('maps empty platforms array to all-true', async () => {
mockListFlags.mockResolvedValue({
flags: [{ key: 'kill_switch', enabled: true, platforms: [] }],
});
const res = await makeGet();
const data = await res.json();
expect(data.platforms.desktop).toBe(true);
expect(data.platforms.ios).toBe(true);
});
it('returns 500 on error', async () => {
mockListFlags.mockRejectedValue(new Error('Service down'));
const res = await makeGet();
expect(res.status).toBe(500);
const data = await res.json();
expect(data.error).toContain('kill switch');
});
});
describe('PUT /api/settings/kill-switch', () => {
beforeEach(() => vi.clearAllMocks());
it('updates existing kill_switch flag', async () => {
mockListFlags.mockResolvedValue({
flags: [{ key: 'kill_switch', id: 'flag-123', enabled: false }],
});
mockUpdateFlag.mockResolvedValue({
enabled: true,
platforms: [],
description: 'Emergency shutdown',
updatedAt: '2026-02-16T10:00:00Z',
});
const res = await makePut({ enabled: true, reason: 'Emergency shutdown' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.enabled).toBe(true);
expect(data.reason).toBe('Emergency shutdown');
});
it('creates new kill_switch flag when none exists', async () => {
mockListFlags.mockResolvedValue({ flags: [] });
mockCreateFlag.mockResolvedValue({
key: 'kill_switch',
enabled: true,
platforms: ['desktop'],
description: 'Test',
updatedAt: '2026-02-16T10:00:00Z',
});
const res = await makePut({
enabled: true,
reason: 'Test',
platforms: { desktop: true, ios: false, android: false, web: false },
});
expect(res.status).toBe(200);
expect(mockCreateFlag).toHaveBeenCalled();
});
it('returns 500 on error', async () => {
mockListFlags.mockRejectedValue(new Error('Network error'));
const res = await makePut({ enabled: false });
expect(res.status).toBe(500);
});
it('defaults enabled to false when not provided', async () => {
mockListFlags.mockResolvedValue({ flags: [] });
mockCreateFlag.mockResolvedValue({
key: 'kill_switch',
enabled: false,
platforms: [],
updatedAt: '2026-02-16T10:00:00Z',
});
const res = await makePut({});
const data = await res.json();
expect(data.enabled).toBe(false);
});
});

View File

@ -0,0 +1,126 @@
/**
* Tests for GET/POST /api/licenses
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockRequireAdmin = vi.fn();
vi.mock('@/lib/auth-server', () => ({
requireAdmin: (...args: unknown[]) => mockRequireAdmin(...args),
}));
const mockGetUserLicenses = vi.fn();
const mockGenerateLicense = vi.fn();
const mockRevokeLicense = vi.fn();
const mockDeactivateLicenseDevice = vi.fn();
vi.mock('@/lib/billing-client', () => ({
getUserLicenses: (...args: unknown[]) => mockGetUserLicenses(...args),
generateLicense: (...args: unknown[]) => mockGenerateLicense(...args),
revokeLicense: (...args: unknown[]) => mockRevokeLicense(...args),
deactivateLicenseDevice: (...args: unknown[]) => mockDeactivateLicenseDevice(...args),
}));
import { GET, POST } from '@/app/api/licenses/route';
async function makeGet(params: Record<string, string> = {}) {
const { NextRequest } = await import('next/server');
const qs = new URLSearchParams(params).toString();
return GET(new NextRequest(new Request(`http://localhost:3001/api/licenses?${qs}`)));
}
async function makePost(body: object) {
const { NextRequest } = await import('next/server');
return POST(
new NextRequest(
new Request('http://localhost:3001/api/licenses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
)
);
}
describe('GET /api/licenses', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when not admin', async () => {
mockRequireAdmin.mockResolvedValue(null);
const res = await makeGet({ userId: 'u1' });
expect(res.status).toBe(401);
});
it('returns 400 when userId missing', async () => {
mockRequireAdmin.mockResolvedValue({ id: 'admin1' });
const res = await makeGet();
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('userId');
});
it('returns licenses for user', async () => {
mockRequireAdmin.mockResolvedValue({ id: 'admin1' });
mockGetUserLicenses.mockResolvedValue({
licenses: [{ key: 'LYSNR-ABCD', status: 'active' }],
});
const res = await makeGet({ userId: 'usr_1' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.licenses).toHaveLength(1);
});
it('returns 500 on service error', async () => {
mockRequireAdmin.mockResolvedValue({ id: 'admin1' });
mockGetUserLicenses.mockRejectedValue(new Error('Service down'));
const res = await makeGet({ userId: 'usr_1' });
expect(res.status).toBe(500);
});
});
describe('POST /api/licenses', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when not admin', async () => {
mockRequireAdmin.mockResolvedValue(null);
const res = await makePost({ userId: 'u1' });
expect(res.status).toBe(401);
});
it('generates new license by default', async () => {
mockRequireAdmin.mockResolvedValue({ id: 'admin1' });
mockGenerateLicense.mockResolvedValue({ key: 'LYSNR-NEW1', status: 'active' });
const res = await makePost({ userId: 'usr_1', plan: 'pro' });
expect(res.status).toBe(201);
const data = await res.json();
expect(data.key).toBe('LYSNR-NEW1');
});
it('revokes license when action is revoke', async () => {
mockRequireAdmin.mockResolvedValue({ id: 'admin1' });
mockRevokeLicense.mockResolvedValue({ success: true });
const res = await makePost({ action: 'revoke', key: 'LYSNR-ABCD' });
expect(res.status).toBe(200);
expect(mockRevokeLicense).toHaveBeenCalledWith('LYSNR-ABCD');
});
it('deactivates device when action is deactivate', async () => {
mockRequireAdmin.mockResolvedValue({ id: 'admin1' });
mockDeactivateLicenseDevice.mockResolvedValue({ success: true });
const res = await makePost({ action: 'deactivate', key: 'LYSNR-ABCD', deviceId: 'dev1' });
expect(res.status).toBe(200);
expect(mockDeactivateLicenseDevice).toHaveBeenCalledWith('LYSNR-ABCD', 'dev1');
});
it('returns 500 on error', async () => {
mockRequireAdmin.mockResolvedValue({ id: 'admin1' });
mockGenerateLicense.mockRejectedValue(new Error('Limit reached'));
const res = await makePost({ userId: 'u1' });
expect(res.status).toBe(500);
});
});

View File

@ -0,0 +1,281 @@
/**
* Tests for GET/POST /api/promos route.
*
* Covers:
* GET:
* - 401 when not authenticated
* - 200 lists promos with mapped fields
* - 200 filters by active param
* - Handles coupon as expanded object vs string ID
* - 500 when Stripe throws
*
* POST:
* - 403 for non-admin
* - 400 when code is missing
* - 400 when neither percentOff nor amountOff provided
* - 201 creates coupon + promo with percentOff
* - 201 creates coupon + promo with amountOff
* - 201 creates with maxRedemptions and expiresAt
* - 500 when Stripe throws (surfaces error message)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────────────────────
const mockGetCurrentUser = vi.fn();
vi.mock('@/lib/auth-server', () => ({
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
}));
const mockListPromos = vi.fn();
const mockCreatePromo = vi.fn();
vi.mock('@/lib/growth-client', () => ({
listPromos: (...args: unknown[]) => mockListPromos(...args),
createPromo: (...args: unknown[]) => mockCreatePromo(...args),
}));
import { GET, POST } from '@/app/api/promos/route';
// ── Helpers ────────────────────────────────────────────────────────────
const adminUser = {
id: 'usr_admin_1',
email: 'admin@example.com',
name: 'Admin',
role: 'super_admin',
plan: 'enterprise',
};
const viewerUser = {
id: 'usr_viewer_1',
email: 'viewer@example.com',
name: 'Viewer',
role: 'viewer',
plan: 'free',
};
async function callGET(params = '', token = 'Bearer admin_tok') {
const { NextRequest } = await import('next/server');
return GET(
new NextRequest(
new Request(`http://localhost:3001/api/promos${params}`, {
headers: { Authorization: token },
})
)
);
}
async function callPOST(body: object, token = 'Bearer admin_tok') {
const { NextRequest } = await import('next/server');
return POST(
new NextRequest(
new Request('http://localhost:3001/api/promos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
body: JSON.stringify(body),
})
)
);
}
// Sample Stripe promo code with expanded coupon object (Stripe SDK v20+: coupon under promotion)
const samplePromo = {
id: 'promo_abc123',
code: 'LAUNCH20',
active: true,
promotion: {
coupon: {
id: 'coup_xyz',
percent_off: 20,
amount_off: null,
currency: null,
duration: 'once',
},
type: 'coupon',
},
times_redeemed: 3,
max_redemptions: 100,
expires_at: 1767225600, // 2026-01-01T00:00:00Z
created: 1736689200, // 2025-01-12
metadata: { createdBy: 'usr_admin_1' },
};
// Promo with coupon as string ID (not expanded)
const _samplePromoStringCoupon = {
id: 'promo_def456',
code: 'VIP50',
active: false,
promotion: {
coupon: 'coup_string_id',
type: 'coupon',
},
times_redeemed: 0,
max_redemptions: null,
expires_at: null,
created: 1736689200,
metadata: {},
};
// ── Tests ──────────────────────────────────────────────────────────────
describe('GET /api/promos', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when not authenticated', async () => {
mockGetCurrentUser.mockResolvedValue(null);
const res = await callGET();
expect(res.status).toBe(401);
});
it('lists promos via growth service', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockListPromos.mockResolvedValue({
promos: [samplePromo],
});
const res = await callGET();
expect(res.status).toBe(200);
const data = await res.json();
expect(data.promos).toHaveLength(1);
expect(data.promos[0].code).toBe('LAUNCH20');
});
it('passes active filter to growth service', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockListPromos.mockResolvedValue({ promos: [] });
await callGET('?active=true');
expect(mockListPromos).toHaveBeenCalledWith(true);
});
it('passes undefined when no active filter', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockListPromos.mockResolvedValue({ promos: [] });
await callGET();
expect(mockListPromos).toHaveBeenCalledWith(undefined);
});
it('returns 500 when growth service throws', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockListPromos.mockRejectedValue(new Error('Growth Service error'));
const res = await callGET();
expect(res.status).toBe(500);
});
});
describe('POST /api/promos', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 403 for non-admin', async () => {
mockGetCurrentUser.mockResolvedValue(viewerUser);
const res = await callPOST({ code: 'TEST', percentOff: 10 });
expect(res.status).toBe(403);
});
it('returns 400 when code is missing', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
const res = await callPOST({ percentOff: 10 });
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('code');
});
it('returns 400 when neither percentOff nor amountOff', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
const res = await callPOST({ code: 'NODISC' });
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('percentOff');
});
it('creates promo with percentOff via growth service', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockCreatePromo.mockResolvedValue({
id: 'promo_new',
code: 'SUMMER25',
percentOff: 25,
couponId: 'coup_new',
});
const res = await callPOST({ code: 'summer25', percentOff: 25 });
expect(res.status).toBe(201);
const data = await res.json();
expect(data.code).toBe('SUMMER25');
expect(data.percentOff).toBe(25);
expect(data.couponId).toBe('coup_new');
expect(mockCreatePromo).toHaveBeenCalledWith(
expect.objectContaining({
code: 'summer25',
percentOff: 25,
createdBy: 'usr_admin_1',
})
);
});
it('creates promo with amountOff via growth service', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockCreatePromo.mockResolvedValue({
id: 'promo_amt',
code: 'SAVE5',
amountOff: 500,
});
const res = await callPOST({ code: 'SAVE5', amountOff: 500, currency: 'usd' });
expect(res.status).toBe(201);
expect(mockCreatePromo).toHaveBeenCalledWith(
expect.objectContaining({
code: 'SAVE5',
amountOff: 500,
currency: 'usd',
})
);
});
it('passes maxRedemptions and expiresAt', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockCreatePromo.mockResolvedValue({
id: 'promo_lim',
code: 'LIMITED',
maxRedemptions: 50,
expiresAt: '2026-01-01T00:00:00Z',
});
const res = await callPOST({
code: 'LIMITED',
percentOff: 10,
duration: 'forever',
maxRedemptions: 50,
expiresAt: '2026-01-01T00:00:00Z',
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.maxRedemptions).toBe(50);
expect(data.expiresAt).toBeTruthy();
expect(mockCreatePromo).toHaveBeenCalledWith(
expect.objectContaining({
maxRedemptions: 50,
expiresAt: '2026-01-01T00:00:00Z',
})
);
});
it('returns 500 with error message when growth service throws', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockCreatePromo.mockRejectedValue(new Error('Invalid coupon params'));
const res = await callPOST({ code: 'BAD', percentOff: -5 });
expect(res.status).toBe(500);
const data = await res.json();
expect(data.error).toBe('Invalid coupon params');
});
});

View File

@ -0,0 +1,94 @@
/**
* Tests for GET /api/referrals (admin view).
*
* Covers:
* - 401 when not authenticated
* - 200 returns referrals + stats
* - 200 summary mode returns stats only
* - 500 on unexpected error
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────────────────────
const mockGetCurrentUser = vi.fn();
vi.mock('@/lib/auth-server', () => ({
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
}));
const mockListReferrals = vi.fn();
const mockGetReferralStats = vi.fn();
vi.mock('@/lib/growth-client', () => ({
listReferrals: (...args: unknown[]) => mockListReferrals(...args),
getReferralStats: (...args: unknown[]) => mockGetReferralStats(...args),
}));
import { GET } from '@/app/api/referrals/route';
// ── Helpers ────────────────────────────────────────────────────────────
const adminUser = {
id: 'usr_admin_1',
email: 'admin@example.com',
name: 'Admin',
role: 'super_admin',
plan: 'enterprise',
};
async function callGET(params = '', token = 'Bearer admin_tok') {
const { NextRequest } = await import('next/server');
return GET(
new NextRequest(
new Request(`http://localhost:3001/api/referrals${params}`, {
headers: { Authorization: token },
})
)
);
}
// ── Tests ──────────────────────────────────────────────────────────────
describe('GET /api/referrals (admin)', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when not authenticated', async () => {
mockGetCurrentUser.mockResolvedValue(null);
const res = await callGET();
expect(res.status).toBe(401);
});
it('returns referrals and stats', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockListReferrals.mockResolvedValue({
referrals: [{ id: 'ref_1', referrerEmail: 'a@b.com', status: 'signed_up' }],
});
mockGetReferralStats.mockResolvedValue({ total: 5, completed: 3, rewarded: 1 });
const res = await callGET();
expect(res.status).toBe(200);
const data = await res.json();
expect(data.referrals).toHaveLength(1);
expect(data.stats.total).toBe(5);
expect(data.stats.completed).toBe(3);
expect(data.stats.rewarded).toBe(1);
});
it('returns summary only when mode=summary', async () => {
mockGetCurrentUser.mockResolvedValue(adminUser);
mockGetReferralStats.mockResolvedValue({ total: 10, completed: 6, rewarded: 2 });
const res = await callGET('?mode=summary');
expect(res.status).toBe(200);
const data = await res.json();
expect(data.total).toBe(10);
expect(data.completed).toBe(6);
expect(data.referrals).toBeUndefined();
});
it('returns 500 on unexpected error', async () => {
mockGetCurrentUser.mockRejectedValue(new Error('DB down'));
const res = await callGET();
expect(res.status).toBe(500);
});
});

View File

@ -0,0 +1,173 @@
/**
* Tests for client-side self-telemetry module (src/lib/telemetry.ts) admin dashboard.
* Verifies event format, queue behavior, flush logic (admin-ingest proxy), and install ID.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock browser globals before importing the module
const mockSendBeacon = vi.fn().mockReturnValue(true);
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
const mockAddEventListener = vi.fn();
vi.stubGlobal('navigator', { sendBeacon: mockSendBeacon, userAgent: 'TestAgent/1.0' });
vi.stubGlobal('fetch', mockFetch);
vi.stubGlobal('crypto', {
randomUUID: () => '550e8400-e29b-41d4-a716-446655440000',
});
const mockLocalStorage = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => mockLocalStorage.get(key) ?? null,
setItem: (key: string, value: string) => mockLocalStorage.set(key, value),
removeItem: (key: string) => mockLocalStorage.delete(key),
});
vi.stubGlobal('document', { visibilityState: 'visible' });
vi.stubGlobal('window', {
addEventListener: mockAddEventListener,
});
import { trackEvent, trackPageView, flush, initTelemetry } from '@/lib/telemetry';
beforeEach(() => {
mockSendBeacon.mockClear();
mockFetch.mockClear();
mockAddEventListener.mockClear();
mockLocalStorage.clear();
});
// ─── trackEvent ─────────────────────────────────────────────────────
describe('trackEvent', () => {
it('queues an event with correct fields', () => {
trackEvent('info', 'admin', 'filter_applied');
flush();
expect(mockSendBeacon).toHaveBeenCalledTimes(1);
const [url, body] = mockSendBeacon.mock.calls[0];
expect(url).toBe('/api/telemetry/admin-ingest');
const payload = JSON.parse(body);
expect(payload.productId).toBe('test-product');
expect(payload.events).toHaveLength(1);
const event = payload.events[0];
expect(event.platform).toBe('web');
expect(event.channel).toBe('web_app');
expect(event.osFamily).toBe('other');
expect(event.eventType).toBe('info');
expect(event.module).toBe('admin');
expect(event.eventName).toBe('filter_applied');
expect(event.id).toBeDefined();
expect(event.sessionId).toBeDefined();
expect(event.anonymousInstallId).toBeDefined();
expect(event.occurredAt).toBeDefined();
});
it('includes optional fields when provided', () => {
trackEvent('warn', 'admin', 'policy_changed', {
message: 'Policy updated',
tags: { policyId: 'pol_1' },
metrics: { duration_ms: 120 },
});
flush();
const payload = JSON.parse(mockSendBeacon.mock.calls[0][1]);
const event = payload.events[0];
expect(event.message).toBe('Policy updated');
expect(event.tags.policyId).toBe('pol_1');
expect(event.metrics.duration_ms).toBe(120);
});
it('osFamily is "other" not "web"', () => {
trackEvent('info', 'test', 'test_event');
flush();
const payload = JSON.parse(mockSendBeacon.mock.calls[0][1]);
expect(payload.events[0].osFamily).toBe('other');
});
});
// ─── trackPageView ──────────────────────────────────────────────────
describe('trackPageView', () => {
it('creates page_view event with path tag', () => {
trackPageView('/ops/client-logs');
flush();
const payload = JSON.parse(mockSendBeacon.mock.calls[0][1]);
const event = payload.events[0];
expect(event.module).toBe('navigation');
expect(event.eventName).toBe('page_view');
expect(event.tags.path).toBe('/ops/client-logs');
});
});
// ─── flush ──────────────────────────────────────────────────────────
describe('flush', () => {
it('uses sendBeacon to admin-ingest route', () => {
trackEvent('info', 'test', 'test_event');
flush();
expect(mockSendBeacon).toHaveBeenCalledWith(
'/api/telemetry/admin-ingest',
expect.any(String),
);
});
it('falls back to fetch when sendBeacon fails', () => {
mockSendBeacon.mockReturnValueOnce(false);
trackEvent('info', 'test', 'test_event');
flush();
expect(mockFetch).toHaveBeenCalledWith(
'/api/telemetry/admin-ingest',
expect.objectContaining({ method: 'POST', keepalive: true }),
);
});
it('does nothing when queue is empty', () => {
flush();
expect(mockSendBeacon).not.toHaveBeenCalled();
});
it('clears queue after flush', () => {
trackEvent('info', 'test', 'event1');
trackEvent('info', 'test', 'event2');
flush();
mockSendBeacon.mockClear();
flush();
expect(mockSendBeacon).not.toHaveBeenCalled();
});
});
// ─── initTelemetry ──────────────────────────────────────────────────
describe('initTelemetry', () => {
afterEach(() => {
flush();
});
it('registers visibilitychange listener', () => {
initTelemetry();
expect(mockAddEventListener).toHaveBeenCalledWith(
'visibilitychange',
expect.any(Function),
);
});
it('tracks session_started event', () => {
initTelemetry();
flush();
const payload = JSON.parse(mockSendBeacon.mock.calls[0][1]);
const sessionEvent = payload.events.find(
(e: Record<string, string>) => e.eventName === 'session_started',
);
expect(sessionEvent).toBeDefined();
expect(sessionEvent.module).toBe('app_lifecycle');
});
});

View File

@ -0,0 +1,160 @@
/**
* Tests for /api/tokens routes
*
* Routes proxy to platform-service via JWT no direct Cosmos or role checks.
*
* Covers:
* - GET /api/tokens 401 without JWT, 200 with list
* - POST /api/tokens 400 missing name, 201 on success
* - PATCH /api/tokens/[id] 401 without JWT, 200 on success
* - DELETE /api/tokens/[id] 401 without JWT, 200 on success
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────────────────────
const mockListTokens = vi.fn();
const mockCreateToken = vi.fn();
const mockRevokeToken = vi.fn();
const mockDeleteToken = vi.fn();
vi.mock('@/lib/platform-client', () => ({
listTokens: (...args: unknown[]) => mockListTokens(...args),
createToken: (...args: unknown[]) => mockCreateToken(...args),
revokeToken: (...args: unknown[]) => mockRevokeToken(...args),
deleteToken: (...args: unknown[]) => mockDeleteToken(...args),
}));
import { GET, POST } from '@/app/api/tokens/route';
import { PATCH, DELETE } from '@/app/api/tokens/[id]/route';
// ── Helpers ────────────────────────────────────────────────────────────
async function callGet(token?: string) {
const { NextRequest } = await import('next/server');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
return GET(new NextRequest(new Request('http://localhost:3001/api/tokens', { headers })));
}
async function callPost(body: object, token = 'admin_jwt') {
const { NextRequest } = await import('next/server');
return POST(
new NextRequest(
new Request('http://localhost:3001/api/tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
})
)
);
}
async function callPatch(id: string, token = 'admin_jwt') {
const { NextRequest } = await import('next/server');
return PATCH(
new NextRequest(
new Request(`http://localhost:3001/api/tokens/${id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}` },
})
),
{ params: Promise.resolve({ id }) }
);
}
async function callDelete(id: string, token = 'admin_jwt') {
const { NextRequest } = await import('next/server');
return DELETE(
new NextRequest(
new Request(`http://localhost:3001/api/tokens/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
),
{ params: Promise.resolve({ id }) }
);
}
// ── Tests ──────────────────────────────────────────────────────────────
describe('GET /api/tokens', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when no JWT', async () => {
const res = await callGet();
expect(res.status).toBe(401);
});
it('returns token list on success', async () => {
mockListTokens.mockResolvedValue([
{ id: 'tok_1', name: 'CI Token', prefix: 'lysnr_abc', status: 'active' },
]);
const res = await callGet('valid_jwt');
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toHaveLength(1);
expect(data[0].name).toBe('CI Token');
});
});
describe('POST /api/tokens', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 400 when name is missing', async () => {
const res = await callPost({});
expect(res.status).toBe(400);
});
it('creates token and returns with 201', async () => {
mockCreateToken.mockResolvedValue({
id: 'tok_new',
name: 'CI',
prefix: 'lysnr_xyz',
rawToken: 'lysnr_xyz_full_secret',
status: 'active',
});
const res = await callPost({ name: 'CI', scopes: ['dictation'] });
expect(res.status).toBe(201);
const data = await res.json();
expect(data.rawToken).toBe('lysnr_xyz_full_secret');
});
});
describe('PATCH /api/tokens/[id]', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 without JWT', async () => {
const { NextRequest } = await import('next/server');
const res = await PATCH(
new NextRequest(new Request('http://localhost:3001/api/tokens/tok_1', { method: 'PATCH' })),
{ params: Promise.resolve({ id: 'tok_1' }) }
);
expect(res.status).toBe(401);
});
it('revokes token successfully', async () => {
mockRevokeToken.mockResolvedValue({ success: true, id: 'tok_1' });
const res = await callPatch('tok_1');
expect(res.status).toBe(200);
});
});
describe('DELETE /api/tokens/[id]', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 without JWT', async () => {
const { NextRequest } = await import('next/server');
const res = await DELETE(
new NextRequest(new Request('http://localhost:3001/api/tokens/tok_1', { method: 'DELETE' })),
{ params: Promise.resolve({ id: 'tok_1' }) }
);
expect(res.status).toBe(401);
});
it('deletes token successfully', async () => {
mockDeleteToken.mockResolvedValue({ success: true, id: 'tok_1' });
const res = await callDelete('tok_1');
expect(res.status).toBe(200);
});
});

View File

@ -0,0 +1,222 @@
/**
* Tests for /api/users (GET, POST) and /api/users/[id] (GET, PUT, DELETE)
*
* Routes use requireAdmin from auth-server for auth, and platform-client
* functions (listUsers, getUserCounts, registerUser, getUser, updateUser,
* deleteUser) for data no direct Cosmos repositories.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────────────────────
const mockRequireAdmin = vi.fn();
vi.mock('@/lib/auth-server', () => ({
requireAdmin: (...args: unknown[]) => mockRequireAdmin(...args),
}));
const mockListUsers = vi.fn();
const mockGetUserCounts = vi.fn();
const mockRegisterUser = vi.fn();
const mockGetUser = vi.fn();
const mockUpdateUser = vi.fn();
const mockDeleteUser = vi.fn();
vi.mock('@/lib/platform-client', () => ({
listUsers: (...args: unknown[]) => mockListUsers(...args),
getUserCounts: (...args: unknown[]) => mockGetUserCounts(...args),
registerUser: (...args: unknown[]) => mockRegisterUser(...args),
getUser: (...args: unknown[]) => mockGetUser(...args),
updateUser: (...args: unknown[]) => mockUpdateUser(...args),
deleteUser: (...args: unknown[]) => mockDeleteUser(...args),
}));
vi.mock('@/lib/product-config', () => ({
PRODUCT_ID: 'test-product',
}));
import { GET as listUsersGET, POST as createUserPOST } from '@/app/api/users/route';
import {
GET as getUserGET,
PUT as updateUserPUT,
DELETE as deleteUserDELETE,
} from '@/app/api/users/[id]/route';
// ── Helpers ────────────────────────────────────────────────────────────
async function nr(url: string, opts?: RequestInit) {
const { NextRequest } = await import('next/server');
return new NextRequest(
new Request(url, {
headers: { Authorization: 'Bearer test', 'Content-Type': 'application/json', ...opts?.headers },
...opts,
})
);
}
const admin = { id: 'usr_a', email: 'a@example.com', name: 'Admin', role: 'admin' };
// ── GET /api/users ─────────────────────────────────────────────────────
describe('GET /api/users', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when unauthenticated', async () => {
mockRequireAdmin.mockResolvedValue(null);
const res = await listUsersGET(await nr('http://localhost:3001/api/users'));
expect(res.status).toBe(401);
});
it('returns users list with total and byPlan', async () => {
mockRequireAdmin.mockResolvedValue(admin);
mockListUsers.mockResolvedValue({ users: [{ id: 'u1', email: 'a@b.com' }] });
mockGetUserCounts.mockResolvedValue({ total: 42, byPlan: { free: 30, pro: 10, enterprise: 2 } });
const res = await listUsersGET(await nr('http://localhost:3001/api/users'));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.users).toHaveLength(1);
expect(data.total).toBe(42);
expect(data.byPlan.free).toBe(30);
});
it('passes limit and offset from query params', async () => {
mockRequireAdmin.mockResolvedValue(admin);
mockListUsers.mockResolvedValue({ users: [] });
mockGetUserCounts.mockResolvedValue({ total: 0, byPlan: {} });
await listUsersGET(await nr('http://localhost:3001/api/users?limit=10&offset=20'));
expect(mockListUsers).toHaveBeenCalledWith('test', 10, 20);
});
});
// ── POST /api/users ────────────────────────────────────────────────────
describe('POST /api/users', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 403 for non-admin users', async () => {
mockRequireAdmin.mockResolvedValue(null);
const res = await createUserPOST(
await nr('http://localhost:3001/api/users', {
method: 'POST',
body: JSON.stringify({ email: 'new@example.com', name: 'New', password: 'pass123' }),
})
);
expect(res.status).toBe(403);
});
it('returns 400 when required fields are missing', async () => {
mockRequireAdmin.mockResolvedValue(admin);
const res = await createUserPOST(
await nr('http://localhost:3001/api/users', {
method: 'POST',
body: JSON.stringify({ email: 'new@example.com' }),
})
);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain('required');
});
it('creates user with 201 when admin provides all fields', async () => {
mockRequireAdmin.mockResolvedValue(admin);
mockRegisterUser.mockResolvedValue({
user: { id: 'usr_new', email: 'new@example.com', displayName: 'New User', role: 'user', plan: 'free' },
});
const res = await createUserPOST(
await nr('http://localhost:3001/api/users', {
method: 'POST',
body: JSON.stringify({ email: 'new@example.com', name: 'New User', password: 'pass123' }),
})
);
expect(res.status).toBe(201);
const data = await res.json();
expect(data.email).toBe('new@example.com');
});
});
// ── GET /api/users/[id] ────────────────────────────────────────────────
describe('GET /api/users/[id]', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 401 when unauthenticated', async () => {
mockRequireAdmin.mockResolvedValue(null);
const res = await getUserGET(await nr('http://localhost:3001/api/users/usr_1'), {
params: Promise.resolve({ id: 'usr_1' }),
});
expect(res.status).toBe(401);
});
it('returns user data on success', async () => {
mockRequireAdmin.mockResolvedValue(admin);
mockGetUser.mockResolvedValue({ id: 'usr_1', email: 'user@example.com', role: 'user' });
const res = await getUserGET(await nr('http://localhost:3001/api/users/usr_1'), {
params: Promise.resolve({ id: 'usr_1' }),
});
expect(res.status).toBe(200);
const data = await res.json();
expect(data.id).toBe('usr_1');
});
});
// ── PUT /api/users/[id] ────────────────────────────────────────────────
describe('PUT /api/users/[id]', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 403 for non-admin users', async () => {
mockRequireAdmin.mockResolvedValue(null);
const res = await updateUserPUT(
await nr('http://localhost:3001/api/users/usr_1', {
method: 'PUT',
body: JSON.stringify({ name: 'Updated' }),
}),
{ params: Promise.resolve({ id: 'usr_1' }) }
);
expect(res.status).toBe(403);
});
it('updates user and returns updated data', async () => {
mockRequireAdmin.mockResolvedValue(admin);
mockUpdateUser.mockResolvedValue({ id: 'usr_1', displayName: 'Updated Name', role: 'user' });
const res = await updateUserPUT(
await nr('http://localhost:3001/api/users/usr_1', {
method: 'PUT',
body: JSON.stringify({ name: 'Updated Name' }),
}),
{ params: Promise.resolve({ id: 'usr_1' }) }
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.displayName).toBe('Updated Name');
});
});
// ── DELETE /api/users/[id] ─────────────────────────────────────────────
describe('DELETE /api/users/[id]', () => {
beforeEach(() => vi.clearAllMocks());
it('returns 403 for non-admin', async () => {
mockRequireAdmin.mockResolvedValue(null);
const res = await deleteUserDELETE(
await nr('http://localhost:3001/api/users/usr_1', { method: 'DELETE' }),
{ params: Promise.resolve({ id: 'usr_1' }) }
);
expect(res.status).toBe(403);
});
it('deletes user successfully', async () => {
mockRequireAdmin.mockResolvedValue(admin);
mockDeleteUser.mockResolvedValue({ success: true });
const res = await deleteUserDELETE(
await nr('http://localhost:3001/api/users/usr_1', { method: 'DELETE' }),
{ params: Promise.resolve({ id: 'usr_1' }) }
);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
});
});

View File

@ -0,0 +1,216 @@
'use client';
import { useState, useEffect } from 'react';
import { Shield, User, Settings2, Key, CreditCard, Search, AlertTriangle } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { mockAuditLog, type AuditEntry } from '@/lib/mock-data';
import { apiGetAudit } from '@/lib/api';
const categoryConfig: Record<
AuditEntry['category'],
{ label: string; icon: typeof Shield; color: string }
> = {
auth: { label: 'Auth', icon: Shield, color: 'bg-blue-50 text-blue-700' },
user: { label: 'User', icon: User, color: 'bg-purple-50 text-purple-700' },
config: { label: 'Config', icon: Settings2, color: 'bg-amber-50 text-amber-700' },
token: { label: 'Token', icon: Key, color: 'bg-emerald-50 text-emerald-700' },
billing: { label: 'Billing', icon: CreditCard, color: 'bg-pink-50 text-pink-700' },
};
function formatTimestamp(ts: string): string {
const d = new Date(ts);
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export default function AuditLogPage() {
const [entries, setEntries] = useState<AuditEntry[]>(mockAuditLog);
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
useEffect(() => {
apiGetAudit().then(({ data }) => {
if (data?.entries?.length) {
setEntries(
data.entries.map(e => ({
id: e.id,
category: e.category as AuditEntry['category'],
action: e.action,
actor: e.actor,
target: e.target,
ip: e.ip,
timestamp: e.timestamp,
}))
);
}
});
}, []);
const filtered = entries.filter(entry => {
const matchSearch =
!search ||
entry.actor.toLowerCase().includes(search.toLowerCase()) ||
entry.action.toLowerCase().includes(search.toLowerCase()) ||
(entry.target?.toLowerCase().includes(search.toLowerCase()) ?? false) ||
(entry.details?.toLowerCase().includes(search.toLowerCase()) ?? false);
const matchCategory = categoryFilter === 'all' || entry.category === categoryFilter;
return matchSearch && matchCategory;
});
const failedLogins = entries.filter(e => e.action === 'Failed Login').length;
const configChanges = entries.filter(e => e.category === 'config').length;
const userActions = entries.filter(e => e.category === 'user').length;
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Audit Log</h1>
<p className="text-muted-foreground">
Track all administrative actions and security events
</p>
</div>
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Total Events</CardTitle>
<Shield className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{entries.length}</div>
<p className="text-xs text-muted-foreground">Last 3 days</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Failed Logins</CardTitle>
<AlertTriangle className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">{failedLogins}</div>
<p className="text-xs text-muted-foreground">Potential threats</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Config Changes</CardTitle>
<Settings2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{configChanges}</div>
<p className="text-xs text-muted-foreground">+ {userActions} user actions</p>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex flex-col gap-3 sm:flex-row">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by actor, action, target…"
className="pl-9"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="auth">Auth</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="config">Config</SelectItem>
<SelectItem value="token">Token</SelectItem>
<SelectItem value="billing">Billing</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">Time</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Action</TableHead>
<TableHead>Category</TableHead>
<TableHead>Target</TableHead>
<TableHead className="hidden lg:table-cell">Details</TableHead>
<TableHead className="hidden md:table-cell">IP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map(entry => {
const cat = categoryConfig[entry.category];
const isFailed = entry.action === 'Failed Login';
return (
<TableRow key={entry.id} className={isFailed ? 'bg-destructive/5' : undefined}>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatTimestamp(entry.timestamp)}
</TableCell>
<TableCell className="text-sm font-medium">{entry.actor}</TableCell>
<TableCell className="text-sm">
{isFailed && (
<AlertTriangle className="mr-1 inline h-3.5 w-3.5 text-destructive" />
)}
{entry.action}
</TableCell>
<TableCell>
<Badge variant="secondary" className={cat.color}>
<cat.icon className="mr-1 h-3 w-3" />
{cat.label}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{entry.target ?? '—'}
</TableCell>
<TableCell className="hidden lg:table-cell text-xs text-muted-foreground max-w-[200px] truncate">
{entry.details ?? '—'}
</TableCell>
<TableCell className="hidden md:table-cell font-mono text-xs text-muted-foreground">
{entry.ip}
</TableCell>
</TableRow>
);
})}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
No audit entries found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,199 @@
'use client';
import { useStripeConfig } from '@/lib/stripe-context';
import {
FlaskConical,
ShieldCheck,
CreditCard,
Webhook,
Server,
CheckCircle2,
XCircle,
ExternalLink,
} from 'lucide-react';
function StatusBadge({ ok, label }: { ok: boolean; label: string }) {
return (
<div className="flex items-center gap-2">
{ok ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<span className={ok ? 'text-foreground' : 'text-muted-foreground'}>{label}</span>
</div>
);
}
export default function BillingSettingsPage() {
const { mode, configured, isLive, priceIds, webhookConfigured, billingServiceUrl, loading } =
useStripeConfig();
if (loading) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Billing Configuration</h1>
<div className="text-muted-foreground">Loading Stripe configuration...</div>
</div>
);
}
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold">Billing Configuration</h1>
<p className="text-muted-foreground mt-1">
Stripe account settings, product IDs, and service status.
</p>
</div>
{/* Mode Banner */}
<div
className={`rounded-lg border p-4 flex items-center gap-3 ${
isLive
? 'bg-emerald-50 border-emerald-200 dark:bg-emerald-950/30 dark:border-emerald-800'
: mode === 'test'
? 'bg-amber-50 border-amber-200 dark:bg-amber-950/30 dark:border-amber-800'
: 'bg-red-50 border-red-200 dark:bg-red-950/30 dark:border-red-800'
}`}
>
{isLive ? (
<ShieldCheck className="h-6 w-6 text-emerald-600" />
) : (
<FlaskConical className="h-6 w-6 text-amber-600" />
)}
<div>
<div className="font-semibold text-sm">
{isLive ? 'LIVE MODE' : mode === 'test' ? 'TEST MODE' : 'NOT CONFIGURED'}
</div>
<div className="text-xs text-muted-foreground">
{isLive
? 'Real payments are being processed. Changes affect real customers.'
: mode === 'test'
? 'Using Stripe test keys. No real charges. Use card 4242 4242 4242 4242.'
: 'Stripe is not configured. Set STRIPE_SECRET_KEY in env.'}
</div>
</div>
</div>
{/* Configuration Status */}
<div className="rounded-lg border p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Configuration Status
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<StatusBadge ok={configured} label="Stripe API key configured" />
<StatusBadge ok={webhookConfigured} label="Webhook secret configured" />
<StatusBadge
ok={!!priceIds.pro && !priceIds.pro.includes('placeholder')}
label="Pro price ID set"
/>
<StatusBadge
ok={!!priceIds.enterprise && !priceIds.enterprise.includes('placeholder')}
label="Enterprise price ID set"
/>
</div>
</div>
{/* Product IDs */}
<div className="rounded-lg border p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Stripe Products
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b">
<div>
<div className="font-medium">Pro Plan</div>
<div className="text-sm text-muted-foreground">$9.99/month</div>
</div>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
{priceIds.pro || 'Not configured'}
</code>
</div>
<div className="flex items-center justify-between py-2 border-b">
<div>
<div className="font-medium">Enterprise Plan</div>
<div className="text-sm text-muted-foreground">$29.99/month</div>
</div>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
{priceIds.enterprise || 'Not configured'}
</code>
</div>
</div>
</div>
{/* Services */}
<div className="rounded-lg border p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Server className="h-5 w-5" />
Services
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b">
<div>
<div className="font-medium">Billing Service</div>
<div className="text-sm text-muted-foreground">
Subscriptions, usage metering, plan config
</div>
</div>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
{billingServiceUrl}
</code>
</div>
<div className="flex items-center justify-between py-2 border-b">
<div>
<div className="font-medium flex items-center gap-2">
Webhook Endpoint
<Webhook className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="text-sm text-muted-foreground">
Receives Stripe events (checkout, subscription changes)
</div>
</div>
<span
className={`text-xs font-medium px-2 py-1 rounded ${
webhookConfigured
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400'
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400'
}`}
>
{webhookConfigured ? 'Configured' : 'Not configured'}
</span>
</div>
</div>
</div>
{/* Switching to Production */}
<div className="rounded-lg border p-6 space-y-4">
<h2 className="text-lg font-semibold">Switching to Production</h2>
<p className="text-sm text-muted-foreground">
Stripe mode is determined by the <code>STRIPE_SECRET_KEY</code> env var prefix. There is
no runtime toggle this is a deliberate safety measure.
</p>
<div className="bg-muted/50 rounded p-4 text-sm space-y-2">
<div>
<strong>Test mode:</strong> <code>STRIPE_SECRET_KEY=sk_test_...</code>
</div>
<div>
<strong>Live mode:</strong> <code>STRIPE_SECRET_KEY=sk_live_...</code>
</div>
</div>
<p className="text-sm text-muted-foreground">
To go live: update the env var in your deployment, create live products/prices, and
redeploy. See <code>docs/STRIPE_SETUP_GUIDE.md</code> Go Live Checklist.
</p>
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
>
Open Stripe Dashboard
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
);
}

View File

@ -0,0 +1,436 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
FileText,
Search,
MessageSquare,
X,
Send,
Loader2,
ChevronRight,
FolderOpen,
BookOpen,
Bot,
User,
} from 'lucide-react';
// ── Types ──────────────────────────────────────────────────────────
interface DocFile {
slug: string;
title: string;
path: string;
category: string;
sizeBytes: number;
modifiedAt: string;
}
interface SearchResult extends DocFile {
snippet: string;
}
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
// ── Category metadata ──────────────────────────────────────────────
const CATEGORY_LABELS: Record<string, string> = {
root: 'Project Root',
docs: 'Documentation',
'docs/research': 'Research',
services: 'Service READMEs',
};
// ── Component ──────────────────────────────────────────────────────
export default function DocsPage() {
// Doc list & viewer state
const [docs, setDocs] = useState<DocFile[]>([]);
const [categories, setCategories] = useState<Record<string, DocFile[]>>({});
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
const [docContent, setDocContent] = useState<string>('');
const [docMeta, setDocMeta] = useState<DocFile | null>(null);
const [loadingDoc, setLoadingDoc] = useState(false);
// Search state
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [, setSearching] = useState(false);
// Chat state
const [chatOpen, setChatOpen] = useState(false);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [chatInput, setChatInput] = useState('');
const [chatLoading, setChatLoading] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
// Expanded categories
const [expandedCats, setExpandedCats] = useState<Set<string>>(new Set(['docs', 'root']));
// ── Load doc list ──
useEffect(() => {
fetch('/api/docs')
.then(r => r.json())
.then(data => {
setDocs(data.docs || []);
setCategories(data.categories || {});
// Auto-expand all categories
if (data.categories) {
setExpandedCats(new Set(Object.keys(data.categories)));
}
})
.catch(console.error);
}, []);
// ── Load a specific doc ──
const loadDoc = useCallback(async (slug: string) => {
setSelectedSlug(slug);
setLoadingDoc(true);
setSearchResults(null);
try {
const res = await fetch(`/api/docs/${slug}`);
const data = await res.json();
setDocContent(data.content || '');
setDocMeta(data.meta || null);
} catch {
setDocContent('# Error\nFailed to load document.');
} finally {
setLoadingDoc(false);
}
}, []);
// ── Search ──
const handleSearch = useCallback(async () => {
if (!searchQuery.trim()) {
setSearchResults(null);
return;
}
setSearching(true);
try {
const res = await fetch(`/api/docs?q=${encodeURIComponent(searchQuery)}`);
const data = await res.json();
setSearchResults(data.results || []);
} catch {
setSearchResults([]);
} finally {
setSearching(false);
}
}, [searchQuery]);
// ── Chat ──
const sendChat = useCallback(async () => {
if (!chatInput.trim() || chatLoading) return;
const question = chatInput.trim();
setChatInput('');
const newMessages: ChatMessage[] = [...chatMessages, { role: 'user', content: question }];
setChatMessages(newMessages);
setChatLoading(true);
try {
const res = await fetch('/api/docs/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
question,
history: newMessages.slice(-6).map(m => ({
role: m.role,
content: m.content,
})),
}),
});
const data = await res.json();
setChatMessages([
...newMessages,
{ role: 'assistant', content: data.answer || data.error || 'No response' },
]);
} catch {
setChatMessages([
...newMessages,
{ role: 'assistant', content: 'Failed to reach the AI assistant.' },
]);
} finally {
setChatLoading(false);
}
}, [chatInput, chatLoading, chatMessages]);
// Auto-scroll chat
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chatMessages]);
const toggleCategory = (cat: string) => {
setExpandedCats(prev => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
};
return (
<div className="flex h-[calc(100vh-4rem)] gap-0">
{/* ── Sidebar: File Tree ── */}
<div className="w-72 shrink-0 border-r bg-card overflow-y-auto">
{/* Search bar */}
<div className="p-3 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search docs..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
className="w-full rounded-md border bg-background pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Search results */}
{searchResults !== null ? (
<div className="p-2">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-xs font-semibold text-muted-foreground">
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
</span>
<button
onClick={() => {
setSearchResults(null);
setSearchQuery('');
}}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear
</button>
</div>
{searchResults.map(r => (
<button
key={r.slug}
onClick={() => loadDoc(r.slug)}
className={`w-full text-left rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors ${
selectedSlug === r.slug ? 'bg-accent' : ''
}`}
>
<div className="font-medium truncate">{r.title}</div>
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{r.snippet}</div>
</button>
))}
{searchResults.length === 0 && (
<p className="text-sm text-muted-foreground px-3 py-4 text-center">
No documents match your search.
</p>
)}
</div>
) : (
/* File tree */
<div className="p-2">
{Object.entries(categories).map(([cat, catDocs]) => (
<div key={cat} className="mb-1">
<button
onClick={() => toggleCategory(cat)}
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs font-semibold text-muted-foreground hover:text-foreground rounded transition-colors"
>
<ChevronRight
className={`h-3 w-3 transition-transform ${
expandedCats.has(cat) ? 'rotate-90' : ''
}`}
/>
<FolderOpen className="h-3.5 w-3.5" />
{CATEGORY_LABELS[cat] || cat}
<span className="ml-auto text-[10px] tabular-nums">{catDocs.length}</span>
</button>
{expandedCats.has(cat) && (
<div className="ml-4">
{catDocs.map(doc => (
<button
key={doc.slug}
onClick={() => loadDoc(doc.slug)}
className={`flex items-center gap-2 w-full rounded-md px-2 py-1.5 text-sm transition-colors ${
selectedSlug === doc.slug
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{doc.title}</span>
</button>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* ── Main Content Area ── */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Doc viewer */}
<div className="flex-1 overflow-y-auto">
{loadingDoc ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : docContent ? (
<div className="max-w-4xl mx-auto px-8 py-6">
{docMeta && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-4 pb-3 border-b">
<BookOpen className="h-3.5 w-3.5" />
<span className="font-mono">{docMeta.path}</span>
<span></span>
<span>{(docMeta.sizeBytes / 1024).toFixed(1)} KB</span>
<span></span>
<span>Modified {new Date(docMeta.modifiedAt).toLocaleDateString()}</span>
</div>
)}
<article className="prose prose-sm dark:prose-invert max-w-none prose-headings:scroll-mt-4 prose-code:before:content-none prose-code:after:content-none prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{docContent}</ReactMarkdown>
</article>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<BookOpen className="h-12 w-12 mb-4 opacity-30" />
<h2 className="text-lg font-semibold">Project Documentation</h2>
<p className="text-sm mt-1">
Select a document from the sidebar or ask the AI assistant.
</p>
<p className="text-xs mt-4">
{docs.length} documents across {Object.keys(categories).length} categories
</p>
</div>
)}
</div>
{/* Chat FAB */}
{!chatOpen && (
<button
onClick={() => setChatOpen(true)}
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-3 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<MessageSquare className="h-5 w-5" />
<span className="text-sm font-medium">Ask AI</span>
</button>
)}
</div>
{/* ── Chat Panel ── */}
{chatOpen && (
<div className="w-96 shrink-0 border-l bg-card flex flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-primary" />
<div>
<h3 className="text-sm font-semibold">AI Assistant</h3>
<p className="text-[10px] text-muted-foreground">
Powered by Perplexity Knows all project docs
</p>
</div>
</div>
<button
onClick={() => setChatOpen(false)}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{chatMessages.length === 0 && (
<div className="text-center py-8">
<Bot className="h-10 w-10 mx-auto mb-3 text-muted-foreground opacity-40" />
<p className="text-sm text-muted-foreground">Ask me anything about the project!</p>
<div className="mt-4 space-y-2">
{[
'How do I deploy to production?',
"What's the Stripe setup process?",
'How does the billing service work?',
'What are the iOS TestFlight steps?',
].map(q => (
<button
key={q}
onClick={() => {
setChatInput(q);
}}
className="block w-full text-left text-xs bg-muted rounded-lg px-3 py-2 hover:bg-accent transition-colors"
>
{q}
</button>
))}
</div>
</div>
)}
{chatMessages.map((msg, i) => (
<div key={i} className={`flex gap-2 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="shrink-0 w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="h-4 w-4 text-primary" />
</div>
)}
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
msg.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted'
}`}
>
{msg.role === 'assistant' ? (
<div className="prose prose-sm dark:prose-invert max-w-none prose-p:my-1 prose-code:before:content-none prose-code:after:content-none prose-code:bg-background/50 prose-code:px-1 prose-code:rounded prose-code:text-xs">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</div>
) : (
msg.content
)}
</div>
{msg.role === 'user' && (
<div className="shrink-0 w-7 h-7 rounded-full bg-muted flex items-center justify-center">
<User className="h-4 w-4" />
</div>
)}
</div>
))}
{chatLoading && (
<div className="flex gap-2">
<div className="shrink-0 w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center">
<Bot className="h-4 w-4 text-primary" />
</div>
<div className="bg-muted rounded-lg px-3 py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Input */}
<div className="border-t p-3">
<div className="flex gap-2">
<input
type="text"
value={chatInput}
onChange={e => setChatInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendChat()}
placeholder="Ask about the project..."
className="flex-1 rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={chatLoading}
/>
<button
onClick={sendChat}
disabled={chatLoading || !chatInput.trim()}
className="rounded-md bg-primary px-3 py-2 text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-opacity"
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,425 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Sparkles,
FileText,
Zap,
Activity,
RefreshCw,
ChevronDown,
ChevronUp,
Tag,
Clock,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Separator } from '@/components/ui/separator';
import { apiFetch } from '@/lib/api';
// ── Types ────────────────────────────────────────────────────────
interface ExtractionEntity {
extraction_class: string;
extraction_text: string;
attributes?: Record<string, string>;
}
interface ExtractResponse {
extractions: ExtractionEntity[];
metadata: {
modelId: string;
durationMs: number;
tokenCount?: number;
charCount: number;
};
requestId?: string;
}
interface ExtractionTask {
id: string;
name: string;
description?: string;
prompt: string;
classes: string[];
builtIn: boolean;
productId: string;
}
interface SidecarHealth {
status: string;
sidecar?: { status: string; version: string };
}
// ── Helpers ──────────────────────────────────────────────────────
const CLASS_COLORS: Record<string, string> = {
action_item: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
decision: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
question: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
deadline: 'bg-red-500/20 text-red-400 border-red-500/30',
person: 'bg-green-500/20 text-green-400 border-green-500/30',
topic: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
emotion: 'bg-pink-500/20 text-pink-400 border-pink-500/30',
brain_signal: 'bg-indigo-500/20 text-indigo-400 border-indigo-500/30',
entity: 'bg-teal-500/20 text-teal-400 border-teal-500/30',
action: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
};
function getClassColor(cls: string): string {
return CLASS_COLORS[cls] || 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30';
}
// ── Component ────────────────────────────────────────────────────
export default function ExtractionPage() {
const [inputText, setInputText] = useState('');
const [selectedTask, setSelectedTask] = useState('transcript-extraction');
const [tasks, setTasks] = useState<ExtractionTask[]>([]);
const [result, setResult] = useState<ExtractResponse | null>(null);
const [health, setHealth] = useState<SidecarHealth | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showTaskDetails, setShowTaskDetails] = useState(false);
// Load tasks and health on mount
useEffect(() => {
loadTasks();
checkHealth();
}, []);
async function loadTasks() {
try {
const res = await apiFetch<ExtractionTask[]>('/extraction/tasks');
if (res.data) setTasks(res.data);
} catch {
// Tasks endpoint may not be available yet
}
}
async function checkHealth() {
try {
const res = await apiFetch<SidecarHealth>('/extraction/extract/sidecar-health');
setHealth(res.data);
} catch {
setHealth({ status: 'error' });
}
}
const handleExtract = useCallback(async () => {
if (!inputText.trim()) return;
setLoading(true);
setError(null);
setResult(null);
try {
const res = await apiFetch<ExtractResponse>('/extraction/extract', {
method: 'POST',
body: JSON.stringify({
text: inputText,
taskId: selectedTask,
}),
});
if (res.data) setResult(res.data);
else setError(res.error || 'No response from extraction service');
} catch (err) {
setError(err instanceof Error ? err.message : 'Extraction failed');
} finally {
setLoading(false);
}
}, [inputText, selectedTask]);
const currentTask = tasks.find((t) => t.id === selectedTask);
// Group extractions by class
const groupedExtractions = result?.extractions.reduce(
(acc, e) => {
const cls = e.extraction_class;
if (!acc[cls]) acc[cls] = [];
acc[cls].push(e);
return acc;
},
{} as Record<string, ExtractionEntity[]>,
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Extraction</h1>
<p className="text-muted-foreground text-sm">
Extract structured entities from text using LangExtract
</p>
</div>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={
health?.status === 'ok'
? 'border-green-500/50 text-green-400'
: 'border-red-500/50 text-red-400'
}
>
<Activity className="mr-1 h-3 w-3" />
{health?.status === 'ok' ? 'Sidecar Online' : 'Sidecar Offline'}
</Badge>
<Button variant="ghost" size="icon" onClick={checkHealth}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
<Separator />
{/* Input Section */}
<div className="grid gap-4 md:grid-cols-3">
<div className="md:col-span-2 space-y-3">
<label className="text-sm font-medium">Input Text</label>
<textarea
className="w-full min-h-[200px] rounded-lg border border-border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Paste a transcript, meeting notes, or any text to extract structured entities from..."
value={inputText}
onChange={(e) => setInputText(e.target.value)}
/>
<div className="flex items-center gap-2">
<Button onClick={handleExtract} disabled={loading || !inputText.trim()}>
{loading ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{loading ? 'Extracting...' : 'Extract'}
</Button>
<span className="text-xs text-muted-foreground">
{inputText.length.toLocaleString()} chars
</span>
</div>
</div>
{/* Task Selector */}
<div className="space-y-3">
<label className="text-sm font-medium">Task</label>
<select
className="w-full rounded-lg border border-border bg-background p-2 text-sm"
value={selectedTask}
onChange={(e) => setSelectedTask(e.target.value)}
>
{tasks.length > 0 ? (
tasks.map((t) => (
<option key={t.id} value={t.id}>
{t.name} {t.builtIn ? '(built-in)' : ''}
</option>
))
) : (
<>
<option value="transcript-extraction">Transcript Extraction</option>
<option value="triage">MindLyst Triage</option>
<option value="memory-insight">Memory Insight</option>
<option value="reflection-enrichment">Reflection Enrichment</option>
<option value="bug-report-extraction">Bug Report Extraction</option>
</>
)}
</select>
{currentTask && (
<div className="space-y-2">
<button
className="flex items-center text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowTaskDetails(!showTaskDetails)}
>
{showTaskDetails ? (
<ChevronUp className="mr-1 h-3 w-3" />
) : (
<ChevronDown className="mr-1 h-3 w-3" />
)}
Task Details
</button>
{showTaskDetails && (
<Card>
<CardContent className="pt-4 space-y-2 text-xs">
<p className="text-muted-foreground">{currentTask.description}</p>
<div className="flex flex-wrap gap-1">
{currentTask.classes.map((cls) => (
<Badge
key={cls}
variant="outline"
className={`text-[10px] ${getClassColor(cls)}`}
>
{cls}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Quick Stats */}
<Card>
<CardContent className="pt-4 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Model</span>
<span>gemini-2.5-flash</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Tasks</span>
<span>{tasks.length || 5} available</span>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Error */}
{error && (
<Card className="border-red-500/30 bg-red-500/5">
<CardContent className="pt-4">
<p className="text-sm text-red-400">{error}</p>
</CardContent>
</Card>
)}
{/* Results */}
{result && (
<div className="space-y-4">
<Separator />
{/* Result Metadata */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Entities</CardTitle>
<Tag className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{result.extractions.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Duration</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{result.metadata.durationMs}ms</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Model</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-lg font-bold truncate">{result.metadata.modelId}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Input</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{result.metadata.charCount.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">characters</p>
</CardContent>
</Card>
</div>
{/* Grouped Extractions */}
{groupedExtractions && Object.keys(groupedExtractions).length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium">Extracted Entities by Class</h3>
<div className="grid gap-3 md:grid-cols-2">
{Object.entries(groupedExtractions).map(([cls, entities]) => (
<Card key={cls}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<Badge variant="outline" className={getClassColor(cls)}>
{cls}
</Badge>
<span className="text-xs text-muted-foreground">
{entities.length} found
</span>
</div>
</CardHeader>
<CardContent className="space-y-2">
{entities.map((e, i) => (
<div
key={i}
className="rounded-md border border-border/50 bg-muted/30 p-2 text-sm"
>
<p className="text-foreground">&quot;{e.extraction_text}&quot;</p>
{e.attributes && Object.keys(e.attributes).length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{Object.entries(e.attributes).map(([k, v]) => (
<Badge
key={k}
variant="secondary"
className="text-[10px]"
>
{k}: {v}
</Badge>
))}
</div>
)}
</div>
))}
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Raw Results Table */}
<div className="space-y-3">
<h3 className="text-sm font-medium">All Extractions</h3>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">Class</TableHead>
<TableHead>Text</TableHead>
<TableHead className="w-[200px]">Attributes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.extractions.map((e, i) => (
<TableRow key={i}>
<TableCell>
<Badge variant="outline" className={`text-xs ${getClassColor(e.extraction_class)}`}>
{e.extraction_class}
</Badge>
</TableCell>
<TableCell className="text-sm">{e.extraction_text}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{e.attributes
? Object.entries(e.attributes)
.map(([k, v]) => `${k}=${v}`)
.join(', ')
: '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,322 @@
'use client';
import { useState, useEffect } from 'react';
import {
Flag,
Plus,
Loader2,
Trash2,
ToggleLeft,
ToggleRight,
} from 'lucide-react';
import { Card, CardContent, 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 { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
interface FlagDoc {
id: string;
productId: string;
key: string;
enabled: boolean;
description: string;
platforms: string[];
segments: string[];
percentage: number;
createdAt: string;
updatedAt: string;
}
export default function FlagsPage() {
const [flags, setFlags] = useState<FlagDoc[]>([]);
const [loading, setLoading] = useState(true);
// Create dialog
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({
key: '',
description: '',
enabled: true,
percentage: 100,
platforms: '',
});
const loadFlags = async () => {
try {
const res = await fetch('/api/flags');
if (res.ok) {
const data = await res.json();
setFlags(data.flags ?? []);
}
} catch {
// ignore
} finally {
setLoading(false);
}
};
useEffect(() => {
loadFlags();
}, []);
const handleCreate = async () => {
if (!form.key.trim()) return;
setCreating(true);
try {
const res = await fetch('/api/flags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: form.key.trim().toLowerCase().replace(/\s+/g, '_'),
description: form.description,
enabled: form.enabled,
percentage: form.percentage,
platforms: form.platforms
? form.platforms.split(',').map(s => s.trim()).filter(Boolean)
: [],
}),
});
if (res.ok) {
setCreateOpen(false);
setForm({ key: '', description: '', enabled: true, percentage: 100, platforms: '' });
loadFlags();
}
} catch {
// ignore
} finally {
setCreating(false);
}
};
const handleToggle = async (flag: FlagDoc) => {
try {
const res = await fetch(`/api/flags/${encodeURIComponent(flag.key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !flag.enabled }),
});
if (res.ok) loadFlags();
} catch {
// ignore
}
};
const handleUpdatePercentage = async (flag: FlagDoc, percentage: number) => {
try {
await fetch(`/api/flags/${encodeURIComponent(flag.key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ percentage }),
});
loadFlags();
} catch {
// ignore
}
};
const handleDelete = async (flag: FlagDoc) => {
if (!confirm(`Delete flag "${flag.key}"? This cannot be undone.`)) return;
try {
const res = await fetch(`/api/flags/${encodeURIComponent(flag.key)}`, { method: 'DELETE' });
if (res.ok) loadFlags();
} catch {
// ignore
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Feature Flags</h1>
<p className="text-muted-foreground">
Control feature rollouts with percentage-based targeting
</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Flag
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Feature Flag</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label>Key</Label>
<Input
placeholder="enable_new_editor"
value={form.key}
onChange={e => setForm({ ...form, key: e.target.value })}
/>
<p className="text-[10px] text-muted-foreground">
Lowercase, underscores only (e.g. enable_new_editor)
</p>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
placeholder="Enable the new rich text editor"
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Platforms (comma-separated, optional)</Label>
<Input
placeholder="desktop, ios, android, web"
value={form.platforms}
onChange={e => setForm({ ...form, platforms: e.target.value })}
/>
</div>
<div className="flex items-center justify-between">
<Label>Enabled by default</Label>
<Switch
checked={form.enabled}
onCheckedChange={v => setForm({ ...form, enabled: v })}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Rollout Percentage</Label>
<span className="text-sm font-mono">{form.percentage}%</span>
</div>
<Slider
value={[form.percentage]}
onValueChange={([v]: number[]) => setForm({ ...form, percentage: v })}
min={0}
max={100}
step={1}
/>
</div>
<Button
className="w-full"
onClick={handleCreate}
disabled={creating || !form.key.trim()}
>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Flag
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{flags.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Flag className="mx-auto h-12 w-12 mb-3 opacity-30" />
<p>No feature flags yet</p>
<p className="text-xs mt-1">Create your first flag to control feature rollouts</p>
</div>
) : (
<div className="space-y-3">
{flags.map(flag => (
<Card key={flag.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => handleToggle(flag)}
className="text-muted-foreground hover:text-foreground transition-colors"
title={flag.enabled ? 'Disable flag' : 'Enable flag'}
>
{flag.enabled ? (
<ToggleRight className="h-6 w-6 text-emerald-500" />
) : (
<ToggleLeft className="h-6 w-6" />
)}
</button>
<div>
<CardTitle className="text-sm font-mono">{flag.key}</CardTitle>
{flag.description && (
<p className="text-xs text-muted-foreground mt-0.5">
{flag.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={
flag.enabled
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
}
>
{flag.enabled ? 'ON' : 'OFF'}
</Badge>
<Badge variant="outline" className="font-mono text-xs">
{flag.percentage}%
</Badge>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => handleDelete(flag)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{flag.platforms.length > 0 && (
<span>
Platforms: {flag.platforms.join(', ')}
</span>
)}
{flag.segments.length > 0 && (
<span>
Segments: {flag.segments.join(', ')}
</span>
)}
<span>
Updated {new Date(flag.updatedAt).toLocaleDateString()}
</span>
</div>
{flag.enabled && (
<div className="mt-3 flex items-center gap-3">
<span className="text-xs text-muted-foreground w-16">Rollout:</span>
<Slider
value={[flag.percentage]}
onValueChange={([v]: number[]) => handleUpdatePercentage(flag, v)}
min={0}
max={100}
step={1}
className="flex-1"
/>
<span className="text-xs font-mono w-10 text-right">{flag.percentage}%</span>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,572 @@
'use client';
import { useState, useEffect } from 'react';
import {
Ticket,
Plus,
Copy,
MoreHorizontal,
CheckCircle2,
XCircle,
Clock,
Upload,
FileSpreadsheet,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
apiListInvitations,
apiCreateInvitation,
apiUpdateInvitation,
apiDeleteInvitation,
apiBulkCreateInvitations,
type ApiInvitation,
type BulkInviteResult,
} from '@/lib/api';
const statusConfig: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
active: { label: 'Active', color: 'bg-emerald-50 text-emerald-700', icon: CheckCircle2 },
expired: { label: 'Expired', color: 'bg-amber-50 text-amber-700', icon: Clock },
disabled: { label: 'Disabled', color: 'bg-red-50 text-red-700', icon: XCircle },
};
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
export default function InvitationsPage() {
const [codes, setCodes] = useState<ApiInvitation[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
// Create form state
const [newCode, setNewCode] = useState('');
const [newDescription, setNewDescription] = useState('');
const [newPlan, setNewPlan] = useState('pro');
const [newTrialDays, setNewTrialDays] = useState('0');
const [newBonusTokens, setNewBonusTokens] = useState('0');
const [newMaxUses, setNewMaxUses] = useState('100');
const [creating, setCreating] = useState(false);
// Bulk upload state
const [showBulk, setShowBulk] = useState(false);
const [csvRows, setCsvRows] = useState<Record<string, string>[]>([]);
const [bulkUploading, setBulkUploading] = useState(false);
const [bulkResult, setBulkResult] = useState<BulkInviteResult | null>(null);
async function loadCodes() {
setLoading(true);
const { data } = await apiListInvitations();
if (data) {
setCodes(data.codes);
setTotal(data.total);
}
setLoading(false);
}
useEffect(() => {
void loadCodes();
}, []);
async function handleCreate() {
setCreating(true);
const { data, error } = await apiCreateInvitation({
code: newCode || undefined,
description: newDescription,
grantPlan: newPlan,
grantTrialDays: parseInt(newTrialDays) || 0,
bonusTokens: parseInt(newBonusTokens) || 0,
maxUses: parseInt(newMaxUses) || 100,
});
setCreating(false);
if (data) {
setShowCreate(false);
setNewCode('');
setNewDescription('');
setNewPlan('pro');
setNewTrialDays('0');
setNewBonusTokens('0');
setNewMaxUses('100');
loadCodes();
} else {
alert(error || 'Failed to create invitation');
}
}
async function handleToggle(inv: ApiInvitation) {
const newStatus = inv.status === 'active' ? 'disabled' : 'active';
await apiUpdateInvitation(inv.id, { status: newStatus });
loadCodes();
}
async function handleDelete(id: string) {
if (!confirm('Delete this invitation code?')) return;
await apiDeleteInvitation(id);
loadCodes();
}
function parseCsvLine(line: string): string[] {
const fields: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
current += '"';
i++; // skip escaped quote
} else if (ch === '"') {
inQuotes = false;
} else {
current += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
fields.push(current.trim());
current = '';
} else {
current += ch;
}
}
}
fields.push(current.trim());
return fields;
}
function handleCsvFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
const text = ev.target?.result as string;
const lines = text.split(/\r?\n/).filter(l => l.trim());
if (lines.length < 2) return;
const headers = parseCsvLine(lines[0]).map(h => h.toLowerCase());
const rows = lines.slice(1).map(line => {
const vals = parseCsvLine(line);
const row: Record<string, string> = {};
headers.forEach((h, i) => (row[h] = vals[i] || ''));
return row;
});
setCsvRows(rows);
setBulkResult(null);
};
reader.readAsText(file);
}
async function handleBulkUpload() {
if (csvRows.length === 0) return;
setBulkUploading(true);
const invitations = csvRows.map(row => ({
code: row.code || '',
description: row.description || '',
createdBy: row.createdby || 'admin',
grantPlan: row.grantplan || row.plan || 'pro',
grantTrialDays: parseInt(row.granttrialdays || row.trialdays || '0') || 0,
bonusTokens: parseInt(row.bonustokens || '0') || 0,
maxUses: parseInt(row.maxuses || '100') || 100,
expiresAt: row.expiresat || null,
}));
const { data, error } = await apiBulkCreateInvitations(invitations);
setBulkUploading(false);
if (data) {
setBulkResult(data);
loadCodes();
} else {
alert(error || 'Bulk upload failed');
}
}
function copyCode(code: string) {
navigator.clipboard.writeText(code);
setCopied(code);
setTimeout(() => setCopied(null), 2000);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Invitation Codes</h1>
<p className="text-sm text-muted-foreground">
Generate invite codes that grant plan access or bonus tokens
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setShowBulk(true);
setCsvRows([]);
setBulkResult(null);
}}
>
<Upload className="mr-2 h-4 w-4" /> CSV Bulk Import
</Button>
<Button onClick={() => setShowCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Create Code
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Codes</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Active Codes
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{codes.filter(c => c.status === 'active').length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Redemptions
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{codes.reduce((sum, c) => sum + c.currentUses, 0)}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : codes.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Ticket className="mx-auto mb-2 h-8 w-8 opacity-40" />
No invitation codes yet. Create one to get started.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Trial</TableHead>
<TableHead>Bonus Tokens</TableHead>
<TableHead>Uses</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{codes.map(inv => {
const cfg = statusConfig[inv.status] || statusConfig.active;
const StatusIcon = cfg.icon;
return (
<TableRow key={inv.id}>
<TableCell>
<div className="flex items-center gap-2">
<code className="text-sm font-mono font-bold">{inv.code}</code>
<button
onClick={() => copyCode(inv.code)}
className="text-muted-foreground hover:text-foreground"
title="Copy code"
>
<Copy className="h-3.5 w-3.5" />
</button>
{copied === inv.code && (
<span className="text-xs text-emerald-600">Copied!</span>
)}
</div>
{inv.description && (
<p className="text-xs text-muted-foreground mt-0.5">{inv.description}</p>
)}
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{inv.grantPlan}
</Badge>
</TableCell>
<TableCell>
{inv.grantTrialDays > 0 ? `${inv.grantTrialDays} days` : '—'}
</TableCell>
<TableCell>
{inv.bonusTokens > 0 ? inv.bonusTokens.toLocaleString() : '—'}
</TableCell>
<TableCell>
{inv.currentUses} / {inv.maxUses}
</TableCell>
<TableCell>
<Badge className={`${cfg.color} border-0 gap-1`}>
<StatusIcon className="h-3 w-3" />
{cfg.label}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatDate(inv.createdAt)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => copyCode(inv.code)}>
Copy Code
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleToggle(inv)}>
{inv.status === 'active' ? 'Disable' : 'Enable'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(inv.id)}
className="text-destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Invitation Code</DialogTitle>
<DialogDescription>
Generate an invite code that grants plan access or bonus tokens when redeemed.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Code (optional auto-generated if empty)</Label>
<Input
placeholder="e.g. BETA-2026"
value={newCode}
onChange={e => setNewCode(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
placeholder="e.g. Beta tester invite"
value={newDescription}
onChange={e => setNewDescription(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Grant Plan</Label>
<Select value={newPlan} onValueChange={setNewPlan}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pro">Pro</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Trial Days (0 = permanent)</Label>
<Input
type="number"
min="0"
value={newTrialDays}
onChange={e => setNewTrialDays(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Bonus Tokens</Label>
<Input
type="number"
min="0"
value={newBonusTokens}
onChange={e => setNewBonusTokens(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Max Uses</Label>
<Input
type="number"
min="1"
value={newMaxUses}
onChange={e => setNewMaxUses(e.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? 'Creating...' : 'Create Code'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk CSV Upload Dialog */}
<Dialog open={showBulk} onOpenChange={setShowBulk}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" />
CSV Bulk Import
</DialogTitle>
<DialogDescription>
Upload a CSV file with columns:{' '}
<code className="text-xs">
code, description, grantPlan, grantTrialDays, bonusTokens, maxUses
</code>
. The <code className="text-xs">createdBy</code> column is optional (defaults to your
admin ID).
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input type="file" accept=".csv" onChange={handleCsvFile} className="cursor-pointer" />
{csvRows.length > 0 && !bulkResult && (
<div className="rounded border">
<div className="bg-muted px-3 py-2 text-sm font-medium">
Preview ({csvRows.length} row{csvRows.length !== 1 ? 's' : ''})
</div>
<div className="max-h-[200px] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Trial</TableHead>
<TableHead>Tokens</TableHead>
<TableHead>Max Uses</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{csvRows.slice(0, 10).map((row, i) => (
<TableRow key={i}>
<TableCell className="font-mono text-xs">{row.code}</TableCell>
<TableCell>{row.grantplan || row.plan || 'pro'}</TableCell>
<TableCell>{row.granttrialdays || row.trialdays || '0'}</TableCell>
<TableCell>{row.bonustokens || '0'}</TableCell>
<TableCell>{row.maxuses || '100'}</TableCell>
</TableRow>
))}
{csvRows.length > 10 && (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground text-xs"
>
...and {csvRows.length - 10} more rows
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)}
{bulkResult && (
<div className="space-y-2">
<div className="flex gap-4 text-sm">
<span className="text-emerald-600 font-medium">
{bulkResult.created} created
</span>
{bulkResult.failed > 0 && (
<span className="text-red-600 font-medium"> {bulkResult.failed} failed</span>
)}
<span className="text-muted-foreground">of {bulkResult.total} total</span>
</div>
{bulkResult.errors.length > 0 && (
<div className="rounded border border-red-200 bg-red-50 p-2 text-xs space-y-1">
{bulkResult.errors.map((err, i) => (
<div key={i}>
<span className="font-medium">Row {err.index + 1}:</span> {err.error}
</div>
))}
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowBulk(false)}>
{bulkResult ? 'Close' : 'Cancel'}
</Button>
{!bulkResult && (
<Button onClick={handleBulkUpload} disabled={csvRows.length === 0 || bulkUploading}>
{bulkUploading
? 'Uploading...'
: `Import ${csvRows.length} Code${csvRows.length !== 1 ? 's' : ''}`}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,44 @@
'use client';
import { SidebarNav } from '@/components/sidebar-nav';
import { AuthGuard } from '@/components/auth-guard';
import { ErrorBoundary } from '@/components/error-boundary';
import { useStripeConfig } from '@/lib/stripe-context';
import { FlaskConical, ShieldCheck } from 'lucide-react';
function StripeModeBanner() {
const { mode, isLive } = useStripeConfig();
if (mode === null) return null;
if (isLive) {
return (
<div className="bg-emerald-600 text-white text-xs font-semibold text-center py-1.5 px-4 flex items-center justify-center gap-2">
<ShieldCheck className="h-3.5 w-3.5" />
STRIPE LIVE MODE Real payments active
</div>
);
}
return (
<div className="bg-amber-400 text-amber-950 text-xs font-semibold text-center py-1.5 px-4 flex items-center justify-center gap-2">
<FlaskConical className="h-3.5 w-3.5" />
{mode === 'test'
? 'STRIPE TEST MODE — No real charges, use test cards'
: 'DEV MODE — Stripe not configured, payments disabled'}
</div>
);
}
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<AuthGuard>
<SidebarNav />
<main className="ml-64 min-h-screen bg-background max-md:ml-0">
<StripeModeBanner />
<div className="p-8 max-md:p-4">
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</main>
</AuthGuard>
);
}

View File

@ -0,0 +1,383 @@
'use client';
import { useState } from 'react';
import {
Key,
Search,
Plus,
Copy,
Check,
Loader2,
Monitor,
Smartphone,
X,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
interface LicenseDoc {
id: string;
productId: string;
key: string;
userId: string;
plan: 'free' | 'pro' | 'enterprise';
status: 'active' | 'revoked' | 'expired';
activatedAt: string | null;
expiresAt: string | null;
deviceIds: string[];
maxDevices: number;
createdAt: string;
updatedAt: string;
}
const statusColors: Record<string, string> = {
active: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400',
revoked: 'bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400',
expired: 'bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400',
};
const planColors: Record<string, string> = {
free: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
pro: 'bg-blue-50 text-blue-700 dark:bg-blue-950/30 dark:text-blue-400',
enterprise: 'bg-purple-50 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400',
};
export default function LicensesPage() {
const [userId, setUserId] = useState('');
const [licenses, setLicenses] = useState<LicenseDoc[]>([]);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Generate dialog state
const [genOpen, setGenOpen] = useState(false);
const [genUserId, setGenUserId] = useState('');
const [genPlan, setGenPlan] = useState<'free' | 'pro' | 'enterprise'>('pro');
const [genMaxDevices, setGenMaxDevices] = useState('3');
const [generating, setGenerating] = useState(false);
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
const handleSearch = async () => {
if (!userId.trim()) return;
setLoading(true);
setSearched(true);
try {
const res = await fetch(`/api/licenses?userId=${encodeURIComponent(userId.trim())}`);
if (res.ok) {
const data = await res.json();
setLicenses(data.licenses ?? []);
} else {
setLicenses([]);
}
} catch {
setLicenses([]);
} finally {
setLoading(false);
}
};
const handleGenerate = async () => {
if (!genUserId.trim()) return;
setGenerating(true);
setGeneratedKey(null);
try {
const res = await fetch('/api/licenses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: genUserId.trim(),
plan: genPlan,
maxDevices: Number(genMaxDevices) || 3,
}),
});
if (res.ok) {
const license = await res.json();
setGeneratedKey(license.key);
// Refresh search if same user
if (userId.trim() === genUserId.trim()) {
handleSearch();
}
}
} catch {
// ignore
} finally {
setGenerating(false);
}
};
const handleRevokeLicense = async (key: string) => {
if (!confirm('Revoke this license key? The user will lose access on all devices.')) return;
try {
const res = await fetch('/api/licenses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, action: 'revoke' }),
});
if (res.ok) {
setLicenses(prev =>
prev.map(l => (l.key === key ? { ...l, status: 'revoked' } : l))
);
}
} catch {
// ignore
}
};
const handleDeactivateDevice = async (key: string, deviceId: string) => {
try {
const res = await fetch('/api/licenses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'deactivate', key, deviceId }),
});
if (res.ok) handleSearch();
} catch {
// ignore
}
};
const copyToClipboard = async (key: string) => {
await navigator.clipboard.writeText(key);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Licenses</h1>
<p className="text-muted-foreground">
Manage license keys generate, view status, and deactivate devices
</p>
</div>
<Dialog open={genOpen} onOpenChange={setGenOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Generate License
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Generate New License Key</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label>User ID</Label>
<Input
placeholder="usr_..."
value={genUserId}
onChange={e => setGenUserId(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Plan</Label>
<Select value={genPlan} onValueChange={v => setGenPlan(v as typeof genPlan)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Max Devices</Label>
<Input
type="number"
min={1}
max={100}
value={genMaxDevices}
onChange={e => setGenMaxDevices(e.target.value)}
/>
</div>
{generatedKey && (
<div className="rounded-lg border border-emerald-500/50 bg-emerald-50 dark:bg-emerald-950/30 p-4">
<p className="text-xs text-emerald-700 dark:text-emerald-300 mb-1">
License key generated:
</p>
<div className="flex items-center gap-2">
<code className="text-sm font-mono font-bold text-emerald-800 dark:text-emerald-200">
{generatedKey}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(generatedKey)}
>
{copiedKey === generatedKey ? (
<Check className="h-4 w-4 text-emerald-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
<Button
className="w-full"
onClick={handleGenerate}
disabled={generating || !genUserId.trim()}
>
{generating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{/* Search */}
<Card>
<CardHeader>
<CardTitle className="text-base">Look Up Licenses</CardTitle>
<CardDescription>Search by user ID to view their license keys</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="Enter user ID (e.g. usr_abc123...)"
value={userId}
onChange={e => setUserId(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
className="max-w-md"
/>
<Button onClick={handleSearch} disabled={loading || !userId.trim()}>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
Search
</Button>
</div>
</CardContent>
</Card>
{/* Results */}
{searched && !loading && licenses.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<Key className="mx-auto h-12 w-12 mb-3 opacity-30" />
<p>No licenses found for this user</p>
</div>
)}
{licenses.map(lic => (
<Card key={lic.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Key className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<code className="text-sm font-mono font-bold">{lic.key}</code>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => copyToClipboard(lic.key)}
>
{copiedKey === lic.key ? (
<Check className="h-3 w-3 text-emerald-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
Created {new Date(lic.createdAt).toLocaleDateString()}
{lic.expiresAt && (
<> · Expires {new Date(lic.expiresAt).toLocaleDateString()}</>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className={planColors[lic.plan]}>
{lic.plan}
</Badge>
<Badge variant="secondary" className={statusColors[lic.status]}>
{lic.status}
</Badge>
{lic.status === 'active' && (
<Button
variant="outline"
size="sm"
className="h-7 text-destructive hover:text-destructive border-destructive/30"
onClick={() => handleRevokeLicense(lic.key)}
>
Revoke
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm mb-3">
<span className="text-muted-foreground">
Devices: {lic.deviceIds.length} / {lic.maxDevices}
</span>
{lic.activatedAt && (
<span className="text-muted-foreground">
First activated {new Date(lic.activatedAt).toLocaleDateString()}
</span>
)}
</div>
{lic.deviceIds.length > 0 ? (
<div className="space-y-2">
{lic.deviceIds.map(deviceId => (
<div
key={deviceId}
className="flex items-center justify-between rounded-md border px-3 py-2"
>
<div className="flex items-center gap-2">
{deviceId.length > 12 ? (
<Monitor className="h-4 w-4 text-muted-foreground" />
) : (
<Smartphone className="h-4 w-4 text-muted-foreground" />
)}
<code className="text-xs font-mono">{deviceId}</code>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-destructive hover:text-destructive"
onClick={() => handleDeactivateDevice(lic.key, deviceId)}
>
<X className="h-3 w-3 mr-1" />
Remove
</Button>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">
No devices activated yet
</p>
)}
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -0,0 +1,39 @@
import { Loader2 } from 'lucide-react';
export default function DashboardLoading() {
return (
<div className="space-y-6">
{/* Header skeleton */}
<div className="space-y-2">
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
<div className="h-4 w-72 animate-pulse rounded bg-muted" />
</div>
{/* KPI cards skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-xl border bg-card p-6">
<div className="flex items-center justify-between">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-8 w-8 animate-pulse rounded bg-muted" />
</div>
<div className="mt-3 h-7 w-20 animate-pulse rounded bg-muted" />
<div className="mt-1 h-3 w-32 animate-pulse rounded bg-muted" />
</div>
))}
</div>
{/* Chart skeleton */}
<div className="grid gap-4 lg:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="rounded-xl border bg-card p-6">
<div className="mb-4 h-5 w-36 animate-pulse rounded bg-muted" />
<div className="flex items-center justify-center h-64">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,227 @@
'use client';
import { useState } from 'react';
import {
Bell,
Search,
Loader2,
Monitor,
Smartphone,
Tablet,
Check,
X,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
interface DeviceDoc {
id: string;
productId: string;
userId: string;
deviceId: string;
platform: string;
pushToken?: string;
appVersion?: string;
osVersion?: string;
lastSeenAt: string;
createdAt: string;
}
interface NotificationPrefs {
pushEnabled: boolean;
emailEnabled: boolean;
categories: Record<string, boolean>;
}
const platformIcons: Record<string, typeof Monitor> = {
macos: Monitor,
windows: Monitor,
linux: Monitor,
ios: Smartphone,
android: Smartphone,
ipad: Tablet,
};
export default function NotificationsPage() {
const [userId, setUserId] = useState('');
const [devices, setDevices] = useState<DeviceDoc[]>([]);
const [prefs, setPrefs] = useState<NotificationPrefs | null>(null);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const handleSearch = async () => {
if (!userId.trim()) return;
setLoading(true);
setSearched(true);
try {
const res = await fetch(`/api/notifications?userId=${encodeURIComponent(userId.trim())}`);
if (res.ok) {
const data = await res.json();
setDevices(data.devices ?? []);
setPrefs(data.prefs ?? null);
} else {
setDevices([]);
setPrefs(null);
}
} catch {
setDevices([]);
setPrefs(null);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Notifications</h1>
<p className="text-muted-foreground">
View registered devices and notification preferences by user
</p>
</div>
{/* Search */}
<Card>
<CardHeader>
<CardTitle className="text-base">Look Up User</CardTitle>
<CardDescription>Search by user ID to view their devices and notification preferences</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
placeholder="Enter user ID (e.g. usr_abc123...)"
value={userId}
onChange={e => setUserId(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
className="max-w-md"
/>
<Button onClick={handleSearch} disabled={loading || !userId.trim()}>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
Search
</Button>
</div>
</CardContent>
</Card>
{searched && !loading && (
<>
{/* Notification Preferences */}
{prefs && (
<Card>
<CardHeader>
<CardTitle className="text-base">Notification Preferences</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Push:</span>
{prefs.pushEnabled ? (
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400">
<Check className="mr-1 h-3 w-3" /> Enabled
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<X className="mr-1 h-3 w-3" /> Disabled
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Email:</span>
{prefs.emailEnabled ? (
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400">
<Check className="mr-1 h-3 w-3" /> Enabled
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
<X className="mr-1 h-3 w-3" /> Disabled
</Badge>
)}
</div>
</div>
{Object.keys(prefs.categories).length > 0 && (
<div className="mt-3">
<p className="text-xs text-muted-foreground mb-2">Category Overrides:</p>
<div className="flex flex-wrap gap-2">
{Object.entries(prefs.categories).map(([cat, enabled]) => (
<Badge
key={cat}
variant="outline"
className={enabled ? 'border-emerald-300' : 'border-red-300 line-through'}
>
{cat}: {enabled ? 'on' : 'off'}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Devices */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Registered Devices ({devices.length})
</CardTitle>
</CardHeader>
<CardContent>
{devices.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Bell className="mx-auto h-10 w-10 mb-2 opacity-30" />
<p className="text-sm">No devices registered for this user</p>
</div>
) : (
<div className="space-y-3">
{devices.map(device => {
const Icon = platformIcons[device.platform?.toLowerCase()] ?? Monitor;
return (
<div
key={device.id}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<code className="text-xs font-mono">{device.deviceId}</code>
<Badge variant="outline" className="text-[10px]">
{device.platform}
</Badge>
</div>
<div className="flex items-center gap-3 mt-0.5 text-[11px] text-muted-foreground">
{device.appVersion && <span>v{device.appVersion}</span>}
{device.osVersion && <span>{device.osVersion}</span>}
<span>
Last seen {new Date(device.lastSeenAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
{device.pushToken ? (
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400 text-[10px]">
Push token
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 text-[10px]">
No push token
</Badge>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,237 @@
'use client';
import { useEffect, useState } from 'react';
import { Activity, CheckCircle, RefreshCw, ShieldAlert } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface ServiceCheck {
id: string;
name: string;
status: 'healthy' | 'degraded' | 'down' | 'maintenance';
latency: number;
version?: string;
message?: string;
lastChecked: string;
}
interface OpsStatus {
overall: 'healthy' | 'degraded' | 'critical';
timestamp: string;
services: ServiceCheck[];
}
export default function OpsPage() {
const [data, setData] = useState<OpsStatus | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [_error, setError] = useState<string | null>(null);
const [countdown, setCountdown] = useState(10);
const fetchStatus = async () => {
try {
setLoading(true);
const res = await fetch('/api/ops/status');
if (!res.ok) throw new Error('Failed to fetch status');
const json = await res.json();
setData(json);
setLastUpdated(new Date());
setError(null);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
setCountdown(10);
}
};
useEffect(() => {
fetchStatus();
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
fetchStatus(); // trigger refresh
return 10;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, []);
const getStatusColor = (status: string) => {
switch (status) {
case 'healthy':
return 'bg-green-500/10 text-green-500 hover:bg-green-500/20';
case 'degraded':
return 'bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20';
case 'down':
return 'bg-red-500/10 text-red-500 hover:bg-red-500/20';
default:
return 'bg-gray-500/10 text-gray-500';
}
};
const getLatencyColor = (ms: number) => {
if (ms < 100) return 'text-green-500';
if (ms < 500) return 'text-yellow-500';
return 'text-red-500';
};
return (
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Mission Control</h2>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm" onClick={() => fetchStatus()}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh ({countdown}s)
</Button>
</div>
</div>
{/* Global Status Banner */}
{data && (
<Card
className={`border-l-4 ${data.overall === 'healthy' ? 'border-l-green-500' : data.overall === 'degraded' ? 'border-l-yellow-500' : 'border-l-red-500'}`}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Global System Status</CardTitle>
{data.overall === 'healthy' ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<ShieldAlert className="h-4 w-4 text-red-500" />
)}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold capitalize">{data.overall}</div>
<p className="text-xs text-muted-foreground">
Last updated: {lastUpdated?.toLocaleTimeString()}
</p>
</CardContent>
</Card>
)}
{/* Service Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data?.services.map(svc => (
<Card key={svc.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{svc.name}</CardTitle>
<Activity
className={`h-4 w-4 ${svc.status === 'healthy' ? 'text-muted-foreground' : 'text-red-500 animate-pulse'}`}
/>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-2">
<Badge variant="outline" className={getStatusColor(svc.status)}>
{svc.status}
</Badge>
<div className={`text-sm font-mono font-bold ${getLatencyColor(svc.latency)}`}>
{svc.latency}ms
</div>
</div>
<div className="space-y-1 mt-3">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Uptime (30d)</span>
<span className="font-medium">99.9%</span>
</div>
<Progress value={svc.status === 'down' ? 0 : 99} className="h-1" />
</div>
{svc.message && (
<div className="mt-3 rounded bg-muted p-2 text-xs font-mono text-destructive">
{svc.message}
</div>
)}
<div className="mt-3 text-xs text-muted-foreground flex justify-between">
<span>v{svc.version || '?'}</span>
<span>{new Date(svc.lastChecked).toLocaleTimeString()}</span>
</div>
</CardContent>
</Card>
))}
{!data &&
loading &&
Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-4 w-[150px]" />
</CardHeader>
<CardContent>
<Skeleton className="h-[100px] w-full" />
</CardContent>
</Card>
))}
</div>
{/* Dependency Matrix (Static for now) */}
<Card>
<CardHeader>
<CardTitle>Infrastructure Dependencies</CardTitle>
<CardDescription>Status of external cloud providers and databases.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Dependency</TableHead>
<TableHead>Type</TableHead>
<TableHead>Region</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">Azure Cosmos DB</TableCell>
<TableCell>Database</TableCell>
<TableCell>West US 2</TableCell>
<TableCell>
<Badge variant="outline" className="bg-green-500/10 text-green-500">
Operational
</Badge>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Azure OpenAI</TableCell>
<TableCell>AI Model</TableCell>
<TableCell>Sweden Central</TableCell>
<TableCell>
<Badge variant="outline" className="bg-green-500/10 text-green-500">
Operational
</Badge>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Stripe API</TableCell>
<TableCell>Payments</TableCell>
<TableCell>Global</TableCell>
<TableCell>
<Badge variant="outline" className="bg-green-500/10 text-green-500">
Operational
</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,565 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import {
KeyRound,
RefreshCw,
Plus,
Eye,
EyeOff,
Copy,
Check,
Trash2,
RotateCcw,
Shield,
Clock,
AlertTriangle,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface SecretEntry {
name: string;
enabled: boolean;
createdOn: string | null;
updatedOn: string | null;
expiresOn: string | null;
contentType: string | null;
tags: Record<string, string>;
}
interface SecretDetail {
name: string;
value: string;
version: string;
enabled: boolean;
createdOn: string;
updatedOn: string;
expiresOn: string | null;
}
export default function SecretsPage() {
const [secrets, setSecrets] = useState<SecretEntry[]>([]);
const [vaultUrl, setVaultUrl] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// View secret
const [viewingSecret, setViewingSecret] = useState<SecretDetail | null>(null);
const [viewLoading, setViewLoading] = useState(false);
const [showValue, setShowValue] = useState(false);
const [copied, setCopied] = useState(false);
// Add/Edit dialog
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'add' | 'edit' | 'rotate'>('add');
const [formName, setFormName] = useState('');
const [formValue, setFormValue] = useState('');
const [saving, setSaving] = useState(false);
// Delete confirmation
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchSecrets = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch('/api/ops/secrets');
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || `HTTP ${res.status}`);
}
const json = await res.json();
setSecrets(json.secrets);
setVaultUrl(json.vaultUrl);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSecrets();
}, [fetchSecrets]);
const handleViewSecret = async (name: string) => {
try {
setViewLoading(true);
setShowValue(false);
setCopied(false);
const res = await fetch(`/api/ops/secrets/${encodeURIComponent(name)}`);
if (!res.ok) throw new Error('Failed to read secret');
const json = await res.json();
setViewingSecret(json);
} catch {
setViewingSecret(null);
} finally {
setViewLoading(false);
}
};
const handleCopy = async () => {
if (viewingSecret?.value) {
await navigator.clipboard.writeText(viewingSecret.value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const openAddDialog = () => {
setDialogMode('add');
setFormName('');
setFormValue('');
setDialogOpen(true);
};
const openEditDialog = (name: string, currentValue?: string) => {
setDialogMode('edit');
setFormName(name);
setFormValue(currentValue || '');
setDialogOpen(true);
};
const openRotateDialog = (name: string) => {
setDialogMode('rotate');
setFormName(name);
// Generate a random 64-char hex string for rotation
const array = new Uint8Array(32);
crypto.getRandomValues(array);
setFormValue(Array.from(array, b => b.toString(16).padStart(2, '0')).join(''));
setDialogOpen(true);
};
const handleSave = async () => {
if (!formName || !formValue) return;
try {
setSaving(true);
const res = await fetch('/api/ops/secrets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: formName, value: formValue }),
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || 'Failed to save');
}
setDialogOpen(false);
setFormName('');
setFormValue('');
await fetchSecrets();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
setDeleting(true);
const res = await fetch(`/api/ops/secrets/${encodeURIComponent(deleteTarget)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete');
setDeleteTarget(null);
await fetchSecrets();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setDeleting(false);
}
};
const isExpiringSoon = (expiresOn: string | null) => {
if (!expiresOn) return false;
const diff = new Date(expiresOn).getTime() - Date.now();
return diff > 0 && diff < 30 * 24 * 60 * 60 * 1000; // 30 days
};
const isExpired = (expiresOn: string | null) => {
if (!expiresOn) return false;
return new Date(expiresOn).getTime() < Date.now();
};
const formatDate = (iso: string | null) => {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Secrets Manager</h2>
{vaultUrl && (
<p className="text-sm text-muted-foreground mt-1 font-mono">{vaultUrl}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchSecrets} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button size="sm" onClick={openAddDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Secret
</Button>
</div>
</div>
{/* Error Banner */}
{error && (
<Card className="border-red-500/50 bg-red-500/5">
<CardContent className="p-4 flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-red-500 shrink-0" />
<div>
<p className="text-sm font-medium text-red-500">Error</p>
<p className="text-xs text-muted-foreground">{error}</p>
</div>
</CardContent>
</Card>
)}
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Secrets</CardTitle>
<KeyRound className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{secrets.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active</CardTitle>
<Shield className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-500">
{secrets.filter(s => s.enabled && !isExpired(s.expiresOn)).length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Expiring Soon</CardTitle>
<Clock className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-500">
{secrets.filter(s => isExpiringSoon(s.expiresOn)).length}
</div>
</CardContent>
</Card>
</div>
{/* Secrets Table */}
<Card>
<CardHeader>
<CardTitle>Vault Secrets</CardTitle>
<CardDescription>
Manage secrets stored in Azure Key Vault. Click a row to view its value.
</CardDescription>
</CardHeader>
<CardContent>
{loading && secrets.length === 0 ? (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Updated</TableHead>
<TableHead>Expires</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{secrets.map(secret => (
<TableRow key={secret.name} className="cursor-pointer hover:bg-muted/50">
<TableCell
className="font-mono text-sm font-medium"
onClick={() => handleViewSecret(secret.name)}
>
{secret.name}
</TableCell>
<TableCell>
{isExpired(secret.expiresOn) ? (
<Badge variant="destructive">Expired</Badge>
) : !secret.enabled ? (
<Badge variant="secondary">Disabled</Badge>
) : isExpiringSoon(secret.expiresOn) ? (
<Badge className="bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20">
Expiring
</Badge>
) : (
<Badge className="bg-green-500/10 text-green-500 hover:bg-green-500/20">
Active
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(secret.updatedOn)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{secret.expiresOn ? formatDate(secret.expiresOn) : 'Never'}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="View"
onClick={() => handleViewSecret(secret.name)}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Edit"
onClick={() => openEditDialog(secret.name)}
>
<KeyRound className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Rotate"
onClick={() => openRotateDialog(secret.name)}
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600"
title="Delete"
onClick={() => setDeleteTarget(secret.name)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{secrets.length === 0 && !loading && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
No secrets found in vault
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* View Secret Dialog */}
<Dialog open={viewingSecret !== null} onOpenChange={() => setViewingSecret(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="font-mono">{viewingSecret?.name}</DialogTitle>
<DialogDescription>Secret value from Azure Key Vault</DialogDescription>
</DialogHeader>
{viewLoading ? (
<Skeleton className="h-20 w-full" />
) : viewingSecret ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>Value</Label>
<div className="relative">
<Input
type={showValue ? 'text' : 'password'}
value={viewingSecret.value}
readOnly
className="pr-20 font-mono text-sm"
/>
<div className="absolute right-1 top-1 flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setShowValue(!showValue)}
>
{showValue ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy}>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Version</p>
<p className="font-mono text-xs truncate">{viewingSecret.version}</p>
</div>
<div>
<p className="text-muted-foreground">Created</p>
<p>{formatDate(viewingSecret.createdOn)}</p>
</div>
<div>
<p className="text-muted-foreground">Updated</p>
<p>{formatDate(viewingSecret.updatedOn)}</p>
</div>
<div>
<p className="text-muted-foreground">Expires</p>
<p>{viewingSecret.expiresOn ? formatDate(viewingSecret.expiresOn) : 'Never'}</p>
</div>
</div>
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setViewingSecret(null)}>
Close
</Button>
<Button
variant="outline"
onClick={() => {
if (viewingSecret) {
openEditDialog(viewingSecret.name, viewingSecret.value);
setViewingSecret(null);
}
}}
>
Edit Value
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add / Edit / Rotate Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{dialogMode === 'add'
? 'Add Secret'
: dialogMode === 'edit'
? 'Edit Secret'
: 'Rotate Secret'}
</DialogTitle>
<DialogDescription>
{dialogMode === 'add'
? 'Create a new secret in Azure Key Vault'
: dialogMode === 'edit'
? `Update the value for ${formName}`
: `Generate a new value for ${formName}`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Secret Name</Label>
<Input
value={formName}
onChange={e => setFormName(e.target.value)}
placeholder="e.g. my-new-secret"
disabled={dialogMode !== 'add'}
className="font-mono"
/>
</div>
<div className="space-y-2">
<Label>Secret Value</Label>
<Input
type="password"
value={formValue}
onChange={e => setFormValue(e.target.value)}
placeholder="Enter secret value"
className="font-mono"
/>
{dialogMode === 'rotate' && (
<p className="text-xs text-muted-foreground">
A random 64-character hex value has been generated. Edit or accept.
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !formName || !formValue}>
{saving ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : dialogMode === 'rotate' ? (
<RotateCcw className="mr-2 h-4 w-4" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
)}
{dialogMode === 'add' ? 'Create' : dialogMode === 'edit' ? 'Update' : 'Rotate'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteTarget !== null} onOpenChange={() => setDeleteTarget(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-red-500">Delete Secret</DialogTitle>
<DialogDescription>
Are you sure you want to delete{' '}
<span className="font-mono font-bold">{deleteTarget}</span>? This will soft-delete
the secret in Azure Key Vault. It can be recovered within the retention period.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
{deleting && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,675 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import {
Plus,
RefreshCw,
Trash2,
Pencil,
ToggleLeft,
ToggleRight,
Shield,
Target,
Clock,
Percent,
X,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
// ─── Types ──────────────────────────────────────────────────────
interface TelemetryPolicy {
id: string;
productId: string;
name: string;
description?: string;
enabled: boolean;
priority: number;
eventTypes: string[];
modules: string[];
samplingRate: number;
targeting: {
platforms?: string[];
channels?: string[];
osFamilies?: string[];
appVersions?: string[];
releaseChannels?: string[];
percentage?: number;
};
startsAt?: string;
expiresAt?: string;
createdAt: string;
updatedAt: string;
createdBy?: string;
}
const EVENT_TYPES = ['debug', 'info', 'warn', 'error', 'fatal'];
const PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'web'];
const CHANNELS = ['keyboard_extension', 'mobile_app', 'desktop_app', 'web_app'];
const RELEASE_CHANNELS = ['alpha', 'beta', 'stable'];
// ─── Component ──────────────────────────────────────────────────
export default function TelemetryPoliciesPage() {
const [policies, setPolicies] = useState<TelemetryPolicy[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingPolicy, setEditingPolicy] = useState<TelemetryPolicy | null>(null);
const [saving, setSaving] = useState(false);
// Form state
const [formName, setFormName] = useState('');
const [formDescription, setFormDescription] = useState('');
const [formEnabled, setFormEnabled] = useState(true);
const [formPriority, setFormPriority] = useState(100);
const [formEventTypes, setFormEventTypes] = useState<string[]>(['warn', 'error', 'fatal']);
const [formModules, setFormModules] = useState('');
const [formSamplingRate, setFormSamplingRate] = useState(1.0);
const [formPlatforms, setFormPlatforms] = useState<string[]>([]);
const [formChannels, setFormChannels] = useState<string[]>([]);
const [formReleaseChannels, setFormReleaseChannels] = useState<string[]>([]);
const [formPercentage, setFormPercentage] = useState(100);
const [formStartsAt, setFormStartsAt] = useState('');
const [formExpiresAt, setFormExpiresAt] = useState('');
const [preview, setPreview] = useState<{ matchedClients: number; totalClients: number; sampleSize: number } | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const fetchPolicies = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/telemetry/policies');
if (res.ok) {
const data = await res.json();
setPolicies(data.policies ?? []);
}
} catch {
// best effort
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPolicies();
}, [fetchPolicies]);
const resetForm = () => {
setFormName('');
setFormDescription('');
setFormEnabled(true);
setFormPriority(100);
setFormEventTypes(['warn', 'error', 'fatal']);
setFormModules('');
setFormSamplingRate(1.0);
setFormPlatforms([]);
setFormChannels([]);
setFormReleaseChannels([]);
setFormPercentage(100);
setFormStartsAt('');
setFormExpiresAt('');
setEditingPolicy(null);
};
const openCreateForm = () => {
resetForm();
setShowForm(true);
};
const openEditForm = (policy: TelemetryPolicy) => {
setEditingPolicy(policy);
setFormName(policy.name);
setFormDescription(policy.description ?? '');
setFormEnabled(policy.enabled);
setFormPriority(policy.priority);
setFormEventTypes(policy.eventTypes);
setFormModules(policy.modules.join(', '));
setFormSamplingRate(policy.samplingRate);
setFormPlatforms(policy.targeting.platforms ?? []);
setFormChannels(policy.targeting.channels ?? []);
setFormReleaseChannels(policy.targeting.releaseChannels ?? []);
setFormPercentage(policy.targeting.percentage ?? 100);
setFormStartsAt(policy.startsAt ?? '');
setFormExpiresAt(policy.expiresAt ?? '');
setShowForm(true);
};
const handleSave = async () => {
setSaving(true);
const body = {
name: formName,
description: formDescription || undefined,
enabled: formEnabled,
priority: formPriority,
eventTypes: formEventTypes,
modules: formModules ? formModules.split(',').map(m => m.trim()).filter(Boolean) : [],
samplingRate: formSamplingRate,
targeting: {
platforms: formPlatforms.length > 0 ? formPlatforms : undefined,
channels: formChannels.length > 0 ? formChannels : undefined,
releaseChannels: formReleaseChannels.length > 0 ? formReleaseChannels : undefined,
percentage: formPercentage < 100 ? formPercentage : undefined,
},
startsAt: formStartsAt || undefined,
expiresAt: formExpiresAt || undefined,
};
try {
if (editingPolicy) {
await fetch(`/api/telemetry/policies/${editingPolicy.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} else {
await fetch('/api/telemetry/policies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
setShowForm(false);
resetForm();
fetchPolicies();
} catch {
// best effort
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this policy?')) return;
try {
await fetch(`/api/telemetry/policies/${id}`, { method: 'DELETE' });
fetchPolicies();
} catch {
// best effort
}
};
const handleToggleEnabled = async (policy: TelemetryPolicy) => {
try {
await fetch(`/api/telemetry/policies/${policy.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !policy.enabled }),
});
fetchPolicies();
} catch {
// best effort
}
};
const toggleArrayItem = (
arr: string[],
item: string,
setter: (v: string[]) => void
) => {
setter(arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item]);
};
// ─── Render ───────────────────────────────────────────────────
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Telemetry Policies</h2>
<p className="text-muted-foreground">
Control what telemetry data is collected from clients
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={fetchPolicies}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Button size="sm" onClick={openCreateForm}>
<Plus className="mr-2 h-4 w-4" />
Create Policy
</Button>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardDescription>Total Policies</CardDescription>
<CardTitle className="text-3xl">{policies.length}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Active</CardDescription>
<CardTitle className="text-3xl text-green-500">
{policies.filter(p => p.enabled).length}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Disabled</CardDescription>
<CardTitle className="text-3xl text-muted-foreground">
{policies.filter(p => !p.enabled).length}
</CardTitle>
</CardHeader>
</Card>
</div>
{/* Create/Edit Form */}
{showForm && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{editingPolicy ? 'Edit Policy' : 'Create Policy'}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowForm(false);
resetForm();
}}
>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Basic info */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Name *</label>
<Input
value={formName}
onChange={e => setFormName(e.target.value)}
placeholder="e.g. Collect errors from beta"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<Input
value={formDescription}
onChange={e => setFormDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
</div>
{/* Priority + Sampling */}
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-1">
<Shield className="h-3.5 w-3.5" /> Priority
</label>
<Input
type="number"
value={formPriority}
onChange={e => setFormPriority(Number(e.target.value))}
min={0}
max={999}
/>
<p className="text-xs text-muted-foreground">Higher = more important</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-1">
<Percent className="h-3.5 w-3.5" /> Sampling Rate
</label>
<Input
type="number"
value={formSamplingRate}
onChange={e => setFormSamplingRate(Number(e.target.value))}
min={0}
max={1}
step={0.1}
/>
<p className="text-xs text-muted-foreground">0 = none, 1 = all</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Enabled</label>
<Button
variant="outline"
className="w-full justify-start"
onClick={() => setFormEnabled(!formEnabled)}
>
{formEnabled ? (
<ToggleRight className="mr-2 h-4 w-4 text-green-500" />
) : (
<ToggleLeft className="mr-2 h-4 w-4 text-muted-foreground" />
)}
{formEnabled ? 'Enabled' : 'Disabled'}
</Button>
</div>
</div>
{/* Event Types */}
<div className="space-y-2">
<label className="text-sm font-medium">Event Types</label>
<div className="flex flex-wrap gap-2">
{EVENT_TYPES.map(et => (
<Badge
key={et}
variant={formEventTypes.includes(et) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => toggleArrayItem(formEventTypes, et, setFormEventTypes)}
>
{et}
</Badge>
))}
</div>
</div>
{/* Modules */}
<div className="space-y-2">
<label className="text-sm font-medium">Modules (comma-separated)</label>
<Input
value={formModules}
onChange={e => setFormModules(e.target.value)}
placeholder="e.g. dictation, keyboard, app_lifecycle (empty = all)"
/>
</div>
{/* Targeting */}
<div className="space-y-4">
<h4 className="text-sm font-medium flex items-center gap-1">
<Target className="h-3.5 w-3.5" /> Targeting Rules
</h4>
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Platforms</label>
<div className="flex flex-wrap gap-2">
{PLATFORMS.map(p => (
<Badge
key={p}
variant={formPlatforms.includes(p) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => toggleArrayItem(formPlatforms, p, setFormPlatforms)}
>
{p}
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground">Empty = all platforms</p>
</div>
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Channels</label>
<div className="flex flex-wrap gap-2">
{CHANNELS.map(c => (
<Badge
key={c}
variant={formChannels.includes(c) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => toggleArrayItem(formChannels, c, setFormChannels)}
>
{c}
</Badge>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-xs text-muted-foreground">Release Channels</label>
<div className="flex flex-wrap gap-2">
{RELEASE_CHANNELS.map(rc => (
<Badge
key={rc}
variant={formReleaseChannels.includes(rc) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() =>
toggleArrayItem(formReleaseChannels, rc, setFormReleaseChannels)
}
>
{rc}
</Badge>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-xs text-muted-foreground">
Percentage Rollout: {formPercentage}%
</label>
<input
type="range"
min={0}
max={100}
value={formPercentage}
onChange={e => setFormPercentage(Number(e.target.value))}
className="w-full"
/>
</div>
{/* Live Preview */}
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
disabled={previewLoading}
onClick={async () => {
setPreviewLoading(true);
setPreview(null);
try {
const res = await fetch('/api/telemetry/policies/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targeting: {
platforms: formPlatforms.length > 0 ? formPlatforms : undefined,
channels: formChannels.length > 0 ? formChannels : undefined,
releaseChannels: formReleaseChannels.length > 0 ? formReleaseChannels : undefined,
},
}),
});
if (res.ok) setPreview(await res.json());
} catch { /* best effort */ } finally {
setPreviewLoading(false);
}
}}
>
<Target className="mr-1 h-3.5 w-3.5" />
{previewLoading ? 'Checking...' : 'Preview Match'}
</Button>
{preview && (
<span className="text-sm">
<strong className="text-primary">{preview.matchedClients}</strong>
<span className="text-muted-foreground">
{' '}/ {preview.totalClients} clients would match
</span>
<span className="text-xs text-muted-foreground ml-1">
(from {preview.sampleSize} recent events)
</span>
</span>
)}
</div>
</div>
{/* Scheduling */}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-1">
<Clock className="h-3.5 w-3.5" /> Starts At
</label>
<Input
type="datetime-local"
value={formStartsAt ? formStartsAt.slice(0, 16) : ''}
onChange={e =>
setFormStartsAt(e.target.value ? new Date(e.target.value).toISOString() : '')
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-1">
<Clock className="h-3.5 w-3.5" /> Expires At
</label>
<Input
type="datetime-local"
value={formExpiresAt ? formExpiresAt.slice(0, 16) : ''}
onChange={e =>
setFormExpiresAt(e.target.value ? new Date(e.target.value).toISOString() : '')
}
/>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setShowForm(false);
resetForm();
}}
>
Cancel
</Button>
<Button onClick={handleSave} disabled={!formName || saving}>
{saving ? 'Saving...' : editingPolicy ? 'Update Policy' : 'Create Policy'}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Policies Table */}
<Card>
<CardContent className="p-0">
{loading ? (
<div className="space-y-3 p-6">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : policies.length === 0 ? (
<div className="p-12 text-center text-muted-foreground">
No policies yet. Create one to control telemetry collection.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Event Types</TableHead>
<TableHead>Sampling</TableHead>
<TableHead>Targeting</TableHead>
<TableHead>Schedule</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{policies
.sort((a, b) => b.priority - a.priority)
.map(policy => (
<TableRow key={policy.id}>
<TableCell>
<div>
<p className="font-medium">{policy.name}</p>
{policy.description && (
<p className="text-xs text-muted-foreground">
{policy.description}
</p>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={policy.enabled ? 'default' : 'secondary'}>
{policy.enabled ? 'Active' : 'Disabled'}
</Badge>
</TableCell>
<TableCell className="font-mono">{policy.priority}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{policy.eventTypes.map(et => (
<Badge key={et} variant="outline" className="text-xs">
{et}
</Badge>
))}
</div>
</TableCell>
<TableCell className="font-mono">
{(policy.samplingRate * 100).toFixed(0)}%
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1 text-xs">
{policy.targeting.platforms?.map(p => (
<Badge key={p} variant="outline">
{p}
</Badge>
))}
{policy.targeting.percentage !== undefined &&
policy.targeting.percentage < 100 && (
<Badge variant="outline">
{policy.targeting.percentage}% rollout
</Badge>
)}
{!policy.targeting.platforms?.length &&
policy.targeting.percentage === undefined && (
<span className="text-muted-foreground">All</span>
)}
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{policy.startsAt
? new Date(policy.startsAt).toLocaleDateString()
: '—'}
{' → '}
{policy.expiresAt
? new Date(policy.expiresAt).toLocaleDateString()
: '∞'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleEnabled(policy)}
title={policy.enabled ? 'Disable' : 'Enable'}
>
{policy.enabled ? (
<ToggleRight className="h-4 w-4 text-green-500" />
) : (
<ToggleLeft className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openEditForm(policy)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(policy.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,509 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Users,
DollarSign,
Zap,
TrendingUp,
UserPlus,
Activity,
ArrowUpRight,
ArrowDownRight,
RefreshCw,
Cpu,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
mockSummaryStats,
formatNumber,
formatCurrency,
type DailyMetric,
type User,
type ModelUsage,
} from '@/lib/mock-data';
import {
apiGetDashboardStats,
apiGetUsage,
apiListUsers,
apiGetRevenueAnalytics,
type DashboardStats,
type ApiUsageRecord,
type RevenueAnalytics,
} from '@/lib/api';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
} from 'recharts';
function buildKpiCards(stats: typeof mockSummaryStats, revenue?: RevenueAnalytics | null) {
const fmt = (n: number) => (n >= 0 ? `+${n}%` : `${n}%`);
const mrrChange = revenue?.mrrChange ?? 0;
const churnRate = revenue?.churnRate ?? stats.churnRate;
return [
{
title: 'Total Users',
value: stats.totalUsers.toString(),
change: revenue
? fmt(
Math.round(
((revenue.newSubscriptions - revenue.canceledSubscriptions) /
(stats.totalUsers || 1)) *
100 *
10
) / 10
)
: '—',
trend: (revenue
? revenue.newSubscriptions >= revenue.canceledSubscriptions
? 'up'
: 'down'
: 'up') as 'up' | 'down',
icon: Users,
subtitle: `${stats.activeUsers} active`,
},
{
title: 'Monthly Revenue',
value: formatCurrency(revenue?.mrr ?? stats.monthlyRecurring),
change: revenue ? fmt(mrrChange) : '—',
trend: (mrrChange >= 0 ? 'up' : 'down') as 'up' | 'down',
icon: DollarSign,
subtitle: `${formatCurrency(revenue?.totalRevenue ?? stats.totalRevenue)} total`,
},
{
title: 'Tokens This Month',
value: formatNumber(stats.totalTokensThisMonth),
change: '—',
trend: 'up' as const,
icon: Zap,
subtitle: `${formatNumber(stats.avgTokensPerUser)} avg/user`,
},
{
title: 'New Users',
value: (revenue?.newSubscriptions ?? stats.newUsersThisMonth).toString(),
change: '—',
trend: 'up' as const,
icon: UserPlus,
subtitle: `${stats.conversionRate}% conversion`,
},
{
title: 'Requests This Month',
value: formatNumber(stats.totalRequestsThisMonth),
change: '—',
trend: 'up' as const,
icon: Activity,
subtitle: 'API calls',
},
{
title: 'Churn Rate',
value: `${churnRate}%`,
change: revenue ? `${revenue.churnCount} canceled` : '—',
trend: (churnRate <= 5 ? 'down' : 'up') as 'up' | 'down',
icon: TrendingUp,
subtitle: 'Month over month',
},
];
}
function mergeApiStats(
base: typeof mockSummaryStats,
api: DashboardStats
): typeof mockSummaryStats {
const totalUsers = api.users.total || base.totalUsers;
const totalTokens = api.usage.totalWords || base.totalTokensThisMonth;
return {
...base,
totalUsers,
activeUsers: totalUsers,
totalTokensThisMonth: totalTokens,
totalRequestsThisMonth: api.usage.totalDictations || base.totalRequestsThisMonth,
avgTokensPerUser: totalUsers > 0 ? Math.round(totalTokens / totalUsers) : 0,
};
}
function usageRecordsToDailyMetrics(records: ApiUsageRecord[]): DailyMetric[] {
const byDate = new Map<string, DailyMetric>();
for (const r of records) {
const existing = byDate.get(r.date);
if (existing) {
existing.totalTokens += r.tokensUsed;
existing.totalRequests += r.dictations;
existing.revenue += r.costUsd;
existing.activeUsers += 1;
} else {
byDate.set(r.date, {
date: r.date,
activeUsers: 1,
totalRequests: r.dictations,
totalTokens: r.tokensUsed,
revenue: r.costUsd,
});
}
}
return Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date));
}
function buildModelUsage(records: ApiUsageRecord[]): ModelUsage[] {
// Aggregate per-model from real usage records (each record now has optional model field)
const byModel: Record<string, { tokens: number; requests: number; cost: number }> = {};
for (const r of records) {
const model = (r as unknown as { model?: string }).model || 'gpt-4o-mini';
if (!byModel[model]) byModel[model] = { tokens: 0, requests: 0, cost: 0 };
byModel[model].tokens += r.tokensUsed;
byModel[model].requests += r.dictations;
byModel[model].cost += r.costUsd;
}
const totalTokens = Object.values(byModel).reduce((s, m) => s + m.tokens, 0);
if (totalTokens === 0) return [];
return Object.entries(byModel).map(([model, stats]) => ({
model,
tokens: stats.tokens,
requests: stats.requests,
cost: stats.cost,
percentage: Math.round((stats.tokens / totalTokens) * 100),
}));
}
function KpiSkeleton() {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded" />
</CardHeader>
<CardContent>
<Skeleton className="h-7 w-20 mb-2" />
<Skeleton className="h-4 w-32" />
</CardContent>
</Card>
);
}
function ChartSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-[280px] w-full" />
</CardContent>
</Card>
);
}
export default function DashboardPage() {
const [stats, setStats] = useState({
...mockSummaryStats,
totalUsers: 0,
activeUsers: 0,
totalRevenue: 0,
monthlyRecurring: 0,
totalTokensThisMonth: 0,
totalRequestsThisMonth: 0,
avgTokensPerUser: 0,
newUsersThisMonth: 0,
conversionRate: 0,
churnRate: 0,
});
const [dailyMetrics, setDailyMetrics] = useState<DailyMetric[]>([]);
const [recentUsers, setRecentUsers] = useState<User[]>([]);
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
const [revenue, setRevenue] = useState<RevenueAnalytics | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const fetchData = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
try {
const [statsRes, usageRes, usersRes, revenueRes] = await Promise.allSettled([
apiGetDashboardStats(),
apiGetUsage(30),
apiListUsers(10),
apiGetRevenueAnalytics(6),
]);
if (statsRes.status === 'fulfilled' && statsRes.value.data) {
setStats(prev => mergeApiStats(prev, statsRes.value.data!));
}
if (usageRes.status === 'fulfilled' && usageRes.value.data?.records?.length) {
const metrics = usageRecordsToDailyMetrics(usageRes.value.data.records);
setDailyMetrics(metrics);
setModelUsage(buildModelUsage(usageRes.value.data.records));
}
if (usersRes.status === 'fulfilled' && usersRes.value.data?.users?.length) {
setRecentUsers(
usersRes.value.data.users
.sort((a, b) => b.lastActive.localeCompare(a.lastActive))
.slice(0, 6)
.map(u => ({
id: u.id,
name: u.name,
email: u.email,
plan: u.plan as User['plan'],
status: u.status as User['status'],
createdAt: u.createdAt,
lastActive: u.lastActive,
totalTokensUsed: u.totalTokensUsed,
totalRequests: u.totalRequests,
monthlySpend: u.monthlySpend,
}))
);
}
if (revenueRes.status === 'fulfilled' && revenueRes.value.data) {
setRevenue(revenueRes.value.data);
}
setLastUpdated(new Date());
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(() => fetchData(), 60000);
return () => clearInterval(interval);
}, [fetchData]);
const kpiCards = buildKpiCards(stats, revenue);
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Platform overview and key metrics
{lastUpdated && (
<span className="ml-2 text-xs">
&middot; Updated {lastUpdated.toLocaleTimeString()}
</span>
)}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => fetchData(true)} disabled={refreshing}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* KPI Cards */}
{loading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<KpiSkeleton key={i} />
))}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{kpiCards.map(card => (
<Card key={card.title} className="transition-shadow hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{card.title}
</CardTitle>
<card.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value}</div>
<div className="flex items-center gap-2 mt-1">
<Badge
variant={card.title === 'Churn Rate' ? 'default' : 'secondary'}
className={`text-xs ${
card.trend === 'up' && card.title !== 'Churn Rate'
? 'text-emerald-600 bg-emerald-50'
: card.title === 'Churn Rate'
? 'text-emerald-600 bg-emerald-50'
: 'text-red-600 bg-red-50'
}`}
>
{card.trend === 'up' && card.title !== 'Churn Rate' ? (
<ArrowUpRight className="h-3 w-3 mr-0.5" />
) : (
<ArrowDownRight className="h-3 w-3 mr-0.5" />
)}
{card.change}
</Badge>
<span className="text-xs text-muted-foreground">{card.subtitle}</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Charts Row */}
{loading ? (
<div className="grid gap-6 lg:grid-cols-2">
<ChartSkeleton />
<ChartSkeleton />
</div>
) : (
<div className="grid gap-6 lg:grid-cols-2">
{/* Active Users Chart */}
<Card>
<CardHeader>
<CardTitle className="text-base">Daily Active Users (30 days)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={dailyMetrics}>
<defs>
<linearGradient id="colorUsers" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="date" tickFormatter={v => v.slice(5)} className="text-xs" />
<YAxis className="text-xs" />
<Tooltip
contentStyle={{
borderRadius: '8px',
border: '1px solid hsl(var(--border))',
}}
/>
<Area
type="monotone"
dataKey="activeUsers"
stroke="hsl(221, 83%, 53%)"
fill="url(#colorUsers)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Revenue Chart */}
<Card>
<CardHeader>
<CardTitle className="text-base">Daily Revenue (30 days)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={dailyMetrics}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="date" tickFormatter={v => v.slice(5)} className="text-xs" />
<YAxis className="text-xs" tickFormatter={v => `$${v}`} />
<Tooltip
formatter={value => formatCurrency(Number(value))}
contentStyle={{
borderRadius: '8px',
border: '1px solid hsl(var(--border))',
}}
/>
<Bar dataKey="revenue" fill="hsl(142, 71%, 45%)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
)}
{/* Bottom Row: Model Usage + Recent Users */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Model Usage */}
<Card>
<CardHeader>
<CardTitle className="text-base">Model Usage Breakdown</CardTitle>
</CardHeader>
<CardContent>
{modelUsage.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Cpu className="h-10 w-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">No model usage data yet</p>
<p className="text-xs text-muted-foreground mt-1">
Usage will appear once users start dictating
</p>
</div>
) : (
<div className="space-y-4">
{modelUsage.map(m => (
<div key={m.model} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{m.model}</span>
<span className="text-muted-foreground">
{formatNumber(m.tokens)} tokens &middot; {formatCurrency(m.cost)}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${m.percentage}%` }}
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Recent Users */}
<Card>
<CardHeader>
<CardTitle className="text-base">Recent Users</CardTitle>
</CardHeader>
<CardContent>
{recentUsers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-10 w-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">No users yet</p>
</div>
) : (
<div className="space-y-3">
{recentUsers.map(user => (
<div
key={user.id}
className="flex items-center justify-between rounded-lg p-2 -mx-2 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{user.name
.split(' ')
.map(n => n[0])
.join('')}
</div>
<div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
<Badge
variant="secondary"
className={
user.plan === 'enterprise'
? 'bg-violet-50 text-violet-700'
: user.plan === 'pro'
? 'bg-blue-50 text-blue-700'
: ''
}
>
{user.plan}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,567 @@
'use client';
import { useState, useEffect } from 'react';
import {
Package,
Plus,
Loader2,
Check,
X,
Globe,
Edit2,
Rocket,
CheckCircle2,
Trash2,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
interface ProductDoc {
id: string;
productId: string;
displayName: string;
licensePrefix: string;
packageName: string;
defaultPlan: 'free' | 'pro';
trialDays: number;
deviceLimits: { free: number; pro: number; enterprise: number };
websiteUrl: string;
status: 'active' | 'disabled';
createdAt: string;
updatedAt: string;
}
export default function ProductsPage() {
const [products, setProducts] = useState<ProductDoc[]>([]);
const [loading, setLoading] = useState(true);
// Create dialog
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({
productId: '',
displayName: '',
licensePrefix: '',
packageName: '',
defaultPlan: 'free' as 'free' | 'pro',
trialDays: '14',
websiteUrl: '',
deviceLimitFree: '1',
deviceLimitPro: '3',
deviceLimitEnterprise: '10',
});
// Edit dialog
const [editProduct, setEditProduct] = useState<ProductDoc | null>(null);
const [editForm, setEditForm] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
// Onboarding state
const [onboarding, setOnboarding] = useState<string | null>(null);
const [onboardResult, setOnboardResult] = useState<{
productId: string;
plans: number;
flags: number;
} | null>(null);
const loadProducts = async () => {
try {
const res = await fetch('/api/products');
if (res.ok) {
const data = await res.json();
setProducts(data.products ?? []);
}
} catch {
// ignore
} finally {
setLoading(false);
}
};
useEffect(() => {
loadProducts();
}, []);
const handleCreate = async () => {
setCreating(true);
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productId: form.productId,
displayName: form.displayName,
licensePrefix: form.licensePrefix,
packageName: form.packageName,
defaultPlan: form.defaultPlan,
trialDays: Number(form.trialDays) || 14,
websiteUrl: form.websiteUrl || '',
deviceLimits: {
free: Number(form.deviceLimitFree) || 1,
pro: Number(form.deviceLimitPro) || 3,
enterprise: Number(form.deviceLimitEnterprise) || 10,
},
}),
});
if (res.ok) {
const product = await res.json();
setCreateOpen(false);
setForm({
productId: '', displayName: '', licensePrefix: '', packageName: '',
defaultPlan: 'free', trialDays: '14', websiteUrl: '',
deviceLimitFree: '1', deviceLimitPro: '3', deviceLimitEnterprise: '10',
});
// Auto-onboard: seed plans + kill_switch flag
await handleOnboard(product.productId);
loadProducts();
}
} catch {
// ignore
} finally {
setCreating(false);
}
};
const handleOnboard = async (productId: string) => {
setOnboarding(productId);
setOnboardResult(null);
try {
const res = await fetch(`/api/products/${productId}/onboard`, { method: 'POST' });
if (res.ok) {
const data = await res.json();
setOnboardResult({
productId,
plans: data.plans?.length ?? 0,
flags: data.flags?.length ?? 0,
});
setTimeout(() => setOnboardResult(null), 8000);
}
} catch {
// ignore
} finally {
setOnboarding(null);
}
};
const openEdit = (p: ProductDoc) => {
setEditProduct(p);
setEditForm({
displayName: p.displayName,
trialDays: String(p.trialDays),
defaultPlan: p.defaultPlan,
status: p.status,
websiteUrl: p.websiteUrl,
deviceLimitFree: String(p.deviceLimits.free),
deviceLimitPro: String(p.deviceLimits.pro),
deviceLimitEnterprise: String(p.deviceLimits.enterprise),
});
};
const handleDelete = async (productId: string) => {
if (!confirm(`Delete product "${productId}"? This cannot be undone.`)) return;
try {
const res = await fetch(`/api/products/${productId}`, { method: 'DELETE' });
if (res.ok) {
setProducts(prev => prev.filter(p => p.productId !== productId && p.id !== productId));
}
} catch {
// ignore
}
};
const handleUpdate = async () => {
if (!editProduct) return;
setSaving(true);
try {
const res = await fetch(`/api/products/${editProduct.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName: editForm.displayName,
trialDays: Number(editForm.trialDays),
defaultPlan: editForm.defaultPlan,
status: editForm.status,
websiteUrl: editForm.websiteUrl || '',
deviceLimits: {
free: Number(editForm.deviceLimitFree),
pro: Number(editForm.deviceLimitPro),
enterprise: Number(editForm.deviceLimitEnterprise),
},
}),
});
if (res.ok) {
setEditProduct(null);
loadProducts();
}
} catch {
// ignore
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Products</h1>
<p className="text-muted-foreground">
Manage registered products in the platform
</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Product
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Register New Product</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2 max-h-[60vh] overflow-y-auto">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Product ID</Label>
<Input
placeholder="my-product"
value={form.productId}
onChange={e => setForm({ ...form, productId: e.target.value })}
/>
<p className="text-[10px] text-muted-foreground">Lowercase, no spaces</p>
</div>
<div className="space-y-2">
<Label>Display Name</Label>
<Input
placeholder="My Product"
value={form.displayName}
onChange={e => setForm({ ...form, displayName: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>License Prefix</Label>
<Input
placeholder="PROD"
value={form.licensePrefix}
onChange={e => setForm({ ...form, licensePrefix: e.target.value.toUpperCase() })}
/>
</div>
<div className="space-y-2">
<Label>Package Name</Label>
<Input
placeholder="com.bytelyst.myproduct"
value={form.packageName}
onChange={e => setForm({ ...form, packageName: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Default Plan</Label>
<Select
value={form.defaultPlan}
onValueChange={v => setForm({ ...form, defaultPlan: v as 'free' | 'pro' })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Trial Days</Label>
<Input
type="number"
min={0}
max={365}
value={form.trialDays}
onChange={e => setForm({ ...form, trialDays: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label>Website URL</Label>
<Input
placeholder="https://example.com"
value={form.websiteUrl}
onChange={e => setForm({ ...form, websiteUrl: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Device Limits</Label>
<div className="grid grid-cols-3 gap-2">
<div>
<p className="text-[10px] text-muted-foreground mb-1">Free</p>
<Input
type="number" min={0}
value={form.deviceLimitFree}
onChange={e => setForm({ ...form, deviceLimitFree: e.target.value })}
/>
</div>
<div>
<p className="text-[10px] text-muted-foreground mb-1">Pro</p>
<Input
type="number" min={0}
value={form.deviceLimitPro}
onChange={e => setForm({ ...form, deviceLimitPro: e.target.value })}
/>
</div>
<div>
<p className="text-[10px] text-muted-foreground mb-1">Enterprise</p>
<Input
type="number" min={0}
value={form.deviceLimitEnterprise}
onChange={e => setForm({ ...form, deviceLimitEnterprise: e.target.value })}
/>
</div>
</div>
</div>
<Button
className="w-full"
onClick={handleCreate}
disabled={creating || !form.productId || !form.displayName || !form.licensePrefix}
>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Product
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{/* Onboarding success banner */}
{onboardResult && (
<div className="flex items-center gap-3 rounded-lg border border-emerald-500/50 bg-emerald-50 dark:bg-emerald-950/30 p-4">
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
<div>
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200">
Product &quot;{onboardResult.productId}&quot; onboarded successfully
</p>
<p className="text-xs text-emerald-700 dark:text-emerald-300">
{onboardResult.plans} plans seeded{onboardResult.flags > 0 ? `, ${onboardResult.flags} flag(s) created` : ''}
</p>
</div>
</div>
)}
{products.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Package className="mx-auto h-12 w-12 mb-3 opacity-30" />
<p>No products registered yet</p>
<p className="text-xs mt-1">Create your first product to get started</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{products.map(p => (
<Card key={p.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Package className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{p.displayName}</CardTitle>
<CardDescription className="font-mono text-xs">{p.productId}</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant="secondary"
className={
p.status === 'active'
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400'
: 'bg-red-50 text-red-700 dark:bg-red-950/30 dark:text-red-400'
}
>
{p.status === 'active' ? <Check className="mr-1 h-3 w-3" /> : <X className="mr-1 h-3 w-3" />}
{p.status}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => handleOnboard(p.productId)}
disabled={onboarding === p.productId}
title="Seed plans & flags"
>
{onboarding === p.productId ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Rocket className="h-4 w-4" />
)}
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(p)}>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(p.id)}
title="Delete product"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-y-2 text-sm">
<div className="text-muted-foreground">License Prefix</div>
<div className="font-mono">{p.licensePrefix}-XXXX-XXXX-XXXX</div>
<div className="text-muted-foreground">Default Plan</div>
<div className="capitalize">{p.defaultPlan}</div>
<div className="text-muted-foreground">Trial Days</div>
<div>{p.trialDays}</div>
<div className="text-muted-foreground">Device Limits</div>
<div>
Free: {p.deviceLimits.free} · Pro: {p.deviceLimits.pro} · Ent: {p.deviceLimits.enterprise}
</div>
{p.websiteUrl && (
<>
<div className="text-muted-foreground">Website</div>
<a
href={p.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1"
>
<Globe className="h-3 w-3" />
{p.websiteUrl.replace(/^https?:\/\//, '')}
</a>
</>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Edit Dialog */}
<Dialog open={!!editProduct} onOpenChange={open => !open && setEditProduct(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit {editProduct?.displayName}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Display Name</Label>
<Input
value={editForm.displayName ?? ''}
onChange={e => setEditForm({ ...editForm, displayName: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select
value={editForm.status ?? 'active'}
onValueChange={v => setEditForm({ ...editForm, status: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Default Plan</Label>
<Select
value={editForm.defaultPlan ?? 'free'}
onValueChange={v => setEditForm({ ...editForm, defaultPlan: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Trial Days</Label>
<Input
type="number" min={0} max={365}
value={editForm.trialDays ?? '14'}
onChange={e => setEditForm({ ...editForm, trialDays: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label>Website URL</Label>
<Input
value={editForm.websiteUrl ?? ''}
onChange={e => setEditForm({ ...editForm, websiteUrl: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Device Limits</Label>
<div className="grid grid-cols-3 gap-2">
<div>
<p className="text-[10px] text-muted-foreground mb-1">Free</p>
<Input
type="number" min={0}
value={editForm.deviceLimitFree ?? '1'}
onChange={e => setEditForm({ ...editForm, deviceLimitFree: e.target.value })}
/>
</div>
<div>
<p className="text-[10px] text-muted-foreground mb-1">Pro</p>
<Input
type="number" min={0}
value={editForm.deviceLimitPro ?? '3'}
onChange={e => setEditForm({ ...editForm, deviceLimitPro: e.target.value })}
/>
</div>
<div>
<p className="text-[10px] text-muted-foreground mb-1">Enterprise</p>
<Input
type="number" min={0}
value={editForm.deviceLimitEnterprise ?? '10'}
onChange={e => setEditForm({ ...editForm, deviceLimitEnterprise: e.target.value })}
/>
</div>
</div>
</div>
<Button className="w-full" onClick={handleUpdate} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,348 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Tag, Plus, CheckCircle2, XCircle, Trash2, ToggleLeft, ToggleRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiListPromos, apiCreatePromo, apiDeletePromo, apiUpdatePromo, type ApiPromo } from '@/lib/api';
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
function formatDiscount(promo: ApiPromo): string {
if (promo.percentOff) return `${promo.percentOff}% off`;
if (promo.amountOff) return `$${(promo.amountOff / 100).toFixed(2)} off`;
return '—';
}
export default function PromosPage() {
const [promos, setPromos] = useState<ApiPromo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
// Create form
const [newCode, setNewCode] = useState('');
const [discountType, setDiscountType] = useState('percent');
const [newPercentOff, setNewPercentOff] = useState('20');
const [newAmountOff, setNewAmountOff] = useState('500');
const [newDuration, setNewDuration] = useState('once');
const [newMaxRedemptions, setNewMaxRedemptions] = useState('');
const [creating, setCreating] = useState(false);
const handleDelete = async (id: string) => {
if (!confirm('Delete this promo code? This cannot be undone.')) return;
const { error: err } = await apiDeletePromo(id);
if (!err) setPromos(prev => prev.filter(p => p.id !== id));
};
const handleToggleActive = async (promo: ApiPromo) => {
const { data } = await apiUpdatePromo(promo.id, { active: !promo.active });
if (data) setPromos(prev => prev.map(p => p.id === promo.id ? { ...p, active: !p.active } : p));
};
const loadPromos = useCallback(async () => {
setLoading(true);
setError(null);
const { data, error: err } = await apiListPromos();
if (data) {
setPromos(data.promos);
} else {
setError(err || 'Failed to load promos');
}
setLoading(false);
}, []);
useEffect(() => {
void loadPromos();
}, [loadPromos]);
async function handleCreate() {
if (!newCode.trim()) return;
setCreating(true);
const body: Record<string, unknown> = {
code: newCode.trim(),
duration: newDuration,
};
if (discountType === 'percent') {
body.percentOff = parseFloat(newPercentOff);
} else {
body.amountOff = parseInt(newAmountOff);
body.currency = 'usd';
}
if (newMaxRedemptions) {
body.maxRedemptions = parseInt(newMaxRedemptions);
}
const { error: err } = await apiCreatePromo(body as Parameters<typeof apiCreatePromo>[0]);
setCreating(false);
if (!err) {
setShowCreate(false);
setNewCode('');
setNewPercentOff('20');
setNewAmountOff('500');
setNewDuration('once');
setNewMaxRedemptions('');
loadPromos();
} else {
alert(err);
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Promo Codes</h1>
<p className="text-sm text-muted-foreground">
Stripe promotion codes for discounts on subscriptions
</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="mr-2 h-4 w-4" /> Create Promo
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Promos
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{promos.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Active Promos
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{promos.filter(p => p.active).length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Redemptions
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{promos.reduce((sum, p) => sum + p.timesRedeemed, 0)}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : error ? (
<div className="p-8 text-center text-muted-foreground">
<p className="text-sm">{error}</p>
<p className="text-xs mt-1">Ensure STRIPE_SECRET_KEY is set in admin .env.local</p>
</div>
) : promos.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Tag className="mx-auto mb-2 h-8 w-8 opacity-40" />
No promo codes yet. Create one in Stripe or via the button above.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Discount</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Redemptions</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{promos.map(promo => (
<TableRow key={promo.id}>
<TableCell>
<code className="text-sm font-mono font-bold">{promo.code}</code>
</TableCell>
<TableCell>{formatDiscount(promo)}</TableCell>
<TableCell className="capitalize">{promo.duration || '—'}</TableCell>
<TableCell>
{promo.timesRedeemed}
{promo.maxRedemptions ? ` / ${promo.maxRedemptions}` : ''}
</TableCell>
<TableCell>
<Badge
className={`border-0 gap-1 ${
promo.active ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'
}`}
>
{promo.active ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{promo.active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatDate(promo.created)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{promo.expiresAt ? formatDate(promo.expiresAt) : 'Never'}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleToggleActive(promo)}
title={promo.active ? 'Deactivate' : 'Activate'}
>
{promo.active ? (
<ToggleRight className="h-4 w-4 text-emerald-600" />
) : (
<ToggleLeft className="h-4 w-4 text-muted-foreground" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => handleDelete(promo.id)}
title="Delete promo"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Promo Code</DialogTitle>
<DialogDescription>
Creates a Stripe coupon + promotion code. Users can enter this at checkout.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Promo Code</Label>
<Input
placeholder="e.g. LAUNCH20"
value={newCode}
onChange={e => setNewCode(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Discount Type</Label>
<Select value={discountType} onValueChange={setDiscountType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="percent">Percentage</SelectItem>
<SelectItem value="amount">Fixed Amount</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{discountType === 'percent' ? 'Percent Off' : 'Amount Off (cents)'}</Label>
<Input
type="number"
min="1"
value={discountType === 'percent' ? newPercentOff : newAmountOff}
onChange={e =>
discountType === 'percent'
? setNewPercentOff(e.target.value)
: setNewAmountOff(e.target.value)
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Duration</Label>
<Select value={newDuration} onValueChange={setNewDuration}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="once">Once</SelectItem>
<SelectItem value="repeating">Repeating</SelectItem>
<SelectItem value="forever">Forever</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Max Redemptions (empty = unlimited)</Label>
<Input
type="number"
min="1"
placeholder="Unlimited"
value={newMaxRedemptions}
onChange={e => setNewMaxRedemptions(e.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={creating || !newCode.trim()}>
{creating ? 'Creating...' : 'Create Promo'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,168 @@
'use client';
import { useState, useEffect } from 'react';
import { Users, Gift, CheckCircle2, Clock } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { apiListReferrals, type ApiReferral, type ReferralStats } from '@/lib/api';
const statusConfig: Record<string, { label: string; color: string; icon: typeof CheckCircle2 }> = {
pending: { label: 'Pending', color: 'bg-slate-50 text-slate-600', icon: Clock },
signed_up: { label: 'Signed Up', color: 'bg-blue-50 text-blue-700', icon: CheckCircle2 },
subscribed: { label: 'Subscribed', color: 'bg-emerald-50 text-emerald-700', icon: Gift },
rewarded: { label: 'Rewarded', color: 'bg-purple-50 text-purple-700', icon: Gift },
};
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
export default function ReferralsPage() {
const [referrals, setReferrals] = useState<ApiReferral[]>([]);
const [stats, setStats] = useState<ReferralStats>({ total: 0, completed: 0, rewarded: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
setLoading(true);
const { data } = await apiListReferrals();
if (data) {
setReferrals(data.referrals);
setStats(data.stats);
}
setLoading(false);
}
load();
}, []);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Referral Program</h1>
<p className="text-sm text-muted-foreground">
Track user referrals and rewards across the platform
</p>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Referrals
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Completed</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.completed}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Rewarded</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.rewarded}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Conversion Rate
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.total > 0 ? `${Math.round((stats.completed / stats.total) * 100)}%` : '—'}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-8 text-center text-muted-foreground">Loading...</div>
) : referrals.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
<Users className="mx-auto mb-2 h-8 w-8 opacity-40" />
No referrals yet. Users can share their referral links from the portal.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Referrer</TableHead>
<TableHead>Referred Email</TableHead>
<TableHead>Status</TableHead>
<TableHead>Referrer Reward</TableHead>
<TableHead>Referred Reward</TableHead>
<TableHead>Created</TableHead>
<TableHead>Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{referrals.map(ref => {
const cfg = statusConfig[ref.status] || statusConfig.pending;
const StatusIcon = cfg.icon;
return (
<TableRow key={ref.id}>
<TableCell className="text-sm">{ref.referrerEmail}</TableCell>
<TableCell className="text-sm">{ref.referredEmail}</TableCell>
<TableCell>
<Badge className={`${cfg.color} border-0 gap-1`}>
<StatusIcon className="h-3 w-3" />
{cfg.label}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm">
{ref.referrerRewardTokens.toLocaleString()} tokens
{ref.referrerRewarded && (
<CheckCircle2 className="inline ml-1 h-3.5 w-3.5 text-emerald-600" />
)}
</span>
</TableCell>
<TableCell>
<span className="text-sm">
{ref.referredRewardTokens.toLocaleString()} tokens
{ref.referredRewarded && (
<CheckCircle2 className="inline ml-1 h-3.5 w-3.5 text-emerald-600" />
)}
</span>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatDate(ref.createdAt)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{ref.completedAt ? formatDate(ref.completedAt) : '—'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,658 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Save,
Globe,
Shield,
Bell,
Database,
Key,
Power,
Monitor,
Smartphone,
Tablet,
Globe2,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/toast';
interface PlatformFlags {
desktop: boolean;
ios: boolean;
android: boolean;
web: boolean;
}
interface KillSwitchState {
enabled: boolean;
platforms: PlatformFlags;
reason: string;
updatedAt: string;
updatedBy: string;
}
interface PlatformSettings {
platformName: string;
supportEmail: string;
defaultLanguage: string;
maintenanceMode: boolean;
allowSelfRegistration: boolean;
rateLimits: {
globalPerMin: number;
perUserPerMin: number;
maxTokenBurst: number;
abuseThreshold: number;
autoSuspendOnAbuse: boolean;
ipBlocklist: boolean;
};
notifications: {
newUserSignup: boolean;
usageThreshold: boolean;
failedPayment: boolean;
securityAlerts: boolean;
};
dataRetentionDays: number;
backupFrequency: string;
auditLogging: boolean;
updatedAt?: string;
updatedBy?: string;
}
const DEFAULTS: PlatformSettings = {
platformName: '',
supportEmail: '',
defaultLanguage: 'en-US',
maintenanceMode: false,
allowSelfRegistration: true,
rateLimits: {
globalPerMin: 1000,
perUserPerMin: 60,
maxTokenBurst: 4096,
abuseThreshold: 50,
autoSuspendOnAbuse: true,
ipBlocklist: true,
},
notifications: {
newUserSignup: true,
usageThreshold: true,
failedPayment: true,
securityAlerts: true,
},
dataRetentionDays: 365,
backupFrequency: 'daily',
auditLogging: true,
};
function getAuthHeaders(): HeadersInit {
if (typeof window === 'undefined') return {};
const token = localStorage.getItem('admin_access_token');
if (!token) return {};
return { Authorization: `Bearer ${token}` };
}
export default function SettingsPage() {
const [saved, setSaved] = useState(false);
const [saving, setSaving] = useState(false);
const { toast } = useToast();
const [settings, setSettings] = useState<PlatformSettings>(DEFAULTS);
const [settingsLoading, setSettingsLoading] = useState(true);
const [killSwitch, setKillSwitch] = useState<KillSwitchState | null>(null);
const [killSwitchLoading, setKillSwitchLoading] = useState(true);
const [killSwitchToggling, setKillSwitchToggling] = useState(false);
const fetchSettings = useCallback(async () => {
try {
const res = await fetch('/api/settings/platform', { headers: getAuthHeaders() });
if (res.ok) {
const data = await res.json();
setSettings({
platformName: data.platformName ?? DEFAULTS.platformName,
supportEmail: data.supportEmail ?? DEFAULTS.supportEmail,
defaultLanguage: data.defaultLanguage ?? DEFAULTS.defaultLanguage,
maintenanceMode: data.maintenanceMode ?? DEFAULTS.maintenanceMode,
allowSelfRegistration: data.allowSelfRegistration ?? DEFAULTS.allowSelfRegistration,
rateLimits: { ...DEFAULTS.rateLimits, ...(data.rateLimits ?? {}) },
notifications: { ...DEFAULTS.notifications, ...(data.notifications ?? {}) },
dataRetentionDays: data.dataRetentionDays ?? DEFAULTS.dataRetentionDays,
backupFrequency: data.backupFrequency ?? DEFAULTS.backupFrequency,
auditLogging: data.auditLogging ?? DEFAULTS.auditLogging,
updatedAt: data.updatedAt,
updatedBy: data.updatedBy,
});
}
} catch {
// use defaults
} finally {
setSettingsLoading(false);
}
}, []);
const fetchKillSwitch = useCallback(async () => {
try {
const res = await fetch('/api/settings/kill-switch');
if (res.ok) {
const data = await res.json();
setKillSwitch(data);
}
} catch {
// ignore
} finally {
setKillSwitchLoading(false);
}
}, []);
useEffect(() => {
fetchSettings();
fetchKillSwitch();
}, [fetchSettings, fetchKillSwitch]);
const toggleKillSwitch = async () => {
if (!killSwitch) return;
setKillSwitchToggling(true);
try {
const newEnabled = !killSwitch.enabled;
const res = await fetch('/api/settings/kill-switch', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: newEnabled,
platforms: killSwitch.platforms,
reason: newEnabled ? '' : 'Disabled by admin from dashboard',
updatedBy: 'admin',
}),
});
if (res.ok) {
const data = await res.json();
setKillSwitch(data);
}
} catch {
// ignore
} finally {
setKillSwitchToggling(false);
}
};
const togglePlatform = async (platform: keyof PlatformFlags) => {
if (!killSwitch) return;
setKillSwitchToggling(true);
try {
const newPlatforms = { ...killSwitch.platforms, [platform]: !killSwitch.platforms[platform] };
const res = await fetch('/api/settings/kill-switch', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: killSwitch.enabled,
platforms: newPlatforms,
reason: killSwitch.reason,
updatedBy: 'admin',
}),
});
if (res.ok) {
const data = await res.json();
setKillSwitch(data);
}
} catch {
// ignore
} finally {
setKillSwitchToggling(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch('/api/settings/platform', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify(settings),
});
if (res.ok) {
const data = await res.json();
setSettings(prev => ({ ...prev, updatedAt: data.updatedAt, updatedBy: data.updatedBy }));
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else {
toast({ title: 'Failed to save settings', variant: 'error' });
}
} catch {
toast({ title: 'Network error — could not save settings', variant: 'error' });
} finally {
setSaving(false);
}
};
if (settingsLoading && killSwitchLoading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground">Platform configuration and admin preferences</p>
</div>
<div className="flex items-center gap-3">
{settings.updatedAt && (
<span className="text-xs text-muted-foreground">
Last saved: {new Date(settings.updatedAt).toLocaleString()}
</span>
)}
<Button onClick={handleSave} disabled={saving || settingsLoading}>
<Save className="mr-2 h-4 w-4" />
{saving ? 'Saving…' : saved ? 'Saved!' : 'Save Changes'}
</Button>
</div>
</div>
{/* Kill Switch */}
<Card
className={
killSwitch && !killSwitch.enabled
? 'border-destructive bg-destructive/5'
: 'border-green-500/50 bg-green-500/5'
}
>
<CardHeader>
<div className="flex items-center gap-2">
<Power
className={`h-5 w-5 ${killSwitch?.enabled ? 'text-green-500' : 'text-destructive'}`}
/>
<div className="flex-1">
<CardTitle className="text-base">Platform Kill Switch</CardTitle>
<CardDescription>
Master toggle disables all apps (desktop, mobile, API) in real time via Cosmos DB
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p
className={`text-lg font-semibold ${killSwitch?.enabled ? 'text-green-600 dark:text-green-400' : 'text-destructive'}`}
>
{killSwitchLoading
? 'Loading...'
: killSwitch?.enabled
? 'PLATFORM ACTIVE'
: 'PLATFORM DISABLED'}
</p>
{killSwitch && !killSwitch.enabled && killSwitch.reason && (
<p className="text-sm text-muted-foreground mt-1">Reason: {killSwitch.reason}</p>
)}
{killSwitch?.updatedAt && (
<p className="text-xs text-muted-foreground mt-1">
Last changed: {new Date(killSwitch.updatedAt).toLocaleString()} by{' '}
{killSwitch.updatedBy}
</p>
)}
</div>
<Switch
checked={killSwitch?.enabled ?? true}
onCheckedChange={toggleKillSwitch}
disabled={killSwitchLoading || killSwitchToggling}
className="scale-125"
/>
</div>
{killSwitch?.enabled && (
<>
<Separator className="my-4" />
<div>
<p className="text-sm font-medium mb-3">Per-Platform Control</p>
<p className="text-xs text-muted-foreground mb-4">
Disable individual platforms while keeping others active
</p>
<div className="grid grid-cols-2 gap-3">
{[
{ key: 'desktop' as const, label: 'Desktop (macOS/Win)', icon: Monitor },
{ key: 'ios' as const, label: 'iOS', icon: Smartphone },
{ key: 'android' as const, label: 'Android', icon: Tablet },
{ key: 'web' as const, label: 'Web Dashboard', icon: Globe2 },
].map(({ key, label, icon: Icon }) => (
<div
key={key}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-2">
<Icon
className={`h-4 w-4 ${killSwitch.platforms?.[key] ? 'text-green-500' : 'text-muted-foreground'}`}
/>
<span className="text-sm">{label}</span>
</div>
<Switch
checked={killSwitch.platforms?.[key] ?? true}
onCheckedChange={() => togglePlatform(key)}
disabled={killSwitchToggling}
/>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* General */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle className="text-base">General</CardTitle>
<CardDescription>Basic platform configuration</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Platform Name</Label>
<Input
value={settings.platformName}
onChange={e => setSettings(s => ({ ...s, platformName: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Support Email</Label>
<Input
type="email"
placeholder="support@example.com"
value={settings.supportEmail}
onChange={e => setSettings(s => ({ ...s, supportEmail: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>Default Language</Label>
<Select
value={settings.defaultLanguage}
onValueChange={v => setSettings(s => ({ ...s, defaultLanguage: v }))}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en-US">English (US)</SelectItem>
<SelectItem value="en-GB">English (UK)</SelectItem>
<SelectItem value="es-ES">Spanish</SelectItem>
<SelectItem value="fr-FR">French</SelectItem>
<SelectItem value="de-DE">German</SelectItem>
<SelectItem value="ja-JP">Japanese</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Maintenance Mode</p>
<p className="text-xs text-muted-foreground">
Disable all API access for non-admin users
</p>
</div>
<Switch
checked={settings.maintenanceMode}
onCheckedChange={v => setSettings(s => ({ ...s, maintenanceMode: v }))}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Allow Self-Registration</p>
<p className="text-xs text-muted-foreground">Users can sign up without an invite</p>
</div>
<Switch
checked={settings.allowSelfRegistration}
onCheckedChange={v => setSettings(s => ({ ...s, allowSelfRegistration: v }))}
/>
</div>
</CardContent>
</Card>
{/* Azure Configuration — managed via Secrets Manager */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Key className="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle className="text-base">Azure Configuration</CardTitle>
<CardDescription>
Azure secrets are managed via the{' '}
<a href="/ops/secrets" className="text-primary underline underline-offset-2 hover:text-primary/80">
Secrets Manager
</a>
{' '}(Key Vault)
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Azure Speech keys, OpenAI keys, Cosmos connection strings, and other secrets are stored
in Azure Key Vault and resolved at runtime. Use the{' '}
<a href="/ops/secrets" className="text-primary underline underline-offset-2">
Secrets Manager
</a>
{' '}to view, rotate, or update them.
</p>
</CardContent>
</Card>
{/* Rate Limiting */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle className="text-base">Rate Limiting</CardTitle>
<CardDescription>Global rate limits and abuse prevention</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Global Rate Limit (req/min)</Label>
<Input
type="number"
value={settings.rateLimits.globalPerMin}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, globalPerMin: parseInt(e.target.value) || 0 } }))}
/>
</div>
<div className="space-y-2">
<Label>Per-User Rate Limit (req/min)</Label>
<Input
type="number"
value={settings.rateLimits.perUserPerMin}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, perUserPerMin: parseInt(e.target.value) || 0 } }))}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Max Token Burst (per request)</Label>
<Input
type="number"
value={settings.rateLimits.maxTokenBurst}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, maxTokenBurst: parseInt(e.target.value) || 0 } }))}
/>
</div>
<div className="space-y-2">
<Label>Abuse Threshold (failed auth/hr)</Label>
<Input
type="number"
value={settings.rateLimits.abuseThreshold}
onChange={e => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, abuseThreshold: parseInt(e.target.value) || 0 } }))}
/>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Auto-Suspend on Abuse</p>
<p className="text-xs text-muted-foreground">
Automatically suspend users exceeding abuse thresholds
</p>
</div>
<Switch
checked={settings.rateLimits.autoSuspendOnAbuse}
onCheckedChange={v => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, autoSuspendOnAbuse: v } }))}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">IP Blocklist</p>
<p className="text-xs text-muted-foreground">
Block requests from known malicious IPs
</p>
</div>
<Switch
checked={settings.rateLimits.ipBlocklist}
onCheckedChange={v => setSettings(s => ({ ...s, rateLimits: { ...s.rateLimits, ipBlocklist: v } }))}
/>
</div>
</CardContent>
</Card>
{/* Notifications */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle className="text-base">Notifications</CardTitle>
<CardDescription>Admin alerts and email notifications</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">New User Signup</p>
<p className="text-xs text-muted-foreground">
Get notified when a new user registers
</p>
</div>
<Switch
checked={settings.notifications.newUserSignup}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, newUserSignup: v } }))}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Usage Threshold Alert</p>
<p className="text-xs text-muted-foreground">
Alert when monthly token usage exceeds 80% of budget
</p>
</div>
<Switch
checked={settings.notifications.usageThreshold}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, usageThreshold: v } }))}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Failed Payment</p>
<p className="text-xs text-muted-foreground">
Alert when a subscription payment fails
</p>
</div>
<Switch
checked={settings.notifications.failedPayment}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, failedPayment: v } }))}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Security Alerts</p>
<p className="text-xs text-muted-foreground">
Suspicious activity and brute-force attempts
</p>
</div>
<Switch
checked={settings.notifications.securityAlerts}
onCheckedChange={v => setSettings(s => ({ ...s, notifications: { ...s.notifications, securityAlerts: v } }))}
/>
</div>
</CardContent>
</Card>
{/* Database */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-muted-foreground" />
<div>
<CardTitle className="text-base">Database</CardTitle>
<CardDescription>Storage and data management</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Database connection is configured via environment variables and{' '}
<a href="/ops/secrets" className="text-primary underline underline-offset-2">
Secrets Manager
</a>
. Cosmos DB endpoint and key are resolved from Azure Key Vault at runtime.
</p>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Data Retention (days)</Label>
<Input
type="number"
value={settings.dataRetentionDays}
onChange={e => setSettings(s => ({ ...s, dataRetentionDays: parseInt(e.target.value) || 365 }))}
/>
</div>
<div className="space-y-2">
<Label>Backup Frequency</Label>
<Select
value={settings.backupFrequency}
onValueChange={v => setSettings(s => ({ ...s, backupFrequency: v }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Audit Logging</p>
<p className="text-xs text-muted-foreground">Log all admin actions for compliance</p>
</div>
<Switch
checked={settings.auditLogging}
onCheckedChange={v => setSettings(s => ({ ...s, auditLogging: v }))}
/>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,447 @@
'use client';
import { useState, useEffect } from 'react';
import { Check, Pencil, Plus, Users, Zap, Shield, Crown } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { formatCurrency, formatNumber } from '@/lib/mock-data';
import { apiListUsers, apiUpdatePlan, apiCreatePlan, apiListPlans, type PlanDoc } from '@/lib/api';
const planIcons = {
Free: Zap,
Pro: Shield,
Enterprise: Crown,
};
const planColors = {
Free: 'border-muted',
Pro: 'border-blue-200 bg-blue-50/30',
Enterprise: 'border-violet-200 bg-violet-50/30',
};
interface LocalPlan {
id: string;
name: string;
price: number;
interval: string;
features: string[];
limits: { tokensPerMonth: number; requestsPerDay: number; modelsAllowed: string[] };
activeUsers: number;
}
function planDocToLocal(p: PlanDoc): LocalPlan {
return {
id: p.id,
name: p.displayName || p.name,
price: p.price,
interval: 'monthly',
features: p.features ?? [],
limits: {
tokensPerMonth: p.tokens ?? 0,
requestsPerDay: p.dictations ?? 0,
modelsAllowed: ['gpt-4o-mini'],
},
activeUsers: 0,
};
}
export default function SubscriptionsPage() {
const [plans, setPlans] = useState<LocalPlan[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [newPlanName, setNewPlanName] = useState('');
const [newPlanPrice, setNewPlanPrice] = useState('');
const [newPlanTokens, setNewPlanTokens] = useState('');
const [newPlanRequests, setNewPlanRequests] = useState('');
const [newPlanYearly, setNewPlanYearly] = useState(false);
useEffect(() => {
async function load() {
try {
const [plansRes, usersRes] = await Promise.allSettled([
apiListPlans(),
apiListUsers(),
]);
let loadedPlans: LocalPlan[] = [];
if (plansRes.status === 'fulfilled' && plansRes.value.data?.plans?.length) {
loadedPlans = plansRes.value.data.plans.filter(p => p.active).map(planDocToLocal);
}
if (usersRes.status === 'fulfilled' && usersRes.value.data?.byPlan) {
const byPlan = usersRes.value.data.byPlan;
loadedPlans = loadedPlans.map(p => ({
...p,
activeUsers: byPlan[p.name.toLowerCase()] ?? 0,
}));
}
if (loadedPlans.length > 0) setPlans(loadedPlans);
} catch {
// use empty
} finally {
setLoading(false);
}
}
load();
}, []);
const [editingPlan, setEditingPlan] = useState<string | null>(null);
const [showNewPlan, setShowNewPlan] = useState(false);
const [editPrice, setEditPrice] = useState('');
const [editTokens, setEditTokens] = useState('');
const [editRequests, setEditRequests] = useState('');
const totalMRR = plans.reduce((sum, p) => sum + p.price * p.activeUsers, 0);
const totalActiveUsers = plans.reduce((sum, p) => sum + p.activeUsers, 0);
const startEdit = (planId: string) => {
const plan = plans.find(p => p.id === planId);
if (plan) {
setEditPrice(plan.price.toString());
setEditTokens(plan.limits.tokensPerMonth.toString());
setEditRequests(plan.limits.requestsPerDay.toString());
setEditingPlan(planId);
}
};
const saveEdit = async () => {
if (!editingPlan) return;
setSaving(true);
try {
await apiUpdatePlan(editingPlan, {
price: parseFloat(editPrice) || 0,
tokensPerMonth: parseInt(editTokens) || 0,
requestsPerDay: parseInt(editRequests) || 0,
});
setPlans(prev =>
prev.map(p =>
p.id === editingPlan
? {
...p,
price: parseFloat(editPrice) || 0,
limits: {
...p.limits,
tokensPerMonth: parseInt(editTokens) || 0,
requestsPerDay: parseInt(editRequests) || 0,
},
}
: p
)
);
setEditingPlan(null);
} catch {
// API failed — keep dialog open so user can retry
} finally {
setSaving(false);
}
};
const handleCreatePlan = async () => {
if (!newPlanName.trim()) return;
setSaving(true);
try {
const created = await apiCreatePlan({
name: newPlanName.trim(),
price: parseFloat(newPlanPrice) || 0,
tokensPerMonth: parseInt(newPlanTokens) || 0,
requestsPerDay: parseInt(newPlanRequests) || 0,
yearlyDiscount: newPlanYearly,
});
setPlans(prev => [
...prev,
{
id: created.data?.plan ?? `plan-${Date.now()}`,
name: newPlanName.trim(),
price: parseFloat(newPlanPrice) || 0,
interval: newPlanYearly ? 'yearly' : 'monthly',
features: [],
limits: {
tokensPerMonth: parseInt(newPlanTokens) || 0,
requestsPerDay: parseInt(newPlanRequests) || 0,
modelsAllowed: ['gpt-4o-mini'],
},
activeUsers: 0,
},
]);
setShowNewPlan(false);
setNewPlanName('');
setNewPlanPrice('');
setNewPlanTokens('');
setNewPlanRequests('');
setNewPlanYearly(false);
} catch {
// keep dialog open on failure
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Subscriptions</h1>
<p className="text-muted-foreground">Manage pricing plans and subscription tiers</p>
</div>
<Button onClick={() => setShowNewPlan(true)}>
<Plus className="mr-2 h-4 w-4" />
New Plan
</Button>
</div>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Monthly Recurring Revenue
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatCurrency(totalMRR)}</div>
<p className="text-xs text-muted-foreground mt-1">Across {plans.length} plans</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Subscribers
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalActiveUsers}</div>
<p className="text-xs text-muted-foreground mt-1">Active subscriptions</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Avg Revenue Per User
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalActiveUsers > 0 ? formatCurrency(totalMRR / totalActiveUsers) : '$0.00'}</div>
<p className="text-xs text-muted-foreground mt-1">ARPU</p>
</CardContent>
</Card>
</div>
{/* Plan Cards */}
<div className="grid gap-6 md:grid-cols-3">
{plans.map(plan => {
const Icon = planIcons[plan.name as keyof typeof planIcons] || Zap;
const borderColor = planColors[plan.name as keyof typeof planColors] || 'border-muted';
return (
<Card key={plan.id} className={`relative ${borderColor}`}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg">{plan.name}</CardTitle>
<div className="flex items-center gap-1">
<Users className="h-3 w-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{plan.activeUsers} active
</span>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(plan.id)}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<span className="text-3xl font-bold">
{plan.price === 0 ? 'Free' : formatCurrency(plan.price)}
</span>
{plan.price > 0 && (
<span className="text-muted-foreground text-sm">
/{plan.interval === 'monthly' ? 'mo' : 'yr'}
</span>
)}
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Limits
</p>
<div className="text-sm space-y-1">
<p>
<span className="font-medium">Tokens: </span>
{plan.limits.tokensPerMonth === -1
? 'Unlimited'
: formatNumber(plan.limits.tokensPerMonth) + '/mo'}
</p>
<p>
<span className="font-medium">Requests: </span>
{plan.limits.requestsPerDay === -1
? 'Unlimited'
: formatNumber(plan.limits.requestsPerDay) + '/day'}
</p>
<p>
<span className="font-medium">Models: </span>
{plan.limits.modelsAllowed.join(', ')}
</p>
</div>
</div>
<Separator />
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Features
</p>
<ul className="space-y-1.5">
{plan.features.map(feature => (
<li key={feature} className="flex items-center gap-2 text-sm">
<Check className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
{feature}
</li>
))}
</ul>
</div>
<div className="pt-2">
<Badge variant="secondary" className="w-full justify-center py-1">
Revenue: {formatCurrency(plan.price * plan.activeUsers)}/mo
</Badge>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Edit Plan Dialog */}
<Dialog open={!!editingPlan} onOpenChange={() => setEditingPlan(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Plan Pricing</DialogTitle>
<DialogDescription>
Update pricing and limits for this subscription tier.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Price (USD / month)</Label>
<Input type="number" value={editPrice} onChange={e => setEditPrice(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Tokens per Month (-1 for unlimited)</Label>
<Input
type="number"
value={editTokens}
onChange={e => setEditTokens(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Requests per Day (-1 for unlimited)</Label>
<Input
type="number"
value={editRequests}
onChange={e => setEditRequests(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingPlan(null)}>
Cancel
</Button>
<Button onClick={saveEdit} disabled={saving}>
{saving ? 'Saving…' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* New Plan Dialog */}
<Dialog open={showNewPlan} onOpenChange={setShowNewPlan}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Plan</DialogTitle>
<DialogDescription>Define a new subscription tier for your platform.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Plan Name</Label>
<Input
placeholder="e.g. Team, Business..."
value={newPlanName}
onChange={e => setNewPlanName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Price (USD / month)</Label>
<Input
type="number"
placeholder="49.99"
value={newPlanPrice}
onChange={e => setNewPlanPrice(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Tokens per Month</Label>
<Input
type="number"
placeholder="5000000"
value={newPlanTokens}
onChange={e => setNewPlanTokens(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Requests per Day</Label>
<Input
type="number"
placeholder="5000"
value={newPlanRequests}
onChange={e => setNewPlanRequests(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Switch id="yearly" checked={newPlanYearly} onCheckedChange={setNewPlanYearly} />
<Label htmlFor="yearly">Offer yearly billing (20% discount)</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewPlan(false)}>
Cancel
</Button>
<Button onClick={handleCreatePlan} disabled={saving || !newPlanName.trim()}>
{saving ? 'Creating…' : 'Create Plan'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,452 @@
'use client';
import { useState, useEffect } from 'react';
import {
Key,
Plus,
Copy,
MoreHorizontal,
ShieldCheck,
ShieldOff,
Clock,
CheckCircle2,
XCircle,
AlertTriangle,
Trash2,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { mockApiTokens, formatDate, type ApiToken as MockApiToken } from '@/lib/mock-data';
import { apiListTokens, apiCreateToken, apiRevokeToken, apiDeleteToken } from '@/lib/api';
const statusConfig = {
active: {
label: 'Active',
color: 'bg-emerald-50 text-emerald-700',
icon: CheckCircle2,
},
revoked: {
label: 'Revoked',
color: 'bg-red-50 text-red-700',
icon: XCircle,
},
expired: {
label: 'Expired',
color: 'bg-amber-50 text-amber-700',
icon: AlertTriangle,
},
};
const allScopes = ['dictate', 'transcribe', 'cleanup', 'admin'];
export default function TokensPage() {
const [tokens, setTokens] = useState<MockApiToken[]>(mockApiTokens);
const [showCreate, setShowCreate] = useState(false);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [newTokenName, setNewTokenName] = useState('');
const [newTokenScopes, setNewTokenScopes] = useState<string[]>(['dictate', 'transcribe']);
const [newTokenExpiry, setNewTokenExpiry] = useState('90');
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
const [copiedPrefix, setCopiedPrefix] = useState<string | null>(null);
const [copiedToken, setCopiedToken] = useState(false);
useEffect(() => {
apiListTokens().then(({ data }) => {
if (data?.tokens?.length) {
setTokens(
data.tokens.map(t => ({
id: t.id,
userId: t.userId,
userName: t.userName,
name: t.name,
prefix: t.prefix,
status: t.status as MockApiToken['status'],
createdAt: t.createdAt,
expiresAt: t.expiresAt,
lastUsed: t.lastUsed,
scopes: t.scopes,
}))
);
}
});
}, []);
const filtered = tokens.filter(t => statusFilter === 'all' || t.status === statusFilter);
const activeCount = tokens.filter(t => t.status === 'active').length;
const revokedCount = tokens.filter(t => t.status === 'revoked').length;
const expiredCount = tokens.filter(t => t.status === 'expired').length;
const handleCreate = async () => {
const { data, error } = await apiCreateToken({
name: newTokenName,
scopes: newTokenScopes,
expiresInDays: parseInt(newTokenExpiry),
});
if (data) {
setGeneratedToken(data.rawToken);
setTokens(prev => [
{
id: data.id,
userId: data.userId,
userName: data.userName,
name: data.name,
prefix: data.prefix,
status: data.status as MockApiToken['status'],
createdAt: data.createdAt,
expiresAt: data.expiresAt,
lastUsed: data.lastUsed,
scopes: data.scopes,
},
...prev,
]);
} else {
setGeneratedToken(`error: ${error}`);
}
};
const handleRevoke = async (tokenId: string) => {
const token = tokens.find(t => t.id === tokenId);
if (!token) return;
if (!confirm(`Revoke token "${token.name}"? This cannot be undone.`)) return;
const { error } = await apiRevokeToken(tokenId);
if (!error) {
setTokens(prev =>
prev.map(t => (t.id === tokenId ? { ...t, status: 'revoked' as const } : t))
);
}
};
const handleDelete = async (tokenId: string) => {
const token = tokens.find(t => t.id === tokenId);
if (!token) return;
if (!confirm(`Permanently delete token "${token.name}"? This cannot be undone.`)) return;
const { error } = await apiDeleteToken(tokenId);
if (!error) {
setTokens(prev => prev.filter(t => t.id !== tokenId));
}
};
const toggleScope = (scope: string) => {
setNewTokenScopes(prev =>
prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]
);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">API Tokens</h1>
<p className="text-muted-foreground">Create and manage API access tokens</p>
</div>
<Button
onClick={() => {
setShowCreate(true);
setGeneratedToken(null);
setNewTokenName('');
}}
>
<Plus className="mr-2 h-4 w-4" />
Generate Token
</Button>
</div>
{/* Summary */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Active Tokens
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-emerald-500" />
<span className="text-2xl font-bold">{activeCount}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Revoked</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<ShieldOff className="h-5 w-5 text-red-500" />
<span className="text-2xl font-bold">{revokedCount}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Expired</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-amber-500" />
<span className="text-2xl font-bold">{expiredCount}</span>
</div>
</CardContent>
</Card>
</div>
{/* Filter */}
<div className="flex gap-4">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Tokens</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="revoked">Revoked</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
</SelectContent>
</Select>
</div>
{/* Tokens Table */}
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>User</TableHead>
<TableHead>Prefix</TableHead>
<TableHead>Status</TableHead>
<TableHead>Scopes</TableHead>
<TableHead>Created</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Last Used</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map(token => {
const status = statusConfig[token.status];
return (
<TableRow key={token.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Key className="h-4 w-4 text-muted-foreground" />
{token.name}
</div>
</TableCell>
<TableCell className="text-sm">{token.userName}</TableCell>
<TableCell>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{token.prefix}...
</code>
</TableCell>
<TableCell>
<Badge variant="secondary" className={status.color}>
<status.icon className="mr-1 h-3 w-3" />
{status.label}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{token.scopes.map(scope => (
<Badge key={scope} variant="outline" className="text-[10px]">
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(token.createdAt)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(token.expiresAt)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{token.lastUsed ? formatDate(token.lastUsed) : 'Never'}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(token.prefix);
setCopiedPrefix(token.id);
setTimeout(() => setCopiedPrefix(null), 2000);
}}
>
{copiedPrefix === token.id ? (
<CheckCircle2 className="mr-2 h-4 w-4 text-green-500" />
) : (
<Copy className="mr-2 h-4 w-4" />
)}
{copiedPrefix === token.id ? 'Copied!' : 'Copy Prefix'}
</DropdownMenuItem>
<DropdownMenuSeparator />
{token.status === 'active' && (
<DropdownMenuItem
className="text-red-600"
onClick={() => handleRevoke(token.id)}
>
<ShieldOff className="mr-2 h-4 w-4" />
Revoke Token
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-red-600"
onClick={() => handleDelete(token.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Token
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Create Token Dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Generate API Token</DialogTitle>
<DialogDescription>Create a new API token for programmatic access.</DialogDescription>
</DialogHeader>
{!generatedToken ? (
<>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Token Name</Label>
<Input
placeholder="e.g. Production, CI/CD, Dev..."
value={newTokenName}
onChange={e => setNewTokenName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Expiry</Label>
<Select value={newTokenExpiry} onValueChange={setNewTokenExpiry}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="30">30 days</SelectItem>
<SelectItem value="90">90 days</SelectItem>
<SelectItem value="180">180 days</SelectItem>
<SelectItem value="365">1 year</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>Scopes</Label>
{allScopes.map(scope => (
<div key={scope} className="flex items-center gap-2">
<Switch
id={`scope-${scope}`}
checked={newTokenScopes.includes(scope)}
onCheckedChange={() => toggleScope(scope)}
/>
<Label htmlFor={`scope-${scope}`} className="font-normal capitalize">
{scope}
</Label>
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreate(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!newTokenName.trim()}>
Generate
</Button>
</DialogFooter>
</>
) : (
<div className="space-y-4 py-4">
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-xs text-muted-foreground mb-2">
Your new API token (copy it now, it won&apos;t be shown again):
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm font-mono break-all">{generatedToken}</code>
<Button
variant="ghost"
size="icon"
className="shrink-0"
title={copiedToken ? 'Copied!' : 'Copy token'}
onClick={() => {
navigator.clipboard.writeText(generatedToken);
setCopiedToken(true);
setTimeout(() => setCopiedToken(false), 2000);
}}
>
{copiedToken ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="text-sm text-amber-800">
<AlertTriangle className="inline mr-1 h-4 w-4" />
Store this token securely. You will not be able to see it again after closing this
dialog.
</p>
</div>
<DialogFooter>
<Button onClick={() => setShowCreate(false)}>Done</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,798 @@
'use client';
import { useState, useEffect } from 'react';
import { Zap, BarChart3, DollarSign, Cpu, X, User as UserIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
mockDailyMetrics,
mockUsers,
formatNumber,
formatCurrency,
type DailyMetric,
type User,
} from '@/lib/mock-data';
import {
apiGetUsage,
apiGetUsageSummary,
apiListUsers,
apiGetRetention,
type ApiUsageRecord,
type ApiModelBreakdown,
type ApiSourceBreakdown,
type ApiProductBreakdown,
type RetentionCohort,
} from '@/lib/api';
import { Separator } from '@/components/ui/separator';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
PieChart,
Pie,
Cell,
} from 'recharts';
const COLORS = [
'hsl(221, 83%, 53%)',
'hsl(142, 71%, 45%)',
'hsl(38, 92%, 50%)',
'hsl(280, 67%, 55%)',
];
function usageRecordsToDailyMetrics(records: ApiUsageRecord[]): DailyMetric[] {
const byDate = new Map<string, DailyMetric>();
for (const r of records) {
const existing = byDate.get(r.date);
if (existing) {
existing.totalTokens += r.tokensUsed;
existing.totalRequests += r.dictations;
existing.revenue += r.costUsd;
existing.activeUsers += 1;
} else {
byDate.set(r.date, {
date: r.date,
activeUsers: 1,
totalRequests: r.dictations,
totalTokens: r.tokensUsed,
revenue: r.costUsd,
});
}
}
return Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date));
}
export default function UsagePage() {
const [timeRange, setTimeRange] = useState('30d');
const [dailyMetrics, setDailyMetrics] = useState<DailyMetric[]>(mockDailyMetrics);
const [modelUsage, setModelUsage] = useState<(ApiModelBreakdown & { percentage: number })[]>([]);
const [sourceUsage, setSourceUsage] = useState<(ApiSourceBreakdown & { percentage: number })[]>([]);
const [productUsage, setProductUsage] = useState<(ApiProductBreakdown & { percentage: number })[]>([]);
const [users, setUsers] = useState<User[]>(mockUsers);
const [cohorts, setCohorts] = useState<RetentionCohort[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [productFilter, setProductFilter] = useState<string>('current');
useEffect(() => {
const days = timeRange === '7d' ? 7 : timeRange === '90d' ? 90 : 30;
const uid = selectedUserId || undefined;
const pid = productFilter === 'current' ? undefined : productFilter;
apiGetUsage(days, uid, pid).then(({ data }) => {
if (data?.records?.length) {
setDailyMetrics(usageRecordsToDailyMetrics(data.records));
} else {
setDailyMetrics([]);
}
});
apiGetUsageSummary(days, uid, pid).then(({ data }) => {
if (data?.modelBreakdown?.length) {
const totalCost = data.modelBreakdown.reduce((s, m) => s + m.cost, 0);
setModelUsage(
data.modelBreakdown.map(m => ({
...m,
percentage: totalCost > 0 ? Math.round((m.cost / totalCost) * 100) : 0,
}))
);
} else {
setModelUsage([]);
}
if (data?.sourceBreakdown?.length) {
const totalTokens = data.sourceBreakdown.reduce((s, m) => s + m.tokens, 0);
setSourceUsage(
data.sourceBreakdown.map(m => ({
...m,
percentage: totalTokens > 0 ? Math.round((m.tokens / totalTokens) * 100) : 0,
}))
);
} else {
setSourceUsage([]);
}
if (data?.productBreakdown?.length) {
const totalTokens = data.productBreakdown.reduce((s, m) => s + m.tokens, 0);
setProductUsage(
data.productBreakdown.map(m => ({
...m,
percentage: totalTokens > 0 ? Math.round((m.tokens / totalTokens) * 100) : 0,
}))
);
} else {
setProductUsage([]);
}
});
apiGetRetention(8).then(({ data }) => {
if (data?.cohorts) setCohorts(data.cohorts);
});
apiListUsers().then(({ data }) => {
if (data?.users?.length) {
setUsers(
data.users.map(u => ({
id: u.id,
name: u.name,
email: u.email,
plan: u.plan as User['plan'],
status: u.status as User['status'],
createdAt: u.createdAt,
lastActive: u.lastActive,
totalTokensUsed: u.totalTokensUsed,
totalRequests: u.totalRequests,
monthlySpend: u.monthlySpend,
}))
);
}
});
}, [timeRange, selectedUserId, productFilter]);
const totalTokens = dailyMetrics.reduce((s, d) => s + d.totalTokens, 0);
const totalRequests = dailyMetrics.reduce((s, d) => s + d.totalRequests, 0);
const totalRevenue = dailyMetrics.reduce((s, d) => s + d.revenue, 0);
const totalModelCost = modelUsage.reduce((s, m) => s + m.cost, 0);
const topUsers = [...users].sort((a, b) => b.totalTokensUsed - a.totalTokensUsed).slice(0, 5);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Usage Analytics</h1>
<p className="text-muted-foreground">
Monitor token consumption, API requests, and model usage
</p>
</div>
<div className="flex items-center gap-2">
<Select value={productFilter} onValueChange={setProductFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current">Current Product</SelectItem>
<SelectItem value="_all">All Products</SelectItem>
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
<SelectItem value="90d">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* User Filter Banner */}
{selectedUserId && (
<Card className="border-blue-500/50 bg-blue-50 dark:bg-blue-950/30">
<CardContent className="pt-4 pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UserIcon className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-200">
Viewing usage for: {users.find(u => u.id === selectedUserId)?.name || selectedUserId}
</span>
<span className="text-xs text-blue-600 dark:text-blue-400">
({users.find(u => u.id === selectedUserId)?.email})
</span>
</div>
<Button
size="sm"
variant="ghost"
className="text-blue-700 hover:text-blue-900"
onClick={() => setSelectedUserId(null)}
>
<X className="mr-1 h-3.5 w-3.5" />
Clear Filter
</Button>
</div>
</CardContent>
</Card>
)}
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Tokens
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-blue-500" />
<span className="text-2xl font-bold">{formatNumber(totalTokens)}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatNumber(Math.round(totalTokens / 30))}/day avg
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Requests
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-emerald-500" />
<span className="text-2xl font-bold">{formatNumber(totalRequests)}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatNumber(Math.round(totalRequests / 30))}/day avg
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Revenue
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-amber-500" />
<span className="text-2xl font-bold">{formatCurrency(totalRevenue)}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatCurrency(totalRevenue / 30)}/day avg
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Model Costs (Azure)
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-violet-500" />
<span className="text-2xl font-bold">{formatCurrency(totalModelCost)}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Margin: {formatCurrency(totalRevenue - totalModelCost)}
</p>
</CardContent>
</Card>
</div>
{/* Charts Row 1 */}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Token Consumption (30 days)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={dailyMetrics}>
<defs>
<linearGradient id="colorTokens" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="date" tickFormatter={v => v.slice(5)} className="text-xs" />
<YAxis className="text-xs" tickFormatter={v => formatNumber(v)} />
<Tooltip
formatter={value => formatNumber(Number(value))}
contentStyle={{
borderRadius: '8px',
border: '1px solid hsl(var(--border))',
}}
/>
<Area
type="monotone"
dataKey="totalTokens"
stroke="hsl(221, 83%, 53%)"
fill="url(#colorTokens)"
strokeWidth={2}
name="Tokens"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">API Requests (30 days)</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={dailyMetrics}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="date" tickFormatter={v => v.slice(5)} className="text-xs" />
<YAxis className="text-xs" tickFormatter={v => formatNumber(v)} />
<Tooltip
formatter={value => formatNumber(Number(value))}
contentStyle={{
borderRadius: '8px',
border: '1px solid hsl(var(--border))',
}}
/>
<Bar
dataKey="totalRequests"
fill="hsl(142, 71%, 45%)"
radius={[4, 4, 0, 0]}
name="Requests"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Charts Row 2: Pie + Top Users */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Model Distribution Pie */}
<Card>
<CardHeader>
<CardTitle className="text-base">Model Distribution by Cost</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={modelUsage}
cx="50%"
cy="50%"
innerRadius={70}
outerRadius={110}
paddingAngle={4}
dataKey="cost"
nameKey="model"
label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
>
{modelUsage.map((_, idx) => (
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={value => formatCurrency(Number(value))}
contentStyle={{
borderRadius: '8px',
border: '1px solid hsl(var(--border))',
}}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Top Users by Token Usage */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Top Users by Token Usage
<span className="text-xs font-normal text-muted-foreground ml-2">
(click to drill down)
</span>
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Rank</TableHead>
<TableHead>User</TableHead>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Requests</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topUsers.map((user, idx) => (
<TableRow
key={user.id}
className={`cursor-pointer hover:bg-muted/50 ${selectedUserId === user.id ? 'bg-blue-50 dark:bg-blue-950/30' : ''}`}
onClick={() => setSelectedUserId(selectedUserId === user.id ? null : user.id)}
>
<TableCell>
<Badge
variant="secondary"
className={
idx === 0
? 'bg-amber-50 text-amber-700'
: idx === 1
? 'bg-slate-100 text-slate-600'
: idx === 2
? 'bg-orange-50 text-orange-700'
: ''
}
>
#{idx + 1}
</Badge>
</TableCell>
<TableCell>
<div>
<p className="font-medium text-sm">{user.name}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</TableCell>
<TableCell>
<Badge
variant="secondary"
className={
user.plan === 'enterprise'
? 'bg-violet-50 text-violet-700'
: user.plan === 'pro'
? 'bg-blue-50 text-blue-700'
: ''
}
>
{user.plan}
</Badge>
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatNumber(user.totalTokensUsed)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatNumber(user.totalRequests)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
{/* Detailed Model Breakdown */}
<Card>
<CardHeader>
<CardTitle className="text-base">Model Usage Breakdown</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">% of Total</TableHead>
<TableHead className="text-right">Cost/1K Tokens</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modelUsage.map(model => (
<TableRow key={model.model}>
<TableCell className="font-medium">{model.model}</TableCell>
<TableCell className="text-right font-mono">
{formatNumber(model.tokens)}
</TableCell>
<TableCell className="text-right font-mono">
{formatNumber(model.requests)}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(model.cost)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-2 w-16 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${model.percentage}%` }}
/>
</div>
<span className="text-sm">{model.percentage}%</span>
</div>
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatCurrency((model.cost / model.tokens) * 1000)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Platform / Source Breakdown */}
{sourceUsage.length > 0 && (
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Usage by Platform</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={sourceUsage}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={4}
dataKey="tokens"
nameKey="source"
label={({ name, percent }) => `${name} (${((percent ?? 0) * 100).toFixed(0)}%)`}
>
{sourceUsage.map((_, idx) => (
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={value => formatNumber(Number(value))}
contentStyle={{
borderRadius: '8px',
border: '1px solid hsl(var(--border))',
}}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Platform Breakdown</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Platform</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">% of Tokens</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sourceUsage.map(src => (
<TableRow key={src.source}>
<TableCell className="font-medium capitalize">{src.source}</TableCell>
<TableCell className="text-right font-mono">
{formatNumber(src.tokens)}
</TableCell>
<TableCell className="text-right font-mono">
{formatNumber(src.requests)}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(src.cost)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-2 w-16 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${src.percentage}%` }}
/>
</div>
<span className="text-sm">{src.percentage}%</span>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)}
{/* Cross-Product Comparison */}
{productUsage.length > 1 && (
<>
<Separator />
<div>
<h2 className="text-xl font-bold tracking-tight mb-1">
Cross-Product Comparison
{selectedUserId && (
<span className="text-sm font-normal text-muted-foreground ml-2">
for {users.find(u => u.id === selectedUserId)?.name || selectedUserId}
</span>
)}
</h2>
<p className="text-sm text-muted-foreground mb-4">
Token usage, requests, and cost breakdown by product
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Tokens by Product</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={productUsage} layout="vertical">
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis type="number" tickFormatter={v => formatNumber(v)} className="text-xs" />
<YAxis type="category" dataKey="productId" width={100} className="text-xs" />
<Tooltip
formatter={value => formatNumber(Number(value))}
contentStyle={{
borderRadius: '8px',
border: '1px solid hsl(var(--border))',
}}
/>
<Bar dataKey="tokens" name="Tokens" radius={[0, 4, 4, 0]}>
{productUsage.map((_, idx) => (
<Cell key={idx} fill={COLORS[idx % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Product Breakdown</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">% of Tokens</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{productUsage.map((prod, idx) => (
<TableRow key={prod.productId}>
<TableCell>
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: COLORS[idx % COLORS.length] }}
/>
<span className="font-medium">{prod.productId}</span>
</div>
</TableCell>
<TableCell className="text-right font-mono">
{formatNumber(prod.tokens)}
</TableCell>
<TableCell className="text-right font-mono">
{formatNumber(prod.requests)}
</TableCell>
<TableCell className="text-right font-mono">
{formatCurrency(prod.cost)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-2 w-16 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${prod.percentage}%`,
backgroundColor: COLORS[idx % COLORS.length],
}}
/>
</div>
<span className="text-sm">{prod.percentage}%</span>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</>
)}
{/* Cohort Retention Analysis */}
<Separator />
<div>
<h2 className="text-xl font-bold tracking-tight mb-1">Cohort Retention</h2>
<p className="text-sm text-muted-foreground mb-4">
Weekly signup cohorts percentage of users active at 7, 14, and 30 days after signup
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Retention by Signup Week</CardTitle>
</CardHeader>
<CardContent>
{cohorts.length === 0 ? (
<p className="text-center text-muted-foreground py-8 text-sm">
No cohort data available yet. Users need to sign up and use the platform.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Cohort</TableHead>
<TableHead>Week Start</TableHead>
<TableHead className="text-right">Signups</TableHead>
<TableHead className="text-center">7-Day</TableHead>
<TableHead className="text-center">14-Day</TableHead>
<TableHead className="text-center">30-Day</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cohorts.map(c => (
<TableRow key={c.cohortWeek}>
<TableCell className="font-mono text-sm font-medium">{c.cohortWeek}</TableCell>
<TableCell className="text-sm text-muted-foreground">{c.cohortStart}</TableCell>
<TableCell className="text-right font-mono text-sm">{c.signups}</TableCell>
<TableCell className="text-center">
<RetentionCell rate={c.rate7d} count={c.retained7d} />
</TableCell>
<TableCell className="text-center">
<RetentionCell rate={c.rate14d} count={c.retained14d} />
</TableCell>
<TableCell className="text-center">
<RetentionCell rate={c.rate30d} count={c.retained30d} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function RetentionCell({ rate, count }: { rate: number; count: number }) {
if (rate < 0) {
return <span className="text-xs text-muted-foreground"></span>;
}
const bg =
rate >= 60
? 'bg-emerald-100 text-emerald-800'
: rate >= 40
? 'bg-emerald-50 text-emerald-700'
: rate >= 20
? 'bg-amber-50 text-amber-700'
: 'bg-red-50 text-red-700';
return (
<span
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium ${bg}`}
title={`${count} users retained`}
>
{rate}%
</span>
);
}

View File

@ -0,0 +1,384 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
ArrowLeft,
User,
Mail,
Calendar,
Activity,
Zap,
DollarSign,
Shield,
Clock,
BarChart3,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { apiGetUserView, type UserViewResponse, type ApiUsageRecord } from '@/lib/api';
import { formatNumber, formatCurrency, formatDate } from '@/lib/mock-data';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
const planColors: Record<string, string> = {
free: '',
pro: 'bg-blue-50 text-blue-700',
enterprise: 'bg-violet-50 text-violet-700',
};
const statusColors: Record<string, string> = {
active: 'bg-emerald-50 text-emerald-700',
inactive: 'bg-amber-50 text-amber-700',
suspended: 'bg-red-50 text-red-700',
};
interface DailyUsage {
date: string;
dictations: number;
words: number;
tokens: number;
cost: number;
}
function aggregateDaily(records: ApiUsageRecord[]): DailyUsage[] {
const byDate = new Map<string, DailyUsage>();
for (const r of records) {
const existing = byDate.get(r.date);
if (existing) {
existing.dictations += r.dictations;
existing.words += r.words;
existing.tokens += r.tokensUsed;
existing.cost += r.costUsd;
} else {
byDate.set(r.date, {
date: r.date,
dictations: r.dictations,
words: r.words,
tokens: r.tokensUsed,
cost: r.costUsd,
});
}
}
return Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date));
}
function LoadingSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid gap-4 md:grid-cols-4">
{[1, 2, 3, 4].map(i => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-[300px]" />
</div>
);
}
export default function UserDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [data, setData] = useState<UserViewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
apiGetUserView(id)
.then(({ data: viewData, error: err }) => {
if (err) setError(err);
else if (viewData) setData(viewData);
})
.finally(() => setLoading(false));
}, [id]);
if (loading) return <LoadingSkeleton />;
if (error || !data) {
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => router.push('/users')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Users
</Button>
<Card>
<CardContent className="pt-6 text-center text-muted-foreground">
{error || 'User not found'}
</CardContent>
</Card>
</div>
);
}
const { profile, usage } = data;
const records = usage?.records ?? [];
const dailyUsage = aggregateDaily(records);
const totalTokens = records.reduce((s, r) => s + r.tokensUsed, 0);
const totalWords = records.reduce((s, r) => s + r.words, 0);
const totalDictations = records.reduce((s, r) => s + r.dictations, 0);
const totalCost = records.reduce((s, r) => s + r.costUsd, 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.push('/users')}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{profile.name}</h1>
<Badge variant="secondary" className={planColors[profile.plan] || ''}>
{profile.plan}
</Badge>
<Badge variant="secondary" className={statusColors[profile.status] || ''}>
{profile.status}
</Badge>
</div>
<p className="text-muted-foreground text-sm">{profile.email}</p>
</div>
</div>
<Badge variant="outline" className="text-xs">
<Shield className="mr-1 h-3 w-3" />
Read-only view
</Badge>
</div>
{/* Impersonation Banner */}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 flex items-center gap-2">
<Shield className="h-4 w-4 shrink-0" />
<span>
You are viewing this user&apos;s data as <strong>{data.viewedBy.name}</strong> (
{data.viewedBy.role}). All data is read-only.
</span>
</div>
{/* Profile Info */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<User className="h-3.5 w-3.5" /> User ID
</CardTitle>
</CardHeader>
<CardContent>
<code className="text-xs font-mono">{profile.id}</code>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Mail className="h-3.5 w-3.5" /> Email
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm font-medium">{profile.email}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" /> Member Since
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm font-medium">{formatDate(profile.createdAt)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Clock className="h-3.5 w-3.5" /> Last Active
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm font-medium">{formatDate(profile.lastActive)}</p>
</CardContent>
</Card>
</div>
{/* Usage KPI Cards (30 days) */}
<div>
<h2 className="text-lg font-semibold mb-3">Usage (Last 30 Days)</h2>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Activity className="h-3.5 w-3.5" /> Dictations
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatNumber(totalDictations)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<BarChart3 className="h-3.5 w-3.5" /> Words
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatNumber(totalWords)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Zap className="h-3.5 w-3.5" /> Tokens Used
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatNumber(totalTokens)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<DollarSign className="h-3.5 w-3.5" /> Cost
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatCurrency(totalCost)}</p>
</CardContent>
</Card>
</div>
</div>
{/* Usage Chart */}
{dailyUsage.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Daily Dictation Activity</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={250}>
<AreaChart data={dailyUsage}>
<defs>
<linearGradient id="colorDict" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(221, 83%, 53%)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="date" tickFormatter={v => v.slice(5)} className="text-xs" />
<YAxis className="text-xs" />
<Tooltip
contentStyle={{
borderRadius: '8px',
border: '1px solid hsl(var(--border))',
}}
/>
<Area
type="monotone"
dataKey="dictations"
stroke="hsl(221, 83%, 53%)"
fill="url(#colorDict)"
strokeWidth={2}
name="Dictations"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* Recent Usage Records Table */}
{records.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Recent Usage Records ({records.length})</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-right">Dictations</TableHead>
<TableHead className="text-right">Words</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.slice(0, 20).map(r => (
<TableRow key={r.id}>
<TableCell className="text-sm">{r.date}</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatNumber(r.dictations)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatNumber(r.words)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatNumber(r.tokensUsed)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatCurrency(r.costUsd)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{records.length === 0 && (
<Card>
<CardContent className="pt-6 text-center text-muted-foreground">
<Activity className="mx-auto h-10 w-10 mb-3 opacity-30" />
<p className="text-sm">No usage data in the last 30 days</p>
</CardContent>
</Card>
)}
<Separator />
{/* Lifetime Stats */}
<div>
<h2 className="text-lg font-semibold mb-3">Lifetime Statistics</h2>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">Total Tokens Used</p>
<p className="text-xl font-bold font-mono">{formatNumber(profile.totalTokensUsed)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">Total Requests</p>
<p className="text-xl font-bold font-mono">{formatNumber(profile.totalRequests)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">Monthly Spend</p>
<p className="text-xl font-bold font-mono">{formatCurrency(profile.monthlySpend)}</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,612 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Search,
MoreHorizontal,
UserPlus,
Ban,
CheckCircle2,
XCircle,
Eye,
Trash2,
Copy,
Link,
Loader2,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { mockUsers, formatNumber, formatCurrency, formatDate, type User } from '@/lib/mock-data';
import { apiListUsers, apiUpdateUser, apiDeleteUser, apiCreateInvitation, type ApiUser } from '@/lib/api';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/toast';
function apiUserToUser(u: ApiUser): User {
return {
id: u.id,
name: u.name,
email: u.email,
plan: u.plan as User['plan'],
status: u.status as User['status'],
createdAt: u.createdAt,
lastActive: u.lastActive,
totalTokensUsed: u.totalTokensUsed,
totalRequests: u.totalRequests,
monthlySpend: u.monthlySpend,
};
}
const statusConfig = {
active: { label: 'Active', color: 'bg-emerald-50 text-emerald-700', icon: CheckCircle2 },
inactive: { label: 'Inactive', color: 'bg-amber-50 text-amber-700', icon: XCircle },
suspended: { label: 'Suspended', color: 'bg-red-50 text-red-700', icon: Ban },
};
const planConfig = {
free: { label: 'Free', color: '' },
pro: { label: 'Pro', color: 'bg-blue-50 text-blue-700' },
enterprise: { label: 'Enterprise', color: 'bg-violet-50 text-violet-700' },
};
export default function UsersPage() {
const router = useRouter();
const { toast } = useToast();
const [users, setUsers] = useState<User[]>(mockUsers);
const [, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [planFilter, setPlanFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkLoading, setBulkLoading] = useState(false);
// Invite dialog state
const [showInvite, setShowInvite] = useState(false);
const [invitePlan, setInvitePlan] = useState<string>('pro');
const [inviteDescription, setInviteDescription] = useState('');
const [inviteCreating, setInviteCreating] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [inviteCopied, setInviteCopied] = useState(false);
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (selectedIds.size === filtered.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filtered.map(u => u.id)));
}
};
const handleInvite = async () => {
setInviteCreating(true);
const { data, error } = await apiCreateInvitation({
description: inviteDescription || 'Invited from Users page',
grantPlan: invitePlan,
maxUses: 1,
});
setInviteCreating(false);
if (data) {
const userDashboardUrl = process.env.NEXT_PUBLIC_USER_DASHBOARD_URL || 'http://localhost:3002';
setInviteLink(`${userDashboardUrl}/login?ref=${encodeURIComponent(data.code)}`);
} else {
toast({ title: 'Failed to create invite', description: error || 'Unknown error', variant: 'error' });
}
};
const copyInviteLink = () => {
if (!inviteLink) return;
navigator.clipboard.writeText(inviteLink);
setInviteCopied(true);
setTimeout(() => setInviteCopied(false), 2000);
};
const handleChangePlan = async (userId: string, newPlan: string) => {
const { error } = await apiUpdateUser(userId, { plan: newPlan });
if (!error) {
setUsers(prev =>
prev.map(u => (u.id === userId ? { ...u, plan: newPlan as User['plan'] } : u))
);
toast({ title: `Plan changed to ${newPlan}`, variant: 'success' });
} else {
toast({ title: 'Failed to change plan', description: error, variant: 'error' });
}
};
const handleToggleSuspend = async (user: User) => {
const newStatus = user.status === 'suspended' ? 'active' : 'suspended';
const { error } = await apiUpdateUser(user.id, { status: newStatus });
if (!error) {
setUsers(prev =>
prev.map(u => (u.id === user.id ? { ...u, status: newStatus as User['status'] } : u))
);
toast({ title: `User ${newStatus === 'suspended' ? 'suspended' : 'activated'}`, variant: newStatus === 'suspended' ? 'warning' : 'success' });
} else {
toast({ title: 'Action failed', description: error, variant: 'error' });
}
};
const bulkAction = async (action: 'activate' | 'suspend' | 'delete') => {
if (selectedIds.size === 0) return;
setBulkLoading(true);
try {
for (const userId of selectedIds) {
if (action === 'delete') {
await apiDeleteUser(userId);
} else {
const status = action === 'activate' ? 'active' : 'suspended';
await apiUpdateUser(userId, { status });
}
}
// Update local state
setUsers(prev =>
action === 'delete'
? prev.filter(u => !selectedIds.has(u.id))
: prev.map(u =>
selectedIds.has(u.id)
? { ...u, status: action === 'activate' ? 'active' : 'suspended' }
: u
)
);
setSelectedIds(new Set());
const count = selectedIds.size;
toast({
title:
action === 'delete'
? `${count} user${count !== 1 ? 's' : ''} deleted`
: `${count} user${count !== 1 ? 's' : ''} ${action === 'activate' ? 'activated' : 'suspended'}`,
variant: action === 'delete' ? 'warning' : 'success',
});
} catch (e) {
console.error('Bulk action failed:', e);
toast({ title: 'Bulk action failed', description: String(e), variant: 'error' });
} finally {
setBulkLoading(false);
}
};
useEffect(() => {
apiListUsers()
.then(({ data }) => {
if (data?.users?.length) {
setUsers(data.users.map(apiUserToUser));
}
})
.finally(() => setLoading(false));
}, []);
const filtered = users.filter(u => {
const matchesSearch =
u.name.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase());
const matchesPlan = planFilter === 'all' || u.plan === planFilter;
const matchesStatus = statusFilter === 'all' || u.status === statusFilter;
return matchesSearch && matchesPlan && matchesStatus;
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">Manage platform users and their subscriptions</p>
</div>
<Button onClick={() => {
setShowInvite(true);
setInviteLink(null);
setInviteDescription('');
setInvitePlan('pro');
setInviteCopied(false);
}}>
<UserPlus className="mr-2 h-4 w-4" />
Invite User
</Button>
</div>
{/* Bulk Actions Bar */}
{selectedIds.size > 0 && (
<Card className="border-primary/50 bg-primary/5">
<CardContent className="pt-4 pb-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{selectedIds.size} user{selectedIds.size !== 1 ? 's' : ''} selected
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => bulkAction('activate')}
disabled={bulkLoading}
>
<CheckCircle2 className="mr-1 h-3.5 w-3.5 text-emerald-600" />
Activate
</Button>
<Button
size="sm"
variant="outline"
onClick={() => bulkAction('suspend')}
disabled={bulkLoading}
>
<Ban className="mr-1 h-3.5 w-3.5 text-amber-600" />
Suspend
</Button>
<Button
size="sm"
variant="outline"
className="text-red-600 hover:text-red-700"
onClick={() => {
if (confirm(`Delete ${selectedIds.size} user(s)? This cannot be undone.`)) {
bulkAction('delete');
}
}}
disabled={bulkLoading}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Delete
</Button>
<Button size="sm" variant="ghost" onClick={() => setSelectedIds(new Set())}>
Clear
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by name or email..."
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={planFilter} onValueChange={setPlanFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Plan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Plans</SelectItem>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="suspended">Suspended</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Users Table */}
<Card>
<CardHeader>
<CardTitle className="text-base">
{filtered.length} user{filtered.length !== 1 ? 's' : ''}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px]">
<input
type="checkbox"
className="rounded border-gray-300"
checked={filtered.length > 0 && selectedIds.size === filtered.length}
onChange={toggleSelectAll}
/>
</TableHead>
<TableHead>User</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Tokens Used</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Monthly Spend</TableHead>
<TableHead>Last Active</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map(user => {
const status = statusConfig[user.status];
const plan = planConfig[user.plan];
return (
<TableRow
key={user.id}
className={selectedIds.has(user.id) ? 'bg-primary/5' : ''}
>
<TableCell>
<input
type="checkbox"
className="rounded border-gray-300"
checked={selectedIds.has(user.id)}
onChange={() => toggleSelect(user.id)}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-bold">
{user.name
.split(' ')
.map(n => n[0])
.join('')}
</div>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className={plan.color}>
{plan.label}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary" className={status.color}>
<status.icon className="mr-1 h-3 w-3" />
{status.label}
</Badge>
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatNumber(user.totalTokensUsed)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatNumber(user.totalRequests)}
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatCurrency(user.monthlySpend)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(user.lastActive)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setSelectedUser(user)}>
<Eye className="mr-2 h-4 w-4" />
Quick View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/users/${user.id}`)}>
<Eye className="mr-2 h-4 w-4" />
View as User
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleChangePlan(user.id, user.plan === 'free' ? 'pro' : user.plan === 'pro' ? 'enterprise' : 'free')}
>
Cycle Plan ({user.plan} {user.plan === 'free' ? 'pro' : user.plan === 'pro' ? 'enterprise' : 'free'})
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onClick={() => handleToggleSuspend(user)}
>
<Ban className="mr-2 h-4 w-4" />
{user.status === 'suspended' ? 'Unsuspend' : 'Suspend'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
{/* User Detail Dialog */}
<Dialog open={!!selectedUser} onOpenChange={() => setSelectedUser(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{selectedUser?.name}</DialogTitle>
<DialogDescription>{selectedUser?.email}</DialogDescription>
</DialogHeader>
{selectedUser && (
<div className="space-y-4 pt-2">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Plan</p>
<Badge variant="secondary" className={planConfig[selectedUser.plan].color}>
{planConfig[selectedUser.plan].label}
</Badge>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Status</p>
<Badge variant="secondary" className={statusConfig[selectedUser.status].color}>
{statusConfig[selectedUser.status].label}
</Badge>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Member Since</p>
<p className="text-sm font-medium">{formatDate(selectedUser.createdAt)}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Last Active</p>
<p className="text-sm font-medium">{formatDate(selectedUser.lastActive)}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Total Tokens Used</p>
<p className="text-sm font-medium font-mono">
{formatNumber(selectedUser.totalTokensUsed)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Total Requests</p>
<p className="text-sm font-medium font-mono">
{formatNumber(selectedUser.totalRequests)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Monthly Spend</p>
<p className="text-sm font-medium font-mono">
{formatCurrency(selectedUser.monthlySpend)}
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">User ID</p>
<p className="text-sm font-mono text-muted-foreground">{selectedUser.id}</p>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Invite User Dialog */}
<Dialog open={showInvite} onOpenChange={setShowInvite}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogDescription>
Generate a single-use invite link to share with a new user.
</DialogDescription>
</DialogHeader>
{!inviteLink ? (
<>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Grant Plan</Label>
<Select value={invitePlan} onValueChange={setInvitePlan}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pro">Pro</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Description (optional)</Label>
<Input
placeholder="e.g. Invite for John Doe"
value={inviteDescription}
onChange={e => setInviteDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowInvite(false)}>
Cancel
</Button>
<Button onClick={handleInvite} disabled={inviteCreating}>
{inviteCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
<>
<Link className="mr-2 h-4 w-4" />
Generate Link
</>
)}
</Button>
</DialogFooter>
</>
) : (
<div className="space-y-4 py-2">
<div className="rounded-lg border bg-muted/50 p-4">
<p className="text-xs text-muted-foreground mb-2">
Share this link with the user to register:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm font-mono break-all select-all">
{inviteLink}
</code>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={copyInviteLink}
>
<Copy className="h-4 w-4" />
</Button>
</div>
{inviteCopied && (
<p className="text-xs text-emerald-600 mt-1">Copied to clipboard!</p>
)}
</div>
<p className="text-xs text-muted-foreground">
This is a single-use invite that grants <strong>{invitePlan}</strong> plan access.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setShowInvite(false)}>
Done
</Button>
<Button onClick={copyInviteLink}>
<Copy className="mr-2 h-4 w-4" />
{inviteCopied ? 'Copied!' : 'Copy Link'}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,145 @@
/**
* GET /api/analytics/retention Cohort retention analysis.
*
* Groups users by signup week, then checks how many were active at
* 7, 14, and 30 days after signup. Returns a table suitable for a
* retention heatmap.
*/
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { getCurrentUser } from '@/lib/auth-server';
import { getContainer } from '@/lib/cosmos';
import { PRODUCT_ID } from '@/lib/product-config';
interface CohortRow {
cohortWeek: string; // e.g. "2026-W05"
cohortStart: string; // ISO date of Monday
signups: number;
retained7d: number;
retained14d: number;
retained30d: number;
rate7d: number; // 0-100
rate14d: number;
rate30d: number;
}
function getWeekLabel(date: Date): string {
const d = new Date(date);
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
function getMondayOfWeek(date: Date): string {
const d = new Date(date);
const day = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() - day + 1);
return d.toISOString().slice(0, 10);
}
export async function GET(req: NextRequest) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(req.url);
const weeks = parseInt(url.searchParams.get('weeks') ?? '8', 10);
// Get users created in the last N weeks
const sinceDate = new Date(Date.now() - weeks * 7 * 86400000).toISOString().slice(0, 10);
const usersContainer = getContainer('users');
const { resources: users } = await usersContainer.items
.query<{ id: string; createdAt: string }>({
query:
'SELECT c.id, c.createdAt FROM c ' +
'WHERE c.productId = @pid AND c.createdAt >= @since ' +
'ORDER BY c.createdAt ASC',
parameters: [
{ name: '@pid', value: PRODUCT_ID },
{ name: '@since', value: sinceDate },
],
})
.fetchAll();
if (users.length === 0) {
return NextResponse.json({ cohorts: [], totalUsers: 0 });
}
// Group users by signup week
const cohortMap = new Map<string, { start: string; userIds: string[] }>();
for (const u of users) {
const d = new Date(u.createdAt);
const week = getWeekLabel(d);
const monday = getMondayOfWeek(d);
if (!cohortMap.has(week)) {
cohortMap.set(week, { start: monday, userIds: [] });
}
cohortMap.get(week)!.userIds.push(u.id);
}
// Get all usage records for these users
const usageContainer = getContainer('usage_daily');
const { resources: usageRecords } = await usageContainer.items
.query<{ userId: string; date: string }>({
query: 'SELECT c.userId, c.date FROM c ' + 'WHERE c.productId = @pid AND c.date >= @since',
parameters: [
{ name: '@pid', value: PRODUCT_ID },
{ name: '@since', value: sinceDate },
],
})
.fetchAll();
// Build a set of (userId, date) for quick lookup
const userActiveDates = new Map<string, Set<string>>();
for (const r of usageRecords) {
if (!userActiveDates.has(r.userId)) {
userActiveDates.set(r.userId, new Set());
}
userActiveDates.get(r.userId)!.add(r.date);
}
// Calculate retention for each cohort
const cohorts: CohortRow[] = [];
const now = Date.now();
for (const [week, { start, userIds }] of cohortMap) {
const cohortStart = new Date(start).getTime();
const signups = userIds.length;
let retained7d = 0;
let retained14d = 0;
let retained30d = 0;
for (const uid of userIds) {
const dates = userActiveDates.get(uid);
if (!dates) continue;
// Check if user was active in the window [start+Nd, start+Nd+7d)
const hasActivityInRange = (offsetDays: number) => {
const rangeStart = cohortStart + offsetDays * 86400000;
const rangeEnd = rangeStart + 7 * 86400000;
if (rangeStart > now) return false; // Window hasn't arrived yet
for (const d of dates) {
const ts = new Date(d).getTime();
if (ts >= rangeStart && ts < rangeEnd) return true;
}
return false;
};
if (hasActivityInRange(7)) retained7d++;
if (hasActivityInRange(14)) retained14d++;
if (hasActivityInRange(30)) retained30d++;
}
const day7elapsed = now - cohortStart >= 14 * 86400000;
const day14elapsed = now - cohortStart >= 21 * 86400000;
const day30elapsed = now - cohortStart >= 37 * 86400000;
cohorts.push({
cohortWeek: week,
cohortStart: start,
signups,
retained7d,
retained14d,
retained30d,
rate7d: day7elapsed ? Math.round((retained7d / signups) * 100) : -1,
rate14d: day14elapsed ? Math.round((retained14d / signups) * 100) : -1,
rate30d: day30elapsed ? Math.round((retained30d / signups) * 100) : -1,
});
}
return NextResponse.json({
cohorts,
totalUsers: users.length,
weeksAnalyzed: weeks,
});
} catch (error) {
logError('Retention analysis error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,170 @@
/**
* GET /api/analytics/revenue Revenue analytics for admin dashboard.
*
* Queries subscriptions and payments containers to compute:
* MRR, ARR, churn rate, LTV, ARPU, and monthly revenue breakdown.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { getContainer } from '@/lib/cosmos';
import { PRODUCT_ID } from '@/lib/product-config';
interface MonthlyRevenue {
month: string; // YYYY-MM
revenue: number;
subscriptions: number;
}
interface RevenueResponse {
mrr: number;
arr: number;
mrrChange: number; // percentage vs prior month
totalRevenue: number;
revenueByMonth: MonthlyRevenue[];
churnRate: number;
churnCount: number;
ltv: number;
arpu: number;
newSubscriptions: number;
canceledSubscriptions: number;
}
export async function GET(req: NextRequest) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(req.url);
const months = parseInt(url.searchParams.get('months') ?? '6', 10);
const now = new Date();
const sinceDate = new Date(now.getFullYear(), now.getMonth() - months, 1).toISOString();
// ---- Active subscriptions for MRR ----
const subsContainer = getContainer('subscriptions');
const { resources: activeSubs } = await subsContainer.items
.query<{ id: string; plan: string; price: number; status: string; createdAt: string }>({
query:
'SELECT c.id, c.plan, c.price, c.status, c.createdAt FROM c ' +
"WHERE c.productId = @pid AND c.status = 'active'",
parameters: [{ name: '@pid', value: PRODUCT_ID }],
})
.fetchAll();
// ---- Canceled subscriptions (last N months) ----
const { resources: canceledSubs } = await subsContainer.items
.query<{ id: string; canceledAt: string }>({
query:
'SELECT c.id, c.canceledAt FROM c ' +
"WHERE c.productId = @pid AND c.status = 'canceled' " +
'AND c.canceledAt >= @since',
parameters: [
{ name: '@pid', value: PRODUCT_ID },
{ name: '@since', value: sinceDate },
],
})
.fetchAll();
// ---- New subscriptions (last N months) ----
const { resources: newSubs } = await subsContainer.items
.query<{ id: string; createdAt: string }>({
query:
'SELECT c.id, c.createdAt FROM c ' + 'WHERE c.productId = @pid AND c.createdAt >= @since',
parameters: [
{ name: '@pid', value: PRODUCT_ID },
{ name: '@since', value: sinceDate },
],
})
.fetchAll();
// ---- Payments (last N months) ----
const paymentsContainer = getContainer('payments');
const { resources: payments } = await paymentsContainer.items
.query<{ amount: number; paidAt: string }>({
query:
'SELECT c.amount, c.paidAt FROM c ' +
"WHERE c.productId = @pid AND c.status = 'succeeded' " +
'AND c.paidAt >= @since',
parameters: [
{ name: '@pid', value: PRODUCT_ID },
{ name: '@since', value: sinceDate },
],
})
.fetchAll();
// ---- Compute metrics ----
const mrr = activeSubs.reduce((sum, s) => sum + (s.price ?? 0), 0);
const arr = mrr * 12;
const totalRevenue = payments.reduce((sum, p) => sum + (p.amount ?? 0), 0);
// Revenue by month
const monthBuckets: Record<string, { revenue: number; subscriptions: number }> = {};
for (let i = 0; i < months; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
monthBuckets[key] = { revenue: 0, subscriptions: 0 };
}
for (const p of payments) {
const key = (p.paidAt ?? '').slice(0, 7);
if (monthBuckets[key]) {
monthBuckets[key].revenue += p.amount ?? 0;
}
}
for (const s of newSubs) {
const key = (s.createdAt ?? '').slice(0, 7);
if (monthBuckets[key]) {
monthBuckets[key].subscriptions += 1;
}
}
const revenueByMonth: MonthlyRevenue[] = Object.entries(monthBuckets)
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, data]) => ({ month, ...data }));
// MRR change vs prior month
const currentMonth = revenueByMonth[revenueByMonth.length - 1];
const priorMonth = revenueByMonth[revenueByMonth.length - 2];
const mrrChange =
priorMonth && priorMonth.revenue > 0
? ((currentMonth.revenue - priorMonth.revenue) / priorMonth.revenue) * 100
: 0;
// Churn
const totalSubsStart = activeSubs.length + canceledSubs.length;
const churnRate = totalSubsStart > 0 ? (canceledSubs.length / totalSubsStart) * 100 : 0;
// LTV & ARPU
const activeCount = activeSubs.length || 1;
const arpu = mrr / activeCount;
const monthlyChurn = churnRate / 100 || 0.01; // avoid division by zero
const ltv = arpu / monthlyChurn;
const response: RevenueResponse = {
mrr: Math.round(mrr * 100) / 100,
arr: Math.round(arr * 100) / 100,
mrrChange: Math.round(mrrChange * 10) / 10,
totalRevenue: Math.round(totalRevenue * 100) / 100,
revenueByMonth,
churnRate: Math.round(churnRate * 10) / 10,
churnCount: canceledSubs.length,
ltv: Math.round(ltv * 100) / 100,
arpu: Math.round(arpu * 100) / 100,
newSubscriptions: newSubs.length,
canceledSubscriptions: canceledSubs.length,
};
return NextResponse.json(response);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json(
{ error: 'Failed to compute revenue analytics', detail: message },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { getCurrentUser } from '@/lib/auth-server';
import * as platformClient from '@/lib/platform-client';
export async function GET(req: NextRequest) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(req.url);
const category = url.searchParams.get('category') ?? undefined;
const limit = parseInt(url.searchParams.get('limit') ?? '100', 10);
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
const summary = url.searchParams.get('summary') === 'true';
if (summary) {
const { stats } = await platformClient.getAuditStats(90);
const total = Object.values(stats).reduce((s, n) => s + n, 0);
const failedLogins = stats['login_failed'] ?? 0;
return NextResponse.json({ total, failedLogins });
}
const { records, count } = await platformClient.queryAudit({ category, limit, offset });
return NextResponse.json({ entries: records, total: count });
} catch (error) {
logError('Audit error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { loginViaService, logAudit } from '@/lib/platform-client';
import { PRODUCT_ID } from '@/lib/product-config';
export async function POST(req: NextRequest) {
try {
const { email, password } = await req.json();
if (!email || !password) {
return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
}
const ip = req.headers.get('x-forwarded-for') ?? req.headers.get('x-real-ip') ?? '';
const userAgent = req.headers.get('user-agent') ?? '';
try {
const result = await loginViaService(email, password, PRODUCT_ID);
await logAudit({
userId: result.user.id,
action: 'login_success',
category: 'auth',
details: { ip, userAgent, email: result.user.email },
});
return NextResponse.json({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: {
id: result.user.id,
email: result.user.email,
name: result.user.displayName,
role: result.user.role,
plan: result.user.plan,
},
});
} catch {
await logAudit({
userId: email,
action: 'login_failed',
category: 'auth',
details: { ip, userAgent },
});
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
} catch (error) {
logError('Login error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { getMeViaService } from '@/lib/platform-client';
export async function GET(req: NextRequest) {
try {
const token = req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await getMeViaService(token);
return NextResponse.json({
id: user.id,
email: user.email,
name: user.displayName,
role: user.role,
plan: user.plan,
});
} catch (error) {
logError('Auth/me error', error);
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}

View File

@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { requireAdmin } from '@/lib/auth-server';
import { countActiveTokens } from '@/lib/repositories/tokens';
import * as billingClient from '@/lib/billing-client';
import * as platformClient from '@/lib/platform-client';
export async function GET(req: NextRequest) {
try {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const token = req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
const [countsResult, activeTokens, usageRes, auditStatsRes] = await Promise.all([
platformClient.getUserCounts(token),
countActiveTokens(),
billingClient.getUsageSummary(30),
platformClient.getAuditStats(90),
]);
const auditTotal = Object.values(auditStatsRes.stats).reduce((s, n) => s + n, 0);
const failedLogins = auditStatsRes.stats['login_failed'] ?? 0;
return NextResponse.json({
users: { total: countsResult.total, byPlan: countsResult.byPlan },
tokens: { active: activeTokens },
usage: {
totalWords: usageRes.totalWords ?? 0,
totalDictations: usageRes.totalDictations ?? 0,
totalCost: usageRes.totalCost ?? 0,
},
audit: { total: auditTotal, failedLogins },
});
} catch (error) {
logError('Dashboard stats error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,19 @@
/**
* GET /api/docs/[...slug] Read a specific doc by its slug path.
* Example: GET /api/docs/docs/STRIPE_SETUP_GUIDE
*/
import { NextRequest, NextResponse } from 'next/server';
import { readDoc } from '@/lib/docs';
export async function GET(_req: NextRequest, { params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params;
const slugPath = slug.join('/');
const result = readDoc(slugPath);
if (!result) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 });
}
return NextResponse.json(result);
}

View File

@ -0,0 +1,76 @@
/**
* POST /api/docs/chat RAG chatbot powered by Perplexity AI.
*
* Sends all project docs as context + user question to Perplexity,
* returns a streaming or complete response.
*
* Body: { question: string, history?: Array<{ role: string, content: string }> }
*/
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { getAllDocsContent } from '@/lib/docs';
const PERPLEXITY_API_KEY = process.env.PERPLEXITY_API_KEY;
const PERPLEXITY_URL = 'https://api.perplexity.ai/chat/completions';
const SYSTEM_PROMPT = `You are Platform Admin Assistant — an expert on the platform project.
You answer questions about the project's architecture, devops runbooks, deployment,
billing, Stripe setup, mobile apps, desktop app, microservices, testing, and any
other topic covered in the project documentation.
You have access to ALL project documentation below. Use it to give accurate,
specific answers. Reference document names when citing information.
If the docs don't contain the answer, say so clearly.
Be concise, use markdown formatting, and include code snippets when helpful.
--- PROJECT DOCUMENTATION ---
`;
export async function POST(req: NextRequest) {
if (!PERPLEXITY_API_KEY) {
return NextResponse.json({ error: 'PERPLEXITY_API_KEY not configured' }, { status: 500 });
}
const body = await req.json();
const { question, history = [] } = body;
if (!question || typeof question !== 'string') {
return NextResponse.json({ error: "Missing 'question' field" }, { status: 400 });
}
// Build context from all docs
const docsContext = getAllDocsContent(3000);
const systemMessage = SYSTEM_PROMPT + docsContext;
// Build messages array
const messages = [
{ role: 'system', content: systemMessage },
...history.slice(-6), // Keep last 6 messages for context window
{ role: 'user', content: question },
];
try {
const response = await fetch(PERPLEXITY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${PERPLEXITY_API_KEY}`,
},
body: JSON.stringify({
model: 'sonar',
messages,
max_tokens: 2048,
temperature: 0.2,
}),
});
if (!response.ok) {
const err = await response.text();
logError('Perplexity API error', err, { status: response.status });
return NextResponse.json(
{ error: `Perplexity API error: ${response.status}` },
{ status: 502 }
);
}
const data = await response.json();
const answer = data.choices?.[0]?.message?.content ?? 'No response generated.';
return NextResponse.json({
answer,
model: data.model,
usage: data.usage,
});
} catch (err) {
logError('Perplexity chat error', err);
return NextResponse.json({ error: 'Failed to reach Perplexity API' }, { status: 502 });
}
}

View File

@ -0,0 +1,28 @@
/**
* GET /api/docs List all project documentation files.
* GET /api/docs?q=search+term Full-text search across docs.
*/
import { NextRequest, NextResponse } from 'next/server';
import { listDocs, searchDocs } from '@/lib/docs';
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get('q');
if (q) {
const results = searchDocs(q);
return NextResponse.json({ results, query: q });
}
const docs = listDocs();
// Group by category
const categories: Record<string, typeof docs> = {};
for (const doc of docs) {
const cat = doc.category;
if (!categories[cat]) categories[cat] = [];
categories[cat].push(doc);
}
return NextResponse.json({ docs, categories });
}

View File

@ -0,0 +1,86 @@
/**
* Extraction API proxy forwards requests to extraction-service (port 4005).
*
* GET/POST /api/extraction/* extraction-service /api/*
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const EXTRACTION_SERVICE_URL =
process.env.EXTRACTION_SERVICE_URL || 'http://localhost:4005';
async function proxyToExtraction(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { path } = await params;
const targetPath = `/api/${path.join('/')}`;
const url = new URL(req.url);
const qs = url.search;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
'x-user-id': caller.id,
};
const fetchOptions: RequestInit = {
method: req.method,
headers,
};
if (req.method === 'POST' || req.method === 'PUT') {
fetchOptions.body = await req.text();
}
const res = await fetch(`${EXTRACTION_SERVICE_URL}${targetPath}${qs}`, fetchOptions);
const data = await res.json().catch(() => null);
return NextResponse.json(data ?? { error: res.statusText }, {
status: res.status,
});
} catch (error) {
logError('Extraction proxy error', error);
return NextResponse.json(
{ error: 'Extraction service unavailable' },
{ status: 502 },
);
}
}
export async function GET(
req: NextRequest,
context: { params: Promise<{ path: string[] }> },
) {
return proxyToExtraction(req, context);
}
export async function POST(
req: NextRequest,
context: { params: Promise<{ path: string[] }> },
) {
return proxyToExtraction(req, context);
}
export async function PUT(
req: NextRequest,
context: { params: Promise<{ path: string[] }> },
) {
return proxyToExtraction(req, context);
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ path: string[] }> },
) {
return proxyToExtraction(req, context);
}

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-server';
import { updateFlag, deleteFlag } from '@/lib/platform-client';
// [id] here is actually the flag key (e.g. "kill_switch", "enable_new_editor")
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id: key } = await params;
const body = await req.json();
const flag = await updateFlag(key, body);
return NextResponse.json(flag);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id: key } = await params;
await deleteFlag(key);
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-server';
import { listFlags, createFlag } from '@/lib/platform-client';
export async function GET(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const result = await listFlags();
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const body = await req.json();
const flag = await createFlag(body);
return NextResponse.json(flag, { status: 201 });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,61 @@
import { NextResponse } from 'next/server';
import { getDatabase } from '@/lib/cosmos';
interface Check {
name: string;
status: 'pass' | 'fail';
message?: string;
latencyMs?: number;
}
const REQUIRED_ENV = [
'COSMOS_ENDPOINT',
'COSMOS_KEY',
'COSMOS_DATABASE',
'JWT_SECRET',
'STRIPE_SECRET_KEY',
'SEED_SECRET',
];
function checkEnvVars(): Check {
const missing = REQUIRED_ENV.filter(key => !process.env[key]);
if (missing.length > 0) {
return { name: 'env', status: 'fail', message: `Missing: ${missing.join(', ')}` };
}
return { name: 'env', status: 'pass', message: `${REQUIRED_ENV.length} required vars set` };
}
async function checkCosmos(): Promise<Check> {
const start = Date.now();
try {
const db = getDatabase();
await db.read();
return { name: 'cosmos', status: 'pass', latencyMs: Date.now() - start };
} catch (err) {
return {
name: 'cosmos',
status: 'fail',
message: err instanceof Error ? err.message : String(err),
latencyMs: Date.now() - start,
};
}
}
export async function GET() {
const checks: Check[] = [];
checks.push(checkEnvVars());
checks.push(await checkCosmos());
const overall = checks.every(c => c.status === 'pass') ? 'ok' : 'degraded';
return NextResponse.json(
{
status: overall,
service: 'admin-dashboard',
timestamp: new Date().toISOString(),
checks,
},
{ status: overall === 'ok' ? 200 : 503 }
);
}

View File

@ -0,0 +1,54 @@
/**
* PATCH /api/invitations/[id] Update invitation code (disable/enable).
* DELETE /api/invitations/[id] Delete invitation code.
*/
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { getCurrentUser } from '@/lib/auth-server';
import * as growthClient from '@/lib/growth-client';
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller || !['super_admin', 'admin'].includes(caller.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
const body = await req.json();
const updates: Record<string, unknown> = {};
if (body.status && ['active', 'disabled'].includes(body.status)) {
updates.status = body.status;
}
if (typeof body.maxUses === 'number') {
updates.maxUses = Math.max(1, body.maxUses);
}
if (body.description !== undefined) {
updates.description = body.description;
}
if (body.expiresAt !== undefined) {
updates.expiresAt = body.expiresAt;
}
const updated = await growthClient.updateInvitation(id, updates);
if (!updated) {
return NextResponse.json({ error: 'Update failed' }, { status: 500 });
}
return NextResponse.json(updated);
} catch (error) {
logError('Update invitation error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller || caller.role !== 'super_admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await params;
await growthClient.deleteInvitation(id);
return NextResponse.json({ success: true });
} catch (error) {
logError('Delete invitation error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,32 @@
/**
* POST /api/invitations/bulk Bulk create invitation codes.
* Accepts a JSON array of invitation objects, proxies to Growth Service.
*/
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { getCurrentUser } from '@/lib/auth-server';
import * as growthClient from '@/lib/growth-client';
export async function POST(req: NextRequest) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller || !['super_admin', 'admin'].includes(caller.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json();
if (!Array.isArray(body)) {
return NextResponse.json({ error: 'Request body must be a JSON array' }, { status: 400 });
}
// Inject createdBy from the caller if not provided
const enriched = body.map((item: Record<string, unknown>) => ({
...item,
createdBy: item.createdBy || caller.id,
}));
const result = await growthClient.bulkCreateInvitations(enriched);
const status = result.failed > 0 ? 207 : 201;
return NextResponse.json(result, { status });
} catch (error) {
logError('Bulk invite error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,69 @@
/**
* GET /api/invitations List all invitation codes.
* POST /api/invitations Create a new invitation code.
*/
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { getCurrentUser } from '@/lib/auth-server';
import * as growthClient from '@/lib/growth-client';
import crypto from 'crypto';
export async function GET(req: NextRequest) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(req.url);
const limit = parseInt(url.searchParams.get('limit') ?? '100', 10);
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
const [listRes, countRes] = await Promise.all([
growthClient.listInvitations(limit, offset),
growthClient.countInvitations(),
]);
const codes = listRes.invitations;
const total = countRes.count;
return NextResponse.json({ codes, total });
} catch (error) {
logError('List invitations error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller || !['super_admin', 'admin'].includes(caller.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json();
const {
description = '',
grantPlan = 'pro',
grantTrialDays = 0,
bonusTokens = 0,
maxUses = 100,
expiresAt = null,
code: customCode,
} = body;
if (!['pro', 'enterprise'].includes(grantPlan)) {
return NextResponse.json({ error: 'grantPlan must be pro or enterprise' }, { status: 400 });
}
const code = customCode
? customCode.toUpperCase().replace(/[^A-Z0-9-]/g, '')
: `INVITE-${crypto.randomBytes(4).toString('hex').toUpperCase()}`;
const invitation = await growthClient.createInvitation({
code,
description,
createdBy: caller.id,
grantPlan,
grantTrialDays: Math.max(0, grantTrialDays),
bonusTokens: Math.max(0, bonusTokens),
maxUses: Math.max(1, maxUses),
expiresAt,
});
return NextResponse.json(invitation, { status: 201 });
} catch (error) {
logError('Create invitation error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-server';
import {
getUserLicenses,
generateLicense,
revokeLicense,
deactivateLicenseDevice,
} from '@/lib/billing-client';
export async function GET(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const userId = req.nextUrl.searchParams.get('userId');
if (!userId) return NextResponse.json({ error: 'userId is required' }, { status: 400 });
try {
const result = await getUserLicenses(userId);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const body = await req.json();
if (body.action === 'revoke' && body.key) {
const result = await revokeLicense(body.key);
return NextResponse.json(result);
}
if (body.action === 'deactivate' && body.key && body.deviceId) {
const result = await deactivateLicenseDevice(body.key, body.deviceId);
return NextResponse.json(result);
}
// Default: generate new license
const license = await generateLicense(body);
return NextResponse.json(license, { status: 201 });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-server';
import { listDevices, getNotificationPrefs } from '@/lib/platform-client';
export async function GET(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const userId = req.nextUrl.searchParams.get('userId');
if (!userId) return NextResponse.json({ error: 'userId is required' }, { status: 400 });
try {
const [devicesResult, prefs] = await Promise.all([
listDevices(userId),
getNotificationPrefs(userId),
]);
return NextResponse.json({
devices: devicesResult.devices ?? [],
prefs: prefs ?? { pushEnabled: true, emailEnabled: true, categories: {} },
});
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server';
import { DefaultAzureCredential } from '@azure/identity';
import { SecretClient } from '@azure/keyvault-secrets';
export const dynamic = 'force-dynamic';
function getSecretClient(): SecretClient {
const vaultUrl = process.env.AZURE_KEYVAULT_URL;
if (!vaultUrl) {
throw new Error('AZURE_KEYVAULT_URL is not configured');
}
return new SecretClient(vaultUrl, new DefaultAzureCredential());
}
/** GET /api/ops/secrets/[name] — read a specific secret value */
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
try {
const { name } = await params;
const client = getSecretClient();
const secret = await client.getSecret(name);
return NextResponse.json({
name: secret.name,
value: secret.value,
version: secret.properties.version,
enabled: secret.properties.enabled,
createdOn: secret.properties.createdOn?.toISOString(),
updatedOn: secret.properties.updatedOn?.toISOString(),
expiresOn: secret.properties.expiresOn?.toISOString() ?? null,
contentType: secret.properties.contentType ?? null,
tags: secret.properties.tags ?? {},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('NotFound') ? 404 : 500;
return NextResponse.json({ error: message }, { status });
}
}
/** DELETE /api/ops/secrets/[name] — soft-delete a secret */
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
try {
const { name } = await params;
const client = getSecretClient();
const poller = await client.beginDeleteSecret(name);
await poller.pollUntilDone();
return NextResponse.json({ deleted: true, name });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import { DefaultAzureCredential } from '@azure/identity';
import { SecretClient } from '@azure/keyvault-secrets';
export const dynamic = 'force-dynamic';
function getSecretClient(): SecretClient {
const vaultUrl = process.env.AZURE_KEYVAULT_URL;
if (!vaultUrl) {
throw new Error('AZURE_KEYVAULT_URL is not configured');
}
return new SecretClient(vaultUrl, new DefaultAzureCredential());
}
/** GET /api/ops/secrets — list all secrets with metadata */
export async function GET() {
try {
const client = getSecretClient();
const secrets: Array<{
name: string;
enabled: boolean;
createdOn: string | null;
updatedOn: string | null;
expiresOn: string | null;
contentType: string | null;
tags: Record<string, string>;
}> = [];
for await (const properties of client.listPropertiesOfSecrets()) {
secrets.push({
name: properties.name,
enabled: properties.enabled ?? true,
createdOn: properties.createdOn?.toISOString() ?? null,
updatedOn: properties.updatedOn?.toISOString() ?? null,
expiresOn: properties.expiresOn?.toISOString() ?? null,
contentType: properties.contentType ?? null,
tags: properties.tags ?? {},
});
}
// Sort alphabetically
secrets.sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json({
vaultUrl: process.env.AZURE_KEYVAULT_URL,
count: secrets.length,
secrets,
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}
/** POST /api/ops/secrets — set or update a secret */
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { name, value, contentType, expiresOn, tags } = body as {
name: string;
value: string;
contentType?: string;
expiresOn?: string;
tags?: Record<string, string>;
};
if (!name || !value) {
return NextResponse.json(
{ error: 'name and value are required' },
{ status: 400 },
);
}
const client = getSecretClient();
const result = await client.setSecret(name, value, {
contentType,
expiresOn: expiresOn ? new Date(expiresOn) : undefined,
tags,
});
return NextResponse.json({
name: result.name,
createdOn: result.properties.createdOn?.toISOString(),
updatedOn: result.properties.updatedOn?.toISOString(),
version: result.properties.version,
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,119 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic'; // No caching
interface ServiceCheck {
id: string;
name: string;
url: string;
status: 'healthy' | 'degraded' | 'down' | 'maintenance';
latency: number;
version?: string;
uptime?: number;
message?: string;
lastChecked: string;
}
interface OpsStatus {
overall: 'healthy' | 'degraded' | 'critical';
timestamp: string;
services: ServiceCheck[];
}
const SERVICES = [
{
id: 'backend',
name: 'Backend API',
env: 'API_BASE_URL',
default: 'http://localhost:8000',
path: '/health',
},
{
id: 'platform',
name: 'Platform Service',
env: 'PLATFORM_SERVICE_URL',
default: 'http://localhost:4003',
path: '/health',
},
{
id: 'extraction',
name: 'Extraction Service',
env: 'EXTRACTION_SERVICE_URL',
default: 'http://localhost:4005',
path: '/health',
},
];
export async function GET() {
const checks = await Promise.all(
SERVICES.map(async svc => {
const baseUrl = process.env[svc.env] || svc.default;
const url = `${baseUrl}${svc.path}`;
const start = Date.now();
try {
const res = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
next: { revalidate: 0 },
signal: AbortSignal.timeout(3000), // 3s timeout
});
const latency = Date.now() - start;
if (!res.ok) {
return {
id: svc.id,
name: svc.name,
url,
status: 'down',
latency,
message: `HTTP ${res.status}`,
lastChecked: new Date().toISOString(),
} as ServiceCheck;
}
const json = await res.json();
// Assuming standard health response: { status: "ok", version: "0.1.0" }
// Fastify services return { status: "ok" }
const isOk = json.status === 'ok';
return {
id: svc.id,
name: svc.name,
url,
status: isOk ? 'healthy' : 'degraded',
latency,
version: json.version,
message: isOk ? undefined : JSON.stringify(json),
lastChecked: new Date().toISOString(),
} as ServiceCheck;
} catch (err) {
return {
id: svc.id,
name: svc.name,
url,
status: 'down',
latency: Date.now() - start,
message: err instanceof Error ? err.message : String(err),
lastChecked: new Date().toISOString(),
} as ServiceCheck;
}
})
);
const downCount = checks.filter(c => c.status === 'down').length;
const degradedCount = checks.filter(c => c.status === 'degraded').length;
let overall: OpsStatus['overall'] = 'healthy';
if (downCount > 0) overall = 'critical';
else if (degradedCount > 0) overall = 'degraded';
const response: OpsStatus = {
overall,
timestamp: new Date().toISOString(),
services: checks,
};
return NextResponse.json(response);
}

View File

@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-server';
import { onboardProduct } from '@/lib/platform-client';
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id } = await params;
const result = await onboardProduct(id);
return NextResponse.json(result, { status: 201 });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-server';
import { updateProduct } from '@/lib/platform-client';
type RouteContext = { params: Promise<{ id: string }> };
export async function PUT(req: NextRequest, { params }: RouteContext) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id } = await params;
const body = await req.json();
const product = await updateProduct(id, body);
return NextResponse.json(product);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
export async function DELETE(req: NextRequest, { params }: RouteContext) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id } = await params;
const product = await updateProduct(id, { status: 'disabled' });
return NextResponse.json(product);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-server';
import { listProducts, createProduct } from '@/lib/platform-client';
export async function GET(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const result = await listProducts();
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const body = await req.json();
const product = await createProduct(body);
return NextResponse.json(product, { status: 201 });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,52 @@
/**
* Promo by ID proxies to platform-service promo endpoints.
*
* DELETE /api/promos/:id deactivate promo (Stripe promos cannot be deleted, only deactivated)
* PATCH /api/promos/:id deactivate promo (toggle off)
*/
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth-server';
import { deactivatePromo } from '@/lib/growth-client';
import { logError } from '@/lib/logger';
type RouteContext = { params: Promise<{ id: string }> };
export async function DELETE(req: NextRequest, ctx: RouteContext) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller || !['super_admin', 'admin'].includes(caller.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await ctx.params;
await deactivatePromo(id);
return NextResponse.json({ success: true });
} catch (error) {
logError('Promo delete error', error);
return NextResponse.json({ error: 'Failed to deactivate promo' }, { status: 500 });
}
}
export async function PATCH(req: NextRequest, ctx: RouteContext) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller || !['super_admin', 'admin'].includes(caller.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { id } = await ctx.params;
const body = await req.json();
// Only deactivation is supported by Stripe — active: false
if (body.active === false) {
const result = await deactivatePromo(id);
return NextResponse.json(result);
}
// Stripe promotion codes cannot be re-activated once deactivated
return NextResponse.json(
{ error: 'Stripe promotion codes cannot be re-activated once deactivated' },
{ status: 400 }
);
} catch (error) {
logError('Promo update error', error);
return NextResponse.json({ error: 'Failed to update promo' }, { status: 500 });
}
}

View File

@ -0,0 +1,71 @@
/**
* GET /api/promos List Stripe promotion codes.
* POST /api/promos Create a Stripe coupon + promotion code.
*
* Uses Stripe's native Coupon + Promotion Code APIs.
* No local DB needed Stripe is the source of truth.
*/
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { getCurrentUser } from '@/lib/auth-server';
import * as growthClient from '@/lib/growth-client';
export async function GET(req: NextRequest) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(req.url);
const active = url.searchParams.get('active');
const result = await growthClient.listPromos(active !== null ? active === 'true' : undefined);
return NextResponse.json(result);
} catch (error) {
logError('List promos error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller || !['super_admin', 'admin'].includes(caller.role)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json();
const {
code,
percentOff,
amountOff,
currency = 'usd',
duration = 'once',
durationInMonths,
maxRedemptions,
expiresAt,
} = body;
if (!code) {
return NextResponse.json({ error: 'code is required' }, { status: 400 });
}
if (!percentOff && !amountOff) {
return NextResponse.json(
{ error: 'Either percentOff or amountOff is required' },
{ status: 400 }
);
}
const created = await growthClient.createPromo({
code,
percentOff,
amountOff,
currency,
duration,
durationInMonths,
maxRedemptions,
expiresAt,
createdBy: caller.id,
});
return NextResponse.json(created, { status: 201 });
} catch (error) {
logError('Create promo error', error);
const msg = error instanceof Error ? error.message : 'Internal server error';
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@ -0,0 +1,32 @@
/**
* GET /api/referrals List all referrals (admin view).
*/
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { getCurrentUser } from '@/lib/auth-server';
import * as growthClient from '@/lib/growth-client';
export async function GET(req: NextRequest) {
try {
const caller = await getCurrentUser(req.headers.get('authorization'));
if (!caller) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(req.url);
const limit = parseInt(url.searchParams.get('limit') ?? '100', 10);
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
const mode = url.searchParams.get('mode');
if (mode === 'summary') {
const stats = await growthClient.getReferralStats();
return NextResponse.json(stats);
}
const [listRes, stats] = await Promise.all([
growthClient.listReferrals(limit, offset),
growthClient.getReferralStats(),
]);
return NextResponse.json({ referrals: listRes.referrals, stats });
} catch (error) {
logError('List referrals error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,77 @@
/**
* Seed endpoint creates containers and initial admin user.
* POST /api/seed?secret=<SEED_SECRET>
*
* Only works when SEED_SECRET env var matches the query param.
* Disable in production by not setting SEED_SECRET.
*/
import { NextRequest, NextResponse } from 'next/server';
import { logError } from '@/lib/logger';
import { initializeAllContainers } from '@/lib/cosmos';
import { hashPassword } from '@/lib/auth-server';
import { getUserByEmail, createUser } from '@/lib/repositories/users';
import { PRODUCT_ID } from '@/lib/product-config';
export async function POST(req: NextRequest) {
try {
const seedSecret = process.env.SEED_SECRET;
if (!seedSecret) {
return NextResponse.json({ error: 'Seeding is disabled' }, { status: 403 });
}
const url = new URL(req.url);
if (url.searchParams.get('secret') !== seedSecret) {
return NextResponse.json({ error: 'Invalid seed secret' }, { status: 403 });
}
// 1. Create all Cosmos DB containers
await initializeAllContainers();
// 2. Create default admin user if not exists
const adminEmail = 'admin@example.com';
const existing = await getUserByEmail(adminEmail);
if (!existing) {
const now = new Date().toISOString();
await createUser({
id: `usr_admin_${Date.now()}`,
productId: PRODUCT_ID,
email: adminEmail,
name: 'Admin User',
passwordHash: await hashPassword('admin123'),
role: 'super_admin',
plan: 'enterprise',
status: 'active',
createdAt: now,
lastActive: now,
totalTokensUsed: 0,
totalRequests: 0,
monthlySpend: 0,
});
}
// 3. Create viewer user if not exists
const viewerEmail = 'viewer@example.com';
const existingViewer = await getUserByEmail(viewerEmail);
if (!existingViewer) {
const now = new Date().toISOString();
await createUser({
id: `usr_viewer_${Date.now()}`,
productId: PRODUCT_ID,
email: viewerEmail,
name: 'Viewer User',
passwordHash: await hashPassword('viewer123'),
role: 'viewer',
plan: 'free',
status: 'active',
createdAt: now,
lastActive: now,
totalTokensUsed: 0,
totalRequests: 0,
monthlySpend: 0,
});
}
return NextResponse.json({
success: true,
message: 'Containers initialized and default users seeded',
});
} catch (error) {
logError('Seed error', error);
return NextResponse.json({ error: 'Seed failed', details: String(error) }, { status: 500 });
}
}

View File

@ -0,0 +1,121 @@
/**
* Kill Switch API proxies to platform-service feature flags module.
*
* GET /api/settings/kill-switch { enabled, platforms, reason, updatedAt, updatedBy }
* PUT /api/settings/kill-switch body: { enabled?, platforms?, reason? }
*
* Maps the kill_switch feature flag to the legacy per-platform format.
* The flag's `enabled` field = master switch, `platforms` array = which platforms are affected,
* and `description` field stores the reason.
*/
import { NextRequest, NextResponse } from 'next/server';
import { listFlags, createFlag, updateFlag } from '@/lib/platform-client';
const FLAG_KEY = 'kill_switch';
const ALL_PLATFORMS = ['desktop', 'ios', 'android', 'web'];
interface PlatformFlags {
desktop: boolean;
ios: boolean;
android: boolean;
web: boolean;
}
function flagToPlatforms(flagPlatforms: string[]): PlatformFlags {
if (flagPlatforms.length === 0) {
return { desktop: true, ios: true, android: true, web: true };
}
return {
desktop: flagPlatforms.includes('desktop'),
ios: flagPlatforms.includes('ios'),
android: flagPlatforms.includes('android'),
web: flagPlatforms.includes('web'),
};
}
function platformsToArray(platforms: PlatformFlags): string[] {
const arr = ALL_PLATFORMS.filter(p => platforms[p as keyof PlatformFlags]);
return arr.length === ALL_PLATFORMS.length ? [] : arr;
}
export async function GET() {
try {
const result = await listFlags();
const flag = (result.flags ?? []).find(f => f.key === FLAG_KEY);
if (!flag) {
return NextResponse.json({
enabled: false,
platforms: { desktop: true, ios: true, android: true, web: true },
reason: '',
updatedAt: new Date().toISOString(),
updatedBy: 'system',
});
}
return NextResponse.json({
enabled: flag.enabled,
platforms: flagToPlatforms(flag.platforms ?? []),
reason: flag.description ?? '',
updatedAt: flag.updatedAt,
updatedBy: 'admin',
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to read kill switch', details: String(error) },
{ status: 500 }
);
}
}
export async function PUT(req: NextRequest) {
try {
const body = await req.json();
const enabled = typeof body.enabled === 'boolean' ? body.enabled : false;
const reason = typeof body.reason === 'string' ? body.reason : '';
const platforms: PlatformFlags = body.platforms ?? {
desktop: true, ios: true, android: true, web: true,
};
const result = await listFlags();
const existing = (result.flags ?? []).find(f => f.key === FLAG_KEY);
if (existing) {
const updated = await updateFlag(existing.id, {
enabled,
description: reason,
platforms: platformsToArray(platforms),
});
return NextResponse.json({
enabled: updated.enabled,
platforms: flagToPlatforms(updated.platforms ?? []),
reason: updated.description ?? '',
updatedAt: updated.updatedAt,
updatedBy: body.updatedBy ?? 'admin',
});
}
const created = await createFlag({
key: FLAG_KEY,
enabled,
description: reason,
platforms: platformsToArray(platforms),
segments: [],
percentage: 100,
});
return NextResponse.json({
enabled: created.enabled,
platforms: flagToPlatforms(created.platforms ?? []),
reason: created.description ?? '',
updatedAt: created.updatedAt,
updatedBy: body.updatedBy ?? 'admin',
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to update kill switch', details: String(error) },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,24 @@
/**
* Plans API proxies to platform-service /plans endpoint.
*
* GET /api/settings/plans list plans
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth-server';
import { listPlans } from '@/lib/platform-client';
import { PRODUCT_ID } from '@/lib/product-config';
import { logError } from '@/lib/logger';
export async function GET(req: NextRequest) {
try {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const result = await listPlans(PRODUCT_ID);
return NextResponse.json(result);
} catch (error) {
logError('Plans list error', error);
return NextResponse.json({ error: 'Failed to list plans' }, { status: 500 });
}
}

View File

@ -0,0 +1,133 @@
/**
* Platform Settings API persists admin-configurable settings to Cosmos DB.
*
* GET /api/settings/platform current settings
* PUT /api/settings/platform update settings
*
* Stored in the `settings` container with id=platform_config, userId=_system.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getContainer } from '@/lib/cosmos';
import { requireAdmin } from '@/lib/auth-server';
import { logError } from '@/lib/logger';
const SETTINGS_ID = 'platform_config';
const PARTITION_KEY = '_system';
interface PlatformSettings {
id: string;
userId: string;
platformName: string;
supportEmail: string;
defaultLanguage: string;
maintenanceMode: boolean;
allowSelfRegistration: boolean;
rateLimits: {
globalPerMin: number;
perUserPerMin: number;
maxTokenBurst: number;
abuseThreshold: number;
autoSuspendOnAbuse: boolean;
ipBlocklist: boolean;
};
notifications: {
newUserSignup: boolean;
usageThreshold: boolean;
failedPayment: boolean;
securityAlerts: boolean;
};
dataRetentionDays: number;
backupFrequency: string;
auditLogging: boolean;
updatedAt: string;
updatedBy: string;
}
const DEFAULTS: PlatformSettings = {
id: SETTINGS_ID,
userId: PARTITION_KEY,
platformName: '',
supportEmail: '',
defaultLanguage: 'en-US',
maintenanceMode: false,
allowSelfRegistration: true,
rateLimits: {
globalPerMin: 1000,
perUserPerMin: 60,
maxTokenBurst: 4096,
abuseThreshold: 50,
autoSuspendOnAbuse: true,
ipBlocklist: true,
},
notifications: {
newUserSignup: true,
usageThreshold: true,
failedPayment: true,
securityAlerts: true,
},
dataRetentionDays: 365,
backupFrequency: 'daily',
auditLogging: true,
updatedAt: new Date().toISOString(),
updatedBy: 'system',
};
export async function GET(req: NextRequest) {
try {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const container = getContainer('settings');
try {
const { resource } = await container.item(SETTINGS_ID, PARTITION_KEY).read<PlatformSettings>();
if (resource) return NextResponse.json(resource);
} catch {
// Not found — return defaults
}
return NextResponse.json(DEFAULTS);
} catch (error) {
logError('Platform settings GET error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT(req: NextRequest) {
try {
const admin = await requireAdmin(req);
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = await req.json();
const container = getContainer('settings');
let existing: PlatformSettings = DEFAULTS;
try {
const { resource } = await container.item(SETTINGS_ID, PARTITION_KEY).read<PlatformSettings>();
if (resource) existing = resource;
} catch {
// Use defaults
}
const updated: PlatformSettings = {
...existing,
platformName: body.platformName ?? existing.platformName,
supportEmail: body.supportEmail ?? existing.supportEmail,
defaultLanguage: body.defaultLanguage ?? existing.defaultLanguage,
maintenanceMode: body.maintenanceMode ?? existing.maintenanceMode,
allowSelfRegistration: body.allowSelfRegistration ?? existing.allowSelfRegistration,
rateLimits: { ...existing.rateLimits, ...(body.rateLimits ?? {}) },
notifications: { ...existing.notifications, ...(body.notifications ?? {}) },
dataRetentionDays: body.dataRetentionDays ?? existing.dataRetentionDays,
backupFrequency: body.backupFrequency ?? existing.backupFrequency,
auditLogging: body.auditLogging ?? existing.auditLogging,
updatedAt: new Date().toISOString(),
updatedBy: admin.email ?? 'admin',
};
await container.items.upsert(updated);
return NextResponse.json(updated);
} catch (error) {
logError('Platform settings PUT error', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,34 @@
/**
* GET /api/stripe/config Returns Stripe configuration for admin dashboard.
* Includes mode, configured status, product/price IDs, and account info.
* Safe to expose no secret keys included.
*/
import { NextResponse } from 'next/server';
function getStripeMode(): 'test' | 'live' | 'dev' {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) return 'dev';
if (key.startsWith('sk_test_')) return 'test';
if (key.startsWith('sk_live_')) return 'live';
return 'dev';
}
export async function GET() {
const mode = getStripeMode();
const key = process.env.STRIPE_SECRET_KEY || '';
return NextResponse.json({
mode,
configured: !!key && !key.includes('placeholder'),
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY
? `${process.env.STRIPE_PUBLISHABLE_KEY.slice(0, 12)}...`
: null,
priceIds: {
pro: process.env.STRIPE_PRICE_PRO || null,
enterprise: process.env.STRIPE_PRICE_ENTERPRISE || null,
},
webhookConfigured: !!process.env.STRIPE_WEBHOOK_SECRET,
billingServiceUrl: process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003',
});
}

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
const PLATFORM_SERVICE_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
const PRODUCT_ID = process.env.PRODUCT_ID || 'unknown';
/**
* Proxy admin self-telemetry events from the browser to platform-service.
* Separate from /api/telemetry which is the admin query route for viewing client logs.
* Accepts sendBeacon POST requests from the client-side telemetry module.
*/
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const res = await fetch(`${PLATFORM_SERVICE_URL}/api/telemetry/events`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Product-Id': PRODUCT_ID,
},
body,
});
if (res.ok) {
return NextResponse.json({ ok: true });
}
return NextResponse.json({ ok: false }, { status: res.status });
} catch {
return NextResponse.json({ ok: false }, { status: 502 });
}
}

View File

@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { updateClusterStatus } from '@/lib/platform-client';
function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (match) return match[1];
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id } = await params;
const { searchParams } = new URL(req.url);
const pk = searchParams.get('pk') ?? '';
const { status } = await req.json();
const result = await updateClusterStatus(jwt, id, pk, status);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { eraseTelemetryUser } from '@/lib/platform-client';
function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (match) return match[1];
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
}
/**
* GDPR erasure delete all telemetry events for a given userId.
* POST body: { userId: string }
*/
export async function POST(req: NextRequest) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { userId } = await req.json();
if (!userId || typeof userId !== 'string') {
return NextResponse.json({ error: 'userId required' }, { status: 400 });
}
const result = await eraseTelemetryUser(jwt, userId);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { getTelemetryGeoDistribution } from '@/lib/platform-client';
function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (match) return match[1];
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
}
export async function GET(req: NextRequest) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { searchParams } = new URL(req.url);
const from = searchParams.get('from') ?? undefined;
const to = searchParams.get('to') ?? undefined;
const result = await getTelemetryGeoDistribution(jwt, from, to);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { getTelemetryMetrics } from '@/lib/platform-client';
function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (match) return match[1];
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
}
export async function GET(req: NextRequest) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const result = await getTelemetryMetrics(jwt);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server';
import {
updateTelemetryPolicy,
deleteTelemetryPolicy,
} from '@/lib/platform-client';
function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (match) return match[1];
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
}
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id } = await params;
const body = await req.json();
const result = await updateTelemetryPolicy(jwt, id, body);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { id } = await params;
const result = await deleteTelemetryPolicy(jwt, id);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server';
import { previewTelemetryPolicy } from '@/lib/platform-client';
function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (match) return match[1];
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
}
export async function POST(req: NextRequest) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const { targeting } = await req.json();
const result = await previewTelemetryPolicy(jwt, targeting || {});
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import {
listTelemetryPolicies,
createTelemetryPolicy,
} from '@/lib/platform-client';
function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (match) return match[1];
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
}
export async function GET(req: NextRequest) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const result = await listTelemetryPolicies(jwt);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const body = await req.json();
const result = await createTelemetryPolicy(jwt, body);
return NextResponse.json(result, { status: 201 });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { queryTelemetryEvents, queryTelemetryClusters } from '@/lib/platform-client';
function getJwt(req: NextRequest): string {
const cookie = req.headers.get('cookie') ?? '';
const match = cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (match) return match[1];
return req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
}
export async function GET(req: NextRequest) {
const jwt = getJwt(req);
if (!jwt) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { searchParams } = new URL(req.url);
const view = searchParams.get('view') || 'events';
try {
const filters: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
if (key !== 'view' && value) filters[key] = value;
}
if (view === 'clusters') {
const result = await queryTelemetryClusters(jwt, filters);
return NextResponse.json(result);
}
const result = await queryTelemetryEvents(jwt, filters);
return NextResponse.json(result);
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

Some files were not shown because too many files have changed in this diff Show More