Compare commits
No commits in common. "e5a7ce13e05a2ef97b8e4f1c9be1e991975aba51" and "2ec70d93443377a6df01b87cb49ef103ff41e8c0" have entirely different histories.
e5a7ce13e0
...
2ec70d9344
@ -1,101 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -19,7 +19,6 @@ 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
|
||||||
@ -56,11 +55,10 @@ 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, scoped to this user
|
# Mirror the filter from /api/reminders/due
|
||||||
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
|
||||||
@ -74,8 +72,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 includes user_id to prevent cross-user dedup collisions
|
# Key ties notification to the specific day to handle re-fires after midnight
|
||||||
key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}"
|
key = f"reminder:{reminder.id}:{reminder.remind_at.date()}"
|
||||||
if await _already_sent(db, key):
|
if await _already_sent(db, key):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -100,13 +98,9 @@ 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.
|
||||||
@ -122,8 +116,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 user_id to prevent cross-user dedup collisions
|
# Key includes the minute-precision start to avoid re-firing during the window
|
||||||
key = f"event:{settings.user_id}:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}"
|
key = f"event:{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
|
||||||
|
|
||||||
@ -147,13 +141,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,
|
||||||
@ -163,8 +157,7 @@ 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 includes user_id to prevent cross-user dedup collisions
|
key = f"todo:{todo.id}:{today}"
|
||||||
key = f"todo:{settings.user_id}:{todo.id}:{today}"
|
|
||||||
if await _already_sent(db, key):
|
if await _already_sent(db, key):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -192,7 +185,6 @@ 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",
|
||||||
@ -202,8 +194,7 @@ 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 includes user_id to prevent cross-user dedup collisions
|
key = f"project:{project.id}:{today}"
|
||||||
key = f"project:{settings.user_id}:{project.id}:{today}"
|
|
||||||
if await _already_sent(db, key):
|
if await _already_sent(db, key):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -222,18 +213,6 @@ 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
|
||||||
@ -261,35 +240,29 @@ 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:
|
||||||
# Fetch all Settings rows that have ntfy enabled
|
result = await db.execute(select(Settings))
|
||||||
result = await db.execute(
|
settings = result.scalar_one_or_none()
|
||||||
select(Settings).where(Settings.ntfy_enabled == True) # noqa: E712
|
|
||||||
)
|
|
||||||
all_settings = result.scalars().all()
|
|
||||||
|
|
||||||
if not all_settings:
|
if not settings or not settings.ntfy_enabled:
|
||||||
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()
|
||||||
|
|
||||||
for user_settings in all_settings:
|
if settings.ntfy_reminders_enabled:
|
||||||
try:
|
await _dispatch_reminders(db, settings, now)
|
||||||
await _dispatch_for_user(db, user_settings, now)
|
if settings.ntfy_events_enabled:
|
||||||
except Exception:
|
await _dispatch_events(db, settings, now)
|
||||||
# Isolate per-user failures — log and continue to next user
|
if settings.ntfy_todos_enabled:
|
||||||
logger.exception(
|
await _dispatch_todos(db, settings, today)
|
||||||
"ntfy dispatch failed for user_id=%s", user_settings.user_id
|
if settings.ntfy_projects_enabled:
|
||||||
)
|
await _dispatch_projects(db, settings, today)
|
||||||
|
|
||||||
# Daily housekeeping: purge stale dedup records (shared across all users)
|
# Daily housekeeping: purge stale dedup records
|
||||||
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
|
||||||
|
|||||||
@ -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, admin
|
from app.routers import totp
|
||||||
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,8 +15,6 @@ 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
|
||||||
@ -70,7 +68,6 @@ 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("/")
|
||||||
|
|||||||
@ -13,8 +13,6 @@ 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",
|
||||||
@ -32,6 +30,4 @@ __all__ = [
|
|||||||
"NtfySent",
|
"NtfySent",
|
||||||
"TOTPUsage",
|
"TOTPUsage",
|
||||||
"BackupCode",
|
"BackupCode",
|
||||||
"SystemConfig",
|
|
||||||
"AuditLog",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, func
|
from sqlalchemy import String, Boolean, 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,9 +9,6 @@ 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")
|
||||||
|
|||||||
@ -9,9 +9,6 @@ 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)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func, text
|
from sqlalchemy import String, Text, Boolean, 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,9 +9,6 @@ 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")
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import String, Integer, ForeignKey, UniqueConstraint, func
|
from sqlalchemy import String, 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. Scoped per-user.
|
within a given time window.
|
||||||
|
|
||||||
Key format: "{type}:{entity_id}:{date_window}"
|
Key format: "{type}:{entity_id}:{date_window}"
|
||||||
Examples:
|
Examples:
|
||||||
@ -18,13 +18,7 @@ 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)
|
||||||
user_id: Mapped[int] = mapped_column(
|
notification_key: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||||
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())
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import String, Text, Date, Boolean, Integer, ForeignKey, func, text
|
from sqlalchemy import String, Text, Date, Boolean, 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,9 +9,6 @@ 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)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import Boolean, String, Text, Date, Integer, ForeignKey, func
|
from sqlalchemy import Boolean, String, Text, Date, 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,9 +10,6 @@ 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")
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func
|
from sqlalchemy import String, Text, Boolean, 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,9 +9,6 @@ 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)
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
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()
|
|
||||||
)
|
|
||||||
@ -9,9 +9,6 @@ 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")
|
||||||
|
|||||||
@ -23,23 +23,7 @@ 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)
|
|
||||||
|
|||||||
@ -1,687 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@ -1,20 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Authentication router — username/password with DB-backed sessions, account lockout,
|
Authentication router — username/password with DB-backed sessions and account lockout.
|
||||||
role-based access control, and multi-user registration.
|
|
||||||
|
|
||||||
Session flow:
|
Session flow:
|
||||||
POST /setup → create admin User + Settings + calendars → issue session cookie
|
POST /setup → create User + Settings row → issue session cookie
|
||||||
POST /login → verify credentials → check lockout → MFA/enforce checks → issue session
|
POST /login → verify credentials → check lockout → insert UserSession → issue cookie
|
||||||
POST /register → create standard user (when registration enabled)
|
→ if TOTP enabled: return mfa_token instead of full session
|
||||||
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 + role + registration_open
|
GET /status → verify user exists + session valid
|
||||||
|
|
||||||
Security layers:
|
Security layers:
|
||||||
1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on auth endpoints
|
1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on all auth endpoints
|
||||||
2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423)
|
2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) — per-user guard
|
||||||
3. Session revocation stored in DB (survives container restarts)
|
3. Session revocation stored in DB (survives container restarts)
|
||||||
4. bcrypt→Argon2id transparent upgrade on first login
|
4. bcrypt→Argon2id transparent upgrade on first login with migrated hash
|
||||||
5. Role-based authorization via require_role() dependency factory
|
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@ -28,21 +26,14 @@ 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.models.system_config import SystemConfig
|
from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest, VerifyPasswordRequest
|
||||||
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()
|
||||||
@ -73,6 +64,8 @@ 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")
|
||||||
@ -126,24 +119,6 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -191,7 +166,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,
|
ip_address=ip[:45] if ip else None, # clamp to column width
|
||||||
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)
|
||||||
@ -200,25 +175,6 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -231,7 +187,7 @@ async def setup(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
First-time setup: create the admin User + Settings + default calendars.
|
First-time setup: create the User record and a linked Settings row.
|
||||||
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))
|
||||||
@ -239,16 +195,13 @@ 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(
|
new_user = User(username=data.username, password_hash=password_hash)
|
||||||
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()
|
await db.flush() # assign new_user.id before creating Settings
|
||||||
|
|
||||||
await _create_user_defaults(db, new_user.id)
|
# Create Settings row linked to this user with all defaults
|
||||||
|
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"
|
||||||
@ -256,11 +209,6 @@ 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}
|
||||||
|
|
||||||
|
|
||||||
@ -275,15 +223,15 @@ async def login(
|
|||||||
Authenticate with username + password.
|
Authenticate with username + password.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{ authenticated: true } — on success (no TOTP, no enforcement)
|
{ authenticated: true } — on success (no TOTP)
|
||||||
{ authenticated: false, totp_required: true, mfa_token: "..." } — TOTP pending
|
{ authenticated: false, totp_required: true, mfa_token: "..." } — TOTP pending
|
||||||
{ authenticated: false, mfa_setup_required: true, mfa_token: "..." } — MFA enforcement
|
HTTP 401 — wrong credentials (generic; never reveals which field is wrong)
|
||||||
{ 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()
|
||||||
|
|
||||||
@ -292,36 +240,20 @@ 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)
|
||||||
|
|
||||||
# SEC-03: MFA enforcement — block login entirely until MFA setup completes
|
# If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session
|
||||||
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 {
|
||||||
@ -330,97 +262,13 @@ 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,
|
||||||
@ -456,13 +304,13 @@ async def auth_status(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Check authentication status, role, and whether initial setup/registration is available.
|
Check authentication status and whether initial setup has been performed.
|
||||||
|
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)
|
||||||
@ -478,32 +326,9 @@ async def auth_status(
|
|||||||
UserSession.expires_at > datetime.now(),
|
UserSession.expires_at > datetime.now(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if session_result.scalar_one_or_none() is not None:
|
authenticated = 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
|
|
||||||
|
|
||||||
# Check registration availability
|
return {"authenticated": authenticated, "setup_required": setup_required}
|
||||||
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")
|
||||||
@ -515,6 +340,8 @@ 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 bcrypt→Argon2id 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)
|
||||||
|
|
||||||
@ -523,6 +350,7 @@ 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()
|
||||||
@ -545,12 +373,6 @@ 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"}
|
||||||
|
|||||||
@ -18,11 +18,7 @@ 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(
|
result = await db.execute(select(Calendar).order_by(Calendar.is_default.desc(), Calendar.name.asc()))
|
||||||
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()
|
||||||
|
|
||||||
|
|
||||||
@ -38,7 +34,6 @@ 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()
|
||||||
@ -53,9 +48,7 @@ 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(
|
result = await db.execute(select(Calendar).where(Calendar.id == calendar_id))
|
||||||
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:
|
||||||
@ -81,9 +74,7 @@ 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(
|
result = await db.execute(select(Calendar).where(Calendar.id == calendar_id))
|
||||||
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:
|
||||||
@ -95,13 +86,8 @@ 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 user's default calendar
|
# Reassign all events on this calendar to the default calendar
|
||||||
default_result = await db.execute(
|
default_result = await db.execute(select(Calendar).where(Calendar.is_default == True))
|
||||||
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:
|
||||||
|
|||||||
@ -8,11 +8,9 @@ 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.models.user import User
|
from app.routers.auth import get_current_settings
|
||||||
from app.routers.auth import get_current_user, get_current_settings
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@ -28,21 +26,16 @@ _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_user: User = Depends(get_current_user),
|
current_settings: Settings = Depends(get_current_settings)
|
||||||
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,
|
||||||
@ -52,7 +45,6 @@ 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,
|
||||||
@ -63,7 +55,6 @@ 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
|
||||||
@ -71,32 +62,26 @@ 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 (scoped to user)
|
# Project stats
|
||||||
total_projects_result = await db.execute(
|
total_projects_result = await db.execute(select(func.count(Project.id)))
|
||||||
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")
|
||||||
).where(Project.user_id == current_user.id).group_by(Project.status)
|
).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 (scoped to user)
|
# Total incomplete todos count
|
||||||
total_incomplete_result = await db.execute(
|
total_incomplete_result = await db.execute(
|
||||||
select(func.count(Todo.id)).where(
|
select(func.count(Todo.id)).where(Todo.completed == False)
|
||||||
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, scoped to user's calendars)
|
# Starred events (upcoming, ordered by date)
|
||||||
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,
|
||||||
@ -158,8 +143,7 @@ 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_user: User = Depends(get_current_user),
|
current_settings: Settings = Depends(get_current_settings)
|
||||||
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()
|
||||||
@ -167,12 +151,8 @@ 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())
|
||||||
|
|
||||||
# Subquery: calendar IDs belonging to this user
|
# Get upcoming todos with due dates (today onward only)
|
||||||
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,
|
||||||
@ -181,9 +161,8 @@ 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, scoped to user's calendars)
|
# Get upcoming events (from today onward, exclude parent templates)
|
||||||
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,
|
||||||
@ -191,9 +170,8 @@ 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, scoped to user)
|
# Get upcoming reminders (today onward only)
|
||||||
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,
|
||||||
|
|||||||
@ -20,11 +20,7 @@ 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(
|
result = await db.execute(select(EventTemplate).order_by(EventTemplate.name))
|
||||||
select(EventTemplate)
|
|
||||||
.where(EventTemplate.user_id == current_user.id)
|
|
||||||
.order_by(EventTemplate.name)
|
|
||||||
)
|
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
@ -34,7 +30,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(), user_id=current_user.id)
|
template = EventTemplate(**payload.model_dump())
|
||||||
db.add(template)
|
db.add(template)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(template)
|
await db.refresh(template)
|
||||||
@ -49,10 +45,7 @@ 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(
|
select(EventTemplate).where(EventTemplate.id == template_id)
|
||||||
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:
|
||||||
@ -73,10 +66,7 @@ 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(
|
select(EventTemplate).where(EventTemplate.id == template_id)
|
||||||
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:
|
||||||
|
|||||||
@ -105,29 +105,15 @@ def _birthday_events_for_range(
|
|||||||
return virtual_events
|
return virtual_events
|
||||||
|
|
||||||
|
|
||||||
async def _get_default_calendar_id(db: AsyncSession, user_id: int) -> int:
|
async def _get_default_calendar_id(db: AsyncSession) -> int:
|
||||||
"""Return the id of the user's default calendar, raising 500 if not found."""
|
"""Return the id of the default calendar, raising 500 if not found."""
|
||||||
result = await db.execute(
|
result = await db.execute(select(Calendar).where(Calendar.is_default == True))
|
||||||
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),
|
||||||
@ -142,13 +128,9 @@ 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
|
||||||
@ -172,24 +154,14 @@ 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 the user's Birthdays system calendar; only generate virtual events if visible
|
# Fetch Birthdays calendar; only generate virtual events if visible
|
||||||
bday_result = await db.execute(
|
bday_result = await db.execute(
|
||||||
select(Calendar).where(
|
select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True)
|
||||||
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:
|
||||||
# Scope birthday people to this user
|
people_result = await db.execute(select(Person).where(Person.birthday.isnot(None)))
|
||||||
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(
|
||||||
@ -215,12 +187,9 @@ async def create_event(
|
|||||||
|
|
||||||
data = event.model_dump()
|
data = event.model_dump()
|
||||||
|
|
||||||
# Resolve calendar_id to user's default if not provided
|
# Resolve calendar_id to 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, current_user.id)
|
data["calendar_id"] = await _get_default_calendar_id(db)
|
||||||
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
|
||||||
@ -276,15 +245,10 @@ 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(
|
.where(CalendarEvent.id == event_id)
|
||||||
CalendarEvent.id == event_id,
|
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
event = result.scalar_one_or_none()
|
event = result.scalar_one_or_none()
|
||||||
|
|
||||||
@ -301,15 +265,10 @@ 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(
|
.where(CalendarEvent.id == event_id)
|
||||||
CalendarEvent.id == event_id,
|
|
||||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
event = result.scalar_one_or_none()
|
event = result.scalar_one_or_none()
|
||||||
|
|
||||||
@ -326,10 +285,6 @@ 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:
|
||||||
@ -426,14 +381,7 @@ 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),
|
||||||
):
|
):
|
||||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_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:
|
||||||
|
|||||||
@ -29,15 +29,14 @@ 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 — scoped to user's locations
|
# Local DB search
|
||||||
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)
|
||||||
)
|
)
|
||||||
@ -90,7 +89,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).where(Location.user_id == current_user.id)
|
query = select(Location)
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
query = query.where(Location.category == category)
|
query = query.where(Location.category == category)
|
||||||
@ -110,7 +109,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(), user_id=current_user.id)
|
new_location = Location(**location.model_dump())
|
||||||
db.add(new_location)
|
db.add(new_location)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(new_location)
|
await db.refresh(new_location)
|
||||||
@ -125,9 +124,7 @@ 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(
|
result = await db.execute(select(Location).where(Location.id == location_id))
|
||||||
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:
|
||||||
@ -144,9 +141,7 @@ 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(
|
result = await db.execute(select(Location).where(Location.id == location_id))
|
||||||
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:
|
||||||
@ -173,9 +168,7 @@ 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(
|
result = await db.execute(select(Location).where(Location.id == location_id))
|
||||||
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:
|
||||||
|
|||||||
@ -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).where(Person.user_id == current_user.id)
|
query = select(Person)
|
||||||
|
|
||||||
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, user_id=current_user.id)
|
new_person = Person(**data)
|
||||||
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,9 +96,7 @@ 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(
|
result = await db.execute(select(Person).where(Person.id == person_id))
|
||||||
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:
|
||||||
@ -115,9 +113,7 @@ 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(
|
result = await db.execute(select(Person).where(Person.id == person_id))
|
||||||
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:
|
||||||
@ -151,9 +147,7 @@ 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(
|
result = await db.execute(select(Person).where(Person.id == person_id))
|
||||||
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:
|
||||||
|
|||||||
@ -49,12 +49,7 @@ 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 = (
|
query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc())
|
||||||
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)
|
||||||
@ -82,7 +77,6 @@ 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,
|
||||||
@ -116,7 +110,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(), user_id=current_user.id)
|
new_project = Project(**project.model_dump())
|
||||||
db.add(new_project)
|
db.add(new_project)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@ -133,11 +127,7 @@ 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 = (
|
query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
|
||||||
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()
|
||||||
|
|
||||||
@ -155,9 +145,7 @@ 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(
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
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:
|
||||||
@ -183,9 +171,7 @@ 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(
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
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:
|
||||||
@ -204,10 +190,7 @@ 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)."""
|
||||||
# Verify project ownership first
|
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:
|
||||||
@ -236,10 +219,7 @@ 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."""
|
||||||
# Verify project ownership first
|
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:
|
||||||
@ -285,10 +265,7 @@ 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."""
|
||||||
# Verify project ownership first
|
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:
|
||||||
@ -319,13 +296,6 @@ 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,
|
||||||
@ -362,13 +332,6 @@ 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,
|
||||||
@ -395,13 +358,6 @@ 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,
|
||||||
@ -430,13 +386,6 @@ 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,
|
||||||
|
|||||||
@ -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).where(Reminder.user_id == current_user.id)
|
query = select(Reminder)
|
||||||
|
|
||||||
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,7 +48,6 @@ 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,
|
||||||
@ -75,12 +74,7 @@ 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(
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
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:
|
||||||
@ -105,7 +99,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(), user_id=current_user.id)
|
new_reminder = Reminder(**reminder.model_dump())
|
||||||
db.add(new_reminder)
|
db.add(new_reminder)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(new_reminder)
|
await db.refresh(new_reminder)
|
||||||
@ -120,12 +114,7 @@ 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(
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
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:
|
||||||
@ -142,12 +131,7 @@ 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(
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
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:
|
||||||
@ -180,12 +164,7 @@ 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(
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
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:
|
||||||
@ -204,12 +183,7 @@ 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(
|
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||||
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:
|
||||||
|
|||||||
@ -73,17 +73,15 @@ def _calculate_recurrence(
|
|||||||
return reset_at, next_due
|
return reset_at, next_due
|
||||||
|
|
||||||
|
|
||||||
async def _reactivate_recurring_todos(db: AsyncSession, user_id: int) -> None:
|
async def _reactivate_recurring_todos(db: AsyncSession) -> 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),
|
||||||
@ -112,14 +110,13 @@ 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: User = Depends(get_current_user),
|
current_user: Settings = Depends(get_current_settings)
|
||||||
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, current_user.id)
|
await _reactivate_recurring_todos(db)
|
||||||
|
|
||||||
query = select(Todo).where(Todo.user_id == current_user.id)
|
query = select(Todo)
|
||||||
|
|
||||||
if completed is not None:
|
if completed is not None:
|
||||||
query = query.where(Todo.completed == completed)
|
query = query.where(Todo.completed == completed)
|
||||||
@ -147,10 +144,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: User = Depends(get_current_user),
|
current_user: Settings = Depends(get_current_settings)
|
||||||
):
|
):
|
||||||
"""Create a new todo."""
|
"""Create a new todo."""
|
||||||
new_todo = Todo(**todo.model_dump(), user_id=current_user.id)
|
new_todo = Todo(**todo.model_dump())
|
||||||
db.add(new_todo)
|
db.add(new_todo)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(new_todo)
|
await db.refresh(new_todo)
|
||||||
@ -162,12 +159,10 @@ 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: User = Depends(get_current_user),
|
current_user: Settings = Depends(get_current_settings)
|
||||||
):
|
):
|
||||||
"""Get a specific todo by ID."""
|
"""Get a specific todo by ID."""
|
||||||
result = await db.execute(
|
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||||
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:
|
||||||
@ -181,13 +176,10 @@ 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: User = Depends(get_current_user),
|
current_user: Settings = Depends(get_current_settings)
|
||||||
current_settings: Settings = Depends(get_current_settings),
|
|
||||||
):
|
):
|
||||||
"""Update a todo."""
|
"""Update a todo."""
|
||||||
result = await db.execute(
|
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||||
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:
|
||||||
@ -218,7 +210,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_settings.first_day_of_week,
|
current_user.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
|
||||||
@ -237,12 +229,10 @@ 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: User = Depends(get_current_user),
|
current_user: Settings = Depends(get_current_settings)
|
||||||
):
|
):
|
||||||
"""Delete a todo."""
|
"""Delete a todo."""
|
||||||
result = await db.execute(
|
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||||
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:
|
||||||
@ -258,13 +248,10 @@ 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: User = Depends(get_current_user),
|
current_user: Settings = Depends(get_current_settings)
|
||||||
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(
|
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||||
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:
|
||||||
@ -280,7 +267,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_settings.first_day_of_week,
|
current_user.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
|
||||||
|
|||||||
@ -3,7 +3,6 @@ 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
|
||||||
@ -12,37 +11,13 @@ 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()
|
||||||
|
|
||||||
# SEC-15: Bounded LRU cache keyed by (user_id, location) — max 100 entries.
|
_cache: dict = {}
|
||||||
# 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):
|
||||||
@ -91,24 +66,23 @@ 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: User = Depends(get_current_user),
|
current_user: Settings = Depends(get_current_settings)
|
||||||
current_settings: Settings = Depends(get_current_settings),
|
|
||||||
):
|
):
|
||||||
city = current_settings.weather_city
|
city = current_user.weather_city
|
||||||
lat = current_settings.weather_lat
|
lat = current_user.weather_lat
|
||||||
lon = current_settings.weather_lon
|
lon = current_user.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")
|
||||||
|
|
||||||
# Cache key includes user_id so each user gets isolated cache entries
|
# Build cache key from coordinates or city
|
||||||
use_coords = lat is not None and lon is not None
|
use_coords = lat is not None and lon is not None
|
||||||
location_key = f"{lat},{lon}" if use_coords else city
|
cache_key = f"{lat},{lon}" if use_coords else city
|
||||||
cache_key = (current_user.id, location_key)
|
|
||||||
|
|
||||||
cached = _cache_get(cache_key)
|
# Check cache
|
||||||
if cached is not None:
|
now = datetime.now()
|
||||||
return cached
|
if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("cache_key") == cache_key:
|
||||||
|
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:
|
||||||
@ -148,7 +122,11 @@ async def get_weather(
|
|||||||
"city": current_data["name"],
|
"city": current_data["name"],
|
||||||
}
|
}
|
||||||
|
|
||||||
_cache_set(cache_key, weather_result)
|
# Cache for 1 hour
|
||||||
|
_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:
|
||||||
|
|||||||
@ -1,133 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
|
||||||
def _validate_password_strength(v: str) -> str:
|
def _validate_password_strength(v: str) -> str:
|
||||||
@ -21,16 +21,6 @@ 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 3–50 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
|
||||||
@ -38,29 +28,12 @@ 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:
|
||||||
return _validate_username(v)
|
v = v.strip().lower()
|
||||||
|
if not 3 <= len(v) <= 50:
|
||||||
@field_validator("password")
|
raise ValueError("Username must be 3–50 characters")
|
||||||
@classmethod
|
if not re.fullmatch(r"[a-z0-9_\-]+", v):
|
||||||
def validate_password(cls, v: str) -> str:
|
raise ValueError("Username may only contain letters, numbers, _ and -")
|
||||||
return _validate_password_strength(v)
|
return 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
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -126,32 +126,3 @@ 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
|
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
# 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 {
|
||||||
@ -64,20 +60,6 @@ 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;
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
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';
|
||||||
@ -13,8 +12,6 @@ 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();
|
||||||
|
|
||||||
@ -33,24 +30,6 @@ 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>
|
||||||
@ -73,16 +52,6 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,7 +16,6 @@ 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';
|
||||||
@ -45,7 +44,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, isAdmin } = useAuth();
|
const { logout } = useAuth();
|
||||||
const { lock } = useLock();
|
const { lock } = useLock();
|
||||||
const [projectsExpanded, setProjectsExpanded] = useState(false);
|
const [projectsExpanded, setProjectsExpanded] = useState(false);
|
||||||
|
|
||||||
@ -194,16 +193,6 @@ 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}
|
||||||
|
|||||||
@ -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,34 +23,11 @@ export function useAuth() {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if ('mfa_setup_required' in data && data.mfa_setup_required) {
|
if ('mfa_token' in data && data.totp_required) {
|
||||||
// MFA enforcement — user must set up TOTP before accessing app
|
// MFA required — store token locally, do NOT mark as authenticated yet
|
||||||
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'] });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -66,7 +43,6 @@ export function useAuth() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setMfaToken(null);
|
setMfaToken(null);
|
||||||
setMfaSetupRequired(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -88,7 +64,6 @@ export function useAuth() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setMfaToken(null);
|
setMfaToken(null);
|
||||||
setMfaSetupRequired(false);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -96,18 +71,12 @@ export function useAuth() {
|
|||||||
return {
|
return {
|
||||||
authStatus: authQuery.data,
|
authStatus: authQuery.data,
|
||||||
isLoading: authQuery.isLoading,
|
isLoading: authQuery.isLoading,
|
||||||
role: authQuery.data?.role ?? null,
|
mfaRequired: mfaToken !== 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,7 +4,6 @@ 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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -188,19 +188,14 @@ 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 {
|
||||||
@ -209,64 +204,7 @@ export interface LoginMfaRequiredResponse {
|
|||||||
mfa_token: string;
|
mfa_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginMfaSetupRequiredResponse {
|
export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse;
|
||||||
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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user