Compare commits

...

22 Commits

Author SHA1 Message Date
17643d54ea Suppress reminder toasts while lock screen is active
Swap LockProvider to outer wrapper so AlertsProvider can read isLocked.
When locked, dismiss all visible reminder toasts and skip firing new ones.
Toasts re-fire normally on unlock via the firedRef.clear() reset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:23:26 +08:00
5ad0a610bd Address all QA review warnings and suggestions for lock screen feature
- [C-1] Add rate limiting and account lockout to /verify-password endpoint
- [W-3] Add max length validator (128 chars) to VerifyPasswordRequest
- [W-1] Move activeMutations to ref in useLock to prevent timer thrashing
- [W-5] Add user_id field to frontend Settings interface
- [S-1] Export auth schemas from schemas registry
- [S-3] Add aria-label to LockOverlay password input

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:20:42 +08:00
aa2d011700 Fix lock overlay z-index and duplicate recurring event notifications
- Lock overlay: z-50 -> z-[100] so it renders above Sheet/Dialog (both z-50)
- Event notifications: skip recurring parent template rows (recurrence_rule
  set + parent_event_id NULL) which duplicate the child instance rows,
  causing double notifications for recurring events

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:12:23 +08:00
f5265a589e Fix form validation: red outline only on submit, add required asterisks
- Remove instant invalid:ring/border from Input component (was showing
  red outline on empty required fields before any interaction)
- Add CSS rule: form[data-submitted] input:invalid shows red border
- Add global submit listener in main.tsx that sets data-submitted on forms
- Add required prop to Labels missing asterisks: PersonForm (First Name),
  LocationForm (Location Name), CalendarForm (Name), LockScreen
  (Username, Password, Confirm Password)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:53:15 +08:00
4207a62ad8 Fix build: remove unused cn import from NtfySettingsSection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:32:46 +08:00
0d2d321fbb Restructure Integrations card: inline title, ntfy sub-section with collapsible config
- Integrations card now has inline icon+title (no description), future-proofed
  for additional integrations as sub-sections
- ntfy is its own sub-section with Bell icon + "ntfy Push Notifications" header
- Connection settings (URL, topic, token) collapse into a "Configure ntfy
  connection" disclosure after saving, keeping the UI tidy
- Notification types and test/save buttons remain visible when enabled
- First-time setup shows connection fields expanded; they collapse on save

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:24:40 +08:00
ca1cd14ed1 Rebalance settings page columns and inline lock-after input
Left column: Profile, Appearance, Calendar, Dashboard, Weather (prefs & display)
Right column: Security, Authentication, Integrations (security & services)

Also inlines the "Lock after [input] minutes" onto a single row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:14:24 +08:00
7d6ac4d257 Fix auto-lock minutes input: allow backspace to clear before retyping
The onChange was using parseInt(...) || 5 which snapped empty input back
to 5 immediately. Now allows empty string as intermediate state; value
is clamped to 1-60 on blur/Enter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:01:06 +08:00
b0af07c270 Add lock screen, auto-lock timeout, and login visual upgrade
- Backend: POST /verify-password endpoint for lock screen re-auth,
  auto_lock_enabled/auto_lock_minutes columns on Settings with migration 025
- Frontend: LockProvider context with idle detection (throttled activity
  listeners, pauses during mutations), Lock button in sidebar, full-screen
  LockOverlay with password re-entry and "Switch account" option
- Settings: Security card with auto-lock toggle and configurable timeout (1-60 min)
- Visual: Upgraded login screen with large title, animated floating gradient
  orbs (3 drift keyframes), subtle grid overlay, shared AmbientBackground component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:03:12 +08:00
