Merge multi-user RBAC implementation (Phases 1-8)

Complete multi-user integration for UMBRA:
- Database: role enum, system_config, audit_log, user_id FKs on all tables
- Auth: RBAC with require_role(), registration, MFA enforcement
- Routing: All 12 routers scoped by user_id
- Admin API: 14 endpoints for IAM, config, audit, dashboard
- Admin Portal: IAM page, config page, dashboard with stats
- Registration flow: LockScreen with register/MFA-enforce/password-change
- Security: SEC-01 through SEC-15 mitigations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-26 19:06:43 +08:00
commit e5a7ce13e0
54 changed files with 4329 additions and 368 deletions

View File

@ -0,0 +1,101 @@
"""Add role, mfa_enforce_pending, must_change_password to users; create system_config table.
Revision ID: 026
Revises: 025
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "026"
down_revision = "025"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. Add role column with server_default for existing rows
op.add_column("users", sa.Column(
"role", sa.String(30), nullable=False, server_default="standard"
))
# 2. Add MFA enforcement pending flag
op.add_column("users", sa.Column(
"mfa_enforce_pending", sa.Boolean(), nullable=False, server_default="false"
))
# 3. Add forced password change flag (SEC-12)
op.add_column("users", sa.Column(
"must_change_password", sa.Boolean(), nullable=False, server_default="false"
))
# 4. Add last_password_change_at audit column
op.add_column("users", sa.Column(
"last_password_change_at", sa.DateTime(), nullable=True
))
# 5. Add CHECK constraint on role values (SEC-16)
op.create_check_constraint(
"ck_users_role",
"users",
"role IN ('admin', 'standard', 'public_event_manager')"
)
# 6. Promote the first (existing) user to admin
op.execute(
"UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)"
)
# 7. Create system_config table (singleton pattern -- always id=1)
op.create_table(
"system_config",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("allow_registration", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("enforce_mfa_new_users", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")),
sa.PrimaryKeyConstraint("id"),
# SEC-09: Enforce singleton row
sa.CheckConstraint("id = 1", name="ck_system_config_singleton"),
)
# 8. Seed the singleton row
op.execute(
"INSERT INTO system_config (id, allow_registration, enforce_mfa_new_users) "
"VALUES (1, false, false)"
)
# 9. Create audit_log table
op.create_table(
"audit_log",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("target_user_id", sa.Integer(), nullable=True),
sa.Column("action", sa.String(100), nullable=False),
sa.Column("detail", sa.Text(), nullable=True),
sa.Column("ip_address", sa.String(45), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"]),
sa.ForeignKeyConstraint(
["target_user_id"], ["users.id"], ondelete="SET NULL"
),
)
op.create_index("ix_audit_log_actor_user_id", "audit_log", ["actor_user_id"])
op.create_index("ix_audit_log_target_user_id", "audit_log", ["target_user_id"])
op.create_index("ix_audit_log_action", "audit_log", ["action"])
op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_audit_log_created_at", table_name="audit_log")
op.drop_index("ix_audit_log_action", table_name="audit_log")
op.drop_index("ix_audit_log_target_user_id", table_name="audit_log")
op.drop_index("ix_audit_log_actor_user_id", table_name="audit_log")
op.drop_table("audit_log")
op.drop_table("system_config")
op.drop_constraint("ck_users_role", "users", type_="check")
op.drop_column("users", "last_password_change_at")
op.drop_column("users", "must_change_password")
op.drop_column("users", "mfa_enforce_pending")
op.drop_column("users", "role")

View File

@ -0,0 +1,38 @@
"""Add user_id FK to todos table.
Revision ID: 027
Revises: 026
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "027"
down_revision = "026"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("todos", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE todos SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_todos_user_id", "todos", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("todos", "user_id", nullable=False)
op.create_index("ix_todos_user_id", "todos", ["user_id"])
op.create_index("ix_todos_user_completed", "todos", ["user_id", "completed"])
op.create_index("ix_todos_user_due_date", "todos", ["user_id", "due_date"])
def downgrade() -> None:
op.drop_index("ix_todos_user_due_date", table_name="todos")
op.drop_index("ix_todos_user_completed", table_name="todos")
op.drop_index("ix_todos_user_id", table_name="todos")
op.drop_constraint("fk_todos_user_id", "todos", type_="foreignkey")
op.drop_column("todos", "user_id")

View File

@ -0,0 +1,36 @@
"""Add user_id FK to reminders table.
Revision ID: 028
Revises: 027
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "028"
down_revision = "027"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("reminders", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE reminders SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_reminders_user_id", "reminders", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("reminders", "user_id", nullable=False)
op.create_index("ix_reminders_user_id", "reminders", ["user_id"])
op.create_index("ix_reminders_user_remind_at", "reminders", ["user_id", "remind_at"])
def downgrade() -> None:
op.drop_index("ix_reminders_user_remind_at", table_name="reminders")
op.drop_index("ix_reminders_user_id", table_name="reminders")
op.drop_constraint("fk_reminders_user_id", "reminders", type_="foreignkey")
op.drop_column("reminders", "user_id")

View File

@ -0,0 +1,36 @@
"""Add user_id FK to projects table.
Revision ID: 029
Revises: 028
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "029"
down_revision = "028"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("projects", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE projects SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_projects_user_id", "projects", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("projects", "user_id", nullable=False)
op.create_index("ix_projects_user_id", "projects", ["user_id"])
op.create_index("ix_projects_user_status", "projects", ["user_id", "status"])
def downgrade() -> None:
op.drop_index("ix_projects_user_status", table_name="projects")
op.drop_index("ix_projects_user_id", table_name="projects")
op.drop_constraint("fk_projects_user_id", "projects", type_="foreignkey")
op.drop_column("projects", "user_id")

View File

@ -0,0 +1,36 @@
"""Add user_id FK to calendars table.
Revision ID: 030
Revises: 029
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "030"
down_revision = "029"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("calendars", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE calendars SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_calendars_user_id", "calendars", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("calendars", "user_id", nullable=False)
op.create_index("ix_calendars_user_id", "calendars", ["user_id"])
op.create_index("ix_calendars_user_default", "calendars", ["user_id", "is_default"])
def downgrade() -> None:
op.drop_index("ix_calendars_user_default", table_name="calendars")
op.drop_index("ix_calendars_user_id", table_name="calendars")
op.drop_constraint("fk_calendars_user_id", "calendars", type_="foreignkey")
op.drop_column("calendars", "user_id")

View File

@ -0,0 +1,36 @@
"""Add user_id FK to people table.
Revision ID: 031
Revises: 030
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "031"
down_revision = "030"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("people", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE people SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_people_user_id", "people", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("people", "user_id", nullable=False)
op.create_index("ix_people_user_id", "people", ["user_id"])
op.create_index("ix_people_user_name", "people", ["user_id", "name"])
def downgrade() -> None:
op.drop_index("ix_people_user_name", table_name="people")
op.drop_index("ix_people_user_id", table_name="people")
op.drop_constraint("fk_people_user_id", "people", type_="foreignkey")
op.drop_column("people", "user_id")

View File

@ -0,0 +1,34 @@
"""Add user_id FK to locations table.
Revision ID: 032
Revises: 031
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "032"
down_revision = "031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("locations", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE locations SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_locations_user_id", "locations", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("locations", "user_id", nullable=False)
op.create_index("ix_locations_user_id", "locations", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_locations_user_id", table_name="locations")
op.drop_constraint("fk_locations_user_id", "locations", type_="foreignkey")
op.drop_column("locations", "user_id")

View File

@ -0,0 +1,34 @@
"""Add user_id FK to event_templates table.
Revision ID: 033
Revises: 032
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "033"
down_revision = "032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("event_templates", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE event_templates SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_event_templates_user_id", "event_templates", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("event_templates", "user_id", nullable=False)
op.create_index("ix_event_templates_user_id", "event_templates", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_event_templates_user_id", table_name="event_templates")
op.drop_constraint("fk_event_templates_user_id", "event_templates", type_="foreignkey")
op.drop_column("event_templates", "user_id")

View File

@ -0,0 +1,46 @@
"""Add user_id FK to ntfy_sent table, rebuild unique constraint as composite.
Revision ID: 034
Revises: 033
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "034"
down_revision = "033"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("ntfy_sent", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE ntfy_sent SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_ntfy_sent_user_id", "ntfy_sent", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("ntfy_sent", "user_id", nullable=False)
# Drop old unique constraint on notification_key alone
op.drop_constraint("ntfy_sent_notification_key_key", "ntfy_sent", type_="unique")
# Create composite unique constraint (per-user dedup)
op.create_unique_constraint(
"uq_ntfy_sent_user_key", "ntfy_sent", ["user_id", "notification_key"]
)
op.create_index("ix_ntfy_sent_user_id", "ntfy_sent", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_ntfy_sent_user_id", table_name="ntfy_sent")
op.drop_constraint("uq_ntfy_sent_user_key", "ntfy_sent", type_="unique")
op.create_unique_constraint(
"ntfy_sent_notification_key_key", "ntfy_sent", ["notification_key"]
)
op.drop_constraint("fk_ntfy_sent_user_id", "ntfy_sent", type_="foreignkey")
op.drop_column("ntfy_sent", "user_id")

View File

@ -19,6 +19,7 @@ from app.database import AsyncSessionLocal
from app.models.settings import Settings from app.models.settings import Settings
from app.models.reminder import Reminder from app.models.reminder import Reminder
from app.models.calendar_event import CalendarEvent from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
from app.models.todo import Todo from app.models.todo import Todo
from app.models.project import Project from app.models.project import Project
from app.models.ntfy_sent import NtfySent from app.models.ntfy_sent import NtfySent
@ -55,10 +56,11 @@ async def _mark_sent(db: AsyncSession, key: str) -> None:
async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime) -> None: async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime) -> 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 # Mirror the filter from /api/reminders/due, scoped to this user
result = await db.execute( result = await db.execute(
select(Reminder).where( select(Reminder).where(
and_( and_(
Reminder.user_id == settings.user_id,
Reminder.remind_at <= now, Reminder.remind_at <= now,
Reminder.is_dismissed == False, # noqa: E712 Reminder.is_dismissed == False, # noqa: E712
Reminder.is_active == True, # noqa: E712 Reminder.is_active == True, # noqa: E712
@ -72,8 +74,8 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim
if reminder.snoozed_until and reminder.snoozed_until > now: if reminder.snoozed_until and reminder.snoozed_until > now:
continue # respect snooze continue # respect snooze
# Key ties notification to the specific day to handle re-fires after midnight # Key includes user_id to prevent cross-user dedup collisions
key = f"reminder:{reminder.id}:{reminder.remind_at.date()}" key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}"
if await _already_sent(db, key): if await _already_sent(db, key):
continue continue
@ -98,9 +100,13 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime)
# Window: events starting between now and (now + lead_minutes) # Window: events starting between now and (now + lead_minutes)
window_end = now + timedelta(minutes=lead_minutes) window_end = now + timedelta(minutes=lead_minutes)
# Scope events through calendar ownership
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == settings.user_id)
result = await db.execute( result = await db.execute(
select(CalendarEvent).where( select(CalendarEvent).where(
and_( and_(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.start_datetime >= now, CalendarEvent.start_datetime >= now,
CalendarEvent.start_datetime <= window_end, CalendarEvent.start_datetime <= window_end,
# Exclude recurring parent templates — they duplicate the child instance rows. # Exclude recurring parent templates — they duplicate the child instance rows.
@ -116,8 +122,8 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime)
today = now.date() today = now.date()
for event in events: for event in events:
# Key includes the minute-precision start to avoid re-firing during the window # Key includes user_id to prevent cross-user dedup collisions
key = f"event:{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): if await _already_sent(db, key):
continue continue
@ -141,13 +147,13 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime)
async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
"""Send notifications for incomplete todos due within the configured lead days.""" """Send notifications for incomplete todos due within the configured lead days."""
from datetime import date as date_type
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)
result = await db.execute( result = await db.execute(
select(Todo).where( select(Todo).where(
and_( and_(
Todo.user_id == settings.user_id,
Todo.completed == False, # noqa: E712 Todo.completed == False, # noqa: E712
Todo.due_date != None, # noqa: E711 Todo.due_date != None, # noqa: E711
Todo.due_date <= cutoff, Todo.due_date <= cutoff,
@ -157,7 +163,8 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
todos = result.scalars().all() todos = result.scalars().all()
for todo in todos: for todo in todos:
key = f"todo:{todo.id}:{today}" # Key includes user_id to prevent cross-user dedup collisions
key = f"todo:{settings.user_id}:{todo.id}:{today}"
if await _already_sent(db, key): if await _already_sent(db, key):
continue continue
@ -185,6 +192,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non
result = await db.execute( result = await db.execute(
select(Project).where( select(Project).where(
and_( and_(
Project.user_id == settings.user_id,
Project.due_date != None, # noqa: E711 Project.due_date != None, # noqa: E711
Project.due_date <= cutoff, Project.due_date <= cutoff,
Project.status != "completed", Project.status != "completed",
@ -194,7 +202,8 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non
projects = result.scalars().all() projects = result.scalars().all()
for project in projects: for project in projects:
key = f"project:{project.id}:{today}" # Key includes user_id to prevent cross-user dedup collisions
key = f"project:{settings.user_id}:{project.id}:{today}"
if await _already_sent(db, key): if await _already_sent(db, key):
continue continue
@ -213,6 +222,18 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non
await _mark_sent(db, key) await _mark_sent(db, key)
async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime) -> None:
"""Run all notification dispatches for a single user's settings."""
if settings.ntfy_reminders_enabled:
await _dispatch_reminders(db, settings, now)
if settings.ntfy_events_enabled:
await _dispatch_events(db, settings, now)
if settings.ntfy_todos_enabled:
await _dispatch_todos(db, settings, now.date())
if settings.ntfy_projects_enabled:
await _dispatch_projects(db, settings, now.date())
async def _purge_old_sent_records(db: AsyncSession) -> None: async def _purge_old_sent_records(db: AsyncSession) -> None:
"""Remove ntfy_sent entries older than 7 days to keep the table lean.""" """Remove ntfy_sent entries older than 7 days to keep the table lean."""
# See DATETIME NOTE at top of file re: naive datetime usage # See DATETIME NOTE at top of file re: naive datetime usage
@ -240,29 +261,35 @@ async def run_notification_dispatch() -> None:
""" """
Main dispatch function called by APScheduler every 60 seconds. Main dispatch function called by APScheduler every 60 seconds.
Uses AsyncSessionLocal directly not the get_db() request-scoped dependency. Uses AsyncSessionLocal directly not the get_db() request-scoped dependency.
Iterates over ALL users with ntfy enabled. Per-user errors are caught and
logged individually so one user's failure does not prevent others from
receiving notifications.
""" """
try: try:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
result = await db.execute(select(Settings)) # Fetch all Settings rows that have ntfy enabled
settings = result.scalar_one_or_none() result = await db.execute(
select(Settings).where(Settings.ntfy_enabled == True) # noqa: E712
)
all_settings = result.scalars().all()
if not settings or not settings.ntfy_enabled: if not all_settings:
return return
# See DATETIME NOTE at top of file re: naive datetime usage # See DATETIME NOTE at top of file re: naive datetime usage
now = datetime.now() now = datetime.now()
today = now.date()
if settings.ntfy_reminders_enabled: for user_settings in all_settings:
await _dispatch_reminders(db, settings, now) try:
if settings.ntfy_events_enabled: await _dispatch_for_user(db, user_settings, now)
await _dispatch_events(db, settings, now) except Exception:
if settings.ntfy_todos_enabled: # Isolate per-user failures — log and continue to next user
await _dispatch_todos(db, settings, today) logger.exception(
if settings.ntfy_projects_enabled: "ntfy dispatch failed for user_id=%s", user_settings.user_id
await _dispatch_projects(db, settings, today) )
# Daily housekeeping: purge stale dedup records # Daily housekeeping: purge stale dedup records (shared across all users)
await _purge_old_sent_records(db) await _purge_old_sent_records(db)
# Security housekeeping runs every cycle regardless of ntfy_enabled # Security housekeeping runs every cycle regardless of ntfy_enabled

View File

@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings from app.config import settings
from app.database import engine from app.database import engine
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
from app.routers import totp from app.routers import totp, admin
from app.jobs.notifications import run_notification_dispatch from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them # Import models so Alembic's autogenerate can discover them
@ -15,6 +15,8 @@ from app.models import user as _user_model # noqa: F401
from app.models import session as _session_model # noqa: F401 from app.models import session as _session_model # noqa: F401
from app.models import totp_usage as _totp_usage_model # noqa: F401 from app.models import totp_usage as _totp_usage_model # noqa: F401
from app.models import backup_code as _backup_code_model # noqa: F401 from app.models import backup_code as _backup_code_model # noqa: F401
from app.models import system_config as _system_config_model # noqa: F401
from app.models import audit_log as _audit_log_model # noqa: F401
@asynccontextmanager @asynccontextmanager
@ -68,6 +70,7 @@ app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"]) app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"]) app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"]) app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
@app.get("/") @app.get("/")

View File

@ -13,6 +13,8 @@ from app.models.session import UserSession
from app.models.ntfy_sent import NtfySent from app.models.ntfy_sent import NtfySent
from app.models.totp_usage import TOTPUsage from app.models.totp_usage import TOTPUsage
from app.models.backup_code import BackupCode from app.models.backup_code import BackupCode
from app.models.system_config import SystemConfig
from app.models.audit_log import AuditLog
__all__ = [ __all__ = [
"Settings", "Settings",
@ -30,4 +32,6 @@ __all__ = [
"NtfySent", "NtfySent",
"TOTPUsage", "TOTPUsage",
"BackupCode", "BackupCode",
"SystemConfig",
"AuditLog",
] ]

View File

@ -0,0 +1,27 @@
from sqlalchemy import String, Text, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
from app.database import Base
class AuditLog(Base):
"""
Append-only audit trail for admin actions and auth events.
No DELETE endpoint this table is immutable once written.
"""
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(primary_key=True)
actor_user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id"), nullable=True, index=True
)
target_user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(
default=func.now(), server_default=func.now(), index=True
)

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Boolean, func from sqlalchemy import String, Boolean, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime from datetime import datetime
from typing import List from typing import List
@ -9,6 +9,9 @@ class Calendar(Base):
__tablename__ = "calendars" __tablename__ = "calendars"
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str] = mapped_column(String(20), nullable=False, default="#3b82f6") color: Mapped[str] = mapped_column(String(20), nullable=False, default="#3b82f6")
is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")

View File

@ -9,6 +9,9 @@ class EventTemplate(Base):
__tablename__ = "event_templates" __tablename__ = "event_templates"
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Text, Boolean, func, text from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List
@ -9,6 +9,9 @@ class Location(Base):
__tablename__ = "locations" __tablename__ = "locations"
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
address: Mapped[str] = mapped_column(Text, nullable=False) address: Mapped[str] = mapped_column(Text, nullable=False)
category: Mapped[str] = mapped_column(String(100), default="other") category: Mapped[str] = mapped_column(String(100), default="other")

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, func from sqlalchemy import String, Integer, ForeignKey, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime from datetime import datetime
from app.database import Base from app.database import Base
@ -8,7 +8,7 @@ class NtfySent(Base):
""" """
Deduplication table for ntfy notifications. Deduplication table for ntfy notifications.
Prevents the background job from re-sending the same notification Prevents the background job from re-sending the same notification
within a given time window. within a given time window. Scoped per-user.
Key format: "{type}:{entity_id}:{date_window}" Key format: "{type}:{entity_id}:{date_window}"
Examples: Examples:
@ -18,7 +18,13 @@ class NtfySent(Base):
"project:3:2026-02-25" "project:3:2026-02-25"
""" """
__tablename__ = "ntfy_sent" __tablename__ = "ntfy_sent"
__table_args__ = (
UniqueConstraint("user_id", "notification_key", name="uq_ntfy_sent_user_key"),
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
notification_key: Mapped[str] = mapped_column(String(255), unique=True, index=True) user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
notification_key: Mapped[str] = mapped_column(String(255), index=True)
sent_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) sent_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Text, Date, Boolean, func, text from sqlalchemy import String, Text, Date, Boolean, Integer, ForeignKey, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List from typing import Optional, List
@ -9,6 +9,9 @@ class Person(Base):
__tablename__ = "people" __tablename__ = "people"
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)

