Add admin interface, auto-promote admin email, fix sender name

Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/98c1f90b-0287-4908-a0be-ad066c453cc5

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-26 15:53:24 +00:00
parent 37b0af2eb7
commit 979b7b6cec
12 changed files with 1393 additions and 10 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>
);
}
+413
View File
@@ -0,0 +1,413 @@
'use client';
import { AuthGuard } from '@/components/AuthGuard';
import { DashboardLayout } from '@/components/DashboardLayout';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
adminApi,
SubscriptionPlan,
SubscriptionPlanCreate,
SubscriptionPlanUpdate,
} from '@/lib/api';
import { useAuthStore } from '@/store/authStore';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Shield, Plus, Pencil, Trash2, X, Check } from 'lucide-react';
const TIERS = ['free', 'basic', 'pro', 'enterprise'];
const DEFAULT_FORM: SubscriptionPlanCreate = {
tier: 'free',
name: '',
description: '',
price_monthly: 0,
price_yearly: undefined,
max_mail_accounts: 1,
max_emails_per_day: 1000,
check_interval_minutes: 5,
support_level: 'community',
is_active: true,
};
function PlanFormModal({
plan,
onClose,
onSave,
}: {
plan: SubscriptionPlan | null;
onClose: () => void;
onSave: (data: SubscriptionPlanCreate | SubscriptionPlanUpdate) => void;
}) {
const isEdit = plan !== null;
const [form, setForm] = useState<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={() => onSave(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)}
onSave={(data) => createMutation.mutate(data as SubscriptionPlanCreate)}
/>
)}
{editingPlan && (
<PlanFormModal
plan={editingPlan}
onClose={() => setEditingPlan(null)}
onSave={(data) =>
updateMutation.mutate({ id: editingPlan.id, data: data as SubscriptionPlanUpdate })
}
/>
)}
</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>
);
}
+74 -5
View File
@@ -11,7 +11,10 @@ import {
LogOut,
Menu,
X,
User
User,
Shield,
Users,
CreditCard
} from 'lucide-react';
interface DashboardLayoutProps {
@@ -35,6 +38,16 @@ 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 */}
@@ -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
@@ -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;