Fix TS build errors and apply remaining QA fixes
Remove unused imports (UserCheck, Loader2, ShieldOff) and replace non-existent SmartphoneOff icon with Smartphone in admin components. Includes backend query fixes, performance indexes migration, and admin page shared utilities extraction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e57a5b00c9
commit
cbf4663e8d
64
backend/alembic/versions/035_add_performance_indexes.py
Normal file
64
backend/alembic/versions/035_add_performance_indexes.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"""Add performance indexes for hot query paths.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- calendar_events range queries scoped by calendar (dashboard, notifications)
|
||||||
|
- calendar_events starred query (dashboard widget)
|
||||||
|
- calendar_events parent_event_id (recurring series DELETE)
|
||||||
|
- user_sessions lookup (auth middleware, every request)
|
||||||
|
- ntfy_sent purge query (background job, every 60s)
|
||||||
|
|
||||||
|
Revision ID: 035
|
||||||
|
Revises: 034
|
||||||
|
Create Date: 2026-02-27
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "035"
|
||||||
|
down_revision = "034"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Composite index for event range queries scoped by calendar
|
||||||
|
op.create_index(
|
||||||
|
"ix_calendar_events_calendar_start_end",
|
||||||
|
"calendar_events",
|
||||||
|
["calendar_id", "start_datetime", "end_datetime"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Partial index for starred events dashboard query
|
||||||
|
op.create_index(
|
||||||
|
"ix_calendar_events_calendar_starred",
|
||||||
|
"calendar_events",
|
||||||
|
["calendar_id", "is_starred"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# FK lookup index for recurring children DELETE
|
||||||
|
op.create_index(
|
||||||
|
"ix_calendar_events_parent_id",
|
||||||
|
"calendar_events",
|
||||||
|
["parent_event_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Composite index for session validation (runs on every authenticated request)
|
||||||
|
op.create_index(
|
||||||
|
"ix_user_sessions_lookup",
|
||||||
|
"user_sessions",
|
||||||
|
["user_id", "revoked", "expires_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index for ntfy_sent purge query (DELETE WHERE sent_at < cutoff)
|
||||||
|
op.create_index(
|
||||||
|
"ix_ntfy_sent_sent_at",
|
||||||
|
"ntfy_sent",
|
||||||
|
["sent_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_ntfy_sent_sent_at", table_name="ntfy_sent")
|
||||||
|
op.drop_index("ix_user_sessions_lookup", table_name="user_sessions")
|
||||||
|
op.drop_index("ix_calendar_events_parent_id", table_name="calendar_events")
|
||||||
|
op.drop_index("ix_calendar_events_calendar_starred", table_name="calendar_events")
|
||||||
|
op.drop_index("ix_calendar_events_calendar_start_end", table_name="calendar_events")
|
||||||
@ -40,14 +40,12 @@ UMBRA_URL = "http://10.0.69.35"
|
|||||||
|
|
||||||
# ── Dedup helpers ─────────────────────────────────────────────────────────────
|
# ── Dedup helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _already_sent(db: AsyncSession, key: str, user_id: int) -> bool:
|
async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]:
|
||||||
|
"""Batch-fetch all notification keys for a user in one query."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(NtfySent).where(
|
select(NtfySent.notification_key).where(NtfySent.user_id == user_id)
|
||||||
NtfySent.user_id == user_id,
|
|
||||||
NtfySent.notification_key == key,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none() is not None
|
return set(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
|
async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
|
||||||
@ -57,7 +55,7 @@ async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
|
|||||||
|
|
||||||
# ── Dispatch functions ────────────────────────────────────────────────────────
|
# ── Dispatch functions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime) -> None:
|
async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime, sent_keys: set[str]) -> None:
|
||||||
"""Send notifications for reminders that are currently due and not dismissed/snoozed."""
|
"""Send notifications for reminders that are currently due and not dismissed/snoozed."""
|
||||||
# Mirror the filter from /api/reminders/due, scoped to this user
|
# Mirror the filter from /api/reminders/due, scoped to this user
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@ -79,7 +77,7 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim
|
|||||||
|
|
||||||
# Key includes user_id to prevent cross-user dedup collisions
|
# Key includes user_id to prevent cross-user dedup collisions
|
||||||
key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}"
|
key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}"
|
||||||
if await _already_sent(db, key, settings.user_id):
|
if key in sent_keys:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
payload = build_reminder_notification(
|
payload = build_reminder_notification(
|
||||||
@ -95,9 +93,10 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim
|
|||||||
)
|
)
|
||||||
if sent:
|
if sent:
|
||||||
await _mark_sent(db, key, settings.user_id)
|
await _mark_sent(db, key, settings.user_id)
|
||||||
|
sent_keys.add(key)
|
||||||
|
|
||||||
|
|
||||||
async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) -> None:
|
async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime, sent_keys: set[str]) -> None:
|
||||||
"""Send notifications for calendar events within the configured lead time window."""
|
"""Send notifications for calendar events within the configured lead time window."""
|
||||||
lead_minutes = settings.ntfy_event_lead_minutes
|
lead_minutes = settings.ntfy_event_lead_minutes
|
||||||
# Window: events starting between now and (now + lead_minutes)
|
# Window: events starting between now and (now + lead_minutes)
|
||||||
@ -127,7 +126,7 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime)
|
|||||||
for event in events:
|
for event in events:
|
||||||
# Key includes user_id to prevent cross-user dedup collisions
|
# Key includes user_id to prevent cross-user dedup collisions
|
||||||
key = f"event:{settings.user_id}:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}"
|
key = f"event:{settings.user_id}:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}"
|
||||||
if await _already_sent(db, key, settings.user_id):
|
if key in sent_keys:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
payload = build_event_notification(
|
payload = build_event_notification(
|
||||||
@ -146,9 +145,10 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime)
|
|||||||
)
|
)
|
||||||
if sent:
|
if sent:
|
||||||
await _mark_sent(db, key, settings.user_id)
|
await _mark_sent(db, key, settings.user_id)
|
||||||
|
sent_keys.add(key)
|
||||||
|
|
||||||
|
|
||||||
async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
|
async def _dispatch_todos(db: AsyncSession, settings: Settings, today, sent_keys: set[str]) -> None:
|
||||||
"""Send notifications for incomplete todos due within the configured lead days."""
|
"""Send notifications for incomplete todos due within the configured lead days."""
|
||||||
lead_days = settings.ntfy_todo_lead_days
|
lead_days = settings.ntfy_todo_lead_days
|
||||||
cutoff = today + timedelta(days=lead_days)
|
cutoff = today + timedelta(days=lead_days)
|
||||||
@ -168,7 +168,7 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
|
|||||||
for todo in todos:
|
for todo in todos:
|
||||||
# Key includes user_id to prevent cross-user dedup collisions
|
# Key includes user_id to prevent cross-user dedup collisions
|
||||||
key = f"todo:{settings.user_id}:{todo.id}:{today}"
|
key = f"todo:{settings.user_id}:{todo.id}:{today}"
|
||||||
if await _already_sent(db, key, settings.user_id):
|
if key in sent_keys:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
payload = build_todo_notification(
|
payload = build_todo_notification(
|
||||||
@ -185,9 +185,10 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
|
|||||||
)
|
)
|
||||||
if sent:
|
if sent:
|
||||||
await _mark_sent(db, key, settings.user_id)
|
await _mark_sent(db, key, settings.user_id)
|
||||||
|
sent_keys.add(key)
|
||||||
|
|
||||||
|
|
||||||
async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> None:
|
async def _dispatch_projects(db: AsyncSession, settings: Settings, today, sent_keys: set[str]) -> None:
|
||||||
"""Send notifications for projects with deadlines within the configured lead days."""
|
"""Send notifications for projects with deadlines within the configured lead days."""
|
||||||
lead_days = settings.ntfy_project_lead_days
|
lead_days = settings.ntfy_project_lead_days
|
||||||
cutoff = today + timedelta(days=lead_days)
|
cutoff = today + timedelta(days=lead_days)
|
||||||
@ -207,7 +208,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non
|
|||||||
for project in projects:
|
for project in projects:
|
||||||
# Key includes user_id to prevent cross-user dedup collisions
|
# Key includes user_id to prevent cross-user dedup collisions
|
||||||
key = f"project:{settings.user_id}:{project.id}:{today}"
|
key = f"project:{settings.user_id}:{project.id}:{today}"
|
||||||
if await _already_sent(db, key, settings.user_id):
|
if key in sent_keys:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
payload = build_project_notification(
|
payload = build_project_notification(
|
||||||
@ -223,18 +224,22 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non
|
|||||||
)
|
)
|
||||||
if sent:
|
if sent:
|
||||||
await _mark_sent(db, key, settings.user_id)
|
await _mark_sent(db, key, settings.user_id)
|
||||||
|
sent_keys.add(key)
|
||||||
|
|
||||||
|
|
||||||
async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime) -> None:
|
async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime) -> None:
|
||||||
"""Run all notification dispatches for a single user's settings."""
|
"""Run all notification dispatches for a single user's settings."""
|
||||||
|
# Batch-fetch all sent keys once per user instead of one query per entity
|
||||||
|
sent_keys = await _get_sent_keys(db, settings.user_id)
|
||||||
|
|
||||||
if settings.ntfy_reminders_enabled:
|
if settings.ntfy_reminders_enabled:
|
||||||
await _dispatch_reminders(db, settings, now)
|
await _dispatch_reminders(db, settings, now, sent_keys)
|
||||||
if settings.ntfy_events_enabled:
|
if settings.ntfy_events_enabled:
|
||||||
await _dispatch_events(db, settings, now)
|
await _dispatch_events(db, settings, now, sent_keys)
|
||||||
if settings.ntfy_todos_enabled:
|
if settings.ntfy_todos_enabled:
|
||||||
await _dispatch_todos(db, settings, now.date())
|
await _dispatch_todos(db, settings, now.date(), sent_keys)
|
||||||
if settings.ntfy_projects_enabled:
|
if settings.ntfy_projects_enabled:
|
||||||
await _dispatch_projects(db, settings, now.date())
|
await _dispatch_projects(db, settings, now.date(), sent_keys)
|
||||||
|
|
||||||
|
|
||||||
async def _purge_old_sent_records(db: AsyncSession) -> None:
|
async def _purge_old_sent_records(db: AsyncSession) -> None:
|
||||||
|
|||||||
@ -26,7 +26,7 @@ _not_parent_template = or_(
|
|||||||
|
|
||||||
@router.get("/dashboard")
|
@router.get("/dashboard")
|
||||||
async def get_dashboard(
|
async def get_dashboard(
|
||||||
client_date: Optional[date] = Query(None),
|
client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
current_settings: Settings = Depends(get_current_settings),
|
current_settings: Settings = Depends(get_current_settings),
|
||||||
@ -94,11 +94,10 @@ async def get_dashboard(
|
|||||||
total_incomplete_todos = total_incomplete_result.scalar()
|
total_incomplete_todos = total_incomplete_result.scalar()
|
||||||
|
|
||||||
# Starred events (upcoming, ordered by date, scoped to user's calendars)
|
# Starred events (upcoming, ordered by date, scoped to user's calendars)
|
||||||
now = datetime.now()
|
|
||||||
starred_query = select(CalendarEvent).where(
|
starred_query = select(CalendarEvent).where(
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||||
CalendarEvent.is_starred == True,
|
CalendarEvent.is_starred == True,
|
||||||
CalendarEvent.start_datetime > now,
|
CalendarEvent.start_datetime > today_start,
|
||||||
_not_parent_template,
|
_not_parent_template,
|
||||||
).order_by(CalendarEvent.start_datetime.asc()).limit(5)
|
).order_by(CalendarEvent.start_datetime.asc()).limit(5)
|
||||||
starred_result = await db.execute(starred_query)
|
starred_result = await db.execute(starred_query)
|
||||||
@ -156,7 +155,7 @@ async def get_dashboard(
|
|||||||
@router.get("/upcoming")
|
@router.get("/upcoming")
|
||||||
async def get_upcoming(
|
async def get_upcoming(
|
||||||
days: int = Query(default=7, ge=1, le=90),
|
days: int = Query(default=7, ge=1, le=90),
|
||||||
client_date: Optional[date] = Query(None),
|
client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
current_settings: Settings = Depends(get_current_settings),
|
current_settings: Settings = Depends(get_current_settings),
|
||||||
|
|||||||
@ -130,8 +130,8 @@ async def _verify_calendar_ownership(db: AsyncSession, calendar_id: int, user_id
|
|||||||
|
|
||||||
@router.get("/", response_model=None)
|
@router.get("/", response_model=None)
|
||||||
async def get_events(
|
async def get_events(
|
||||||
start: Optional[date] = Query(None),
|
start: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
|
||||||
end: Optional[date] = Query(None),
|
end: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> List[Any]:
|
) -> List[Any]:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, and_
|
from sqlalchemy import select, and_, func
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
import calendar
|
import calendar
|
||||||
@ -81,6 +81,20 @@ async def _reactivate_recurring_todos(db: AsyncSession, user_id: int) -> None:
|
|||||||
Scoped to a single user to avoid cross-user reactivation.
|
Scoped to a single user to avoid cross-user reactivation.
|
||||||
"""
|
"""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Fast-path: skip the FOR UPDATE lock when nothing needs reactivation (common case)
|
||||||
|
count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Todo).where(
|
||||||
|
Todo.user_id == user_id,
|
||||||
|
Todo.completed == True, # noqa: E712
|
||||||
|
Todo.recurrence_rule.isnot(None),
|
||||||
|
Todo.reset_at.isnot(None),
|
||||||
|
Todo.reset_at <= now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if count == 0:
|
||||||
|
return
|
||||||
|
|
||||||
query = select(Todo).where(
|
query = select(Todo).where(
|
||||||
and_(
|
and_(
|
||||||
Todo.user_id == user_id,
|
Todo.user_id == user_id,
|
||||||
|
|||||||
@ -12,42 +12,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { useAdminDashboard, useAuditLog } from '@/hooks/useAdmin';
|
import { useAdminDashboard, useAuditLog } from '@/hooks/useAdmin';
|
||||||
import { getRelativeTime } from '@/lib/date-utils';
|
import { getRelativeTime } from '@/lib/date-utils';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { StatCard, actionColor } from './shared';
|
||||||
interface StatCardProps {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
iconBg?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) {
|
|
||||||
return (
|
|
||||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={cn('p-1.5 rounded-md', iconBg)}>{icon}</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">{label}</p>
|
|
||||||
<p className="font-heading text-xl font-bold tabular-nums">{value}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionColor(action: string): string {
|
|
||||||
if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) {
|
|
||||||
return 'bg-red-500/15 text-red-400';
|
|
||||||
}
|
|
||||||
if (action.includes('login') || action.includes('create') || action.includes('enabled')) {
|
|
||||||
return 'bg-green-500/15 text-green-400';
|
|
||||||
}
|
|
||||||
if (action.includes('config') || action.includes('role') || action.includes('password')) {
|
|
||||||
return 'bg-orange-500/15 text-orange-400';
|
|
||||||
}
|
|
||||||
return 'bg-blue-500/15 text-blue-400';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
const { data: dashboard, isLoading } = useAdminDashboard();
|
const { data: dashboard, isLoading } = useAdminDashboard();
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { useAuditLog } from '@/hooks/useAdmin';
|
import { useAuditLog } from '@/hooks/useAdmin';
|
||||||
import { getRelativeTime } from '@/lib/date-utils';
|
import { getRelativeTime } from '@/lib/date-utils';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { actionColor } from './shared';
|
||||||
|
|
||||||
const ACTION_TYPES = [
|
const ACTION_TYPES = [
|
||||||
'user.create',
|
'user.create',
|
||||||
@ -39,19 +40,6 @@ function actionLabel(action: string): string {
|
|||||||
.join(' — ');
|
.join(' — ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionColor(action: string): string {
|
|
||||||
if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) {
|
|
||||||
return 'bg-red-500/15 text-red-400';
|
|
||||||
}
|
|
||||||
if (action.includes('login') || action.includes('create') || action.includes('enabled')) {
|
|
||||||
return 'bg-green-500/15 text-green-400';
|
|
||||||
}
|
|
||||||
if (action.includes('config') || action.includes('role') || action.includes('password')) {
|
|
||||||
return 'bg-orange-500/15 text-orange-400';
|
|
||||||
}
|
|
||||||
return 'bg-blue-500/15 text-blue-400';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConfigPage() {
|
export default function ConfigPage() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [filterAction, setFilterAction] = useState<string>('');
|
const [filterAction, setFilterAction] = useState<string>('');
|
||||||
|
|||||||
@ -2,11 +2,9 @@ import { useState } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
UserCheck,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Plus,
|
Plus,
|
||||||
Loader2,
|
|
||||||
Activity,
|
Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@ -14,6 +12,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { StatCard } from './shared';
|
||||||
import {
|
import {
|
||||||
useAdminUsers,
|
useAdminUsers,
|
||||||
useAdminDashboard,
|
useAdminDashboard,
|
||||||
@ -52,31 +51,6 @@ function RoleBadge({ role }: { role: UserRole }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stat card ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
iconBg?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) {
|
|
||||||
return (
|
|
||||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={cn('p-1.5 rounded-md', iconBg)}>{icon}</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">{label}</p>
|
|
||||||
<p className="font-heading text-xl font-bold tabular-nums">{value}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function IAMPage() {
|
export default function IAMPage() {
|
||||||
|
|||||||
@ -3,13 +3,11 @@ import { toast } from 'sonner';
|
|||||||
import {
|
import {
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldOff,
|
|
||||||
KeyRound,
|
KeyRound,
|
||||||
UserX,
|
UserX,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
LogOut,
|
LogOut,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
SmartphoneOff,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@ -208,7 +206,7 @@ export default function UserActionsMenu({ user }: UserActionsMenuProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SmartphoneOff className="h-4 w-4 text-muted-foreground" />
|
<Smartphone className="h-4 w-4 text-muted-foreground" />
|
||||||
Remove MFA Enforcement
|
Remove MFA Enforcement
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@ -233,7 +231,7 @@ export default function UserActionsMenu({ user }: UserActionsMenuProps) {
|
|||||||
)}
|
)}
|
||||||
onClick={disableMfaConfirm.handleClick}
|
onClick={disableMfaConfirm.handleClick}
|
||||||
>
|
>
|
||||||
<SmartphoneOff className="h-4 w-4" />
|
<Smartphone className="h-4 w-4" />
|
||||||
{disableMfaConfirm.confirming ? 'Sure? Click to confirm' : 'Disable MFA'}
|
{disableMfaConfirm.confirming ? 'Sure? Click to confirm' : 'Disable MFA'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
42
frontend/src/components/admin/shared.tsx
Normal file
42
frontend/src/components/admin/shared.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ── StatCard ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
iconBg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn('p-1.5 rounded-md', iconBg)}>{icon}</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">{label}</p>
|
||||||
|
<p className="font-heading text-xl font-bold tabular-nums">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── actionColor ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function actionColor(action: string): string {
|
||||||
|
if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) {
|
||||||
|
return 'bg-red-500/15 text-red-400';
|
||||||
|
}
|
||||||
|
if (action.includes('login') || action.includes('create') || action.includes('enabled')) {
|
||||||
|
return 'bg-green-500/15 text-green-400';
|
||||||
|
}
|
||||||
|
if (action.includes('config') || action.includes('role') || action.includes('password')) {
|
||||||
|
return 'bg-orange-500/15 text-orange-400';
|
||||||
|
}
|
||||||
|
return 'bg-blue-500/15 text-blue-400';
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user