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:
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '@/lib/api';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { Users, Mail, Activity, Shield } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !user.is_superuser) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ['admin-stats'],
|
||||
queryFn: adminApi.getStats,
|
||||
enabled: !!user?.is_superuser,
|
||||
});
|
||||
|
||||
if (!user?.is_superuser) return null;
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Shield className="h-6 w-6 text-purple-600" />
|
||||
Admin Overview
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
System-wide statistics and management tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-purple-100">
|
||||
<Users className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Total Users</p>
|
||||
<p className="text-3xl font-semibold text-gray-900">{stats?.total_users ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-blue-100">
|
||||
<Mail className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Mail Accounts</p>
|
||||
<p className="text-3xl font-semibold text-gray-900">{stats?.total_mail_accounts ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-green-100">
|
||||
<Activity className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">Processing Runs</p>
|
||||
<p className="text-3xl font-semibold text-gray-900">{stats?.total_processing_runs ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<Link
|
||||
href="/admin/users"
|
||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
|
||||
>
|
||||
<div className="p-3 rounded-full bg-purple-100 group-hover:bg-purple-200 transition-colors">
|
||||
<Users className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">Manage Users</p>
|
||||
<p className="text-sm text-gray-500">View, edit, assign plans, promote to admin</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/plans"
|
||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-center gap-4 group"
|
||||
>
|
||||
<div className="p-3 rounded-full bg-blue-100 group-hover:bg-blue-200 transition-colors">
|
||||
<Mail className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-gray-900">Manage Plans</p>
|
||||
<p className="text-sm text-gray-500">Create and configure subscription plans</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
'use client';
|
||||
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
adminApi,
|
||||
SubscriptionPlan,
|
||||
SubscriptionPlanCreate,
|
||||
SubscriptionPlanUpdate,
|
||||
} from '@/lib/api';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Shield, Plus, Pencil, Trash2, X, Check } from 'lucide-react';
|
||||
|
||||
const TIERS = ['free', 'basic', 'pro', 'enterprise'];
|
||||
|
||||
const DEFAULT_FORM: SubscriptionPlanCreate = {
|
||||
tier: 'free',
|
||||
name: '',
|
||||
description: '',
|
||||
price_monthly: 0,
|
||||
price_yearly: undefined,
|
||||
max_mail_accounts: 1,
|
||||
max_emails_per_day: 1000,
|
||||
check_interval_minutes: 5,
|
||||
support_level: 'community',
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
function PlanFormModal({
|
||||
plan,
|
||||
onClose,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
}: {
|
||||
plan: SubscriptionPlan | null;
|
||||
onClose: () => void;
|
||||
onCreate?: (data: SubscriptionPlanCreate) => void;
|
||||
onUpdate?: (data: SubscriptionPlanUpdate) => void;
|
||||
}) {
|
||||
const isEdit = plan !== null;
|
||||
const [form, setForm] = useState<SubscriptionPlanCreate>(
|
||||
plan
|
||||
? {
|
||||
tier: plan.tier,
|
||||
name: plan.name,
|
||||
description: plan.description ?? '',
|
||||
price_monthly: plan.price_monthly,
|
||||
price_yearly: plan.price_yearly ?? undefined,
|
||||
max_mail_accounts: plan.max_mail_accounts,
|
||||
max_emails_per_day: plan.max_emails_per_day,
|
||||
check_interval_minutes: plan.check_interval_minutes,
|
||||
support_level: plan.support_level,
|
||||
is_active: plan.is_active,
|
||||
}
|
||||
: DEFAULT_FORM
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6 overflow-y-auto max-h-[90vh]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{isEdit ? 'Edit Plan' : 'Create Plan'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Tier</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.tier}
|
||||
onChange={(e) => setForm({ ...form, tier: e.target.value })}
|
||||
>
|
||||
{TIERS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.description ?? ''}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Monthly Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.price_monthly}
|
||||
onChange={(e) => setForm({ ...form, price_monthly: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Yearly Price ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.price_yearly ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
price_yearly: e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Max Mailboxes</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.max_mail_accounts}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, max_mail_accounts: parseInt(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Max Emails/Day</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.max_emails_per_day}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, max_emails_per_day: parseInt(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Check Interval (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.check_interval_minutes}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, check_interval_minutes: parseInt(e.target.value) || 5 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Support Level</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.support_level}
|
||||
onChange={(e) => setForm({ ...form, support_level: e.target.value })}
|
||||
>
|
||||
{['community', 'email', 'priority'].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
checked={form.is_active}
|
||||
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
|
||||
/>
|
||||
Active (visible to users)
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isEdit && onUpdate) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { tier: _tier, ...updateFields } = form;
|
||||
onUpdate(updateFields);
|
||||
} else if (!isEdit && onCreate) {
|
||||
onCreate(form);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
|
||||
>
|
||||
{isEdit ? 'Save Changes' : 'Create Plan'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPlansPage() {
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingPlan, setEditingPlan] = useState<SubscriptionPlan | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !user.is_superuser) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
const { data: plans, isLoading } = useQuery({
|
||||
queryKey: ['admin-plans'],
|
||||
queryFn: adminApi.listPlans,
|
||||
enabled: !!user?.is_superuser,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: SubscriptionPlanCreate) => adminApi.createPlan(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
setShowCreate(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: SubscriptionPlanUpdate }) =>
|
||||
adminApi.updatePlan(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
setEditingPlan(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => adminApi.deletePlan(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-plans'] });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.is_superuser) return null;
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Shield className="h-6 w-6 text-purple-600" />
|
||||
Manage Plans
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Configure subscription plans, limits, and pricing.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Plan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{['Tier', 'Name', 'Price/mo', 'Mailboxes', 'Emails/day', 'Interval', 'Support', 'Status', 'Actions'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{(plans ?? []).map((p) => (
|
||||
<tr key={p.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
|
||||
{p.tier}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-gray-900">{p.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
${p.price_monthly.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
{p.max_mail_accounts}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
{p.max_emails_per_day.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
{p.check_interval_minutes}m
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 capitalize">
|
||||
{p.support_level}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
p.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{p.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditingPlan(p)}
|
||||
className="p-1.5 text-gray-400 hover:text-purple-600 rounded hover:bg-purple-50"
|
||||
title="Edit plan"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
{deleteConfirm === p.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(p.id)}
|
||||
className="p-1.5 text-white bg-red-600 rounded hover:bg-red-700"
|
||||
title="Confirm delete"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(p.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50"
|
||||
title="Delete plan"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(plans ?? []).length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">
|
||||
No plans found. Create one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<PlanFormModal
|
||||
plan={null}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreate={(data) => createMutation.mutate(data)}
|
||||
/>
|
||||
)}
|
||||
{editingPlan && (
|
||||
<PlanFormModal
|
||||
plan={editingPlan}
|
||||
onClose={() => setEditingPlan(null)}
|
||||
onUpdate={(data) =>
|
||||
updateMutation.mutate({ id: editingPlan.id, data })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import { AuthGuard } from '@/components/AuthGuard';
|
||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { adminApi, AdminUser, AdminUserUpdate } from '@/lib/api';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Shield, Pencil, Trash2, X, Check } from 'lucide-react';
|
||||
|
||||
const TIERS = ['free', 'basic', 'pro', 'enterprise'];
|
||||
const STATUSES = ['active', 'canceled', 'past_due'];
|
||||
|
||||
function EditUserModal({
|
||||
user,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
onSave: (data: AdminUserUpdate) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<AdminUserUpdate>({
|
||||
full_name: user.full_name ?? '',
|
||||
email: user.email,
|
||||
is_active: user.is_active,
|
||||
is_superuser: user.is_superuser,
|
||||
subscription_tier: user.subscription_tier,
|
||||
subscription_status: user.subscription_status,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Edit User</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.full_name ?? ''}
|
||||
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.email ?? ''}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Subscription Tier</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.subscription_tier ?? 'free'}
|
||||
onChange={(e) => setForm({ ...form, subscription_tier: e.target.value })}
|
||||
>
|
||||
{TIERS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Subscription Status</label>
|
||||
<select
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
value={form.subscription_status ?? 'active'}
|
||||
onChange={(e) => setForm({ ...form, subscription_status: e.target.value })}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
checked={form.is_active ?? true}
|
||||
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
checked={form.is_superuser ?? false}
|
||||
onChange={(e) => setForm({ ...form, is_superuser: e.target.checked })}
|
||||
/>
|
||||
Admin (superuser)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave(form)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser && !currentUser.is_superuser) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [currentUser, router]);
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['admin-users'],
|
||||
queryFn: () => adminApi.listUsers(),
|
||||
enabled: !!currentUser?.is_superuser,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: AdminUserUpdate }) =>
|
||||
adminApi.updateUser(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
setEditingUser(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => adminApi.deleteUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentUser?.is_superuser) return null;
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Shield className="h-6 w-6 text-purple-600" />
|
||||
Manage Users
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
View all registered users, assign plans, and manage admin privileges.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{['User', 'Plan', 'Status', 'Accounts', 'Last Login', 'Role', 'Actions'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{(users ?? []).map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{u.full_name || '—'}</p>
|
||||
<p className="text-xs text-gray-500">{u.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
|
||||
{u.subscription_tier}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
u.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{u.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 text-center">
|
||||
{u.mail_account_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">
|
||||
{u.last_login_at
|
||||
? new Date(u.last_login_at).toLocaleDateString()
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{u.is_superuser ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<Shield className="h-3 w-3" />
|
||||
Admin
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">User</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setEditingUser(u)}
|
||||
className="p-1.5 text-gray-400 hover:text-purple-600 rounded hover:bg-purple-50"
|
||||
title="Edit user"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
{u.id !== currentUser?.id && (
|
||||
deleteConfirm === u.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(u.id)}
|
||||
className="p-1.5 text-white bg-red-600 rounded hover:bg-red-700"
|
||||
title="Confirm delete"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(u.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50"
|
||||
title="Delete user"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(users ?? []).length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">No users found.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingUser && (
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onSave={(data) => updateMutation.mutate({ id: editingUser.id, data })}
|
||||
/>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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
@@ -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'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'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's it. Your mail arrives automatically from now on.
|
||||
Check the dashboard whenever you like, but you won'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user