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:
Christian Krakau-Louis
2026-03-26 17:18:00 +01:00
committed by GitHub
19 changed files with 1763 additions and 83 deletions
+111
View File
@@ -0,0 +1,111 @@
'use client';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Users, Mail, Activity, Shield } from 'lucide-react';
import Link from 'next/link';
export default function AdminPage() {
const { user } = useAuthStore();
const router = useRouter();
useEffect(() => {
if (user && !user.is_superuser) {
router.replace('/dashboard');
}
}, [user, router]);
const { data: stats, isLoading } = useQuery({
queryKey: ['admin-stats'],
queryFn: adminApi.getStats,
enabled: !!user?.is_superuser,
});
if (!user?.is_superuser) return null;
return (
<AuthGuard>
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="h-6 w-6 text-purple-600" />
Admin Overview
</h1>
<p className="mt-1 text-sm text-gray-500">
System-wide statistics and management tools.
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-purple-100">
<Users className="h-6 w-6 text-purple-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Total Users</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.total_users ?? '—'}</p>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-blue-100">
<Mail className="h-6 w-6 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Mail Accounts</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.total_mail_accounts ?? '—'}</p>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
<div className="p-3 rounded-full bg-green-100">
<Activity className="h-6 w-6 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-500">Processing Runs</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.total_processing_runs ?? '—'}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<Link
href="/admin/users"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
>
<div className="p-3 rounded-full bg-purple-100 group-hover:bg-purple-200 transition-colors">
<Users className="h-6 w-6 text-purple-600" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Manage Users</p>
<p className="text-sm text-gray-500">View, edit, assign plans, promote to admin</p>
</div>
</Link>
<Link
href="/admin/plans"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
>
<div className="p-3 rounded-full bg-blue-100 group-hover:bg-blue-200 transition-colors">
<Mail className="h-6 w-6 text-blue-600" />
</div>
<div>
<p className="text-lg font-semibold text-gray-900">Manage Plans</p>
<p className="text-sm text-gray-500">Create and configure subscription plans</p>
</div>
</Link>
</div>
</div>
</DashboardLayout>
</AuthGuard>
);
}
+423
View File
@@ -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>
);
}
+305
View File
@@ -0,0 +1,305 @@
'use client';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi, AdminUser, AdminUserUpdate } from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Shield, Pencil, Trash2, X, Check } from 'lucide-react';
const TIERS = ['free', 'basic', 'pro', 'enterprise'];
const STATUSES = ['active', 'canceled', 'past_due'];
function EditUserModal({
user,
onClose,
onSave,
}: {
user: AdminUser;
onClose: () => void;
onSave: (data: AdminUserUpdate) => void;
}) {
const [form, setForm] = useState<AdminUserUpdate>({
full_name: user.full_name ?? '',
email: user.email,
is_active: user.is_active,
is_superuser: user.is_superuser,
subscription_tier: user.subscription_tier,
subscription_status: user.subscription_status,
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Edit User</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Full Name</label>
<input
type="text"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.full_name ?? ''}
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.email ?? ''}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Subscription Tier</label>
<select
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.subscription_tier ?? 'free'}
onChange={(e) => setForm({ ...form, subscription_tier: e.target.value })}
>
{TIERS.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Subscription Status</label>
<select
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
value={form.subscription_status ?? 'active'}
onChange={(e) => setForm({ ...form, subscription_status: e.target.value })}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ')}
</option>
))}
</select>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
<input
type="checkbox"
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
checked={form.is_active ?? true}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
/>
Active
</label>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
<input
type="checkbox"
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
checked={form.is_superuser ?? false}
onChange={(e) => setForm({ ...form, is_superuser: e.target.checked })}
/>
Admin (superuser)
</label>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={() => onSave(form)}
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
>
Save Changes
</button>
</div>
</div>
</div>
);
}
export default function AdminUsersPage() {
const { user: currentUser } = useAuthStore();
const router = useRouter();
const queryClient = useQueryClient();
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
useEffect(() => {
if (currentUser && !currentUser.is_superuser) {
router.replace('/dashboard');
}
}, [currentUser, router]);
const { data: users, isLoading } = useQuery({
queryKey: ['admin-users'],
queryFn: () => adminApi.listUsers(),
enabled: !!currentUser?.is_superuser,
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: AdminUserUpdate }) =>
adminApi.updateUser(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setEditingUser(null);
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => adminApi.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setDeleteConfirm(null);
},
});
if (!currentUser?.is_superuser) return null;
return (
<AuthGuard>
<DashboardLayout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Shield className="h-6 w-6 text-purple-600" />
Manage Users
</h1>
<p className="mt-1 text-sm text-gray-500">
View all registered users, assign plans, and manage admin privileges.
</p>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{['User', 'Plan', 'Status', 'Accounts', 'Last Login', 'Role', 'Actions'].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{(users ?? []).map((u) => (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div>
<p className="text-sm font-medium text-gray-900">{u.full_name || '—'}</p>
<p className="text-xs text-gray-500">{u.email}</p>
</div>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
{u.subscription_tier}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
u.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{u.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500 text-center">
{u.mail_account_count}
</td>
<td className="px-4 py-3 text-xs text-gray-500">
{u.last_login_at
? new Date(u.last_login_at).toLocaleDateString()
: '—'}
</td>
<td className="px-4 py-3">
{u.is_superuser ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
<Shield className="h-3 w-3" />
Admin
</span>
) : (
<span className="text-xs text-gray-400">User</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => setEditingUser(u)}
className="p-1.5 text-gray-400 hover:text-purple-600 rounded hover:bg-purple-50"
title="Edit user"
>
<Pencil className="h-4 w-4" />
</button>
{u.id !== currentUser?.id && (
deleteConfirm === u.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => deleteMutation.mutate(u.id)}
className="p-1.5 text-white bg-red-600 rounded hover:bg-red-700"
title="Confirm delete"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(u.id)}
className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50"
title="Delete user"
>
<Trash2 className="h-4 w-4" />
</button>
)
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{(users ?? []).length === 0 && (
<div className="text-center py-12 text-gray-500 text-sm">No users found.</div>
)}
</div>
)}
</div>
</div>
{editingUser && (
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSave={(data) => updateMutation.mutate({ id: editingUser.id, data })}
/>
)}
</DashboardLayout>
</AuthGuard>
);
}
+2 -2
View File
@@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "POP3 Forwarder - Automatic Email Forwarding to Gmail",
description: "Forward your POP3 emails to Gmail automatically with our secure and reliable service",
title: "InboxRescue — your old inboxes, delivered to Gmail",
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({
+124 -39
View File
@@ -4,8 +4,61 @@ import { useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/authStore';
import { userApi } from '@/lib/api';
import { Mail, ArrowRight, Shield, Zap, Clock } from 'lucide-react';
import { userApi, SubscriptionPlan } from '@/lib/api';
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() {
const router = useRouter();
@@ -29,6 +82,12 @@ export default function Home() {
});
}, [router, setUser, setLoading]);
const { data: plans } = useQuery({
queryKey: ['public-plans'],
queryFn: fetchPublicPlans,
staleTime: 5 * 60 * 1000,
});
if (isLoading) {
return (
<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 (
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-white">
{/* Header */}
@@ -45,7 +106,7 @@ export default function Home() {
<div className="flex justify-between items-center py-4">
<div className="flex items-center">
<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 className="flex items-center gap-4">
<Link
@@ -58,31 +119,37 @@ export default function Home() {
href="/register"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Sign Up
Sign Up Free
</Link>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
{/* Hero */}
<div className="text-center">
<h2 className="text-4xl sm:text-5xl font-bold text-gray-900 mb-6">
Forward Your POP3 Emails to Gmail
<br />
<span className="text-blue-600">Automatically</span>
Your old inboxes,{' '}
<span className="text-blue-600">delivered to Gmail.</span>
</h2>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
Connect your POP3 email accounts and automatically forward all messages to Gmail.
Simple, secure, and reliable email forwarding service.
<p className="text-xl text-gray-600 mb-4 max-w-2xl mx-auto">
You know the ones that GMX account from 2009, the old ISP address your
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>
<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
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"
>
Get Started Free
Get Started it&apos;s free
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
<Link
@@ -101,11 +168,11 @@ export default function Home() {
<Zap className="h-6 w-6" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Auto-Detection
Auto-detects everything
</h3>
<p className="text-gray-600">
Automatically detect POP3 server settings from your email address.
Quick and easy setup in minutes.
Type your old email address and InboxRescue figures out the server
settings. No Googling port numbers required.
</p>
</div>
@@ -114,11 +181,11 @@ export default function Home() {
<Clock className="h-6 w-6" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Scheduled Checks
Runs in the background
</h3>
<p className="text-gray-600">
Set custom check intervals for each account. From every minute to once a day,
you control the frequency.
Checks your old inboxes on a schedule you choose from every minute
to once a day. New mail appears in Gmail as if it was always there.
</p>
</div>
@@ -127,11 +194,11 @@ export default function Home() {
<Shield className="h-6 w-6" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Secure & Private
Your passwords stay yours
</h3>
<p className="text-gray-600">
Your credentials are encrypted and secure. We use SSL/TLS for all connections
and OAuth2 for Gmail.
Credentials are encrypted at rest and never shared. All connections
use SSL/TLS and Gmail delivery uses OAuth2 no app passwords needed.
</p>
</div>
</div>
@@ -139,53 +206,71 @@ export default function Home() {
{/* How It Works */}
<div className="mt-20">
<h3 className="text-3xl font-bold text-center text-gray-900 mb-12">
How It Works
Three steps and you&apos;re done
</h3>
<div className="grid md:grid-cols-3 gap-8">
<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">
1
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">
Connect Accounts
</h4>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Add your old inbox</h4>
<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>
</div>
<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">
2
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">
Authorize Gmail
</h4>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Connect Gmail</h4>
<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>
</div>
<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">
3
</div>
<h4 className="text-xl font-semibold text-gray-900 mb-2">
Relax & Enjoy
</h4>
<h4 className="text-xl font-semibold text-gray-900 mb-2">Close the tab</h4>
<p className="text-gray-600">
Emails are automatically forwarded. Monitor activity from your dashboard
Seriously, that&apos;s it. Your mail arrives automatically from now on.
Check the dashboard whenever you like, but you won&apos;t need to.
</p>
</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>
{/* Footer */}
<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">
<p className="text-center text-gray-600">
© 2024 POP3 Forwarder. Secure email forwarding service.
<p className="text-center text-gray-600 text-sm">
© {new Date().getFullYear()} InboxRescue made for people, not enterprises.
</p>
</div>
</footer>
+76 -7
View File
@@ -11,7 +11,10 @@ import {
LogOut,
Menu,
X,
User
User,
Shield,
Users,
CreditCard
} from 'lucide-react';
interface DashboardLayoutProps {
@@ -35,13 +38,23 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{ name: 'Settings', href: '/settings', icon: Settings },
];
const adminNavigation = user?.is_superuser
? [
{ name: 'Admin Overview', href: '/admin', icon: Shield },
{ name: 'Manage Users', href: '/admin/users', icon: Users },
{ name: 'Manage Plans', href: '/admin/plans', icon: CreditCard },
]
: [];
const allNavItems = [...navigation, ...adminNavigation];
return (
<div className="min-h-screen bg-gray-50">
{/* Sidebar for desktop */}
<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 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>
<nav className="flex-1 px-2 py-4 space-y-1">
{navigation.map((item) => {
@@ -61,6 +74,30 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
</Link>
);
})}
{adminNavigation.length > 0 && (
<>
<div className="pt-4 pb-1 px-4">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Admin</p>
</div>
{adminNavigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors ${
isActive
? 'bg-purple-50 text-purple-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-purple-600' : 'text-gray-400'}`} />
{item.name}
</Link>
);
})}
</>
)}
</nav>
<div className="flex-shrink-0 border-t border-gray-200 p-4">
<button
@@ -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-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">
<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">
<X className="h-6 w-6" />
</button>
@@ -104,6 +141,31 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
</Link>
);
})}
{adminNavigation.length > 0 && (
<>
<div className="pt-4 pb-1 px-4">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Admin</p>
</div>
{adminNavigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.name}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center px-4 py-2 text-sm font-medium rounded-md transition-colors ${
isActive
? 'bg-purple-50 text-purple-600'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<item.icon className={`mr-3 h-5 w-5 ${isActive ? 'text-purple-600' : 'text-gray-400'}`} />
{item.name}
</Link>
);
})}
</>
)}
</nav>
<div className="flex-shrink-0 border-t border-gray-200 p-4">
<button
@@ -132,17 +194,24 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div className="flex flex-1 justify-between px-4 sm:px-6 lg:px-8">
<div className="flex flex-1 items-center">
<h2 className="text-lg font-semibold text-gray-900">
{navigation.find((item) => item.href === pathname)?.name || 'Dashboard'}
{allNavItems.find((item) => item.href === pathname)?.name || 'Dashboard'}
</h2>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-white">
<User className="h-4 w-4" />
<div className={`flex h-8 w-8 items-center justify-center rounded-full text-white ${user?.is_superuser ? 'bg-purple-600' : 'bg-blue-600'}`}>
{user?.is_superuser ? <Shield className="h-4 w-4" /> : <User className="h-4 w-4" />}
</div>
<div className="hidden sm:block">
<p className="text-sm font-medium text-gray-900">{user?.full_name}</p>
<p className="text-xs text-gray-500">{user?.email}</p>
<p className="text-xs text-gray-500">
{user?.email}
{user?.is_superuser && (
<span className="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
Admin
</span>
)}
</p>
</div>
</div>
</div>
+124
View File
@@ -38,6 +38,7 @@ export interface User {
email: string;
full_name: string | null;
is_active: boolean;
is_superuser: boolean;
subscription_tier: string;
subscription_status: string;
created_at: string;
@@ -361,4 +362,127 @@ export const smtpApi = {
},
};
// ── Admin Types ─────────────────────────────────────────────────────────
export interface AdminUser {
id: number;
email: string;
full_name: string | null;
is_active: boolean;
is_superuser: boolean;
subscription_tier: string;
subscription_status: string;
google_id?: string | null;
oauth_provider?: string | null;
last_login_at?: string | null;
created_at: string;
mail_account_count: number;
}
export interface AdminUserUpdate {
full_name?: string | null;
email?: string;
is_active?: boolean;
is_superuser?: boolean;
subscription_tier?: string;
subscription_status?: string;
}
export interface SubscriptionPlan {
id: number;
tier: string;
name: string;
description?: string | null;
price_monthly: number;
price_yearly?: number | null;
max_mail_accounts: number;
max_emails_per_day: number;
check_interval_minutes: number;
support_level: string;
features?: Record<string, unknown> | null;
is_active: boolean;
}
export interface SubscriptionPlanCreate {
tier: string;
name: string;
description?: string;
price_monthly: number;
price_yearly?: number;
max_mail_accounts: number;
max_emails_per_day: number;
check_interval_minutes: number;
support_level: string;
features?: Record<string, unknown>;
is_active: boolean;
}
export interface SubscriptionPlanUpdate {
name?: string;
description?: string;
price_monthly?: number;
price_yearly?: number;
max_mail_accounts?: number;
max_emails_per_day?: number;
check_interval_minutes?: number;
support_level?: string;
features?: Record<string, unknown>;
is_active?: boolean;
}
export interface AdminStats {
total_users: number;
total_mail_accounts: number;
total_processing_runs: number;
}
// ── Admin API ───────────────────────────────────────────────────────────
export const adminApi = {
async getStats(): Promise<AdminStats> {
const response = await api.get<AdminStats>('/admin/stats');
return response.data;
},
async listUsers(skip = 0, limit = 100): Promise<AdminUser[]> {
const response = await api.get<AdminUser[]>('/admin/users', {
params: { skip, limit },
});
return response.data;
},
async getUser(id: number): Promise<User> {
const response = await api.get<User>(`/admin/users/${id}`);
return response.data;
},
async updateUser(id: number, data: AdminUserUpdate): Promise<User> {
const response = await api.put<User>(`/admin/users/${id}`, data);
return response.data;
},
async deleteUser(id: number): Promise<void> {
await api.delete(`/admin/users/${id}`);
},
async listPlans(): Promise<SubscriptionPlan[]> {
const response = await api.get<SubscriptionPlan[]>('/admin/plans');
return response.data;
},
async createPlan(data: SubscriptionPlanCreate): Promise<SubscriptionPlan> {
const response = await api.post<SubscriptionPlan>('/admin/plans', data);
return response.data;
},
async updatePlan(id: number, data: SubscriptionPlanUpdate): Promise<SubscriptionPlan> {
const response = await api.put<SubscriptionPlan>(`/admin/plans/${id}`, data);
return response.data;
},
async deletePlan(id: number): Promise<void> {
await api.delete(`/admin/plans/${id}`);
},
};
export default api;