From 979b7b6cec8d4edd36f1f36b2060afa1db83697d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:53:24 +0000 Subject: [PATCH] Add admin interface, auto-promote admin email, fix sender name Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/98c1f90b-0287-4908-a0be-ad066c453cc5 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- CHANGELOG.md | 21 + backend/app/api/v1/endpoints/admin.py | 251 +++++++++++- backend/app/api/v1/endpoints/auth.py | 27 ++ backend/app/core/config.py | 2 +- backend/app/models/schemas.py | 57 +++ backend/app/services/gmail_service.py | 4 +- docs/TODO.md | 9 + frontend/src/app/admin/page.tsx | 111 ++++++ frontend/src/app/admin/plans/page.tsx | 413 ++++++++++++++++++++ frontend/src/app/admin/users/page.tsx | 305 +++++++++++++++ frontend/src/components/DashboardLayout.tsx | 79 +++- frontend/src/lib/api.ts | 124 ++++++ 12 files changed, 1393 insertions(+), 10 deletions(-) create mode 100644 frontend/src/app/admin/page.tsx create mode 100644 frontend/src/app/admin/plans/page.tsx create mode 100644 frontend/src/app/admin/users/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 6754953..2d60da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Admin interface**: Superusers now have access to a dedicated Admin section in the sidebar with three pages: + - **Admin Overview** (`/admin`): System-wide stats (total users, mail accounts, processing runs). + - **Manage Users** (`/admin/users`): Table of all registered users with their subscription tier, status, mail account count, and last login. Admins can edit any user's name, email, plan, active status, and promote/demote admin (superuser) privileges. Users can be deleted (with confirmation). + - **Manage Plans** (`/admin/plans`): Full CRUD for subscription plans—create, edit, and delete plans with fields for tier, name, pricing, max mailboxes, max emails/day, check interval, and support level. +- **Auto-promotion of admin email**: When the user whose email matches the `ADMIN_EMAIL` environment variable logs in or registers (via email/password or Google OAuth), they are automatically promoted to superuser. Default value is `christianlouis@gmail.com` (configurable via the `ADMIN_EMAIL` env var). +- **`is_superuser` field in API responses**: `GET /users/me` and all admin user endpoints now include `is_superuser` so the frontend can conditionally show admin UI. +- **New admin API endpoints** (all require superuser role): + - `GET /admin/users` – List all users with mail account counts. + - `GET /admin/users/{id}` – Get a single user's details. + - `PUT /admin/users/{id}` – Update user details, plan, active status, and superuser flag. + - `DELETE /admin/users/{id}` – Delete a user. + - `GET /admin/plans` – List all subscription plans (including inactive). + - `POST /admin/plans` – Create a new subscription plan. + - `PUT /admin/plans/{id}` – Update a subscription plan. + - `DELETE /admin/plans/{id}` – Delete a subscription plan. +- **Admin badge in top bar**: Admin users see a purple shield icon and an "Admin" badge next to their email in the top navigation bar. + +### Fixed +- Test email sender name corrected from "Christian Loris" to "Christian Krakau-Louis". + ### Fixed - Fixed `TypeError: can't subtract offset-naive and offset-aware datetimes` in `process_mail_account` task when computing `duration_seconds`. After a database refresh, `started_at` may be returned as a naive datetime; it is now normalized to UTC before subtraction. 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..a5d6b4e 100644 --- a/backend/app/api/v1/endpoints/auth.py +++ b/backend/app/api/v1/endpoints/auth.py @@ -57,6 +57,10 @@ async def register(user_in: UserCreate, db: AsyncSession = Depends(get_db)): ), subscription_tier=SubscriptionTier.FREE, is_active=True, + is_superuser=( + settings.ADMIN_EMAIL is not None + and user_in.email.lower() == settings.ADMIN_EMAIL.lower() + ), ) db.add(user) @@ -101,6 +105,16 @@ async def login( # Update last login user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment] + + # Auto-promote to superuser if this is the configured admin email + if ( + settings.ADMIN_EMAIL is not None + and user.email.lower() == settings.ADMIN_EMAIL.lower() + and not user.is_superuser + ): + user.is_superuser = True # type: ignore[assignment] + logger.info(f"Auto-promoted admin user: {user.email}") + await db.commit() # Create tokens @@ -149,6 +163,15 @@ async def google_oauth( # Update last login user.last_login_at = datetime.now(timezone.utc) # type: ignore[assignment] + # Auto-promote to superuser if this is the configured admin email + if ( + settings.ADMIN_EMAIL is not None + and user.email.lower() == settings.ADMIN_EMAIL.lower() + and not user.is_superuser + ): + user.is_superuser = True # type: ignore[assignment] + logger.info(f"Auto-promoted admin user via Google OAuth: {user.email}") + logger.info(f"Existing user logged in with Google: {user.email}") else: # Create new user @@ -160,6 +183,10 @@ async def google_oauth( subscription_tier=SubscriptionTier.FREE, is_active=True, last_login_at=datetime.now(timezone.utc), + is_superuser=( + settings.ADMIN_EMAIL is not None + and email.lower() == settings.ADMIN_EMAIL.lower() + ), ) db.add(user) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 070fe26..9c58ec9 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -94,7 +94,7 @@ class Settings(BaseSettings): LOG_LEVEL: str = "INFO" # Admin - ADMIN_EMAIL: Optional[str] = None + ADMIN_EMAIL: Optional[str] = "christianlouis@gmail.com" ADMIN_PASSWORD: Optional[str] = None # Mail Server Presets 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/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/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..db9f103 --- /dev/null +++ b/frontend/src/app/admin/plans/page.tsx @@ -0,0 +1,413 @@ +'use client'; + +import { AuthGuard } from '@/components/AuthGuard'; +import { DashboardLayout } from '@/components/DashboardLayout'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + adminApi, + SubscriptionPlan, + SubscriptionPlanCreate, + SubscriptionPlanUpdate, +} from '@/lib/api'; +import { useAuthStore } from '@/store/authStore'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { Shield, Plus, Pencil, Trash2, X, Check } from 'lucide-react'; + +const TIERS = ['free', 'basic', 'pro', 'enterprise']; + +const DEFAULT_FORM: SubscriptionPlanCreate = { + tier: 'free', + name: '', + description: '', + price_monthly: 0, + price_yearly: undefined, + max_mail_accounts: 1, + max_emails_per_day: 1000, + check_interval_minutes: 5, + support_level: 'community', + is_active: true, +}; + +function PlanFormModal({ + plan, + onClose, + onSave, +}: { + plan: SubscriptionPlan | null; + onClose: () => void; + onSave: (data: SubscriptionPlanCreate | SubscriptionPlanUpdate) => void; +}) { + const isEdit = plan !== null; + const [form, setForm] = useState( + 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 })} + /> +
+
+ +