Compare commits

...

2 Commits

Author SHA1 Message Date
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
25 changed files with 1329 additions and 644 deletions

View File

@ -0,0 +1,143 @@
"""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. 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

@ -9,6 +9,15 @@ class Settings(BaseSettings):
COOKIE_SECURE: bool = False COOKIE_SECURE: bool = False
OPENWEATHERMAP_API_KEY: str = "" 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( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",

View File

@ -8,6 +8,10 @@ from app.database import engine
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
from app.jobs.notifications import run_notification_dispatch 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
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):

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,4 +1,4 @@
from sqlalchemy import String, Integer, Float, Boolean, func from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@ -9,7 +9,14 @@ class Settings(Base):
__tablename__ = "settings" __tablename__ = "settings"
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
pin_hash: Mapped[str] = mapped_column(String(255), nullable=False)
# FK to users table — nullable during migration, will be NOT NULL after data migration
user_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
accent_color: Mapped[str] = mapped_column(String(20), default="cyan") accent_color: Mapped[str] = mapped_column(String(20), default="cyan")
upcoming_days: Mapped[int] = mapped_column(Integer, default=7) upcoming_days: Mapped[int] = mapped_column(Integer, default=7)
preferred_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None) preferred_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None)

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,239 @@
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.ext.asyncio import AsyncSession
from sqlalchemy import select 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.database import get_db
from app.models.user import User
from app.models.session import UserSession
from app.models.settings import Settings from app.models.settings import Settings
from app.schemas.settings import SettingsCreate from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest
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 from app.config import settings as app_settings
router = APIRouter() router = APIRouter()
# Initialize signer for session management # ---------------------------------------------------------------------------
signer = TimestampSigner(app_settings.SECRET_KEY) # IP-based in-memory rate limit (retained as outer layer for all login attempts)
# ---------------------------------------------------------------------------
# Brute-force protection: track failed login attempts per IP
_failed_attempts: dict[str, list[float]] = defaultdict(list) _failed_attempts: dict[str, list[float]] = defaultdict(list)
_MAX_ATTEMPTS = 5 _MAX_IP_ATTEMPTS = 5
_WINDOW_SECONDS = 300 # 5-minute lockout window _IP_WINDOW_SECONDS = 300 # 5 minutes
# Server-side session revocation (in-memory, sufficient for single-user app)
_revoked_sessions: set[str] = set()
def _check_rate_limit(ip: str) -> None: def _check_ip_rate_limit(ip: str) -> None:
"""Raise 429 if IP has exceeded failed login attempts.""" """Raise 429 if the IP has exceeded the failure window."""
now = time.time() now = time.time()
attempts = _failed_attempts[ip] _failed_attempts[ip] = [t for t in _failed_attempts[ip] if now - t < _IP_WINDOW_SECONDS]
# 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
if not _failed_attempts[ip]: if not _failed_attempts[ip]:
del _failed_attempts[ip] _failed_attempts.pop(ip, None)
elif len(_failed_attempts[ip]) >= _MAX_ATTEMPTS: elif len(_failed_attempts[ip]) >= _MAX_IP_ATTEMPTS:
raise HTTPException( raise HTTPException(
status_code=429, status_code=429,
detail="Too many failed login attempts. Try again in a few minutes.", detail="Too many failed login attempts. Try again in a few minutes.",
) )
def _record_failed_attempt(ip: str) -> None: def _record_ip_failure(ip: str) -> None:
"""Record a failed login attempt for the given IP."""
_failed_attempts[ip].append(time.time()) _failed_attempts[ip].append(time.time())
def hash_pin(pin: str) -> str: # ---------------------------------------------------------------------------
"""Hash a PIN using bcrypt.""" # Cookie helper
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
def _set_session_cookie(response: Response, token: str) -> None: def _set_session_cookie(response: Response, token: str) -> None:
"""Set the session cookie with secure defaults."""
response.set_cookie( response.set_cookie(
key="session", key="session",
value=token, value=token,
httponly=True, httponly=True,
secure=app_settings.COOKIE_SECURE, secure=app_settings.COOKIE_SECURE,
max_age=86400 * 30, # 30 days max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
samesite="lax", 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"), session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db),
) -> Settings: ) -> User:
"""Dependency to verify session and return current settings.""" """
Dependency that verifies the session cookie and returns the authenticated User.
Replaces the old get_current_session (which returned Settings).
Any router that hasn't been updated will get a compile-time type error.
"""
if not session_cookie: if not session_cookie:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
# Check if session has been revoked payload = verify_session_token(session_cookie)
if session_cookie in _revoked_sessions: if payload is None:
raise HTTPException(status_code=401, detail="Session has been revoked")
user_id = verify_session_token(session_cookie)
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid or expired session") 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() settings_obj = result.scalar_one_or_none()
if not settings_obj: 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 return settings_obj
@router.post("/setup") # ---------------------------------------------------------------------------
async def setup_pin( # Account lockout helpers
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()
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") raise HTTPException(status_code=400, detail="Setup already completed")
pin_hash = hash_pin(data.pin) password_hash = hash_password(data.password)
new_settings = Settings(pin_hash=pin_hash) 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) db.add(new_settings)
await db.commit() await db.commit()
await db.refresh(new_settings)
# Create session ip = request.client.host if request.client else "unknown"
token = create_session_token(new_settings.id) user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, new_user, ip, user_agent)
_set_session_cookie(response, token) _set_session_cookie(response, token)
return {"message": "Setup completed successfully", "authenticated": True} return {"message": "Setup completed successfully", "authenticated": True}
@ -136,48 +241,91 @@ async def setup_pin(
@router.post("/login") @router.post("/login")
async def login( async def login(
data: SettingsCreate, data: LoginRequest,
request: Request, request: Request,
response: Response, 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" 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)) # Lookup user — do NOT differentiate "user not found" from "wrong password"
settings_obj = result.scalar_one_or_none() result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none()
if not settings_obj: if not user:
raise HTTPException(status_code=400, detail="Setup required") _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): await _check_account_lockout(user)
_record_failed_attempt(client_ip)
raise HTTPException(status_code=401, detail="Invalid PIN")
# 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) _failed_attempts.pop(client_ip, None)
await _record_successful_login(db, user)
# Create session # If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session
token = create_session_token(settings_obj.id) 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) _set_session_cookie(response, token)
return {"message": "Login successful", "authenticated": True} return {"authenticated": True}
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(
response: Response, 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: 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( response.delete_cookie(
key="session", key="session",
httponly=True, httponly=True,
secure=app_settings.COOKIE_SECURE, secure=app_settings.COOKIE_SECURE,
samesite="lax" samesite="lax",
) )
return {"message": "Logout successful"} return {"message": "Logout successful"}
@ -185,23 +333,48 @@ async def logout(
@router.get("/status") @router.get("/status")
async def auth_status( async def auth_status(
session_cookie: Optional[str] = Cookie(None, alias="session"), 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)) Check authentication status and whether initial setup has been performed.
settings_obj = result.scalar_one_or_none() Used by the frontend to decide whether to show login vs setup screen.
"""
setup_required = settings_obj is None user_result = await db.execute(select(User))
existing_user = user_result.scalar_one_or_none()
setup_required = existing_user is None
authenticated = False authenticated = False
if not setup_required and session_cookie: if not setup_required and session_cookie:
if session_cookie in _revoked_sessions: payload = verify_session_token(session_cookie)
authenticated = False if payload:
else: user_id = payload.get("uid")
user_id = verify_session_token(session_cookie) session_id = payload.get("sid")
authenticated = user_id is not None 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 { return {"authenticated": authenticated, "setup_required": setup_required}
"authenticated": authenticated,
"setup_required": setup_required
} @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 import Calendar
from app.models.calendar_event import CalendarEvent from app.models.calendar_event import CalendarEvent
from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse
from app.routers.auth import get_current_session from app.routers.auth import get_current_user
from app.models.settings import Settings from app.models.user import User
router = APIRouter() router = APIRouter()
@ -16,7 +16,7 @@ router = APIRouter()
@router.get("/", response_model=List[CalendarResponse]) @router.get("/", response_model=List[CalendarResponse])
async def get_calendars( async def get_calendars(
db: AsyncSession = Depends(get_db), 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())) result = await db.execute(select(Calendar).order_by(Calendar.is_default.desc(), Calendar.name.asc()))
return result.scalars().all() return result.scalars().all()
@ -26,7 +26,7 @@ async def get_calendars(
async def create_calendar( async def create_calendar(
calendar: CalendarCreate, calendar: CalendarCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
new_calendar = Calendar( new_calendar = Calendar(
name=calendar.name, name=calendar.name,
@ -46,7 +46,7 @@ async def update_calendar(
calendar_id: int, calendar_id: int,
calendar_update: CalendarUpdate, calendar_update: CalendarUpdate,
db: AsyncSession = Depends(get_db), 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)) result = await db.execute(select(Calendar).where(Calendar.id == calendar_id))
calendar = result.scalar_one_or_none() calendar = result.scalar_one_or_none()
@ -72,7 +72,7 @@ async def update_calendar(
async def delete_calendar( async def delete_calendar(
calendar_id: int, calendar_id: int,
db: AsyncSession = Depends(get_db), 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)) result = await db.execute(select(Calendar).where(Calendar.id == calendar_id))
calendar = result.scalar_one_or_none() 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.calendar_event import CalendarEvent
from app.models.reminder import Reminder from app.models.reminder import Reminder
from app.models.project import Project from app.models.project import Project
from app.routers.auth import get_current_session from app.routers.auth import get_current_settings
router = APIRouter() router = APIRouter()
@ -26,7 +26,7 @@ _not_parent_template = or_(
async def get_dashboard( async def get_dashboard(
client_date: Optional[date] = Query(None), client_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_settings)
): ):
"""Get aggregated dashboard data.""" """Get aggregated dashboard data."""
today = client_date or date.today() today = client_date or date.today()
@ -143,7 +143,7 @@ async def get_upcoming(
days: int = Query(default=7, ge=1, le=90), days: int = Query(default=7, ge=1, le=90),
client_date: Optional[date] = Query(None), client_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_settings)
): ):
"""Get unified list of upcoming items (todos, events, reminders) sorted by date.""" """Get unified list of upcoming items (todos, events, reminders) sorted by date."""
today = client_date or date.today() today = client_date or date.today()

View File

@ -3,7 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.database import get_db 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.models.event_template import EventTemplate
from app.schemas.event_template import ( from app.schemas.event_template import (
EventTemplateCreate, EventTemplateCreate,
@ -17,7 +18,7 @@ router = APIRouter()
@router.get("/", response_model=list[EventTemplateResponse]) @router.get("/", response_model=list[EventTemplateResponse])
async def list_templates( async def list_templates(
db: AsyncSession = Depends(get_db), 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)) result = await db.execute(select(EventTemplate).order_by(EventTemplate.name))
return result.scalars().all() return result.scalars().all()
@ -27,7 +28,7 @@ async def list_templates(
async def create_template( async def create_template(
payload: EventTemplateCreate, payload: EventTemplateCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_session), current_user: User = Depends(get_current_user),
): ):
template = EventTemplate(**payload.model_dump()) template = EventTemplate(**payload.model_dump())
db.add(template) db.add(template)
@ -41,7 +42,7 @@ async def update_template(
template_id: int, template_id: int,
payload: EventTemplateUpdate, payload: EventTemplateUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_session), current_user: User = Depends(get_current_user),
): ):
result = await db.execute( result = await db.execute(
select(EventTemplate).where(EventTemplate.id == template_id) select(EventTemplate).where(EventTemplate.id == template_id)
@ -62,7 +63,7 @@ async def update_template(
async def delete_template( async def delete_template(
template_id: int, template_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_session), current_user: User = Depends(get_current_user),
): ):
result = await db.execute( result = await db.execute(
select(EventTemplate).where(EventTemplate.id == template_id) select(EventTemplate).where(EventTemplate.id == template_id)

View File

@ -16,8 +16,8 @@ from app.schemas.calendar_event import (
CalendarEventUpdate, CalendarEventUpdate,
CalendarEventResponse, CalendarEventResponse,
) )
from app.routers.auth import get_current_session from app.routers.auth import get_current_user
from app.models.settings import Settings from app.models.user import User
from app.services.recurrence import generate_occurrences from app.services.recurrence import generate_occurrences
router = APIRouter() router = APIRouter()
@ -119,7 +119,7 @@ async def get_events(
start: Optional[date] = Query(None), start: Optional[date] = Query(None),
end: Optional[date] = Query(None), end: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session), current_user: User = Depends(get_current_user),
) -> List[Any]: ) -> List[Any]:
""" """
Get all calendar events with optional date range filtering. Get all calendar events with optional date range filtering.
@ -180,7 +180,7 @@ async def get_events(
async def create_event( async def create_event(
event: CalendarEventCreate, event: CalendarEventCreate,
db: AsyncSession = Depends(get_db), 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: if event.end_datetime < event.start_datetime:
raise HTTPException(status_code=400, detail="End datetime must be after 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( async def get_event(
event_id: int, event_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session), current_user: User = Depends(get_current_user),
): ):
result = await db.execute( result = await db.execute(
select(CalendarEvent) select(CalendarEvent)
@ -263,7 +263,7 @@ async def update_event(
event_id: int, event_id: int,
event_update: CalendarEventUpdate, event_update: CalendarEventUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session), current_user: User = Depends(get_current_user),
): ):
result = await db.execute( result = await db.execute(
select(CalendarEvent) select(CalendarEvent)
@ -379,7 +379,7 @@ async def delete_event(
event_id: int, event_id: int,
scope: Optional[Literal["this", "this_and_future"]] = Query(None), scope: Optional[Literal["this", "this_and_future"]] = Query(None),
db: AsyncSession = Depends(get_db), 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)) result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
event = result.scalar_one_or_none() event = result.scalar_one_or_none()

View File

@ -12,8 +12,8 @@ import logging
from app.database import get_db from app.database import get_db
from app.models.location import Location from app.models.location import Location
from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse, LocationSearchResult from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse, LocationSearchResult
from app.routers.auth import get_current_session from app.routers.auth import get_current_user
from app.models.settings import Settings from app.models.user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -24,7 +24,7 @@ router = APIRouter()
async def search_locations( async def search_locations(
q: str = Query(..., min_length=1), q: str = Query(..., min_length=1),
db: AsyncSession = Depends(get_db), 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.""" """Search locations from local DB and Nominatim OSM."""
results: List[LocationSearchResult] = [] results: List[LocationSearchResult] = []
@ -86,7 +86,7 @@ async def search_locations(
async def get_locations( async def get_locations(
category: Optional[str] = Query(None), category: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), 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.""" """Get all locations with optional category filter."""
query = select(Location) query = select(Location)
@ -106,7 +106,7 @@ async def get_locations(
async def create_location( async def create_location(
location: LocationCreate, location: LocationCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Create a new location.""" """Create a new location."""
new_location = Location(**location.model_dump()) new_location = Location(**location.model_dump())
@ -121,7 +121,7 @@ async def create_location(
async def get_location( async def get_location(
location_id: int, location_id: int,
db: AsyncSession = Depends(get_db), 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.""" """Get a specific location by ID."""
result = await db.execute(select(Location).where(Location.id == location_id)) result = await db.execute(select(Location).where(Location.id == location_id))
@ -138,7 +138,7 @@ async def update_location(
location_id: int, location_id: int,
location_update: LocationUpdate, location_update: LocationUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Update a location.""" """Update a location."""
result = await db.execute(select(Location).where(Location.id == location_id)) result = await db.execute(select(Location).where(Location.id == location_id))
@ -165,7 +165,7 @@ async def update_location(
async def delete_location( async def delete_location(
location_id: int, location_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Delete a location.""" """Delete a location."""
result = await db.execute(select(Location).where(Location.id == location_id)) result = await db.execute(select(Location).where(Location.id == location_id))

View File

@ -7,8 +7,8 @@ from typing import Optional, List
from app.database import get_db from app.database import get_db
from app.models.person import Person from app.models.person import Person
from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
from app.routers.auth import get_current_session from app.routers.auth import get_current_user
from app.models.settings import Settings from app.models.user import User
router = APIRouter() router = APIRouter()
@ -34,7 +34,7 @@ async def get_people(
search: Optional[str] = Query(None), search: Optional[str] = Query(None),
category: Optional[str] = Query(None), category: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), 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.""" """Get all people with optional search and category filter."""
query = select(Person) query = select(Person)
@ -66,7 +66,7 @@ async def get_people(
async def create_person( async def create_person(
person: PersonCreate, person: PersonCreate,
db: AsyncSession = Depends(get_db), 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.""" """Create a new person with denormalised display name."""
data = person.model_dump() data = person.model_dump()
@ -93,7 +93,7 @@ async def create_person(
async def get_person( async def get_person(
person_id: int, person_id: int,
db: AsyncSession = Depends(get_db), 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.""" """Get a specific person by ID."""
result = await db.execute(select(Person).where(Person.id == person_id)) result = await db.execute(select(Person).where(Person.id == person_id))
@ -110,7 +110,7 @@ async def update_person(
person_id: int, person_id: int,
person_update: PersonUpdate, person_update: PersonUpdate,
db: AsyncSession = Depends(get_db), 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.""" """Update a person and refresh the denormalised display name."""
result = await db.execute(select(Person).where(Person.id == person_id)) result = await db.execute(select(Person).where(Person.id == person_id))
@ -144,7 +144,7 @@ async def update_person(
async def delete_person( async def delete_person(
person_id: int, person_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Delete a person.""" """Delete a person."""
result = await db.execute(select(Person).where(Person.id == person_id)) result = await db.execute(select(Person).where(Person.id == person_id))

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 import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse
from app.routers.auth import get_current_session from app.routers.auth import get_current_user
from app.models.settings import Settings from app.models.user import User
router = APIRouter() router = APIRouter()
@ -46,7 +46,7 @@ def _task_load_options():
async def get_projects( async def get_projects(
tracked: Optional[bool] = Query(None), tracked: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db), 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.""" """Get all projects with their tasks. Optionally filter by tracked status."""
query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc()) query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc())
@ -63,7 +63,7 @@ async def get_projects(
async def get_tracked_tasks( async def get_tracked_tasks(
days: int = Query(7, ge=1, le=90), days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db), 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.""" """Get tasks and subtasks from tracked projects with due dates within the next N days."""
today = date.today() today = date.today()
@ -107,7 +107,7 @@ async def get_tracked_tasks(
async def create_project( async def create_project(
project: ProjectCreate, project: ProjectCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Create a new project.""" """Create a new project."""
new_project = Project(**project.model_dump()) new_project = Project(**project.model_dump())
@ -124,7 +124,7 @@ async def create_project(
async def get_project( async def get_project(
project_id: int, project_id: int,
db: AsyncSession = Depends(get_db), 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.""" """Get a specific project by ID with its tasks."""
query = select(Project).options(*_project_load_options()).where(Project.id == project_id) query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
@ -142,7 +142,7 @@ async def update_project(
project_id: int, project_id: int,
project_update: ProjectUpdate, project_update: ProjectUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Update a project.""" """Update a project."""
result = await db.execute(select(Project).where(Project.id == project_id)) result = await db.execute(select(Project).where(Project.id == project_id))
@ -168,7 +168,7 @@ async def update_project(
async def delete_project( async def delete_project(
project_id: int, project_id: int,
db: AsyncSession = Depends(get_db), 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.""" """Delete a project and all its tasks."""
result = await db.execute(select(Project).where(Project.id == project_id)) result = await db.execute(select(Project).where(Project.id == project_id))
@ -187,7 +187,7 @@ async def delete_project(
async def get_project_tasks( async def get_project_tasks(
project_id: int, project_id: int,
db: AsyncSession = Depends(get_db), 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).""" """Get top-level tasks for a specific project (subtasks are nested)."""
result = await db.execute(select(Project).where(Project.id == project_id)) result = await db.execute(select(Project).where(Project.id == project_id))
@ -216,7 +216,7 @@ async def create_project_task(
project_id: int, project_id: int,
task: ProjectTaskCreate, task: ProjectTaskCreate,
db: AsyncSession = Depends(get_db), 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.""" """Create a new task or subtask for a project."""
result = await db.execute(select(Project).where(Project.id == project_id)) result = await db.execute(select(Project).where(Project.id == project_id))
@ -262,7 +262,7 @@ async def reorder_tasks(
project_id: int, project_id: int,
items: List[ReorderItem], items: List[ReorderItem],
db: AsyncSession = Depends(get_db), 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.""" """Bulk update sort_order for tasks."""
result = await db.execute(select(Project).where(Project.id == project_id)) result = await db.execute(select(Project).where(Project.id == project_id))
@ -293,7 +293,7 @@ async def update_project_task(
task_id: int, task_id: int,
task_update: ProjectTaskUpdate, task_update: ProjectTaskUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Update a project task.""" """Update a project task."""
result = await db.execute( result = await db.execute(
@ -329,7 +329,7 @@ async def delete_project_task(
project_id: int, project_id: int,
task_id: int, task_id: int,
db: AsyncSession = Depends(get_db), 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).""" """Delete a project task (cascades to subtasks)."""
result = await db.execute( result = await db.execute(
@ -355,7 +355,7 @@ async def create_task_comment(
task_id: int, task_id: int,
comment: TaskCommentCreate, comment: TaskCommentCreate,
db: AsyncSession = Depends(get_db), 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.""" """Add a comment to a task."""
result = await db.execute( result = await db.execute(
@ -383,7 +383,7 @@ async def delete_task_comment(
task_id: int, task_id: int,
comment_id: int, comment_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Delete a task comment.""" """Delete a task comment."""
result = await db.execute( result = await db.execute(

View File

@ -8,8 +8,8 @@ from typing import Optional, List
from app.database import get_db from app.database import get_db
from app.models.reminder import Reminder from app.models.reminder import Reminder
from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse, ReminderSnooze from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse, ReminderSnooze
from app.routers.auth import get_current_session from app.routers.auth import get_current_user
from app.models.settings import Settings from app.models.user import User
router = APIRouter() router = APIRouter()
@ -19,7 +19,7 @@ async def get_reminders(
active: Optional[bool] = Query(None), active: Optional[bool] = Query(None),
dismissed: Optional[bool] = Query(None), dismissed: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db), 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.""" """Get all reminders with optional filters."""
query = select(Reminder) query = select(Reminder)
@ -42,7 +42,7 @@ async def get_reminders(
async def get_due_reminders( async def get_due_reminders(
client_now: Optional[datetime] = Query(None), client_now: Optional[datetime] = Query(None),
db: AsyncSession = Depends(get_db), 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.""" """Get reminders that are currently due for alerting."""
now = client_now or datetime.now() now = client_now or datetime.now()
@ -71,7 +71,7 @@ async def snooze_reminder(
reminder_id: int, reminder_id: int,
body: ReminderSnooze, body: ReminderSnooze,
db: AsyncSession = Depends(get_db), 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.""" """Snooze a reminder for N minutes from now."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
@ -96,7 +96,7 @@ async def snooze_reminder(
async def create_reminder( async def create_reminder(
reminder: ReminderCreate, reminder: ReminderCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Create a new reminder.""" """Create a new reminder."""
new_reminder = Reminder(**reminder.model_dump()) new_reminder = Reminder(**reminder.model_dump())
@ -111,7 +111,7 @@ async def create_reminder(
async def get_reminder( async def get_reminder(
reminder_id: int, reminder_id: int,
db: AsyncSession = Depends(get_db), 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.""" """Get a specific reminder by ID."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
@ -128,7 +128,7 @@ async def update_reminder(
reminder_id: int, reminder_id: int,
reminder_update: ReminderUpdate, reminder_update: ReminderUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Update a reminder.""" """Update a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
@ -161,7 +161,7 @@ async def update_reminder(
async def delete_reminder( async def delete_reminder(
reminder_id: int, reminder_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Delete a reminder.""" """Delete a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
@ -180,7 +180,7 @@ async def delete_reminder(
async def dismiss_reminder( async def dismiss_reminder(
reminder_id: int, reminder_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: User = Depends(get_current_user)
): ):
"""Dismiss a reminder.""" """Dismiss a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))

View File

@ -1,11 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db from app.database import get_db
from app.models.settings import Settings from app.models.settings import Settings
from app.schemas.settings import SettingsUpdate, SettingsResponse, ChangePinRequest from app.models.user import User
from app.routers.auth import get_current_session, hash_pin, verify_pin from app.schemas.settings import SettingsUpdate, SettingsResponse
from app.routers.auth import get_current_user, get_current_settings
router = APIRouter() router = APIRouter()
@ -43,51 +43,34 @@ def _to_settings_response(s: Settings) -> SettingsResponse:
@router.get("/", response_model=SettingsResponse) @router.get("/", response_model=SettingsResponse)
async def get_settings( async def get_settings(
db: AsyncSession = Depends(get_db), 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 and ntfy auth token).""" """Get current settings (excluding ntfy auth token)."""
return _to_settings_response(current_user) return _to_settings_response(current_settings)
@router.put("/", response_model=SettingsResponse) @router.put("/", response_model=SettingsResponse)
async def update_settings( async def update_settings(
settings_update: SettingsUpdate, settings_update: SettingsUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_settings: Settings = Depends(get_current_settings)
): ):
"""Update settings.""" """Update settings."""
update_data = settings_update.model_dump(exclude_unset=True) update_data = settings_update.model_dump(exclude_unset=True)
for key, value in update_data.items(): for key, value in update_data.items():
setattr(current_user, key, value) setattr(current_settings, key, value)
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_settings)
return _to_settings_response(current_user) return _to_settings_response(current_settings)
@router.put("/pin")
async def change_pin(
pin_change: ChangePinRequest,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""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")
current_user.pin_hash = hash_pin(pin_change.new_pin)
await db.commit()
return {"message": "PIN changed successfully"}
@router.post("/ntfy/test") @router.post("/ntfy/test")
async def test_ntfy( async def test_ntfy(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_settings: Settings = Depends(get_current_settings)
): ):
""" """
Send a test ntfy notification to verify the user's configuration. Send a test ntfy notification to verify the user's configuration.
@ -95,7 +78,7 @@ async def test_ntfy(
Note: ntfy_enabled does not need to be True to run the test the service Note: ntfy_enabled does not need to be True to run the test the service
call bypasses that check because we pass settings directly. call bypasses that check because we pass settings directly.
""" """
if not current_user.ntfy_server_url or not current_user.ntfy_topic: if not current_settings.ntfy_server_url or not current_settings.ntfy_topic:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="ntfy server URL and topic must be configured before sending a test" detail="ntfy server URL and topic must be configured before sending a test"
@ -104,7 +87,7 @@ async def test_ntfy(
# SSRF-validate the URL before attempting the outbound request # SSRF-validate the URL before attempting the outbound request
from app.services.ntfy import validate_ntfy_host, send_ntfy_notification from app.services.ntfy import validate_ntfy_host, send_ntfy_notification
try: try:
validate_ntfy_host(current_user.ntfy_server_url) validate_ntfy_host(current_settings.ntfy_server_url)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@ -112,9 +95,9 @@ async def test_ntfy(
class _TestSettings: class _TestSettings:
"""Thin wrapper that forces ntfy_enabled=True for the test call.""" """Thin wrapper that forces ntfy_enabled=True for the test call."""
ntfy_enabled = True ntfy_enabled = True
ntfy_server_url = current_user.ntfy_server_url ntfy_server_url = current_settings.ntfy_server_url
ntfy_topic = current_user.ntfy_topic ntfy_topic = current_settings.ntfy_topic
ntfy_auth_token = current_user.ntfy_auth_token ntfy_auth_token = current_settings.ntfy_auth_token
success = await send_ntfy_notification( success = await send_ntfy_notification(
settings=_TestSettings(), # type: ignore[arg-type] settings=_TestSettings(), # type: ignore[arg-type]

View File

@ -8,7 +8,8 @@ import calendar
from app.database import get_db from app.database import get_db
from app.models.todo import Todo from app.models.todo import Todo
from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse 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 from app.models.settings import Settings
router = APIRouter() router = APIRouter()
@ -109,7 +110,7 @@ async def get_todos(
category: Optional[str] = Query(None), category: Optional[str] = Query(None),
search: Optional[str] = Query(None), search: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_settings)
): ):
"""Get all todos with optional filters.""" """Get all todos with optional filters."""
# Reactivate any recurring todos whose reset time has passed # Reactivate any recurring todos whose reset time has passed
@ -143,7 +144,7 @@ async def get_todos(
async def create_todo( async def create_todo(
todo: TodoCreate, todo: TodoCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_settings)
): ):
"""Create a new todo.""" """Create a new todo."""
new_todo = Todo(**todo.model_dump()) new_todo = Todo(**todo.model_dump())
@ -158,7 +159,7 @@ async def create_todo(
async def get_todo( async def get_todo(
todo_id: int, todo_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_settings)
): ):
"""Get a specific todo by ID.""" """Get a specific todo by ID."""
result = await db.execute(select(Todo).where(Todo.id == todo_id)) result = await db.execute(select(Todo).where(Todo.id == todo_id))
@ -175,7 +176,7 @@ async def update_todo(
todo_id: int, todo_id: int,
todo_update: TodoUpdate, todo_update: TodoUpdate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_settings)
): ):
"""Update a todo.""" """Update a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id)) result = await db.execute(select(Todo).where(Todo.id == todo_id))
@ -228,7 +229,7 @@ async def update_todo(
async def delete_todo( async def delete_todo(
todo_id: int, todo_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_settings)
): ):
"""Delete a todo.""" """Delete a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id)) result = await db.execute(select(Todo).where(Todo.id == todo_id))
@ -247,7 +248,7 @@ async def delete_todo(
async def toggle_todo( async def toggle_todo(
todo_id: int, todo_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_settings)
): ):
"""Toggle todo completion status. For recurring todos, calculates reset schedule.""" """Toggle todo completion status. For recurring todos, calculates reset schedule."""
result = await db.execute(select(Todo).where(Todo.id == todo_id)) result = await db.execute(select(Todo).where(Todo.id == todo_id))

View File

@ -12,7 +12,8 @@ import json
from app.database import get_db from app.database import get_db
from app.models.settings import Settings from app.models.settings import Settings
from app.config import settings as app_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() router = APIRouter()
@ -35,7 +36,7 @@ def _fetch_json(url: str) -> dict:
@router.get("/search", response_model=list[GeoSearchResult]) @router.get("/search", response_model=list[GeoSearchResult])
async def search_locations( async def search_locations(
q: str = Query(..., min_length=1, max_length=100), 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 api_key = app_settings.OPENWEATHERMAP_API_KEY
if not api_key: if not api_key:
@ -65,14 +66,11 @@ async def search_locations(
@router.get("/") @router.get("/")
async def get_weather( async def get_weather(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_settings)
): ):
# Get settings city = current_user.weather_city
result = await db.execute(select(Settings)) lat = current_user.weather_lat
settings_row = result.scalar_one_or_none() lon = current_user.weather_lon
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
if not city and (lat is None or lon is None): if not city and (lat is None or lon is None):
raise HTTPException(status_code=400, detail="No weather location configured") raise HTTPException(status_code=400, detail="No weather location configured")

View File

@ -0,0 +1,62 @@
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)

View File

@ -8,23 +8,6 @@ AccentColor = Literal["cyan", "blue", "green", "purple", "red", "orange", "pink"
_NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$') _NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
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)
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
accent_color: Optional[AccentColor] = None accent_color: Optional[AccentColor] = None
upcoming_days: int | None = None upcoming_days: int | None = None
@ -154,13 +137,3 @@ class SettingsResponse(BaseModel):
updated_at: datetime updated_at: datetime
model_config = ConfigDict(from_attributes=True) 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

@ -6,6 +6,7 @@ alembic==1.14.1
pydantic==2.10.4 pydantic==2.10.4
pydantic-settings==2.7.1 pydantic-settings==2.7.1
bcrypt==4.2.1 bcrypt==4.2.1
argon2-cffi>=23.1.0
python-multipart==0.0.20 python-multipart==0.0.20
python-dateutil==2.9.0 python-dateutil==2.9.0
itsdangerous==2.2.0 itsdangerous==2.2.0

View File

@ -1,115 +1,282 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { useNavigate, Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Lock } from 'lucide-react'; import { Lock, Loader2 } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { getErrorMessage } from '@/lib/api'; import { getErrorMessage } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
/** 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() { export default function LockScreen() {
const navigate = useNavigate(); const { authStatus, isLoading, login, setup, verifyTotp, mfaRequired, isLoginPending, isSetupPending, isTotpPending } = useAuth();
const { authStatus, login, setup, isLoginPending, isSetupPending } = useAuth();
const [pin, setPin] = useState('');
const [confirmPin, setConfirmPin] = useState('');
// Redirect authenticated users to dashboard // Credentials state (shared across login/setup states)
if (authStatus?.authenticated) { 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 />; return <Navigate to="/dashboard" replace />;
} }
const handleSubmit = async (e: FormEvent) => { const isSetup = authStatus?.setup_required === true;
e.preventDefault();
if (authStatus?.setup_required) { const handleCredentialSubmit = async (e: FormEvent) => {
if (pin !== confirmPin) { e.preventDefault();
toast.error('PINs do not match'); setLockoutMessage(null);
if (isSetup) {
// Setup mode: validate password then create account
const validationError = validatePassword(password);
if (validationError) {
toast.error(validationError);
return; return;
} }
if (pin.length < 4) { if (password !== confirmPassword) {
toast.error('PIN must be at least 4 characters'); toast.error('Passwords do not match');
return; return;
} }
try { try {
await setup(pin); await setup({ username, password });
toast.success('PIN created successfully'); // useAuth invalidates auth query → Navigate above handles redirect
navigate('/dashboard');
} catch (error) { } catch (error) {
toast.error(getErrorMessage(error, 'Failed to create PIN')); toast.error(getErrorMessage(error, 'Failed to create account'));
} }
} else { } else {
// Login mode
try { try {
await login(pin); await login({ username, password });
navigate('/dashboard'); // If mfaRequired becomes true, the TOTP state renders automatically
} catch (error) { // If not required, useAuth invalidates auth query → Navigate above handles redirect
toast.error(getErrorMessage(error, 'Invalid PIN')); } catch (error: any) {
setPin(''); 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 ( return (
<div className="flex min-h-screen items-center justify-center bg-background p-4"> <div className="relative flex min-h-screen flex-col items-center justify-center bg-background p-4 overflow-hidden">
<Card className="w-full max-w-md"> {/* Ambient glow blobs */}
<CardHeader className="space-y-4 text-center"> <div className="pointer-events-none absolute inset-0" aria-hidden="true">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-accent/10"> <div
<Lock className="h-8 w-8 text-accent" /> className="absolute -top-32 -left-32 h-96 w-96 rounded-full opacity-20 blur-3xl"
</div> style={{ background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)' }}
<CardTitle className="text-2xl"> />
{isSetup ? 'Welcome to UMBRA' : 'Enter PIN'} <div
</CardTitle> className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full opacity-10 blur-3xl"
<CardDescription> style={{ background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)' }}
{isSetup />
? 'Create a PIN to secure your account' </div>
: 'Enter your PIN to access your dashboard'}
</CardDescription> {/* Wordmark — in flex flow above card */}
</CardHeader> <span className="font-heading text-2xl font-bold tracking-tight text-accent mb-6 relative z-10">
<CardContent> UMBRA
<form onSubmit={handleSubmit} className="space-y-4"> </span>
<div className="space-y-2">
<Label htmlFor="pin">{isSetup ? 'Create PIN' : 'PIN'}</Label> {/* Auth card */}
<Input <Card className="w-full max-w-sm relative z-10 border-border/80 animate-slide-up">
id="pin" {mfaRequired ? (
type="password" // State C: TOTP challenge
value={pin} <>
onChange={(e) => setPin(e.target.value)} <CardHeader>
placeholder="Enter PIN" <div className="flex items-center gap-3">
required <div className="p-1.5 rounded-md bg-accent/10">
autoFocus <Lock className="h-4 w-4 text-accent" aria-hidden="true" />
className="text-center text-lg tracking-widest" </div>
/> <div>
</div> <CardTitle>Two-Factor Authentication</CardTitle>
{isSetup && ( <CardDescription>
<div className="space-y-2"> {useBackupCode
<Label htmlFor="confirm-pin">Confirm PIN</Label> ? 'Enter one of your backup codes'
<Input : 'Enter the code from your authenticator app'}
id="confirm-pin" </CardDescription>
type="password" </div>
value={confirmPin}
onChange={(e) => setConfirmPin(e.target.value)}
placeholder="Confirm PIN"
required
className="text-center text-lg tracking-widest"
/>
</div> </div>
)} </CardHeader>
<Button <CardContent>
type="submit" <form onSubmit={handleTotpSubmit} className="space-y-4">
className="w-full" <div className="space-y-2">
disabled={isLoginPending || isSetupPending} <Label htmlFor="totp-code">
> {useBackupCode ? 'Backup Code' : 'Authenticator Code'}
{isLoginPending || isSetupPending </Label>
? 'Please wait...' <Input
: isSetup id="totp-code"
? 'Create PIN' type="text"
: 'Unlock'} inputMode={useBackupCode ? 'text' : 'numeric'}
</Button> pattern={useBackupCode ? undefined : '[0-9]*'}
</form> maxLength={useBackupCode ? 9 : 6}
</CardContent> value={totpCode}
onChange={(e) =>
setTotpCode(
useBackupCode
? e.target.value.replace(/[^0-9-]/g, '')
: e.target.value.replace(/\D/g, '')
)
}
placeholder={useBackupCode ? 'XXXX-XXXX' : '000000'}
autoFocus
autoComplete="one-time-code"
className="text-center text-lg tracking-widest"
/>
</div>
<Button type="submit" className="w-full" disabled={isTotpPending}>
{isTotpPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verifying
</>
) : (
'Verify'
)}
</Button>
<button
type="button"
onClick={() => {
setUseBackupCode(!useBackupCode);
setTotpCode('');
}}
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{useBackupCode ? 'Use authenticator app instead' : 'Use a backup code instead'}
</button>
</form>
</CardContent>
</>
) : (
// State A (setup) or State B (login)
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-accent/10">
<Lock className="h-4 w-4 text-accent" aria-hidden="true" />
</div>
<div>
<CardTitle>{isSetup ? 'Welcome to UMBRA' : 'Sign in'}</CardTitle>
<CardDescription>
{isSetup
? 'Create your account to get started'
: 'Enter your credentials to continue'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{/* Lockout warning banner */}
{lockoutMessage && (
<div
role="alert"
className={cn(
'flex items-center gap-2 rounded-md border border-red-500/30',
'bg-red-500/10 px-3 py-2 mb-4'
)}
>
<Lock className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
<p className="text-xs text-red-400">{lockoutMessage}</p>
</div>
)}
<form onSubmit={handleCredentialSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
required
autoFocus
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
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">Confirm Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
required
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">
Must be 12-128 characters with at least one letter and one non-letter.
</p>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isLoginPending || isSetupPending || !!lockoutMessage}
>
{isLoginPending || isSetupPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Please wait
</>
) : isSetup ? (
'Create Account'
) : (
'Sign in'
)}
</Button>
</form>
</CardContent>
</>
)}
</Card> </Card>
</div> </div>
); );

View File

@ -1,13 +1,23 @@
import { useState, useEffect, useRef, useCallback, FormEvent, CSSProperties } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query'; 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,
} from 'lucide-react';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import api from '@/lib/api'; import api from '@/lib/api';
import type { GeoLocation } from '@/types'; import type { GeoLocation } from '@/types';
@ -22,7 +32,8 @@ const accentColors = [
export default function SettingsPage() { export default function SettingsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { settings, updateSettings, changePin, isUpdating, isChangingPin } = useSettings(); const { settings, updateSettings, isUpdating } = useSettings();
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan'); const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7); const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? ''); const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? '');
@ -34,11 +45,15 @@ export default function SettingsPage() {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(); const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0); const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0);
const [pinForm, setPinForm] = useState({ // Sync state when settings load
oldPin: '', useEffect(() => {
newPin: '', if (settings) {
confirmPin: '', setSelectedColor(settings.accent_color);
}); setUpcomingDays(settings.upcoming_days);
setPreferredName(settings.preferred_name ?? '');
setFirstDayOfWeek(settings.first_day_of_week);
}
}, [settings?.id]); // only re-sync on initial load (settings.id won't change)
const hasLocation = settings?.weather_lat != null && settings?.weather_lon != null; const hasLocation = settings?.weather_lat != null && settings?.weather_lon != null;
@ -87,11 +102,7 @@ export default function SettingsPage() {
const handleLocationClear = async () => { const handleLocationClear = async () => {
try { try {
await updateSettings({ await updateSettings({ weather_city: null, weather_lat: null, weather_lon: null });
weather_city: null,
weather_lat: null,
weather_lon: null,
});
queryClient.invalidateQueries({ queryKey: ['weather'] }); queryClient.invalidateQueries({ queryKey: ['weather'] });
toast.success('Weather location cleared'); toast.success('Weather location cleared');
} catch { } catch {
@ -110,7 +121,6 @@ export default function SettingsPage() {
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, []); }, []);
// Clean up debounce timer on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
@ -123,7 +133,7 @@ export default function SettingsPage() {
try { try {
await updateSettings({ preferred_name: trimmed || null }); await updateSettings({ preferred_name: trimmed || null });
toast.success('Name updated'); toast.success('Name updated');
} catch (error) { } catch {
toast.error('Failed to update name'); toast.error('Failed to update name');
} }
}; };
@ -133,7 +143,7 @@ export default function SettingsPage() {
try { try {
await updateSettings({ accent_color: color }); await updateSettings({ accent_color: color });
toast.success('Accent color updated'); toast.success('Accent color updated');
} catch (error) { } catch {
toast.error('Failed to update accent color'); toast.error('Failed to update accent color');
} }
}; };
@ -151,305 +161,291 @@ export default function SettingsPage() {
} }
}; };
const handleUpcomingDaysSubmit = async (e: FormEvent) => { const handleUpcomingDaysSave = async () => {
e.preventDefault(); if (isNaN(upcomingDays) || upcomingDays < 1 || upcomingDays > 30) return;
if (upcomingDays === settings?.upcoming_days) return;
try { try {
await updateSettings({ upcoming_days: upcomingDays }); await updateSettings({ upcoming_days: upcomingDays });
toast.success('Settings updated'); toast.success('Settings updated');
} catch (error) { } catch {
toast.error('Failed to update settings'); 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;
}
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');
}
};
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4"> {/* Page header — matches Stage 4-5 pages */}
<h1 className="text-3xl font-bold">Settings</h1> <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>
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl space-y-6"> <div className="max-w-5xl mx-auto">
<Card> <div className="grid gap-6 lg:grid-cols-2">
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Personalize how UMBRA greets you</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="preferred_name">Preferred Name</Label>
<div className="flex gap-3 items-center">
<Input
id="preferred_name"
type="text"
placeholder="Enter your name"
value={preferredName}
onChange={(e) => setPreferredName(e.target.value)}
onBlur={handleNameSave}
onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave(); }}
className="max-w-xs"
maxLength={100}
/>
</div>
<p className="text-sm text-muted-foreground">
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
</p>
</div>
</CardContent>
</Card>
<Card> {/* ── Left column: Profile, Appearance, Weather ── */}
<CardHeader> <div className="space-y-6">
<CardTitle>Appearance</CardTitle>
<CardDescription>Customize the look and feel of your application</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Accent Color</Label>
<div className="flex gap-3 mt-3">
{accentColors.map((color) => (
<button
key={color.name}
type="button"
onClick={() => handleColorChange(color.name)}
className={cn(
'h-12 w-12 rounded-full border-2 transition-all hover:scale-110',
selectedColor === color.name
? 'border-white ring-2 ring-offset-2 ring-offset-background'
: 'border-transparent'
)}
style={
{
backgroundColor: color.color,
'--tw-ring-color': color.color,
} as CSSProperties
}
title={color.label}
/>
))}
</div>
</div>
</CardContent>
</Card>
<Card> {/* Profile */}
<CardHeader> <Card>
<CardTitle>Calendar</CardTitle> <CardHeader>
<CardDescription>Configure your calendar preferences</CardDescription> <div className="flex items-center gap-3">
</CardHeader> <div className="p-1.5 rounded-md bg-accent/10">
<CardContent> <User className="h-4 w-4 text-accent" aria-hidden="true" />
<div className="space-y-2">
<Label>First Day of Week</Label>
<div className="flex items-center rounded-md border border-border overflow-hidden w-fit">
<button
type="button"
onClick={() => handleFirstDayChange(0)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors duration-150',
firstDayOfWeek === 0
? 'text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
)}
style={{
backgroundColor: firstDayOfWeek === 0 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: firstDayOfWeek === 0 ? 'hsl(var(--accent-color))' : undefined,
}}
>
Sunday
</button>
<button
type="button"
onClick={() => handleFirstDayChange(1)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors duration-150',
firstDayOfWeek === 1
? 'text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
)}
style={{
backgroundColor: firstDayOfWeek === 1 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: firstDayOfWeek === 1 ? 'hsl(var(--accent-color))' : undefined,
}}
>
Monday
</button>
</div>
<p className="text-sm text-muted-foreground">
Sets which day the calendar week starts on
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Dashboard</CardTitle>
<CardDescription>Configure your dashboard preferences</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleUpcomingDaysSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="upcoming_days">Upcoming Days Range</Label>
<div className="flex gap-3 items-center">
<Input
id="upcoming_days"
type="number"
min="1"
max="30"
value={upcomingDays}
onChange={(e) => setUpcomingDays(parseInt(e.target.value))}
className="w-24"
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
<p className="text-sm text-muted-foreground">
How many days ahead to show in the upcoming items widget
</p>
</div>
<Button type="submit" disabled={isUpdating}>
{isUpdating ? 'Saving...' : 'Save'}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Weather</CardTitle>
<CardDescription>Configure the weather widget on your dashboard</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>Location</Label>
{hasLocation ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-sm text-foreground">
<MapPin className="h-3.5 w-3.5 text-accent" />
{settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`}
</span>
<button
type="button"
onClick={handleLocationClear}
className="inline-flex items-center justify-center rounded-md h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Clear location"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div ref={searchRef} className="relative max-w-sm">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Search for a city..."
value={locationQuery}
onChange={(e) => handleLocationInputChange(e.target.value)}
onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }}
className="pl-9 pr-9"
/>
{isSearching && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground animate-spin" />
)}
</div> </div>
{showDropdown && ( <div>
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden"> <CardTitle>Profile</CardTitle>
{locationResults.map((loc, i) => { <CardDescription>Personalize how UMBRA greets you</CardDescription>
return ( </div>
<button </div>
key={`${loc.lat}-${loc.lon}-${i}`} </CardHeader>
type="button" <CardContent>
onClick={() => handleLocationSelect(loc)} <div className="space-y-2">
className="flex items-center gap-2.5 w-full px-3 py-2.5 text-sm text-left hover:bg-accent/10 transition-colors" <Label htmlFor="preferred_name">Preferred Name</Label>
> <Input
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> id="preferred_name"
<span> type="text"
<span className="text-foreground font-medium">{loc.name}</span> placeholder="Enter your name"
{(loc.state || loc.country) && ( value={preferredName}
<span className="text-muted-foreground"> onChange={(e) => setPreferredName(e.target.value)}
{loc.state ? `, ${loc.state}` : ''}{loc.country ? `, ${loc.country}` : ''} onBlur={handleNameSave}
</span> onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave(); }}
)} maxLength={100}
</span> />
</button> <p className="text-sm text-muted-foreground">
); Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
})} </p>
</div>
</CardContent>
</Card>
{/* Appearance */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-purple-500/10">
<Palette className="h-4 w-4 text-purple-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Appearance</CardTitle>
<CardDescription>Customize the look and feel of your application</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div>
<Label>Accent Color</Label>
<div className="grid grid-cols-5 gap-3 mt-3">
{accentColors.map((color) => (
<button
key={color.name}
type="button"
onClick={() => handleColorChange(color.name)}
aria-pressed={selectedColor === color.name}
className={cn(
'flex flex-col items-center gap-2 p-3 rounded-lg border transition-all duration-150',
selectedColor === color.name
? 'border-accent/50 bg-accent/5'
: 'border-border hover:border-border/80 hover:bg-card-elevated'
)}
>
<div
className="h-8 w-8 rounded-full"
style={{ backgroundColor: color.color }}
/>
<span className="text-[10px] tracking-wider uppercase text-muted-foreground">
{color.label}
</span>
</button>
))}
</div>
</div>
</CardContent>
</Card>
{/* Weather */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<Cloud className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Weather</CardTitle>
<CardDescription>Configure the weather widget on your dashboard</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>Location</Label>
{hasLocation ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-1.5 text-sm text-foreground">
<MapPin className="h-3.5 w-3.5 text-accent" />
{settings?.weather_city || `${settings?.weather_lat}, ${settings?.weather_lon}`}
</span>
<button
type="button"
onClick={handleLocationClear}
className="inline-flex items-center justify-center rounded-md h-7 w-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Clear location"
aria-label="Clear weather location"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div ref={searchRef} className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Search for a city..."
value={locationQuery}
onChange={(e) => handleLocationInputChange(e.target.value)}
onFocus={() => { if (locationResults.length > 0) setShowDropdown(true); }}
className="pl-9 pr-9"
/>
{isSearching && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground animate-spin" />
)}
</div>
{showDropdown && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden">
{locationResults.map((loc, i) => (
<button
key={`${loc.lat}-${loc.lon}-${i}`}
type="button"
onClick={() => handleLocationSelect(loc)}
className="flex items-center gap-2.5 w-full px-3 py-2.5 text-sm text-left hover:bg-accent/10 transition-colors"
>
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span>
<span className="text-foreground font-medium">{loc.name}</span>
{(loc.state || loc.country) && (
<span className="text-muted-foreground">
{loc.state ? `, ${loc.state}` : ''}{loc.country ? `, ${loc.country}` : ''}
</span>
)}
</span>
</button>
))}
</div>
)}
</div> </div>
)} )}
<p className="text-sm text-muted-foreground">
Search and select your city for accurate weather data on the dashboard.
</p>
</div> </div>
)} </CardContent>
<p className="text-sm text-muted-foreground"> </Card>
Search and select your city for accurate weather data on the dashboard.
</p>
</div>
</CardContent>
</Card>
<Card> </div>
<CardHeader>
<CardTitle>Security</CardTitle> {/* ── Right column: Calendar, Dashboard ── */}
<CardDescription>Change your PIN</CardDescription> <div className="space-y-6">
</CardHeader>
<CardContent> {/* Calendar */}
<form onSubmit={handlePinSubmit} className="space-y-4"> <Card>
<div className="space-y-2"> <CardHeader>
<Label htmlFor="old_pin">Current PIN</Label> <div className="flex items-center gap-3">
<Input <div className="p-1.5 rounded-md bg-blue-500/10">
id="old_pin" <CalendarDays className="h-4 w-4 text-blue-400" aria-hidden="true" />
type="password" </div>
value={pinForm.oldPin} <div>
onChange={(e) => setPinForm({ ...pinForm, oldPin: e.target.value })} <CardTitle>Calendar</CardTitle>
required <CardDescription>Configure your calendar preferences</CardDescription>
className="max-w-xs" </div>
/> </div>
</div> </CardHeader>
<Separator /> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new_pin">New PIN</Label> <Label>First Day of Week</Label>
<Input <div className="flex items-center rounded-md border border-border overflow-hidden w-fit">
id="new_pin" <button
type="password" type="button"
value={pinForm.newPin} onClick={() => handleFirstDayChange(0)}
onChange={(e) => setPinForm({ ...pinForm, newPin: e.target.value })} className={cn(
required 'px-4 py-2 text-sm font-medium transition-colors duration-150',
className="max-w-xs" firstDayOfWeek === 0
/> ? 'text-accent'
</div> : 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
<div className="space-y-2"> )}
<Label htmlFor="confirm_pin">Confirm New PIN</Label> style={{
<Input backgroundColor: firstDayOfWeek === 0 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
id="confirm_pin" color: firstDayOfWeek === 0 ? 'hsl(var(--accent-color))' : undefined,
type="password" }}
value={pinForm.confirmPin} >
onChange={(e) => setPinForm({ ...pinForm, confirmPin: e.target.value })} Sunday
required </button>
className="max-w-xs" <button
/> type="button"
</div> onClick={() => handleFirstDayChange(1)}
<Button type="submit" disabled={isChangingPin}> className={cn(
{isChangingPin ? 'Changing...' : 'Change PIN'} 'px-4 py-2 text-sm font-medium transition-colors duration-150',
</Button> firstDayOfWeek === 1
</form> ? 'text-accent'
</CardContent> : 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
</Card> )}
style={{
backgroundColor: firstDayOfWeek === 1 ? 'hsl(var(--accent-color) / 0.15)' : undefined,
color: firstDayOfWeek === 1 ? 'hsl(var(--accent-color))' : undefined,
}}
>
Monday
</button>
</div>
<p className="text-sm text-muted-foreground">
Sets which day the calendar week starts on
</p>
</div>
</CardContent>
</Card>
{/* Dashboard */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-teal-500/10">
<LayoutDashboard className="h-4 w-4 text-teal-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Dashboard</CardTitle>
<CardDescription>Configure your dashboard preferences</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="upcoming_days">Upcoming Days Range</Label>
<div className="flex gap-3 items-center">
<Input
id="upcoming_days"
type="number"
min="1"
max="30"
value={upcomingDays}
onChange={(e) => setUpcomingDays(parseInt(e.target.value))}
onBlur={handleUpcomingDaysSave}
onKeyDown={(e) => { if (e.key === 'Enter') handleUpcomingDaysSave(); }}
className="w-24"
disabled={isUpdating}
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
<p className="text-sm text-muted-foreground">
How many days ahead to show in the upcoming items widget
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -23,23 +23,10 @@ export function useSettings() {
}, },
}); });
// @deprecated — PIN auth is replaced by username/password in Stage 6.
// SettingsPage will be rewritten in Phase 3 to remove this. Kept here to
// preserve compilation until SettingsPage.tsx is updated.
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 { return {
settings: settingsQuery.data, settings: settingsQuery.data,
isLoading: settingsQuery.isLoading, isLoading: settingsQuery.isLoading,
updateSettings: updateMutation.mutateAsync, updateSettings: updateMutation.mutateAsync,
isUpdating: updateMutation.isPending, isUpdating: updateMutation.isPending,
// @deprecated — remove when SettingsPage is rewritten in Phase 3
changePin: changePinMutation.mutateAsync,
isChangingPin: changePinMutation.isPending,
}; };
} }