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
OPENWEATHERMAP_API_KEY: str = ""
# Session config
SESSION_MAX_AGE_DAYS: int = 30
# MFA token config (short-lived token bridging password OK → TOTP verification)
MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes
# TOTP issuer name shown in authenticator apps
TOTP_ISSUER: str = "UMBRA"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",

View File

@ -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.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
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 datetime import datetime
from typing import Optional
@ -9,7 +9,14 @@ class Settings(Base):
__tablename__ = "settings"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
pin_hash: Mapped[str] = mapped_column(String(255), nullable=False)
# FK to users table — 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")
upcoming_days: Mapped[int] = mapped_column(Integer, default=7)
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 import select
from typing import Optional
from collections import defaultdict
import time
import bcrypt
from itsdangerous import TimestampSigner, BadSignature
from app.database import get_db
from app.models.user import User
from app.models.session import UserSession
from app.models.settings import Settings
from app.schemas.settings import SettingsCreate
from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest
from app.services.auth import (
hash_password,
verify_password_with_upgrade,
create_session_token,
verify_session_token,
create_mfa_token,
)
from app.config import settings as app_settings
router = APIRouter()
# Initialize signer for session management
signer = TimestampSigner(app_settings.SECRET_KEY)
# Brute-force protection: track failed login attempts per IP
# ---------------------------------------------------------------------------
# IP-based in-memory rate limit (retained as outer layer for all login attempts)
# ---------------------------------------------------------------------------
_failed_attempts: dict[str, list[float]] = defaultdict(list)
_MAX_ATTEMPTS = 5
_WINDOW_SECONDS = 300 # 5-minute lockout window
# Server-side session revocation (in-memory, sufficient for single-user app)
_revoked_sessions: set[str] = set()
_MAX_IP_ATTEMPTS = 5
_IP_WINDOW_SECONDS = 300 # 5 minutes
def _check_rate_limit(ip: str) -> None:
"""Raise 429 if IP has exceeded failed login attempts."""
def _check_ip_rate_limit(ip: str) -> None:
"""Raise 429 if the IP has exceeded the failure window."""
now = time.time()
attempts = _failed_attempts[ip]
# Prune old entries outside the window
_failed_attempts[ip] = [t for t in attempts if now - t < _WINDOW_SECONDS]
# Remove the key entirely if no recent attempts remain
_failed_attempts[ip] = [t for t in _failed_attempts[ip] if now - t < _IP_WINDOW_SECONDS]
if not _failed_attempts[ip]:
del _failed_attempts[ip]
elif len(_failed_attempts[ip]) >= _MAX_ATTEMPTS:
_failed_attempts.pop(ip, None)
elif len(_failed_attempts[ip]) >= _MAX_IP_ATTEMPTS:
raise HTTPException(
status_code=429,
detail="Too many failed login attempts. Try again in a few minutes.",
)
def _record_failed_attempt(ip: str) -> None:
"""Record a failed login attempt for the given IP."""
def _record_ip_failure(ip: str) -> None:
_failed_attempts[ip].append(time.time())
def hash_pin(pin: str) -> str:
"""Hash a PIN using bcrypt."""
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
def verify_pin(pin: str, hashed: str) -> bool:
"""Verify a PIN against its hash."""
return bcrypt.checkpw(pin.encode(), hashed.encode())
def create_session_token(user_id: int) -> str:
"""Create a signed session token."""
return signer.sign(str(user_id)).decode()
def verify_session_token(token: str) -> Optional[int]:
"""Verify and extract user ID from session token."""
try:
unsigned = signer.unsign(token, max_age=86400 * 30) # 30 days
return int(unsigned)
except (BadSignature, ValueError):
return None
# ---------------------------------------------------------------------------
# Cookie helper
# ---------------------------------------------------------------------------
def _set_session_cookie(response: Response, token: str) -> None:
"""Set the session cookie with secure defaults."""
response.set_cookie(
key="session",
value=token,
httponly=True,
secure=app_settings.COOKIE_SECURE,
max_age=86400 * 30, # 30 days
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
samesite="lax",
)
async def get_current_session(
# ---------------------------------------------------------------------------
# Auth dependencies — export get_current_user and get_current_settings
# ---------------------------------------------------------------------------
async def get_current_user(
request: Request,
session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db)
) -> Settings:
"""Dependency to verify session and return current settings."""
db: AsyncSession = Depends(get_db),
) -> User:
"""
Dependency that verifies the session cookie and returns the authenticated User.
Replaces the old get_current_session (which returned Settings).
Any router that hasn't been updated will get a compile-time type error.
"""
if not session_cookie:
raise HTTPException(status_code=401, detail="Not authenticated")
# Check if session has been revoked
if session_cookie in _revoked_sessions:
raise HTTPException(status_code=401, detail="Session has been revoked")
user_id = verify_session_token(session_cookie)
if user_id is None:
payload = verify_session_token(session_cookie)
if payload is None:
raise HTTPException(status_code=401, detail="Invalid or expired session")
result = await db.execute(select(Settings).where(Settings.id == user_id))
user_id: int = payload.get("uid")
session_id: str = payload.get("sid")
if user_id is None or session_id is None:
raise HTTPException(status_code=401, detail="Malformed session token")
# Verify session is active in DB (covers revocation + expiry)
session_result = await db.execute(
select(UserSession).where(
UserSession.id == session_id,
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
)
)
db_session = session_result.scalar_one_or_none()
if not db_session:
raise HTTPException(status_code=401, detail="Session has been revoked or expired")
user_result = await db.execute(
select(User).where(User.id == user_id, User.is_active == True)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found or inactive")
return user
async def get_current_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Settings:
"""
Convenience dependency for routers that need Settings access.
Always chain after get_current_user never use standalone.
"""
result = await db.execute(
select(Settings).where(Settings.user_id == current_user.id)
)
settings_obj = result.scalar_one_or_none()
if not settings_obj:
raise HTTPException(status_code=401, detail="Session invalid")
raise HTTPException(status_code=500, detail="Settings not found for user")
return settings_obj
@router.post("/setup")
async def setup_pin(
data: SettingsCreate,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""Create initial PIN. Only works if no settings exist."""
result = await db.execute(select(Settings).with_for_update())
existing = result.scalar_one_or_none()
# ---------------------------------------------------------------------------
# Account lockout helpers
# ---------------------------------------------------------------------------
if existing:
async def _check_account_lockout(user: User) -> None:
"""Raise HTTP 423 if the account is currently locked."""
if user.locked_until and datetime.now() < user.locked_until:
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
raise HTTPException(
status_code=423,
detail=f"Account locked. Try again in {remaining} minutes.",
)
async def _record_failed_login(db: AsyncSession, user: User) -> None:
"""Increment failure counter; lock account after 10 failures."""
user.failed_login_count += 1
if user.failed_login_count >= 10:
user.locked_until = datetime.now() + timedelta(minutes=30)
await db.commit()
async def _record_successful_login(db: AsyncSession, user: User) -> None:
"""Reset failure counter and update last_login_at."""
user.failed_login_count = 0
user.locked_until = None
user.last_login_at = datetime.now()
await db.commit()
# ---------------------------------------------------------------------------
# Session creation helper
# ---------------------------------------------------------------------------
async def _create_db_session(
db: AsyncSession,
user: User,
ip: str,
user_agent: str | None,
) -> tuple[str, str]:
"""Insert a UserSession row and return (session_id, signed_cookie_token)."""
session_id = uuid.uuid4().hex
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
db_session = UserSession(
id=session_id,
user_id=user.id,
expires_at=expires_at,
ip_address=ip[:45] if ip else None, # clamp to column width
user_agent=(user_agent or "")[:255] if user_agent else None,
)
db.add(db_session)
await db.commit()
token = create_session_token(user.id, session_id)
return session_id, token
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.post("/setup")
async def setup(
data: SetupRequest,
response: Response,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""
First-time setup: create the User record and a linked Settings row.
Only works when no users exist (i.e., fresh install).
"""
existing = await db.execute(select(User))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Setup already completed")
pin_hash = hash_pin(data.pin)
new_settings = Settings(pin_hash=pin_hash)
password_hash = hash_password(data.password)
new_user = User(username=data.username, password_hash=password_hash)
db.add(new_user)
await db.flush() # assign new_user.id before creating Settings
# Create Settings row linked to this user with all defaults
new_settings = Settings(user_id=new_user.id)
db.add(new_settings)
await db.commit()
await db.refresh(new_settings)
# Create session
token = create_session_token(new_settings.id)
ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, new_user, ip, user_agent)
_set_session_cookie(response, token)
return {"message": "Setup completed successfully", "authenticated": True}
@ -136,48 +241,91 @@ async def setup_pin(
@router.post("/login")
async def login(
data: SettingsCreate,
data: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Verify PIN and create session."""
"""
Authenticate with username + password.
Returns:
{ authenticated: true } on success (no TOTP)
{ authenticated: false, totp_required: true, mfa_token: "..." } TOTP pending
HTTP 401 wrong credentials (generic; never reveals which field is wrong)
HTTP 423 account locked
HTTP 429 IP rate limited
"""
client_ip = request.client.host if request.client else "unknown"
_check_rate_limit(client_ip)
_check_ip_rate_limit(client_ip)
result = await db.execute(select(Settings))
settings_obj = result.scalar_one_or_none()
# Lookup user — do NOT differentiate "user not found" from "wrong password"
result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none()
if not settings_obj:
raise HTTPException(status_code=400, detail="Setup required")
if not user:
_record_ip_failure(client_ip)
raise HTTPException(status_code=401, detail="Invalid username or password")
if not verify_pin(data.pin, settings_obj.pin_hash):
_record_failed_attempt(client_ip)
raise HTTPException(status_code=401, detail="Invalid PIN")
await _check_account_lockout(user)
# Clear failed attempts on successful login
# Transparent bcrypt→Argon2id upgrade
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
if not valid:
_record_ip_failure(client_ip)
await _record_failed_login(db, user)
raise HTTPException(status_code=401, detail="Invalid username or password")
# Persist upgraded hash if migration happened
if new_hash:
user.password_hash = new_hash
# Clear IP failures and update user state
_failed_attempts.pop(client_ip, None)
await _record_successful_login(db, user)
# Create session
token = create_session_token(settings_obj.id)
# If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session
if user.totp_enabled:
mfa_token = create_mfa_token(user.id)
return {
"authenticated": False,
"totp_required": True,
"mfa_token": mfa_token,
}
user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, user, client_ip, user_agent)
_set_session_cookie(response, token)
return {"message": "Login successful", "authenticated": True}
return {"authenticated": True}
@router.post("/logout")
async def logout(
response: Response,
session_cookie: Optional[str] = Cookie(None, alias="session")
session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db),
):
"""Clear session cookie and invalidate server-side session."""
"""Revoke the current session in DB and clear the cookie."""
if session_cookie:
_revoked_sessions.add(session_cookie)
payload = verify_session_token(session_cookie)
if payload:
session_id = payload.get("sid")
if session_id:
result = await db.execute(
select(UserSession).where(UserSession.id == session_id)
)
db_session = result.scalar_one_or_none()
if db_session:
db_session.revoked = True
await db.commit()
response.delete_cookie(
key="session",
httponly=True,
secure=app_settings.COOKIE_SECURE,
samesite="lax"
samesite="lax",
)
return {"message": "Logout successful"}
@ -185,23 +333,48 @@ async def logout(
@router.get("/status")
async def auth_status(
session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
):
"""Check authentication status."""
result = await db.execute(select(Settings))
settings_obj = result.scalar_one_or_none()
setup_required = settings_obj is None
"""
Check authentication status and whether initial setup has been performed.
Used by the frontend to decide whether to show login vs setup screen.
"""
user_result = await db.execute(select(User))
existing_user = user_result.scalar_one_or_none()
setup_required = existing_user is None
authenticated = False
if not setup_required and session_cookie:
if session_cookie in _revoked_sessions:
authenticated = False
else:
user_id = verify_session_token(session_cookie)
authenticated = user_id is not None
payload = verify_session_token(session_cookie)
if payload:
user_id = payload.get("uid")
session_id = payload.get("sid")
if user_id and session_id:
session_result = await db.execute(
select(UserSession).where(
UserSession.id == session_id,
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
)
)
authenticated = session_result.scalar_one_or_none() is not None
return {
"authenticated": authenticated,
"setup_required": setup_required
}
return {"authenticated": authenticated, "setup_required": setup_required}
@router.post("/change-password")
async def change_password(
data: ChangePasswordRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Change the current user's password. Requires old password verification."""
valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
if not valid:
raise HTTPException(status_code=401, detail="Invalid current password")
current_user.password_hash = hash_password(data.new_password)
await db.commit()
return {"message": "Password changed successfully"}

View File

@ -7,8 +7,8 @@ from app.database import get_db
from app.models.calendar import Calendar
from app.models.calendar_event import CalendarEvent
from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
from app.routers.auth import get_current_user
from app.models.user import User
router = APIRouter()
@ -16,7 +16,7 @@ router = APIRouter()
@router.get("/", response_model=List[CalendarResponse])
async def get_calendars(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
current_user: User = Depends(get_current_user)
):
result = await db.execute(select(Calendar).order_by(Calendar.is_default.desc(), Calendar.name.asc()))
return result.scalars().all()
@ -26,7 +26,7 @@ async def get_calendars(
async def create_calendar(
calendar: CalendarCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
current_user: User = Depends(get_current_user)
):
new_calendar = Calendar(
name=calendar.name,
@ -46,7 +46,7 @@ async def update_calendar(
calendar_id: int,
calendar_update: CalendarUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
current_user: User = Depends(get_current_user)
):
result = await db.execute(select(Calendar).where(Calendar.id == calendar_id))
calendar = result.scalar_one_or_none()
@ -72,7 +72,7 @@ async def update_calendar(
async def delete_calendar(
calendar_id: int,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
current_user: User = Depends(get_current_user)
):
result = await db.execute(select(Calendar).where(Calendar.id == calendar_id))
calendar = result.scalar_one_or_none()

View File

@ -10,7 +10,7 @@ from app.models.todo import Todo
from app.models.calendar_event import CalendarEvent
from app.models.reminder import Reminder
from app.models.project import Project
from app.routers.auth import get_current_session
from app.routers.auth import get_current_settings
router = APIRouter()
@ -26,7 +26,7 @@ _not_parent_template = or_(
async def get_dashboard(
client_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
current_user: Settings = Depends(get_current_settings)
):
"""Get aggregated dashboard data."""
today = client_date or date.today()
@ -143,7 +143,7 @@ async def get_upcoming(
days: int = Query(default=7, ge=1, le=90),
client_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
current_user: Settings = Depends(get_current_settings)
):
"""Get unified list of upcoming items (todos, events, reminders) sorted by date."""
today = client_date or date.today()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.settings import Settings
from app.schemas.settings import SettingsUpdate, SettingsResponse, ChangePinRequest
from app.routers.auth import get_current_session, hash_pin, verify_pin
from app.models.user import User
from app.schemas.settings import SettingsUpdate, SettingsResponse
from app.routers.auth import get_current_user, get_current_settings
router = APIRouter()
@ -43,51 +43,34 @@ def _to_settings_response(s: Settings) -> SettingsResponse:
@router.get("/", response_model=SettingsResponse)
async def get_settings(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
current_settings: Settings = Depends(get_current_settings)
):
"""Get current settings (excluding PIN hash and ntfy auth token)."""
return _to_settings_response(current_user)
"""Get current settings (excluding ntfy auth token)."""
return _to_settings_response(current_settings)
@router.put("/", response_model=SettingsResponse)
async def update_settings(
settings_update: SettingsUpdate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
current_settings: Settings = Depends(get_current_settings)
):
"""Update settings."""
update_data = settings_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(current_user, key, value)
setattr(current_settings, key, value)
await db.commit()
await db.refresh(current_user)
await db.refresh(current_settings)
return _to_settings_response(current_user)
@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"}
return _to_settings_response(current_settings)
@router.post("/ntfy/test")
async def test_ntfy(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
current_settings: Settings = Depends(get_current_settings)
):
"""
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
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(
status_code=400,
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
from app.services.ntfy import validate_ntfy_host, send_ntfy_notification
try:
validate_ntfy_host(current_user.ntfy_server_url)
validate_ntfy_host(current_settings.ntfy_server_url)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@ -112,9 +95,9 @@ async def test_ntfy(
class _TestSettings:
"""Thin wrapper that forces ntfy_enabled=True for the test call."""
ntfy_enabled = True
ntfy_server_url = current_user.ntfy_server_url
ntfy_topic = current_user.ntfy_topic
ntfy_auth_token = current_user.ntfy_auth_token
ntfy_server_url = current_settings.ntfy_server_url
ntfy_topic = current_settings.ntfy_topic
ntfy_auth_token = current_settings.ntfy_auth_token
success = await send_ntfy_notification(
settings=_TestSettings(), # type: ignore[arg-type]

View File

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

View File

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

View File

@ -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}$')
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):
accent_color: Optional[AccentColor] = None
upcoming_days: int | None = None
@ -154,13 +137,3 @@ class SettingsResponse(BaseModel):
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ChangePinRequest(BaseModel):
old_pin: str
new_pin: str
@field_validator('new_pin')
@classmethod
def new_pin_length(cls, v: str) -> str:
return _validate_pin_length(v, "New PIN")

View File

@ -0,0 +1,128 @@
"""
Authentication service: password hashing, session tokens, MFA tokens.
Password strategy:
- New passwords: Argon2id (OWASP/NIST preferred, PHC winner)
- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
rehashed to Argon2id on first successful use.
"""
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from app.config import settings as app_settings
# OWASP-minimum Argon2id parameters (m=19 MB, t=2 iterations, p=1)
_ph = PasswordHasher(
time_cost=2,
memory_cost=19456, # 19 MB in KB
parallelism=1,
hash_len=32,
salt_len=16,
)
# Session serializer — salt differentiates from MFA tokens
_serializer = URLSafeTimedSerializer(
secret_key=app_settings.SECRET_KEY,
salt="umbra-session-v2",
)
# ---------------------------------------------------------------------------
# Password helpers
# ---------------------------------------------------------------------------
def hash_password(password: str) -> str:
"""Hash a password with Argon2id."""
return _ph.hash(password)
def verify_password(password: str, hashed: str) -> bool:
"""Verify an Argon2id password hash. Returns False on any failure."""
try:
return _ph.verify(hashed, password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
def needs_rehash(hashed: str) -> bool:
"""True if the stored hash was created with outdated parameters."""
return _ph.check_needs_rehash(hashed)
def verify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str | None]:
"""
Verify a password against a stored hash (Argon2id or legacy bcrypt).
Returns (is_valid, new_hash_if_upgrade_needed).
new_hash is non-None only when the stored hash is bcrypt and the password is
correct caller must persist the new hash to complete the migration.
Also returns a new hash when Argon2id parameters are outdated.
"""
if hashed.startswith("$2b$") or hashed.startswith("$2a$"):
# Legacy bcrypt — verify then immediately rehash to Argon2id
import bcrypt # noqa: PLC0415 — intentional lazy import; bcrypt is only needed during migration
try:
valid = bcrypt.checkpw(password.encode(), hashed.encode())
except Exception:
return False, None
if valid:
return True, hash_password(password)
return False, None
# Argon2id path
valid = verify_password(password, hashed)
new_hash = hash_password(password) if (valid and needs_rehash(hashed)) else None
return valid, new_hash
# ---------------------------------------------------------------------------
# Session tokens
# ---------------------------------------------------------------------------
def create_session_token(user_id: int, session_id: str) -> str:
"""Create a signed session cookie payload embedding user_id + session_id."""
return _serializer.dumps({"uid": user_id, "sid": session_id})
def verify_session_token(token: str, max_age: int | None = None) -> dict | None:
"""
Verify a session cookie and return its payload dict, or None if invalid/expired.
max_age defaults to SESSION_MAX_AGE_DAYS from config.
"""
if max_age is None:
max_age = app_settings.SESSION_MAX_AGE_DAYS * 86400
try:
return _serializer.loads(token, max_age=max_age)
except (BadSignature, SignatureExpired):
return None
# ---------------------------------------------------------------------------
# MFA tokens (short-lived, used between password OK and TOTP verification)
# ---------------------------------------------------------------------------
# MFA tokens use a distinct salt so they cannot be replayed as session tokens
_mfa_serializer = URLSafeTimedSerializer(
secret_key=app_settings.SECRET_KEY,
salt="mfa-challenge",
)
def create_mfa_token(user_id: int) -> str:
"""Create a short-lived signed token for the MFA challenge step."""
return _mfa_serializer.dumps({"uid": user_id})
def verify_mfa_token(token: str) -> int | None:
"""
Verify an MFA challenge token.
Returns the user_id on success, None if invalid or expired (5-minute TTL).
"""
try:
data = _mfa_serializer.loads(
token, max_age=app_settings.MFA_TOKEN_MAX_AGE_SECONDS
)
return data["uid"]
except Exception:
return None

View File

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

View File

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

View File

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