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:
parent
9a5e93bf05
commit
2d54795c30
29
dashboards/admin-web/.bundlesizerc.json
Normal file
29
dashboards/admin-web/.bundlesizerc.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
4
dashboards/admin-web/.dockerignore
Normal file
4
dashboards/admin-web/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env.local
|
||||||
|
.git
|
||||||
40
dashboards/admin-web/.env.example
Normal file
40
dashboards/admin-web/.env.example
Normal 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=
|
||||||
44
dashboards/admin-web/.env.local.example
Normal file
44
dashboards/admin-web/.env.local.example
Normal 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
42
dashboards/admin-web/.gitignore
vendored
Normal 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/
|
||||||
1
dashboards/admin-web/.nvmrc
Normal file
1
dashboards/admin-web/.nvmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
20
|
||||||
40
dashboards/admin-web/Dockerfile
Normal file
40
dashboards/admin-web/Dockerfile
Normal 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"]
|
||||||
206
dashboards/admin-web/README.md
Normal file
206
dashboards/admin-web/README.md
Normal 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
|
||||||
23
dashboards/admin-web/components.json
Normal file
23
dashboards/admin-web/components.json
Normal 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": {}
|
||||||
|
}
|
||||||
45
dashboards/admin-web/e2e/login.spec.ts
Normal file
45
dashboards/admin-web/e2e/login.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
dashboards/admin-web/e2e/navigation.spec.ts
Normal file
20
dashboards/admin-web/e2e/navigation.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
dashboards/admin-web/eslint.config.mjs
Normal file
24
dashboards/admin-web/eslint.config.mjs
Normal 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;
|
||||||
56
dashboards/admin-web/next.config.ts
Normal file
56
dashboards/admin-web/next.config.ts
Normal 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
16948
dashboards/admin-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
83
dashboards/admin-web/package.json
Normal file
83
dashboards/admin-web/package.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
26
dashboards/admin-web/playwright.config.ts
Normal file
26
dashboards/admin-web/playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
7
dashboards/admin-web/postcss.config.mjs
Normal file
7
dashboards/admin-web/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
dashboards/admin-web/public/file.svg
Normal file
1
dashboards/admin-web/public/file.svg
Normal 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 |
1
dashboards/admin-web/public/globe.svg
Normal file
1
dashboards/admin-web/public/globe.svg
Normal 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 |
1
dashboards/admin-web/public/next.svg
Normal file
1
dashboards/admin-web/public/next.svg
Normal 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 |
1
dashboards/admin-web/public/vercel.svg
Normal file
1
dashboards/admin-web/public/vercel.svg
Normal 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 |
1
dashboards/admin-web/public/window.svg
Normal file
1
dashboards/admin-web/public/window.svg
Normal 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 |
102
dashboards/admin-web/src/__tests__/audit.test.ts
Normal file
102
dashboards/admin-web/src/__tests__/audit.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
131
dashboards/admin-web/src/__tests__/auth-login.test.ts
Normal file
131
dashboards/admin-web/src/__tests__/auth-login.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
59
dashboards/admin-web/src/__tests__/auth-me.test.ts
Normal file
59
dashboards/admin-web/src/__tests__/auth-me.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
dashboards/admin-web/src/__tests__/dashboard-stats.test.ts
Normal file
105
dashboards/admin-web/src/__tests__/dashboard-stats.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
94
dashboards/admin-web/src/__tests__/flags.test.ts
Normal file
94
dashboards/admin-web/src/__tests__/flags.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
235
dashboards/admin-web/src/__tests__/invitations.test.ts
Normal file
235
dashboards/admin-web/src/__tests__/invitations.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
156
dashboards/admin-web/src/__tests__/kill-switch.test.ts
Normal file
156
dashboards/admin-web/src/__tests__/kill-switch.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
126
dashboards/admin-web/src/__tests__/licenses.test.ts
Normal file
126
dashboards/admin-web/src/__tests__/licenses.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
281
dashboards/admin-web/src/__tests__/promos.test.ts
Normal file
281
dashboards/admin-web/src/__tests__/promos.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
94
dashboards/admin-web/src/__tests__/referrals-admin.test.ts
Normal file
94
dashboards/admin-web/src/__tests__/referrals-admin.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
173
dashboards/admin-web/src/__tests__/telemetry.test.ts
Normal file
173
dashboards/admin-web/src/__tests__/telemetry.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
160
dashboards/admin-web/src/__tests__/tokens.test.ts
Normal file
160
dashboards/admin-web/src/__tests__/tokens.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
222
dashboards/admin-web/src/__tests__/users.test.ts
Normal file
222
dashboards/admin-web/src/__tests__/users.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
216
dashboards/admin-web/src/app/(dashboard)/audit/page.tsx
Normal file
216
dashboards/admin-web/src/app/(dashboard)/audit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
dashboards/admin-web/src/app/(dashboard)/billing/page.tsx
Normal file
199
dashboards/admin-web/src/app/(dashboard)/billing/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
436
dashboards/admin-web/src/app/(dashboard)/docs/page.tsx
Normal file
436
dashboards/admin-web/src/app/(dashboard)/docs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
425
dashboards/admin-web/src/app/(dashboard)/extraction/page.tsx
Normal file
425
dashboards/admin-web/src/app/(dashboard)/extraction/page.tsx
Normal 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">"{e.extraction_text}"</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
dashboards/admin-web/src/app/(dashboard)/flags/page.tsx
Normal file
322
dashboards/admin-web/src/app/(dashboard)/flags/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
572
dashboards/admin-web/src/app/(dashboard)/invitations/page.tsx
Normal file
572
dashboards/admin-web/src/app/(dashboard)/invitations/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
dashboards/admin-web/src/app/(dashboard)/layout.tsx
Normal file
44
dashboards/admin-web/src/app/(dashboard)/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
383
dashboards/admin-web/src/app/(dashboard)/licenses/page.tsx
Normal file
383
dashboards/admin-web/src/app/(dashboard)/licenses/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
dashboards/admin-web/src/app/(dashboard)/loading.tsx
Normal file
39
dashboards/admin-web/src/app/(dashboard)/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
dashboards/admin-web/src/app/(dashboard)/notifications/page.tsx
Normal file
227
dashboards/admin-web/src/app/(dashboard)/notifications/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1045
dashboards/admin-web/src/app/(dashboard)/ops/client-logs/page.tsx
Normal file
1045
dashboards/admin-web/src/app/(dashboard)/ops/client-logs/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
237
dashboards/admin-web/src/app/(dashboard)/ops/page.tsx
Normal file
237
dashboards/admin-web/src/app/(dashboard)/ops/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
565
dashboards/admin-web/src/app/(dashboard)/ops/secrets/page.tsx
Normal file
565
dashboards/admin-web/src/app/(dashboard)/ops/secrets/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
509
dashboards/admin-web/src/app/(dashboard)/page.tsx
Normal file
509
dashboards/admin-web/src/app/(dashboard)/page.tsx
Normal 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">
|
||||||
|
· 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 · {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
567
dashboards/admin-web/src/app/(dashboard)/products/page.tsx
Normal file
567
dashboards/admin-web/src/app/(dashboard)/products/page.tsx
Normal 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 "{onboardResult.productId}" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
348
dashboards/admin-web/src/app/(dashboard)/promos/page.tsx
Normal file
348
dashboards/admin-web/src/app/(dashboard)/promos/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
dashboards/admin-web/src/app/(dashboard)/referrals/page.tsx
Normal file
168
dashboards/admin-web/src/app/(dashboard)/referrals/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
658
dashboards/admin-web/src/app/(dashboard)/settings/page.tsx
Normal file
658
dashboards/admin-web/src/app/(dashboard)/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
447
dashboards/admin-web/src/app/(dashboard)/subscriptions/page.tsx
Normal file
447
dashboards/admin-web/src/app/(dashboard)/subscriptions/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
452
dashboards/admin-web/src/app/(dashboard)/tokens/page.tsx
Normal file
452
dashboards/admin-web/src/app/(dashboard)/tokens/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
798
dashboards/admin-web/src/app/(dashboard)/usage/page.tsx
Normal file
798
dashboards/admin-web/src/app/(dashboard)/usage/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
384
dashboards/admin-web/src/app/(dashboard)/users/[id]/page.tsx
Normal file
384
dashboards/admin-web/src/app/(dashboard)/users/[id]/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
612
dashboards/admin-web/src/app/(dashboard)/users/page.tsx
Normal file
612
dashboards/admin-web/src/app/(dashboard)/users/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
dashboards/admin-web/src/app/api/analytics/retention/route.ts
Normal file
145
dashboards/admin-web/src/app/api/analytics/retention/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
170
dashboards/admin-web/src/app/api/analytics/revenue/route.ts
Normal file
170
dashboards/admin-web/src/app/api/analytics/revenue/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
dashboards/admin-web/src/app/api/audit/route.ts
Normal file
28
dashboards/admin-web/src/app/api/audit/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
49
dashboards/admin-web/src/app/api/auth/login/route.ts
Normal file
49
dashboards/admin-web/src/app/api/auth/login/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
23
dashboards/admin-web/src/app/api/auth/me/route.ts
Normal file
23
dashboards/admin-web/src/app/api/auth/me/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
36
dashboards/admin-web/src/app/api/dashboard/stats/route.ts
Normal file
36
dashboards/admin-web/src/app/api/dashboard/stats/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
19
dashboards/admin-web/src/app/api/docs/[...slug]/route.ts
Normal file
19
dashboards/admin-web/src/app/api/docs/[...slug]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
76
dashboards/admin-web/src/app/api/docs/chat/route.ts
Normal file
76
dashboards/admin-web/src/app/api/docs/chat/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
28
dashboards/admin-web/src/app/api/docs/route.ts
Normal file
28
dashboards/admin-web/src/app/api/docs/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
31
dashboards/admin-web/src/app/api/flags/[id]/route.ts
Normal file
31
dashboards/admin-web/src/app/api/flags/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
28
dashboards/admin-web/src/app/api/flags/route.ts
Normal file
28
dashboards/admin-web/src/app/api/flags/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
61
dashboards/admin-web/src/app/api/health/route.ts
Normal file
61
dashboards/admin-web/src/app/api/health/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
54
dashboards/admin-web/src/app/api/invitations/[id]/route.ts
Normal file
54
dashboards/admin-web/src/app/api/invitations/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
dashboards/admin-web/src/app/api/invitations/bulk/route.ts
Normal file
32
dashboards/admin-web/src/app/api/invitations/bulk/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
69
dashboards/admin-web/src/app/api/invitations/route.ts
Normal file
69
dashboards/admin-web/src/app/api/invitations/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
48
dashboards/admin-web/src/app/api/licenses/route.ts
Normal file
48
dashboards/admin-web/src/app/api/licenses/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
dashboards/admin-web/src/app/api/notifications/route.ts
Normal file
24
dashboards/admin-web/src/app/api/notifications/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
61
dashboards/admin-web/src/app/api/ops/secrets/[name]/route.ts
Normal file
61
dashboards/admin-web/src/app/api/ops/secrets/[name]/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
dashboards/admin-web/src/app/api/ops/secrets/route.ts
Normal file
95
dashboards/admin-web/src/app/api/ops/secrets/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
dashboards/admin-web/src/app/api/ops/status/route.ts
Normal file
119
dashboards/admin-web/src/app/api/ops/status/route.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
dashboards/admin-web/src/app/api/products/[id]/route.ts
Normal file
32
dashboards/admin-web/src/app/api/products/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
28
dashboards/admin-web/src/app/api/products/route.ts
Normal file
28
dashboards/admin-web/src/app/api/products/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
52
dashboards/admin-web/src/app/api/promos/[id]/route.ts
Normal file
52
dashboards/admin-web/src/app/api/promos/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
71
dashboards/admin-web/src/app/api/promos/route.ts
Normal file
71
dashboards/admin-web/src/app/api/promos/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
dashboards/admin-web/src/app/api/referrals/route.ts
Normal file
32
dashboards/admin-web/src/app/api/referrals/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
77
dashboards/admin-web/src/app/api/seed/route.ts
Normal file
77
dashboards/admin-web/src/app/api/seed/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
121
dashboards/admin-web/src/app/api/settings/kill-switch/route.ts
Normal file
121
dashboards/admin-web/src/app/api/settings/kill-switch/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
dashboards/admin-web/src/app/api/settings/plans/route.ts
Normal file
24
dashboards/admin-web/src/app/api/settings/plans/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
133
dashboards/admin-web/src/app/api/settings/platform/route.ts
Normal file
133
dashboards/admin-web/src/app/api/settings/platform/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
34
dashboards/admin-web/src/app/api/stripe/config/route.ts
Normal file
34
dashboards/admin-web/src/app/api/stripe/config/route.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
29
dashboards/admin-web/src/app/api/telemetry/erasure/route.ts
Normal file
29
dashboards/admin-web/src/app/api/telemetry/erasure/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
dashboards/admin-web/src/app/api/telemetry/geo/route.ts
Normal file
24
dashboards/admin-web/src/app/api/telemetry/geo/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
dashboards/admin-web/src/app/api/telemetry/metrics/route.ts
Normal file
21
dashboards/admin-web/src/app/api/telemetry/metrics/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
37
dashboards/admin-web/src/app/api/telemetry/policies/route.ts
Normal file
37
dashboards/admin-web/src/app/api/telemetry/policies/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
34
dashboards/admin-web/src/app/api/telemetry/route.ts
Normal file
34
dashboards/admin-web/src/app/api/telemetry/route.ts
Normal 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
Loading…
Reference in New Issue
Block a user