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