View File

@ -1,5 +1,5 @@
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import Boolean, String, Text, Date, func from sqlalchemy import Boolean, String, Text, Date, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List from typing import Optional, List
@ -10,6 +10,9 @@ class Project(Base):
__tablename__ = "projects" __tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="not_started") status: Mapped[str] = mapped_column(String(20), default="not_started")

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Text, Boolean, func from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@ -9,6 +9,9 @@ class Reminder(Base):
__tablename__ = "reminders" __tablename__ = "reminders"
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
remind_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) remind_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)

View File

@ -0,0 +1,27 @@
from sqlalchemy import Boolean, CheckConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class SystemConfig(Base):
"""
Singleton system configuration table (always id=1).
Stores global toggles for registration, MFA enforcement, etc.
"""
__tablename__ = "system_config"
__table_args__ = (
CheckConstraint("id = 1", name="ck_system_config_singleton"),
)
id: Mapped[int] = mapped_column(primary_key=True)
allow_registration: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
enforce_mfa_new_users: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
default=func.now(), onupdate=func.now(), server_default=func.now()
)

View File

@ -9,6 +9,9 @@ class Todo(Base):
__tablename__ = "todos" __tablename__ = "todos"
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
priority: Mapped[str] = mapped_column(String(20), default="medium") priority: Mapped[str] = mapped_column(String(20), default="medium")

View File

@ -23,7 +23,23 @@ class User(Base):
# Account state # Account state
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# RBAC
role: Mapped[str] = mapped_column(
String(30), nullable=False, default="standard", server_default="standard"
)
# MFA enforcement (admin can toggle; checked at login)
mfa_enforce_pending: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
# Forced password change (set after admin reset)
must_change_password: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
# Audit # Audit
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
last_login_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None) last_login_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None)
last_password_change_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None)

View File

@ -0,0 +1,687 @@
"""
Admin router full user management, system config, and audit log.
Security measures implemented:
SEC-02: Session revocation on role change
SEC-05: Block admin self-actions (own role/password/MFA/active status)
SEC-08: X-Requested-With header check (verify_xhr) on all state-mutating requests
SEC-13: Session revocation + ntfy alert on MFA disable
All routes require the `require_admin` dependency (which chains through
get_current_user, so the session cookie is always validated).
"""
import secrets
from datetime import datetime
from typing import Optional
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.audit_log import AuditLog
from app.models.backup_code import BackupCode
from app.models.session import UserSession
from app.models.system_config import SystemConfig
from app.models.user import User
from app.routers.auth import (
_create_user_defaults,
get_current_user,
require_admin,
)
from app.schemas.admin import (
AdminDashboardResponse,
AuditLogEntry,
AuditLogResponse,
CreateUserRequest,
ResetPasswordResponse,
SystemConfigResponse,
SystemConfigUpdate,
ToggleActiveRequest,
ToggleMfaEnforceRequest,
UpdateUserRoleRequest,
UserDetailResponse,
UserListItem,
UserListResponse,
)
from app.services.audit import log_audit_event
from app.services.auth import hash_password
# ---------------------------------------------------------------------------
# CSRF guard — SEC-08
# ---------------------------------------------------------------------------
async def verify_xhr(request: Request) -> None:
"""
Lightweight CSRF mitigation: require X-Requested-With on state-mutating
requests. Browsers never send this header cross-origin without CORS
pre-flight, which our CORS policy blocks.
"""
if request.method not in ("GET", "HEAD", "OPTIONS"):
if request.headers.get("X-Requested-With") != "XMLHttpRequest":
raise HTTPException(status_code=403, detail="Invalid request origin")
# ---------------------------------------------------------------------------
# Router — all endpoints inherit require_admin + verify_xhr
# ---------------------------------------------------------------------------
router = APIRouter(
dependencies=[Depends(require_admin), Depends(verify_xhr)],
)
# ---------------------------------------------------------------------------
# Session revocation helper (used in multiple endpoints)
# ---------------------------------------------------------------------------
async def _revoke_all_sessions(db: AsyncSession, user_id: int) -> int:
"""Mark every active session for user_id as revoked. Returns count revoked."""
result = await db.execute(
sa.update(UserSession)
.where(UserSession.user_id == user_id, UserSession.revoked == False)
.values(revoked=True)
.returning(UserSession.id)
)
return len(result.fetchall())
# ---------------------------------------------------------------------------
# Self-action guard — SEC-05
# ---------------------------------------------------------------------------
def _guard_self_action(actor: User, target_id: int, action: str) -> None:
"""Raise 403 if an admin attempts a privileged action against their own account."""
if actor.id == target_id:
raise HTTPException(
status_code=403,
detail=f"Admins cannot {action} their own account",
)
# ---------------------------------------------------------------------------
# GET /users
# ---------------------------------------------------------------------------
@router.get("/users", response_model=UserListResponse)
async def list_users(
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
):
"""Return all users with basic stats."""
result = await db.execute(sa.select(User).order_by(User.created_at))
users = result.scalars().all()
return UserListResponse(
users=[UserListItem.model_validate(u) for u in users],
total=len(users),
)
# ---------------------------------------------------------------------------
# GET /users/{user_id}
# ---------------------------------------------------------------------------
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
):
"""Return a single user with their active session count."""
result = await db.execute(sa.select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
session_result = await db.execute(
sa.select(sa.func.count()).select_from(UserSession).where(
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
)
)
active_sessions = session_result.scalar_one()
return UserDetailResponse(
**UserListItem.model_validate(user).model_dump(),
active_sessions=active_sessions,
)
# ---------------------------------------------------------------------------
# POST /users
# ---------------------------------------------------------------------------
@router.post("/users", response_model=UserDetailResponse, status_code=201)
async def create_user(
data: CreateUserRequest,
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""Admin-create a user with Settings and default calendars."""
existing = await db.execute(sa.select(User).where(User.username == data.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Username already taken")
new_user = User(
username=data.username,
password_hash=hash_password(data.password),
role=data.role,
last_password_change_at=datetime.now(),
# Force password change so the user sets their own credential
must_change_password=True,
)
db.add(new_user)
await db.flush() # populate new_user.id
await _create_user_defaults(db, new_user.id)
await db.commit()
await log_audit_event(
db,
action="admin.user_created",
actor_id=actor.id,
target_id=new_user.id,
detail={"username": new_user.username, "role": new_user.role},
ip=request.client.host if request.client else None,
)
await db.commit()
return UserDetailResponse(
**UserListItem.model_validate(new_user).model_dump(),
active_sessions=0,
)
# ---------------------------------------------------------------------------
# PUT /users/{user_id}/role — SEC-02, SEC-05
# ---------------------------------------------------------------------------
@router.put("/users/{user_id}/role")
async def update_user_role(
user_id: int,
data: UpdateUserRoleRequest,
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Change a user's role.
Blocks demotion of the last admin (SEC-05 variant).
Revokes all sessions after role change (SEC-02).
"""
_guard_self_action(actor, user_id, "change role of")
result = await db.execute(sa.select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent demoting the last admin
if user.role == "admin" and data.role != "admin":
admin_count = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.role == "admin")
)
if admin_count <= 1:
raise HTTPException(
status_code=409,
detail="Cannot demote the last admin account",
)
old_role = user.role
user.role = data.role
# SEC-02: revoke sessions so the new role takes effect immediately
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.role_changed",
actor_id=actor.id,
target_id=user_id,
detail={"old_role": old_role, "new_role": data.role, "sessions_revoked": revoked},
ip=request.client.host if request.client else None,
)
await db.commit()
return {"message": f"Role updated to '{data.role}'. {revoked} session(s) revoked."}
# ---------------------------------------------------------------------------
# POST /users/{user_id}/reset-password — SEC-05
# ---------------------------------------------------------------------------
@router.post("/users/{user_id}/reset-password", response_model=ResetPasswordResponse)
async def reset_user_password(
user_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Generate a temporary password, revoke all sessions, and mark must_change_password.
The admin is shown the plaintext temp password once it is not stored.
"""
_guard_self_action(actor, user_id, "reset the password of")
result = await db.execute(sa.select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
temp_password = secrets.token_urlsafe(16)
user.password_hash = hash_password(temp_password)
user.must_change_password = True
user.last_password_change_at = datetime.now()
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.password_reset",
actor_id=actor.id,
target_id=user_id,
detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None,
)
await db.commit()
return ResetPasswordResponse(
message=f"Password reset. {revoked} session(s) revoked. User must change password on next login.",
temporary_password=temp_password,
)
# ---------------------------------------------------------------------------
# POST /users/{user_id}/disable-mfa — SEC-05, SEC-13
# ---------------------------------------------------------------------------
@router.post("/users/{user_id}/disable-mfa")
async def disable_user_mfa(
user_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Clear TOTP secret + backup codes and revoke all sessions (SEC-13).
"""
_guard_self_action(actor, user_id, "disable MFA for")
result = await db.execute(sa.select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.totp_enabled:
raise HTTPException(status_code=409, detail="MFA is not enabled for this user")
# Clear TOTP data
user.totp_secret = None
user.totp_enabled = False
user.mfa_enforce_pending = False
# Remove all backup codes
await db.execute(
sa.delete(BackupCode).where(BackupCode.user_id == user_id)
)
# SEC-13: revoke sessions so the MFA downgrade takes effect immediately
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.mfa_disabled",
actor_id=actor.id,
target_id=user_id,
detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None,
)
await db.commit()
return {"message": f"MFA disabled. {revoked} session(s) revoked."}
# ---------------------------------------------------------------------------
# PUT /users/{user_id}/enforce-mfa — SEC-05
# ---------------------------------------------------------------------------
@router.put("/users/{user_id}/enforce-mfa")
async def toggle_mfa_enforce(
user_id: int,
data: ToggleMfaEnforceRequest,
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""Toggle the mfa_enforce_pending flag. Next login will prompt MFA setup."""
_guard_self_action(actor, user_id, "toggle MFA enforcement for")
result = await db.execute(sa.select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.mfa_enforce_pending = data.enforce
await log_audit_event(
db,
action="admin.mfa_enforce_toggled",
actor_id=actor.id,
target_id=user_id,
detail={"enforce": data.enforce},
ip=request.client.host if request.client else None,
)
await db.commit()
return {"message": f"MFA enforcement {'enabled' if data.enforce else 'disabled'} for user."}
# ---------------------------------------------------------------------------
# PUT /users/{user_id}/active — SEC-05
# ---------------------------------------------------------------------------
@router.put("/users/{user_id}/active")
async def toggle_user_active(
user_id: int,
data: ToggleActiveRequest,
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Enable or disable a user account.
Revoking an account also revokes all active sessions immediately.
"""
_guard_self_action(actor, user_id, "change active status of")
result = await db.execute(sa.select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.is_active = data.is_active
revoked = 0
if not data.is_active:
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.user_deactivated" if not data.is_active else "admin.user_activated",
actor_id=actor.id,
target_id=user_id,
detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None,
)
await db.commit()
state = "activated" if data.is_active else f"deactivated ({revoked} session(s) revoked)"
return {"message": f"User {state}."}
# ---------------------------------------------------------------------------
# DELETE /users/{user_id}/sessions
# ---------------------------------------------------------------------------
@router.delete("/users/{user_id}/sessions")
async def revoke_user_sessions(
user_id: int,
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""Forcibly revoke all active sessions for a user."""
result = await db.execute(sa.select(User).where(User.id == user_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="User not found")
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.sessions_revoked",
actor_id=actor.id,
target_id=user_id,
detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None,
)
await db.commit()
return {"message": f"{revoked} session(s) revoked."}
# ---------------------------------------------------------------------------
# GET /users/{user_id}/sessions
# ---------------------------------------------------------------------------
@router.get("/users/{user_id}/sessions")
async def list_user_sessions(
user_id: int,
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
):
"""List all active (non-revoked, non-expired) sessions for a user."""
result = await db.execute(sa.select(User).where(User.id == user_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="User not found")
sessions_result = await db.execute(
sa.select(UserSession).where(
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
).order_by(UserSession.created_at.desc())
)
sessions = sessions_result.scalars().all()
return {
"sessions": [
{
"id": s.id,
"created_at": s.created_at,
"expires_at": s.expires_at,
"ip_address": s.ip_address,
"user_agent": s.user_agent,
}
for s in sessions
],
"total": len(sessions),
}
# ---------------------------------------------------------------------------
# GET /config
# ---------------------------------------------------------------------------
@router.get("/config", response_model=SystemConfigResponse)
async def get_system_config(
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
):
"""Fetch the singleton system configuration row."""
result = await db.execute(sa.select(SystemConfig).where(SystemConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
# Bootstrap the singleton if it doesn't exist yet
config = SystemConfig(id=1)
db.add(config)
await db.commit()
return config
# ---------------------------------------------------------------------------
# PUT /config
# ---------------------------------------------------------------------------
@router.put("/config", response_model=SystemConfigResponse)
async def update_system_config(
data: SystemConfigUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""Update one or more system config fields (partial update)."""
result = await db.execute(sa.select(SystemConfig).where(SystemConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
config = SystemConfig(id=1)
db.add(config)
await db.flush()
changes: dict = {}
if data.allow_registration is not None:
changes["allow_registration"] = data.allow_registration
config.allow_registration = data.allow_registration
if data.enforce_mfa_new_users is not None:
changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users
config.enforce_mfa_new_users = data.enforce_mfa_new_users
if changes:
await log_audit_event(
db,
action="admin.config_updated",
actor_id=actor.id,
detail=changes,
ip=request.client.host if request.client else None,
)
await db.commit()
return config
# ---------------------------------------------------------------------------
# GET /dashboard
# ---------------------------------------------------------------------------
@router.get("/dashboard", response_model=AdminDashboardResponse)
async def admin_dashboard(
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
):
"""Aggregate stats for the admin portal dashboard."""
total_users = await db.scalar(
sa.select(sa.func.count()).select_from(User)
)
active_users = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.is_active == True)
)
admin_count = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.role == "admin")
)
totp_count = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.totp_enabled == True)
)
active_sessions = await db.scalar(
sa.select(sa.func.count()).select_from(UserSession).where(
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
)
)
mfa_adoption = (totp_count / total_users) if total_users else 0.0
# 10 most recent logins — join to get username
actor_alias = sa.alias(User.__table__, name="actor")
recent_logins_result = await db.execute(
sa.select(User.username, User.last_login_at)
.where(User.last_login_at != None)
.order_by(User.last_login_at.desc())
.limit(10)
)
recent_logins = [
{"username": row.username, "last_login_at": row.last_login_at}
for row in recent_logins_result
]
# 10 most recent audit entries
recent_audit_result = await db.execute(
sa.select(AuditLog).order_by(AuditLog.created_at.desc()).limit(10)
)
recent_audit_entries = [
{
"id": e.id,
"action": e.action,
"actor_user_id": e.actor_user_id,
"target_user_id": e.target_user_id,
"detail": e.detail,
"created_at": e.created_at,
}
for e in recent_audit_result.scalars()
]
return AdminDashboardResponse(
total_users=total_users or 0,
active_users=active_users or 0,
admin_count=admin_count or 0,
active_sessions=active_sessions or 0,
mfa_adoption_rate=round(mfa_adoption, 4),
recent_logins=recent_logins,
recent_audit_entries=recent_audit_entries,
)
# ---------------------------------------------------------------------------
# GET /audit-log
# ---------------------------------------------------------------------------
@router.get("/audit-log", response_model=AuditLogResponse)
async def get_audit_log(
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
action: Optional[str] = Query(None, description="Filter by action string (prefix match)"),
target_user_id: Optional[int] = Query(None, description="Filter by target user ID"),
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
per_page: int = Query(50, ge=1, le=200, description="Results per page"),
):
"""
Paginated audit log with optional filters.
Resolves actor and target user IDs to usernames via a JOIN.
"""
# Aliases for the two user joins
actor_user = sa.orm.aliased(User, name="actor_user")
target_user = sa.orm.aliased(User, name="target_user")
# Base query — left outer join so entries with NULL actor/target still appear
base_q = (
sa.select(
AuditLog,
actor_user.username.label("actor_username"),
target_user.username.label("target_username"),
)
.outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id)
.outerjoin(target_user, AuditLog.target_user_id == target_user.id)
)
if action:
base_q = base_q.where(AuditLog.action.like(f"{action}%"))
if target_user_id is not None:
base_q = base_q.where(AuditLog.target_user_id == target_user_id)
# Count before pagination
count_q = sa.select(sa.func.count()).select_from(
base_q.subquery()
)
total = await db.scalar(count_q) or 0
# Paginate
offset = (page - 1) * per_page
rows_result = await db.execute(
base_q.order_by(AuditLog.created_at.desc()).offset(offset).limit(per_page)
)
entries = [
AuditLogEntry(
id=row.AuditLog.id,
actor_username=row.actor_username,
target_username=row.target_username,
action=row.AuditLog.action,
detail=row.AuditLog.detail,
ip_address=row.AuditLog.ip_address,
created_at=row.AuditLog.created_at,
)
for row in rows_result
]
return AuditLogResponse(entries=entries, total=total)