e5b6725081 Add red, pink, yellow HSL presets to useTheme accent color map
The swatches were added to SettingsPage but useTheme only had 5 presets,
so selecting the new colors saved to DB but never applied CSS variables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:32:19 +08:00
6094561d74 Fix 500 on settings update: include user_id in explicit SettingsResponse constructor
The W9 fix added user_id to SettingsResponse but missed the manual
_to_settings_response() builder, causing Pydantic validation failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:32:52 +08:00
9b261574ca Fix ImportError: remove stale SettingsCreate and ChangePinRequest from schemas registry
These were removed in the auth migration but schemas/__init__.py still imported them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:04:11 +08:00
4a98b67b0b Address all QA review warnings and suggestions for entity pages
- W1: Add ntfy_has_token property to Settings model for safe from_attributes usage
- W2: Eager-load event location and pass location_name to ntfy template builder
- W3: Add missing accent color swatches (red, pink, yellow) to match backend Literal
- W7: Cap IP rate-limit dict at 10k entries with stale-entry purge to prevent OOM
- W9: Include user_id in SettingsResponse for multi-user readiness

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 07:48:45 +08:00
15c99152d3 Address QA review: model registry, NOT NULL constraint, variable naming, toggle defaults, lockout UX
- C3: Register User, UserSession, NtfySent, TOTPUsage, BackupCode in models/__init__.py
- C4: Enforce settings.user_id NOT NULL after backfill in migration 023, update model
- W4: Rename misleading current_user → current_settings in dashboard.py
- W5: Match NtfySettingsSection initial state defaults to backend (true/1/2)
- W8: Clear lockout banner on username/password input change in LockScreen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:34:21 +08:00
f136a0820d Merge branch 'stage6-track-b-totp-mfa' into stage6-phase4-5-settings-totp-ntfy
# Conflicts:
#	frontend/src/components/settings/NtfySettingsSection.tsx
#	frontend/src/components/settings/TotpSetupSection.tsx
2026-02-25 04:29:33 +08:00
3268bfc5d5 Fix SSRF guard to allow private IPs for LAN ntfy servers (W5)
Remove RFC 1918 blocks from _BLOCKED_NETWORKS — only block loopback
and link-local. Self-hosted ntfy servers are typically on the same LAN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:22:48 +08:00
6ad6056125 Stage 6 Phase 4-5: TOTP setup UI and ntfy integrations settings
Adds Authentication card (password change + TOTP 5-state setup flow) and
Integrations card (ntfy master toggle, connection config, per-type toggles,
test button) to SettingsPage right column in correct order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:21:36 +08:00
b134ad9e8b Implement Stage 6 Track B: TOTP MFA (pyotp, Fernet-encrypted secrets, backup codes)
- models/totp_usage.py: replay-prevention table, unique on (user_id, code, window)
- models/backup_code.py: Argon2id-hashed recovery codes with used_at tracking
- services/totp.py: Fernet encrypt/decrypt, verify_totp_code returns actual window, QR base64, backup code generation
- routers/totp.py: setup (idempotent), confirm, totp-verify (mfa_token + TOTP or backup code), disable, regenerate, status
- alembic/024: creates totp_usage and backup_codes tables
- main.py: register totp router, import new models for Alembic discovery
- requirements.txt: add pyotp>=2.9.0, qrcode[pil]>=7.4.0, cryptography>=42.0.0
- jobs/notifications.py: periodic cleanup for totp_usage (5 min) and expired user_sessions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:18:05 +08:00
fbc452a004 Implement Stage 6 Track A: PIN → Username/Password auth migration
- New User model (username, argon2id password_hash, totp fields, lockout)
- New UserSession model (DB-backed revocation, replaces in-memory set)
- New services/auth.py: Argon2id hashing, bcrypt→Argon2id upgrade path, URLSafeTimedSerializer session/MFA tokens
- New schemas/auth.py: SetupRequest, LoginRequest, ChangePasswordRequest with OWASP password strength validation
- Full rewrite of routers/auth.py: setup/login/logout/status/change-password with account lockout (10 failures → 30-min, HTTP 423), IP rate limiting retained as outer layer, get_current_user + get_current_settings dependencies replacing get_current_session
- Settings model: drop pin_hash, add user_id FK (nullable for migration)
- Schemas/settings.py: remove SettingsCreate, ChangePinRequest, _validate_pin_length
- Settings router: rewrite to use get_current_user + get_current_settings, preserve ntfy test endpoint
- All 11 consumer routers updated: auth-gate-only routers use get_current_user, routers reading Settings fields use get_current_settings
- config.py: add SESSION_MAX_AGE_DAYS, MFA_TOKEN_MAX_AGE_SECONDS, TOTP_ISSUER
- main.py: import User and UserSession models for Alembic discovery
- requirements.txt: add argon2-cffi>=23.1.0
- Migration 023: create users + user_sessions tables, migrate pin_hash → User row (admin), backfill settings.user_id, drop pin_hash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:12:37 +08:00
5a8819c4a5 Stage 6 Phase 2-3: LockScreen rewrite + SettingsPage restructure
- LockScreen: full rewrite — username/password auth (setup/login/TOTP states),
  ambient glow blobs, UMBRA wordmark in flex flow, animate-slide-up card,
  HTTP 423 lockout banner, Loader2 spinner, client-side password validation
