Merge pull request #83 from christianlouis/copilot/add-admin-interface-for-users
Fix test_app_title assertion after InboxRescue rebrand
This commit is contained in:
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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 zero-price / 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.
|
||||||
|
- **`DEFAULT_USER_TIER` env var**: Controls the subscription tier assigned to every new user on registration. Defaults to `free`. Set to `enterprise` (or any other tier) for B2B / Google Workspace installations where all employees should start on a zero-rate plan.
|
||||||
|
- **`ALLOWED_DOMAINS` env var**: Comma-separated list of permitted email domains (e.g. `company.com,subsidiary.com`). When set, only addresses from those domains may register or log in. Superusers always bypass this check. Empty (default) = no restriction (normal B2C mode).
|
||||||
|
- **Dynamic pricing section on landing page**: The home page now fetches `GET /subscriptions/plans` and renders a pricing section only when paid plans exist. In enterprise / all-zero-rate deployments the pricing section is silently hidden — the page just shows features and a "Get started free" CTA.
|
||||||
|
- **B2C copy and branding**: App renamed to **InboxRescue** throughout (was "POP3 Forwarder SaaS"). Landing page hero, feature cards, how-it-works, and footer rewritten in a personal, consumer-friendly tone. Pricing updated to €0.99 / €1.99 / €2.99 per month for Good / Better / Best plans.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Test email sender name corrected from "Christian Loris" to "Christian Krakau-Louis".
|
||||||
|
- **Mailbox limit always hit at 1**: The `subscription_plans` table was never seeded, so the limit check fell back to the env-var default of `TIER_FREE_MAX_ACCOUNTS=1` for every user regardless of their tier. Fixed by:
|
||||||
|
1. Seeding four default `SubscriptionPlan` rows at startup — **Free**, **Good** (€0.99), **Better** (€1.99), **Best** (€2.99).
|
||||||
|
2. Rewriting the limit check to look up the user's active plan from the DB first, falling back to env-var config only when no plan row exists.
|
||||||
|
3. Bypassing the limit entirely for superusers (admins can always add mailboxes).
|
||||||
|
4. Adding plan limit fields (`max_mail_accounts`, `max_emails_per_day`, `check_interval_minutes`) to `GET /subscriptions/current`.
|
||||||
|
- **Zero-price plans hidden from public marketing**: `GET /subscriptions/plans` now only returns plans with `price_monthly > 0`. Zero-rate plans (Free tier, custom enterprise plans) are still managed by admins but never shown in the public pricing UI.
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
"""Admin endpoints"""
|
"""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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_superuser
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -35,3 +50,235 @@ async def get_admin_stats(
|
|||||||
"total_mail_accounts": total_accounts,
|
"total_mail_accounts": total_accounts,
|
||||||
"total_processing_runs": total_runs,
|
"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()
|
||||||
|
|||||||
@@ -33,6 +33,48 @@ GOOGLE_LOGIN_SCOPES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_of(email: str) -> str:
|
||||||
|
"""Return the lowercased domain part of an email address."""
|
||||||
|
return email.split("@")[-1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_domain_allowed(email: str) -> None:
|
||||||
|
"""
|
||||||
|
Raise 403 if ALLOWED_DOMAINS is configured and the email's domain is not
|
||||||
|
in the list. Always passes when ALLOWED_DOMAINS is empty (no restriction).
|
||||||
|
"""
|
||||||
|
if not settings.ALLOWED_DOMAINS:
|
||||||
|
return
|
||||||
|
domain = _domain_of(email)
|
||||||
|
if domain not in settings.ALLOWED_DOMAINS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=(
|
||||||
|
f"Registrations are restricted to approved domains. "
|
||||||
|
f"'{domain}' is not authorised."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_tier() -> SubscriptionTier:
|
||||||
|
"""Return the SubscriptionTier that should be assigned to every new user."""
|
||||||
|
try:
|
||||||
|
return SubscriptionTier(settings.DEFAULT_USER_TIER)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"DEFAULT_USER_TIER '%s' is not a valid tier; falling back to FREE.",
|
||||||
|
settings.DEFAULT_USER_TIER,
|
||||||
|
)
|
||||||
|
return SubscriptionTier.FREE
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin_email(email: str) -> bool:
|
||||||
|
return (
|
||||||
|
settings.ADMIN_EMAIL is not None
|
||||||
|
and email.lower() == settings.ADMIN_EMAIL.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
|
"/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED
|
||||||
)
|
)
|
||||||
@@ -48,6 +90,9 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Domain restriction check (before creating the account)
|
||||||
|
_check_domain_allowed(user_in.email)
|
||||||
|
|
||||||
# Create new user
|
# Create new user
|
||||||
user = User(
|
user = User(
|
||||||
email=user_in.email,
|
email=user_in.email,
|
||||||
@@ -55,8 +100,9 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)):
|
|||||||
hashed_password=(
|
hashed_password=(
|
||||||
get_password_hash(user_in.password) if user_in.password else None
|
get_password_hash(user_in.password) if user_in.password else None
|
||||||
),
|
),
|
||||||
subscription_tier=SubscriptionTier.FREE,
|
subscription_tier=_default_tier(),
|
||||||
is_active=True,
|
is_active=True,
|
||||||
|
is_superuser=_is_admin_email(user_in.email),
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(user)
|
db.add(user)
|
||||||
@@ -99,8 +145,18 @@ async def login(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
|
status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Domain restriction — superusers always bypass
|
||||||
|
if not user.is_superuser:
|
||||||
|
_check_domain_allowed(user.email)
|
||||||
|
|
||||||
# Update last login
|
# Update last login
|
||||||
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
|
|
||||||
|
# Auto-promote to superuser if this is the configured admin email
|
||||||
|
if not user.is_superuser and _is_admin_email(user.email):
|
||||||
|
user.is_superuser = True # type: ignore[assignment]
|
||||||
|
logger.info(f"Auto-promoted admin user: {user.email}")
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Create tokens
|
# Create tokens
|
||||||
@@ -149,17 +205,30 @@ async def google_oauth(
|
|||||||
# Update last login
|
# Update last login
|
||||||
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
|
|
||||||
|
# Domain restriction — superusers always bypass
|
||||||
|
if not user.is_superuser:
|
||||||
|
_check_domain_allowed(email)
|
||||||
|
|
||||||
|
# Auto-promote to superuser if this is the configured admin email
|
||||||
|
if not user.is_superuser and _is_admin_email(email):
|
||||||
|
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}")
|
logger.info(f"Existing user logged in with Google: {user.email}")
|
||||||
else:
|
else:
|
||||||
|
# Domain restriction check before creating the account
|
||||||
|
_check_domain_allowed(email)
|
||||||
|
|
||||||
# Create new user
|
# Create new user
|
||||||
user = User(
|
user = User(
|
||||||
email=email,
|
email=email,
|
||||||
full_name=user_info.get("full_name"),
|
full_name=user_info.get("full_name"),
|
||||||
google_id=google_id,
|
google_id=google_id,
|
||||||
oauth_provider="google",
|
oauth_provider="google",
|
||||||
subscription_tier=SubscriptionTier.FREE,
|
subscription_tier=_default_tier(),
|
||||||
is_active=True,
|
is_active=True,
|
||||||
last_login_at=datetime.now(timezone.utc),
|
last_login_at=datetime.now(timezone.utc),
|
||||||
|
is_superuser=_is_admin_email(email),
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ from sqlalchemy import select, desc
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.deps import get_current_active_user
|
from app.core.deps import get_current_active_user
|
||||||
from app.core.security import encrypt_credential
|
from app.core.security import encrypt_credential
|
||||||
from app.models.database_models import User, MailAccount, AccountStatus
|
from app.models.database_models import (
|
||||||
|
User,
|
||||||
|
MailAccount,
|
||||||
|
AccountStatus,
|
||||||
|
SubscriptionPlan,
|
||||||
|
)
|
||||||
from app.models.schemas import (
|
from app.models.schemas import (
|
||||||
MailAccountCreate,
|
MailAccountCreate,
|
||||||
MailAccountResponse,
|
MailAccountResponse,
|
||||||
@@ -34,26 +39,41 @@ async def create_mail_account(
|
|||||||
):
|
):
|
||||||
"""Create a new mail account"""
|
"""Create a new mail account"""
|
||||||
|
|
||||||
# Check subscription limits
|
# Superusers are not subject to subscription limits
|
||||||
result = await db.execute(
|
if not current_user.is_superuser:
|
||||||
select(MailAccount).where(MailAccount.user_id == current_user.id)
|
# Count existing accounts for this user
|
||||||
)
|
result = await db.execute(
|
||||||
existing_accounts = result.scalars().all()
|
select(MailAccount).where(MailAccount.user_id == current_user.id)
|
||||||
|
|
||||||
tier_limits = {
|
|
||||||
"free": settings.TIER_FREE_MAX_ACCOUNTS,
|
|
||||||
"basic": settings.TIER_BASIC_MAX_ACCOUNTS,
|
|
||||||
"pro": settings.TIER_PRO_MAX_ACCOUNTS,
|
|
||||||
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
|
|
||||||
}
|
|
||||||
|
|
||||||
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1)
|
|
||||||
|
|
||||||
if len(existing_accounts) >= max_accounts:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
|
||||||
detail="Account limit reached. Upgrade your subscription to add more accounts.",
|
|
||||||
)
|
)
|
||||||
|
existing_accounts = result.scalars().all()
|
||||||
|
|
||||||
|
# Try to look up the limit from the active SubscriptionPlan in the DB first
|
||||||
|
# so that admin-managed plan limits take effect immediately.
|
||||||
|
plan_result = await db.execute(
|
||||||
|
select(SubscriptionPlan).where(
|
||||||
|
SubscriptionPlan.tier == current_user.subscription_tier,
|
||||||
|
SubscriptionPlan.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
plan = plan_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if plan is not None:
|
||||||
|
max_accounts = plan.max_mail_accounts
|
||||||
|
else:
|
||||||
|
# Fall back to env-var / config values when no plan row exists
|
||||||
|
tier_limits = {
|
||||||
|
"free": settings.TIER_FREE_MAX_ACCOUNTS,
|
||||||
|
"basic": settings.TIER_BASIC_MAX_ACCOUNTS,
|
||||||
|
"pro": settings.TIER_PRO_MAX_ACCOUNTS,
|
||||||
|
"enterprise": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
|
||||||
|
}
|
||||||
|
max_accounts = tier_limits.get(current_user.subscription_tier.value, 1)
|
||||||
|
|
||||||
|
if len(existing_accounts) >= max_accounts:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
detail="Account limit reached. Upgrade your subscription to add more accounts.",
|
||||||
|
)
|
||||||
|
|
||||||
# Encrypt password
|
# Encrypt password
|
||||||
encrypted_password = encrypt_credential(account_in.password)
|
encrypted_password = encrypt_credential(account_in.password)
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
|
@router.get("/plans", response_model=List[SubscriptionPlanResponse])
|
||||||
async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
|
async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
|
||||||
"""List all available subscription plans"""
|
"""
|
||||||
|
List subscription plans shown in marketing / pricing pages.
|
||||||
|
|
||||||
|
Zero-price plans (price_monthly == 0) are intentionally excluded so that
|
||||||
|
enterprise / white-label deployments that assign a free plan to all users
|
||||||
|
don't surface that plan in the public pricing UI.
|
||||||
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SubscriptionPlan).where(SubscriptionPlan.is_active == True) # noqa: E712
|
select(SubscriptionPlan).where(
|
||||||
|
SubscriptionPlan.is_active.is_(True),
|
||||||
|
SubscriptionPlan.price_monthly > 0,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@@ -25,10 +34,22 @@ async def list_subscription_plans(db: AsyncSession = Depends(get_db)):
|
|||||||
@router.get("/current")
|
@router.get("/current")
|
||||||
async def get_current_subscription(
|
async def get_current_subscription(
|
||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get current user's subscription details"""
|
"""Get current user's subscription details including plan limits"""
|
||||||
|
plan_result = await db.execute(
|
||||||
|
select(SubscriptionPlan).where(
|
||||||
|
SubscriptionPlan.tier == current_user.subscription_tier,
|
||||||
|
SubscriptionPlan.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
plan = plan_result.scalar_one_or_none()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"tier": current_user.subscription_tier,
|
"tier": current_user.subscription_tier,
|
||||||
"status": current_user.subscription_status,
|
"status": current_user.subscription_status,
|
||||||
"expires_at": current_user.subscription_expires_at,
|
"expires_at": current_user.subscription_expires_at,
|
||||||
|
"max_mail_accounts": plan.max_mail_accounts if plan else None,
|
||||||
|
"max_emails_per_day": plan.max_emails_per_day if plan else None,
|
||||||
|
"check_interval_minutes": plan.check_interval_minutes if plan else None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
APP_NAME: str = "POP3 Forwarder SaaS"
|
APP_NAME: str = "InboxRescue"
|
||||||
APP_VERSION: str = "2.0.0"
|
APP_VERSION: str = "2.0.0"
|
||||||
DEBUG: bool = False
|
DEBUG: bool = False
|
||||||
API_V1_PREFIX: str = "/api/v1"
|
API_V1_PREFIX: str = "/api/v1"
|
||||||
@@ -94,9 +94,18 @@ class Settings(BaseSettings):
|
|||||||
LOG_LEVEL: str = "INFO"
|
LOG_LEVEL: str = "INFO"
|
||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
ADMIN_EMAIL: Optional[str] = None
|
ADMIN_EMAIL: Optional[str] = "christianlouis@gmail.com"
|
||||||
ADMIN_PASSWORD: Optional[str] = None
|
ADMIN_PASSWORD: Optional[str] = None
|
||||||
|
|
||||||
|
# User defaults & access control
|
||||||
|
# Tier assigned to every new user on registration: free | basic | pro | enterprise
|
||||||
|
DEFAULT_USER_TIER: str = "free"
|
||||||
|
# Comma-separated list of allowed email domains (empty = no restriction).
|
||||||
|
# When set, only addresses from these domains may register or log in.
|
||||||
|
# Useful for B2B / Google Workspace installations.
|
||||||
|
# Example: "company.com,subsidiary.com"
|
||||||
|
ALLOWED_DOMAINS: List[str] = []
|
||||||
|
|
||||||
# Mail Server Presets
|
# Mail Server Presets
|
||||||
MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json"
|
MAIL_SERVER_PRESETS_FILE: str = "app/data/mail_server_presets.json"
|
||||||
|
|
||||||
@@ -108,6 +117,23 @@ class Settings(BaseSettings):
|
|||||||
return [i.strip() for i in v.split(",")]
|
return [i.strip() for i in v.split(",")]
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator("ALLOWED_DOMAINS", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def assemble_allowed_domains(cls, v: str | List[str]) -> List[str]:
|
||||||
|
"""Parse allowed domains from a comma-separated environment variable"""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return [d.strip().lower() for d in v.split(",") if d.strip()]
|
||||||
|
return [d.lower() for d in v if d]
|
||||||
|
|
||||||
|
@field_validator("DEFAULT_USER_TIER")
|
||||||
|
@classmethod
|
||||||
|
def validate_default_user_tier(cls, v: str) -> str:
|
||||||
|
"""Ensure DEFAULT_USER_TIER is one of the known tier values"""
|
||||||
|
valid = {"free", "basic", "pro", "enterprise"}
|
||||||
|
if v.lower() not in valid:
|
||||||
|
raise ValueError(f"DEFAULT_USER_TIER must be one of {valid}, got '{v}'")
|
||||||
|
return v.lower()
|
||||||
|
|
||||||
@field_validator("SECRET_KEY")
|
@field_validator("SECRET_KEY")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_secret_key(cls, v: str) -> str:
|
def validate_secret_key(cls, v: str) -> str:
|
||||||
|
|||||||
+2
-2
@@ -65,7 +65,7 @@ def create_application() -> FastAPI:
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.APP_NAME,
|
title=settings.APP_NAME,
|
||||||
version=settings.APP_VERSION,
|
version=settings.APP_VERSION,
|
||||||
description="Multi-tenant POP3/IMAP to Gmail forwarder with subscription management",
|
description="Poll your legacy POP3/IMAP inboxes and deliver everything to Gmail. For real people, not enterprises.",
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs",
|
||||||
redoc_url="/api/redoc",
|
redoc_url="/api/redoc",
|
||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json",
|
||||||
@@ -92,7 +92,7 @@ def create_application() -> FastAPI:
|
|||||||
async def root():
|
async def root():
|
||||||
"""Root endpoint"""
|
"""Root endpoint"""
|
||||||
return {
|
return {
|
||||||
"message": "POP3 Forwarder SaaS API",
|
"message": "InboxRescue API",
|
||||||
"version": settings.APP_VERSION,
|
"version": settings.APP_VERSION,
|
||||||
"docs": "/api/docs",
|
"docs": "/api/docs",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class UserDetailResponse(UserResponse):
|
|||||||
stripe_customer_id: Optional[str] = None
|
stripe_customer_id: Optional[str] = None
|
||||||
subscription_expires_at: Optional[datetime] = None
|
subscription_expires_at: Optional[datetime] = None
|
||||||
last_login_at: Optional[datetime] = None
|
last_login_at: Optional[datetime] = None
|
||||||
|
is_superuser: bool = False
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -377,3 +378,59 @@ class GmailAuthorizeResponse(BaseModel):
|
|||||||
class GmailCallbackRequest(BaseModel):
|
class GmailCallbackRequest(BaseModel):
|
||||||
code: str
|
code: str
|
||||||
redirect_uri: 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
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.database_models import AppSetting
|
from app.models.database_models import AppSetting, SubscriptionPlan, SubscriptionTier
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -338,4 +338,87 @@ class ConfigService:
|
|||||||
if created:
|
if created:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info("Seeded %d default settings into the database", created)
|
logger.info("Seeded %d default settings into the database", created)
|
||||||
|
|
||||||
|
# Seed default subscription plans
|
||||||
|
await ConfigService.seed_default_plans(db)
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def seed_default_plans(db: AsyncSession) -> int:
|
||||||
|
"""
|
||||||
|
Populate the database with default subscription plans (skip existing tiers).
|
||||||
|
|
||||||
|
Limits mirror the env-var defaults so behaviour is unchanged on first boot
|
||||||
|
but can be overridden by admins via the plan management UI.
|
||||||
|
|
||||||
|
Returns the number of plans created.
|
||||||
|
"""
|
||||||
|
from app.core.config import settings # local import to avoid circular deps
|
||||||
|
|
||||||
|
default_plans = [
|
||||||
|
{
|
||||||
|
"tier": SubscriptionTier.FREE,
|
||||||
|
"name": "Free",
|
||||||
|
"description": "Dip your toes in. One old inbox pulled into Gmail, checked every 30 minutes. Free forever, no card needed.",
|
||||||
|
"price_monthly": 0.0,
|
||||||
|
"price_yearly": 0.0,
|
||||||
|
"max_mail_accounts": settings.TIER_FREE_MAX_ACCOUNTS,
|
||||||
|
"max_emails_per_day": 100,
|
||||||
|
"check_interval_minutes": 30,
|
||||||
|
"support_level": "community",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tier": SubscriptionTier.BASIC,
|
||||||
|
"name": "Good",
|
||||||
|
"description": "Got a handful of dusty inboxes you just can't let go of? This one's for you. Less than a coffee per month.",
|
||||||
|
"price_monthly": 0.99,
|
||||||
|
"price_yearly": 9.90,
|
||||||
|
"max_mail_accounts": settings.TIER_BASIC_MAX_ACCOUNTS,
|
||||||
|
"max_emails_per_day": 1000,
|
||||||
|
"check_interval_minutes": 15,
|
||||||
|
"support_level": "email",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tier": SubscriptionTier.PRO,
|
||||||
|
"name": "Better",
|
||||||
|
"description": "You're clearly the type who keeps every email address you've ever had. Respect. Checked every 5 minutes.",
|
||||||
|
"price_monthly": 1.99,
|
||||||
|
"price_yearly": 19.90,
|
||||||
|
"max_mail_accounts": settings.TIER_PRO_MAX_ACCOUNTS,
|
||||||
|
"max_emails_per_day": 10000,
|
||||||
|
"check_interval_minutes": 5,
|
||||||
|
"support_level": "email",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tier": SubscriptionTier.ENTERPRISE,
|
||||||
|
"name": "Best",
|
||||||
|
"description": "Every old inbox you've ever had, all landing neatly in Gmail, checked every minute. The full works.",
|
||||||
|
"price_monthly": 2.99,
|
||||||
|
"price_yearly": 29.90,
|
||||||
|
"max_mail_accounts": settings.TIER_ENTERPRISE_MAX_ACCOUNTS,
|
||||||
|
"max_emails_per_day": 100000,
|
||||||
|
"check_interval_minutes": 1,
|
||||||
|
"support_level": "email",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
for plan_data in default_plans:
|
||||||
|
result = await db.execute(
|
||||||
|
select(SubscriptionPlan).where(
|
||||||
|
SubscriptionPlan.tier == plan_data["tier"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none() is None:
|
||||||
|
db.add(SubscriptionPlan(**plan_data))
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
if created:
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Seeded %d default subscription plans into the database", created)
|
||||||
return created
|
return created
|
||||||
|
|||||||
@@ -276,12 +276,12 @@ class GmailService:
|
|||||||
delivery is functioning as expected. Feel free to delete it.
|
delivery is functioning as expected. Feel free to delete it.
|
||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
Christian Loris
|
Christian Krakau-Louis
|
||||||
DocuElevate
|
DocuElevate
|
||||||
""")
|
""")
|
||||||
|
|
||||||
msg = MIMEText(body, "plain", "utf-8")
|
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["To"] = recipient_email
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
msg["Date"] = format_datetime(now)
|
msg["Date"] = format_datetime(now)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class TestApplicationFactory:
|
|||||||
|
|
||||||
async def test_app_title(self, app):
|
async def test_app_title(self, app):
|
||||||
"""Test that app has correct title"""
|
"""Test that app has correct title"""
|
||||||
assert app.title == "POP3 Forwarder SaaS"
|
assert app.title == "InboxRescue"
|
||||||
|
|
||||||
async def test_app_version(self, app):
|
async def test_app_version(self, app):
|
||||||
"""Test that app has a version"""
|
"""Test that app has a version"""
|
||||||
|
|||||||
@@ -231,6 +231,15 @@ because the API client layer is missing.
|
|||||||
- [ ] Notification preferences UI
|
- [ ] Notification preferences UI
|
||||||
- [ ] Subscription management / billing 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
|
## 📅 Milestone Timeline
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
'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,
|
||||||
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
plan: SubscriptionPlan | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate?: (data: SubscriptionPlanCreate) => void;
|
||||||
|
onUpdate?: (data: 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={() => {
|
||||||
|
if (isEdit && onUpdate) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { tier: _tier, ...updateFields } = form;
|
||||||
|
onUpdate(updateFields);
|
||||||
|
} else if (!isEdit && onCreate) {
|
||||||
|
onCreate(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)}
|
||||||
|
onCreate={(data) => createMutation.mutate(data)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editingPlan && (
|
||||||
|
<PlanFormModal
|
||||||
|
plan={editingPlan}
|
||||||
|
onClose={() => setEditingPlan(null)}
|
||||||
|
onUpdate={(data) =>
|
||||||
|
updateMutation.mutate({ id: editingPlan.id, data })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DashboardLayout>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "POP3 Forwarder - Automatic Email Forwarding to Gmail",
|
title: "InboxRescue — your old inboxes, delivered to Gmail",
|
||||||
description: "Forward your POP3 emails to Gmail automatically with our secure and reliable service",
|
description: "Poll your legacy POP3 and IMAP mailboxes and have everything land quietly in Gmail. Set it once, forget it exists.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
+124
-39
@@ -4,8 +4,61 @@ import { useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { userApi } from '@/lib/api';
|
import { userApi, SubscriptionPlan } from '@/lib/api';
|
||||||
import { Mail, ArrowRight, Shield, Zap, Clock } from 'lucide-react';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { Mail, ArrowRight, Shield, Zap, Clock, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
async function fetchPublicPlans(): Promise<SubscriptionPlan[]> {
|
||||||
|
const res = await api.get<SubscriptionPlan[]>('/subscriptions/plans');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PricingCard({ plan }: { plan: SubscriptionPlan }) {
|
||||||
|
const yearlyMonthly = plan.price_yearly
|
||||||
|
? (plan.price_yearly / 12).toFixed(2)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-md p-8 flex flex-col border border-gray-100 hover:shadow-lg transition-shadow">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-500 flex-1">{plan.description}</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<span className="text-4xl font-extrabold text-gray-900">
|
||||||
|
€{plan.price_monthly.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">/month</span>
|
||||||
|
{yearlyMonthly && (
|
||||||
|
<p className="text-xs text-green-600 mt-1">
|
||||||
|
or €{yearlyMonthly}/mo billed yearly (€{plan.price_yearly?.toFixed(2)})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="mt-6 space-y-2 text-sm text-gray-600">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
|
{plan.max_mail_accounts === 1
|
||||||
|
? '1 mailbox'
|
||||||
|
: `Up to ${plan.max_mail_accounts} mailboxes`}
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
|
Checked every {plan.check_interval_minutes} minute{plan.check_interval_minutes > 1 ? 's' : ''}
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
|
Up to {plan.max_emails_per_day.toLocaleString()} emails/day
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="mt-8 block text-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -29,6 +82,12 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}, [router, setUser, setLoading]);
|
}, [router, setUser, setLoading]);
|
||||||
|
|
||||||
|
const { data: plans } = useQuery({
|
||||||
|
queryKey: ['public-plans'],
|
||||||
|
queryFn: fetchPublicPlans,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
@@ -37,6 +96,8 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasPaidPlans = plans && plans.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
|
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -45,7 +106,7 @@ export default function Home() {
|
|||||||
<div className="flex justify-between items-center py-4">
|
<div className="flex justify-between items-center py-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Mail className="h-8 w-8 text-blue-600 mr-2" />
|
<Mail className="h-8 w-8 text-blue-600 mr-2" />
|
||||||
<h1 className="text-2xl font-bold text-gray-900">POP3 Forwarder</h1>
|
<h1 className="text-2xl font-bold text-gray-900">InboxRescue</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
@@ -58,31 +119,37 @@ export default function Home() {
|
|||||||
href="/register"
|
href="/register"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
Sign Up
|
Sign Up Free
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-6">
|
<h2 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-6">
|
||||||
Forward Your POP3 Emails to Gmail
|
Your old inboxes,{' '}
|
||||||
<br />
|
<span className="text-blue-600">delivered to Gmail.</span>
|
||||||
<span className="text-blue-600">Automatically</span>
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 mb-4 max-w-2xl mx-auto">
|
||||||
Connect your POP3 email accounts and automatically forward all messages to Gmail.
|
You know the ones — that GMX account from 2009, the old ISP address your
|
||||||
Simple, secure, and reliable email forwarding service.
|
bank still sends to, the Hotmail you gave out in school. InboxRescue
|
||||||
|
quietly polls them all and drops everything into your Gmail. Set it once,
|
||||||
|
forget it exists.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-4">
|
<p className="text-base text-gray-500 mb-8 max-w-xl mx-auto">
|
||||||
|
No forwarding rules to configure. No email clients to keep open.
|
||||||
|
Just your mail, where you actually read it.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center gap-4 flex-wrap">
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-lg font-medium"
|
className="flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-lg font-medium"
|
||||||
>
|
>
|
||||||
Get Started Free
|
Get Started — it's free
|
||||||
<ArrowRight className="ml-2 h-5 w-5" />
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
@@ -101,11 +168,11 @@ export default function Home() {
|
|||||||
<Zap className="h-6 w-6" />
|
<Zap className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
Auto-Detection
|
Auto-detects everything
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Automatically detect POP3 server settings from your email address.
|
Type your old email address and InboxRescue figures out the server
|
||||||
Quick and easy setup in minutes.
|
settings. No Googling port numbers required.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,11 +181,11 @@ export default function Home() {
|
|||||||
<Clock className="h-6 w-6" />
|
<Clock className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
Scheduled Checks
|
Runs in the background
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Set custom check intervals for each account. From every minute to once a day,
|
Checks your old inboxes on a schedule you choose — from every minute
|
||||||
you control the frequency.
|
to once a day. New mail appears in Gmail as if it was always there.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,11 +194,11 @@ export default function Home() {
|
|||||||
<Shield className="h-6 w-6" />
|
<Shield className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
Secure & Private
|
Your passwords stay yours
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Your credentials are encrypted and secure. We use SSL/TLS for all connections
|
Credentials are encrypted at rest and never shared. All connections
|
||||||
and OAuth2 for Gmail.
|
use SSL/TLS and Gmail delivery uses OAuth2 — no app passwords needed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,53 +206,71 @@ export default function Home() {
|
|||||||
{/* How It Works */}
|
{/* How It Works */}
|
||||||
<div className="mt-20">
|
<div className="mt-20">
|
||||||
<h3 className="text-3xl font-bold text-center text-gray-900 mb-12">
|
<h3 className="text-3xl font-bold text-center text-gray-900 mb-12">
|
||||||
How It Works
|
Three steps and you're done
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
||||||
1
|
1
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">
|
<h4 className="text-xl font-semibold text-gray-900 mb-2">Add your old inbox</h4>
|
||||||
Connect Accounts
|
|
||||||
</h4>
|
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Add your POP3 email accounts with auto-detected settings
|
Paste the email address — InboxRescue auto-detects the POP3/IMAP
|
||||||
|
settings in seconds.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
||||||
2
|
2
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">
|
<h4 className="text-xl font-semibold text-gray-900 mb-2">Connect Gmail</h4>
|
||||||
Authorize Gmail
|
|
||||||
</h4>
|
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Sign in with Google to allow forwarding to your Gmail
|
Sign in with Google once. InboxRescue delivers mail directly into
|
||||||
|
your inbox using the Gmail API — no SMTP relay needed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
<div className="flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 text-blue-600 text-2xl font-bold mx-auto mb-4">
|
||||||
3
|
3
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-xl font-semibold text-gray-900 mb-2">
|
<h4 className="text-xl font-semibold text-gray-900 mb-2">Close the tab</h4>
|
||||||
Relax & Enjoy
|
|
||||||
</h4>
|
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Emails are automatically forwarded. Monitor activity from your dashboard
|
Seriously, that's it. Your mail arrives automatically from now on.
|
||||||
|
Check the dashboard whenever you like, but you won't need to.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing — only rendered when paid plans exist (hidden in enterprise/all-free mode) */}
|
||||||
|
{hasPaidPlans && (
|
||||||
|
<div className="mt-24">
|
||||||
|
<h3 className="text-3xl font-bold text-center text-gray-900 mb-4">
|
||||||
|
Pricing
|
||||||
|
</h3>
|
||||||
|
<p className="text-center text-gray-500 mb-12 max-w-xl mx-auto">
|
||||||
|
Start free. Upgrade if you need more inboxes or faster checks.
|
||||||
|
Cancel any time — no questions, no fuss.
|
||||||
|
</p>
|
||||||
|
<div className={`grid gap-8 ${plans.length === 1 ? 'max-w-sm mx-auto' : plans.length === 2 ? 'md:grid-cols-2 max-w-2xl mx-auto' : 'md:grid-cols-3'}`}>
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<PricingCard key={plan.id} plan={plan} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-8 text-center text-sm text-gray-500">
|
||||||
|
All paid plans include a{' '}
|
||||||
|
<span className="font-medium">free tier</span> when you first sign up
|
||||||
|
— no credit card required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="mt-20 border-t border-gray-200 bg-white">
|
<footer className="mt-20 border-t border-gray-200 bg-white">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<p className="text-center text-gray-600">
|
<p className="text-center text-gray-600 text-sm">
|
||||||
© 2024 POP3 Forwarder. Secure email forwarding service.
|
© {new Date().getFullYear()} InboxRescue — made for people, not enterprises.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
User
|
User,
|
||||||
|
Shield,
|
||||||
|
Users,
|
||||||
|
CreditCard
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
@@ -35,13 +38,23 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
{ 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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Sidebar for desktop */}
|
{/* Sidebar for desktop */}
|
||||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||||
<div className="flex flex-col flex-grow bg-white border-r border-gray-200">
|
<div className="flex flex-col flex-grow bg-white border-r border-gray-200">
|
||||||
<div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200">
|
<div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200">
|
||||||
<h1 className="text-xl font-bold text-gray-900">POP3 Forwarder</h1>
|
<h1 className="text-xl font-bold text-gray-900">InboxRescue</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 px-2 py-4 space-y-1">
|
<nav className="flex-1 px-2 py-4 space-y-1">
|
||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
@@ -61,6 +74,30 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
</Link>
|
</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>
|
</nav>
|
||||||
<div className="flex-shrink-0 border-t border-gray-200 p-4">
|
<div className="flex-shrink-0 border-t border-gray-200 p-4">
|
||||||
<button
|
<button
|
||||||
@@ -80,7 +117,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
<div className="fixed inset-0 bg-gray-600/75" onClick={() => setSidebarOpen(false)} />
|
<div className="fixed inset-0 bg-gray-600/75" onClick={() => setSidebarOpen(false)} />
|
||||||
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white">
|
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white">
|
||||||
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
|
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
|
||||||
<h1 className="text-xl font-bold text-gray-900">POP3 Forwarder</h1>
|
<h1 className="text-xl font-bold text-gray-900">InboxRescue</h1>
|
||||||
<button onClick={() => setSidebarOpen(false)} className="text-gray-500 hover:text-gray-700">
|
<button onClick={() => setSidebarOpen(false)} className="text-gray-500 hover:text-gray-700">
|
||||||
<X className="h-6 w-6" />
|
<X className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -104,6 +141,31 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
</Link>
|
</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>
|
</nav>
|
||||||
<div className="flex-shrink-0 border-t border-gray-200 p-4">
|
<div className="flex-shrink-0 border-t border-gray-200 p-4">
|
||||||
<button
|
<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 justify-between px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex flex-1 items-center">
|
<div className="flex flex-1 items-center">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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 className="h-4 w-4" />
|
{user?.is_superuser ? <Shield className="h-4 w-4" /> : <User className="h-4 w-4" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<p className="text-sm font-medium text-gray-900">{user?.full_name}</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
full_name: string | null;
|
full_name: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
is_superuser: boolean;
|
||||||
subscription_tier: string;
|
subscription_tier: string;
|
||||||
subscription_status: string;
|
subscription_status: string;
|
||||||
created_at: 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;
|
export default api;
|
||||||
|
|||||||
Reference in New Issue
Block a user