View File

@ -1,18 +1,20 @@
""" """
Authentication router username/password with DB-backed sessions and account lockout. Authentication router username/password with DB-backed sessions, account lockout,
role-based access control, and multi-user registration.
Session flow: Session flow:
POST /setup create User + Settings row issue session cookie POST /setup create admin User + Settings + calendars issue session cookie
POST /login verify credentials check lockout insert UserSession issue cookie POST /login verify credentials check lockout MFA/enforce checks issue session
if TOTP enabled: return mfa_token instead of full session POST /register create standard user (when registration enabled)
POST /logout mark session revoked in DB delete cookie POST /logout mark session revoked in DB delete cookie
GET /status verify user exists + session valid GET /status verify user exists + session valid + role + registration_open
Security layers: Security layers:
1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) outer guard on all auth endpoints 1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) outer guard on auth endpoints
2. DB-backed account lockout (10 failures 30-min lock, HTTP 423) per-user guard 2. DB-backed account lockout (10 failures 30-min lock, HTTP 423)
3. Session revocation stored in DB (survives container restarts) 3. Session revocation stored in DB (survives container restarts)
4. bcryptArgon2id transparent upgrade on first login with migrated hash 4. bcryptArgon2id transparent upgrade on first login
5. Role-based authorization via require_role() dependency factory
""" """
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -26,14 +28,21 @@ from app.database import get_db
from app.models.user import User from app.models.user import User
from app.models.session import UserSession from app.models.session import UserSession
from app.models.settings import Settings from app.models.settings import Settings
from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest, VerifyPasswordRequest from app.models.system_config import SystemConfig
from app.models.calendar import Calendar
from app.schemas.auth import (
SetupRequest, LoginRequest, RegisterRequest,
ChangePasswordRequest, VerifyPasswordRequest,
)
from app.services.auth import ( from app.services.auth import (
hash_password, hash_password,
verify_password_with_upgrade, verify_password_with_upgrade,
create_session_token, create_session_token,
verify_session_token, verify_session_token,
create_mfa_token, create_mfa_token,
create_mfa_enforce_token,
) )
from app.services.audit import log_audit_event
from app.config import settings as app_settings from app.config import settings as app_settings
router = APIRouter() router = APIRouter()
@ -64,8 +73,6 @@ async def get_current_user(
) -> User: ) -> User:
""" """
Dependency that verifies the session cookie and returns the authenticated User. Dependency that verifies the session cookie and returns the authenticated User.
Replaces the old get_current_session (which returned Settings).
Any router that hasn't been updated will get a compile-time type error.
""" """
if not session_cookie: if not session_cookie:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
@ -119,6 +126,24 @@ async def get_current_settings(
return settings_obj return settings_obj
# ---------------------------------------------------------------------------
# Role-based authorization dependencies
# ---------------------------------------------------------------------------
def require_role(*allowed_roles: str):
"""Factory: returns a dependency that enforces role membership."""
async def _check(
current_user: User = Depends(get_current_user),
) -> User:
if current_user.role not in allowed_roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return current_user
return _check
# Convenience aliases
require_admin = require_role("admin")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Account lockout helpers # Account lockout helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -166,7 +191,7 @@ async def _create_db_session(
id=session_id, id=session_id,
user_id=user.id, user_id=user.id,
expires_at=expires_at, expires_at=expires_at,
ip_address=ip[:45] if ip else None, # clamp to column width ip_address=ip[:45] if ip else None,
user_agent=(user_agent or "")[:255] if user_agent else None, user_agent=(user_agent or "")[:255] if user_agent else None,
) )
db.add(db_session) db.add(db_session)
@ -175,6 +200,25 @@ async def _create_db_session(
return session_id, token return session_id, token
# ---------------------------------------------------------------------------
# User bootstrapping helper (Settings + default calendars)
# ---------------------------------------------------------------------------
async def _create_user_defaults(db: AsyncSession, user_id: int) -> None:
"""Create Settings row and default calendars for a new user."""
db.add(Settings(user_id=user_id))
db.add(Calendar(
name="Personal", color="#3b82f6",
is_default=True, is_system=False, is_visible=True,
user_id=user_id,
))
db.add(Calendar(
name="Birthdays", color="#f59e0b",
is_default=False, is_system=True, is_visible=True,
user_id=user_id,
))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes # Routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -187,7 +231,7 @@ async def setup(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
First-time setup: create the User record and a linked Settings row. First-time setup: create the admin User + Settings + default calendars.
Only works when no users exist (i.e., fresh install). Only works when no users exist (i.e., fresh install).
""" """
existing = await db.execute(select(User)) existing = await db.execute(select(User))
@ -195,13 +239,16 @@ async def setup(
raise HTTPException(status_code=400, detail="Setup already completed") raise HTTPException(status_code=400, detail="Setup already completed")
password_hash = hash_password(data.password) password_hash = hash_password(data.password)
new_user = User(username=data.username, password_hash=password_hash) new_user = User(
username=data.username,
password_hash=password_hash,
role="admin",
last_password_change_at=datetime.now(),
)
db.add(new_user) db.add(new_user)
await db.flush() # assign new_user.id before creating Settings await db.flush()
# Create Settings row linked to this user with all defaults await _create_user_defaults(db, new_user.id)
new_settings = Settings(user_id=new_user.id)
db.add(new_settings)
await db.commit() await db.commit()
ip = request.client.host if request.client else "unknown" ip = request.client.host if request.client else "unknown"
@ -209,6 +256,11 @@ async def setup(
_, token = await _create_db_session(db, new_user, ip, user_agent) _, token = await _create_db_session(db, new_user, ip, user_agent)
_set_session_cookie(response, token) _set_session_cookie(response, token)
await log_audit_event(
db, action="auth.setup_complete", actor_id=new_user.id, ip=ip,
)
await db.commit()
return {"message": "Setup completed successfully", "authenticated": True} return {"message": "Setup completed successfully", "authenticated": True}
@ -223,15 +275,15 @@ async def login(
Authenticate with username + password. Authenticate with username + password.
Returns: Returns:
{ authenticated: true } on success (no TOTP) { authenticated: true } on success (no TOTP, no enforcement)
{ authenticated: false, totp_required: true, mfa_token: "..." } TOTP pending { authenticated: false, totp_required: true, mfa_token: "..." } TOTP pending
HTTP 401 wrong credentials (generic; never reveals which field is wrong) { authenticated: false, mfa_setup_required: true, mfa_token: "..." } MFA enforcement
{ authenticated: false, must_change_password: true } forced password change after admin reset
HTTP 401 wrong credentials
HTTP 423 account locked HTTP 423 account locked
HTTP 429 IP rate limited
""" """
client_ip = request.client.host if request.client else "unknown" client_ip = request.client.host if request.client else "unknown"
# Lookup user — do NOT differentiate "user not found" from "wrong password"
result = await db.execute(select(User).where(User.username == data.username)) result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@ -240,20 +292,36 @@ async def login(
await _check_account_lockout(user) await _check_account_lockout(user)
# Transparent bcrypt→Argon2id upgrade
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash) valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
if not valid: if not valid:
await _record_failed_login(db, user) await _record_failed_login(db, user)
await log_audit_event(
db, action="auth.login_failed", actor_id=user.id,
detail={"reason": "invalid_password"}, ip=client_ip,
)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password") raise HTTPException(status_code=401, detail="Invalid username or password")
# Persist upgraded hash if migration happened
if new_hash: if new_hash:
user.password_hash = new_hash user.password_hash = new_hash
await _record_successful_login(db, user) await _record_successful_login(db, user)
# If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session # SEC-03: MFA enforcement — block login entirely until MFA setup completes
if user.mfa_enforce_pending and not user.totp_enabled:
enforce_token = create_mfa_enforce_token(user.id)
await log_audit_event(
db, action="auth.mfa_enforce_prompted", actor_id=user.id, ip=client_ip,
)
await db.commit()
return {
"authenticated": False,
"mfa_setup_required": True,
"mfa_token": enforce_token,
}
# If TOTP is enabled, issue a short-lived MFA challenge token
if user.totp_enabled: if user.totp_enabled:
mfa_token = create_mfa_token(user.id) mfa_token = create_mfa_token(user.id)
return { return {
@ -262,13 +330,97 @@ async def login(
"mfa_token": mfa_token, "mfa_token": mfa_token,
} }
# SEC-12: Forced password change after admin reset
if user.must_change_password:
# Issue a session but flag the frontend to show password change
user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, user, client_ip, user_agent)
_set_session_cookie(response, token)
return {
"authenticated": True,
"must_change_password": True,
}
user_agent = request.headers.get("user-agent") user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, user, client_ip, user_agent) _, token = await _create_db_session(db, user, client_ip, user_agent)
_set_session_cookie(response, token) _set_session_cookie(response, token)
await log_audit_event(
db, action="auth.login_success", actor_id=user.id, ip=client_ip,
)
await db.commit()
return {"authenticated": True} return {"authenticated": True}
@router.post("/register")
async def register(
data: RegisterRequest,
response: Response,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""
Create a new standard user account.
Only available when system_config.allow_registration is True.
"""
config_result = await db.execute(
select(SystemConfig).where(SystemConfig.id == 1)
)
config = config_result.scalar_one_or_none()
if not config or not config.allow_registration:
raise HTTPException(status_code=403, detail="Registration is not available")
# Check username availability (generic error to prevent enumeration)
existing = await db.execute(
select(User).where(User.username == data.username)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Registration failed")
password_hash = hash_password(data.password)
# SEC-01: Explicit field assignment — never **data.model_dump()
new_user = User(
username=data.username,
password_hash=password_hash,
role="standard",
last_password_change_at=datetime.now(),
)
# Check if MFA enforcement is enabled for new users
if config.enforce_mfa_new_users:
new_user.mfa_enforce_pending = True
db.add(new_user)
await db.flush()
await _create_user_defaults(db, new_user.id)
await db.commit()
ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent")
await log_audit_event(
db, action="auth.registration", actor_id=new_user.id, ip=ip,
)
await db.commit()
# If MFA enforcement is pending, don't issue a session — require MFA setup first
if new_user.mfa_enforce_pending:
enforce_token = create_mfa_enforce_token(new_user.id)
return {
"message": "Registration successful",
"authenticated": False,
"mfa_setup_required": True,
"mfa_token": enforce_token,
}
_, token = await _create_db_session(db, new_user, ip, user_agent)
_set_session_cookie(response, token)
return {"message": "Registration successful", "authenticated": True}
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(
response: Response, response: Response,
@ -304,13 +456,13 @@ async def auth_status(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
Check authentication status and whether initial setup has been performed. Check authentication status, role, and whether initial setup/registration is available.
Used by the frontend to decide whether to show login vs setup screen.
""" """
user_result = await db.execute(select(User)) user_result = await db.execute(select(User))
existing_user = user_result.scalar_one_or_none() existing_user = user_result.scalar_one_or_none()
setup_required = existing_user is None setup_required = existing_user is None
authenticated = False authenticated = False
role = None
if not setup_required and session_cookie: if not setup_required and session_cookie:
payload = verify_session_token(session_cookie) payload = verify_session_token(session_cookie)
@ -326,9 +478,32 @@ async def auth_status(
UserSession.expires_at > datetime.now(), UserSession.expires_at > datetime.now(),
) )
) )
authenticated = session_result.scalar_one_or_none() is not None if session_result.scalar_one_or_none() is not None:
authenticated = True
user_obj_result = await db.execute(
select(User).where(User.id == user_id, User.is_active == True)
)
u = user_obj_result.scalar_one_or_none()
if u:
role = u.role
else:
authenticated = False
return {"authenticated": authenticated, "setup_required": setup_required} # Check registration availability
registration_open = False
if not setup_required:
config_result = await db.execute(
select(SystemConfig).where(SystemConfig.id == 1)
)
config = config_result.scalar_one_or_none()
registration_open = config.allow_registration if config else False
return {
"authenticated": authenticated,
"setup_required": setup_required,
"role": role,
"registration_open": registration_open,
}
@router.post("/verify-password") @router.post("/verify-password")
@ -340,8 +515,6 @@ async def verify_password(
""" """
Verify the current user's password without changing anything. Verify the current user's password without changing anything.
Used by the frontend lock screen to re-authenticate without a full login. Used by the frontend lock screen to re-authenticate without a full login.
Also handles transparent bcryptArgon2id upgrade.
Shares the same lockout guards as /login. Nginx limit_req_zone handles IP rate limiting.
""" """
await _check_account_lockout(current_user) await _check_account_lockout(current_user)
@ -350,7 +523,6 @@ async def verify_password(
await _record_failed_login(db, current_user) await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid password") raise HTTPException(status_code=401, detail="Invalid password")
# Persist upgraded hash if migration happened
if new_hash: if new_hash:
current_user.password_hash = new_hash current_user.password_hash = new_hash
await db.commit() await db.commit()
@ -373,6 +545,12 @@ async def change_password(
raise HTTPException(status_code=401, detail="Invalid current password") raise HTTPException(status_code=401, detail="Invalid current password")
current_user.password_hash = hash_password(data.new_password) current_user.password_hash = hash_password(data.new_password)
current_user.last_password_change_at = datetime.now()
# Clear forced password change flag if set (SEC-12)
if current_user.must_change_password:
current_user.must_change_password = False
await db.commit() await db.commit()
return {"message": "Password changed successfully"} return {"message": "Password changed successfully"}

View File

@ -18,7 +18,11 @@ async def get_calendars(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
result = await db.execute(select(Calendar).order_by(Calendar.is_default.desc(), Calendar.name.asc())) result = await db.execute(
select(Calendar)
.where(Calendar.user_id == current_user.id)
.order_by(Calendar.is_default.desc(), Calendar.name.asc())
)
return result.scalars().all() return result.scalars().all()
@ -34,6 +38,7 @@ async def create_calendar(
is_default=False, is_default=False,
is_system=False, is_system=False,
is_visible=True, is_visible=True,
user_id=current_user.id,
) )
db.add(new_calendar) db.add(new_calendar)
await db.commit() await db.commit()
@ -48,7 +53,9 @@ async def update_calendar(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) result = await db.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id)
)
calendar = result.scalar_one_or_none() calendar = result.scalar_one_or_none()
if not calendar: if not calendar:
@ -74,7 +81,9 @@ async def delete_calendar(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
result = await db.execute(select(Calendar).where(Calendar.id == calendar_id)) result = await db.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id)
)
calendar = result.scalar_one_or_none() calendar = result.scalar_one_or_none()
if not calendar: if not calendar:
@ -86,8 +95,13 @@ async def delete_calendar(
if calendar.is_default: if calendar.is_default:
raise HTTPException(status_code=400, detail="Cannot delete the default calendar") raise HTTPException(status_code=400, detail="Cannot delete the default calendar")
# Reassign all events on this calendar to the default calendar # Reassign all events on this calendar to the user's default calendar
default_result = await db.execute(select(Calendar).where(Calendar.is_default == True)) default_result = await db.execute(
select(Calendar).where(
Calendar.user_id == current_user.id,
Calendar.is_default == True,
)
)
default_calendar = default_result.scalar_one_or_none() default_calendar = default_result.scalar_one_or_none()
if default_calendar: if default_calendar:

View File

@ -8,9 +8,11 @@ from app.database import get_db
from app.models.settings import Settings from app.models.settings import Settings
from app.models.todo import Todo from app.models.todo import Todo
from app.models.calendar_event import CalendarEvent from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
from app.models.reminder import Reminder from app.models.reminder import Reminder
from app.models.project import Project from app.models.project import Project
from app.routers.auth import get_current_settings from app.models.user import User
from app.routers.auth import get_current_user, get_current_settings
router = APIRouter() router = APIRouter()
@ -26,16 +28,21 @@ _not_parent_template = or_(
async def get_dashboard( async def get_dashboard(
client_date: Optional[date] = Query(None), client_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_settings: Settings = Depends(get_current_settings) current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
): ):
"""Get aggregated dashboard data.""" """Get aggregated dashboard data."""
today = client_date or date.today() today = client_date or date.today()
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days) upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
# Subquery: calendar IDs belonging to this user (for event scoping)
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
# Today's events (exclude parent templates — they are hidden, children are shown) # Today's events (exclude parent templates — they are hidden, children are shown)
today_start = datetime.combine(today, datetime.min.time()) today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time()) today_end = datetime.combine(today, datetime.max.time())
events_query = select(CalendarEvent).where( events_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= today_end, CalendarEvent.start_datetime <= today_end,
_not_parent_template, _not_parent_template,
@ -45,6 +52,7 @@ async def get_dashboard(
# Upcoming todos (not completed, with due date from today through upcoming_days) # Upcoming todos (not completed, with due date from today through upcoming_days)
todos_query = select(Todo).where( todos_query = select(Todo).where(
Todo.user_id == current_user.id,
Todo.completed == False, Todo.completed == False,
Todo.due_date.isnot(None), Todo.due_date.isnot(None),
Todo.due_date >= today, Todo.due_date >= today,
@ -55,6 +63,7 @@ async def get_dashboard(
# Active reminders (not dismissed, is_active = true, from today onward) # Active reminders (not dismissed, is_active = true, from today onward)
reminders_query = select(Reminder).where( reminders_query = select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.is_active == True, Reminder.is_active == True,
Reminder.is_dismissed == False, Reminder.is_dismissed == False,
Reminder.remind_at >= today_start Reminder.remind_at >= today_start
@ -62,26 +71,32 @@ async def get_dashboard(
reminders_result = await db.execute(reminders_query) reminders_result = await db.execute(reminders_query)
active_reminders = reminders_result.scalars().all() active_reminders = reminders_result.scalars().all()
# Project stats # Project stats (scoped to user)
total_projects_result = await db.execute(select(func.count(Project.id))) total_projects_result = await db.execute(
select(func.count(Project.id)).where(Project.user_id == current_user.id)
)
total_projects = total_projects_result.scalar() total_projects = total_projects_result.scalar()
projects_by_status_query = select( projects_by_status_query = select(
Project.status, Project.status,
func.count(Project.id).label("count") func.count(Project.id).label("count")
).group_by(Project.status) ).where(Project.user_id == current_user.id).group_by(Project.status)
projects_by_status_result = await db.execute(projects_by_status_query) projects_by_status_result = await db.execute(projects_by_status_query)
projects_by_status = {row[0]: row[1] for row in projects_by_status_result} projects_by_status = {row[0]: row[1] for row in projects_by_status_result}
# Total incomplete todos count # Total incomplete todos count (scoped to user)
total_incomplete_result = await db.execute( total_incomplete_result = await db.execute(
select(func.count(Todo.id)).where(Todo.completed == False) select(func.count(Todo.id)).where(
Todo.user_id == current_user.id,
Todo.completed == False,
)
) )
total_incomplete_todos = total_incomplete_result.scalar() total_incomplete_todos = total_incomplete_result.scalar()
# Starred events (upcoming, ordered by date) # Starred events (upcoming, ordered by date, scoped to user's calendars)
now = datetime.now() now = datetime.now()
starred_query = select(CalendarEvent).where( starred_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.is_starred == True, CalendarEvent.is_starred == True,
CalendarEvent.start_datetime > now, CalendarEvent.start_datetime > now,
_not_parent_template, _not_parent_template,
@ -143,7 +158,8 @@ 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),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_settings: Settings = Depends(get_current_settings) current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
): ):
"""Get unified list of upcoming items (todos, events, reminders) sorted by date.""" """Get unified list of upcoming items (todos, events, reminders) sorted by date."""
today = client_date or date.today() today = client_date or date.today()
@ -151,8 +167,12 @@ async def get_upcoming(
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time()) cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
today_start = datetime.combine(today, datetime.min.time()) today_start = datetime.combine(today, datetime.min.time())
# Get upcoming todos with due dates (today onward only) # Subquery: calendar IDs belonging to this user
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
# Get upcoming todos with due dates (today onward only, scoped to user)
todos_query = select(Todo).where( todos_query = select(Todo).where(
Todo.user_id == current_user.id,
Todo.completed == False, Todo.completed == False,
Todo.due_date.isnot(None), Todo.due_date.isnot(None),
Todo.due_date >= today, Todo.due_date >= today,
@ -161,8 +181,9 @@ async def get_upcoming(
todos_result = await db.execute(todos_query) todos_result = await db.execute(todos_query)
todos = todos_result.scalars().all() todos = todos_result.scalars().all()
# Get upcoming events (from today onward, exclude parent templates) # Get upcoming events (from today onward, exclude parent templates, scoped to user's calendars)
events_query = select(CalendarEvent).where( events_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= cutoff_datetime, CalendarEvent.start_datetime <= cutoff_datetime,
_not_parent_template, _not_parent_template,
@ -170,8 +191,9 @@ async def get_upcoming(
events_result = await db.execute(events_query) events_result = await db.execute(events_query)
events = events_result.scalars().all() events = events_result.scalars().all()
# Get upcoming reminders (today onward only) # Get upcoming reminders (today onward only, scoped to user)
reminders_query = select(Reminder).where( reminders_query = select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.is_active == True, Reminder.is_active == True,
Reminder.is_dismissed == False, Reminder.is_dismissed == False,
Reminder.remind_at >= today_start, Reminder.remind_at >= today_start,

View File

@ -20,7 +20,11 @@ async def list_templates(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
result = await db.execute(select(EventTemplate).order_by(EventTemplate.name)) result = await db.execute(
select(EventTemplate)
.where(EventTemplate.user_id == current_user.id)
.order_by(EventTemplate.name)
)
return result.scalars().all() return result.scalars().all()
@ -30,7 +34,7 @@ async def create_template(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
template = EventTemplate(**payload.model_dump()) template = EventTemplate(**payload.model_dump(), user_id=current_user.id)
db.add(template) db.add(template)
await db.commit() await db.commit()
await db.refresh(template) await db.refresh(template)
@ -45,7 +49,10 @@ async def update_template(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
result = await db.execute( result = await db.execute(
select(EventTemplate).where(EventTemplate.id == template_id) select(EventTemplate).where(
EventTemplate.id == template_id,
EventTemplate.user_id == current_user.id,
)
) )
template = result.scalar_one_or_none() template = result.scalar_one_or_none()
if template is None: if template is None:
@ -66,7 +73,10 @@ async def delete_template(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
result = await db.execute( result = await db.execute(
select(EventTemplate).where(EventTemplate.id == template_id) select(EventTemplate).where(
EventTemplate.id == template_id,
EventTemplate.user_id == current_user.id,
)
) )
template = result.scalar_one_or_none() template = result.scalar_one_or_none()
if template is None: if template is None:

View File

@ -105,15 +105,29 @@ def _birthday_events_for_range(
return virtual_events return virtual_events
async def _get_default_calendar_id(db: AsyncSession) -> int: async def _get_default_calendar_id(db: AsyncSession, user_id: int) -> int:
"""Return the id of the default calendar, raising 500 if not found.""" """Return the id of the user's default calendar, raising 500 if not found."""
result = await db.execute(select(Calendar).where(Calendar.is_default == True)) result = await db.execute(
select(Calendar).where(
Calendar.user_id == user_id,
Calendar.is_default == True,
)
)
default = result.scalar_one_or_none() default = result.scalar_one_or_none()
if not default: if not default:
raise HTTPException(status_code=500, detail="No default calendar configured") raise HTTPException(status_code=500, detail="No default calendar configured")
return default.id return default.id
async def _verify_calendar_ownership(db: AsyncSession, calendar_id: int, user_id: int) -> None:
"""Raise 404 if calendar_id does not belong to user_id (SEC-04)."""
result = await db.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == user_id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Calendar not found")
@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),
@ -128,9 +142,13 @@ async def get_events(
recurrence_rule IS NOT NULL) are excluded their materialised children recurrence_rule IS NOT NULL) are excluded their materialised children
are what get displayed on the calendar. are what get displayed on the calendar.
""" """
# Scope events through calendar ownership
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
query = ( query = (
select(CalendarEvent) select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar)) .options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.calendar_id.in_(user_calendar_ids))
) )
# Exclude parent template rows — they are not directly rendered # Exclude parent template rows — they are not directly rendered
@ -154,14 +172,24 @@ async def get_events(
response: List[dict] = [_event_to_dict(e) for e in events] response: List[dict] = [_event_to_dict(e) for e in events]
# Fetch Birthdays calendar; only generate virtual events if visible # Fetch the user's Birthdays system calendar; only generate virtual events if visible
bday_result = await db.execute( bday_result = await db.execute(
select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True) select(Calendar).where(
Calendar.user_id == current_user.id,
Calendar.name == "Birthdays",
Calendar.is_system == True,
)
) )
bday_calendar = bday_result.scalar_one_or_none() bday_calendar = bday_result.scalar_one_or_none()
if bday_calendar and bday_calendar.is_visible: if bday_calendar and bday_calendar.is_visible:
people_result = await db.execute(select(Person).where(Person.birthday.isnot(None))) # Scope birthday people to this user
people_result = await db.execute(
select(Person).where(
Person.user_id == current_user.id,
Person.birthday.isnot(None),
)
)
people = people_result.scalars().all() people = people_result.scalars().all()
virtual = _birthday_events_for_range( virtual = _birthday_events_for_range(
@ -187,9 +215,12 @@ async def create_event(
data = event.model_dump() data = event.model_dump()
# Resolve calendar_id to default if not provided # Resolve calendar_id to user's default if not provided
if not data.get("calendar_id"): if not data.get("calendar_id"):
data["calendar_id"] = await _get_default_calendar_id(db) data["calendar_id"] = await _get_default_calendar_id(db, current_user.id)
else:
# SEC-04: verify the target calendar belongs to the requesting user
await _verify_calendar_ownership(db, data["calendar_id"], current_user.id)
# Serialize RecurrenceRule object to JSON string for DB storage # Serialize RecurrenceRule object to JSON string for DB storage
# Exclude None values so defaults in recurrence service work correctly # Exclude None values so defaults in recurrence service work correctly
@ -245,10 +276,15 @@ async def get_event(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
result = await db.execute( result = await db.execute(
select(CalendarEvent) select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar)) .options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id) .where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
)
) )
event = result.scalar_one_or_none() event = result.scalar_one_or_none()
@ -265,10 +301,15 @@ async def update_event(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
result = await db.execute( result = await db.execute(
select(CalendarEvent) select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar)) .options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id) .where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
)
) )
event = result.scalar_one_or_none() event = result.scalar_one_or_none()
@ -285,6 +326,10 @@ async def update_event(
if rule_obj is not None: if rule_obj is not None:
update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
if "calendar_id" in update_data and update_data["calendar_id"] is not None:
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id)
start = update_data.get("start_datetime", event.start_datetime) start = update_data.get("start_datetime", event.start_datetime)
end_dt = update_data.get("end_datetime", event.end_datetime) end_dt = update_data.get("end_datetime", event.end_datetime)
if end_dt is not None and end_dt < start: if end_dt is not None and end_dt < start:
@ -381,7 +426,14 @@ async def delete_event(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
result = await db.execute(
select(CalendarEvent).where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
)
)
event = result.scalar_one_or_none() event = result.scalar_one_or_none()
if not event: if not event:

