Compare commits
22 Commits
5feb67bf13
...
17643d54ea
| Author | SHA1 | Date | |
|---|---|---|---|
| 17643d54ea | |||
| 5ad0a610bd | |||
| aa2d011700 | |||
| f5265a589e | |||
| 4207a62ad8 | |||
| 0d2d321fbb | |||
| ca1cd14ed1 | |||
| 7d6ac4d257 | |||
| b0af07c270 | |||
| e5b6725081 | |||
| 6094561d74 | |||
| 9b261574ca | |||
| 4a98b67b0b | |||
| 15c99152d3 | |||
| f136a0820d | |||
| 3268bfc5d5 | |||
| 6ad6056125 | |||
| b134ad9e8b | |||
| fbc452a004 | |||
| 5a8819c4a5 | |||
| 67456c78dd | |||
| 7f0ae0b6ef |
@ -0,0 +1,88 @@
|
||||
"""Add ntfy settings columns and ntfy_sent deduplication table
|
||||
|
||||
Revision ID: 022
|
||||
Revises: 021
|
||||
Create Date: 2026-02-25
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '022'
|
||||
down_revision = '021'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── New columns on settings ──────────────────────────────────────────────
|
||||
op.add_column('settings', sa.Column('ntfy_server_url', sa.String(500), nullable=True))
|
||||
op.add_column('settings', sa.Column('ntfy_topic', sa.String(255), nullable=True))
|
||||
op.add_column('settings', sa.Column('ntfy_auth_token', sa.String(500), nullable=True))
|
||||
op.add_column('settings', sa.Column(
|
||||
'ntfy_enabled', sa.Boolean(), nullable=False,
|
||||
server_default=sa.text('false')
|
||||
))
|
||||
op.add_column('settings', sa.Column(
|
||||
'ntfy_events_enabled', sa.Boolean(), nullable=False,
|
||||
server_default=sa.text('true')
|
||||
))
|
||||
op.add_column('settings', sa.Column(
|
||||
'ntfy_reminders_enabled', sa.Boolean(), nullable=False,
|
||||
server_default=sa.text('true')
|
||||
))
|
||||
op.add_column('settings', sa.Column(
|
||||
'ntfy_todos_enabled', sa.Boolean(), nullable=False,
|
||||
server_default=sa.text('true')
|
||||
))
|
||||
op.add_column('settings', sa.Column(
|
||||
'ntfy_projects_enabled', sa.Boolean(), nullable=False,
|
||||
server_default=sa.text('true')
|
||||
))
|
||||
op.add_column('settings', sa.Column(
|
||||
'ntfy_event_lead_minutes', sa.Integer(), nullable=False,
|
||||
server_default=sa.text('15')
|
||||
))
|
||||
op.add_column('settings', sa.Column(
|
||||
'ntfy_todo_lead_days', sa.Integer(), nullable=False,
|
||||
server_default=sa.text('1')
|
||||
))
|
||||
op.add_column('settings', sa.Column(
|
||||
'ntfy_project_lead_days', sa.Integer(), nullable=False,
|
||||
server_default=sa.text('2')
|
||||
))
|
||||
|
||||
# ── New ntfy_sent deduplication table ────────────────────────────────────
|
||||
op.create_table(
|
||||
'ntfy_sent',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('notification_key', sa.String(255), nullable=False),
|
||||
sa.Column(
|
||||
'sent_at', sa.DateTime(), nullable=False,
|
||||
server_default=sa.text('now()')
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
'ix_ntfy_sent_notification_key',
|
||||
'ntfy_sent',
|
||||
['notification_key'],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ── Drop ntfy_sent table ─────────────────────────────────────────────────
|
||||
op.drop_index('ix_ntfy_sent_notification_key', table_name='ntfy_sent')
|
||||
op.drop_table('ntfy_sent')
|
||||
|
||||
# ── Remove settings columns ──────────────────────────────────────────────
|
||||
op.drop_column('settings', 'ntfy_project_lead_days')
|
||||
op.drop_column('settings', 'ntfy_todo_lead_days')
|
||||
op.drop_column('settings', 'ntfy_event_lead_minutes')
|
||||
op.drop_column('settings', 'ntfy_projects_enabled')
|
||||
op.drop_column('settings', 'ntfy_todos_enabled')
|
||||
op.drop_column('settings', 'ntfy_reminders_enabled')
|
||||
op.drop_column('settings', 'ntfy_events_enabled')
|
||||
op.drop_column('settings', 'ntfy_enabled')
|
||||
op.drop_column('settings', 'ntfy_auth_token')
|
||||
op.drop_column('settings', 'ntfy_topic')
|
||||
op.drop_column('settings', 'ntfy_server_url')
|
||||
148
backend/alembic/versions/023_auth_migration_users_sessions.py
Normal file
148
backend/alembic/versions/023_auth_migration_users_sessions.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""Auth migration: create users + user_sessions tables, migrate pin_hash to User row,
|
||||
add user_id FK to settings, drop pin_hash from settings.
|
||||
|
||||
Revision ID: 023
|
||||
Revises: 022
|
||||
Create Date: 2026-02-25
|
||||
|
||||
Data migration strategy (handles both fresh DB and existing single-user DB):
|
||||
1. Create users table
|
||||
2. Create user_sessions table
|
||||
3. If settings row exists with a pin_hash, create a User row with
|
||||
username='admin' and password_hash = pin_hash (bcrypt hash is valid —
|
||||
user will be prompted to change password on first login, hash upgrades
|
||||
transparently to Argon2id on first successful login).
|
||||
4. Add user_id FK column to settings (nullable initially)
|
||||
5. Backfill settings.user_id to point at the migrated admin user (if any)
|
||||
6. Drop pin_hash from settings
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers
|
||||
revision = '023'
|
||||
down_revision = '022'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Create users table
|
||||
# ------------------------------------------------------------------
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=50), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('totp_secret', sa.String(length=500), nullable=True),
|
||||
sa.Column('totp_enabled', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('failed_login_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('locked_until', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
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.Column('last_login_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_users_id', 'users', ['id'], unique=False)
|
||||
op.create_index('ix_users_username', 'users', ['username'], unique=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Create user_sessions table
|
||||
# ------------------------------------------------------------------
|
||||
op.create_table(
|
||||
'user_sessions',
|
||||
sa.Column('id', sa.String(length=64), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('NOW()')),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('revoked', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('user_agent', sa.String(length=255), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_user_sessions_user_id', 'user_sessions', ['user_id'], unique=False)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Data migration: create admin User from existing pin_hash (if any)
|
||||
# Uses raw SQL to avoid any ORM dependency issues in the migration.
|
||||
# ------------------------------------------------------------------
|
||||
bind = op.get_bind()
|
||||
|
||||
# Check whether settings table has a row with a pin_hash
|
||||
result = bind.execute(
|
||||
sa.text("SELECT id, pin_hash FROM settings WHERE pin_hash IS NOT NULL LIMIT 1")
|
||||
)
|
||||
settings_row = result.fetchone()
|
||||
|
||||
admin_user_id = None
|
||||
if settings_row is not None:
|
||||
settings_id, existing_pin_hash = settings_row[0], settings_row[1]
|
||||
|
||||
# Insert the migrated user — username defaults to 'admin', password_hash
|
||||
# retains the existing bcrypt hash (will upgrade to Argon2id on first login)
|
||||
insert_result = bind.execute(
|
||||
sa.text(
|
||||
"INSERT INTO users (username, password_hash, is_active, totp_enabled, "
|
||||
"failed_login_count, created_at, updated_at) "
|
||||
"VALUES ('admin', :ph, true, false, 0, NOW(), NOW()) RETURNING id"
|
||||
),
|
||||
{"ph": existing_pin_hash},
|
||||
)
|
||||
admin_user_id = insert_result.fetchone()[0]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Add user_id FK column to settings (nullable during migration)
|
||||
# ------------------------------------------------------------------
|
||||
op.add_column(
|
||||
'settings',
|
||||
sa.Column(
|
||||
'user_id',
|
||||
sa.Integer(),
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
op.create_index('ix_settings_user_id', 'settings', ['user_id'], unique=False)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Backfill settings.user_id for the migrated admin user
|
||||
# ------------------------------------------------------------------
|
||||
if admin_user_id is not None:
|
||||
bind.execute(
|
||||
sa.text("UPDATE settings SET user_id = :uid"),
|
||||
{"uid": admin_user_id},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Enforce NOT NULL on user_id now that backfill is complete
|
||||
# ------------------------------------------------------------------
|
||||
op.alter_column('settings', 'user_id', nullable=False)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Drop pin_hash from settings — data now lives in users.password_hash
|
||||
# ------------------------------------------------------------------
|
||||
op.drop_column('settings', 'pin_hash')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Restore pin_hash column (empty — data cannot be recovered from users table
|
||||
# because the column was dropped, not just migrated; acceptable for downgrade path)
|
||||
op.add_column(
|
||||
'settings',
|
||||
sa.Column('pin_hash', sa.String(length=255), nullable=True)
|
||||
)
|
||||
|
||||
op.drop_index('ix_settings_user_id', table_name='settings')
|
||||
op.drop_column('settings', 'user_id')
|
||||
|
||||
op.drop_index('ix_user_sessions_user_id', table_name='user_sessions')
|
||||
op.drop_table('user_sessions')
|
||||
|
||||
op.drop_index('ix_users_username', table_name='users')
|
||||
op.drop_index('ix_users_id', table_name='users')
|
||||
op.drop_table('users')
|
||||
74
backend/alembic/versions/024_totp_mfa_tables.py
Normal file
74
backend/alembic/versions/024_totp_mfa_tables.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""TOTP MFA: create totp_usage and backup_codes tables.
|
||||
|
||||
Revision ID: 024
|
||||
Revises: 023
|
||||
Create Date: 2026-02-25
|
||||
|
||||
Note: totp_secret and totp_enabled columns are already on the users table
|
||||
from migration 023 — this migration only adds the support tables.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "024"
|
||||
down_revision = "023"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --- totp_usage: tracks used TOTP codes for replay prevention ---
|
||||
op.create_table(
|
||||
"totp_usage",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("code", sa.String(6), nullable=False),
|
||||
# The actual TOTP time window (floor(unix_time / 30)) that matched
|
||||
sa.Column("window", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"used_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
# Unique on (user_id, code, window) — not just (user_id, window) — see model comment
|
||||
sa.UniqueConstraint("user_id", "code", "window", name="uq_totp_user_code_window"),
|
||||
)
|
||||
op.create_index("ix_totp_usage_user_id", "totp_usage", ["user_id"])
|
||||
|
||||
# --- backup_codes: hashed recovery codes (Argon2id) ---
|
||||
op.create_table(
|
||||
"backup_codes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
# Argon2id hash of the plaintext recovery code
|
||||
sa.Column("code_hash", sa.String(255), nullable=False),
|
||||
# Null until redeemed
|
||||
sa.Column("used_at", sa.DateTime(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("NOW()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_backup_codes_user_id", "backup_codes", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_backup_codes_user_id", table_name="backup_codes")
|
||||
op.drop_table("backup_codes")
|
||||
op.drop_index("ix_totp_usage_user_id", table_name="totp_usage")
|
||||
op.drop_table("totp_usage")
|
||||
30
backend/alembic/versions/025_add_auto_lock_settings.py
Normal file
30
backend/alembic/versions/025_add_auto_lock_settings.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Add auto-lock settings columns to settings table.
|
||||
|
||||
Revision ID: 025
|
||||
Revises: 024
|
||||
Create Date: 2026-02-25
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = "025"
|
||||
down_revision = "024"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"settings",
|
||||
sa.Column("auto_lock_enabled", sa.Boolean(), server_default="false", nullable=False),
|
||||
)
|
||||
op.add_column(
|
||||
"settings",
|
||||
sa.Column("auto_lock_minutes", sa.Integer(), server_default="5", nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("settings", "auto_lock_minutes")
|
||||
op.drop_column("settings", "auto_lock_enabled")
|
||||
@ -9,6 +9,15 @@ class Settings(BaseSettings):
|
||||
COOKIE_SECURE: bool = False
|
||||
OPENWEATHERMAP_API_KEY: str = ""
|
||||
|
||||
# Session config
|
||||
SESSION_MAX_AGE_DAYS: int = 30
|
||||
|
||||
# MFA token config (short-lived token bridging password OK → TOTP verification)
|
||||
MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes
|
||||
|
||||
# TOTP issuer name shown in authenticator apps
|
||||
TOTP_ISSUER: str = "UMBRA"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
|
||||
275
backend/app/jobs/notifications.py
Normal file
275
backend/app/jobs/notifications.py
Normal file
@ -0,0 +1,275 @@
|
||||
"""
|
||||
Background notification dispatch job.
|
||||
Runs every 60 seconds via APScheduler (registered in main.py lifespan).
|
||||
|
||||
DATETIME NOTE: All comparisons use datetime.now() without timezone info.
|
||||
The DB uses TIMESTAMP WITHOUT TIME ZONE (naive datetimes). The Docker container
|
||||
runs UTC. datetime.now() inside the container returns UTC, which matches the
|
||||
naive datetimes stored in the DB. Do NOT use datetime.now(timezone.utc) here —
|
||||
that would produce a timezone-aware object that cannot be compared with naive DB values.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select, delete, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.settings import Settings
|
||||
from app.models.reminder import Reminder
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.todo import Todo
|
||||
from app.models.project import Project
|
||||
from app.models.ntfy_sent import NtfySent
|
||||
from app.models.totp_usage import TOTPUsage
|
||||
from app.models.session import UserSession
|
||||
from app.services.ntfy import send_ntfy_notification
|
||||
from app.services.ntfy_templates import (
|
||||
build_event_notification,
|
||||
build_reminder_notification,
|
||||
build_todo_notification,
|
||||
build_project_notification,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UMBRA_URL = "http://10.0.69.35"
|
||||
|
||||
|
||||
# ── Dedup helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _already_sent(db: AsyncSession, key: str) -> bool:
|
||||
result = await db.execute(
|
||||
select(NtfySent).where(NtfySent.notification_key == key)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _mark_sent(db: AsyncSession, key: str) -> None:
|
||||
db.add(NtfySent(notification_key=key))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Dispatch functions ────────────────────────────────────────────────────────
|
||||
|
||||
async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime) -> None:
|
||||
"""Send notifications for reminders that are currently due and not dismissed/snoozed."""
|
||||
# Mirror the filter from /api/reminders/due
|
||||
result = await db.execute(
|
||||
select(Reminder).where(
|
||||
and_(
|
||||
Reminder.remind_at <= now,
|
||||
Reminder.is_dismissed == False, # noqa: E712
|
||||
Reminder.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
)
|
||||
reminders = result.scalars().all()
|
||||
today = now.date()
|
||||
|
||||
for reminder in reminders:
|
||||
if reminder.snoozed_until and reminder.snoozed_until > now:
|
||||
continue # respect snooze
|
||||
|
||||
# Key ties notification to the specific day to handle re-fires after midnight
|
||||
key = f"reminder:{reminder.id}:{reminder.remind_at.date()}"
|
||||
if await _already_sent(db, key):
|
||||
continue
|
||||
|
||||
payload = build_reminder_notification(
|
||||
title=reminder.title,
|
||||
remind_at=reminder.remind_at,
|
||||
today=today,
|
||||
description=reminder.description,
|
||||
)
|
||||
sent = await send_ntfy_notification(
|
||||
settings=settings,
|
||||
click_url=UMBRA_URL,
|
||||
**payload,
|
||||
)
|
||||
if sent:
|
||||
await _mark_sent(db, key)
|
||||
|
||||
|
||||
async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) -> None:
|
||||
"""Send notifications for calendar events within the configured lead time window."""
|
||||
lead_minutes = settings.ntfy_event_lead_minutes
|
||||
# Window: events starting between now and (now + lead_minutes)
|
||||
window_end = now + timedelta(minutes=lead_minutes)
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(
|
||||
and_(
|
||||
CalendarEvent.start_datetime >= now,
|
||||
CalendarEvent.start_datetime <= window_end,
|
||||
# Exclude recurring parent templates — they duplicate the child instance rows.
|
||||
# Parent templates have recurrence_rule set but no parent_event_id.
|
||||
~and_(
|
||||
CalendarEvent.recurrence_rule != None, # noqa: E711
|
||||
CalendarEvent.parent_event_id == None, # noqa: E711
|
||||
),
|
||||
)
|
||||
).options(selectinload(CalendarEvent.location))
|
||||
)
|
||||
events = result.scalars().all()
|
||||
today = now.date()
|
||||
|
||||
for event in events:
|
||||
# Key includes the minute-precision start to avoid re-firing during the window
|
||||
key = f"event:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}"
|
||||
if await _already_sent(db, key):
|
||||
continue
|
||||
|
||||
payload = build_event_notification(
|
||||
title=event.title,
|
||||
start_datetime=event.start_datetime,
|
||||
all_day=event.all_day,
|
||||
today=today,
|
||||
location_name=event.location.name if event.location else None,
|
||||
description=event.description,
|
||||
is_starred=event.is_starred,
|
||||
)
|
||||
sent = await send_ntfy_notification(
|
||||
settings=settings,
|
||||
click_url=UMBRA_URL,
|
||||
**payload,
|
||||
)
|
||||
if sent:
|
||||
await _mark_sent(db, key)
|
||||
|
||||
|
||||
async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
|
||||
"""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
|
||||
cutoff = today + timedelta(days=lead_days)
|
||||
|
||||
result = await db.execute(
|
||||
select(Todo).where(
|
||||
and_(
|
||||
Todo.completed == False, # noqa: E712
|
||||
Todo.due_date != None, # noqa: E711
|
||||
Todo.due_date <= cutoff,
|
||||
)
|
||||
)
|
||||
)
|
||||
todos = result.scalars().all()
|
||||
|
||||
for todo in todos:
|
||||
key = f"todo:{todo.id}:{today}"
|
||||
if await _already_sent(db, key):
|
||||
continue
|
||||
|
||||
payload = build_todo_notification(
|
||||
title=todo.title,
|
||||
due_date=todo.due_date,
|
||||
today=today,
|
||||
priority=todo.priority,
|
||||
category=todo.category,
|
||||
)
|
||||
sent = await send_ntfy_notification(
|
||||
settings=settings,
|
||||
click_url=UMBRA_URL,
|
||||
**payload,
|
||||
)
|
||||
if sent:
|
||||
await _mark_sent(db, key)
|
||||
|
||||
|
||||
async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> None:
|
||||
"""Send notifications for projects with deadlines within the configured lead days."""
|
||||
lead_days = settings.ntfy_project_lead_days
|
||||
cutoff = today + timedelta(days=lead_days)
|
||||
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
and_(
|
||||
Project.due_date != None, # noqa: E711
|
||||
Project.due_date <= cutoff,
|
||||
Project.status != "completed",
|
||||
)
|
||||
)
|
||||
)
|
||||
projects = result.scalars().all()
|
||||
|
||||
for project in projects:
|
||||
key = f"project:{project.id}:{today}"
|
||||
if await _already_sent(db, key):
|
||||
continue
|
||||
|
||||
payload = build_project_notification(
|
||||
name=project.name,
|
||||
due_date=project.due_date,
|
||||
today=today,
|
||||
status=project.status,
|
||||
)
|
||||
sent = await send_ntfy_notification(
|
||||
settings=settings,
|
||||
click_url=UMBRA_URL,
|
||||
**payload,
|
||||
)
|
||||
if sent:
|
||||
await _mark_sent(db, key)
|
||||
|
||||
|
||||
async def _purge_old_sent_records(db: AsyncSession) -> None:
|
||||
"""Remove ntfy_sent entries older than 7 days to keep the table lean."""
|
||||
# See DATETIME NOTE at top of file re: naive datetime usage
|
||||
cutoff = datetime.now() - timedelta(days=7)
|
||||
await db.execute(delete(NtfySent).where(NtfySent.sent_at < cutoff))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _purge_totp_usage(db: AsyncSession) -> None:
|
||||
"""Remove TOTP usage records older than 5 minutes — they serve no purpose beyond replay prevention."""
|
||||
cutoff = datetime.now() - timedelta(minutes=5)
|
||||
await db.execute(delete(TOTPUsage).where(TOTPUsage.used_at < cutoff))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _purge_expired_sessions(db: AsyncSession) -> None:
|
||||
"""Remove expired UserSession rows to keep the sessions table lean."""
|
||||
await db.execute(delete(UserSession).where(UserSession.expires_at < datetime.now()))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def run_notification_dispatch() -> None:
|
||||
"""
|
||||
Main dispatch function called by APScheduler every 60 seconds.
|
||||
Uses AsyncSessionLocal directly — not the get_db() request-scoped dependency.
|
||||
"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Settings))
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if not settings or not settings.ntfy_enabled:
|
||||
return
|
||||
|
||||
# See DATETIME NOTE at top of file re: naive datetime usage
|
||||
now = datetime.now()
|
||||
today = now.date()
|
||||
|
||||
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, today)
|
||||
if settings.ntfy_projects_enabled:
|
||||
await _dispatch_projects(db, settings, today)
|
||||
|
||||
# Daily housekeeping: purge stale dedup records
|
||||
await _purge_old_sent_records(db)
|
||||
|
||||
# Security housekeeping runs every cycle regardless of ntfy_enabled
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _purge_totp_usage(db)
|
||||
await _purge_expired_sessions(db)
|
||||
|
||||
except Exception:
|
||||
# Broad catch: job failure must never crash the scheduler or the app
|
||||
logger.exception("ntfy dispatch job encountered an unhandled error")
|
||||
@ -2,13 +2,33 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
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 totp
|
||||
from app.jobs.notifications import run_notification_dispatch
|
||||
|
||||
# Import models so Alembic's autogenerate can discover them
|
||||
from app.models import user as _user_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 backup_code as _backup_code_model # noqa: F401
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
scheduler = AsyncIOScheduler()
|
||||
scheduler.add_job(
|
||||
run_notification_dispatch,
|
||||
"interval",
|
||||
minutes=1,
|
||||
id="ntfy_dispatch",
|
||||
max_instances=1, # prevent overlap if a run takes longer than 60s
|
||||
)
|
||||
scheduler.start()
|
||||
yield
|
||||
scheduler.shutdown(wait=False)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@ -41,6 +61,7 @@ app.include_router(settings_router.router, prefix="/api/settings", tags=["Settin
|
||||
app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
|
||||
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(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@ -8,6 +8,11 @@ from app.models.project_task import ProjectTask
|
||||
from app.models.person import Person
|
||||
from app.models.location import Location
|
||||
from app.models.task_comment import TaskComment
|
||||
from app.models.user import User
|
||||
from app.models.session import UserSession
|
||||
from app.models.ntfy_sent import NtfySent
|
||||
from app.models.totp_usage import TOTPUsage
|
||||
from app.models.backup_code import BackupCode
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
@ -20,4 +25,9 @@ __all__ = [
|
||||
"Person",
|
||||
"Location",
|
||||
"TaskComment",
|
||||
"User",
|
||||
"UserSession",
|
||||
"NtfySent",
|
||||
"TOTPUsage",
|
||||
"BackupCode",
|
||||
]
|
||||
|
||||
19
backend/app/models/backup_code.py
Normal file
19
backend/app/models/backup_code.py
Normal file
@ -0,0 +1,19 @@
|
||||
from sqlalchemy import String, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class BackupCode(Base):
|
||||
__tablename__ = "backup_codes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# Argon2id hash of the plaintext recovery code — never store plaintext
|
||||
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
# Null until redeemed; set to datetime.now() on successful use
|
||||
used_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
24
backend/app/models/ntfy_sent.py
Normal file
24
backend/app/models/ntfy_sent.py
Normal file
@ -0,0 +1,24 @@
|
||||
from sqlalchemy import String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from datetime import datetime
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NtfySent(Base):
|
||||
"""
|
||||
Deduplication table for ntfy notifications.
|
||||
Prevents the background job from re-sending the same notification
|
||||
within a given time window.
|
||||
|
||||
Key format: "{type}:{entity_id}:{date_window}"
|
||||
Examples:
|
||||
"reminder:42:2026-02-25"
|
||||
"event:17:2026-02-25T09:00"
|
||||
"todo:8:2026-02-25"
|
||||
"project:3:2026-02-25"
|
||||
"""
|
||||
__tablename__ = "ntfy_sent"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
notification_key: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
sent_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
||||
23
backend/app/models/session.py
Normal file
23
backend/app/models/session.py
Normal file
@ -0,0 +1,23 @@
|
||||
from sqlalchemy import String, Boolean, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from datetime import datetime
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
__tablename__ = "user_sessions"
|
||||
|
||||
# UUID4 hex — avoids integer primary key enumeration
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(nullable=False)
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Audit fields for security logging
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
@ -1,6 +1,7 @@
|
||||
from sqlalchemy import String, Integer, Float, func
|
||||
from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
@ -8,7 +9,14 @@ class Settings(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
pin_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# FK to users table — NOT NULL enforced by migration 023 after data backfill
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
accent_color: Mapped[str] = mapped_column(String(20), default="cyan")
|
||||
upcoming_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||
preferred_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None)
|
||||
@ -16,5 +24,32 @@ class Settings(Base):
|
||||
weather_lat: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
|
||||
weather_lon: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
|
||||
first_day_of_week: Mapped[int] = mapped_column(Integer, default=0) # 0=Sunday, 1=Monday
|
||||
|
||||
# ntfy push notification configuration
|
||||
ntfy_server_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True, default=None)
|
||||
ntfy_topic: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None)
|
||||
ntfy_auth_token: Mapped[Optional[str]] = mapped_column(String(500), nullable=True, default=None)
|
||||
ntfy_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
# Per-type notification toggles (default on so they work immediately once master is enabled)
|
||||
ntfy_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
ntfy_reminders_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
ntfy_todos_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
ntfy_projects_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
|
||||
# Lead time controls
|
||||
ntfy_event_lead_minutes: Mapped[int] = mapped_column(Integer, default=15, server_default="15")
|
||||
ntfy_todo_lead_days: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
|
||||
ntfy_project_lead_days: Mapped[int] = mapped_column(Integer, default=2, server_default="2")
|
||||
|
||||
# Auto-lock settings
|
||||
auto_lock_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5")
|
||||
|
||||
@property
|
||||
def ntfy_has_token(self) -> bool:
|
||||
"""Derived field for SettingsResponse — True when an auth token is stored."""
|
||||
return bool(self.ntfy_auth_token)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
30
backend/app/models/totp_usage.py
Normal file
30
backend/app/models/totp_usage.py
Normal file
@ -0,0 +1,30 @@
|
||||
from sqlalchemy import String, Integer, ForeignKey, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from datetime import datetime
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TOTPUsage(Base):
|
||||
__tablename__ = "totp_usage"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# The 6-digit code that was used
|
||||
code: Mapped[str] = mapped_column(String(6), nullable=False)
|
||||
# The TOTP time window in which the code was valid (floor(unix_time / 30))
|
||||
window: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
used_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
|
||||
# NOTE on replay prevention design:
|
||||
# The unique constraint is on (user_id, code, window) — NOT (user_id, window).
|
||||
# This allows a user to present the T-1 window code and then the T window code
|
||||
# consecutively — these are different 6-digit OTPs, so both should succeed.
|
||||
# Constraining only (user_id, window) would reject the second legitimate code.
|
||||
# The actual attack we prevent is reusing the *same code string in the same window*,
|
||||
# which is the only true replay. This is compliant with RFC 6238 §5.2.
|
||||
# The 5-minute MFA token expiry provides the outer time bound on any abuse window.
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "code", "window", name="uq_totp_user_code_window"),
|
||||
)
|
||||
29
backend/app/models/user.py
Normal file
29
backend/app/models/user.py
Normal file
@ -0,0 +1,29 @@
|
||||
from sqlalchemy import String, Boolean, Integer, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from datetime import datetime
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# MFA — populated in Track B
|
||||
# String(500) because Fernet-encrypted secrets are longer than raw base32
|
||||
totp_secret: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
|
||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Account lockout
|
||||
failed_login_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(nullable=True, default=None)
|
||||
|
||||
# Account state
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Audit
|
||||
created_at: Mapped[datetime] = mapped_column(default=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)
|
||||
@ -1,134 +1,249 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, Cookie, Request
|
||||
"""
|
||||
Authentication router — username/password with DB-backed sessions and account lockout.
|
||||
|
||||
Session flow:
|
||||
POST /setup → create User + Settings row → issue session cookie
|
||||
POST /login → verify credentials → check lockout → insert UserSession → issue cookie
|
||||
→ if TOTP enabled: return mfa_token instead of full session
|
||||
POST /logout → mark session revoked in DB → delete cookie
|
||||
GET /status → verify user exists + session valid
|
||||
|
||||
Security layers:
|
||||
1. IP-based in-memory rate limit (5 attempts / 5 min) — outer guard, username enumeration
|
||||
2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) — per-user guard
|
||||
3. Session revocation stored in DB (survives container restarts)
|
||||
4. bcrypt→Argon2id transparent upgrade on first login with migrated hash
|
||||
"""
|
||||
import uuid
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Cookie
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional
|
||||
from collections import defaultdict
|
||||
import time
|
||||
import bcrypt
|
||||
from itsdangerous import TimestampSigner, BadSignature
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.session import UserSession
|
||||
from app.models.settings import Settings
|
||||
from app.schemas.settings import SettingsCreate
|
||||
from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest, VerifyPasswordRequest
|
||||
from app.services.auth import (
|
||||
hash_password,
|
||||
verify_password_with_upgrade,
|
||||
create_session_token,
|
||||
verify_session_token,
|
||||
create_mfa_token,
|
||||
)
|
||||
from app.config import settings as app_settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Initialize signer for session management
|
||||
signer = TimestampSigner(app_settings.SECRET_KEY)
|
||||
|
||||
# Brute-force protection: track failed login attempts per IP
|
||||
# ---------------------------------------------------------------------------
|
||||
# IP-based in-memory rate limit (retained as outer layer for all login attempts)
|
||||
# ---------------------------------------------------------------------------
|
||||
_failed_attempts: dict[str, list[float]] = defaultdict(list)
|
||||
_MAX_ATTEMPTS = 5
|
||||
_WINDOW_SECONDS = 300 # 5-minute lockout window
|
||||
|
||||
# Server-side session revocation (in-memory, sufficient for single-user app)
|
||||
_revoked_sessions: set[str] = set()
|
||||
_MAX_IP_ATTEMPTS = 5
|
||||
_IP_WINDOW_SECONDS = 300 # 5 minutes
|
||||
_MAX_TRACKED_IPS = 10000 # cap to prevent unbounded memory growth
|
||||
|
||||
|
||||
def _check_rate_limit(ip: str) -> None:
|
||||
"""Raise 429 if IP has exceeded failed login attempts."""
|
||||
def _check_ip_rate_limit(ip: str) -> None:
|
||||
"""Raise 429 if the IP has exceeded the failure window."""
|
||||
now = time.time()
|
||||
attempts = _failed_attempts[ip]
|
||||
# Prune old entries outside the window
|
||||
_failed_attempts[ip] = [t for t in attempts if now - t < _WINDOW_SECONDS]
|
||||
# Remove the key entirely if no recent attempts remain
|
||||
# Purge all stale entries if the dict is oversized (spray attack defense)
|
||||
if len(_failed_attempts) > _MAX_TRACKED_IPS:
|
||||
stale_ips = [k for k, v in _failed_attempts.items() if all(now - t >= _IP_WINDOW_SECONDS for t in v)]
|
||||
for k in stale_ips:
|
||||
del _failed_attempts[k]
|
||||
# If still over cap after purge, clear everything (all entries are within window
|
||||
# but we can't let memory grow unbounded — login will still hit account lockout)
|
||||
if len(_failed_attempts) > _MAX_TRACKED_IPS:
|
||||
_failed_attempts.clear()
|
||||
_failed_attempts[ip] = [t for t in _failed_attempts[ip] if now - t < _IP_WINDOW_SECONDS]
|
||||
if not _failed_attempts[ip]:
|
||||
del _failed_attempts[ip]
|
||||
elif len(_failed_attempts[ip]) >= _MAX_ATTEMPTS:
|
||||
_failed_attempts.pop(ip, None)
|
||||
elif len(_failed_attempts[ip]) >= _MAX_IP_ATTEMPTS:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many failed login attempts. Try again in a few minutes.",
|
||||
)
|
||||
|
||||
|
||||
def _record_failed_attempt(ip: str) -> None:
|
||||
"""Record a failed login attempt for the given IP."""
|
||||
def _record_ip_failure(ip: str) -> None:
|
||||
_failed_attempts[ip].append(time.time())
|
||||
|
||||
|
||||
def hash_pin(pin: str) -> str:
|
||||
"""Hash a PIN using bcrypt."""
|
||||
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
|
||||
def verify_pin(pin: str, hashed: str) -> bool:
|
||||
"""Verify a PIN against its hash."""
|
||||
return bcrypt.checkpw(pin.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_session_token(user_id: int) -> str:
|
||||
"""Create a signed session token."""
|
||||
return signer.sign(str(user_id)).decode()
|
||||
|
||||
|
||||
def verify_session_token(token: str) -> Optional[int]:
|
||||
"""Verify and extract user ID from session token."""
|
||||
try:
|
||||
unsigned = signer.unsign(token, max_age=86400 * 30) # 30 days
|
||||
return int(unsigned)
|
||||
except (BadSignature, ValueError):
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cookie helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
"""Set the session cookie with secure defaults."""
|
||||
response.set_cookie(
|
||||
key="session",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=app_settings.COOKIE_SECURE,
|
||||
max_age=86400 * 30, # 30 days
|
||||
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
async def get_current_session(
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth dependencies — export get_current_user and get_current_settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
session_cookie: Optional[str] = Cookie(None, alias="session"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
) -> Settings:
|
||||
"""Dependency to verify session and return current settings."""
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> 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:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# Check if session has been revoked
|
||||
if session_cookie in _revoked_sessions:
|
||||
raise HTTPException(status_code=401, detail="Session has been revoked")
|
||||
|
||||
user_id = verify_session_token(session_cookie)
|
||||
if user_id is None:
|
||||
payload = verify_session_token(session_cookie)
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired session")
|
||||
|
||||
result = await db.execute(select(Settings).where(Settings.id == user_id))
|
||||
user_id: int = payload.get("uid")
|
||||
session_id: str = payload.get("sid")
|
||||
if user_id is None or session_id is None:
|
||||
raise HTTPException(status_code=401, detail="Malformed session token")
|
||||
|
||||
# Verify session is active in DB (covers revocation + expiry)
|
||||
session_result = await db.execute(
|
||||
select(UserSession).where(
|
||||
UserSession.id == session_id,
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.revoked == False,
|
||||
UserSession.expires_at > datetime.now(),
|
||||
)
|
||||
)
|
||||
db_session = session_result.scalar_one_or_none()
|
||||
if not db_session:
|
||||
raise HTTPException(status_code=401, detail="Session has been revoked or expired")
|
||||
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.is_active == True)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Settings:
|
||||
"""
|
||||
Convenience dependency for routers that need Settings access.
|
||||
Always chain after get_current_user — never use standalone.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Settings).where(Settings.user_id == current_user.id)
|
||||
)
|
||||
settings_obj = result.scalar_one_or_none()
|
||||
|
||||
if not settings_obj:
|
||||
raise HTTPException(status_code=401, detail="Session invalid")
|
||||
|
||||
raise HTTPException(status_code=500, detail="Settings not found for user")
|
||||
return settings_obj
|
||||
|
||||
|
||||
@router.post("/setup")
|
||||
async def setup_pin(
|
||||
data: SettingsCreate,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create initial PIN. Only works if no settings exist."""
|
||||
result = await db.execute(select(Settings).with_for_update())
|
||||
existing = result.scalar_one_or_none()
|
||||
# ---------------------------------------------------------------------------
|
||||
# Account lockout helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if existing:
|
||||
async def _check_account_lockout(user: User) -> None:
|
||||
"""Raise HTTP 423 if the account is currently locked."""
|
||||
if user.locked_until and datetime.now() < user.locked_until:
|
||||
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=f"Account locked. Try again in {remaining} minutes.",
|
||||
)
|
||||
|
||||
|
||||
async def _record_failed_login(db: AsyncSession, user: User) -> None:
|
||||
"""Increment failure counter; lock account after 10 failures."""
|
||||
user.failed_login_count += 1
|
||||
if user.failed_login_count >= 10:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _record_successful_login(db: AsyncSession, user: User) -> None:
|
||||
"""Reset failure counter and update last_login_at."""
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session creation helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _create_db_session(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
ip: str,
|
||||
user_agent: str | None,
|
||||
) -> tuple[str, str]:
|
||||
"""Insert a UserSession row and return (session_id, signed_cookie_token)."""
|
||||
session_id = uuid.uuid4().hex
|
||||
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
||||
db_session = UserSession(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip[:45] if ip else None, # clamp to column width
|
||||
user_agent=(user_agent or "")[:255] if user_agent else None,
|
||||
)
|
||||
db.add(db_session)
|
||||
await db.commit()
|
||||
token = create_session_token(user.id, session_id)
|
||||
return session_id, token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/setup")
|
||||
async def setup(
|
||||
data: SetupRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
First-time setup: create the User record and a linked Settings row.
|
||||
Only works when no users exist (i.e., fresh install).
|
||||
"""
|
||||
existing = await db.execute(select(User))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Setup already completed")
|
||||
|
||||
pin_hash = hash_pin(data.pin)
|
||||
new_settings = Settings(pin_hash=pin_hash)
|
||||
password_hash = hash_password(data.password)
|
||||
new_user = User(username=data.username, password_hash=password_hash)
|
||||
db.add(new_user)
|
||||
await db.flush() # assign new_user.id before creating Settings
|
||||
|
||||
# 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.refresh(new_settings)
|
||||
|
||||
# Create session
|
||||
token = create_session_token(new_settings.id)
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await _create_db_session(db, new_user, ip, user_agent)
|
||||
_set_session_cookie(response, token)
|
||||
|
||||
return {"message": "Setup completed successfully", "authenticated": True}
|
||||
@ -136,48 +251,91 @@ async def setup_pin(
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
data: SettingsCreate,
|
||||
data: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Verify PIN and create session."""
|
||||
"""
|
||||
Authenticate with username + password.
|
||||
|
||||
Returns:
|
||||
{ authenticated: true } — on success (no TOTP)
|
||||
{ authenticated: false, totp_required: true, mfa_token: "..." } — TOTP pending
|
||||
HTTP 401 — wrong credentials (generic; never reveals which field is wrong)
|
||||
HTTP 423 — account locked
|
||||
HTTP 429 — IP rate limited
|
||||
"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
_check_rate_limit(client_ip)
|
||||
_check_ip_rate_limit(client_ip)
|
||||
|
||||
result = await db.execute(select(Settings))
|
||||
settings_obj = result.scalar_one_or_none()
|
||||
# Lookup user — do NOT differentiate "user not found" from "wrong password"
|
||||
result = await db.execute(select(User).where(User.username == data.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not settings_obj:
|
||||
raise HTTPException(status_code=400, detail="Setup required")
|
||||
if not user:
|
||||
_record_ip_failure(client_ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
if not verify_pin(data.pin, settings_obj.pin_hash):
|
||||
_record_failed_attempt(client_ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid PIN")
|
||||
await _check_account_lockout(user)
|
||||
|
||||
# Clear failed attempts on successful login
|
||||
# Transparent bcrypt→Argon2id upgrade
|
||||
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
|
||||
|
||||
if not valid:
|
||||
_record_ip_failure(client_ip)
|
||||
await _record_failed_login(db, user)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
# Persist upgraded hash if migration happened
|
||||
if new_hash:
|
||||
user.password_hash = new_hash
|
||||
|
||||
# Clear IP failures and update user state
|
||||
_failed_attempts.pop(client_ip, None)
|
||||
await _record_successful_login(db, user)
|
||||
|
||||
# Create session
|
||||
token = create_session_token(settings_obj.id)
|
||||
# If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session
|
||||
if user.totp_enabled:
|
||||
mfa_token = create_mfa_token(user.id)
|
||||
return {
|
||||
"authenticated": False,
|
||||
"totp_required": True,
|
||||
"mfa_token": mfa_token,
|
||||
}
|
||||
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await _create_db_session(db, user, client_ip, user_agent)
|
||||
_set_session_cookie(response, token)
|
||||
|
||||
return {"message": "Login successful", "authenticated": True}
|
||||
return {"authenticated": True}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
response: Response,
|
||||
session_cookie: Optional[str] = Cookie(None, alias="session")
|
||||
session_cookie: Optional[str] = Cookie(None, alias="session"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Clear session cookie and invalidate server-side session."""
|
||||
"""Revoke the current session in DB and clear the cookie."""
|
||||
if session_cookie:
|
||||
_revoked_sessions.add(session_cookie)
|
||||
payload = verify_session_token(session_cookie)
|
||||
if payload:
|
||||
session_id = payload.get("sid")
|
||||
if session_id:
|
||||
result = await db.execute(
|
||||
select(UserSession).where(UserSession.id == session_id)
|
||||
)
|
||||
db_session = result.scalar_one_or_none()
|
||||
if db_session:
|
||||
db_session.revoked = True
|
||||
await db.commit()
|
||||
|
||||
response.delete_cookie(
|
||||
key="session",
|
||||
httponly=True,
|
||||
secure=app_settings.COOKIE_SECURE,
|
||||
samesite="lax"
|
||||
samesite="lax",
|
||||
)
|
||||
return {"message": "Logout successful"}
|
||||
|
||||
@ -185,23 +343,81 @@ async def logout(
|
||||
@router.get("/status")
|
||||
async def auth_status(
|
||||
session_cookie: Optional[str] = Cookie(None, alias="session"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Check authentication status."""
|
||||
result = await db.execute(select(Settings))
|
||||
settings_obj = result.scalar_one_or_none()
|
||||
|
||||
setup_required = settings_obj is None
|
||||
"""
|
||||
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))
|
||||
existing_user = user_result.scalar_one_or_none()
|
||||
setup_required = existing_user is None
|
||||
authenticated = False
|
||||
|
||||
if not setup_required and session_cookie:
|
||||
if session_cookie in _revoked_sessions:
|
||||
authenticated = False
|
||||
else:
|
||||
user_id = verify_session_token(session_cookie)
|
||||
authenticated = user_id is not None
|
||||
payload = verify_session_token(session_cookie)
|
||||
if payload:
|
||||
user_id = payload.get("uid")
|
||||
session_id = payload.get("sid")
|
||||
if user_id and session_id:
|
||||
session_result = await db.execute(
|
||||
select(UserSession).where(
|
||||
UserSession.id == session_id,
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.revoked == False,
|
||||
UserSession.expires_at > datetime.now(),
|
||||
)
|
||||
)
|
||||
authenticated = session_result.scalar_one_or_none() is not None
|
||||
|
||||
return {
|
||||
"authenticated": authenticated,
|
||||
"setup_required": setup_required
|
||||
}
|
||||
return {"authenticated": authenticated, "setup_required": setup_required}
|
||||
|
||||
|
||||
@router.post("/verify-password")
|
||||
async def verify_password(
|
||||
data: VerifyPasswordRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Verify the current user's password without changing anything.
|
||||
Used by the frontend lock screen to re-authenticate without a full login.
|
||||
Also handles transparent bcrypt→Argon2id upgrade.
|
||||
Shares the same rate-limit and lockout guards as /login.
|
||||
"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
_check_ip_rate_limit(client_ip)
|
||||
await _check_account_lockout(current_user)
|
||||
|
||||
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||
if not valid:
|
||||
_record_ip_failure(client_ip)
|
||||
await _record_failed_login(db, current_user)
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
_failed_attempts.pop(client_ip, None)
|
||||
|
||||
# Persist upgraded hash if migration happened
|
||||
if new_hash:
|
||||
current_user.password_hash = new_hash
|
||||
await db.commit()
|
||||
|
||||
return {"verified": True}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
data: ChangePasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Change the current user's password. Requires old password verification."""
|
||||
valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid current password")
|
||||
|
||||
current_user.password_hash = hash_password(data.new_password)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
@ -7,8 +7,8 @@ from app.database import get_db
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse
|
||||
from app.routers.auth import get_current_session
|
||||
from app.models.settings import Settings
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -16,7 +16,7 @@ router = APIRouter()
|
||||
@router.get("/", response_model=List[CalendarResponse])
|
||||
async def get_calendars(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
result = await db.execute(select(Calendar).order_by(Calendar.is_default.desc(), Calendar.name.asc()))
|
||||
return result.scalars().all()
|
||||
@ -26,7 +26,7 @@ async def get_calendars(
|
||||
async def create_calendar(
|
||||
calendar: CalendarCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
new_calendar = Calendar(
|
||||
name=calendar.name,
|
||||
@ -46,7 +46,7 @@ async def update_calendar(
|
||||
calendar_id: int,
|
||||
calendar_update: CalendarUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
result = await db.execute(select(Calendar).where(Calendar.id == calendar_id))
|
||||
calendar = result.scalar_one_or_none()
|
||||
@ -72,7 +72,7 @@ async def update_calendar(
|
||||
async def delete_calendar(
|
||||
calendar_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
result = await db.execute(select(Calendar).where(Calendar.id == calendar_id))
|
||||
calendar = result.scalar_one_or_none()
|
||||
|
||||
@ -10,7 +10,7 @@ from app.models.todo import Todo
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.reminder import Reminder
|
||||
from app.models.project import Project
|
||||
from app.routers.auth import get_current_session
|
||||
from app.routers.auth import get_current_settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -26,11 +26,11 @@ _not_parent_template = or_(
|
||||
async def get_dashboard(
|
||||
client_date: Optional[date] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_settings: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Get aggregated dashboard data."""
|
||||
today = client_date or date.today()
|
||||
upcoming_cutoff = today + timedelta(days=current_user.upcoming_days)
|
||||
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
|
||||
|
||||
# Today's events (exclude parent templates — they are hidden, children are shown)
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
@ -143,7 +143,7 @@ async def get_upcoming(
|
||||
days: int = Query(default=7, ge=1, le=90),
|
||||
client_date: Optional[date] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_settings: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Get unified list of upcoming items (todos, events, reminders) sorted by date."""
|
||||
today = client_date or date.today()
|
||||
|
||||
@ -3,7 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.routers.auth import get_current_session
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.event_template import EventTemplate
|
||||
from app.schemas.event_template import (
|
||||
EventTemplateCreate,
|
||||
@ -17,7 +18,7 @@ router = APIRouter()
|
||||
@router.get("/", response_model=list[EventTemplateResponse])
|
||||
async def list_templates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(EventTemplate).order_by(EventTemplate.name))
|
||||
return result.scalars().all()
|
||||
@ -27,7 +28,7 @@ async def list_templates(
|
||||
async def create_template(
|
||||
payload: EventTemplateCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
template = EventTemplate(**payload.model_dump())
|
||||
db.add(template)
|
||||
@ -41,7 +42,7 @@ async def update_template(
|
||||
template_id: int,
|
||||
payload: EventTemplateUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(EventTemplate).where(EventTemplate.id == template_id)
|
||||
@ -62,7 +63,7 @@ async def update_template(
|
||||
async def delete_template(
|
||||
template_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(EventTemplate).where(EventTemplate.id == template_id)
|
||||
|
||||
@ -16,8 +16,8 @@ from app.schemas.calendar_event import (
|
||||
CalendarEventUpdate,
|
||||
CalendarEventResponse,
|
||||
)
|
||||
from app.routers.auth import get_current_session
|
||||
from app.models.settings import Settings
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.recurrence import generate_occurrences
|
||||
|
||||
router = APIRouter()
|
||||
@ -119,7 +119,7 @@ async def get_events(
|
||||
start: Optional[date] = Query(None),
|
||||
end: Optional[date] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> List[Any]:
|
||||
"""
|
||||
Get all calendar events with optional date range filtering.
|
||||
@ -180,7 +180,7 @@ async def get_events(
|
||||
async def create_event(
|
||||
event: CalendarEventCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
if event.end_datetime < event.start_datetime:
|
||||
raise HTTPException(status_code=400, detail="End datetime must be after start datetime")
|
||||
@ -243,7 +243,7 @@ async def create_event(
|
||||
async def get_event(
|
||||
event_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
@ -263,7 +263,7 @@ async def update_event(
|
||||
event_id: int,
|
||||
event_update: CalendarEventUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
@ -379,7 +379,7 @@ async def delete_event(
|
||||
event_id: int,
|
||||
scope: Optional[Literal["this", "this_and_future"]] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
@ -12,8 +12,8 @@ import logging
|
||||
from app.database import get_db
|
||||
from app.models.location import Location
|
||||
from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse, LocationSearchResult
|
||||
from app.routers.auth import get_current_session
|
||||
from app.models.settings import Settings
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -24,7 +24,7 @@ router = APIRouter()
|
||||
async def search_locations(
|
||||
q: str = Query(..., min_length=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Search locations from local DB and Nominatim OSM."""
|
||||
results: List[LocationSearchResult] = []
|
||||
@ -86,7 +86,7 @@ async def search_locations(
|
||||
async def get_locations(
|
||||
category: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all locations with optional category filter."""
|
||||
query = select(Location)
|
||||
@ -106,7 +106,7 @@ async def get_locations(
|
||||
async def create_location(
|
||||
location: LocationCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new location."""
|
||||
new_location = Location(**location.model_dump())
|
||||
@ -121,7 +121,7 @@ async def create_location(
|
||||
async def get_location(
|
||||
location_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a specific location by ID."""
|
||||
result = await db.execute(select(Location).where(Location.id == location_id))
|
||||
@ -138,7 +138,7 @@ async def update_location(
|
||||
location_id: int,
|
||||
location_update: LocationUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a location."""
|
||||
result = await db.execute(select(Location).where(Location.id == location_id))
|
||||
@ -165,7 +165,7 @@ async def update_location(
|
||||
async def delete_location(
|
||||
location_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a location."""
|
||||
result = await db.execute(select(Location).where(Location.id == location_id))
|
||||
|
||||
@ -7,8 +7,8 @@ from typing import Optional, List
|
||||
from app.database import get_db
|
||||
from app.models.person import Person
|
||||
from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
|
||||
from app.routers.auth import get_current_session
|
||||
from app.models.settings import Settings
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -34,7 +34,7 @@ async def get_people(
|
||||
search: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all people with optional search and category filter."""
|
||||
query = select(Person)
|
||||
@ -66,7 +66,7 @@ async def get_people(
|
||||
async def create_person(
|
||||
person: PersonCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new person with denormalised display name."""
|
||||
data = person.model_dump()
|
||||
@ -93,7 +93,7 @@ async def create_person(
|
||||
async def get_person(
|
||||
person_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a specific person by ID."""
|
||||
result = await db.execute(select(Person).where(Person.id == person_id))
|
||||
@ -110,7 +110,7 @@ async def update_person(
|
||||
person_id: int,
|
||||
person_update: PersonUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a person and refresh the denormalised display name."""
|
||||
result = await db.execute(select(Person).where(Person.id == person_id))
|
||||
@ -144,7 +144,7 @@ async def update_person(
|
||||
async def delete_person(
|
||||
person_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a person."""
|
||||
result = await db.execute(select(Person).where(Person.id == person_id))
|
||||
|
||||
@ -13,8 +13,8 @@ from app.models.task_comment import TaskComment
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse
|
||||
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
|
||||
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse
|
||||
from app.routers.auth import get_current_session
|
||||
from app.models.settings import Settings
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -46,7 +46,7 @@ def _task_load_options():
|
||||
async def get_projects(
|
||||
tracked: Optional[bool] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all projects with their tasks. Optionally filter by tracked status."""
|
||||
query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc())
|
||||
@ -63,7 +63,7 @@ async def get_projects(
|
||||
async def get_tracked_tasks(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get tasks and subtasks from tracked projects with due dates within the next N days."""
|
||||
today = date.today()
|
||||
@ -107,7 +107,7 @@ async def get_tracked_tasks(
|
||||
async def create_project(
|
||||
project: ProjectCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new project."""
|
||||
new_project = Project(**project.model_dump())
|
||||
@ -124,7 +124,7 @@ async def create_project(
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a specific project by ID with its tasks."""
|
||||
query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
|
||||
@ -142,7 +142,7 @@ async def update_project(
|
||||
project_id: int,
|
||||
project_update: ProjectUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a project."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@ -168,7 +168,7 @@ async def update_project(
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a project and all its tasks."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@ -187,7 +187,7 @@ async def delete_project(
|
||||
async def get_project_tasks(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get top-level tasks for a specific project (subtasks are nested)."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@ -216,7 +216,7 @@ async def create_project_task(
|
||||
project_id: int,
|
||||
task: ProjectTaskCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new task or subtask for a project."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@ -262,7 +262,7 @@ async def reorder_tasks(
|
||||
project_id: int,
|
||||
items: List[ReorderItem],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Bulk update sort_order for tasks."""
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
@ -293,7 +293,7 @@ async def update_project_task(
|
||||
task_id: int,
|
||||
task_update: ProjectTaskUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a project task."""
|
||||
result = await db.execute(
|
||||
@ -329,7 +329,7 @@ async def delete_project_task(
|
||||
project_id: int,
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a project task (cascades to subtasks)."""
|
||||
result = await db.execute(
|
||||
@ -355,7 +355,7 @@ async def create_task_comment(
|
||||
task_id: int,
|
||||
comment: TaskCommentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Add a comment to a task."""
|
||||
result = await db.execute(
|
||||
@ -383,7 +383,7 @@ async def delete_task_comment(
|
||||
task_id: int,
|
||||
comment_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a task comment."""
|
||||
result = await db.execute(
|
||||
|
||||
@ -8,8 +8,8 @@ from typing import Optional, List
|
||||
from app.database import get_db
|
||||
from app.models.reminder import Reminder
|
||||
from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse, ReminderSnooze
|
||||
from app.routers.auth import get_current_session
|
||||
from app.models.settings import Settings
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -19,7 +19,7 @@ async def get_reminders(
|
||||
active: Optional[bool] = Query(None),
|
||||
dismissed: Optional[bool] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all reminders with optional filters."""
|
||||
query = select(Reminder)
|
||||
@ -42,7 +42,7 @@ async def get_reminders(
|
||||
async def get_due_reminders(
|
||||
client_now: Optional[datetime] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get reminders that are currently due for alerting."""
|
||||
now = client_now or datetime.now()
|
||||
@ -71,7 +71,7 @@ async def snooze_reminder(
|
||||
reminder_id: int,
|
||||
body: ReminderSnooze,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Snooze a reminder for N minutes from now."""
|
||||
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||
@ -96,7 +96,7 @@ async def snooze_reminder(
|
||||
async def create_reminder(
|
||||
reminder: ReminderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new reminder."""
|
||||
new_reminder = Reminder(**reminder.model_dump())
|
||||
@ -111,7 +111,7 @@ async def create_reminder(
|
||||
async def get_reminder(
|
||||
reminder_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a specific reminder by ID."""
|
||||
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||
@ -128,7 +128,7 @@ async def update_reminder(
|
||||
reminder_id: int,
|
||||
reminder_update: ReminderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a reminder."""
|
||||
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||
@ -161,7 +161,7 @@ async def update_reminder(
|
||||
async def delete_reminder(
|
||||
reminder_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a reminder."""
|
||||
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||
@ -180,7 +180,7 @@ async def delete_reminder(
|
||||
async def dismiss_reminder(
|
||||
reminder_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Dismiss a reminder."""
|
||||
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
|
||||
|
||||
@ -1,54 +1,120 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.settings import Settings
|
||||
from app.schemas.settings import SettingsUpdate, SettingsResponse, ChangePinRequest
|
||||
from app.routers.auth import get_current_session, hash_pin, verify_pin
|
||||
from app.models.user import User
|
||||
from app.schemas.settings import SettingsUpdate, SettingsResponse
|
||||
from app.routers.auth import get_current_user, get_current_settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_settings_response(s: Settings) -> SettingsResponse:
|
||||
"""
|
||||
Explicitly construct SettingsResponse, computing ntfy_has_token
|
||||
from the stored token without ever exposing the token value.
|
||||
"""
|
||||
return SettingsResponse(
|
||||
id=s.id,
|
||||
user_id=s.user_id,
|
||||
accent_color=s.accent_color,
|
||||
upcoming_days=s.upcoming_days,
|
||||
preferred_name=s.preferred_name,
|
||||
weather_city=s.weather_city,
|
||||
weather_lat=s.weather_lat,
|
||||
weather_lon=s.weather_lon,
|
||||
first_day_of_week=s.first_day_of_week,
|
||||
ntfy_server_url=s.ntfy_server_url,
|
||||
ntfy_topic=s.ntfy_topic,
|
||||
ntfy_enabled=s.ntfy_enabled,
|
||||
ntfy_events_enabled=s.ntfy_events_enabled,
|
||||
ntfy_reminders_enabled=s.ntfy_reminders_enabled,
|
||||
ntfy_todos_enabled=s.ntfy_todos_enabled,
|
||||
ntfy_projects_enabled=s.ntfy_projects_enabled,
|
||||
ntfy_event_lead_minutes=s.ntfy_event_lead_minutes,
|
||||
ntfy_todo_lead_days=s.ntfy_todo_lead_days,
|
||||
ntfy_project_lead_days=s.ntfy_project_lead_days,
|
||||
ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value
|
||||
auto_lock_enabled=s.auto_lock_enabled,
|
||||
auto_lock_minutes=s.auto_lock_minutes,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=SettingsResponse)
|
||||
async def get_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_settings: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Get current settings (excluding PIN hash)."""
|
||||
return current_user
|
||||
"""Get current settings (excluding ntfy auth token)."""
|
||||
return _to_settings_response(current_settings)
|
||||
|
||||
|
||||
@router.put("/", response_model=SettingsResponse)
|
||||
async def update_settings(
|
||||
settings_update: SettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_settings: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Update settings (accent color, upcoming days)."""
|
||||
"""Update settings."""
|
||||
update_data = settings_update.model_dump(exclude_unset=True)
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(current_user, key, value)
|
||||
setattr(current_settings, key, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
await db.refresh(current_settings)
|
||||
|
||||
return current_user
|
||||
return _to_settings_response(current_settings)
|
||||
|
||||
|
||||
@router.put("/pin")
|
||||
async def change_pin(
|
||||
pin_change: ChangePinRequest,
|
||||
@router.post("/ntfy/test")
|
||||
async def test_ntfy(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_settings: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Change PIN. Requires old PIN verification."""
|
||||
if not verify_pin(pin_change.old_pin, current_user.pin_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid old PIN")
|
||||
"""
|
||||
Send a test ntfy notification to verify the user's configuration.
|
||||
Requires ntfy_server_url and ntfy_topic to be set.
|
||||
Note: ntfy_enabled does not need to be True to run the test — the service
|
||||
call bypasses that check because we pass settings directly.
|
||||
"""
|
||||
if not current_settings.ntfy_server_url or not current_settings.ntfy_topic:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ntfy server URL and topic must be configured before sending a test"
|
||||
)
|
||||
|
||||
current_user.pin_hash = hash_pin(pin_change.new_pin)
|
||||
# SSRF-validate the URL before attempting the outbound request
|
||||
from app.services.ntfy import validate_ntfy_host, send_ntfy_notification
|
||||
try:
|
||||
validate_ntfy_host(current_settings.ntfy_server_url)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
# Temporarily treat ntfy as enabled for the test send even if master switch is off
|
||||
class _TestSettings:
|
||||
"""Thin wrapper that forces ntfy_enabled=True for the test call."""
|
||||
ntfy_enabled = True
|
||||
ntfy_server_url = current_settings.ntfy_server_url
|
||||
ntfy_topic = current_settings.ntfy_topic
|
||||
ntfy_auth_token = current_settings.ntfy_auth_token
|
||||
|
||||
return {"message": "PIN changed successfully"}
|
||||
success = await send_ntfy_notification(
|
||||
settings=_TestSettings(), # type: ignore[arg-type]
|
||||
title="UMBRA Test Notification",
|
||||
message="If you see this, your ntfy integration is working correctly.",
|
||||
tags=["white_check_mark"],
|
||||
priority=3,
|
||||
click_url="http://10.0.69.35",
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Failed to deliver test notification — check server URL, topic, and auth token"
|
||||
)
|
||||
|
||||
return {"message": "Test notification sent successfully"}
|
||||
|
||||
@ -8,7 +8,8 @@ import calendar
|
||||
from app.database import get_db
|
||||
from app.models.todo import Todo
|
||||
from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
|
||||
from app.routers.auth import get_current_session
|
||||
from app.routers.auth import get_current_user, get_current_settings
|
||||
from app.models.user import User
|
||||
from app.models.settings import Settings
|
||||
|
||||
router = APIRouter()
|
||||
@ -109,7 +110,7 @@ async def get_todos(
|
||||
category: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Get all todos with optional filters."""
|
||||
# Reactivate any recurring todos whose reset time has passed
|
||||
@ -143,7 +144,7 @@ async def get_todos(
|
||||
async def create_todo(
|
||||
todo: TodoCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Create a new todo."""
|
||||
new_todo = Todo(**todo.model_dump())
|
||||
@ -158,7 +159,7 @@ async def create_todo(
|
||||
async def get_todo(
|
||||
todo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Get a specific todo by ID."""
|
||||
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||
@ -175,7 +176,7 @@ async def update_todo(
|
||||
todo_id: int,
|
||||
todo_update: TodoUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Update a todo."""
|
||||
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||
@ -228,7 +229,7 @@ async def update_todo(
|
||||
async def delete_todo(
|
||||
todo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Delete a todo."""
|
||||
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||
@ -247,7 +248,7 @@ async def delete_todo(
|
||||
async def toggle_todo(
|
||||
todo_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Toggle todo completion status. For recurring todos, calculates reset schedule."""
|
||||
result = await db.execute(select(Todo).where(Todo.id == todo_id))
|
||||
|
||||
416
backend/app/routers/totp.py
Normal file
416
backend/app/routers/totp.py
Normal file
@ -0,0 +1,416 @@
|
||||
"""
|
||||
TOTP MFA router.
|
||||
|
||||
Endpoints (all under /api/auth — registered in main.py with prefix="/api/auth"):
|
||||
|
||||
POST /totp/setup — Generate secret + QR + backup codes (auth required)
|
||||
POST /totp/confirm — Verify first code, enable TOTP (auth required)
|
||||
POST /totp-verify — MFA challenge: mfa_token + TOTP/backup code, issues session
|
||||
POST /totp/disable — Disable TOTP (auth required, needs password + code)
|
||||
POST /totp/backup-codes/regenerate — Regenerate backup codes (auth required, needs password + code)
|
||||
GET /totp/status — { enabled, backup_codes_remaining } (auth required)
|
||||
|
||||
Security:
|
||||
- TOTP secrets encrypted at rest (Fernet/AES-128-CBC, key derived from SECRET_KEY)
|
||||
- Replay prevention via totp_usage table (unique on user_id+code+window)
|
||||
- Backup codes hashed with Argon2id, shown plaintext once only
|
||||
- Failed TOTP attempts increment user.failed_login_count (shared lockout counter)
|
||||
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
||||
"""
|
||||
import uuid
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.session import UserSession
|
||||
from app.models.totp_usage import TOTPUsage
|
||||
from app.models.backup_code import BackupCode
|
||||
from app.routers.auth import get_current_user, _set_session_cookie
|
||||
from app.services.auth import (
|
||||
verify_password_with_upgrade,
|
||||
hash_password,
|
||||
verify_mfa_token,
|
||||
create_session_token,
|
||||
)
|
||||
from app.services.totp import (
|
||||
generate_totp_secret,
|
||||
encrypt_totp_secret,
|
||||
decrypt_totp_secret,
|
||||
get_totp_uri,
|
||||
verify_totp_code,
|
||||
generate_qr_base64,
|
||||
generate_backup_codes,
|
||||
)
|
||||
from app.config import settings as app_settings
|
||||
|
||||
# Argon2id for backup code hashing — treat each code like a password
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Argon2id instance for backup code hashes (same params as password hashing)
|
||||
_ph = PasswordHasher(
|
||||
time_cost=2,
|
||||
memory_cost=19456,
|
||||
parallelism=1,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TOTPConfirmRequest(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class TOTPVerifyRequest(BaseModel):
|
||||
mfa_token: str
|
||||
code: Optional[str] = None # 6-digit TOTP code
|
||||
backup_code: Optional[str] = None # Alternative: XXXX-XXXX backup code
|
||||
|
||||
|
||||
class TOTPDisableRequest(BaseModel):
|
||||
password: str
|
||||
code: str # Current TOTP code required to disable
|
||||
|
||||
|
||||
class BackupCodesRegenerateRequest(BaseModel):
|
||||
password: str
|
||||
code: str # Current TOTP code required to regenerate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _store_backup_codes(db: AsyncSession, user_id: int, plaintext_codes: list[str]) -> None:
|
||||
"""Hash and insert backup codes for the given user."""
|
||||
for code in plaintext_codes:
|
||||
code_hash = _ph.hash(code)
|
||||
db.add(BackupCode(user_id=user_id, code_hash=code_hash))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _delete_backup_codes(db: AsyncSession, user_id: int) -> None:
|
||||
"""Delete all backup codes for a user."""
|
||||
await db.execute(delete(BackupCode).where(BackupCode.user_id == user_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _verify_backup_code(
|
||||
db: AsyncSession, user_id: int, submitted_code: str
|
||||
) -> bool:
|
||||
"""
|
||||
Check submitted backup code against all unused hashes for the user.
|
||||
On match, marks the code as used. Returns True if a valid unused code was found.
|
||||
Uses Argon2id verification — constant-time by design.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(BackupCode).where(
|
||||
BackupCode.user_id == user_id,
|
||||
BackupCode.used_at.is_(None),
|
||||
)
|
||||
)
|
||||
unused_codes = result.scalars().all()
|
||||
|
||||
for record in unused_codes:
|
||||
try:
|
||||
if _ph.verify(record.code_hash, submitted_code):
|
||||
record.used_at = datetime.now()
|
||||
await db.commit()
|
||||
return True
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def _create_full_session(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
request: Request,
|
||||
) -> str:
|
||||
"""Create a UserSession row and return the signed cookie token."""
|
||||
session_id = uuid.uuid4().hex
|
||||
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
||||
ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent")
|
||||
|
||||
db_session = UserSession(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip[:45] if ip else None,
|
||||
user_agent=(user_agent or "")[:255] if user_agent else None,
|
||||
)
|
||||
db.add(db_session)
|
||||
await db.commit()
|
||||
return create_session_token(user.id, session_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/totp/setup")
|
||||
async def totp_setup(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Generate a new TOTP secret, QR code, and backup codes.
|
||||
Stores the encrypted secret with totp_enabled=False until confirmed.
|
||||
|
||||
Idempotent: calling again before confirmation overwrites the unconfirmed secret,
|
||||
so browser refreshes mid-setup generate a fresh QR without error.
|
||||
|
||||
Returns { secret, qr_code_base64, backup_codes } — the only time plaintext
|
||||
values are shown. The `secret` field is the raw base32 for manual entry.
|
||||
"""
|
||||
# Generate new secret (idempotent — overwrite any existing unconfirmed secret)
|
||||
raw_secret = generate_totp_secret()
|
||||
encrypted_secret = encrypt_totp_secret(raw_secret)
|
||||
|
||||
current_user.totp_secret = encrypted_secret
|
||||
current_user.totp_enabled = False # Not enabled until /confirm called
|
||||
|
||||
# Generate backup codes — hash before storage, return plaintext once
|
||||
plaintext_codes = generate_backup_codes(10)
|
||||
await _delete_backup_codes(db, current_user.id) # Remove any previous unconfirmed codes
|
||||
await _store_backup_codes(db, current_user.id, plaintext_codes)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Build QR code from provisioning URI
|
||||
uri = get_totp_uri(encrypted_secret, current_user.username)
|
||||
qr_base64 = generate_qr_base64(uri)
|
||||
|
||||
return {
|
||||
"secret": raw_secret, # Raw base32 for manual authenticator entry
|
||||
"qr_code_base64": qr_base64, # PNG QR code, data:image/png;base64,...
|
||||
"backup_codes": plaintext_codes, # Shown once — user must save these
|
||||
}
|
||||
|
||||
|
||||
@router.post("/totp/confirm")
|
||||
async def totp_confirm(
|
||||
data: TOTPConfirmRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Verify the first TOTP code from the authenticator app and enable TOTP.
|
||||
Must be called after /setup while totp_enabled is still False.
|
||||
"""
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(status_code=400, detail="TOTP setup not started — call /setup first")
|
||||
|
||||
if current_user.totp_enabled:
|
||||
raise HTTPException(status_code=400, detail="TOTP is already enabled")
|
||||
|
||||
matched_window = verify_totp_code(current_user.totp_secret, data.code)
|
||||
if matched_window is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid code — check your authenticator app time sync")
|
||||
|
||||
current_user.totp_enabled = True
|
||||
await db.commit()
|
||||
|
||||
return {"message": "TOTP enabled successfully"}
|
||||
|
||||
|
||||
@router.post("/totp-verify")
|
||||
async def totp_verify(
|
||||
data: TOTPVerifyRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
MFA challenge endpoint — called after a successful password login when TOTP is enabled.
|
||||
Accepts either a 6-digit TOTP code or a backup recovery code.
|
||||
|
||||
Uses the short-lived mfa_token (from POST /login) instead of a session cookie
|
||||
because the user is not yet fully authenticated at this stage.
|
||||
|
||||
On success: issues a full session cookie and returns { authenticated: true }.
|
||||
"""
|
||||
if not data.code and not data.backup_code:
|
||||
raise HTTPException(status_code=422, detail="Provide either 'code' or 'backup_code'")
|
||||
|
||||
# Validate the MFA challenge token (5-minute TTL)
|
||||
user_id = verify_mfa_token(data.mfa_token)
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=401, detail="MFA session expired — please log in again")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id, User.is_active == True))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||
|
||||
if not user.totp_enabled or not user.totp_secret:
|
||||
raise HTTPException(status_code=400, detail="TOTP not configured for this account")
|
||||
|
||||
# Check account lockout (shared counter with password failures)
|
||||
if user.locked_until and datetime.now() < user.locked_until:
|
||||
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=f"Account locked. Try again in {remaining} minutes.",
|
||||
)
|
||||
|
||||
# --- Backup code path ---
|
||||
if data.backup_code:
|
||||
normalized = data.backup_code.strip().upper()
|
||||
valid = await _verify_backup_code(db, user.id, normalized)
|
||||
if not valid:
|
||||
user.failed_login_count += 1
|
||||
if user.failed_login_count >= 10:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=401, detail="Invalid backup code")
|
||||
|
||||
# Backup code accepted — reset lockout counter and issue session
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
token = await _create_full_session(db, user, request)
|
||||
_set_session_cookie(response, token)
|
||||
return {"authenticated": True}
|
||||
|
||||
# --- TOTP code path ---
|
||||
matched_window = verify_totp_code(user.totp_secret, data.code)
|
||||
if matched_window is None:
|
||||
user.failed_login_count += 1
|
||||
if user.failed_login_count >= 10:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=401, detail="Invalid code")
|
||||
|
||||
# Replay prevention — record (user_id, code, actual_matching_window)
|
||||
totp_record = TOTPUsage(user_id=user.id, code=data.code, window=matched_window)
|
||||
db.add(totp_record)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=401, detail="Code already used — wait for the next code")
|
||||
|
||||
# Success — reset lockout counter, update last_login_at, issue full session
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
token = await _create_full_session(db, user, request)
|
||||
_set_session_cookie(response, token)
|
||||
return {"authenticated": True}
|
||||
|
||||
|
||||
@router.post("/totp/disable")
|
||||
async def totp_disable(
|
||||
data: TOTPDisableRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Disable TOTP for the current user.
|
||||
Requires both current password AND a valid TOTP code as confirmation.
|
||||
Clears totp_secret, sets totp_enabled=False, and deletes all backup codes.
|
||||
"""
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||
|
||||
# Verify password (handles bcrypt→Argon2id upgrade transparently)
|
||||
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
if new_hash:
|
||||
current_user.password_hash = new_hash
|
||||
|
||||
# Verify TOTP code — both checks required for disable
|
||||
matched_window = verify_totp_code(current_user.totp_secret, data.code)
|
||||
if matched_window is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid TOTP code")
|
||||
|
||||
# All checks passed — disable TOTP
|
||||
current_user.totp_secret = None
|
||||
current_user.totp_enabled = False
|
||||
await _delete_backup_codes(db, current_user.id)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "TOTP disabled successfully"}
|
||||
|
||||
|
||||
@router.post("/totp/backup-codes/regenerate")
|
||||
async def regenerate_backup_codes(
|
||||
data: BackupCodesRegenerateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Regenerate backup recovery codes.
|
||||
Requires current password AND a valid TOTP code.
|
||||
Deletes all existing backup codes and generates 10 fresh ones.
|
||||
Returns plaintext codes once — never retrievable again.
|
||||
"""
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||
|
||||
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
if new_hash:
|
||||
current_user.password_hash = new_hash
|
||||
await db.commit()
|
||||
|
||||
matched_window = verify_totp_code(current_user.totp_secret, data.code)
|
||||
if matched_window is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid TOTP code")
|
||||
|
||||
# Regenerate
|
||||
plaintext_codes = generate_backup_codes(10)
|
||||
await _delete_backup_codes(db, current_user.id)
|
||||
await _store_backup_codes(db, current_user.id, plaintext_codes)
|
||||
|
||||
return {"backup_codes": plaintext_codes}
|
||||
|
||||
|
||||
@router.get("/totp/status")
|
||||
async def totp_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return TOTP enabled state and count of remaining (unused) backup codes."""
|
||||
remaining = 0
|
||||
if current_user.totp_enabled:
|
||||
result = await db.execute(
|
||||
select(BackupCode).where(
|
||||
BackupCode.user_id == current_user.id,
|
||||
BackupCode.used_at.is_(None),
|
||||
)
|
||||
)
|
||||
remaining = len(result.scalars().all())
|
||||
|
||||
return {
|
||||
"enabled": current_user.totp_enabled,
|
||||
"backup_codes_remaining": remaining,
|
||||
}
|
||||
@ -12,7 +12,8 @@ import json
|
||||
from app.database import get_db
|
||||
from app.models.settings import Settings
|
||||
from app.config import settings as app_settings
|
||||
from app.routers.auth import get_current_session
|
||||
from app.routers.auth import get_current_user, get_current_settings
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -35,7 +36,7 @@ def _fetch_json(url: str) -> dict:
|
||||
@router.get("/search", response_model=list[GeoSearchResult])
|
||||
async def search_locations(
|
||||
q: str = Query(..., min_length=1, max_length=100),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
api_key = app_settings.OPENWEATHERMAP_API_KEY
|
||||
if not api_key:
|
||||
@ -65,14 +66,11 @@ async def search_locations(
|
||||
@router.get("/")
|
||||
async def get_weather(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: Settings = Depends(get_current_session)
|
||||
current_user: Settings = Depends(get_current_settings)
|
||||
):
|
||||
# Get settings
|
||||
result = await db.execute(select(Settings))
|
||||
settings_row = result.scalar_one_or_none()
|
||||
city = settings_row.weather_city if settings_row else None
|
||||
lat = settings_row.weather_lat if settings_row else None
|
||||
lon = settings_row.weather_lon if settings_row else None
|
||||
city = current_user.weather_city
|
||||
lat = current_user.weather_lat
|
||||
lon = current_user.weather_lon
|
||||
|
||||
if not city and (lat is None or lon is None):
|
||||
raise HTTPException(status_code=400, detail="No weather location configured")
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from app.schemas.settings import SettingsCreate, SettingsUpdate, SettingsResponse, ChangePinRequest
|
||||
from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest, VerifyPasswordRequest
|
||||
from app.schemas.settings import SettingsUpdate, SettingsResponse
|
||||
from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse
|
||||
from app.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse
|
||||
from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse
|
||||
@ -8,10 +9,12 @@ from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
|
||||
from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse
|
||||
|
||||
__all__ = [
|
||||
"SettingsCreate",
|
||||
"SetupRequest",
|
||||
"LoginRequest",
|
||||
"ChangePasswordRequest",
|
||||
"VerifyPasswordRequest",
|
||||
"SettingsUpdate",
|
||||
"SettingsResponse",
|
||||
"ChangePinRequest",
|
||||
"TodoCreate",
|
||||
"TodoUpdate",
|
||||
"TodoResponse",
|
||||
|
||||
73
backend/app/schemas/auth.py
Normal file
73
backend/app/schemas/auth.py
Normal file
@ -0,0 +1,73 @@
|
||||
import re
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
def _validate_password_strength(v: str) -> str:
|
||||
"""
|
||||
Shared password validation (OWASP ASVS v4 Level 1).
|
||||
- Minimum 12 chars (OWASP minimum)
|
||||
- Maximum 128 chars (prevents DoS via large input to argon2)
|
||||
- Must contain at least one letter and one non-letter
|
||||
- No complexity rules per NIST SP 800-63B
|
||||
"""
|
||||
if len(v) < 12:
|
||||
raise ValueError("Password must be at least 12 characters")
|
||||
if len(v) > 128:
|
||||
raise ValueError("Password must be 128 characters or fewer")
|
||||
if not re.search(r"[A-Za-z]", v):
|
||||
raise ValueError("Password must contain at least one letter")
|
||||
if not re.search(r"[^A-Za-z]", v):
|
||||
raise ValueError("Password must contain at least one non-letter character")
|
||||
return v
|
||||
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, v: str) -> str:
|
||||
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
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
return _validate_password_strength(v)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def normalize_username(cls, v: str) -> str:
|
||||
"""Normalise to lowercase so 'Admin' and 'admin' resolve to the same user."""
|
||||
return v.strip().lower()
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def validate_new_password(cls, v: str) -> str:
|
||||
return _validate_password_strength(v)
|
||||
|
||||
|
||||
class VerifyPasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_length(cls, v: str) -> str:
|
||||
if len(v) > 128:
|
||||
raise ValueError("Password must be 128 characters or fewer")
|
||||
return v
|
||||
@ -1,25 +1,11 @@
|
||||
import re
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
AccentColor = Literal["cyan", "blue", "green", "purple", "red", "orange", "pink", "yellow"]
|
||||
|
||||
|
||||
def _validate_pin_length(v: str, label: str = "PIN") -> str:
|
||||
if len(v) < 4:
|
||||
raise ValueError(f'{label} must be at least 4 characters')
|
||||
if len(v) > 72:
|
||||
raise ValueError(f'{label} must be at most 72 characters')
|
||||
return v
|
||||
|
||||
|
||||
class SettingsCreate(BaseModel):
|
||||
pin: str
|
||||
|
||||
@field_validator('pin')
|
||||
@classmethod
|
||||
def pin_length(cls, v: str) -> str:
|
||||
return _validate_pin_length(v)
|
||||
_NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
@ -31,6 +17,31 @@ class SettingsUpdate(BaseModel):
|
||||
weather_lon: float | None = None
|
||||
first_day_of_week: int | None = None
|
||||
|
||||
# ntfy configuration fields
|
||||
ntfy_server_url: Optional[str] = None
|
||||
ntfy_topic: Optional[str] = None
|
||||
# Empty string means "clear the token"; None means "leave unchanged"
|
||||
ntfy_auth_token: Optional[str] = None
|
||||
ntfy_enabled: Optional[bool] = None
|
||||
ntfy_events_enabled: Optional[bool] = None
|
||||
ntfy_reminders_enabled: Optional[bool] = None
|
||||
ntfy_todos_enabled: Optional[bool] = None
|
||||
ntfy_projects_enabled: Optional[bool] = None
|
||||
ntfy_event_lead_minutes: Optional[int] = None
|
||||
ntfy_todo_lead_days: Optional[int] = None
|
||||
ntfy_project_lead_days: Optional[int] = None
|
||||
|
||||
# Auto-lock settings
|
||||
auto_lock_enabled: Optional[bool] = None
|
||||
auto_lock_minutes: Optional[int] = None
|
||||
|
||||
@field_validator('auto_lock_minutes')
|
||||
@classmethod
|
||||
def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]:
|
||||
if v is not None and not (1 <= v <= 60):
|
||||
raise ValueError("auto_lock_minutes must be between 1 and 60")
|
||||
return v
|
||||
|
||||
@field_validator('first_day_of_week')
|
||||
@classmethod
|
||||
def validate_first_day(cls, v: int | None) -> int | None:
|
||||
@ -52,9 +63,66 @@ class SettingsUpdate(BaseModel):
|
||||
raise ValueError('Longitude must be between -180 and 180')
|
||||
return v
|
||||
|
||||
@field_validator('ntfy_server_url')
|
||||
@classmethod
|
||||
def validate_ntfy_url(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(v)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("ntfy server URL must use http or https")
|
||||
if not parsed.netloc:
|
||||
raise ValueError("ntfy server URL must include a hostname")
|
||||
# Strip trailing slash — ntfy base URL must not have one
|
||||
return v.rstrip("/")
|
||||
|
||||
@field_validator('ntfy_topic')
|
||||
@classmethod
|
||||
def validate_ntfy_topic(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if not _NTFY_TOPIC_RE.match(v):
|
||||
raise ValueError(
|
||||
"ntfy topic must be 1-64 alphanumeric characters, hyphens, or underscores"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator('ntfy_auth_token')
|
||||
@classmethod
|
||||
def validate_ntfy_token(cls, v: Optional[str]) -> Optional[str]:
|
||||
# Empty string signals "clear the token" — normalise to None for storage
|
||||
if v == "":
|
||||
return None
|
||||
if v is not None and len(v) > 500:
|
||||
raise ValueError("ntfy auth token must be at most 500 characters")
|
||||
return v
|
||||
|
||||
@field_validator('ntfy_event_lead_minutes')
|
||||
@classmethod
|
||||
def validate_event_lead(cls, v: Optional[int]) -> Optional[int]:
|
||||
if v is not None and not (1 <= v <= 1440):
|
||||
raise ValueError("ntfy_event_lead_minutes must be between 1 and 1440")
|
||||
return v
|
||||
|
||||
@field_validator('ntfy_todo_lead_days')
|
||||
@classmethod
|
||||
def validate_todo_lead(cls, v: Optional[int]) -> Optional[int]:
|
||||
if v is not None and not (0 <= v <= 30):
|
||||
raise ValueError("ntfy_todo_lead_days must be between 0 and 30")
|
||||
return v
|
||||
|
||||
@field_validator('ntfy_project_lead_days')
|
||||
@classmethod
|
||||
def validate_project_lead(cls, v: Optional[int]) -> Optional[int]:
|
||||
if v is not None and not (0 <= v <= 30):
|
||||
raise ValueError("ntfy_project_lead_days must be between 0 and 30")
|
||||
return v
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
accent_color: str
|
||||
upcoming_days: int
|
||||
preferred_name: str | None = None
|
||||
@ -62,17 +130,26 @@ class SettingsResponse(BaseModel):
|
||||
weather_lat: float | None = None
|
||||
weather_lon: float | None = None
|
||||
first_day_of_week: int = 0
|
||||
|
||||
# ntfy fields — ntfy_auth_token is NEVER included here (security requirement 6.2)
|
||||
ntfy_server_url: Optional[str] = None
|
||||
ntfy_topic: Optional[str] = None
|
||||
ntfy_enabled: bool = False
|
||||
ntfy_events_enabled: bool = True
|
||||
ntfy_reminders_enabled: bool = True
|
||||
ntfy_todos_enabled: bool = True
|
||||
ntfy_projects_enabled: bool = True
|
||||
ntfy_event_lead_minutes: int = 15
|
||||
ntfy_todo_lead_days: int = 1
|
||||
ntfy_project_lead_days: int = 2
|
||||
# Derived field: computed via Settings.ntfy_has_token property (from_attributes reads it)
|
||||
ntfy_has_token: bool = False
|
||||
|
||||
# Auto-lock settings
|
||||
auto_lock_enabled: bool = False
|
||||
auto_lock_minutes: int = 5
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ChangePinRequest(BaseModel):
|
||||
old_pin: str
|
||||
new_pin: str
|
||||
|
||||
@field_validator('new_pin')
|
||||
@classmethod
|
||||
def new_pin_length(cls, v: str) -> str:
|
||||
return _validate_pin_length(v, "New PIN")
|
||||
|
||||
128
backend/app/services/auth.py
Normal file
128
backend/app/services/auth.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""
|
||||
Authentication service: password hashing, session tokens, MFA tokens.
|
||||
|
||||
Password strategy:
|
||||
- New passwords: Argon2id (OWASP/NIST preferred, PHC winner)
|
||||
- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
|
||||
rehashed to Argon2id on first successful use.
|
||||
"""
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
|
||||
from app.config import settings as app_settings
|
||||
|
||||
# OWASP-minimum Argon2id parameters (m=19 MB, t=2 iterations, p=1)
|
||||
_ph = PasswordHasher(
|
||||
time_cost=2,
|
||||
memory_cost=19456, # 19 MB in KB
|
||||
parallelism=1,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
)
|
||||
|
||||
# Session serializer — salt differentiates from MFA tokens
|
||||
_serializer = URLSafeTimedSerializer(
|
||||
secret_key=app_settings.SECRET_KEY,
|
||||
salt="umbra-session-v2",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password with Argon2id."""
|
||||
return _ph.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
"""Verify an Argon2id password hash. Returns False on any failure."""
|
||||
try:
|
||||
return _ph.verify(hashed, password)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
return False
|
||||
|
||||
|
||||
def needs_rehash(hashed: str) -> bool:
|
||||
"""True if the stored hash was created with outdated parameters."""
|
||||
return _ph.check_needs_rehash(hashed)
|
||||
|
||||
|
||||
def verify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Verify a password against a stored hash (Argon2id or legacy bcrypt).
|
||||
|
||||
Returns (is_valid, new_hash_if_upgrade_needed).
|
||||
new_hash is non-None only when the stored hash is bcrypt and the password is
|
||||
correct — caller must persist the new hash to complete the migration.
|
||||
Also returns a new hash when Argon2id parameters are outdated.
|
||||
"""
|
||||
if hashed.startswith("$2b$") or hashed.startswith("$2a$"):
|
||||
# Legacy bcrypt — verify then immediately rehash to Argon2id
|
||||
import bcrypt # noqa: PLC0415 — intentional lazy import; bcrypt is only needed during migration
|
||||
try:
|
||||
valid = bcrypt.checkpw(password.encode(), hashed.encode())
|
||||
except Exception:
|
||||
return False, None
|
||||
if valid:
|
||||
return True, hash_password(password)
|
||||
return False, None
|
||||
|
||||
# Argon2id path
|
||||
valid = verify_password(password, hashed)
|
||||
new_hash = hash_password(password) if (valid and needs_rehash(hashed)) else None
|
||||
return valid, new_hash
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_session_token(user_id: int, session_id: str) -> str:
|
||||
"""Create a signed session cookie payload embedding user_id + session_id."""
|
||||
return _serializer.dumps({"uid": user_id, "sid": session_id})
|
||||
|
||||
|
||||
def verify_session_token(token: str, max_age: int | None = None) -> dict | None:
|
||||
"""
|
||||
Verify a session cookie and return its payload dict, or None if invalid/expired.
|
||||
max_age defaults to SESSION_MAX_AGE_DAYS from config.
|
||||
"""
|
||||
if max_age is None:
|
||||
max_age = app_settings.SESSION_MAX_AGE_DAYS * 86400
|
||||
try:
|
||||
return _serializer.loads(token, max_age=max_age)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MFA tokens (short-lived, used between password OK and TOTP verification)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# MFA tokens use a distinct salt so they cannot be replayed as session tokens
|
||||
_mfa_serializer = URLSafeTimedSerializer(
|
||||
secret_key=app_settings.SECRET_KEY,
|
||||
salt="mfa-challenge",
|
||||
)
|
||||
|
||||
|
||||
def create_mfa_token(user_id: int) -> str:
|
||||
"""Create a short-lived signed token for the MFA challenge step."""
|
||||
return _mfa_serializer.dumps({"uid": user_id})
|
||||
|
||||
|
||||
def verify_mfa_token(token: str) -> int | None:
|
||||
"""
|
||||
Verify an MFA challenge token.
|
||||
Returns the user_id on success, None if invalid or expired (5-minute TTL).
|
||||
"""
|
||||
try:
|
||||
data = _mfa_serializer.loads(
|
||||
token, max_age=app_settings.MFA_TOKEN_MAX_AGE_SECONDS
|
||||
)
|
||||
return data["uid"]
|
||||
except Exception:
|
||||
return None
|
||||
123
backend/app/services/ntfy.py
Normal file
123
backend/app/services/ntfy.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""
|
||||
ntfy push notification service.
|
||||
|
||||
Responsible for:
|
||||
- SSRF validation of user-supplied server URLs
|
||||
- Building and sending ntfy JSON payloads via httpx
|
||||
- Never raising — notification failures must not interrupt application flow
|
||||
"""
|
||||
import httpx
|
||||
import socket
|
||||
import ipaddress
|
||||
import logging
|
||||
from typing import Optional
|
||||
from app.models.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NTFY_TIMEOUT = 8.0 # seconds — hard cap to prevent hung requests
|
||||
|
||||
# Loopback + link-local only. Private IPs (RFC 1918) are intentionally allowed
|
||||
# because UMBRA is self-hosted and the user's ntfy server is typically on the same LAN.
|
||||
_BLOCKED_NETWORKS = [
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
]
|
||||
|
||||
|
||||
def validate_ntfy_host(url: str) -> None:
|
||||
"""
|
||||
SSRF guard: resolve the hostname and reject if it points to any private/blocked range.
|
||||
Raises ValueError on failure. Must be called before any outbound HTTP request.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
hostname = urlparse(url).hostname
|
||||
if not hostname:
|
||||
raise ValueError("Invalid ntfy URL: no hostname")
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror as exc:
|
||||
raise ValueError(f"Cannot resolve ntfy hostname '{hostname}': {exc}") from exc
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
for net in _BLOCKED_NETWORKS:
|
||||
if ip in net:
|
||||
raise ValueError(
|
||||
f"ntfy hostname '{hostname}' resolves to blocked IP range ({ip})"
|
||||
)
|
||||
|
||||
|
||||
def _build_headers(auth_token: Optional[str]) -> dict:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
return headers
|
||||
|
||||
|
||||
async def send_ntfy_notification(
|
||||
settings: Settings,
|
||||
title: str,
|
||||
message: str,
|
||||
tags: list[str],
|
||||
priority: int = 3,
|
||||
click_url: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Fire-and-forget ntfy notification.
|
||||
Returns True on success, False on any failure.
|
||||
Never raises — notification failure must not interrupt application flow.
|
||||
"""
|
||||
if not settings.ntfy_enabled:
|
||||
return False
|
||||
if not settings.ntfy_server_url or not settings.ntfy_topic:
|
||||
return False
|
||||
|
||||
# Truncate to prevent oversized payloads (security requirement 6.3)
|
||||
safe_title = (title[:77] + "...") if len(title) > 80 else title
|
||||
safe_message = (message[:197] + "...") if len(message) > 200 else message
|
||||
|
||||
payload: dict = {
|
||||
"topic": settings.ntfy_topic,
|
||||
"title": safe_title,
|
||||
"message": safe_message,
|
||||
"tags": tags,
|
||||
"priority": priority,
|
||||
}
|
||||
if click_url:
|
||||
payload["click"] = click_url
|
||||
payload["actions"] = [
|
||||
{"action": "view", "label": "Open UMBRA", "url": click_url, "clear": True}
|
||||
]
|
||||
|
||||
try:
|
||||
# SSRF guard: validate resolved IP before making the request
|
||||
validate_ntfy_host(settings.ntfy_server_url)
|
||||
|
||||
async with httpx.AsyncClient(timeout=NTFY_TIMEOUT, follow_redirects=False) as client:
|
||||
resp = await client.post(
|
||||
settings.ntfy_server_url,
|
||||
json=payload,
|
||||
headers=_build_headers(settings.ntfy_auth_token),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return True
|
||||
except ValueError as e:
|
||||
# SSRF validation failure
|
||||
logger.warning("ntfy SSRF validation rejected URL: %s", e)
|
||||
return False
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("ntfy notification timed out (server=%s)", settings.ntfy_server_url)
|
||||
return False
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning(
|
||||
"ntfy HTTP error %s for topic '%s': %s",
|
||||
e.response.status_code,
|
||||
settings.ntfy_topic,
|
||||
e.response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("ntfy notification failed unexpectedly: %s", type(e).__name__)
|
||||
return False
|
||||
134
backend/app/services/ntfy_templates.py
Normal file
134
backend/app/services/ntfy_templates.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""
|
||||
Notification template builders for ntfy push notifications.
|
||||
|
||||
Each build_* function returns a dict with keys: title, message, tags, priority.
|
||||
These are passed directly to send_ntfy_notification().
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def urgency_label(target_date: date, today: date) -> str:
|
||||
"""Human-readable urgency string relative to today."""
|
||||
delta = (target_date - today).days
|
||||
if delta < 0:
|
||||
return f"OVERDUE ({abs(delta)}d ago)"
|
||||
elif delta == 0:
|
||||
return "Today"
|
||||
elif delta == 1:
|
||||
return "Tomorrow"
|
||||
elif delta <= 7:
|
||||
return f"in {delta} days"
|
||||
else:
|
||||
return target_date.strftime("%d %b")
|
||||
|
||||
|
||||
def day_str(dt: datetime, today: date) -> str:
|
||||
"""Return 'Today', 'Tomorrow', or a short date string."""
|
||||
d = dt.date()
|
||||
if d == today:
|
||||
return "Today"
|
||||
delta = (d - today).days
|
||||
if delta == 1:
|
||||
return "Tomorrow"
|
||||
return dt.strftime("%a %d %b")
|
||||
|
||||
|
||||
def time_str(dt: datetime, all_day: bool = False) -> str:
|
||||
"""Return 'All day' or HH:MM."""
|
||||
if all_day:
|
||||
return "All day"
|
||||
return dt.strftime("%H:%M")
|
||||
|
||||
|
||||
def _truncate(text: str, max_len: int) -> str:
|
||||
"""Truncate with ellipsis if over limit."""
|
||||
return (text[:max_len - 3] + "...") if len(text) > max_len else text
|
||||
|
||||
|
||||
# ── Template builders ─────────────────────────────────────────────────────────
|
||||
|
||||
def build_event_notification(
|
||||
title: str,
|
||||
start_datetime: datetime,
|
||||
all_day: bool,
|
||||
today: date,
|
||||
location_name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
is_starred: bool = False,
|
||||
) -> dict:
|
||||
"""Build notification payload for a calendar event reminder."""
|
||||
day = day_str(start_datetime, today)
|
||||
time = time_str(start_datetime, all_day)
|
||||
loc = f" @ {location_name}" if location_name else ""
|
||||
desc = f" — {description[:80]}" if description else ""
|
||||
|
||||
return {
|
||||
"title": _truncate(f"Calendar: {title}", 80),
|
||||
"message": _truncate(f"{day} at {time}{loc}{desc}", 200),
|
||||
"tags": ["calendar"],
|
||||
"priority": 4 if is_starred else 3,
|
||||
}
|
||||
|
||||
|
||||
def build_reminder_notification(
|
||||
title: str,
|
||||
remind_at: datetime,
|
||||
today: date,
|
||||
description: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Build notification payload for a general reminder."""
|
||||
day = day_str(remind_at, today)
|
||||
time = time_str(remind_at)
|
||||
desc = f" — {description[:80]}" if description else ""
|
||||
|
||||
return {
|
||||
"title": _truncate(f"Reminder: {title}", 80),
|
||||
"message": _truncate(f"{day} at {time}{desc}", 200),
|
||||
"tags": ["bell"],
|
||||
"priority": 3,
|
||||
}
|
||||
|
||||
|
||||
def build_todo_notification(
|
||||
title: str,
|
||||
due_date: date,
|
||||
today: date,
|
||||
priority: str = "medium",
|
||||
category: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Build notification payload for a todo due date alert."""
|
||||
urgency = urgency_label(due_date, today)
|
||||
priority_label = priority.capitalize()
|
||||
cat = f" [{category}]" if category else ""
|
||||
# High priority for today/overdue, default otherwise
|
||||
ntfy_priority = 4 if (due_date - today).days <= 0 else 3
|
||||
|
||||
return {
|
||||
"title": _truncate(f"Due {urgency}: {title}", 80),
|
||||
"message": _truncate(f"Priority: {priority_label}{cat}", 200),
|
||||
"tags": ["white_check_mark"],
|
||||
"priority": ntfy_priority,
|
||||
}
|
||||
|
||||
|
||||
def build_project_notification(
|
||||
name: str,
|
||||
due_date: date,
|
||||
today: date,
|
||||
status: str = "in_progress",
|
||||
) -> dict:
|
||||
"""Build notification payload for a project deadline alert."""
|
||||
urgency = urgency_label(due_date, today)
|
||||
# Format status label
|
||||
status_label = status.replace("_", " ").title()
|
||||
ntfy_priority = 4 if due_date <= today else 3
|
||||
|
||||
return {
|
||||
"title": _truncate(f"Project Deadline: {name}", 80),
|
||||
"message": _truncate(f"Due {urgency} — Status: {status_label}", 200),
|
||||
"tags": ["briefcase"],
|
||||
"priority": ntfy_priority,
|
||||
}
|
||||
134
backend/app/services/totp.py
Normal file
134
backend/app/services/totp.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""
|
||||
TOTP service: secret generation/encryption, code verification, QR code generation,
|
||||
backup code generation.
|
||||
|
||||
All TOTP secrets are Fernet-encrypted at rest using a key derived from SECRET_KEY.
|
||||
Raw secrets are never logged and are only returned to the client once (at setup).
|
||||
"""
|
||||
import pyotp
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
import io
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
import qrcode
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.config import settings as app_settings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fernet key derivation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
"""Derive a 32-byte Fernet key from SECRET_KEY via SHA-256."""
|
||||
key = hashlib.sha256(app_settings.SECRET_KEY.encode()).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secret management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_totp_secret() -> str:
|
||||
"""Generate a new random TOTP secret (base32, ~160 bits entropy)."""
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def encrypt_totp_secret(raw: str) -> str:
|
||||
"""Encrypt a raw TOTP secret before storing in the DB."""
|
||||
return _get_fernet().encrypt(raw.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_totp_secret(encrypted: str) -> str:
|
||||
"""Decrypt a TOTP secret retrieved from the DB."""
|
||||
return _get_fernet().decrypt(encrypted.encode()).decode()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provisioning URI and QR code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_totp_uri(encrypted_secret: str, username: str) -> str:
|
||||
"""Return the otpauth:// provisioning URI for QR code generation."""
|
||||
raw = decrypt_totp_secret(encrypted_secret)
|
||||
totp = pyotp.TOTP(raw)
|
||||
return totp.provisioning_uri(name=username, issuer_name=app_settings.TOTP_ISSUER)
|
||||
|
||||
|
||||
def generate_qr_base64(uri: str) -> str:
|
||||
"""Return a base64-encoded PNG of the QR code for the provisioning URI."""
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(uri)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code verification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def verify_totp_code(encrypted_secret: str, code: str, valid_window: int = 1) -> int | None:
|
||||
"""
|
||||
Verify a TOTP code and return the matching time window, or None if invalid.
|
||||
|
||||
Checks each window individually (T-valid_window ... T+valid_window) so the
|
||||
caller knows WHICH window matched — required for correct replay-prevention
|
||||
(the TOTPUsage row must record the actual matching window, not the current one).
|
||||
|
||||
Uses secrets.compare_digest for constant-time comparison to prevent timing attacks.
|
||||
|
||||
Returns:
|
||||
int — the floor(unix_time / 30) window value that matched
|
||||
None — no window matched (invalid code)
|
||||
"""
|
||||
raw = decrypt_totp_secret(encrypted_secret)
|
||||
totp = pyotp.TOTP(raw)
|
||||
current_window = int(time.time() // 30)
|
||||
|
||||
for offset in range(-valid_window, valid_window + 1):
|
||||
check_window = current_window + offset
|
||||
# pyotp.at() accepts a unix timestamp; multiply window back to seconds
|
||||
expected_code = totp.at(check_window * 30)
|
||||
if secrets.compare_digest(code.strip(), expected_code):
|
||||
return check_window # Return the ACTUAL window that matched
|
||||
|
||||
return None # No window matched
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup codes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_backup_codes(count: int = 10) -> list[str]:
|
||||
"""
|
||||
Generate recovery backup codes in XXXX-XXXX format.
|
||||
Uses cryptographically secure randomness (secrets module).
|
||||
"""
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
return [
|
||||
"".join(secrets.choice(alphabet) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(alphabet) for _ in range(4))
|
||||
for _ in range(count)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_totp_window() -> int:
|
||||
"""Return the current TOTP time window (floor(unix_time / 30))."""
|
||||
return int(time.time() // 30)
|
||||
@ -6,6 +6,12 @@ alembic==1.14.1
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
bcrypt==4.2.1
|
||||
argon2-cffi>=23.1.0
|
||||
pyotp>=2.9.0
|
||||
qrcode[pil]>=7.4.0
|
||||
cryptography>=42.0.0
|
||||
python-multipart==0.0.20
|
||||
python-dateutil==2.9.0
|
||||
itsdangerous==2.2.0
|
||||
httpx==0.27.2
|
||||
apscheduler==3.10.4
|
||||
|
||||
45
frontend/src/components/auth/AmbientBackground.tsx
Normal file
45
frontend/src/components/auth/AmbientBackground.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Shared animated ambient background for the login screen and lock overlay.
|
||||
* Renders floating gradient orbs and a subtle grid overlay.
|
||||
*/
|
||||
export default function AmbientBackground() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
{/* Animated gradient orbs */}
|
||||
<div
|
||||
className="absolute h-[500px] w-[500px] rounded-full opacity-15 blur-[100px] animate-drift-1"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)',
|
||||
top: '-10%',
|
||||
left: '-10%',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute h-[400px] w-[400px] rounded-full opacity-10 blur-[100px] animate-drift-2"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)',
|
||||
bottom: '-5%',
|
||||
right: '-5%',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute h-[350px] w-[350px] rounded-full opacity-[0.07] blur-[80px] animate-drift-3"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)',
|
||||
top: '40%',
|
||||
right: '20%',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Subtle grid overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.035]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
|
||||
backgroundSize: '60px 60px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,115 +1,273 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useNavigate, Navigate } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { Lock, Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import AmbientBackground from './AmbientBackground';
|
||||
|
||||
/** Validates password against backend rules: 12-128 chars, at least one letter + one non-letter. */
|
||||
function validatePassword(password: string): string | null {
|
||||
if (password.length < 12) return 'Password must be at least 12 characters';
|
||||
if (password.length > 128) return 'Password must be at most 128 characters';
|
||||
if (!/[a-zA-Z]/.test(password)) return 'Password must contain at least one letter';
|
||||
if (!/[^a-zA-Z]/.test(password)) return 'Password must contain at least one non-letter character';
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function LockScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { authStatus, login, setup, isLoginPending, isSetupPending } = useAuth();
|
||||
const [pin, setPin] = useState('');
|
||||
const [confirmPin, setConfirmPin] = useState('');
|
||||
const { authStatus, isLoading, login, setup, verifyTotp, mfaRequired, isLoginPending, isSetupPending, isTotpPending } = useAuth();
|
||||
|
||||
// Redirect authenticated users to dashboard
|
||||
if (authStatus?.authenticated) {
|
||||
// Credentials state (shared across login/setup states)
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
// TOTP challenge state
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||
|
||||
// Lockout handling (HTTP 423)
|
||||
const [lockoutMessage, setLockoutMessage] = useState<string | null>(null);
|
||||
|
||||
// Redirect authenticated users immediately
|
||||
if (!isLoading && authStatus?.authenticated) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const isSetup = authStatus?.setup_required === true;
|
||||
|
||||
if (authStatus?.setup_required) {
|
||||
if (pin !== confirmPin) {
|
||||
toast.error('PINs do not match');
|
||||
const handleCredentialSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLockoutMessage(null);
|
||||
|
||||
if (isSetup) {
|
||||
// Setup mode: validate password then create account
|
||||
const validationError = validatePassword(password);
|
||||
if (validationError) {
|
||||
toast.error(validationError);
|
||||
return;
|
||||
}
|
||||
if (pin.length < 4) {
|
||||
toast.error('PIN must be at least 4 characters');
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setup(pin);
|
||||
toast.success('PIN created successfully');
|
||||
navigate('/dashboard');
|
||||
await setup({ username, password });
|
||||
// useAuth invalidates auth query → Navigate above handles redirect
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to create PIN'));
|
||||
toast.error(getErrorMessage(error, 'Failed to create account'));
|
||||
}
|
||||
} else {
|
||||
// Login mode
|
||||
try {
|
||||
await login(pin);
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Invalid PIN'));
|
||||
setPin('');
|
||||
await login({ username, password });
|
||||
// If mfaRequired becomes true, the TOTP state renders automatically
|
||||
// If not required, useAuth invalidates auth query → Navigate above handles redirect
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 423) {
|
||||
const msg = error.response.data?.detail || 'Account locked. Try again later.';
|
||||
setLockoutMessage(msg);
|
||||
} else {
|
||||
toast.error(getErrorMessage(error, 'Invalid username or password'));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isSetup = authStatus?.setup_required;
|
||||
const handleTotpSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await verifyTotp(totpCode);
|
||||
// useAuth invalidates auth query → Navigate above handles redirect
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Invalid verification code'));
|
||||
setTotpCode('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-accent/10">
|
||||
<Lock className="h-8 w-8 text-accent" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">
|
||||
{isSetup ? 'Welcome to UMBRA' : 'Enter PIN'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isSetup
|
||||
? 'Create a PIN to secure your account'
|
||||
: 'Enter your PIN to access your dashboard'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pin">{isSetup ? 'Create PIN' : 'PIN'}</Label>
|
||||
<Input
|
||||
id="pin"
|
||||
type="password"
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
placeholder="Enter PIN"
|
||||
required
|
||||
autoFocus
|
||||
className="text-center text-lg tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
{isSetup && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-pin">Confirm PIN</Label>
|
||||
<Input
|
||||
id="confirm-pin"
|
||||
type="password"
|
||||
value={confirmPin}
|
||||
onChange={(e) => setConfirmPin(e.target.value)}
|
||||
placeholder="Confirm PIN"
|
||||
required
|
||||
className="text-center text-lg tracking-widest"
|
||||
/>
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background p-4 overflow-hidden">
|
||||
<AmbientBackground />
|
||||
|
||||
{/* Wordmark — in flex flow above card */}
|
||||
<span className="font-heading text-5xl sm:text-6xl font-bold tracking-tight text-accent mb-10 relative z-10 animate-slide-up">
|
||||
UMBRA
|
||||
</span>
|
||||
|
||||
{/* Auth card */}
|
||||
<Card className="w-full max-w-sm relative z-10 border-border/80 animate-slide-up">
|
||||
{mfaRequired ? (
|
||||
// State C: TOTP challenge
|
||||
<>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<Lock className="h-4 w-4 text-accent" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Two-Factor Authentication</CardTitle>
|
||||
<CardDescription>
|
||||
{useBackupCode
|
||||
? 'Enter one of your backup codes'
|
||||
: 'Enter the code from your authenticator app'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoginPending || isSetupPending}
|
||||
>
|
||||
{isLoginPending || isSetupPending
|
||||
? 'Please wait...'
|
||||
: isSetup
|
||||
? 'Create PIN'
|
||||
: 'Unlock'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleTotpSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp-code">
|
||||
{useBackupCode ? 'Backup Code' : 'Authenticator Code'}
|
||||
</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
inputMode={useBackupCode ? 'text' : 'numeric'}
|
||||
pattern={useBackupCode ? undefined : '[0-9]*'}
|
||||
maxLength={useBackupCode ? 9 : 6}
|
||||
value={totpCode}
|
||||
onChange={(e) =>
|
||||
setTotpCode(
|
||||
useBackupCode
|
||||
? e.target.value.replace(/[^0-9-]/g, '')
|
||||
: e.target.value.replace(/\D/g, '')
|
||||
)
|
||||
}
|
||||
placeholder={useBackupCode ? 'XXXX-XXXX' : '000000'}
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
className="text-center text-lg tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isTotpPending}>
|
||||
{isTotpPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Verifying
|
||||
</>
|
||||
) : (
|
||||
'Verify'
|
||||
)}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode);
|
||||
setTotpCode('');
|
||||
}}
|
||||
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{useBackupCode ? 'Use authenticator app instead' : 'Use a backup code instead'}
|
||||
</button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</>
|
||||
) : (
|
||||
// State A (setup) or State B (login)
|
||||
<>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<Lock className="h-4 w-4 text-accent" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{isSetup ? 'Welcome to UMBRA' : 'Sign in'}</CardTitle>
|
||||
<CardDescription>
|
||||
{isSetup
|
||||
? 'Create your account to get started'
|
||||
: 'Enter your credentials to continue'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Lockout warning banner */}
|
||||
{lockoutMessage && (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-red-500/30',
|
||||
'bg-red-500/10 px-3 py-2 mb-4'
|
||||
)}
|
||||
>
|
||||
<Lock className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
|
||||
<p className="text-xs text-red-400">{lockoutMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCredentialSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" required>Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => { setUsername(e.target.value); setLockoutMessage(null); }}
|
||||
placeholder="Enter username"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" required>Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setLockoutMessage(null); }}
|
||||
placeholder={isSetup ? 'Create a password' : 'Enter password'}
|
||||
required
|
||||
autoComplete={isSetup ? 'new-password' : 'current-password'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSetup && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" required>Confirm Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be 12-128 characters with at least one letter and one non-letter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoginPending || isSetupPending || !!lockoutMessage}
|
||||
>
|
||||
{isLoginPending || isSetupPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Please wait
|
||||
</>
|
||||
) : isSetup ? (
|
||||
'Create Account'
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -89,7 +89,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cal-name">Name</Label>
|
||||
<Label htmlFor="cal-name" required>Name</Label>
|
||||
<Input
|
||||
id="cal-name"
|
||||
value={name}
|
||||
|
||||
@ -3,8 +3,10 @@ import { Outlet } from 'react-router-dom';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { AlertsProvider } from '@/hooks/useAlerts';
|
||||
import { LockProvider } from '@/hooks/useLock';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Sidebar from './Sidebar';
|
||||
import LockOverlay from './LockOverlay';
|
||||
|
||||
export default function AppLayout() {
|
||||
useTheme();
|
||||
@ -12,27 +14,30 @@ export default function AppLayout() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<AlertsProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(!collapsed)}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Mobile header */}
|
||||
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
|
||||
<LockProvider>
|
||||
<AlertsProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(!collapsed)}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Mobile header */}
|
||||
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
|
||||
</div>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</AlertsProvider>
|
||||
<LockOverlay />
|
||||
</AlertsProvider>
|
||||
</LockProvider>
|
||||
);
|
||||
}
|
||||
|
||||
113
frontend/src/components/layout/LockOverlay.tsx
Normal file
113
frontend/src/components/layout/LockOverlay.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { useState, FormEvent, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Lock, Loader2 } from 'lucide-react';
|
||||
import { useLock } from '@/hooks/useLock';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AmbientBackground from '@/components/auth/AmbientBackground';
|
||||
|
||||
export default function LockOverlay() {
|
||||
const { isLocked, unlock } = useLock();
|
||||
const { logout } = useAuth();
|
||||
const { settings } = useSettings();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [isUnlocking, setIsUnlocking] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus password input when lock activates
|
||||
useEffect(() => {
|
||||
if (isLocked) {
|
||||
setPassword('');
|
||||
// Small delay to let the overlay render
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 100);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [isLocked]);
|
||||
|
||||
if (!isLocked) return null;
|
||||
|
||||
const preferredName = settings?.preferred_name;
|
||||
|
||||
const handleUnlock = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password.trim()) return;
|
||||
|
||||
setIsUnlocking(true);
|
||||
try {
|
||||
await unlock(password);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Invalid password'));
|
||||
setPassword('');
|
||||
inputRef.current?.focus();
|
||||
} finally {
|
||||
setIsUnlocking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchAccount = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background animate-fade-in">
|
||||
<AmbientBackground />
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-sm px-4 animate-slide-up">
|
||||
{/* Lock icon */}
|
||||
<div className="p-3 rounded-full bg-accent/10 border border-accent/20">
|
||||
<Lock className="h-6 w-6 text-accent" />
|
||||
</div>
|
||||
|
||||
{/* Greeting */}
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-2xl font-heading font-semibold text-foreground">Locked</h1>
|
||||
{preferredName && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Welcome back, {preferredName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password form */}
|
||||
<form onSubmit={handleUnlock} className="w-full space-y-4">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
aria-label="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password to unlock"
|
||||
autoComplete="current-password"
|
||||
className="text-center"
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={isUnlocking}>
|
||||
{isUnlocking ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Unlocking
|
||||
</>
|
||||
) : (
|
||||
'Unlock'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Switch account link */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwitchAccount}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Switch account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -15,9 +15,11 @@ import {
|
||||
ChevronDown,
|
||||
X,
|
||||
LogOut,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useLock } from '@/hooks/useLock';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import api from '@/lib/api';
|
||||
import type { Project } from '@/types';
|
||||
@ -42,6 +44,7 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { logout } = useAuth();
|
||||
const { lock } = useLock();
|
||||
const [projectsExpanded, setProjectsExpanded] = useState(false);
|
||||
|
||||
const { data: trackedProjects } = useQuery({
|
||||
@ -180,6 +183,13 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
||||
</nav>
|
||||
|
||||
<div className="border-t p-2 space-y-1">
|
||||
<button
|
||||
onClick={lock}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/10 hover:text-accent border-l-2 border-transparent"
|
||||
>
|
||||
<Lock className="h-5 w-5 shrink-0" />
|
||||
{showExpanded && <span>Lock</span>}
|
||||
</button>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
onClick={mobileOpen ? onMobileClose : undefined}
|
||||
|
||||
@ -110,7 +110,7 @@ export default function LocationForm({ location, categories, onClose }: Location
|
||||
<div className="px-6 py-5 space-y-4 flex-1">
|
||||
{/* Location Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loc-name">Location Name</Label>
|
||||
<Label htmlFor="loc-name" required>Location Name</Label>
|
||||
<Input
|
||||
id="loc-name"
|
||||
value={formData.name}
|
||||
|
||||
@ -132,7 +132,7 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
||||
{/* Row 2: First + Last name */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">First Name</Label>
|
||||
<Label htmlFor="first_name" required>First Name</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
|
||||
368
frontend/src/components/settings/NtfySettingsSection.tsx
Normal file
368
frontend/src/components/settings/NtfySettingsSection.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
Send,
|
||||
Settings2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { Settings } from '@/types';
|
||||
|
||||
interface NtfySettingsSectionProps {
|
||||
settings: Settings | undefined;
|
||||
updateSettings: (
|
||||
updates: Partial<Settings> & { preferred_name?: string | null; ntfy_auth_token?: string }
|
||||
) => Promise<Settings>;
|
||||
}
|
||||
|
||||
export default function NtfySettingsSection({ settings, updateSettings }: NtfySettingsSectionProps) {
|
||||
// ── Local form state ──
|
||||
const [ntfyEnabled, setNtfyEnabled] = useState(false);
|
||||
const [ntfyServerUrl, setNtfyServerUrl] = useState('');
|
||||
const [ntfyTopic, setNtfyTopic] = useState('');
|
||||
const [ntfyToken, setNtfyToken] = useState('');
|
||||
const [tokenCleared, setTokenCleared] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
// Per-type toggles
|
||||
const [eventsEnabled, setEventsEnabled] = useState(true);
|
||||
const [eventLeadMinutes, setEventLeadMinutes] = useState(15);
|
||||
const [remindersEnabled, setRemindersEnabled] = useState(true);
|
||||
const [todosEnabled, setTodosEnabled] = useState(true);
|
||||
const [todoLeadDays, setTodoLeadDays] = useState(1);
|
||||
const [projectsEnabled, setProjectsEnabled] = useState(true);
|
||||
const [projectLeadDays, setProjectLeadDays] = useState(2);
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isTestingNtfy, setIsTestingNtfy] = useState(false);
|
||||
|
||||
// Connection config is collapsed once server+topic are saved
|
||||
const isConfigured = !!(settings?.ntfy_server_url && settings?.ntfy_topic);
|
||||
const [configExpanded, setConfigExpanded] = useState(!isConfigured);
|
||||
|
||||
// Sync from settings on initial load
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setNtfyEnabled(settings.ntfy_enabled ?? false);
|
||||
setNtfyServerUrl(settings.ntfy_server_url ?? '');
|
||||
setNtfyTopic(settings.ntfy_topic ?? '');
|
||||
setNtfyToken(''); // never pre-populate token value — backend returns has_token only
|
||||
setTokenCleared(false);
|
||||
setEventsEnabled(settings.ntfy_events_enabled ?? false);
|
||||
setEventLeadMinutes(settings.ntfy_event_lead_minutes ?? 15);
|
||||
setRemindersEnabled(settings.ntfy_reminders_enabled ?? false);
|
||||
setTodosEnabled(settings.ntfy_todos_enabled ?? false);
|
||||
setTodoLeadDays(settings.ntfy_todo_lead_days ?? 0);
|
||||
setProjectsEnabled(settings.ntfy_projects_enabled ?? false);
|
||||
setProjectLeadDays(settings.ntfy_project_lead_days ?? 0);
|
||||
setConfigExpanded(!(settings.ntfy_server_url && settings.ntfy_topic));
|
||||
}, [settings?.id]);
|
||||
|
||||
const ntfyHasToken = settings?.ntfy_has_token ?? false;
|
||||
const isMisconfigured = ntfyEnabled && (!ntfyServerUrl.trim() || !ntfyTopic.trim());
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const updates: Partial<Settings> & { ntfy_auth_token?: string } = {
|
||||
ntfy_enabled: ntfyEnabled,
|
||||
ntfy_server_url: ntfyServerUrl.trim() || null,
|
||||
ntfy_topic: ntfyTopic.trim() || null,
|
||||
ntfy_events_enabled: eventsEnabled,
|
||||
ntfy_event_lead_minutes: eventLeadMinutes,
|
||||
ntfy_reminders_enabled: remindersEnabled,
|
||||
ntfy_todos_enabled: todosEnabled,
|
||||
ntfy_todo_lead_days: todoLeadDays,
|
||||
ntfy_projects_enabled: projectsEnabled,
|
||||
ntfy_project_lead_days: projectLeadDays,
|
||||
};
|
||||
|
||||
// Token logic: include only if user typed a new token OR explicitly cleared it
|
||||
if (ntfyToken) {
|
||||
updates.ntfy_auth_token = ntfyToken;
|
||||
} else if (tokenCleared) {
|
||||
updates.ntfy_auth_token = ''; // backend normalizes "" → None
|
||||
}
|
||||
|
||||
await updateSettings(updates);
|
||||
setNtfyToken('');
|
||||
setTokenCleared(false);
|
||||
// Collapse connection settings after successful save if configured
|
||||
if (ntfyServerUrl.trim() && ntfyTopic.trim()) {
|
||||
setConfigExpanded(false);
|
||||
}
|
||||
toast.success('Notification settings saved');
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to save notification settings'));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNtfyTest = async () => {
|
||||
setIsTestingNtfy(true);
|
||||
try {
|
||||
await api.post('/settings/ntfy/test');
|
||||
toast.success('Test notification sent');
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to send test notification'));
|
||||
} finally {
|
||||
setIsTestingNtfy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearToken = () => {
|
||||
setNtfyToken('');
|
||||
setTokenCleared(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ntfy sub-section header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Bell className="h-4 w-4 text-orange-400" aria-hidden="true" />
|
||||
<p className="text-sm font-medium">ntfy Push Notifications</p>
|
||||
</div>
|
||||
<Switch checked={ntfyEnabled} onCheckedChange={setNtfyEnabled} />
|
||||
</div>
|
||||
|
||||
{ntfyEnabled && (
|
||||
<>
|
||||
{/* Warning banner */}
|
||||
{isMisconfigured && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-amber-400 shrink-0" aria-hidden="true" />
|
||||
<p className="text-xs text-amber-400">
|
||||
Server URL and topic are required to send notifications
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection config — collapsible when already configured */}
|
||||
{isConfigured && !configExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfigExpanded(true)}
|
||||
className="flex items-center gap-2 w-full rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-card-elevated transition-colors"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
<span className="flex-1 text-left">Configure ntfy connection</span>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-md border border-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Connection</p>
|
||||
{isConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfigExpanded(false)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Collapse
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy_server_url">Server URL</Label>
|
||||
<Input
|
||||
id="ntfy_server_url"
|
||||
type="text"
|
||||
placeholder="https://ntfy.sh"
|
||||
value={ntfyServerUrl}
|
||||
onChange={(e) => setNtfyServerUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy_topic">Topic</Label>
|
||||
<Input
|
||||
id="ntfy_topic"
|
||||
type="text"
|
||||
placeholder="my-umbra-alerts"
|
||||
value={ntfyTopic}
|
||||
onChange={(e) => setNtfyTopic(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ntfy_token">Auth Token</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="ntfy_token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={ntfyToken}
|
||||
onChange={(e) => {
|
||||
setNtfyToken(e.target.value);
|
||||
if (tokenCleared && e.target.value) setTokenCleared(false);
|
||||
}}
|
||||
placeholder={
|
||||
tokenCleared
|
||||
? 'Token will be cleared on save'
|
||||
: ntfyHasToken
|
||||
? '(token saved — leave blank to keep)'
|
||||
: 'Optional auth token'
|
||||
}
|
||||
className="pr-16"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1 items-center">
|
||||
{ntfyHasToken && !ntfyToken && !tokenCleared && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearToken}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-0.5"
|
||||
aria-label="Clear saved token"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-0.5"
|
||||
aria-label={showToken ? 'Hide auth token' : 'Show auth token'}
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{tokenCleared && (
|
||||
<p className="text-xs text-amber-400">Token will be removed when you save.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Per-type notification toggles */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Notification Types</p>
|
||||
|
||||
{/* Event reminders */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Switch checked={eventsEnabled} onCheckedChange={setEventsEnabled} />
|
||||
<p className="text-sm">Event reminders</p>
|
||||
</div>
|
||||
{eventsEnabled && (
|
||||
<Select
|
||||
value={String(eventLeadMinutes)}
|
||||
onChange={(e) => setEventLeadMinutes(Number(e.target.value))}
|
||||
className="h-8 w-36 text-xs"
|
||||
>
|
||||
<option value="5">5 min before</option>
|
||||
<option value="10">10 min before</option>
|
||||
<option value="15">15 min before</option>
|
||||
<option value="30">30 min before</option>
|
||||
<option value="60">1 hour before</option>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reminder alerts */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={remindersEnabled} onCheckedChange={setRemindersEnabled} />
|
||||
<p className="text-sm">Reminder alerts</p>
|
||||
</div>
|
||||
|
||||
{/* Todo due dates */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Switch checked={todosEnabled} onCheckedChange={setTodosEnabled} />
|
||||
<p className="text-sm">Todo due dates</p>
|
||||
</div>
|
||||
{todosEnabled && (
|
||||
<Select
|
||||
value={String(todoLeadDays)}
|
||||
onChange={(e) => setTodoLeadDays(Number(e.target.value))}
|
||||
className="h-8 w-36 text-xs"
|
||||
>
|
||||
<option value="0">Same day</option>
|
||||
<option value="1">1 day before</option>
|
||||
<option value="2">2 days before</option>
|
||||
<option value="3">3 days before</option>
|
||||
<option value="7">1 week before</option>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project deadlines */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Switch checked={projectsEnabled} onCheckedChange={setProjectsEnabled} />
|
||||
<p className="text-sm">Project deadlines</p>
|
||||
</div>
|
||||
{projectsEnabled && (
|
||||
<Select
|
||||
value={String(projectLeadDays)}
|
||||
onChange={(e) => setProjectLeadDays(Number(e.target.value))}
|
||||
className="h-8 w-36 text-xs"
|
||||
>
|
||||
<option value="0">Same day</option>
|
||||
<option value="1">1 day before</option>
|
||||
<option value="2">2 days before</option>
|
||||
<option value="3">3 days before</option>
|
||||
<option value="7">1 week before</option>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Test + Save */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNtfyTest}
|
||||
disabled={!ntfyEnabled || !ntfyServerUrl.trim() || !ntfyTopic.trim() || isTestingNtfy}
|
||||
className="gap-2"
|
||||
>
|
||||
{isTestingNtfy ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Sending...</>
|
||||
) : (
|
||||
<><Send className="h-4 w-4" />Send Test Notification</>
|
||||
)}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||
{isSaving ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Saving</>
|
||||
) : (
|
||||
'Save Notifications Config'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save button when disabled — still persist the toggle state */}
|
||||
{!ntfyEnabled && (
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||
{isSaving ? <><Loader2 className="h-4 w-4 animate-spin" />Saving</> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,30 @@
|
||||
import { useState, useEffect, useRef, useCallback, FormEvent, CSSProperties } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { MapPin, X, Search, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Settings,
|
||||
User,
|
||||
Palette,
|
||||
Cloud,
|
||||
CalendarDays,
|
||||
LayoutDashboard,
|
||||
MapPin,
|
||||
X,
|
||||
Search,
|
||||
Loader2,
|
||||
Shield,
|
||||
Blocks,
|
||||
} from 'lucide-react';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
import api from '@/lib/api';
|
||||
import type { GeoLocation } from '@/types';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import TotpSetupSection from './TotpSetupSection';
|
||||
import NtfySettingsSection from './NtfySettingsSection';
|
||||
|
||||
const accentColors = [
|
||||
{ name: 'cyan', label: 'Cyan', color: '#06b6d4' },
|
||||
@ -18,11 +32,15 @@ const accentColors = [
|
||||
{ name: 'purple', label: 'Purple', color: '#8b5cf6' },
|
||||
{ name: 'orange', label: 'Orange', color: '#f97316' },
|
||||
{ name: 'green', label: 'Green', color: '#22c55e' },
|
||||
{ name: 'red', label: 'Red', color: '#ef4444' },
|
||||
{ name: 'pink', label: 'Pink', color: '#ec4899' },
|
||||
{ name: 'yellow', label: 'Yellow', color: '#eab308' },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { settings, updateSettings, changePin, isUpdating, isChangingPin } = useSettings();
|
||||
const { settings, updateSettings, isUpdating } = useSettings();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
|
||||
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
|
||||
const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? '');
|
||||
@ -33,12 +51,20 @@ export default function SettingsPage() {
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0);
|
||||
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
||||
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
|
||||
|
||||
const [pinForm, setPinForm] = useState({
|
||||
oldPin: '',
|
||||
newPin: '',
|
||||
confirmPin: '',
|
||||
});
|
||||
// Sync state when settings load
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setSelectedColor(settings.accent_color);
|
||||
setUpcomingDays(settings.upcoming_days);
|
||||
setPreferredName(settings.preferred_name ?? '');
|
||||
setFirstDayOfWeek(settings.first_day_of_week);
|
||||
setAutoLockEnabled(settings.auto_lock_enabled);
|
||||
setAutoLockMinutes(settings.auto_lock_minutes ?? 5);
|
||||
}
|
||||
}, [settings?.id]); // only re-sync on initial load (settings.id won't change)
|
||||
|
||||
const hasLocation = settings?.weather_lat != null && settings?.weather_lon != null;
|
||||
|
||||
@ -87,11 +113,7 @@ export default function SettingsPage() {
|
||||
|
||||
const handleLocationClear = async () => {
|
||||
try {
|
||||
await updateSettings({
|
||||
weather_city: null,
|
||||
weather_lat: null,
|
||||
weather_lon: null,
|
||||
});
|
||||
await updateSettings({ weather_city: null, weather_lat: null, weather_lon: null });
|
||||
queryClient.invalidateQueries({ queryKey: ['weather'] });
|
||||
toast.success('Weather location cleared');
|
||||
} catch {
|
||||
@ -110,7 +132,6 @@ export default function SettingsPage() {
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
// Clean up debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
@ -123,7 +144,7 @@ export default function SettingsPage() {
|
||||
try {
|
||||
await updateSettings({ preferred_name: trimmed || null });
|
||||
toast.success('Name updated');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to update name');
|
||||
}
|
||||
};
|
||||
@ -133,7 +154,7 @@ export default function SettingsPage() {
|
||||
try {
|
||||
await updateSettings({ accent_color: color });
|
||||
toast.success('Accent color updated');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to update accent color');
|
||||
}
|
||||
};
|
||||
@ -151,305 +172,380 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpcomingDaysSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleUpcomingDaysSave = async () => {
|
||||
if (isNaN(upcomingDays) || upcomingDays < 1 || upcomingDays > 30) return;
|
||||
if (upcomingDays === settings?.upcoming_days) return;
|
||||
try {
|
||||
await updateSettings({ upcoming_days: upcomingDays });
|
||||
toast.success('Settings updated');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to update settings');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (pinForm.newPin !== pinForm.confirmPin) {
|
||||
toast.error('New PINs do not match');
|
||||
return;
|
||||
}
|
||||
if (pinForm.newPin.length < 4) {
|
||||
toast.error('PIN must be at least 4 characters');
|
||||
return;
|
||||
}
|
||||
const handleAutoLockToggle = async (checked: boolean) => {
|
||||
const previous = autoLockEnabled;
|
||||
setAutoLockEnabled(checked);
|
||||
try {
|
||||
await changePin({ oldPin: pinForm.oldPin, newPin: pinForm.newPin });
|
||||
toast.success('PIN changed successfully');
|
||||
setPinForm({ oldPin: '', newPin: '', confirmPin: '' });
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to change PIN');
|
||||
await updateSettings({ auto_lock_enabled: checked });
|
||||
toast.success(checked ? 'Auto-lock enabled' : 'Auto-lock disabled');
|
||||
} catch {
|
||||
setAutoLockEnabled(previous);
|
||||
toast.error('Failed to update auto-lock setting');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoLockMinutesSave = async () => {
|
||||
const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes;
|
||||
const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw));
|
||||
setAutoLockMinutes(clamped);
|
||||
if (clamped === settings?.auto_lock_minutes) return;
|
||||
try {
|
||||
await updateSettings({ auto_lock_minutes: clamped });
|
||||
toast.success(`Auto-lock timeout set to ${clamped} minutes`);
|
||||
} catch {
|
||||
setAutoLockMinutes(settings?.auto_lock_minutes ?? 5);
|
||||
toast.error('Failed to update auto-lock timeout');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="border-b bg-card px-6 py-4">
|
||||
<h1 className="text-3xl font-bold">Settings</h1>
|
||||
{/* Page header — matches Stage 4-5 pages */}
|
||||
<div className="border-b bg-card px-6 h-16 flex items-center gap-3 shrink-0">
|
||||
<Settings className="h-5 w-5 text-accent" aria-hidden="true" />
|
||||
<h1 className="text-xl font-semibold font-heading">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>Personalize how UMBRA greets you</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preferred_name">Preferred Name</Label>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
id="preferred_name"
|
||||
type="text"
|
||||
placeholder="Enter your name"
|
||||
value={preferredName}
|
||||
onChange={(e) => setPreferredName(e.target.value)}
|
||||
onBlur={handleNameSave}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave(); }}
|
||||
className="max-w-xs"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>Customize the look and feel of your application</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label>Accent Color</Label>
|
||||
<div className="flex gap-3 mt-3">
|
||||
{accentColors.map((color) => (
|
||||
<button
|
||||
key={color.name}
|
||||
type="button"
|
||||
onClick={() => handleColorChange(color.name)}
|
||||
className={cn(
|
||||
'h-12 w-12 rounded-full border-2 transition-all hover:scale-110',
|
||||
selectedColor === color.name
|
||||
? 'border-white ring-2 ring-offset-2 ring-offset-background'
|
||||
: 'border-transparent'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
backgroundColor: color.color,
|
||||
'--tw-ring-color': color.color,
|
||||
} as CSSProperties
|
||||
}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ── Left column: Profile, Appearance, Calendar, Dashboard, Weather ── */}
|
||||
<div className="space-y-6">
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Calendar</CardTitle>
|
||||
<CardDescription>Configure your calendar preferences</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label>First Day of Week</Label>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFirstDayChange(0)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium transition-colors duration-150',
|
||||
firstDayOfWeek === 0
|
||||
? 'text-accent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: firstDayOfWeek === 0 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: firstDayOfWeek === 0 ? 'hsl(var(--accent-color))' : undefined,
|
||||
}}
|
||||
>
|
||||
Sunday
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFirstDayChange(1)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium transition-colors duration-150',
|
||||
firstDayOfWeek === 1
|
||||
? 'text-accent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: firstDayOfWeek === 1 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: firstDayOfWeek === 1 ? 'hsl(var(--accent-color))' : undefined,
|
||||
}}
|
||||
>
|
||||
Monday
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sets which day the calendar week starts on
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dashboard</CardTitle>
|
||||
<CardDescription>Configure your dashboard preferences</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleUpcomingDaysSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="upcoming_days">Upcoming Days Range</Label>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
id="upcoming_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="30"
|
||||
value={upcomingDays}
|
||||
onChange={(e) => setUpcomingDays(parseInt(e.target.value))}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How many days ahead to show in the upcoming items widget
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" disabled={isUpdating}>
|
||||
{isUpdating ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Weather</CardTitle>
|
||||
<CardDescription>Configure the weather widget on your dashboard</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label>Location</Label>
|
||||
{hasLocation ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-sm text-foreground">
|
||||
<MapPin className="h-3.5 w-3.5 text-accent" />
|
||||
{settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLocationClear}
|
||||
className="inline-flex items-center justify-center rounded-md h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title="Clear location"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={searchRef} className="relative max-w-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search for a city..."
|
||||
value={locationQuery}
|
||||
onChange={(e) => handleLocationInputChange(e.target.value)}
|
||||
onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{isSearching && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
{/* Profile */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<User className="h-4 w-4 text-accent" aria-hidden="true" />
|
||||
</div>
|
||||
{showDropdown && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden">
|
||||
{locationResults.map((loc, i) => {
|
||||
return (
|
||||
<button
|
||||
key={`${loc.lat}-${loc.lon}-${i}`}
|
||||
type="button"
|
||||
onClick={() => handleLocationSelect(loc)}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2.5 text-sm text-left hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span>
|
||||
<span className="text-foreground font-medium">{loc.name}</span>
|
||||
{(loc.state || loc.country) && (
|
||||
<span className="text-muted-foreground">
|
||||
{loc.state ? `, ${loc.state}` : ''}{loc.country ? `, ${loc.country}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>Personalize how UMBRA greets you</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preferred_name">Preferred Name</Label>
|
||||
<Input
|
||||
id="preferred_name"
|
||||
type="text"
|
||||
placeholder="Enter your name"
|
||||
value={preferredName}
|
||||
onChange={(e) => setPreferredName(e.target.value)}
|
||||
onBlur={handleNameSave}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave(); }}
|
||||
maxLength={100}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Appearance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-purple-500/10">
|
||||
<Palette className="h-4 w-4 text-purple-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>Customize the look and feel of your application</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div>
|
||||
<Label>Accent Color</Label>
|
||||
<div className="grid grid-cols-4 gap-3 mt-3">
|
||||
{accentColors.map((color) => (
|
||||
<button
|
||||
key={color.name}
|
||||
type="button"
|
||||
onClick={() => handleColorChange(color.name)}
|
||||
aria-pressed={selectedColor === color.name}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2 p-3 rounded-lg border transition-all duration-150',
|
||||
selectedColor === color.name
|
||||
? 'border-accent/50 bg-accent/5'
|
||||
: 'border-border hover:border-border/80 hover:bg-card-elevated'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="h-8 w-8 rounded-full"
|
||||
style={{ backgroundColor: color.color }}
|
||||
/>
|
||||
<span className="text-[10px] tracking-wider uppercase text-muted-foreground">
|
||||
{color.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Calendar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-blue-500/10">
|
||||
<CalendarDays className="h-4 w-4 text-blue-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Calendar</CardTitle>
|
||||
<CardDescription>Configure your calendar preferences</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label>First Day of Week</Label>
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFirstDayChange(0)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium transition-colors duration-150',
|
||||
firstDayOfWeek === 0
|
||||
? 'text-accent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: firstDayOfWeek === 0 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: firstDayOfWeek === 0 ? 'hsl(var(--accent-color))' : undefined,
|
||||
}}
|
||||
>
|
||||
Sunday
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFirstDayChange(1)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium transition-colors duration-150',
|
||||
firstDayOfWeek === 1
|
||||
? 'text-accent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: firstDayOfWeek === 1 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: firstDayOfWeek === 1 ? 'hsl(var(--accent-color))' : undefined,
|
||||
}}
|
||||
>
|
||||
Monday
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sets which day the calendar week starts on
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dashboard */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-teal-500/10">
|
||||
<LayoutDashboard className="h-4 w-4 text-teal-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Dashboard</CardTitle>
|
||||
<CardDescription>Configure your dashboard preferences</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="upcoming_days">Upcoming Days Range</Label>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Input
|
||||
id="upcoming_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="30"
|
||||
value={upcomingDays}
|
||||
onChange={(e) => setUpcomingDays(parseInt(e.target.value))}
|
||||
onBlur={handleUpcomingDaysSave}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleUpcomingDaysSave(); }}
|
||||
className="w-24"
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How many days ahead to show in the upcoming items widget
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Weather */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-amber-500/10">
|
||||
<Cloud className="h-4 w-4 text-amber-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Weather</CardTitle>
|
||||
<CardDescription>Configure the weather widget on your dashboard</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label>Location</Label>
|
||||
{hasLocation ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-sm text-foreground">
|
||||
<MapPin className="h-3.5 w-3.5 text-accent" />
|
||||
{settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLocationClear}
|
||||
className="inline-flex items-center justify-center rounded-md h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title="Clear location"
|
||||
aria-label="Clear weather location"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={searchRef} className="relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search for a city..."
|
||||
value={locationQuery}
|
||||
onChange={(e) => handleLocationInputChange(e.target.value)}
|
||||
onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{isSearching && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
{showDropdown && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden">
|
||||
{locationResults.map((loc, i) => (
|
||||
<button
|
||||
key={`${loc.lat}-${loc.lon}-${i}`}
|
||||
type="button"
|
||||
onClick={() => handleLocationSelect(loc)}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2.5 text-sm text-left hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span>
|
||||
<span className="text-foreground font-medium">{loc.name}</span>
|
||||
{(loc.state || loc.country) && (
|
||||
<span className="text-muted-foreground">
|
||||
{loc.state ? `, ${loc.state}` : ''}{loc.country ? `, ${loc.country}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Search and select your city for accurate weather data on the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Search and select your city for accurate weather data on the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security</CardTitle>
|
||||
<CardDescription>Change your PIN</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handlePinSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="old_pin">Current PIN</Label>
|
||||
<Input
|
||||
id="old_pin"
|
||||
type="password"
|
||||
value={pinForm.oldPin}
|
||||
onChange={(e) => setPinForm({ ...pinForm, oldPin: e.target.value })}
|
||||
required
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new_pin">New PIN</Label>
|
||||
<Input
|
||||
id="new_pin"
|
||||
type="password"
|
||||
value={pinForm.newPin}
|
||||
onChange={(e) => setPinForm({ ...pinForm, newPin: e.target.value })}
|
||||
required
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm_pin">Confirm New PIN</Label>
|
||||
<Input
|
||||
id="confirm_pin"
|
||||
type="password"
|
||||
value={pinForm.confirmPin}
|
||||
onChange={(e) => setPinForm({ ...pinForm, confirmPin: e.target.value })}
|
||||
required
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isChangingPin}>
|
||||
{isChangingPin ? 'Changing...' : 'Change PIN'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Right column: Security, Authentication, Integrations ── */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Security (auto-lock) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-emerald-500/10">
|
||||
<Shield className="h-4 w-4 text-emerald-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Security</CardTitle>
|
||||
<CardDescription>Configure screen lock behavior</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Auto-lock</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically lock the screen after idle time
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoLockEnabled}
|
||||
onCheckedChange={handleAutoLockToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="auto_lock_minutes" className="shrink-0">Lock after</Label>
|
||||
<Input
|
||||
id="auto_lock_minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={autoLockMinutes}
|
||||
onChange={(e) => setAutoLockMinutes(e.target.value === '' ? '' : parseInt(e.target.value) || '')}
|
||||
onBlur={handleAutoLockMinutesSave}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAutoLockMinutesSave(); }}
|
||||
className="w-20"
|
||||
disabled={!autoLockEnabled || isUpdating}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground shrink-0">minutes</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Authentication (TOTP + password change) */}
|
||||
<TotpSetupSection />
|
||||
|
||||
{/* Integrations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-orange-500/10">
|
||||
<Blocks className="h-4 w-4 text-orange-400" aria-hidden="true" />
|
||||
</div>
|
||||
<CardTitle>Integrations</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NtfySettingsSection settings={settings} updateSettings={updateSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
539
frontend/src/components/settings/TotpSetupSection.tsx
Normal file
539
frontend/src/components/settings/TotpSetupSection.tsx
Normal file
@ -0,0 +1,539 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Copy,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { TotpSetupResponse } from '@/types';
|
||||
|
||||
type TotpSetupState = 'idle' | 'setup' | 'confirm' | 'backup_codes' | 'enabled';
|
||||
|
||||
export default function TotpSetupSection() {
|
||||
// ── Password change state ──
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
oldPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||
|
||||
// ── TOTP state ──
|
||||
const [totpSetupState, setTotpSetupState] = useState<TotpSetupState>('idle');
|
||||
const [qrCodeBase64, setQrCodeBase64] = useState('');
|
||||
const [totpSecret, setTotpSecret] = useState('');
|
||||
const [totpConfirmCode, setTotpConfirmCode] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [isTotpSetupPending, setIsTotpSetupPending] = useState(false);
|
||||
const [isTotpConfirmPending, setIsTotpConfirmPending] = useState(false);
|
||||
|
||||
// ── Disable / Regenerate dialog state ──
|
||||
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
|
||||
const [regenDialogOpen, setRegenDialogOpen] = useState(false);
|
||||
const [dialogPassword, setDialogPassword] = useState('');
|
||||
const [dialogCode, setDialogCode] = useState('');
|
||||
const [isDialogPending, setIsDialogPending] = useState(false);
|
||||
|
||||
// On mount: check TOTP status to set initial state
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ enabled: boolean }>('/auth/totp/status')
|
||||
.then(({ data }) => {
|
||||
setTotpSetupState(data.enabled ? 'enabled' : 'idle');
|
||||
})
|
||||
.catch(() => {
|
||||
// Endpoint not yet available — default to idle
|
||||
setTotpSetupState('idle');
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Password change ──
|
||||
const handlePasswordChange = async () => {
|
||||
const { oldPassword, newPassword, confirmPassword } = passwordForm;
|
||||
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||
toast.error('All password fields are required');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 12) {
|
||||
toast.error('Password must be at least 12 characters');
|
||||
return;
|
||||
}
|
||||
const hasLetter = /[a-zA-Z]/.test(newPassword);
|
||||
const hasNonLetter = /[^a-zA-Z]/.test(newPassword);
|
||||
if (!hasLetter || !hasNonLetter) {
|
||||
toast.error('Password must contain at least one letter and one non-letter character');
|
||||
return;
|
||||
}
|
||||
setIsChangingPassword(true);
|
||||
try {
|
||||
await api.post('/auth/change-password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
||||
toast.success('Password changed successfully');
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to change password'));
|
||||
} finally {
|
||||
setIsChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── TOTP setup ──
|
||||
const handleBeginTotpSetup = async () => {
|
||||
setIsTotpSetupPending(true);
|
||||
try {
|
||||
const { data } = await api.post<TotpSetupResponse>('/auth/totp/setup');
|
||||
setQrCodeBase64(data.qr_code_base64);
|
||||
setTotpSecret(data.secret);
|
||||
setBackupCodes(data.backup_codes);
|
||||
setTotpSetupState('setup');
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to begin TOTP setup'));
|
||||
} finally {
|
||||
setIsTotpSetupPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTotpConfirm = async () => {
|
||||
if (!totpConfirmCode || totpConfirmCode.length !== 6) {
|
||||
toast.error('Enter a 6-digit code');
|
||||
return;
|
||||
}
|
||||
setIsTotpConfirmPending(true);
|
||||
try {
|
||||
const { data } = await api.post<{ backup_codes: string[] }>('/auth/totp/confirm', {
|
||||
code: totpConfirmCode,
|
||||
});
|
||||
setBackupCodes(data.backup_codes);
|
||||
setTotpConfirmCode('');
|
||||
setTotpSetupState('backup_codes');
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Invalid code — try again'));
|
||||
} finally {
|
||||
setIsTotpConfirmPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyBackupCodes = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodes.join('\n'));
|
||||
toast.success('Backup codes copied');
|
||||
} catch {
|
||||
toast.error('Failed to copy codes');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupCodesConfirmed = () => {
|
||||
setBackupCodes([]);
|
||||
setQrCodeBase64('');
|
||||
setTotpSecret('');
|
||||
setTotpSetupState('enabled');
|
||||
};
|
||||
|
||||
// ── Disable TOTP ──
|
||||
const handleDisableConfirm = async () => {
|
||||
if (!dialogPassword || !dialogCode) {
|
||||
toast.error('Password and code are required');
|
||||
return;
|
||||
}
|
||||
setIsDialogPending(true);
|
||||
try {
|
||||
await api.post('/auth/totp/disable', { password: dialogPassword, code: dialogCode });
|
||||
setDisableDialogOpen(false);
|
||||
setDialogPassword('');
|
||||
setDialogCode('');
|
||||
setTotpSetupState('idle');
|
||||
toast.success('Two-factor authentication disabled');
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to disable TOTP'));
|
||||
} finally {
|
||||
setIsDialogPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Regenerate backup codes ──
|
||||
const handleRegenConfirm = async () => {
|
||||
if (!dialogPassword || !dialogCode) {
|
||||
toast.error('Password and code are required');
|
||||
return;
|
||||
}
|
||||
setIsDialogPending(true);
|
||||
try {
|
||||
const { data } = await api.post<{ backup_codes: string[] }>(
|
||||
'/auth/totp/backup-codes/regenerate',
|
||||
{ password: dialogPassword, code: dialogCode }
|
||||
);
|
||||
setBackupCodes(data.backup_codes);
|
||||
setRegenDialogOpen(false);
|
||||
setDialogPassword('');
|
||||
setDialogCode('');
|
||||
setTotpSetupState('backup_codes');
|
||||
toast.success('New backup codes generated');
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to regenerate backup codes'));
|
||||
} finally {
|
||||
setIsDialogPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDisableDialogOpen(false);
|
||||
setRegenDialogOpen(false);
|
||||
setDialogPassword('');
|
||||
setDialogCode('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<ShieldCheck className="h-4 w-4 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Authentication</CardTitle>
|
||||
<CardDescription>Manage your password and two-factor authentication</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
|
||||
{/* Subsection A: Change Password */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium">Change Password</p>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="old_password">Current Password</Label>
|
||||
<Input
|
||||
id="old_password"
|
||||
type="password"
|
||||
value={passwordForm.oldPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new_password">New Password</Label>
|
||||
<Input
|
||||
id="new_password"
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm_password">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) =>
|
||||
setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePasswordChange}
|
||||
disabled={isChangingPassword}
|
||||
size="sm"
|
||||
>
|
||||
{isChangingPassword ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Saving</>
|
||||
) : (
|
||||
'Change Password'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Subsection B: TOTP MFA Setup */}
|
||||
<div className="space-y-4">
|
||||
{totpSetupState === 'idle' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Two-Factor Authentication</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Add an extra layer of security with an authenticator app
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBeginTotpSetup}
|
||||
disabled={isTotpSetupPending}
|
||||
>
|
||||
{isTotpSetupPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Enable MFA'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpSetupState === 'setup' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium">Scan with your authenticator app</p>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={`data:image/png;base64,${qrCodeBase64}`}
|
||||
alt="TOTP QR code — scan with your authenticator app"
|
||||
className="h-40 w-40 rounded-md border border-border"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Can't scan? Enter this code manually:
|
||||
</p>
|
||||
<code className="block text-center text-xs font-mono bg-secondary px-3 py-2 rounded-md tracking-widest break-all">
|
||||
{totpSecret}
|
||||
</code>
|
||||
<Button className="w-full" onClick={() => setTotpSetupState('confirm')}>
|
||||
Next: Verify Code
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpSetupState === 'confirm' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium">Verify your authenticator app</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter the 6-digit code shown in your app to confirm setup.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp-confirm-code">Verification Code</Label>
|
||||
<Input
|
||||
id="totp-confirm-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={totpConfirmCode}
|
||||
onChange={(e) => setTotpConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="text-center tracking-widest text-lg"
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleTotpConfirm();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleTotpConfirm}
|
||||
disabled={isTotpConfirmPending}
|
||||
>
|
||||
{isTotpConfirmPending ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Verifying</>
|
||||
) : (
|
||||
'Verify & Enable'
|
||||
)}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTotpSetupState('setup')}
|
||||
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Back to QR code
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpSetupState === 'backup_codes' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-400" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-amber-400">Save these backup codes now</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These {backupCodes.length} codes can each be used once if you lose access to your
|
||||
authenticator app. Store them somewhere safe — they will not be shown again.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 bg-secondary rounded-md p-3">
|
||||
{backupCodes.map((code, i) => (
|
||||
<code key={i} className="text-xs font-mono text-foreground text-center py-0.5">
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyBackupCodes}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy All Codes
|
||||
</Button>
|
||||
<Button className="w-full" onClick={handleBackupCodesConfirmed}>
|
||||
I've saved my backup codes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpSetupState === 'enabled' && (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium flex items-center gap-2 flex-wrap">
|
||||
Two-Factor Authentication
|
||||
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-400 font-medium uppercase tracking-wide">
|
||||
<Check className="h-3 w-3" aria-hidden="true" />
|
||||
Enabled
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Your account is protected with an authenticator app
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={() => setRegenDialogOpen(true)}>
|
||||
New backup codes
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setDisableDialogOpen(true)}
|
||||
className="bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/30"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disable TOTP Dialog */}
|
||||
<Dialog open={disableDialogOpen} onOpenChange={closeDialog}>
|
||||
<DialogContent>
|
||||
<DialogClose onClick={closeDialog} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your password and a current authenticator code to disable MFA.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-password">Password</Label>
|
||||
<Input
|
||||
id="disable-password"
|
||||
type="password"
|
||||
value={dialogPassword}
|
||||
onChange={(e) => setDialogPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-code">Authenticator Code</Label>
|
||||
<Input
|
||||
id="disable-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={dialogCode}
|
||||
onChange={(e) => setDialogCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="text-center tracking-widest"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={closeDialog} disabled={isDialogPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDisableConfirm}
|
||||
disabled={isDialogPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDialogPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Disable MFA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Regenerate Backup Codes Dialog */}
|
||||
<Dialog open={regenDialogOpen} onOpenChange={closeDialog}>
|
||||
<DialogContent>
|
||||
<DialogClose onClick={closeDialog} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate New Backup Codes</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your existing backup codes will be invalidated. Enter your password and a current
|
||||
authenticator code to continue.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="regen-password">Password</Label>
|
||||
<Input
|
||||
id="regen-password"
|
||||
type="password"
|
||||
value={dialogPassword}
|
||||
onChange={(e) => setDialogPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="regen-code">Authenticator Code</Label>
|
||||
<Input
|
||||
id="regen-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={dialogCode}
|
||||
onChange={(e) => setDialogCode(e.target.value.replace(/\D/g, ''))}
|
||||
className="text-center tracking-widest"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={closeDialog} disabled={isDialogPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRegenConfirm}
|
||||
disabled={isDialogPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDialogPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Generate New Codes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 invalid:ring-red-500 invalid:border-red-500',
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
43
frontend/src/components/ui/switch.tsx
Normal file
43
frontend/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent',
|
||||
'transition-colors duration-200 ease-in-out',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-accent' : 'bg-input',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-lg',
|
||||
'transform transition-transform duration-200 ease-in-out',
|
||||
checked ? 'translate-x-4' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch };
|
||||
@ -5,6 +5,7 @@ import { toast } from 'sonner';
|
||||
import { Bell, X } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
import { getRelativeTime, toLocalDatetime } from '@/lib/date-utils';
|
||||
import { useLock } from '@/hooks/useLock';
|
||||
import SnoozeDropdown from '@/components/reminders/SnoozeDropdown';
|
||||
import type { Reminder } from '@/types';
|
||||
|
||||
@ -29,6 +30,7 @@ export function useAlerts() {
|
||||
export function AlertsProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const { isLocked } = useLock();
|
||||
const firedRef = useRef<Set<number>>(new Set());
|
||||
const prevPathnameRef = useRef(location.pathname);
|
||||
const isDashboard = location.pathname === '/' || location.pathname === '/dashboard';
|
||||
@ -186,12 +188,20 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Unified toast management — single effect handles both route changes and new alerts
|
||||
// Unified toast management — single effect handles route changes, lock state, and new alerts
|
||||
useEffect(() => {
|
||||
const wasOnDashboard = prevPathnameRef.current === '/' || prevPathnameRef.current === '/dashboard';
|
||||
const nowOnDashboard = isDashboard;
|
||||
prevPathnameRef.current = location.pathname;
|
||||
|
||||
// Suppress toasts while locked — dismiss any visible ones
|
||||
if (isLocked) {
|
||||
alerts.forEach((a) => toast.dismiss(`reminder-${a.id}`));
|
||||
toast.dismiss('reminder-summary');
|
||||
firedRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (nowOnDashboard) {
|
||||
// On dashboard — dismiss all toasts, banner takes over
|
||||
alerts.forEach((a) => toast.dismiss(`reminder-${a.id}`));
|
||||
@ -207,7 +217,7 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// On non-dashboard page — fire toasts for any unfired alerts
|
||||
fireToasts(alerts);
|
||||
}, [location.pathname, isDashboard, alerts]);
|
||||
}, [location.pathname, isDashboard, alerts, isLocked]);
|
||||
|
||||
return (
|
||||
<AlertsContext.Provider value={{ alerts, dismiss: handleDismiss, snooze: handleSnooze }}>
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { AuthStatus } from '@/types';
|
||||
import type { AuthStatus, LoginResponse } from '@/types';
|
||||
|
||||
export function useAuth() {
|
||||
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 authQuery = useQuery({
|
||||
queryKey: ['auth'],
|
||||
@ -15,18 +18,38 @@ export function useAuth() {
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (pin: string) => {
|
||||
const { data } = await api.post('/auth/login', { pin });
|
||||
mutationFn: async ({ username, password }: { username: string; password: string }) => {
|
||||
const { data } = await api.post<LoginResponse>('/auth/login', { username, password });
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if ('mfa_token' in data && data.totp_required) {
|
||||
// MFA required — store token locally, do NOT mark as authenticated yet
|
||||
setMfaToken(data.mfa_token);
|
||||
} else {
|
||||
setMfaToken(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const totpVerifyMutation = useMutation({
|
||||
mutationFn: async (code: string) => {
|
||||
const { data } = await api.post('/auth/totp-verify', {
|
||||
mfa_token: mfaToken,
|
||||
code,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMfaToken(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||
},
|
||||
});
|
||||
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: async (pin: string) => {
|
||||
const { data } = await api.post('/auth/setup', { pin });
|
||||
mutationFn: async ({ username, password }: { username: string; password: string }) => {
|
||||
const { data } = await api.post('/auth/setup', { username, password });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -40,6 +63,7 @@ export function useAuth() {
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMfaToken(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['auth'] });
|
||||
},
|
||||
});
|
||||
@ -47,10 +71,13 @@ export function useAuth() {
|
||||
return {
|
||||
authStatus: authQuery.data,
|
||||
isLoading: authQuery.isLoading,
|
||||
mfaRequired: mfaToken !== null,
|
||||
login: loginMutation.mutateAsync,
|
||||
verifyTotp: totpVerifyMutation.mutateAsync,
|
||||
setup: setupMutation.mutateAsync,
|
||||
logout: logoutMutation.mutateAsync,
|
||||
isLoginPending: loginMutation.isPending,
|
||||
isTotpPending: totpVerifyMutation.isPending,
|
||||
isSetupPending: setupMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
110
frontend/src/hooks/useLock.tsx
Normal file
110
frontend/src/hooks/useLock.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useIsMutating } from '@tanstack/react-query';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface LockContextValue {
|
||||
isLocked: boolean;
|
||||
lock: () => void;
|
||||
unlock: (password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const LockContext = createContext<LockContextValue | null>(null);
|
||||
|
||||
const ACTIVITY_EVENTS = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart'] as const;
|
||||
const THROTTLE_MS = 5_000; // only reset timer every 5s of activity
|
||||
|
||||
export function LockProvider({ children }: { children: ReactNode }) {
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const { settings } = useSettings();
|
||||
const activeMutations = useIsMutating();
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastActivityRef = useRef<number>(Date.now());
|
||||
const activeMutationsRef = useRef(activeMutations);
|
||||
activeMutationsRef.current = activeMutations;
|
||||
|
||||
const lock = useCallback(() => {
|
||||
setIsLocked(true);
|
||||
}, []);
|
||||
|
||||
const unlock = useCallback(async (password: string) => {
|
||||
const { data } = await api.post<{ verified: boolean }>('/auth/verify-password', { password });
|
||||
if (data.verified) {
|
||||
setIsLocked(false);
|
||||
lastActivityRef.current = Date.now();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-lock idle timer
|
||||
useEffect(() => {
|
||||
const enabled = settings?.auto_lock_enabled ?? false;
|
||||
const minutes = settings?.auto_lock_minutes ?? 5;
|
||||
|
||||
if (!enabled || isLocked) {
|
||||
// Clear any existing timer when disabled or already locked
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = minutes * 60_000;
|
||||
|
||||
const resetTimer = () => {
|
||||
// Don't lock while TanStack mutations are in flight
|
||||
if (activeMutationsRef.current > 0) return;
|
||||
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
lock();
|
||||
}, timeoutMs);
|
||||
};
|
||||
|
||||
const handleActivity = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastActivityRef.current < THROTTLE_MS) return;
|
||||
lastActivityRef.current = now;
|
||||
resetTimer();
|
||||
};
|
||||
|
||||
// Start the initial timer
|
||||
resetTimer();
|
||||
|
||||
// Attach throttled listeners
|
||||
for (const event of ACTIVITY_EVENTS) {
|
||||
document.addEventListener(event, handleActivity, { passive: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const event of ACTIVITY_EVENTS) {
|
||||
document.removeEventListener(event, handleActivity);
|
||||
}
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]);
|
||||
|
||||
return (
|
||||
<LockContext.Provider value={{ isLocked, lock, unlock }}>
|
||||
{children}
|
||||
</LockContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLock(): LockContextValue {
|
||||
const ctx = useContext(LockContext);
|
||||
if (!ctx) throw new Error('useLock must be used within a LockProvider');
|
||||
return ctx;
|
||||
}
|
||||
@ -14,7 +14,9 @@ export function useSettings() {
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (updates: Partial<Settings> & { preferred_name?: string | null }) => {
|
||||
mutationFn: async (
|
||||
updates: Partial<Settings> & { preferred_name?: string | null; ntfy_auth_token?: string }
|
||||
) => {
|
||||
const { data } = await api.put<Settings>('/settings', updates);
|
||||
return data;
|
||||
},
|
||||
@ -23,19 +25,12 @@ export function useSettings() {
|
||||
},
|
||||
});
|
||||
|
||||
const changePinMutation = useMutation({
|
||||
mutationFn: async ({ oldPin, newPin }: { oldPin: string; newPin: string }) => {
|
||||
const { data } = await api.put('/settings/pin', { old_pin: oldPin, new_pin: newPin });
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
settings: settingsQuery.data,
|
||||
isLoading: settingsQuery.isLoading,
|
||||
updateSettings: updateMutation.mutateAsync,
|
||||
changePin: changePinMutation.mutateAsync,
|
||||
updateSettings: updateMutation.mutateAsync as (
|
||||
updates: Partial<Settings> & { preferred_name?: string | null; ntfy_auth_token?: string }
|
||||
) => Promise<Settings>,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isChangingPin: changePinMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
@ -7,6 +7,9 @@ const ACCENT_PRESETS: Record<string, { h: number; s: number; l: number }> = {
|
||||
purple: { h: 258, s: 89.5, l: 66.3 },
|
||||
orange: { h: 21, s: 94.6, l: 53.3 },
|
||||
green: { h: 142, s: 70.6, l: 45.3 },
|
||||
red: { h: 0, s: 84.2, l: 60.2 },
|
||||
pink: { h: 330, s: 81.2, l: 60.4 },
|
||||
yellow: { h: 48, s: 83.3, l: 47.5 },
|
||||
};
|
||||
|
||||
export function useTheme() {
|
||||
|
||||
@ -192,3 +192,55 @@
|
||||
color: hsl(var(--accent-color));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Form validation — red outline only after submit attempt ── */
|
||||
form[data-submitted] input:invalid,
|
||||
form[data-submitted] select:invalid,
|
||||
form[data-submitted] textarea:invalid {
|
||||
border-color: hsl(0 62.8% 50%);
|
||||
box-shadow: 0 0 0 2px hsl(0 62.8% 50% / 0.25);
|
||||
}
|
||||
|
||||
/* ── Ambient background animations ── */
|
||||
|
||||
@keyframes drift-1 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
25% { transform: translate(60px, 40px); }
|
||||
50% { transform: translate(-30px, 80px); }
|
||||
75% { transform: translate(40px, -20px); }
|
||||
}
|
||||
|
||||
@keyframes drift-2 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
25% { transform: translate(-50px, -30px); }
|
||||
50% { transform: translate(40px, -60px); }
|
||||
75% { transform: translate(-20px, 40px); }
|
||||
}
|
||||
|
||||
@keyframes drift-3 {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
33% { transform: translate(30px, -50px); }
|
||||
66% { transform: translate(-40px, 30px); }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-drift-1 { animation: drift-1 25s ease-in-out infinite; }
|
||||
.animate-drift-2 { animation: drift-2 30s ease-in-out infinite; }
|
||||
.animate-drift-3 { animation: drift-3 20s ease-in-out infinite; }
|
||||
.animate-slide-up { animation: slide-up 0.5s ease-out both; }
|
||||
.animate-fade-in { animation: fade-in 0.3s ease-out both; }
|
||||
|
||||
@ -6,6 +6,14 @@ import { Toaster } from 'sonner';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
// Mark forms as submitted so CSS validation outlines only appear after a submit attempt.
|
||||
// The attribute is cleared naturally when Sheet/Dialog forms unmount and remount.
|
||||
document.addEventListener('submit', (e) => {
|
||||
if (e.target instanceof HTMLFormElement) {
|
||||
e.target.setAttribute('data-submitted', '');
|
||||
}
|
||||
}, true);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
export interface Settings {
|
||||
id: number;
|
||||
user_id: number;
|
||||
accent_color: string;
|
||||
upcoming_days: number;
|
||||
preferred_name?: string | null;
|
||||
@ -7,6 +8,21 @@ export interface Settings {
|
||||
weather_lat?: number | null;
|
||||
weather_lon?: number | null;
|
||||
first_day_of_week: number;
|
||||
// ntfy push notification fields
|
||||
ntfy_server_url: string | null;
|
||||
ntfy_topic: string | null;
|
||||
ntfy_enabled: boolean;
|
||||
ntfy_events_enabled: boolean;
|
||||
ntfy_reminders_enabled: boolean;
|
||||
ntfy_todos_enabled: boolean;
|
||||
ntfy_projects_enabled: boolean;
|
||||
ntfy_event_lead_minutes: number;
|
||||
ntfy_todo_lead_days: number;
|
||||
ntfy_project_lead_days: number;
|
||||
ntfy_has_token: boolean;
|
||||
// Auto-lock settings
|
||||
auto_lock_enabled: boolean;
|
||||
auto_lock_minutes: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -177,6 +193,26 @@ export interface AuthStatus {
|
||||
setup_required: boolean;
|
||||
}
|
||||
|
||||
// Login response discriminated union
|
||||
export interface LoginSuccessResponse {
|
||||
authenticated: true;
|
||||
}
|
||||
|
||||
export interface LoginMfaRequiredResponse {
|
||||
authenticated: false;
|
||||
totp_required: true;
|
||||
mfa_token: string;
|
||||
}
|
||||
|
||||
export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse;
|
||||
|
||||
// TOTP setup response (from POST /api/auth/totp/setup)
|
||||
export interface TotpSetupResponse {
|
||||
secret: string;
|
||||
qr_code_base64: string;
|
||||
backup_codes: string[];
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
todays_events: Array<{
|
||||
id: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user