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:
Christian Krakau-Louis
2026-03-26 18:45:13 +01:00
committed by GitHub
6 changed files with 380 additions and 323 deletions
+2
View File
@@ -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).
+28
View File
@@ -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
View File
@@ -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
---
+14 -2
View File
@@ -26,11 +26,22 @@ 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>
{!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>
) : (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
@@ -105,6 +116,7 @@ export default function AdminPage() {
</Link>
</div>
</div>
)}
</DashboardLayout>
</AuthGuard>
);
+9 -2
View File
@@ -270,11 +270,16 @@ export default function AdminPlansPage() {
},
});
if (!user?.is_superuser) return null;
// AuthGuard must always render (see admin/page.tsx for explanation).
return (
<AuthGuard>
<DashboardLayout>
{!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="space-y-6">
<div className="flex items-center justify-between">
<div>
@@ -417,6 +422,8 @@ export default function AdminPlansPage() {
}
/>
)}
</>
)}
</DashboardLayout>
</AuthGuard>
);
+9 -2
View File
@@ -162,11 +162,16 @@ export default function AdminUsersPage() {
},
});
if (!currentUser?.is_superuser) return null;
// AuthGuard must always render (see admin/page.tsx for explanation).
return (
<AuthGuard>
<DashboardLayout>
{!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="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
@@ -299,6 +304,8 @@ export default function AdminUsersPage() {
onSave={(data) => updateMutation.mutate({ id: editingUser.id, data })}
/>
)}
</>
)}
</DashboardLayout>
</AuthGuard>
);