- SettingsPage: two-column lg grid (Profile/Appearance/Weather left,
  Calendar/Dashboard right), fixed h-16 page header, icon-anchored CardHeaders,
  labeled accent swatch grid with aria-pressed, max-w-xs removed from name
  input, upcoming days onBlur save with NaN+no-op guard, Security card removed
- useSettings: remove deprecated changePin/isChangingPin stubs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:06:53 +08:00
67456c78dd Implement Track C: NTFY push notification integration
- Add ntfy columns to Settings model (server_url, topic, auth_token, enabled, per-type toggles, lead times)
- Create NtfySent dedup model to prevent duplicate notifications
- Create ntfy service with SSRF validation and async httpx send
- Create ntfy_templates service with per-type payload builders
- Create APScheduler background dispatch job (60s interval, events/reminders/todos/projects)
- Register scheduler in main.py lifespan with max_instances=1
- Update SettingsUpdate with ntfy validators (URL scheme, topic regex, lead time ranges)
- Update SettingsResponse with ntfy fields; ntfy_has_token computed, token never exposed
- Add POST /api/settings/ntfy/test endpoint
- Update GET/PUT settings to use explicit _to_settings_response() helper
- Add Alembic migration 022 for ntfy settings columns + ntfy_sent table
- Add httpx==0.27.2 and apscheduler==3.10.4 to requirements.txt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:04:23 +08:00
7f0ae0b6ef Stage 6 Phase 0-1: Foundation — Switch component, new types, useAuth rewrite, useSettings cleanup
- Create switch.tsx: pure Tailwind Switch toggle, shadcn/ui pattern (forwardRef, cn(), role=switch, aria-checked)
- types/index.ts: extend Settings with all ntfy fields; add LoginSuccessResponse, LoginMfaRequiredResponse, LoginResponse discriminated union, TotpSetupResponse
- useAuth.ts: rewrite with username/password login, ephemeral mfaToken state, totpVerifyMutation, mfaRequired boolean, full mutation exports
- useSettings.ts: remove changePinMutation logic; keep deprecated changePin/isChangingPin stubs for compile compatibility until SettingsPage is rewritten in Phase 3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:02:55 +08:00
56 changed files with 4470 additions and 661 deletions

View File

@ -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')

View 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')

View 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")

View 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")

View File

@ -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",

View 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")

View File

@ -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("/")

View File

@ -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",
]

View 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())

View 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())

View 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)

View File

@ -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())

View 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"),
)

View 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)

View File

@ -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. bcryptArgon2id 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 bcryptArgon2id 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"}

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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))

View File

@ -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))

View File

@ -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(

View File

@ -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))

View File

@ -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"}

View File

@ -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
View 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,
}

View File

@ -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")

View File

@ -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",

View 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 350 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

View File

@ -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")

View 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

View 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

View 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,
}

View 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)

View File

@ -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

View 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>
);
}

View File

