diff --git a/CHANGELOG.md b/CHANGELOG.md index 838554a..308879c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Admin interface**: Superusers now have access to a dedicated Admin section in the sidebar with three pages: + - **Admin Overview** (`/admin`): System-wide stats (total users, mail accounts, processing runs). + - **Manage Users** (`/admin/users`): Table of all registered users with their subscription tier, status, mail account count, and last login. Admins can edit any user's name, email, plan, active status, and promote/demote admin (superuser) privileges. Users can be deleted (with confirmation). + - **Manage Plans** (`/admin/plans`): Full CRUD for subscription plans—create, edit, and delete plans with fields for tier, name, pricing, max mailboxes, max emails/day, check interval, and support level. +- **Auto-promotion of admin email**: When the user whose email matches the `ADMIN_EMAIL` environment variable logs in or registers (via email/password or Google OAuth), they are automatically promoted to superuser. Default value is `christianlouis@gmail.com` (configurable via the `ADMIN_EMAIL` env var). +- **`is_superuser` field in API responses**: `GET /users/me` and all admin user endpoints now include `is_superuser` so the frontend can conditionally show admin UI. +- **New admin API endpoints** (all require superuser role): + - `GET /admin/users` – List all users with mail account counts. + - `GET /admin/users/{id}` – Get a single user's details. + - `PUT /admin/users/{id}` – Update user details, plan, active status, and superuser flag. + - `DELETE /admin/users/{id}` – Delete a user. + - `GET /admin/plans` – List all subscription plans (including 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 `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. diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py index 9aeb1e1..ddab0e9 100644 --- a/backend/app/api/v1/endpoints/admin.py +++ b/backend/app/api/v1/endpoints/admin.py @@ -1,12 +1,27 @@ """Admin endpoints""" -from fastapi import APIRouter, Depends +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.core.database import get_db from app.core.deps import get_current_superuser -from app.models.database_models import User, MailAccount, ProcessingRun +from app.models.database_models import ( + User, + MailAccount, + ProcessingRun, + SubscriptionPlan, + SubscriptionTier, +) +from app.models.schemas import ( + AdminUserListResponse, + AdminUserUpdate, + UserDetailResponse, + SubscriptionPlanResponse, + SubscriptionPlanCreate, + SubscriptionPlanUpdate, +) router = APIRouter() @@ -35,3 +50,235 @@ async def get_admin_stats( "total_mail_accounts": total_accounts, "total_processing_runs": total_runs, } + + +# ── User management ──────────────────────────────────────────────────────────── + + +@router.get("/users", response_model=List[AdminUserListResponse]) +async def list_users( + skip: int = 0, + limit: int = 100, + current_user: User = Depends(get_current_superuser), + db: AsyncSession = Depends(get_db), +): + """List all users with their mail account counts (admin only)""" + result = await db.execute( + select(User).order_by(User.created_at.desc()).offset(skip).limit(limit) + ) + users = result.scalars().all() + + # Fetch mail account counts per user in one query + counts_result = await db.execute( + select(MailAccount.user_id, func.count(MailAccount.id).label("cnt")).group_by( + MailAccount.user_id + ) + ) + counts = {row.user_id: row.cnt for row in counts_result} + + response = [] + for u in users: + response.append( + AdminUserListResponse( + id=u.id, # type: ignore[arg-type] + email=u.email, # type: ignore[arg-type] + full_name=u.full_name, # type: ignore[arg-type] + is_active=u.is_active, # type: ignore[arg-type] + is_superuser=u.is_superuser, # type: ignore[arg-type] + subscription_tier=u.subscription_tier, # type: ignore[arg-type] + subscription_status=u.subscription_status, # type: ignore[arg-type] + google_id=u.google_id, # type: ignore[arg-type] + oauth_provider=u.oauth_provider, # type: ignore[arg-type] + last_login_at=u.last_login_at, # type: ignore[arg-type] + created_at=u.created_at, # type: ignore[arg-type] + mail_account_count=counts.get(u.id, 0), # type: ignore[arg-type] + ) + ) + return response + + +@router.get("/users/{user_id}", response_model=UserDetailResponse) +async def get_user( + user_id: int, + current_user: User = Depends(get_current_superuser), + db: AsyncSession = Depends(get_db), +): + """Get a specific user's details (admin only)""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + return user + + +@router.put("/users/{user_id}", response_model=UserDetailResponse) +async def update_user( + user_id: int, + user_update: AdminUserUpdate, + current_user: User = Depends(get_current_superuser), + db: AsyncSession = Depends(get_db), +): + """Update a user's details, plan, or admin status (admin only)""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + + if user_update.full_name is not None: + user.full_name = user_update.full_name # type: ignore[assignment] + if user_update.email is not None: + user.email = user_update.email # type: ignore[assignment] + if user_update.is_active is not None: + user.is_active = user_update.is_active # type: ignore[assignment] + if user_update.is_superuser is not None: + user.is_superuser = user_update.is_superuser # type: ignore[assignment] + if user_update.subscription_tier is not None: + user.subscription_tier = SubscriptionTier(user_update.subscription_tier.value) # type: ignore[assignment] + if user_update.subscription_status is not None: + user.subscription_status = user_update.subscription_status # type: ignore[assignment] + + await db.commit() + await db.refresh(user) + return user + + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: int, + current_user: User = Depends(get_current_superuser), + db: AsyncSession = Depends(get_db), +): + """Delete a user and all their data (admin only)""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + if user.id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete your own account via admin endpoint", + ) + await db.delete(user) + await db.commit() + + +# ── Plan management ──────────────────────────────────────────────────────────── + + +@router.get("/plans", response_model=List[SubscriptionPlanResponse]) +async def list_all_plans( + current_user: User = Depends(get_current_superuser), + db: AsyncSession = Depends(get_db), +): + """List all subscription plans including inactive ones (admin only)""" + result = await db.execute(select(SubscriptionPlan).order_by(SubscriptionPlan.tier)) + return result.scalars().all() + + +@router.post( + "/plans", + response_model=SubscriptionPlanResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_plan( + plan_in: SubscriptionPlanCreate, + current_user: User = Depends(get_current_superuser), + db: AsyncSession = Depends(get_db), +): + """Create a new subscription plan (admin only)""" + existing = await db.execute( + select(SubscriptionPlan).where( + SubscriptionPlan.tier == SubscriptionTier(plan_in.tier.value) + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"A plan for tier '{plan_in.tier.value}' already exists", + ) + + plan = SubscriptionPlan( + tier=SubscriptionTier(plan_in.tier.value), + name=plan_in.name, + description=plan_in.description, + price_monthly=plan_in.price_monthly, + price_yearly=plan_in.price_yearly, + max_mail_accounts=plan_in.max_mail_accounts, + max_emails_per_day=plan_in.max_emails_per_day, + check_interval_minutes=plan_in.check_interval_minutes, + support_level=plan_in.support_level, + features=plan_in.features, + is_active=plan_in.is_active, + ) + db.add(plan) + await db.commit() + await db.refresh(plan) + return plan + + +@router.put("/plans/{plan_id}", response_model=SubscriptionPlanResponse) +async def update_plan( + plan_id: int, + plan_update: SubscriptionPlanUpdate, + current_user: User = Depends(get_current_superuser), + db: AsyncSession = Depends(get_db), +): + """Update a subscription plan's limits or pricing (admin only)""" + result = await db.execute( + select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id) + ) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found" + ) + + if plan_update.name is not None: + plan.name = plan_update.name # type: ignore[assignment] + if plan_update.description is not None: + plan.description = plan_update.description # type: ignore[assignment] + if plan_update.price_monthly is not None: + plan.price_monthly = plan_update.price_monthly # type: ignore[assignment] + if plan_update.price_yearly is not None: + plan.price_yearly = plan_update.price_yearly # type: ignore[assignment] + if plan_update.max_mail_accounts is not None: + plan.max_mail_accounts = plan_update.max_mail_accounts # type: ignore[assignment] + if plan_update.max_emails_per_day is not None: + plan.max_emails_per_day = plan_update.max_emails_per_day # type: ignore[assignment] + if plan_update.check_interval_minutes is not None: + plan.check_interval_minutes = plan_update.check_interval_minutes # type: ignore[assignment] + if plan_update.support_level is not None: + plan.support_level = plan_update.support_level # type: ignore[assignment] + if plan_update.features is not None: + plan.features = plan_update.features # type: ignore[assignment] + if plan_update.is_active is not None: + plan.is_active = plan_update.is_active # type: ignore[assignment] + + await db.commit() + await db.refresh(plan) + return plan + + +@router.delete("/plans/{plan_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_plan( + plan_id: int, + current_user: User = Depends(get_current_superuser), + db: AsyncSession = Depends(get_db), +): + """Delete a subscription plan (admin only)""" + result = await db.execute( + select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id) + ) + plan = result.scalar_one_or_none() + if not plan: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found" + ) + await db.delete(plan) + await db.commit() diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py index e980b50..1c99855 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -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( "/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" ) + # Domain restriction check (before creating the account) + _check_domain_allowed(user_in.email) + # Create new user user = User( email=user_in.email, @@ -55,8 +100,9 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)): hashed_password=( get_password_hash(user_in.password) if user_in.password else None ), - subscription_tier=SubscriptionTier.FREE, + subscription_tier=_default_tier(), is_active=True, + is_superuser=_is_admin_email(user_in.email), ) db.add(user) @@ -99,8 +145,18 @@ async def login( 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 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() # Create tokens @@ -149,17 +205,30 @@ async def google_oauth( # Update last login 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}") else: + # Domain restriction check before creating the account + _check_domain_allowed(email) + # Create new user user = User( email=email, full_name=user_info.get("full_name"), google_id=google_id, oauth_provider="google", - subscription_tier=SubscriptionTier.FREE, + subscription_tier=_default_tier(), is_active=True, last_login_at=datetime.now(timezone.utc), + is_superuser=_is_admin_email(email), ) db.add(user) diff --git a/backend/app/api/v1/endpoints/mail_accounts.py b/backend/app/api/v1/endpoints/mail_accounts.py index 196b142..8e2d5b1 100644 --- a/backend/app/api/v1/endpoints/mail_accounts.py +++ b/backend/app/api/v1/endpoints/mail_accounts.py @@ -8,7 +8,12 @@ from sqlalchemy import select, desc from app.core.database import get_db from app.core.deps import get_current_active_user 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 ( MailAccountCreate, MailAccountResponse, @@ -34,26 +39,41 @@ async def create_mail_account( ): """Create a new mail account""" - # Check subscription limits - result = await db.execute( - select(MailAccount).where(MailAccount.user_id == current_user.id) - ) - existing_accounts = result.scalars().all() - - 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.", + # Superusers are not subject to subscription limits + if not current_user.is_superuser: + # Count existing accounts for this user + result = await db.execute( + select(MailAccount).where(MailAccount.user_id == current_user.id) ) + 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 encrypted_password = encrypt_credential(account_in.password) diff --git a/backend/app/api/v1/endpoints/subscriptions.py b/backend/app/api/v1/endpoints/subscriptions.py index d25f02f..91d3d93 100644 --- a/backend/app/api/v1/endpoints/subscriptions.py +++ b/backend/app/api/v1/endpoints/subscriptions.py @@ -15,9 +15,18 @@ router = APIRouter() @router.get("/plans", response_model=List[SubscriptionPlanResponse]) 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( - 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() @@ -25,10 +34,22 @@ async def list_subscription_plans(db: AsyncSession = Depends(get_db)): @router.get("/current") async def get_current_subscription( 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 { "tier": current_user.subscription_tier, "status": current_user.subscription_status, "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, } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 070fe26..2752c46 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -27,7 +27,7 @@ class Settings(BaseSettings): ) # Application - APP_NAME: str = "POP3 Forwarder SaaS" + APP_NAME: str = "InboxRescue" APP_VERSION: str = "2.0.0" DEBUG: bool = False API_V1_PREFIX: str = "/api/v1" @@ -94,9 +94,18 @@ class Settings(BaseSettings): LOG_LEVEL: str = "INFO" # Admin - ADMIN_EMAIL: Optional[str] = None + ADMIN_EMAIL: Optional[str] = "christianlouis@gmail.com" ADMIN_PASSWORD: Optional[str] = None + # 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_FILE: str = "app/data/mail_server_presets.json" @@ -108,6 +117,23 @@ class Settings(BaseSettings): return [i.strip() for i in v.split(",")] 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") @classmethod def validate_secret_key(cls, v: str) -> str: diff --git a/backend/app/main.py b/backend/app/main.py index cd53368..31b2c53 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -65,7 +65,7 @@ def create_application() -> FastAPI: app = FastAPI( title=settings.APP_NAME, 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", redoc_url="/api/redoc", openapi_url="/api/openapi.json", @@ -92,7 +92,7 @@ def create_application() -> FastAPI: async def root(): """Root endpoint""" return { - "message": "POP3 Forwarder SaaS API", + "message": "InboxRescue API", "version": settings.APP_VERSION, "docs": "/api/docs", } diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 4c99c59..c5882ab 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -74,6 +74,7 @@ class UserDetailResponse(UserResponse): stripe_customer_id: Optional[str] = None subscription_expires_at: Optional[datetime] = None last_login_at: Optional[datetime] = None + is_superuser: bool = False model_config = ConfigDict(from_attributes=True) @@ -377,3 +378,59 @@ class GmailAuthorizeResponse(BaseModel): class GmailCallbackRequest(BaseModel): code: str redirect_uri: str + + +# Admin Schemas + + +class AdminUserListResponse(BaseModel): + id: int + email: str + full_name: Optional[str] = None + is_active: bool + is_superuser: bool + subscription_tier: SubscriptionTier + subscription_status: str + google_id: Optional[str] = None + oauth_provider: Optional[str] = None + last_login_at: Optional[datetime] = None + created_at: datetime + mail_account_count: int = 0 + + model_config = ConfigDict(from_attributes=True) + + +class AdminUserUpdate(BaseModel): + full_name: Optional[str] = None + email: Optional[EmailStr] = None + is_active: Optional[bool] = None + is_superuser: Optional[bool] = None + subscription_tier: Optional[SubscriptionTier] = None + subscription_status: Optional[str] = None + + +class SubscriptionPlanCreate(BaseModel): + tier: SubscriptionTier + name: str + description: Optional[str] = None + price_monthly: float = 0.0 + price_yearly: Optional[float] = None + max_mail_accounts: int = 1 + max_emails_per_day: int = 1000 + check_interval_minutes: int = 5 + support_level: str = "community" + features: Optional[Dict[str, Any]] = None + is_active: bool = True + + +class SubscriptionPlanUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + price_monthly: Optional[float] = None + price_yearly: Optional[float] = None + max_mail_accounts: Optional[int] = None + max_emails_per_day: Optional[int] = None + check_interval_minutes: Optional[int] = None + support_level: Optional[str] = None + features: Optional[Dict[str, Any]] = None + is_active: Optional[bool] = None diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py index 142a997..2fd5b23 100644 --- a/backend/app/services/config_service.py +++ b/backend/app/services/config_service.py @@ -20,7 +20,7 @@ from typing import Any, Dict, List, Optional from sqlalchemy import select 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__) @@ -338,4 +338,87 @@ class ConfigService: if created: await db.commit() 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 diff --git a/backend/app/services/gmail_service.py b/backend/app/services/gmail_service.py index 18221e5..505d976 100644 --- a/backend/app/services/gmail_service.py +++ b/backend/app/services/gmail_service.py @@ -276,12 +276,12 @@ class GmailService: delivery is functioning as expected. Feel free to delete it. Best regards, - Christian Loris + Christian Krakau-Louis DocuElevate """) msg = MIMEText(body, "plain", "utf-8") - msg["From"] = "Christian Loris " + msg["From"] = "Christian Krakau-Louis " msg["To"] = recipient_email msg["Subject"] = subject msg["Date"] = format_datetime(now) diff --git a/backend/tests/unit/test_app.py b/backend/tests/unit/test_app.py index f868886..ac8f017 100644 --- a/backend/tests/unit/test_app.py +++ b/backend/tests/unit/test_app.py @@ -89,7 +89,7 @@ class TestApplicationFactory: async def test_app_title(self, app): """Test that app has correct title""" - assert app.title == "POP3 Forwarder SaaS" + assert app.title == "InboxRescue" async def test_app_version(self, app): """Test that app has a version""" diff --git a/docs/TODO.md b/docs/TODO.md index 2aa806e..eb04299 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -231,6 +231,15 @@ because the API client layer is missing. - [ ] Notification preferences UI - [ ] Subscription management / billing UI +### Admin Interface ✅ +- [x] Admin section in sidebar (visible to superusers only) +- [x] Admin overview page (`/admin`) with system-wide stats +- [x] User management page (`/admin/users`) — list, edit, delete users; assign plans; promote/demote admin +- [x] Plan management page (`/admin/plans`) — full CRUD for subscription plans (mailboxes, emails/day, interval, pricing) +- [x] `ADMIN_EMAIL` env var with default `christianlouis@gmail.com`; admin auto-promoted on login +- [x] `is_superuser` exposed in `/users/me` response +- [x] Admin badge (purple shield) shown in top bar for superusers + --- ## 📅 Milestone Timeline diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..953d71a --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -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 ( + + +
+
+

+ + Admin Overview +

+

+ System-wide statistics and management tools. +

+
+ + {isLoading ? ( +
+
+
+ ) : ( +
+
+
+ +
+
+

Total Users

+

{stats?.total_users ?? '—'}

+
+
+
+
+ +
+
+

Mail Accounts

+

{stats?.total_mail_accounts ?? '—'}

+
+
+
+
+ +
+
+

Processing Runs

+

{stats?.total_processing_runs ?? '—'}

+
+
+
+ )} + +
+ +
+ +
+
+

Manage Users

+

View, edit, assign plans, promote to admin

+
+ + +
+ +
+
+

Manage Plans

+

Create and configure subscription plans

+
+ +
+
+ + + ); +} diff --git a/frontend/src/app/admin/plans/page.tsx b/frontend/src/app/admin/plans/page.tsx new file mode 100644 index 0000000..4e4b208 --- /dev/null +++ b/frontend/src/app/admin/plans/page.tsx @@ -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( + 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 ( +
+
+
+

+ {isEdit ? 'Edit Plan' : 'Create Plan'} +

+ +
+
+ {!isEdit && ( +
+ + +
+ )} +
+ + setForm({ ...form, name: e.target.value })} + /> +
+
+ +