Add admin interface, auto-promote admin email, fix sender name

Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/98c1f90b-0287-4908-a0be-ad066c453cc5

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-26 15:53:24 +00:00
parent 37b0af2eb7
commit 979b7b6cec
12 changed files with 1393 additions and 10 deletions
+21
View File
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Admin interface**: Superusers now have access to a dedicated Admin section in the sidebar with three pages:
- **Admin Overview** (`/admin`): System-wide stats (total users, mail accounts, processing runs).
- **Manage Users** (`/admin/users`): Table of all registered users with their subscription tier, status, mail account count, and last login. Admins can edit any user's name, email, plan, active status, and promote/demote admin (superuser) privileges. Users can be deleted (with confirmation).
- **Manage Plans** (`/admin/plans`): Full CRUD for subscription plans—create, edit, and delete plans with fields for tier, name, pricing, max mailboxes, max emails/day, check interval, and support level.
- **Auto-promotion of admin email**: When the user whose email matches the `ADMIN_EMAIL` environment variable logs in or registers (via email/password or Google OAuth), they are automatically promoted to superuser. Default value is `christianlouis@gmail.com` (configurable via the `ADMIN_EMAIL` env var).
- **`is_superuser` field in API responses**: `GET /users/me` and all admin user endpoints now include `is_superuser` so the frontend can conditionally show admin UI.
- **New admin API endpoints** (all require superuser role):
- `GET /admin/users` List all users with mail account counts.
- `GET /admin/users/{id}` Get a single user's details.
- `PUT /admin/users/{id}` Update user details, plan, active status, and superuser flag.
- `DELETE /admin/users/{id}` Delete a user.
- `GET /admin/plans` List all subscription plans (including inactive).
- `POST /admin/plans` Create a new subscription plan.
- `PUT /admin/plans/{id}` Update a subscription plan.
- `DELETE /admin/plans/{id}` Delete a subscription plan.
- **Admin badge in top bar**: Admin users see a purple shield icon and an "Admin" badge next to their email in the top navigation bar.
### Fixed
- Test email sender name corrected from "Christian Loris" to "Christian Krakau-Louis".
### Fixed
- Fixed `TypeError: can't subtract offset-naive and offset-aware datetimes` in `process_mail_account` task when computing `duration_seconds`. After a database refresh, `started_at` may be returned as a naive datetime; it is now normalized to UTC before subtraction.
+249 -2
View File
@@ -1,12 +1,27 @@
"""Admin endpoints"""
from fastapi import APIRouter, Depends
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.core.deps import get_current_superuser
from app.models.database_models import User, MailAccount, ProcessingRun
from app.models.database_models import (
User,
MailAccount,
ProcessingRun,
SubscriptionPlan,
SubscriptionTier,
)
from app.models.schemas import (
AdminUserListResponse,
AdminUserUpdate,
UserDetailResponse,
SubscriptionPlanResponse,
SubscriptionPlanCreate,
SubscriptionPlanUpdate,
)
router = APIRouter()
@@ -35,3 +50,235 @@ async def get_admin_stats(
"total_mail_accounts": total_accounts,
"total_processing_runs": total_runs,
}
# ── User management ────────────────────────────────────────────────────────────
@router.get("/users", response_model=List[AdminUserListResponse])
async def list_users(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""List all users with their mail account counts (admin only)"""
result = await db.execute(
select(User).order_by(User.created_at.desc()).offset(skip).limit(limit)
)
users = result.scalars().all()
# Fetch mail account counts per user in one query
counts_result = await db.execute(
select(MailAccount.user_id, func.count(MailAccount.id).label("cnt")).group_by(
MailAccount.user_id
)
)
counts = {row.user_id: row.cnt for row in counts_result}
response = []
for u in users:
response.append(
AdminUserListResponse(
id=u.id, # type: ignore[arg-type]
email=u.email, # type: ignore[arg-type]
full_name=u.full_name, # type: ignore[arg-type]
is_active=u.is_active, # type: ignore[arg-type]
is_superuser=u.is_superuser, # type: ignore[arg-type]
subscription_tier=u.subscription_tier, # type: ignore[arg-type]
subscription_status=u.subscription_status, # type: ignore[arg-type]
google_id=u.google_id, # type: ignore[arg-type]
oauth_provider=u.oauth_provider, # type: ignore[arg-type]
last_login_at=u.last_login_at, # type: ignore[arg-type]
created_at=u.created_at, # type: ignore[arg-type]
mail_account_count=counts.get(u.id, 0), # type: ignore[arg-type]
)
)
return response
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: int,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Get a specific user's details (admin only)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
return user
@router.put("/users/{user_id}", response_model=UserDetailResponse)
async def update_user(
user_id: int,
user_update: AdminUserUpdate,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Update a user's details, plan, or admin status (admin only)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
if user_update.full_name is not None:
user.full_name = user_update.full_name # type: ignore[assignment]
if user_update.email is not None:
user.email = user_update.email # type: ignore[assignment]
if user_update.is_active is not None:
user.is_active = user_update.is_active # type: ignore[assignment]
if user_update.is_superuser is not None:
user.is_superuser = user_update.is_superuser # type: ignore[assignment]
if user_update.subscription_tier is not None:
user.subscription_tier = SubscriptionTier(user_update.subscription_tier.value) # type: ignore[assignment]
if user_update.subscription_status is not None:
user.subscription_status = user_update.subscription_status # type: ignore[assignment]
await db.commit()
await db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Delete a user and all their data (admin only)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account via admin endpoint",
)
await db.delete(user)
await db.commit()
# ── Plan management ────────────────────────────────────────────────────────────
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
async def list_all_plans(
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""List all subscription plans including inactive ones (admin only)"""
result = await db.execute(select(SubscriptionPlan).order_by(SubscriptionPlan.tier))
return result.scalars().all()
@router.post(
"/plans",
response_model=SubscriptionPlanResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_plan(
plan_in: SubscriptionPlanCreate,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Create a new subscription plan (admin only)"""
existing = await db.execute(
select(SubscriptionPlan).where(
SubscriptionPlan.tier == SubscriptionTier(plan_in.tier.value)
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"A plan for tier '{plan_in.tier.value}' already exists",
)
plan = SubscriptionPlan(
tier=SubscriptionTier(plan_in.tier.value),
name=plan_in.name,
description=plan_in.description,
price_monthly=plan_in.price_monthly,
price_yearly=plan_in.price_yearly,
max_mail_accounts=plan_in.max_mail_accounts,
max_emails_per_day=plan_in.max_emails_per_day,
check_interval_minutes=plan_in.check_interval_minutes,
support_level=plan_in.support_level,
features=plan_in.features,
is_active=plan_in.is_active,
)
db.add(plan)
await db.commit()
await db.refresh(plan)
return plan
@router.put("/plans/{plan_id}", response_model=SubscriptionPlanResponse)
async def update_plan(
plan_id: int,
plan_update: SubscriptionPlanUpdate,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Update a subscription plan's limits or pricing (admin only)"""
result = await db.execute(
select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id)
)
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
)
if plan_update.name is not None:
plan.name = plan_update.name # type: ignore[assignment]
if plan_update.description is not None:
plan.description = plan_update.description # type: ignore[assignment]
if plan_update.price_monthly is not None:
plan.price_monthly = plan_update.price_monthly # type: ignore[assignment]
if plan_update.price_yearly is not None:
plan.price_yearly = plan_update.price_yearly # type: ignore[assignment]
if plan_update.max_mail_accounts is not None:
plan.max_mail_accounts = plan_update.max_mail_accounts # type: ignore[assignment]
if plan_update.max_emails_per_day is not None:
plan.max_emails_per_day = plan_update.max_emails_per_day # type: ignore[assignment]
if plan_update.check_interval_minutes is not None:
plan.check_interval_minutes = plan_update.check_interval_minutes # type: ignore[assignment]
if plan_update.support_level is not None:
plan.support_level = plan_update.support_level # type: ignore[assignment]
if plan_update.features is not None:
plan.features = plan_update.features # type: ignore[assignment]
if plan_update.is_active is not None:
plan.is_active = plan_update.is_active # type: ignore[assignment]
await db.commit()
await db.refresh(plan)
return plan
@router.delete("/plans/{plan_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_plan(
plan_id: int,
current_user: User = Depends(get_current_superuser),
db: AsyncSession = Depends(get_db),
):
"""Delete a subscription plan (admin only)"""
result = await db.execute(
select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id)
)
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
)
await db.delete(plan)
await db.commit()
+27
View File
@@ -57,6 +57,10 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
),
subscription_tier=SubscriptionTier.FREE,
is_active=True,
is_superuser=(
settings.ADMIN_EMAIL is not None
and user_in.email.lower() == settings.ADMIN_EMAIL.lower()
),
)
db.add(user)
@@ -101,6 +105,16 @@ async def login(
# Update last login
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
# Auto-promote to superuser if this is the configured admin email
if (
settings.ADMIN_EMAIL is not None
and user.email.lower() == settings.ADMIN_EMAIL.lower()
and not user.is_superuser
):
user.is_superuser = True # type: ignore[assignment]
logger.info(f"Auto-promoted admin user: {user.email}")
await db.commit()
# Create tokens
@@ -149,6 +163,15 @@ async def google_oauth(
# Update last login
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
# Auto-promote to superuser if this is the configured admin email
if (
settings.ADMIN_EMAIL is not None
and user.email.lower() == settings.ADMIN_EMAIL.lower()
and not user.is_superuser
):
user.is_superuser = True # type: ignore[assignment]
logger.info(f"Auto-promoted admin user via Google OAuth: {user.email}")
logger.info(f"Existing user logged in with Google: {user.email}")
else:
# Create new user
@@ -160,6 +183,10 @@ async def google_oauth(
subscription_tier=SubscriptionTier.FREE,
is_active=True,
last_login_at=datetime.now(timezone.utc),
is_superuser=(
settings.ADMIN_EMAIL is not None
and email.lower() == settings.ADMIN_EMAIL.lower()
),
)
db.add(user)
+1 -1
View File
@@ -94,7 +94,7 @@ class Settings(BaseSettings):
LOG_LEVEL: str = "INFO"
# Admin
ADMIN_EMAIL: Optional[str] = None
ADMIN_EMAIL: Optional[str] = "christianlouis@gmail.com"
ADMIN_PASSWORD: Optional[str] = None
# Mail Server Presets
+57
View File
@@ -74,6 +74,7 @@ class UserDetailResponse(UserResponse):
stripe_customer_id: Optional[str] = None
subscription_expires_at: Optional[datetime] = None
last_login_at: Optional[datetime] = None
is_superuser: bool = False
model_config = ConfigDict(from_attributes=True)
@@ -377,3 +378,59 @@ class GmailAuthorizeResponse(BaseModel):
class GmailCallbackRequest(BaseModel):
code: str
redirect_uri: str
# Admin Schemas
class AdminUserListResponse(BaseModel):
id: int
email: str
full_name: Optional[str] = None
is_active: bool
is_superuser: bool
subscription_tier: SubscriptionTier
subscription_status: str
google_id: Optional[str] = None
oauth_provider: Optional[str] = None
last_login_at: Optional[datetime] = None
created_at: datetime
mail_account_count: int = 0
model_config = ConfigDict(from_attributes=True)
class AdminUserUpdate(BaseModel):
full_name: Optional[str] = None
email: Optional[EmailStr] = None
is_active: Optional[bool] = None
is_superuser: Optional[bool] = None
subscription_tier: Optional[SubscriptionTier] = None
subscription_status: Optional[str] = None
class SubscriptionPlanCreate(BaseModel):
tier: SubscriptionTier
name: str
description: Optional[str] = None
price_monthly: float = 0.0
price_yearly: Optional[float] = None
max_mail_accounts: int = 1
max_emails_per_day: int = 1000
check_interval_minutes: int = 5
support_level: str = "community"
features: Optional[Dict[str, Any]] = None
is_active: bool = True
class SubscriptionPlanUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
price_monthly: Optional[float] = None
price_yearly: Optional[float] = None
max_mail_accounts: Optional[int] = None
max_emails_per_day: Optional[int] = None
check_interval_minutes: Optional[int] = None
support_level: Optional[str] = None
features: Optional[Dict[str, Any]] = None
is_active: Optional[bool] = None
+2 -2
View File
@@ -276,12 +276,12 @@ class GmailService:
delivery is functioning as expected. Feel free to delete it.
Best regards,
Christian Loris
Christian Krakau-Louis
DocuElevate
""")
msg = MIMEText(body, "plain", "utf-8")
msg["From"] = "Christian Loris <christian@docuelevate.org>"
msg["From"] = "Christian Krakau-Louis <christian@docuelevate.org>"
msg["To"] = recipient_email
msg["Subject"] = subject
msg["Date"] = format_datetime(now)
+9
View File
@@ -231,6 +231,15 @@ because the API client layer is missing.
- [ ] Notification preferences UI
- [ ] Subscription management / billing UI
### Admin Interface ✅
- [x] Admin section in sidebar (visible to superusers only)
- [x] Admin overview page (`/admin`) with system-wide stats
- [x] User management page (`/admin/users`) — list, edit, delete users; assign plans; promote/demote admin
- [x] Plan management page (`/admin/plans`) — full CRUD for subscription plans (mailboxes, emails/day, interval, pricing)
- [x] `ADMIN_EMAIL` env var with default `christianlouis@gmail.com`; admin auto-promoted on login
- [x] `is_superuser` exposed in `/users/me` response
- [x] Admin badge (purple shield) shown in top bar for superusers
---
## 📅 Milestone Timeline
+111
View File
@@ -0,0 +1,111 @@
'use client';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Users, Mail, Activity, Shield } from 'lucide-react';
import Link from 'next/link';
export default function AdminPage() {
const { user } = useAuthStore();
const router = useRouter();
useEffect(() => {
if (user && !user.is_superuser) {
router.replace('/dashboard');
}
}, [user, router]);
const { data: stats, isLoading } = useQuery({
queryKey: ['admin-stats'],
queryFn: adminApi.getStats,
enabled: !!user?.is_superuser,
});
if (!user?.is_superuser) return null;
return (
<AuthGuard>
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="h-6 w-6 text-purple-600" />
Admin Overview
</h1>
<p className="mt-1 text-sm text-gray-500">
System-wide statistics and management tools.
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-purple-100">
<Users className="h-6 w-6 text-purple-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Total Users</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.total_users ?? '—'}</p>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-blue-100">
<Mail className="h-6 w-6 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Mail Accounts</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.total_mail_accounts ?? '—'}</p>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-green-100">
<Activity className="h-6 w-6 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Processing Runs</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.total_processing_runs ?? '—'}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<Link
href="/admin/users"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
>
<div className="p-3 rounded-full bg-purple-100 group-hover:bg-purple-200 transition-colors">
<Users className="h-6 w-6 text-purple-600" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Manage Users</p>
<p className="text-sm text-gray-500">View, edit, assign plans, promote to admin</p>
</div>
</Link>
<Link
href="/admin/plans"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
>
<div className="p-3 rounded-full bg-blue-100 group-hover:bg-blue-200 transition-colors">
<Mail className="h-6 w-6 text-blue-600" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Manage Plans</p>
<p className="text-sm text-gray-500">Create and configure subscription plans</p>
</div>
</Link>
</div>
</div>
</DashboardLayout>
</AuthGuard>
);
}
+413
View File
@@ -0,0 +1,413 @@
'use client';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
adminApi,
SubscriptionPlan,
SubscriptionPlanCreate,
SubscriptionPlanUpdate,
} from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Shield, Plus, Pencil, Trash2, X, Check } from 'lucide-react';
const TIERS = ['free', 'basic', 'pro', 'enterprise'];
const DEFAULT_FORM: SubscriptionPlanCreate = {
tier: 'free',
name: '',
description: '',
price_monthly: 0,
price_yearly: undefined,
max_mail_accounts: 1,
max_emails_per_day: 1000,
check_interval_minutes: 5,
support_level: 'community',
is_active: true,
};
function PlanFormModal({
plan,
onClose,
onSave,
}: {
plan: SubscriptionPlan | null;
onClose: () => void;
onSave: (data: SubscriptionPlanCreate | SubscriptionPlanUpdate) => void;
}) {
const isEdit = plan !== null;
const [form, setForm] = useState<SubscriptionPlanCreate>(
plan
? {
tier: plan.tier,
name: plan.name,
description: plan.description ?? '',
price_monthly: plan.price_monthly,
price_yearly: plan.price_yearly ?? undefined,
max_mail_accounts: plan.max_mail_accounts,
max_emails_per_day: plan.max_emails_per_day,
check_interval_minutes: plan.check_interval_minutes,
support_level: plan.support_level,
is_active: plan.is_active,
}
: DEFAULT_FORM
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6 overflow-y-auto max-h-[90vh]">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">
{isEdit ? 'Edit Plan' : 'Create Plan'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
{!isEdit && (
<div>
<label className="block text-sm font-medium text-gray-700">Tier</label>
<select
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.tier}
onChange={(e) => setForm({ ...form, tier: e.target.value })}
>
{TIERS.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Description</label>
<textarea
rows={2}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.description ?? ''}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Monthly Price ($)</label>
<input
type="number"
min={0}
step={0.01}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.price_monthly}
onChange={(e) => setForm({ ...form, price_monthly: parseFloat(e.target.value) || 0 })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Yearly Price ($)</label>
<input
type="number"
min={0}
step={0.01}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.price_yearly ?? ''}
onChange={(e) =>
setForm({
...form,
price_yearly: e.target.value ? parseFloat(e.target.value) : undefined,
})
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Max Mailboxes</label>
<input
type="number"
min={1}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.max_mail_accounts}
onChange={(e) =>
setForm({ ...form, max_mail_accounts: parseInt(e.target.value) || 1 })
}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Max Emails/Day</label>
<input
type="number"
min={1}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.max_emails_per_day}
onChange={(e) =>
setForm({ ...form, max_emails_per_day: parseInt(e.target.value) || 1 })
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Check Interval (min)</label>
<input
type="number"
min={1}
max={1440}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.check_interval_minutes}
onChange={(e) =>
setForm({ ...form, check_interval_minutes: parseInt(e.target.value) || 5 })
}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Support Level</label>
<select
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.support_level}
onChange={(e) => setForm({ ...form, support_level: e.target.value })}
>
{['community', 'email', 'priority'].map((s) => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
</div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
<input
type="checkbox"
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
/>
Active (visible to users)
</label>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={() => onSave(form)}
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
>
{isEdit ? 'Save Changes' : 'Create Plan'}
</button>
</div>
</div>
</div>
);
}
export default function AdminPlansPage() {
const { user } = useAuthStore();
const router = useRouter();
const queryClient = useQueryClient();
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
useEffect(() => {
if (user && !user.is_superuser) {
router.replace('/dashboard');
}
}, [user, router]);
const { data: plans, isLoading } = useQuery({
queryKey: ['admin-plans'],
queryFn: adminApi.listPlans,
enabled: !!user?.is_superuser,
});
const createMutation = useMutation({
mutationFn: (data: SubscriptionPlanCreate) => adminApi.createPlan(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
setShowCreate(false);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: SubscriptionPlanUpdate }) =>
adminApi.updatePlan(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
setEditingPlan(null);
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => adminApi.deletePlan(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
setDeleteConfirm(null);
},
});
if (!user?.is_superuser) return null;
return (
<AuthGuard>
<DashboardLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="h-6 w-6 text-purple-600" />
Manage Plans
</h1>
<p className="mt-1 text-sm text-gray-500">
Configure subscription plans, limits, and pricing.
</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
>
<Plus className="h-4 w-4" />
New Plan
</button>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{['Tier', 'Name', 'Price/mo', 'Mailboxes', 'Emails/day', 'Interval', 'Support', 'Status', 'Actions'].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{(plans ?? []).map((p) => (
<tr key={p.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
{p.tier}
</span>
</td>
<td className="px-4 py-3 text-sm font-medium text-gray-900">{p.name}</td>
<td className="px-4 py-3 text-sm text-gray-500">
${p.price_monthly.toFixed(2)}
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">
{p.max_mail_accounts}
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">
{p.max_emails_per_day.toLocaleString()}
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">
{p.check_interval_minutes}m
</td>
<td className="px-4 py-3 text-sm text-gray-500 capitalize">
{p.support_level}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
p.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-500'
}`}
>
{p.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => setEditingPlan(p)}
className="p-1.5 text-gray-400 hover:text-purple-600 rounded hover:bg-purple-50"
title="Edit plan"
>
<Pencil className="h-4 w-4" />
</button>
{deleteConfirm === p.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => deleteMutation.mutate(p.id)}
className="p-1.5 text-white bg-red-600 rounded hover:bg-red-700"
title="Confirm delete"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(p.id)}
className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50"
title="Delete plan"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{(plans ?? []).length === 0 && (
<div className="text-center py-12 text-gray-500 text-sm">
No plans found. Create one to get started.
</div>
)}
</div>
)}
</div>
</div>
{showCreate && (
<PlanFormModal
plan={null}
onClose={() => setShowCreate(false)}
onSave={(data) => createMutation.mutate(data as SubscriptionPlanCreate)}
/>
)}
{editingPlan && (
<PlanFormModal
plan={editingPlan}
onClose={() => setEditingPlan(null)}
onSave={(data) =>
updateMutation.mutate({ id: editingPlan.id, data: data as SubscriptionPlanUpdate })
}
/>
)}
</DashboardLayout>
</AuthGuard>
);
}
+305
View File
@@ -0,0 +1,305 @@
'use client';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi, AdminUser, AdminUserUpdate } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Shield, Pencil, Trash2, X, Check } from 'lucide-react';
const TIERS = ['free', 'basic', 'pro', 'enterprise'];
const STATUSES = ['active', 'canceled', 'past_due'];
function EditUserModal({
user,
onClose,
onSave,
}: {
user: AdminUser;
onClose: () => void;
onSave: (data: AdminUserUpdate) => void;
}) {
const [form, setForm] = useState<AdminUserUpdate>({
full_name: user.full_name ?? '',
email: user.email,
is_active: user.is_active,
is_superuser: user.is_superuser,
subscription_tier: user.subscription_tier,
subscription_status: user.subscription_status,
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Edit User</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Full Name</label>
<input
type="text"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.full_name ?? ''}
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.email ?? ''}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Subscription Tier</label>
<select
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.subscription_tier ?? 'free'}
onChange={(e) => setForm({ ...form, subscription_tier: e.target.value })}
>
{TIERS.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Subscription Status</label>
<select
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.subscription_status ?? 'active'}
onChange={(e) => setForm({ ...form, subscription_status: e.target.value })}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ')}
</option>
))}
</select>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
<input
type="checkbox"
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
checked={form.is_active ?? true}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
/>
Active
</label>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
<input
type="checkbox"
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
checked={form.is_superuser ?? false}
onChange={(e) => setForm({ ...form, is_superuser: e.target.checked })}
/>
Admin (superuser)
</label>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={() => onSave(form)}
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
>
Save Changes
</button>
</div>
</div>
</div>
);
}
export default function AdminUsersPage() {
const { user: currentUser } = useAuthStore();
const router = useRouter();
const queryClient = useQueryClient();
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
useEffect(() => {
if (currentUser && !currentUser.is_superuser) {
router.replace('/dashboard');
}
}, [currentUser, router]);
const { data: users, isLoading } = useQuery({
queryKey: ['admin-users'],
queryFn: () => adminApi.listUsers(),
enabled: !!currentUser?.is_superuser,
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: AdminUserUpdate }) =>
adminApi.updateUser(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setEditingUser(null);
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => adminApi.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setDeleteConfirm(null);
},
});
if (!currentUser?.is_superuser) return null;
return (
<AuthGuard>
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="h-6 w-6 text-purple-600" />
Manage Users
</h1>
<p className="mt-1 text-sm text-gray-500">
View all registered users, assign plans, and manage admin privileges.
</p>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{['User', 'Plan', 'Status', 'Accounts', 'Last Login', 'Role', 'Actions'].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{(users ?? []).map((u) => (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div>
<p className="text-sm font-medium text-gray-900">{u.full_name || '—'}</p>
<p className="text-xs text-gray-500">{u.email}</p>
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
{u.subscription_tier}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
u.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{u.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">
{u.mail_account_count}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{u.last_login_at
? new Date(u.last_login_at).toLocaleDateString()
: '—'}
</td>
<td className="px-4 py-3">
{u.is_superuser ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<Shield className="h-3 w-3" />
Admin
</span>
) : (
<span className="text-xs text-gray-400">User</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => setEditingUser(u)}
className="p-1.5 text-gray-400 hover:text-purple-600 rounded hover:bg-purple-50"
title="Edit user"
>
<Pencil className="h-4 w-4" />
</button>
{u.id !== currentUser?.id && (
deleteConfirm === u.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => deleteMutation.mutate(u.id)}
className="p-1.5 text-white bg-red-600 rounded hover:bg-red-700"
title="Confirm delete"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(u.id)}
className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50"
title="Delete user"
>
<Trash2 className="h-4 w-4" />
</button>
)
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{(users ?? []).length === 0 && (
<div className="text-center py-12 text-gray-500 text-sm">No users found.</div>
)}
</div>
)}
</div>
</div>
{editingUser && (
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSave={(data) => updateMutation.mutate({ id: editingUser.id, data })}
/>
)}
</DashboardLayout>
</AuthGuard>
);
}
+74 -5
View File
@@ -11,7 +11,10 @@ import {
LogOut,
Menu,
X,
User
User,
Shield,
Users,
CreditCard
} from 'lucide-react';
interface DashboardLayoutProps {
@@ -35,6 +38,16 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{ name: 'Settings', href: '/settings', icon: Settings },
];
const adminNavigation = user?.is_superuser
? [
{ name: 'Admin Overview', href: '/admin', icon: Shield },
{ name: 'Manage Users', href: '/admin/users', icon: Users },
{ name: 'Manage Plans', href: '/admin/plans', icon: CreditCard },
]
: [];
const allNavItems = [...navigation, ...adminNavigation];
return (
<div className="min-h-screen bg-gray-50">
{/* Sidebar for desktop */}
@@ -61,6 +74,30 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
</Link>
);
})}
{adminNavigation.length > 0 && (
<>
<div className="pt-4 pb-1 px-4">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Admin</p>
</div>
{adminNavigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors ${
isActive
? 'bg-purple-50 text-purple-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-purple-600' : 'text-gray-400'}`} />
{item.name}
</Link>
);
})}
</>
)}
</nav>
<div className="flex-shrink-0 border-t border-gray-200 p-4">
<button
@@ -104,6 +141,31 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
</Link>
);
})}
{adminNavigation.length > 0 && (
<>
<div className="pt-4 pb-1 px-4">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Admin</p>
</div>
{adminNavigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.name}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors ${
isActive
? 'bg-purple-50 text-purple-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-purple-600' : 'text-gray-400'}`} />
{item.name}
</Link>
);
})}
</>
)}
</nav>
<div className="flex-shrink-0 border-t border-gray-200 p-4">
<button
@@ -132,17 +194,24 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div className="flex flex-1 justify-between px-4 sm:px-6 lg:px-8">
<div className="flex flex-1 items-center">
<h2 className="text-lg font-semibold text-gray-900">
{navigation.find((item) => item.href === pathname)?.name || 'Dashboard'}
{allNavItems.find((item) => item.href === pathname)?.name || 'Dashboard'}
</h2>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-white">
<User className="h-4 w-4" />
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-white ${user?.is_superuser ? 'bg-purple-600' : 'bg-blue-600'}`}>
{user?.is_superuser ? <Shield className="h-4 w-4" /> : <User className="h-4 w-4" />}
</div>
<div className="hidden sm:block">
<p className="text-sm font-medium text-gray-900">{user?.full_name}</p>
<p className="text-xs text-gray-500">{user?.email}</p>
<p className="text-xs text-gray-500">
{user?.email}
{user?.is_superuser && (
<span className="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
Admin
</span>
)}
</p>
</div>
</div>
</div>
+124
View File
@@ -38,6 +38,7 @@ export interface User {
email: string;
full_name: string | null;
is_active: boolean;
is_superuser: boolean;
subscription_tier: string;
subscription_status: string;
created_at: string;
@@ -361,4 +362,127 @@ export const smtpApi = {
},
};
// ── Admin Types ─────────────────────────────────────────────────────────
export interface AdminUser {
id: number;
email: string;
full_name: string | null;
is_active: boolean;
is_superuser: boolean;
subscription_tier: string;
subscription_status: string;
google_id?: string | null;
oauth_provider?: string | null;
last_login_at?: string | null;
created_at: string;
mail_account_count: number;
}
export interface AdminUserUpdate {
full_name?: string | null;
email?: string;
is_active?: boolean;
is_superuser?: boolean;
subscription_tier?: string;
subscription_status?: string;
}
export interface SubscriptionPlan {
id: number;
tier: string;
name: string;
description?: string | null;
price_monthly: number;
price_yearly?: number | null;
max_mail_accounts: number;
max_emails_per_day: number;
check_interval_minutes: number;
support_level: string;
features?: Record<string, unknown> | null;
is_active: boolean;
}
export interface SubscriptionPlanCreate {
tier: string;
name: string;
description?: string;
price_monthly: number;
price_yearly?: number;
max_mail_accounts: number;
max_emails_per_day: number;
check_interval_minutes: number;
support_level: string;
features?: Record<string, unknown>;
is_active: boolean;
}
export interface SubscriptionPlanUpdate {
name?: string;
description?: string;
price_monthly?: number;
price_yearly?: number;
max_mail_accounts?: number;
max_emails_per_day?: number;
check_interval_minutes?: number;
support_level?: string;
features?: Record<string, unknown>;
is_active?: boolean;
}
export interface AdminStats {
total_users: number;
total_mail_accounts: number;
total_processing_runs: number;
}
// ── Admin API ───────────────────────────────────────────────────────────
export const adminApi = {
async getStats(): Promise<AdminStats> {
const response = await api.get<AdminStats>('/admin/stats');
return response.data;
},
async listUsers(skip = 0, limit = 100): Promise<AdminUser[]> {
const response = await api.get<AdminUser[]>('/admin/users', {
params: { skip, limit },
});
return response.data;
},
async getUser(id: number): Promise<User> {
const response = await api.get<User>(`/admin/users/${id}`);
return response.data;
},
async updateUser(id: number, data: AdminUserUpdate): Promise<User> {
const response = await api.put<User>(`/admin/users/${id}`, data);
return response.data;
},
async deleteUser(id: number): Promise<void> {
await api.delete(`/admin/users/${id}`);
},
async listPlans(): Promise<SubscriptionPlan[]> {
const response = await api.get<SubscriptionPlan[]>('/admin/plans');
return response.data;
},
async createPlan(data: SubscriptionPlanCreate): Promise<SubscriptionPlan> {
const response = await api.post<SubscriptionPlan>('/admin/plans', data);
return response.data;
},
async updatePlan(id: number, data: SubscriptionPlanUpdate): Promise<SubscriptionPlan> {
const response = await api.put<SubscriptionPlan>(`/admin/plans/${id}`, data);
return response.data;
},
async deletePlan(id: number): Promise<void> {
await api.delete(`/admin/plans/${id}`);
},
};
export default api;