@ -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 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>
<CardTitle className="text-2xl">
{isSetup ? 'Welcome to UMBRA' : 'Enter PIN'}
</CardTitle>
<div>
<CardTitle>Two-Factor Authentication</CardTitle>
<CardDescription>
{isSetup
? 'Create a PIN to secure your account'
: 'Enter your PIN to access your dashboard'}
{useBackupCode
? 'Enter one of your backup codes'
: 'Enter the code from your authenticator app'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleTotpSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="pin">{isSetup ? 'Create PIN' : 'PIN'}</Label>
<Label htmlFor="totp-code">
{useBackupCode ? 'Backup Code' : 'Authenticator Code'}
</Label>
<Input
id="pin"
type="password"
value={pin}
onChange={(e) => setPin(e.target.value)}
placeholder="Enter PIN"
required
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>
{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"
/>
<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}
disabled={isLoginPending || isSetupPending || !!lockoutMessage}
>
{isLoginPending || isSetupPending
? 'Please wait...'
: isSetup
? 'Create PIN'
: 'Unlock'}
{isLoginPending || isSetupPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Please wait
</>
) : isSetup ? (
'Create Account'
) : (
'Sign in'
)}
</Button>
</form>
</CardContent>
</>
)}
</Card>
</div>
);

View File

@ -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}

View File

@ -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,6 +14,7 @@ export default function AppLayout() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<LockProvider>
<AlertsProvider>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar
@ -33,6 +36,8 @@ export default function AppLayout() {
</main>
</div>
</div>
<LockOverlay />
</AlertsProvider>
</LockProvider>
);
}

View 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>
);
}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View 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>
);
}

View File

@ -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,52 +172,74 @@ 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">
<div className="max-w-5xl mx-auto">
<div className="grid gap-6 lg:grid-cols-2">
{/* ── Left column: Profile, Appearance, Calendar, Dashboard, Weather ── */}
<div className="space-y-6">
{/* 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>
<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>
<div className="flex gap-3 items-center">
<Input
id="preferred_name"
type="text"
@ -205,10 +248,8 @@ export default function SettingsPage() {
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>
@ -216,44 +257,62 @@ export default function SettingsPage() {
</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 className="space-y-4">
<CardContent>
<div>
<Label>Accent Color</Label>
<div className="flex gap-3 mt-3">
<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(
'h-12 w-12 rounded-full border-2 transition-all hover:scale-110',
'flex flex-col items-center gap-2 p-3 rounded-lg border transition-all duration-150',
selectedColor === color.name
? 'border-white ring-2 ring-offset-2 ring-offset-background'
: 'border-transparent'
? 'border-accent/50 bg-accent/5'
: 'border-border hover:border-border/80 hover:bg-card-elevated'
)}
style={
{
backgroundColor: color.color,
'--tw-ring-color': color.color,
} as CSSProperties
}
title={color.label}
>
<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">
@ -299,13 +358,20 @@ export default function SettingsPage() {
</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>
<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">
@ -316,7 +382,10 @@ export default function SettingsPage() {
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>
@ -324,17 +393,21 @@ export default function SettingsPage() {
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>
{/* 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">
@ -350,12 +423,13 @@ export default function SettingsPage() {
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 max-w-sm">
<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
@ -372,8 +446,7 @@ export default function SettingsPage() {
</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 (
{locationResults.map((loc, i) => (
<button
key={`${loc.lat}-${loc.lon}-${i}`}
type="button"
@ -390,8 +463,7 @@ export default function SettingsPage() {
)}
</span>
</button>
);
})}
))}
</div>
)}
</div>
@ -403,53 +475,77 @@ export default function SettingsPage() {
</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>Change your PIN</CardDescription>
<CardDescription>Configure screen lock behavior</CardDescription>
</div>
</div>
</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"
<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>
<Separator />
<div className="space-y-2">
<Label htmlFor="new_pin">New PIN</Label>
<div className="flex items-center gap-3">
<Label htmlFor="auto_lock_minutes" className="shrink-0">Lock after</Label>
<Input
id="new_pin"
type="password"
value={pinForm.newPin}
onChange={(e) => setPinForm({ ...pinForm, newPin: e.target.value })}
required
className="max-w-xs"
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>
<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>
{/* 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>

View 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>
</>
);
}

View File

@ -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}

View 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 };

View File

@ -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 }}>

View File

@ -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,
};
}

View 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;
}

View File

@ -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,
};
}

View File

@ -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() {

View File

@ -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; }

View File

@ -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: {

View File

@ -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;