Merge pull request #86 from christianlouis/copilot/check-admin-dashboard-access
Fix blank admin pages and missing superuser flag on existing accounts
This commit is contained in:
@@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
- Fixed `TypeError: can't subtract offset-naive and offset-aware datetimes` in `process_mail_account` task when computing `duration_seconds`. After a database refresh, `started_at` may be returned as a naive datetime; it is now normalized to UTC before subtraction.
|
||||
- **Admin user not seeing admin dashboard**: Added startup auto-promotion in `main.py` lifespan handler — on every application start, if the user matching `ADMIN_EMAIL` exists in the database but does not yet have `is_superuser=True`, they are promoted immediately. This fixes accounts created before the auto-promotion-on-login code was deployed (e.g. `christianlouis@gmail.com` was logged in but saw no admin section).
|
||||
- **Blank page on direct navigation to `/admin`, `/admin/users`, `/admin/plans`**: All three admin pages had `if (!user?.is_superuser) return null` before the `<AuthGuard>` was ever rendered. On a direct page load or refresh the Zustand store initialises with `user = null`, so the guard fired immediately and returned an empty render — `AuthGuard` was never mounted, its `checkAuth` effect never ran, and the user data was never fetched. Fixed by removing the early return and moving the superuser guard inside the `<AuthGuard>/<DashboardLayout>` tree, so authentication always runs first.
|
||||
|
||||
### Security
|
||||
- Upgraded `python-jose` from 3.3.0 to 3.5.0 to fix CVE: algorithm confusion vulnerability with OpenSSH ECDSA keys (affected versions < 3.4.0).
|
||||
|
||||
@@ -58,6 +58,34 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
except Exception as exc:
|
||||
logger.warning("Could not seed default settings: %s", exc, exc_info=True)
|
||||
|
||||
# Ensure the configured ADMIN_EMAIL user has is_superuser=True.
|
||||
# This runs on every startup so that existing accounts created before the
|
||||
# auto-promotion login logic existed are also promoted correctly.
|
||||
if settings.ADMIN_EMAIL:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
from app.core.database import async_session_maker
|
||||
from app.models.database_models import User
|
||||
|
||||
async with async_session_maker() as db:
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
func.lower(User.email) == settings.ADMIN_EMAIL.lower()
|
||||
)
|
||||
)
|
||||
admin_user = result.scalar_one_or_none()
|
||||
if admin_user and not admin_user.is_superuser:
|
||||
admin_user.is_superuser = True # type: ignore[assignment]
|
||||
await db.commit()
|
||||
logger.info(
|
||||
"Auto-promoted admin user to superuser on startup: %s",
|
||||
admin_user.email,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not auto-promote admin user on startup: %s", exc, exc_info=True
|
||||
)
|
||||
|
||||
yield
|
||||
# Shutdown
|
||||
logger.info("Shutting down application")
|
||||
|
||||
+2
-1
@@ -245,9 +245,10 @@ because the API client layer is missing.
|
||||
- [x] Admin overview page (`/admin`) with system-wide stats
|
||||
- [x] User management page (`/admin/users`) — list, edit, delete users; assign plans; promote/demote admin
|
||||
- [x] Plan management page (`/admin/plans`) — full CRUD for subscription plans (mailboxes, emails/day, interval, pricing)
|
||||
- [x] `ADMIN_EMAIL` env var with default `christianlouis@gmail.com`; admin auto-promoted on login
|
||||
- [x] `ADMIN_EMAIL` env var with default `christianlouis@gmail.com`; admin auto-promoted on login and on every application startup (fixes pre-existing accounts)
|
||||
- [x] `is_superuser` exposed in `/users/me` response
|
||||
- [x] Admin badge (purple shield) shown in top bar for superusers
|
||||
- [x] Fix blank page on direct navigation to `/admin*`: moved superuser guard inside `<AuthGuard>` so auth check always runs on fresh load
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,85 +26,97 @@ export default function AdminPage() {
|
||||
enabled: !!user?.is_superuser,
|
||||
});
|
||||
|
||||
if (!user?.is_superuser) return null;
|
||||
|
||||
// AuthGuard must always render so it can fetch the current user and handle
|
||||
// unauthenticated redirects. The early-return that was here prevented
|
||||
// AuthGuard from ever mounting on a direct navigation to /admin, leaving a
|
||||
// permanent blank page. The superuser guard is now applied inside the
|
||||
// layout so that the auth check always runs first.
|
||||
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>
|
||||
{!user?.is_superuser ? (
|
||||
// Shown briefly while AuthGuard resolves the current user, or while
|
||||
// the non-superuser redirect in the useEffect at the top of this
|
||||
// component fires (router.replace('/dashboard')).
|
||||
<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>
|
||||
|
||||
{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 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>
|
||||
) : (
|
||||
<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">
|
||||
|
||||
{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-sm font-medium text-gray-500">Total Users</p>
|
||||
<p className="text-3xl font-semibold text-gray-900">{stats?.total_users ?? '—'}</p>
|
||||
<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>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6 flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-blue-100">
|
||||
</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-sm font-medium text-gray-500">Mail Accounts</p>
|
||||
<p className="text-3xl font-semibold text-gray-900">{stats?.total_mail_accounts ?? '—'}</p>
|
||||
<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>
|
||||
</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>
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -270,152 +270,159 @@ export default function AdminPlansPage() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.is_superuser) return null;
|
||||
|
||||
// AuthGuard must always render (see admin/page.tsx for explanation).
|
||||
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>
|
||||
{!user?.is_superuser ? (
|
||||
<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="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 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="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"
|
||||
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
{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'
|
||||
}`}
|
||||
>
|
||||
<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.
|
||||
{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>
|
||||
</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 })
|
||||
}
|
||||
/>
|
||||
{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>
|
||||
|
||||
@@ -162,142 +162,149 @@ export default function AdminUsersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentUser?.is_superuser) return null;
|
||||
|
||||
// AuthGuard must always render (see admin/page.tsx for explanation).
|
||||
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>
|
||||
{!currentUser?.is_superuser ? (
|
||||
<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="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 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="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"
|
||||
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
{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={() => setDeleteConfirm(u.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded hover:bg-red-50"
|
||||
title="Delete user"
|
||||
onClick={() => setEditingUser(u)}
|
||||
className="p-1.5 text-gray-400 hover:text-purple-600 rounded hover:bg-purple-50"
|
||||
title="Edit user"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Pencil 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>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingUser && (
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onSave={(data) => updateMutation.mutate({ id: editingUser.id, data })}
|
||||
/>
|
||||
{editingUser && (
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onSave={(data) => updateMutation.mutate({ id: editingUser.id, data })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
</AuthGuard>
|
||||
|
||||
Reference in New Issue
Block a user