View File

@ -29,14 +29,15 @@ async def search_locations(
"""Search locations from local DB and Nominatim OSM.""" """Search locations from local DB and Nominatim OSM."""
results: List[LocationSearchResult] = [] results: List[LocationSearchResult] = []
# Local DB search # Local DB search — scoped to user's locations
local_query = ( local_query = (
select(Location) select(Location)
.where( .where(
Location.user_id == current_user.id,
or_( or_(
Location.name.ilike(f"%{q}%"), Location.name.ilike(f"%{q}%"),
Location.address.ilike(f"%{q}%"), Location.address.ilike(f"%{q}%"),
) ),
) )
.limit(5) .limit(5)
) )
@ -89,7 +90,7 @@ async def get_locations(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get all locations with optional category filter.""" """Get all locations with optional category filter."""
query = select(Location) query = select(Location).where(Location.user_id == current_user.id)
if category: if category:
query = query.where(Location.category == category) query = query.where(Location.category == category)
@ -109,7 +110,7 @@ async def create_location(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Create a new location.""" """Create a new location."""
new_location = Location(**location.model_dump()) new_location = Location(**location.model_dump(), user_id=current_user.id)
db.add(new_location) db.add(new_location)
await db.commit() await db.commit()
await db.refresh(new_location) await db.refresh(new_location)
@ -124,7 +125,9 @@ async def get_location(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get a specific location by ID.""" """Get a specific location by ID."""
result = await db.execute(select(Location).where(Location.id == location_id)) result = await db.execute(
select(Location).where(Location.id == location_id, Location.user_id == current_user.id)
)
location = result.scalar_one_or_none() location = result.scalar_one_or_none()
if not location: if not location:
@ -141,7 +144,9 @@ async def update_location(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Update a location.""" """Update a location."""
result = await db.execute(select(Location).where(Location.id == location_id)) result = await db.execute(
select(Location).where(Location.id == location_id, Location.user_id == current_user.id)
)
location = result.scalar_one_or_none() location = result.scalar_one_or_none()
if not location: if not location:
@ -168,7 +173,9 @@ async def delete_location(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a location.""" """Delete a location."""
result = await db.execute(select(Location).where(Location.id == location_id)) result = await db.execute(
select(Location).where(Location.id == location_id, Location.user_id == current_user.id)
)
location = result.scalar_one_or_none() location = result.scalar_one_or_none()
if not location: if not location:

View File

@ -37,7 +37,7 @@ async def get_people(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get all people with optional search and category filter.""" """Get all people with optional search and category filter."""
query = select(Person) query = select(Person).where(Person.user_id == current_user.id)
if search: if search:
term = f"%{search}%" term = f"%{search}%"
@ -75,7 +75,7 @@ async def create_person(
parts = data['name'].split(' ', 1) parts = data['name'].split(' ', 1)
data['first_name'] = parts[0] data['first_name'] = parts[0]
data['last_name'] = parts[1] if len(parts) > 1 else None data['last_name'] = parts[1] if len(parts) > 1 else None
new_person = Person(**data) new_person = Person(**data, user_id=current_user.id)
new_person.name = _compute_display_name( new_person.name = _compute_display_name(
new_person.first_name, new_person.first_name,
new_person.last_name, new_person.last_name,
@ -96,7 +96,9 @@ async def get_person(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get a specific person by ID.""" """Get a specific person by ID."""
result = await db.execute(select(Person).where(Person.id == person_id)) result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
)
person = result.scalar_one_or_none() person = result.scalar_one_or_none()
if not person: if not person:
@ -113,7 +115,9 @@ async def update_person(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Update a person and refresh the denormalised display name.""" """Update a person and refresh the denormalised display name."""
result = await db.execute(select(Person).where(Person.id == person_id)) result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
)
person = result.scalar_one_or_none() person = result.scalar_one_or_none()
if not person: if not person:
@ -147,7 +151,9 @@ async def delete_person(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a person.""" """Delete a person."""
result = await db.execute(select(Person).where(Person.id == person_id)) result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
)
person = result.scalar_one_or_none() person = result.scalar_one_or_none()
if not person: if not person:

View File

@ -49,7 +49,12 @@ async def get_projects(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get all projects with their tasks. Optionally filter by tracked status.""" """Get all projects with their tasks. Optionally filter by tracked status."""
query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc()) query = (
select(Project)
.options(*_project_load_options())
.where(Project.user_id == current_user.id)
.order_by(Project.created_at.desc())
)
if tracked is not None: if tracked is not None:
query = query.where(Project.is_tracked == tracked) query = query.where(Project.is_tracked == tracked)
result = await db.execute(query) result = await db.execute(query)
@ -77,6 +82,7 @@ async def get_tracked_tasks(
selectinload(ProjectTask.parent_task), selectinload(ProjectTask.parent_task),
) )
.where( .where(
Project.user_id == current_user.id,
Project.is_tracked == True, Project.is_tracked == True,
ProjectTask.due_date.isnot(None), ProjectTask.due_date.isnot(None),
ProjectTask.due_date >= today, ProjectTask.due_date >= today,
@ -110,7 +116,7 @@ async def create_project(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Create a new project.""" """Create a new project."""
new_project = Project(**project.model_dump()) new_project = Project(**project.model_dump(), user_id=current_user.id)
db.add(new_project) db.add(new_project)
await db.commit() await db.commit()
@ -127,7 +133,11 @@ async def get_project(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get a specific project by ID with its tasks.""" """Get a specific project by ID with its tasks."""
query = select(Project).options(*_project_load_options()).where(Project.id == project_id) query = (
select(Project)
.options(*_project_load_options())
.where(Project.id == project_id, Project.user_id == current_user.id)
)
result = await db.execute(query) result = await db.execute(query)
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
@ -145,7 +155,9 @@ async def update_project(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Update a project.""" """Update a project."""
result = await db.execute(select(Project).where(Project.id == project_id)) result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
)
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
@ -171,7 +183,9 @@ async def delete_project(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a project and all its tasks.""" """Delete a project and all its tasks."""
result = await db.execute(select(Project).where(Project.id == project_id)) result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
)
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
@ -190,7 +204,10 @@ async def get_project_tasks(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get top-level tasks for a specific project (subtasks are nested).""" """Get top-level tasks for a specific project (subtasks are nested)."""
result = await db.execute(select(Project).where(Project.id == project_id)) # Verify project ownership first
result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
)
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
@ -219,7 +236,10 @@ async def create_project_task(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Create a new task or subtask for a project.""" """Create a new task or subtask for a project."""
result = await db.execute(select(Project).where(Project.id == project_id)) # Verify project ownership first
result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
)
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
@ -265,7 +285,10 @@ async def reorder_tasks(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Bulk update sort_order for tasks.""" """Bulk update sort_order for tasks."""
result = await db.execute(select(Project).where(Project.id == project_id)) # Verify project ownership first
result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
)
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
@ -296,6 +319,13 @@ async def update_project_task(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Update a project task.""" """Update a project task."""
# Verify project ownership first, then fetch task scoped to that project
project_result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
)
if not project_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
result = await db.execute( result = await db.execute(
select(ProjectTask).where( select(ProjectTask).where(
ProjectTask.id == task_id, ProjectTask.id == task_id,
@ -332,6 +362,13 @@ async def delete_project_task(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a project task (cascades to subtasks).""" """Delete a project task (cascades to subtasks)."""
# Verify project ownership first, then fetch task scoped to that project
project_result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
)
if not project_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
result = await db.execute( result = await db.execute(
select(ProjectTask).where( select(ProjectTask).where(
ProjectTask.id == task_id, ProjectTask.id == task_id,
@ -358,6 +395,13 @@ async def create_task_comment(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Add a comment to a task.""" """Add a comment to a task."""
# Verify project ownership first, then fetch task scoped to that project
project_result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
)
if not project_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
result = await db.execute( result = await db.execute(
select(ProjectTask).where( select(ProjectTask).where(
ProjectTask.id == task_id, ProjectTask.id == task_id,
@ -386,6 +430,13 @@ async def delete_task_comment(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a task comment.""" """Delete a task comment."""
# Verify project ownership first, then fetch comment scoped through task
project_result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
)
if not project_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
result = await db.execute( result = await db.execute(
select(TaskComment).where( select(TaskComment).where(
TaskComment.id == comment_id, TaskComment.id == comment_id,

View File

@ -22,7 +22,7 @@ async def get_reminders(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get all reminders with optional filters.""" """Get all reminders with optional filters."""
query = select(Reminder) query = select(Reminder).where(Reminder.user_id == current_user.id)
if active is not None: if active is not None:
query = query.where(Reminder.is_active == active) query = query.where(Reminder.is_active == active)
@ -48,6 +48,7 @@ async def get_due_reminders(
now = client_now or datetime.now() now = client_now or datetime.now()
query = select(Reminder).where( query = select(Reminder).where(
and_( and_(
Reminder.user_id == current_user.id,
Reminder.remind_at <= now, Reminder.remind_at <= now,
Reminder.is_dismissed == False, Reminder.is_dismissed == False,
Reminder.is_active == True, Reminder.is_active == True,
@ -74,7 +75,12 @@ async def snooze_reminder(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Snooze a reminder for N minutes from now.""" """Snooze a reminder for N minutes from now."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) result = await db.execute(
select(Reminder).where(
Reminder.id == reminder_id,
Reminder.user_id == current_user.id,
)
)
reminder = result.scalar_one_or_none() reminder = result.scalar_one_or_none()
if not reminder: if not reminder:
@ -99,7 +105,7 @@ async def create_reminder(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Create a new reminder.""" """Create a new reminder."""
new_reminder = Reminder(**reminder.model_dump()) new_reminder = Reminder(**reminder.model_dump(), user_id=current_user.id)
db.add(new_reminder) db.add(new_reminder)
await db.commit() await db.commit()
await db.refresh(new_reminder) await db.refresh(new_reminder)
@ -114,7 +120,12 @@ async def get_reminder(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get a specific reminder by ID.""" """Get a specific reminder by ID."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) result = await db.execute(
select(Reminder).where(
Reminder.id == reminder_id,
Reminder.user_id == current_user.id,
)
)
reminder = result.scalar_one_or_none() reminder = result.scalar_one_or_none()
if not reminder: if not reminder:
@ -131,7 +142,12 @@ async def update_reminder(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Update a reminder.""" """Update a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) result = await db.execute(
select(Reminder).where(
Reminder.id == reminder_id,
Reminder.user_id == current_user.id,
)
)
reminder = result.scalar_one_or_none() reminder = result.scalar_one_or_none()
if not reminder: if not reminder:
@ -164,7 +180,12 @@ async def delete_reminder(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a reminder.""" """Delete a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) result = await db.execute(
select(Reminder).where(
Reminder.id == reminder_id,
Reminder.user_id == current_user.id,
)
)
reminder = result.scalar_one_or_none() reminder = result.scalar_one_or_none()
if not reminder: if not reminder:
@ -183,7 +204,12 @@ async def dismiss_reminder(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Dismiss a reminder.""" """Dismiss a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) result = await db.execute(
select(Reminder).where(
Reminder.id == reminder_id,
Reminder.user_id == current_user.id,
)
)
reminder = result.scalar_one_or_none() reminder = result.scalar_one_or_none()
if not reminder: if not reminder:

View File

@ -73,15 +73,17 @@ def _calculate_recurrence(
return reset_at, next_due return reset_at, next_due
async def _reactivate_recurring_todos(db: AsyncSession) -> None: async def _reactivate_recurring_todos(db: AsyncSession, user_id: int) -> None:
"""Auto-reactivate recurring todos whose reset_at has passed. """Auto-reactivate recurring todos whose reset_at has passed.
Uses flush (not commit) so changes are visible to the subsequent query Uses flush (not commit) so changes are visible to the subsequent query
within the same transaction. The caller's commit handles persistence. within the same transaction. The caller's commit handles persistence.
Scoped to a single user to avoid cross-user reactivation.
""" """
now = datetime.now() now = datetime.now()
query = select(Todo).where( query = select(Todo).where(
and_( and_(
Todo.user_id == user_id,
Todo.completed == True, Todo.completed == True,
Todo.recurrence_rule.isnot(None), Todo.recurrence_rule.isnot(None),
Todo.reset_at.isnot(None), Todo.reset_at.isnot(None),
@ -110,13 +112,14 @@ async def get_todos(
category: Optional[str] = Query(None), category: Optional[str] = Query(None),
search: Optional[str] = Query(None), search: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings) current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
): ):
"""Get all todos with optional filters.""" """Get all todos with optional filters."""
# Reactivate any recurring todos whose reset time has passed # Reactivate any recurring todos whose reset time has passed
await _reactivate_recurring_todos(db) await _reactivate_recurring_todos(db, current_user.id)
query = select(Todo) query = select(Todo).where(Todo.user_id == current_user.id)
if completed is not None: if completed is not None:
query = query.where(Todo.completed == completed) query = query.where(Todo.completed == completed)
@ -144,10 +147,10 @@ async def get_todos(
async def create_todo( async def create_todo(
todo: TodoCreate, todo: TodoCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings) current_user: User = Depends(get_current_user),
): ):
"""Create a new todo.""" """Create a new todo."""
new_todo = Todo(**todo.model_dump()) new_todo = Todo(**todo.model_dump(), user_id=current_user.id)
db.add(new_todo) db.add(new_todo)
await db.commit() await db.commit()
await db.refresh(new_todo) await db.refresh(new_todo)
@ -159,10 +162,12 @@ async def create_todo(
async def get_todo( async def get_todo(
todo_id: int, todo_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings) current_user: User = Depends(get_current_user),
): ):
"""Get a specific todo by ID.""" """Get a specific todo by ID."""
result = await db.execute(select(Todo).where(Todo.id == todo_id)) result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none() todo = result.scalar_one_or_none()
if not todo: if not todo:
@ -176,10 +181,13 @@ async def update_todo(
todo_id: int, todo_id: int,
todo_update: TodoUpdate, todo_update: TodoUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings) current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
): ):
"""Update a todo.""" """Update a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id)) result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none() todo = result.scalar_one_or_none()
if not todo: if not todo:
@ -210,7 +218,7 @@ async def update_todo(
reset_at, next_due = _calculate_recurrence( reset_at, next_due = _calculate_recurrence(
todo.recurrence_rule, todo.recurrence_rule,
todo.due_date, todo.due_date,
current_user.first_day_of_week, current_settings.first_day_of_week,
) )
todo.reset_at = reset_at todo.reset_at = reset_at
todo.next_due_date = next_due todo.next_due_date = next_due
@ -229,10 +237,12 @@ async def update_todo(
async def delete_todo( async def delete_todo(
todo_id: int, todo_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings) current_user: User = Depends(get_current_user),
): ):
"""Delete a todo.""" """Delete a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id)) result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none() todo = result.scalar_one_or_none()
if not todo: if not todo:
@ -248,10 +258,13 @@ async def delete_todo(
async def toggle_todo( async def toggle_todo(
todo_id: int, todo_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings) current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
): ):
"""Toggle todo completion status. For recurring todos, calculates reset schedule.""" """Toggle todo completion status. For recurring todos, calculates reset schedule."""
result = await db.execute(select(Todo).where(Todo.id == todo_id)) result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none() todo = result.scalar_one_or_none()
if not todo: if not todo:
@ -267,7 +280,7 @@ async def toggle_todo(
reset_at, next_due = _calculate_recurrence( reset_at, next_due = _calculate_recurrence(
todo.recurrence_rule, todo.recurrence_rule,
todo.due_date, todo.due_date,
current_user.first_day_of_week, current_settings.first_day_of_week,
) )
todo.reset_at = reset_at todo.reset_at = reset_at
todo.next_due_date = next_due todo.next_due_date = next_due

View File

@ -39,6 +39,7 @@ from app.services.auth import (
verify_password_with_upgrade, verify_password_with_upgrade,
hash_password, hash_password,
verify_mfa_token, verify_mfa_token,
verify_mfa_enforce_token,
create_session_token, create_session_token,
) )
from app.services.totp import ( from app.services.totp import (
@ -94,6 +95,15 @@ class BackupCodesRegenerateRequest(BaseModel):
code: str # Current TOTP code required to regenerate code: str # Current TOTP code required to regenerate
class EnforceSetupRequest(BaseModel):
mfa_token: str
class EnforceConfirmRequest(BaseModel):
mfa_token: str
code: str # 6-digit TOTP code from authenticator app
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Internal helpers # Internal helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -394,6 +404,108 @@ async def regenerate_backup_codes(
return {"backup_codes": plaintext_codes} return {"backup_codes": plaintext_codes}
@router.post("/totp/enforce-setup")
async def enforce_setup_totp(
data: EnforceSetupRequest,
db: AsyncSession = Depends(get_db),
):
"""
Generate TOTP secret + QR code + backup codes during MFA enforcement.
Called after login returns mfa_setup_required=True. Uses the mfa_enforce_token
(not a session cookie) because the user is not yet fully authenticated.
Idempotent: regenerates secret if called again before confirm.
Returns { secret, qr_code_base64, backup_codes }.
"""
user_id = verify_mfa_enforce_token(data.mfa_token)
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid or expired enforcement token — please log in again")
result = await db.execute(select(User).where(User.id == user_id, User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found or inactive")
if not user.mfa_enforce_pending:
raise HTTPException(status_code=400, detail="MFA enforcement is not pending for this account")
if user.totp_enabled:
raise HTTPException(status_code=400, detail="TOTP is already enabled for this account")
# Generate new secret (idempotent — overwrite any unconfirmed secret)
raw_secret = generate_totp_secret()
encrypted_secret = encrypt_totp_secret(raw_secret)
user.totp_secret = encrypted_secret
user.totp_enabled = False # Not enabled until enforce-confirm called
# Generate backup codes — hash before storage, return plaintext once
plaintext_codes = generate_backup_codes(10)
await _delete_backup_codes(db, user.id)
await _store_backup_codes(db, user.id, plaintext_codes)
await db.commit()
uri = get_totp_uri(encrypted_secret, user.username)
qr_base64 = generate_qr_base64(uri)
return {
"secret": raw_secret,
"qr_code_base64": qr_base64,
"backup_codes": plaintext_codes,
}
@router.post("/totp/enforce-confirm")
async def enforce_confirm_totp(
data: EnforceConfirmRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""
Confirm TOTP setup during enforcement, clear the pending flag, issue a full session.
Must be called after /totp/enforce-setup while totp_enabled is still False.
On success: enables TOTP, clears mfa_enforce_pending, sets session cookie,
returns { authenticated: true }.
"""
user_id = verify_mfa_enforce_token(data.mfa_token)
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid or expired enforcement token — please log in again")
result = await db.execute(select(User).where(User.id == user_id, User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found or inactive")
if not user.mfa_enforce_pending:
raise HTTPException(status_code=400, detail="MFA enforcement is not pending for this account")
if not user.totp_secret:
raise HTTPException(status_code=400, detail="TOTP setup not started — call /totp/enforce-setup first")
if user.totp_enabled:
raise HTTPException(status_code=400, detail="TOTP is already enabled")
# Verify the confirmation code
matched_window = verify_totp_code(user.totp_secret, data.code)
if matched_window is None:
raise HTTPException(status_code=400, detail="Invalid code — check your authenticator app time sync")
# Enable TOTP and clear the enforcement flag
user.totp_enabled = True
user.mfa_enforce_pending = False
user.last_login_at = datetime.now()
await db.commit()
# Issue a full session
token = await _create_full_session(db, user, request)
_set_session_cookie(response, token)
return {"authenticated": True}
@router.get("/totp/status") @router.get("/totp/status")
async def totp_status( async def totp_status(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),

View File

@ -3,6 +3,7 @@ from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from datetime import datetime, timedelta from datetime import datetime, timedelta
from collections import OrderedDict
import asyncio import asyncio
import urllib.request import urllib.request
import urllib.parse import urllib.parse
@ -11,13 +12,37 @@ import json
from app.database import get_db from app.database import get_db
from app.models.settings import Settings from app.models.settings import Settings
from app.models.user import User
from app.config import settings as app_settings from app.config import settings as app_settings
from app.routers.auth import get_current_user, get_current_settings from app.routers.auth import get_current_user, get_current_settings
from app.models.user import User
router = APIRouter() router = APIRouter()
_cache: dict = {} # SEC-15: Bounded LRU cache keyed by (user_id, location) — max 100 entries.
# OrderedDict preserves insertion order; move_to_end on hit, popitem(last=False)
# to evict the oldest when capacity is exceeded.
_CACHE_MAX = 100
_cache: OrderedDict = OrderedDict()
def _cache_get(key: tuple) -> dict | None:
"""Return cached entry if it exists and hasn't expired."""
entry = _cache.get(key)
if entry and datetime.now() < entry["expires_at"]:
_cache.move_to_end(key) # LRU: promote to most-recently-used
return entry["data"]
if entry:
del _cache[key] # expired — evict immediately
return None
def _cache_set(key: tuple, data: dict) -> None:
"""Store an entry; evict the oldest if over capacity."""
if key in _cache:
_cache.move_to_end(key)
_cache[key] = {"data": data, "expires_at": datetime.now() + timedelta(hours=1)}
while len(_cache) > _CACHE_MAX:
_cache.popitem(last=False) # evict LRU (oldest)
class GeoSearchResult(BaseModel): class GeoSearchResult(BaseModel):
@ -66,23 +91,24 @@ async def search_locations(
@router.get("/") @router.get("/")
async def get_weather( async def get_weather(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings) current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
): ):
city = current_user.weather_city city = current_settings.weather_city
lat = current_user.weather_lat lat = current_settings.weather_lat
lon = current_user.weather_lon lon = current_settings.weather_lon
if not city and (lat is None or lon is None): if not city and (lat is None or lon is None):
raise HTTPException(status_code=400, detail="No weather location configured") raise HTTPException(status_code=400, detail="No weather location configured")
# Build cache key from coordinates or city # Cache key includes user_id so each user gets isolated cache entries
use_coords = lat is not None and lon is not None use_coords = lat is not None and lon is not None
cache_key = f"{lat},{lon}" if use_coords else city location_key = f"{lat},{lon}" if use_coords else city
cache_key = (current_user.id, location_key)
# Check cache cached = _cache_get(cache_key)
now = datetime.now() if cached is not None:
if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("cache_key") == cache_key: return cached
return _cache["data"]
api_key = app_settings.OPENWEATHERMAP_API_KEY api_key = app_settings.OPENWEATHERMAP_API_KEY
if not api_key: if not api_key:
@ -122,11 +148,7 @@ async def get_weather(
"city": current_data["name"], "city": current_data["name"],
} }
# Cache for 1 hour _cache_set(cache_key, weather_result)
_cache["data"] = weather_result
_cache["expires_at"] = now + timedelta(hours=1)
_cache["cache_key"] = cache_key
return weather_result return weather_result
except urllib.error.URLError: except urllib.error.URLError:

View File

@ -0,0 +1,133 @@
"""
Admin API schemas Pydantic v2.
All admin-facing request/response shapes live here to keep the admin router
clean and testable in isolation.
"""
import re
from datetime import datetime
from typing import Optional, Literal
from pydantic import BaseModel, ConfigDict, field_validator
from app.schemas.auth import _validate_username, _validate_password_strength
# ---------------------------------------------------------------------------
# User list / detail
# ---------------------------------------------------------------------------
class UserListItem(BaseModel):
id: int
username: str
role: str
is_active: bool
last_login_at: Optional[datetime] = None
last_password_change_at: Optional[datetime] = None
totp_enabled: bool
mfa_enforce_pending: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class UserListResponse(BaseModel):
users: list[UserListItem]
total: int
class UserDetailResponse(UserListItem):
active_sessions: int
# ---------------------------------------------------------------------------
# Mutating user requests
# ---------------------------------------------------------------------------
class CreateUserRequest(BaseModel):
"""Admin-created user — allows role selection (unlike public RegisterRequest)."""
model_config = ConfigDict(extra="forbid")
username: str
password: str
role: Literal["admin", "standard", "public_event_manager"] = "standard"
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
return _validate_username(v)
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
return _validate_password_strength(v)
class UpdateUserRoleRequest(BaseModel):
role: Literal["admin", "standard", "public_event_manager"]
class ToggleActiveRequest(BaseModel):
is_active: bool
class ToggleMfaEnforceRequest(BaseModel):
enforce: bool
# ---------------------------------------------------------------------------
# System config
# ---------------------------------------------------------------------------
class SystemConfigResponse(BaseModel):
allow_registration: bool
enforce_mfa_new_users: bool
model_config = ConfigDict(from_attributes=True)
class SystemConfigUpdate(BaseModel):
allow_registration: Optional[bool] = None
enforce_mfa_new_users: Optional[bool] = None
# ---------------------------------------------------------------------------
# Admin dashboard
# ---------------------------------------------------------------------------
class AdminDashboardResponse(BaseModel):
total_users: int
active_users: int
admin_count: int
active_sessions: int
mfa_adoption_rate: float
recent_logins: list[dict]
recent_audit_entries: list[dict]
# ---------------------------------------------------------------------------
# Password reset
# ---------------------------------------------------------------------------
class ResetPasswordResponse(BaseModel):
message: str
temporary_password: str
# ---------------------------------------------------------------------------
# Audit log
# ---------------------------------------------------------------------------
class AuditLogEntry(BaseModel):
id: int
actor_username: Optional[str] = None
target_username: Optional[str] = None
action: str
detail: Optional[str] = None
ip_address: Optional[str] = None
created_at: datetime
class AuditLogResponse(BaseModel):
entries: list[AuditLogEntry]
total: int

View File

@ -1,5 +1,5 @@
import re import re
from pydantic import BaseModel, field_validator from pydantic import BaseModel, ConfigDict, field_validator
def _validate_password_strength(v: str) -> str: def _validate_password_strength(v: str) -> str:
@ -21,6 +21,16 @@ def _validate_password_strength(v: str) -> str:
return v return v
def _validate_username(v: str) -> str:
"""Shared username validation."""
v = v.strip().lower()
if not 3 <= len(v) <= 50:
raise ValueError("Username must be 350 characters")
if not re.fullmatch(r"[a-z0-9_\-]+", v):
raise ValueError("Username may only contain letters, numbers, _ and -")
return v
class SetupRequest(BaseModel): class SetupRequest(BaseModel):
username: str username: str
password: str password: str
@ -28,12 +38,29 @@ class SetupRequest(BaseModel):
@field_validator("username") @field_validator("username")
@classmethod @classmethod
def validate_username(cls, v: str) -> str: def validate_username(cls, v: str) -> str:
v = v.strip().lower() return _validate_username(v)
if not 3 <= len(v) <= 50:
raise ValueError("Username must be 350 characters") @field_validator("password")
if not re.fullmatch(r"[a-z0-9_\-]+", v): @classmethod
raise ValueError("Username may only contain letters, numbers, _ and -") def validate_password(cls, v: str) -> str:
return v return _validate_password_strength(v)
class RegisterRequest(BaseModel):
"""
Public registration schema SEC-01: extra="forbid" prevents role injection.
An attacker sending {"username": "...", "password": "...", "role": "admin"}
will get a 422 Validation Error instead of silent acceptance.
"""
model_config = ConfigDict(extra="forbid")
username: str
password: str
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
return _validate_username(v)
@field_validator("password") @field_validator("password")
@classmethod @classmethod

View File

@ -0,0 +1,22 @@
import json
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
async def log_audit_event(
db: AsyncSession,
action: str,
actor_id: int | None = None,
target_id: int | None = None,
detail: dict | None = None,
ip: str | None = None,
) -> None:
"""Record an action in the audit log. Does NOT commit — caller handles transaction."""
entry = AuditLog(
actor_user_id=actor_id,
target_user_id=target_id,
action=action,
detail=json.dumps(detail) if detail else None,
ip_address=ip[:45] if ip else None,
)
db.add(entry)

View File

@ -126,3 +126,32 @@ def verify_mfa_token(token: str) -> int | None:
return data["uid"] return data["uid"]
except Exception: except Exception:
return None return None
# ---------------------------------------------------------------------------
# MFA enforcement tokens (SEC-03: distinct salt from challenge tokens)
# ---------------------------------------------------------------------------
_mfa_enforce_serializer = URLSafeTimedSerializer(
secret_key=app_settings.SECRET_KEY,
salt="mfa-enforce-setup-v1",
)
def create_mfa_enforce_token(user_id: int) -> str:
"""Create a short-lived token for MFA enforcement setup (not a session)."""
return _mfa_enforce_serializer.dumps({"uid": user_id})
def verify_mfa_enforce_token(token: str) -> int | None:
"""
Verify an MFA enforcement setup token.
Returns user_id on success, None if invalid or expired (5-minute TTL).
"""
try:
data = _mfa_enforce_serializer.loads(
token, max_age=app_settings.MFA_TOKEN_MAX_AGE_SECONDS
)
return data["uid"]
except Exception:
return None

View File

@ -1,5 +1,9 @@
# Rate limiting zones (before server block) # Rate limiting zones (before server block)
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
# SEC-14: Registration endpoint slightly more permissive than strict auth endpoints
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m;
# Admin API generous for legitimate use but still guards against scraping/brute-force
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m;
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access # Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
map $http_x_forwarded_proto $forwarded_proto { map $http_x_forwarded_proto $forwarded_proto {
@ -60,6 +64,20 @@ server {
include /etc/nginx/proxy-params.conf; include /etc/nginx/proxy-params.conf;
} }
# SEC-14: Rate-limit public registration endpoint
location /api/auth/register {
limit_req zone=register_limit burst=3 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Admin API rate-limited separately from general /api traffic
location /api/admin/ {
limit_req zone=admin_limit burst=10 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# API proxy # API proxy
location /api { location /api {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;

View File

@ -1,3 +1,4 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import LockScreen from '@/components/auth/LockScreen'; import LockScreen from '@/components/auth/LockScreen';
@ -12,6 +13,8 @@ import PeoplePage from '@/components/people/PeoplePage';
import LocationsPage from '@/components/locations/LocationsPage'; import LocationsPage from '@/components/locations/LocationsPage';
import SettingsPage from '@/components/settings/SettingsPage'; import SettingsPage from '@/components/settings/SettingsPage';
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth(); const { authStatus, isLoading } = useAuth();
@ -30,6 +33,24 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
function AdminRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
}
if (!authStatus?.authenticated || authStatus?.role !== 'admin') {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
}
function App() { function App() {
return ( return (
<Routes> <Routes>
@ -52,6 +73,16 @@ function App() {
<Route path="people" element={<PeoplePage />} /> <Route path="people" element={<PeoplePage />} />
<Route path="locations" element={<LocationsPage />} /> <Route path="locations" element={<LocationsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route
path="admin/*"
element={
<AdminRoute>
<Suspense fallback={<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>}>
<AdminPortal />
</Suspense>
</AdminRoute>
}
/>
</Route> </Route>
</Routes> </Routes>
); );

View File

@ -0,0 +1,241 @@
import {
Users,
UserCheck,
UserX,
Activity,
Smartphone,
LogIn,
ShieldAlert,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAdminDashboard, useAuditLog } from '@/hooks/useAdmin';
import { getRelativeTime } from '@/lib/date-utils';
import { cn } from '@/lib/utils';
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() {
const { data: dashboard, isLoading } = useAdminDashboard();
const { data: auditData } = useAuditLog(1, 10);
const mfaPct = dashboard ? Math.round(dashboard.mfa_adoption_rate * 100) : null;
const disabledUsers =
dashboard ? dashboard.total_users - dashboard.active_users : null;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
{/* Stats grid */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-5">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-5">
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
))
) : (
<>
<StatCard
icon={<Users className="h-5 w-5 text-accent" />}
label="Total Users"
value={dashboard?.total_users ?? '—'}
/>
<StatCard
icon={<UserCheck className="h-5 w-5 text-green-400" />}
label="Active Users"
value={dashboard?.active_users ?? '—'}
iconBg="bg-green-500/10"
/>
<StatCard
icon={<UserX className="h-5 w-5 text-red-400" />}
label="Disabled Users"
value={disabledUsers ?? '—'}
iconBg="bg-red-500/10"
/>
<StatCard
icon={<Activity className="h-5 w-5 text-blue-400" />}
label="Active Sessions"
value={dashboard?.active_sessions ?? '—'}
iconBg="bg-blue-500/10"
/>
<StatCard
icon={<Smartphone className="h-5 w-5 text-purple-400" />}
label="MFA Adoption"
value={mfaPct !== null ? `${mfaPct}%` : '—'}
iconBg="bg-purple-500/10"
/>
</>
)}
</div>
<div className="grid gap-5 lg:grid-cols-2">
{/* Recent logins */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-green-500/10">
<LogIn className="h-4 w-4 text-green-400" />
</div>
<CardTitle>Recent Logins</CardTitle>
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="px-5 pb-5 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !dashboard?.recent_logins?.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">No recent logins.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Username
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
When
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
IP
</th>
</tr>
</thead>
<tbody>
{dashboard.recent_logins.map((entry, idx) => (
<tr
key={idx}
className={cn(
'border-b border-border hover:bg-card-elevated/50 transition-colors',
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)}
>
<td className="px-5 py-2.5 font-medium">{entry.username}</td>
<td className="px-5 py-2.5 text-xs text-muted-foreground">
{getRelativeTime(entry.last_login_at)}
</td>
<td className="px-5 py-2.5 text-xs text-muted-foreground font-mono">
{entry.ip_address ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Recent admin actions */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-orange-500/10">
<ShieldAlert className="h-4 w-4 text-orange-400" />
</div>
<CardTitle>Recent Admin Actions</CardTitle>
</div>
</CardHeader>
<CardContent className="p-0">
{!auditData?.entries?.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">No recent actions.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Action
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Actor
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Target
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
When
</th>
</tr>
</thead>
<tbody>
{auditData.entries.slice(0, 10).map((entry, idx) => (
<tr
key={entry.id}
className={cn(
'border-b border-border hover:bg-card-elevated/50 transition-colors',
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)}
>
<td className="px-5 py-2.5">
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
actionColor(entry.action)
)}
>
{entry.action}
</span>
</td>
<td className="px-5 py-2.5 text-xs font-medium">
{entry.actor_username ?? (
<span className="text-muted-foreground italic">system</span>
)}
</td>
<td className="px-5 py-2.5 text-xs text-muted-foreground">
{entry.target_username ?? '—'}
</td>
<td className="px-5 py-2.5 text-xs text-muted-foreground whitespace-nowrap">
{getRelativeTime(entry.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
import { NavLink, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Users, Settings2, LayoutDashboard, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import IAMPage from './IAMPage';
import ConfigPage from './ConfigPage';
import AdminDashboardPage from './AdminDashboardPage';
const tabs = [
{ label: 'IAM Management', path: '/admin/iam', icon: Users },
{ label: 'Configuration', path: '/admin/config', icon: Settings2 },
{ label: 'Management Dashboard', path: '/admin/dashboard', icon: LayoutDashboard },
];
export default function AdminPortal() {
const location = useLocation();
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Portal header with tab navigation */}
<div className="shrink-0 border-b bg-card">
<div className="px-6 h-16 flex items-center gap-4">
<div className="flex items-center gap-2 mr-6">
<div className="p-1.5 rounded-md bg-red-500/10">
<ShieldCheck className="h-5 w-5 text-red-400" />
</div>
<h1 className="font-heading text-2xl font-bold tracking-tight">Admin Portal</h1>
</div>
{/* Horizontal tab navigation */}
<nav className="flex items-center gap-1 h-full">
{tabs.map(({ label, path, icon: Icon }) => {
const isActive = location.pathname.startsWith(path);
return (
<NavLink
key={path}
to={path}
className={cn(
'flex items-center gap-2 px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px',
isActive
? 'text-accent border-accent'
: 'text-muted-foreground hover:text-foreground border-transparent'
)}
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
);
})}
</nav>
</div>
</div>
{/* Page content */}
<div className="flex-1 overflow-y-auto">
<Routes>
<Route index element={<Navigate to="iam" replace />} />
<Route path="iam" element={<IAMPage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="dashboard" element={<AdminDashboardPage />} />
</Routes>
</div>
</div>
);
}

View File

@ -0,0 +1,231 @@
import { useState } from 'react';
import {
FileText,
ChevronLeft,
ChevronRight,
Filter,
X,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuditLog } from '@/hooks/useAdmin';
import { getRelativeTime } from '@/lib/date-utils';
import { cn } from '@/lib/utils';
const ACTION_TYPES = [
'user.create',
'user.login',
'user.logout',
'user.login_failed',
'user.locked',
'user.unlocked',
'user.role_changed',
'user.disabled',
'user.enabled',
'user.password_reset',
'user.totp_disabled',
'user.mfa_enforced',
'user.mfa_enforcement_removed',
'user.sessions_revoked',
'config.updated',
];
function actionLabel(action: string): string {
return action
.split('.')
.map((p) => p.replace(/_/g, ' '))
.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() {
const [page, setPage] = useState(1);
const [filterAction, setFilterAction] = useState<string>('');
const PER_PAGE = 25;
const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined);
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
<Card>
<CardHeader className="flex-row items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<FileText className="h-4 w-4 text-accent" />
</div>
<CardTitle>Audit Log</CardTitle>
{data && (
<span className="text-xs text-muted-foreground">
{data.total} entries
</span>
)}
</div>
{/* Filter controls */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Filter:</span>
</div>
<div className="w-52">
<Select
value={filterAction}
onChange={(e) => {
setFilterAction(e.target.value);
setPage(1);
}}
className="h-8 text-xs"
>
<option value="">All actions</option>
{ACTION_TYPES.map((a) => (
<option key={a} value={a}>
{actionLabel(a)}
</option>
))}
</Select>
</div>
{filterAction && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
setFilterAction('');
setPage(1);
}}
aria-label="Clear filter"
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="px-5 pb-5 space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : !data?.entries?.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">No audit entries found.</p>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Time
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Actor
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Action
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Target
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
IP
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Detail
</th>
</tr>
</thead>
<tbody>
{data.entries.map((entry, idx) => (
<tr
key={entry.id}
className={cn(
'border-b border-border transition-colors hover:bg-card-elevated/50',
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)}
>
<td className="px-5 py-3 text-xs text-muted-foreground whitespace-nowrap">
{getRelativeTime(entry.created_at)}
</td>
<td className="px-5 py-3 text-xs font-medium">
{entry.actor_username ?? (
<span className="text-muted-foreground italic">system</span>
)}
</td>
<td className="px-5 py-3">
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
actionColor(entry.action)
)}
>
{entry.action}
</span>
</td>
<td className="px-5 py-3 text-xs text-muted-foreground">
{entry.target_username ?? '—'}
</td>
<td className="px-5 py-3 text-xs text-muted-foreground font-mono">
{entry.ip_address ?? '—'}
</td>
<td className="px-5 py-3 text-xs text-muted-foreground max-w-xs truncate">
{entry.detail ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-5 py-3 border-t border-border">
<span className="text-xs text-muted-foreground">
Page {page} of {totalPages}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
Prev
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,121 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { UserPlus, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { useCreateUser, getErrorMessage } from '@/hooks/useAdmin';
import type { UserRole } from '@/types';
interface CreateUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialogProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<UserRole>('standard');
const createUser = useCreateUser();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !password.trim()) return;
try {
await createUser.mutateAsync({ username: username.trim(), password, role });
toast.success(`User "${username.trim()}" created successfully`);
setUsername('');
setPassword('');
setRole('standard');
onOpenChange(false);
} catch (err) {
toast.error(getErrorMessage(err, 'Failed to create user'));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-accent" />
Create User
</DialogTitle>
</DialogHeader>
<DialogClose onClick={() => onOpenChange(false)} />
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="new-username">Username</Label>
<Input
id="new-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password">Password</Label>
<Input
id="new-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
required
/>
<p className="text-[11px] text-muted-foreground">
Must be at least 8 characters. The user will be prompted to change it on first login.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-role">Role</Label>
<Select
id="new-role"
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
>
<option value="standard">Standard</option>
<option value="admin">Admin</option>
<option value="public_event_manager">Public Event Manager</option>
</Select>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={createUser.isPending || !username.trim() || !password.trim()}
>
{createUser.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Create User
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,298 @@
import { useState } from 'react';
import { toast } from 'sonner';
import {
Users,
UserCheck,
ShieldCheck,
Smartphone,
Plus,
Loader2,
Activity,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import {
useAdminUsers,
useAdminDashboard,
useAdminConfig,
useUpdateConfig,
getErrorMessage,
} from '@/hooks/useAdmin';
import { getRelativeTime } from '@/lib/date-utils';
import type { AdminUserDetail, UserRole } from '@/types';
import { cn } from '@/lib/utils';
import UserActionsMenu from './UserActionsMenu';
import CreateUserDialog from './CreateUserDialog';
// ── Role badge ────────────────────────────────────────────────────────────────
function RoleBadge({ role }: { role: UserRole }) {
const styles: Record<UserRole, string> = {
admin: 'bg-red-500/15 text-red-400',
standard: 'bg-blue-500/15 text-blue-400',
public_event_manager: 'bg-purple-500/15 text-purple-400',
};
const labels: Record<UserRole, string> = {
admin: 'Admin',
standard: 'Standard',
public_event_manager: 'Pub. Events',
};
return (
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
styles[role]
)}
>
{labels[role]}
</span>
);
}
// ── 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 ─────────────────────────────────────────────────────────────────
export default function IAMPage() {
const [createOpen, setCreateOpen] = useState(false);
const { data: users, isLoading: usersLoading } = useAdminUsers();
const { data: dashboard } = useAdminDashboard();
const { data: config, isLoading: configLoading } = useAdminConfig();
const updateConfig = useUpdateConfig();
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => {
try {
await updateConfig.mutateAsync({ [key]: value });
toast.success('System settings updated');
} catch (err) {
toast.error(getErrorMessage(err, 'Failed to update settings'));
}
};
const mfaPct = dashboard
? Math.round(dashboard.mfa_adoption_rate * 100)
: null;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
{/* Stats row */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Users className="h-5 w-5 text-accent" />}
label="Total Users"
value={dashboard?.total_users ?? '—'}
/>
<StatCard
icon={<Activity className="h-5 w-5 text-green-400" />}
label="Active Sessions"
value={dashboard?.active_sessions ?? '—'}
iconBg="bg-green-500/10"
/>
<StatCard
icon={<ShieldCheck className="h-5 w-5 text-red-400" />}
label="Admins"
value={dashboard?.admin_count ?? '—'}
iconBg="bg-red-500/10"
/>
<StatCard
icon={<Smartphone className="h-5 w-5 text-purple-400" />}
label="MFA Adoption"
value={mfaPct !== null ? `${mfaPct}%` : '—'}
iconBg="bg-purple-500/10"
/>
</div>
{/* User table */}
<Card>
<CardHeader className="flex-row items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<Users className="h-4 w-4 text-accent" />
</div>
<CardTitle>Users</CardTitle>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Create User
</Button>
</CardHeader>
<CardContent className="p-0">
{usersLoading ? (
<div className="px-5 pb-5 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : !users?.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">No users found.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Username
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Role
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Status
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Last Login
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
MFA
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Sessions
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Created
</th>
<th className="px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Actions
</th>
</tr>
</thead>
<tbody>
{users.map((user: AdminUserDetail, idx) => (
<tr
key={user.id}
className={cn(
'border-b border-border transition-colors hover:bg-card-elevated/50',
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)}
>
<td className="px-5 py-3 font-medium">{user.username}</td>
<td className="px-5 py-3">
<RoleBadge role={user.role} />
</td>
<td className="px-5 py-3">
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
user.is_active
? 'bg-green-500/15 text-green-400'
: 'bg-red-500/15 text-red-400'
)}
>
{user.is_active ? 'Active' : 'Disabled'}
</span>
</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{user.last_login_at ? getRelativeTime(user.last_login_at) : '—'}
</td>
<td className="px-5 py-3">
{user.totp_enabled ? (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
On
</span>
) : user.mfa_enforce_pending ? (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-orange-500/15 text-orange-400">
Pending
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="px-5 py-3 text-muted-foreground text-xs tabular-nums">
{user.active_sessions}
</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{getRelativeTime(user.created_at)}
</td>
<td className="px-5 py-3 text-right">
<UserActionsMenu user={user} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* System settings */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<ShieldCheck className="h-4 w-4 text-accent" />
</div>
<CardTitle>System Settings</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-5">
{configLoading ? (
<div className="space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : (
<>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Allow New Account Registration</Label>
<p className="text-xs text-muted-foreground">
When enabled, the /register page accepts new sign-ups.
</p>
</div>
<Switch
checked={config?.allow_registration ?? false}
onCheckedChange={(v) => handleConfigToggle('allow_registration', v)}
disabled={updateConfig.isPending}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Enforce MFA on New Users</Label>
<p className="text-xs text-muted-foreground">
Newly registered users will be required to set up TOTP before accessing the app.
</p>
</div>
<Switch
checked={config?.enforce_mfa_new_users ?? false}
onCheckedChange={(v) => handleConfigToggle('enforce_mfa_new_users', v)}
disabled={updateConfig.isPending}
/>
</div>
</>
)}
</CardContent>
</Card>
<CreateUserDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@ -0,0 +1,296 @@
import { useState, useRef, useEffect } from 'react';
import { toast } from 'sonner';
import {
MoreHorizontal,
ShieldCheck,
ShieldOff,
KeyRound,
UserX,
UserCheck,
LogOut,
Smartphone,
SmartphoneOff,
ChevronRight,
Loader2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import {
useUpdateRole,
useResetPassword,
useDisableMfa,
useEnforceMfa,
useRemoveMfaEnforcement,
useToggleUserActive,
useRevokeSessions,
getErrorMessage,
} from '@/hooks/useAdmin';
import type { AdminUserDetail, UserRole } from '@/types';
import { cn } from '@/lib/utils';
interface UserActionsMenuProps {
user: AdminUserDetail;
}
const ROLES: { value: UserRole; label: string }[] = [
{ value: 'admin', label: 'Admin' },
{ value: 'standard', label: 'Standard' },
{ value: 'public_event_manager', label: 'Public Event Manager' },
];
export default function UserActionsMenu({ user }: UserActionsMenuProps) {
const [open, setOpen] = useState(false);
const [roleSubmenuOpen, setRoleSubmenuOpen] = useState(false);
const [showResetPassword, setShowResetPassword] = useState(false);
const [newPassword, setNewPassword] = useState('');
const menuRef = useRef<HTMLDivElement>(null);
const updateRole = useUpdateRole();
const resetPassword = useResetPassword();
const disableMfa = useDisableMfa();
const enforceMfa = useEnforceMfa();
const removeMfaEnforcement = useRemoveMfaEnforcement();
const toggleActive = useToggleUserActive();
const revokeSessions = useRevokeSessions();
// Close on outside click
useEffect(() => {
const handleOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
setRoleSubmenuOpen(false);
}
};
if (open) document.addEventListener('mousedown', handleOutside);
return () => document.removeEventListener('mousedown', handleOutside);
}, [open]);
const handleAction = async (fn: () => Promise<unknown>, successMsg: string) => {
try {
await fn();
toast.success(successMsg);
setOpen(false);
} catch (err) {
toast.error(getErrorMessage(err, 'Action failed'));
}
};
// Two-click confirms
const disableMfaConfirm = useConfirmAction(() => {
handleAction(() => disableMfa.mutateAsync(user.id), 'MFA disabled');
});
const toggleActiveConfirm = useConfirmAction(() => {
handleAction(
() => toggleActive.mutateAsync({ userId: user.id, active: !user.is_active }),
user.is_active ? 'Account disabled' : 'Account enabled'
);
});
const revokeSessionsConfirm = useConfirmAction(() => {
handleAction(() => revokeSessions.mutateAsync(user.id), 'Sessions revoked');
});
const isLoading =
updateRole.isPending ||
resetPassword.isPending ||
disableMfa.isPending ||
enforceMfa.isPending ||
removeMfaEnforcement.isPending ||
toggleActive.isPending ||
revokeSessions.isPending;
return (
<div ref={menuRef} className="relative">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setOpen((v) => !v)}
aria-label="User actions"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
{open && (
<div className="absolute right-0 top-8 z-50 min-w-[200px] rounded-lg border bg-card shadow-lg py-1">
{/* Edit Role */}
<div className="relative">
<button
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onMouseEnter={() => setRoleSubmenuOpen(true)}
onMouseLeave={() => setRoleSubmenuOpen(false)}
onClick={() => setRoleSubmenuOpen((v) => !v)}
>
<span className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
Edit Role
</span>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
</button>
{roleSubmenuOpen && (
<div
className="absolute left-full top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1"
onMouseEnter={() => setRoleSubmenuOpen(true)}
onMouseLeave={() => setRoleSubmenuOpen(false)}
>
{ROLES.map(({ value, label }) => (
<button
key={value}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors',
user.role === value && 'text-accent'
)}
onClick={() =>
handleAction(
() => updateRole.mutateAsync({ userId: user.id, role: value }),
`Role updated to ${label}`
)
}
>
{user.role === value && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
{label}
</button>
))}
</div>
)}
</div>
{/* Reset Password */}
{!showResetPassword ? (
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={() => setShowResetPassword(true)}
>
<KeyRound className="h-4 w-4 text-muted-foreground" />
Reset Password
</button>
) : (
<div className="px-3 py-2 space-y-2">
<input
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
type="password"
placeholder="New password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoFocus
/>
<div className="flex gap-2">
<button
className="flex-1 rounded-md bg-accent/15 px-2 py-1 text-xs text-accent hover:bg-accent/25 transition-colors"
onClick={() => {
if (!newPassword.trim()) return;
handleAction(
() => resetPassword.mutateAsync({ userId: user.id, new_password: newPassword }),
'Password reset'
);
setNewPassword('');
setShowResetPassword(false);
}}
>
Set
</button>
<button
className="flex-1 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setShowResetPassword(false);
setNewPassword('');
}}
>
Cancel
</button>
</div>
</div>
)}
<div className="my-1 border-t border-border" />
{/* MFA actions */}
{user.mfa_enforce_pending ? (
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={() =>
handleAction(
() => removeMfaEnforcement.mutateAsync(user.id),
'MFA enforcement removed'
)
}
>
<SmartphoneOff className="h-4 w-4 text-muted-foreground" />
Remove MFA Enforcement
</button>
) : (
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={() =>
handleAction(() => enforceMfa.mutateAsync(user.id), 'MFA enforcement set')
}
>
<Smartphone className="h-4 w-4 text-muted-foreground" />
Enforce MFA
</button>
)}
{user.totp_enabled && (
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
disableMfaConfirm.confirming
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
: 'hover:bg-card-elevated'
)}
onClick={disableMfaConfirm.handleClick}
>
<SmartphoneOff className="h-4 w-4" />
{disableMfaConfirm.confirming ? 'Sure? Click to confirm' : 'Disable MFA'}
</button>
)}
<div className="my-1 border-t border-border" />
{/* Disable / Enable Account */}
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
toggleActiveConfirm.confirming
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
: 'hover:bg-card-elevated'
)}
onClick={toggleActiveConfirm.handleClick}
>
{user.is_active ? (
<>
<UserX className="h-4 w-4" />
{toggleActiveConfirm.confirming ? 'Sure? Click to confirm' : 'Disable Account'}
</>
) : (
<>
<UserCheck className="h-4 w-4 text-green-400" />
{toggleActiveConfirm.confirming ? 'Sure? Click to confirm' : 'Enable Account'}
</>
)}
</button>
{/* Revoke Sessions */}
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
revokeSessionsConfirm.confirming
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
: 'hover:bg-card-elevated'
)}
onClick={revokeSessionsConfirm.handleClick}
>
<LogOut className="h-4 w-4" />
{revokeSessionsConfirm.confirming ? 'Sure? Click to confirm' : 'Revoke All Sessions'}
</button>
</div>
)}
</div>
);
}

View File

@ -1,15 +1,16 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Lock, Loader2 } from 'lucide-react'; import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import AmbientBackground from './AmbientBackground'; import AmbientBackground from './AmbientBackground';
import type { TotpSetupResponse } from '@/types';
/** Validates password against backend rules: 12-128 chars, at least one letter + one non-letter. */ /** Validates password against backend rules: 12-128 chars, at least one letter + one non-letter. */
function validatePassword(password: string): string | null { function validatePassword(password: string): string | null {
@ -20,63 +21,124 @@ function validatePassword(password: string): string | null {
return null; return null;
} }
export default function LockScreen() { type ScreenMode =
const { authStatus, isLoading, login, setup, verifyTotp, mfaRequired, isLoginPending, isSetupPending, isTotpPending } = useAuth(); | 'login'
| 'setup' // first-run admin account creation
| 'register' // open registration
| 'totp' // TOTP challenge after login
| 'mfa_enforce' // forced MFA setup after login/register
| 'force_pw'; // admin-forced password change
// Credentials state (shared across login/setup states) type MfaEnforceStep = 'qr' | 'verify' | 'backup_codes';
export default function LockScreen() {
const {
authStatus,
isLoading,
login,
register,
setup,
verifyTotp,
mfaRequired,
mfaSetupRequired,
mfaToken,
isLoginPending,
isRegisterPending,
isSetupPending,
isTotpPending,
} = useAuth();
// ── Shared credential fields ──
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
// TOTP challenge state // ── TOTP challenge ──
const [totpCode, setTotpCode] = useState(''); const [totpCode, setTotpCode] = useState('');
const [useBackupCode, setUseBackupCode] = useState(false); const [useBackupCode, setUseBackupCode] = useState(false);
// Lockout handling (HTTP 423) // ── Registration mode ──
const [mode, setMode] = useState<ScreenMode>('login');
// ── Lockout (HTTP 423) ──
const [lockoutMessage, setLockoutMessage] = useState<string | null>(null); const [lockoutMessage, setLockoutMessage] = useState<string | null>(null);
// Redirect authenticated users immediately // ── MFA enforcement setup flow ──
if (!isLoading && authStatus?.authenticated) { const [mfaEnforceStep, setMfaEnforceStep] = useState<MfaEnforceStep>('qr');
const [mfaEnforceQr, setMfaEnforceQr] = useState('');
const [mfaEnforceSecret, setMfaEnforceSecret] = useState('');
const [mfaEnforceBackupCodes, setMfaEnforceBackupCodes] = useState<string[]>([]);
const [mfaEnforceCode, setMfaEnforceCode] = useState('');
const [isMfaEnforceSetupPending, setIsMfaEnforceSetupPending] = useState(false);
const [isMfaEnforceConfirmPending, setIsMfaEnforceConfirmPending] = useState(false);
// ── Forced password change ──
const [forcedNewPassword, setForcedNewPassword] = useState('');
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
const [isForcePwPending, setIsForcePwPending] = useState(false);
// Redirect authenticated users (no pending MFA flows)
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
} }
const isSetup = authStatus?.setup_required === true; const isSetup = authStatus?.setup_required === true;
const registrationOpen = authStatus?.registration_open === true;
// Derive active screen — hook-driven states override local mode
const activeMode: ScreenMode = mfaRequired
? 'totp'
: mfaSetupRequired
? 'mfa_enforce'
: isSetup
? 'setup'
: mode;
// ── Handlers ──
const handleCredentialSubmit = async (e: FormEvent) => { const handleCredentialSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setLockoutMessage(null); setLockoutMessage(null);
if (isSetup) { if (isSetup) {
// Setup mode: validate password then create account const err = validatePassword(password);
const validationError = validatePassword(password); if (err) { toast.error(err); return; }
if (validationError) { if (password !== confirmPassword) { toast.error('Passwords do not match'); return; }
toast.error(validationError);
return;
}
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
try { try {
await setup({ username, password }); await setup({ username, password });
// useAuth invalidates auth query → Navigate above handles redirect
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error, 'Failed to create account')); toast.error(getErrorMessage(error, 'Failed to create account'));
} }
} else { return;
// Login mode }
try { try {
await login({ username, password }); const result = await login({ username, password });
// If mfaRequired becomes true, the TOTP state renders automatically // must_change_password: backend issued session but UI must gate the app
// If not required, useAuth invalidates auth query → Navigate above handles redirect if ('must_change_password' in result && result.must_change_password) {
setMode('force_pw');
}
// mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically
} catch (error: any) { } catch (error: any) {
if (error?.response?.status === 423) { if (error?.response?.status === 423) {
const msg = error.response.data?.detail || 'Account locked. Try again later.'; setLockoutMessage(error.response.data?.detail || 'Account locked. Try again later.');
setLockoutMessage(msg);
} else { } else {
toast.error(getErrorMessage(error, 'Invalid username or password')); toast.error(getErrorMessage(error, 'Invalid username or password'));
} }
} }
};
const handleRegisterSubmit = async (e: FormEvent) => {
e.preventDefault();
const err = validatePassword(password);
if (err) { toast.error(err); return; }
if (password !== confirmPassword) { toast.error('Passwords do not match'); return; }
try {
await register({ username, password });
// On success useAuth invalidates query → Navigate handles redirect
// If mfa_setup_required the hook sets mfaSetupRequired → activeMode switches
} catch (error) {
toast.error(getErrorMessage(error, 'Registration failed'));
} }
}; };
@ -84,26 +146,87 @@ export default function LockScreen() {
e.preventDefault(); e.preventDefault();
try { try {
await verifyTotp(totpCode); await verifyTotp(totpCode);
// useAuth invalidates auth query → Navigate above handles redirect
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error, 'Invalid verification code')); toast.error(getErrorMessage(error, 'Invalid verification code'));
setTotpCode(''); setTotpCode('');
} }
}; };
return ( const handleMfaEnforceStart = async () => {
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background p-4 overflow-hidden"> if (!mfaToken) return;
<AmbientBackground /> setIsMfaEnforceSetupPending(true);
try {
const { data } = await api.post<TotpSetupResponse>('/auth/totp/enforce-setup', {
mfa_token: mfaToken,
});
setMfaEnforceQr(data.qr_code_base64);
setMfaEnforceSecret(data.secret);
setMfaEnforceBackupCodes(data.backup_codes);
setMfaEnforceStep('qr');
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to begin MFA setup'));
} finally {
setIsMfaEnforceSetupPending(false);
}
};
{/* Wordmark — in flex flow above card */} const handleMfaEnforceConfirm = async () => {
<span className="font-heading text-5xl sm:text-6xl font-bold tracking-tight text-accent mb-10 relative z-10 animate-slide-up"> if (!mfaToken || !mfaEnforceCode || mfaEnforceCode.length !== 6) {
UMBRA toast.error('Enter a 6-digit code from your authenticator app');
</span> return;
}
setIsMfaEnforceConfirmPending(true);
try {
await api.post('/auth/totp/enforce-confirm', {
mfa_token: mfaToken,
code: mfaEnforceCode,
});
// Backend issued session — show backup codes then redirect
setMfaEnforceStep('backup_codes');
} catch (error) {
toast.error(getErrorMessage(error, 'Invalid code — try again'));
setMfaEnforceCode('');
} finally {
setIsMfaEnforceConfirmPending(false);
}
};
{/* Auth card */} const handleCopyBackupCodes = async () => {
<Card className="w-full max-w-sm relative z-10 border-border/80 animate-slide-up"> try {
{mfaRequired ? ( await navigator.clipboard.writeText(mfaEnforceBackupCodes.join('\n'));
// State C: TOTP challenge toast.success('Backup codes copied');
} catch {
toast.error('Failed to copy — please select and copy manually');
}
};
const handleForcePwSubmit = async (e: FormEvent) => {
e.preventDefault();
const err = validatePassword(forcedNewPassword);
if (err) { toast.error(err); return; }
if (forcedNewPassword !== forcedConfirmPassword) {
toast.error('Passwords do not match');
return;
}
setIsForcePwPending(true);
try {
await api.post('/auth/change-password', {
old_password: password, // retained from original login submission
new_password: forcedNewPassword,
});
toast.success('Password updated — welcome to UMBRA');
// Auth query still has authenticated:true → Navigate will fire after re-render
setMode('login');
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to change password'));
} finally {
setIsForcePwPending(false);
}
};
// ── Render helpers ──
const renderTotpChallenge = () => (
<> <>
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -113,9 +236,7 @@ export default function LockScreen() {
<div> <div>
<CardTitle>Two-Factor Authentication</CardTitle> <CardTitle>Two-Factor Authentication</CardTitle>
<CardDescription> <CardDescription>
{useBackupCode {useBackupCode ? 'Enter one of your backup codes' : 'Enter the code from your authenticator app'}
? 'Enter one of your backup codes'
: 'Enter the code from your authenticator app'}
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@ -148,20 +269,14 @@ export default function LockScreen() {
</div> </div>
<Button type="submit" className="w-full" disabled={isTotpPending}> <Button type="submit" className="w-full" disabled={isTotpPending}>
{isTotpPending ? ( {isTotpPending ? (
<> <><Loader2 className="h-4 w-4 animate-spin" />Verifying</>
<Loader2 className="h-4 w-4 animate-spin" />
Verifying
</>
) : ( ) : (
'Verify' 'Verify'
)} )}
</Button> </Button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => { setUseBackupCode(!useBackupCode); setTotpCode(''); }}
setUseBackupCode(!useBackupCode);
setTotpCode('');
}}
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors" className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
> >
{useBackupCode ? 'Use authenticator app instead' : 'Use a backup code instead'} {useBackupCode ? 'Use authenticator app instead' : 'Use a backup code instead'}
@ -169,8 +284,181 @@ export default function LockScreen() {
</form> </form>
</CardContent> </CardContent>
</> </>
);
const renderMfaEnforce = () => {
// Show a loading/start state if QR hasn't been fetched yet
if (!mfaEnforceQr && mfaEnforceStep !== 'backup_codes') {
return (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<ShieldCheck className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Set Up Two-Factor Authentication</CardTitle>
<CardDescription>Your account requires MFA before you can continue</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
An administrator has required that your account be protected with an authenticator app.
You'll need an app like Google Authenticator, Authy, or 1Password to continue.
</p>
<Button className="w-full" onClick={handleMfaEnforceStart} disabled={isMfaEnforceSetupPending}>
{isMfaEnforceSetupPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Generating QR Code</>
) : ( ) : (
// State A (setup) or State B (login) 'Begin Setup'
)}
</Button>
</CardContent>
</>
);
}
if (mfaEnforceStep === 'backup_codes') {
return (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<AlertTriangle className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Save Your Backup Codes</CardTitle>
<CardDescription>Store these somewhere safe they won't be shown again</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-xs text-muted-foreground">
These {mfaEnforceBackupCodes.length} codes can each be used once if you lose access to
your authenticator app. MFA is now active on your account.
</p>
<div className="grid grid-cols-2 gap-2 bg-secondary rounded-md p-3">
{mfaEnforceBackupCodes.map((code, i) => (
<code key={i} className="text-xs font-mono text-foreground text-center py-0.5">
{code}
</code>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={handleCopyBackupCodes}
className="w-full gap-2"
>
<Copy className="h-4 w-4" />
Copy All Codes
</Button>
<Button
className="w-full"
onClick={() => {
// Session is already issued — redirect to app
window.location.href = '/dashboard';
}}
>
I've saved my backup codes Enter UMBRA
</Button>
</CardContent>
</>
);
}
if (mfaEnforceStep === 'qr') {
return (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<ShieldCheck className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Scan QR Code</CardTitle>
<CardDescription>Add UMBRA to your authenticator app</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<img
src={`data:image/png;base64,${mfaEnforceQr}`}
alt="TOTP QR code — scan with your authenticator app"
className="h-44 w-44 rounded-md border border-border"
/>
</div>
<p className="text-xs text-muted-foreground text-center">
Can't scan? Enter this code manually in your app:
</p>
<code className="block text-center text-xs font-mono bg-secondary px-3 py-2 rounded-md tracking-widest break-all">
{mfaEnforceSecret}
</code>
<Button className="w-full" onClick={() => setMfaEnforceStep('verify')}>
Next: Verify Code
</Button>
</CardContent>
</>
);
}
// verify step
return (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<ShieldCheck className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Verify Your Authenticator</CardTitle>
<CardDescription>Enter the 6-digit code shown in your app</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="enforce-code">Verification Code</Label>
<Input
id="enforce-code"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="000000"
value={mfaEnforceCode}
onChange={(e) => setMfaEnforceCode(e.target.value.replace(/\D/g, ''))}
className="text-center tracking-widest text-lg"
autoFocus
autoComplete="one-time-code"
onKeyDown={(e) => { if (e.key === 'Enter') handleMfaEnforceConfirm(); }}
/>
</div>
<Button
className="w-full"
onClick={handleMfaEnforceConfirm}
disabled={isMfaEnforceConfirmPending}
>
{isMfaEnforceConfirmPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Verifying</>
) : (
'Verify & Enable MFA'
)}
</Button>
<button
type="button"
onClick={() => setMfaEnforceStep('qr')}
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Back to QR code
</button>
</CardContent>
</>
);
};
const renderLoginOrSetup = () => (
<> <>
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -180,15 +468,12 @@ export default function LockScreen() {
<div> <div>
<CardTitle>{isSetup ? 'Welcome to UMBRA' : 'Sign in'}</CardTitle> <CardTitle>{isSetup ? 'Welcome to UMBRA' : 'Sign in'}</CardTitle>
<CardDescription> <CardDescription>
{isSetup {isSetup ? 'Create your account to get started' : 'Enter your credentials to continue'}
? 'Create your account to get started'
: 'Enter your credentials to continue'}
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* Lockout warning banner */}
{lockoutMessage && ( {lockoutMessage && (
<div <div
role="alert" role="alert"
@ -201,7 +486,6 @@ export default function LockScreen() {
<p className="text-xs text-red-400">{lockoutMessage}</p> <p className="text-xs text-red-400">{lockoutMessage}</p>
</div> </div>
)} )}
<form onSubmit={handleCredentialSubmit} className="space-y-4"> <form onSubmit={handleCredentialSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username" required>Username</Label> <Label htmlFor="username" required>Username</Label>
@ -216,7 +500,6 @@ export default function LockScreen() {
autoComplete="username" autoComplete="username"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password" required>Password</Label> <Label htmlFor="password" required>Password</Label>
<Input <Input
@ -229,7 +512,6 @@ export default function LockScreen() {
autoComplete={isSetup ? 'new-password' : 'current-password'} autoComplete={isSetup ? 'new-password' : 'current-password'}
/> />
</div> </div>
{isSetup && ( {isSetup && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirm-password" required>Confirm Password</Label> <Label htmlFor="confirm-password" required>Confirm Password</Label>
@ -247,17 +529,13 @@ export default function LockScreen() {
</p> </p>
</div> </div>
)} )}
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
disabled={isLoginPending || isSetupPending || !!lockoutMessage} disabled={isLoginPending || isSetupPending || !!lockoutMessage}
> >
{isLoginPending || isSetupPending ? ( {isLoginPending || isSetupPending ? (
<> <><Loader2 className="h-4 w-4 animate-spin" />Please wait</>
<Loader2 className="h-4 w-4 animate-spin" />
Please wait
</>
) : isSetup ? ( ) : isSetup ? (
'Create Account' 'Create Account'
) : ( ) : (
@ -265,9 +543,183 @@ export default function LockScreen() {
)} )}
</Button> </Button>
</form> </form>
{/* Open registration link — only shown on login screen when enabled */}
{!isSetup && registrationOpen && (
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setMode('register');
setUsername('');
setPassword('');
setConfirmPassword('');
setLockoutMessage(null);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Don't have an account?{' '}
<span className="text-accent hover:underline">Create one</span>
</button>
</div>
)}
</CardContent> </CardContent>
</> </>
);
const renderRegister = () => (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-accent/10">
<UserPlus className="h-4 w-4 text-accent" aria-hidden="true" />
</div>
<div>
<CardTitle>Create Account</CardTitle>
<CardDescription>Register for access to UMBRA</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleRegisterSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="reg-username" required>Username</Label>
<Input
id="reg-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose a username"
required
autoFocus
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reg-password" required>Password</Label>
<Input
id="reg-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Create a password"
required
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reg-confirm-password" required>Confirm Password</Label>
<Input
id="reg-confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
required
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">
Must be 12-128 characters with at least one letter and one non-letter.
</p>
</div>
<Button type="submit" className="w-full" disabled={isRegisterPending}>
{isRegisterPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Creating account</>
) : (
'Create Account'
)} )}
</Button>
</form>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setMode('login');
setUsername('');
setPassword('');
setConfirmPassword('');
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Already have an account?{' '}
<span className="text-accent hover:underline">Sign in</span>
</button>
</div>
</CardContent>
</>
);
const renderForcedPasswordChange = () => (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<Lock className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Password Change Required</CardTitle>
<CardDescription>An administrator has reset your password. Please set a new one.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleForcePwSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="force-new-pw" required>New Password</Label>
<Input
id="force-new-pw"
type="password"
value={forcedNewPassword}
onChange={(e) => setForcedNewPassword(e.target.value)}
placeholder="Create a new password"
required
autoFocus
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="force-confirm-pw" required>Confirm New Password</Label>
<Input
id="force-confirm-pw"
type="password"
value={forcedConfirmPassword}
onChange={(e) => setForcedConfirmPassword(e.target.value)}
placeholder="Confirm your new password"
required
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">
Must be 12-128 characters with at least one letter and one non-letter.
</p>
</div>
<Button type="submit" className="w-full" disabled={isForcePwPending}>
{isForcePwPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Saving</>
) : (
'Set New Password'
)}
</Button>
</form>
</CardContent>
</>
);
return (
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background p-4 overflow-hidden">
<AmbientBackground />
{/* Wordmark */}
<span className="font-heading text-5xl sm:text-6xl font-bold tracking-tight text-accent mb-10 relative z-10 animate-slide-up">
UMBRA
</span>
{/* Auth card */}
<Card className="w-full max-w-sm relative z-10 border-border/80 animate-slide-up">
{activeMode === 'totp' && renderTotpChallenge()}
{activeMode === 'mfa_enforce' && renderMfaEnforce()}
{activeMode === 'force_pw' && renderForcedPasswordChange()}
{activeMode === 'register' && renderRegister()}
{(activeMode === 'login' || activeMode === 'setup') && renderLoginOrSetup()}
</Card> </Card>
</div> </div>
); );

View File

@ -16,6 +16,7 @@ import {
X, X,
LogOut, LogOut,
Lock, Lock,
Shield,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
@ -44,7 +45,7 @@ interface SidebarProps {
export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) { export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { logout } = useAuth(); const { logout, isAdmin } = useAuth();
const { lock } = useLock(); const { lock } = useLock();
const [projectsExpanded, setProjectsExpanded] = useState(false); const [projectsExpanded, setProjectsExpanded] = useState(false);
@ -193,6 +194,16 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
<Lock className="h-5 w-5 shrink-0" /> <Lock className="h-5 w-5 shrink-0" />
{showExpanded && <span>Lock</span>} {showExpanded && <span>Lock</span>}
</button> </button>
{isAdmin && (
<NavLink
to="/admin"
onClick={mobileOpen ? onMobileClose : undefined}
className={navLinkClass}
>
<Shield className="h-5 w-5 shrink-0" />
{showExpanded && <span>Admin</span>}
</NavLink>
)}
<NavLink <NavLink
to="/settings" to="/settings"
onClick={mobileOpen ? onMobileClose : undefined} onClick={mobileOpen ? onMobileClose : undefined}

View File

@ -0,0 +1,165 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api, { getErrorMessage } from '@/lib/api';
import type {
AdminUser,
AdminUserDetail,
AdminDashboardData,
SystemConfig,
AuditLogEntry,
UserRole,
} from '@/types';
interface AuditLogResponse {
entries: AuditLogEntry[];
total: number;
page: number;
per_page: number;
}
interface CreateUserPayload {
username: string;
password: string;
role: UserRole;
}
interface UpdateRolePayload {
userId: number;
role: UserRole;
}
interface ResetPasswordPayload {
userId: number;
new_password: string;
}
// ── Queries ──────────────────────────────────────────────────────────────────
export function useAdminUsers() {
return useQuery<AdminUserDetail[]>({
queryKey: ['admin', 'users'],
queryFn: async () => {
const { data } = await api.get<AdminUserDetail[]>('/admin/users');
return data;
},
});
}
export function useAdminDashboard() {
return useQuery<AdminDashboardData>({
queryKey: ['admin', 'dashboard'],
queryFn: async () => {
const { data } = await api.get<AdminDashboardData>('/admin/dashboard');
return data;
},
});
}
export function useAdminConfig() {
return useQuery<SystemConfig>({
queryKey: ['admin', 'config'],
queryFn: async () => {
const { data } = await api.get<SystemConfig>('/admin/config');
return data;
},
});
}
export function useAuditLog(
page: number,
perPage: number,
action?: string,
targetUserId?: number
) {
return useQuery<AuditLogResponse>({
queryKey: ['admin', 'audit-log', page, perPage, action, targetUserId],
queryFn: async () => {
const params: Record<string, unknown> = { page, per_page: perPage };
if (action) params.action = action;
if (targetUserId) params.target_user_id = targetUserId;
const { data } = await api.get<AuditLogResponse>('/admin/audit-log', { params });
return data;
},
});
}
// ── Mutations ─────────────────────────────────────────────────────────────────
function useAdminMutation<TVariables>(
mutationFn: (vars: TVariables) => Promise<unknown>,
onSuccess?: () => void
) {
const queryClient = useQueryClient();
return useMutation<unknown, Error, TVariables>({
mutationFn,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin'] });
onSuccess?.();
},
});
}
export function useCreateUser() {
return useAdminMutation(async (payload: CreateUserPayload) => {
const { data } = await api.post('/admin/users', payload);
return data;
});
}
export function useUpdateRole() {
return useAdminMutation(async ({ userId, role }: UpdateRolePayload) => {
const { data } = await api.patch(`/admin/users/${userId}/role`, { role });
return data;
});
}
export function useResetPassword() {
return useAdminMutation(async ({ userId, new_password }: ResetPasswordPayload) => {
const { data } = await api.post(`/admin/users/${userId}/reset-password`, { new_password });
return data;
});
}
export function useDisableMfa() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.delete(`/admin/users/${userId}/totp`);
return data;
});
}
export function useEnforceMfa() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.post(`/admin/users/${userId}/enforce-mfa`);
return data;
});
}
export function useRemoveMfaEnforcement() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.delete(`/admin/users/${userId}/enforce-mfa`);
return data;
});
}
export function useToggleUserActive() {
return useAdminMutation(async ({ userId, active }: { userId: number; active: boolean }) => {
const { data } = await api.patch(`/admin/users/${userId}/active`, { is_active: active });
return data;
});
}
export function useRevokeSessions() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.delete(`/admin/users/${userId}/sessions`);
return data;
});
}
export function useUpdateConfig() {
return useAdminMutation(async (config: Partial<SystemConfig>) => {
const { data } = await api.patch('/admin/config', config);
return data;
});
}
// Re-export getErrorMessage for convenience in admin components
export { getErrorMessage };

View File

@ -5,8 +5,8 @@ import type { AuthStatus, LoginResponse } from '@/types';
export function useAuth() { export function useAuth() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Ephemeral MFA token — not in TanStack cache, lives only during the TOTP challenge step
const [mfaToken, setMfaToken] = useState<string | null>(null); const [mfaToken, setMfaToken] = useState<string | null>(null);
const [mfaSetupRequired, setMfaSetupRequired] = useState(false);
const authQuery = useQuery({ const authQuery = useQuery({
queryKey: ['auth'], queryKey: ['auth'],
@ -23,11 +23,34 @@ export function useAuth() {
return data; return data;
}, },
onSuccess: (data) => { onSuccess: (data) => {
if ('mfa_token' in data && data.totp_required) { if ('mfa_setup_required' in data && data.mfa_setup_required) {
// MFA required — store token locally, do NOT mark as authenticated yet // MFA enforcement — user must set up TOTP before accessing app
setMfaSetupRequired(true);
setMfaToken(data.mfa_token);
} else if ('mfa_token' in data && 'totp_required' in data && data.totp_required) {
// Regular TOTP challenge
setMfaToken(data.mfa_token);
setMfaSetupRequired(false);
} else {
setMfaToken(null);
setMfaSetupRequired(false);
queryClient.invalidateQueries({ queryKey: ['auth'] });
}
},
});
const registerMutation = useMutation({
mutationFn: async ({ username, password }: { username: string; password: string }) => {
const { data } = await api.post<LoginResponse & { message?: string }>('/auth/register', { username, password });
return data;
},
onSuccess: (data) => {
if ('mfa_setup_required' in data && data.mfa_setup_required) {
setMfaSetupRequired(true);
setMfaToken(data.mfa_token); setMfaToken(data.mfa_token);
} else { } else {
setMfaToken(null); setMfaToken(null);
setMfaSetupRequired(false);
queryClient.invalidateQueries({ queryKey: ['auth'] }); queryClient.invalidateQueries({ queryKey: ['auth'] });
} }
}, },
@ -43,6 +66,7 @@ export function useAuth() {
}, },
onSuccess: () => { onSuccess: () => {
setMfaToken(null); setMfaToken(null);
setMfaSetupRequired(false);
queryClient.invalidateQueries({ queryKey: ['auth'] }); queryClient.invalidateQueries({ queryKey: ['auth'] });
}, },
}); });
@ -64,6 +88,7 @@ export function useAuth() {
}, },
onSuccess: () => { onSuccess: () => {
setMfaToken(null); setMfaToken(null);
setMfaSetupRequired(false);
queryClient.invalidateQueries({ queryKey: ['auth'] }); queryClient.invalidateQueries({ queryKey: ['auth'] });
}, },
}); });
@ -71,12 +96,18 @@ export function useAuth() {
return { return {
authStatus: authQuery.data, authStatus: authQuery.data,
isLoading: authQuery.isLoading, isLoading: authQuery.isLoading,
mfaRequired: mfaToken !== null, role: authQuery.data?.role ?? null,
isAdmin: authQuery.data?.role === 'admin',
mfaRequired: mfaToken !== null && !mfaSetupRequired,
mfaSetupRequired,
mfaToken,
login: loginMutation.mutateAsync, login: loginMutation.mutateAsync,
register: registerMutation.mutateAsync,
verifyTotp: totpVerifyMutation.mutateAsync, verifyTotp: totpVerifyMutation.mutateAsync,
setup: setupMutation.mutateAsync, setup: setupMutation.mutateAsync,
logout: logoutMutation.mutateAsync, logout: logoutMutation.mutateAsync,
isLoginPending: loginMutation.isPending, isLoginPending: loginMutation.isPending,
isRegisterPending: registerMutation.isPending,
isTotpPending: totpVerifyMutation.isPending, isTotpPending: totpVerifyMutation.isPending,
isSetupPending: setupMutation.isPending, isSetupPending: setupMutation.isPending,
}; };

View File

@ -4,6 +4,7 @@ const api = axios.create({
baseURL: '/api', baseURL: '/api',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
}, },
withCredentials: true, withCredentials: true,
}); });

View File

@ -188,14 +188,19 @@ export interface Location {
updated_at: string; updated_at: string;
} }
export type UserRole = 'admin' | 'standard' | 'public_event_manager';
export interface AuthStatus { export interface AuthStatus {
authenticated: boolean; authenticated: boolean;
setup_required: boolean; setup_required: boolean;
role: UserRole | null;
registration_open: boolean;
} }
// Login response discriminated union // Login response discriminated union
export interface LoginSuccessResponse { export interface LoginSuccessResponse {
authenticated: true; authenticated: true;
must_change_password?: boolean;
} }
export interface LoginMfaRequiredResponse { export interface LoginMfaRequiredResponse {
@ -204,7 +209,64 @@ export interface LoginMfaRequiredResponse {
mfa_token: string; mfa_token: string;
} }
export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse; export interface LoginMfaSetupRequiredResponse {
authenticated: false;
mfa_setup_required: true;
mfa_token: string;
}
export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | LoginMfaSetupRequiredResponse;
// Admin types
export interface AdminUser {
id: number;
username: string;
role: UserRole;
is_active: boolean;
last_login_at: string | null;
last_password_change_at: string | null;
totp_enabled: boolean;
mfa_enforce_pending: boolean;
created_at: string;
}
export interface AdminUserDetail extends AdminUser {
active_sessions: number;
}
export interface SystemConfig {
allow_registration: boolean;
enforce_mfa_new_users: boolean;
}
export interface AuditLogEntry {
id: number;
actor_username: string | null;
target_username: string | null;
action: string;
detail: string | null;
ip_address: string | null;
created_at: string;
}
export interface AdminDashboardData {
total_users: number;
active_users: number;
admin_count: number;
active_sessions: number;
mfa_adoption_rate: number;
recent_logins: Array<{
username: string;
last_login_at: string;
ip_address: string;
}>;
recent_audit_entries: Array<{
action: string;
actor_username: string | null;
target_username: string | null;
created_at: string;
}>;
}
// TOTP setup response (from POST /api/auth/totp/setup) // TOTP setup response (from POST /api/auth/totp/setup)
export interface TotpSetupResponse { export interface TotpSetupResponse {