Compare commits
2 Commits
67456c78dd
...
fbc452a004
| Author | SHA1 | Date | |
|---|---|---|---|
| fbc452a004 | |||
| 5a8819c4a5 |
143
backend/alembic/versions/023_auth_migration_users_sessions.py
Normal file
143
backend/alembic/versions/023_auth_migration_users_sessions.py
Normal 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')
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
23
backend/app/models/session.py
Normal file
23
backend/app/models/session.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy import String, Boolean, ForeignKey, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserSession(Base):
|
||||||
|
__tablename__ = "user_sessions"
|
||||||
|
|
||||||
|
# UUID4 hex — avoids integer primary key enumeration
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(nullable=False)
|
||||||
|
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Audit fields for security logging
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
@ -1,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)
|
||||||
|
|||||||
29
backend/app/models/user.py
Normal file
29
backend/app/models/user.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from sqlalchemy import String, Boolean, Integer, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# MFA — populated in Track B
|
||||||
|
# String(500) because Fernet-encrypted secrets are longer than raw base32
|
||||||
|
totp_secret: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
|
||||||
|
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Account lockout
|
||||||
|
failed_login_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
locked_until: Mapped[datetime | None] = mapped_column(nullable=True, default=None)
|
||||||
|
|
||||||
|
# Account state
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None)
|
||||||
@ -1,134 +1,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. bcrypt→Argon2id transparent upgrade on first login with migrated hash
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Cookie
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.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"}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
62
backend/app/schemas/auth.py
Normal file
62
backend/app/schemas/auth.py
Normal 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 3–50 characters")
|
||||||
|
if not re.fullmatch(r"[a-z0-9_\-]+", v):
|
||||||
|
raise ValueError("Username may only contain letters, numbers, _ and -")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def validate_password(cls, v: str) -> str:
|
||||||
|
return _validate_password_strength(v)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@field_validator("username")
|
||||||
|
@classmethod
|
||||||
|
def normalize_username(cls, v: str) -> str:
|
||||||
|
"""Normalise to lowercase so 'Admin' and 'admin' resolve to the same user."""
|
||||||
|
return v.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
@field_validator("new_password")
|
||||||
|
@classmethod
|
||||||
|
def validate_new_password(cls, v: str) -> str:
|
||||||
|
return _validate_password_strength(v)
|
||||||
@ -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")
|
|
||||||
|
|||||||
128
backend/app/services/auth.py
Normal file
128
backend/app/services/auth.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Authentication service: password hashing, session tokens, MFA tokens.
|
||||||
|
|
||||||
|
Password strategy:
|
||||||
|
- New passwords: Argon2id (OWASP/NIST preferred, PHC winner)
|
||||||
|
- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
|
||||||
|
rehashed to Argon2id on first successful use.
|
||||||
|
"""
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||||
|
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||||
|
|
||||||
|
from app.config import settings as app_settings
|
||||||
|
|
||||||
|
# OWASP-minimum Argon2id parameters (m=19 MB, t=2 iterations, p=1)
|
||||||
|
_ph = PasswordHasher(
|
||||||
|
time_cost=2,
|
||||||
|
memory_cost=19456, # 19 MB in KB
|
||||||
|
parallelism=1,
|
||||||
|
hash_len=32,
|
||||||
|
salt_len=16,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Session serializer — salt differentiates from MFA tokens
|
||||||
|
_serializer = URLSafeTimedSerializer(
|
||||||
|
secret_key=app_settings.SECRET_KEY,
|
||||||
|
salt="umbra-session-v2",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Password helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Hash a password with Argon2id."""
|
||||||
|
return _ph.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, hashed: str) -> bool:
|
||||||
|
"""Verify an Argon2id password hash. Returns False on any failure."""
|
||||||
|
try:
|
||||||
|
return _ph.verify(hashed, password)
|
||||||
|
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def needs_rehash(hashed: str) -> bool:
|
||||||
|
"""True if the stored hash was created with outdated parameters."""
|
||||||
|
return _ph.check_needs_rehash(hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str | None]:
|
||||||
|
"""
|
||||||
|
Verify a password against a stored hash (Argon2id or legacy bcrypt).
|
||||||
|
|
||||||
|
Returns (is_valid, new_hash_if_upgrade_needed).
|
||||||
|
new_hash is non-None only when the stored hash is bcrypt and the password is
|
||||||
|
correct — caller must persist the new hash to complete the migration.
|
||||||
|
Also returns a new hash when Argon2id parameters are outdated.
|
||||||
|
"""
|
||||||
|
if hashed.startswith("$2b$") or hashed.startswith("$2a$"):
|
||||||
|
# Legacy bcrypt — verify then immediately rehash to Argon2id
|
||||||
|
import bcrypt # noqa: PLC0415 — intentional lazy import; bcrypt is only needed during migration
|
||||||
|
try:
|
||||||
|
valid = bcrypt.checkpw(password.encode(), hashed.encode())
|
||||||
|
except Exception:
|
||||||
|
return False, None
|
||||||
|
if valid:
|
||||||
|
return True, hash_password(password)
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
# Argon2id path
|
||||||
|
valid = verify_password(password, hashed)
|
||||||
|
new_hash = hash_password(password) if (valid and needs_rehash(hashed)) else None
|
||||||
|
return valid, new_hash
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session tokens
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_session_token(user_id: int, session_id: str) -> str:
|
||||||
|
"""Create a signed session cookie payload embedding user_id + session_id."""
|
||||||
|
return _serializer.dumps({"uid": user_id, "sid": session_id})
|
||||||
|
|
||||||
|
|
||||||
|
def verify_session_token(token: str, max_age: int | None = None) -> dict | None:
|
||||||
|
"""
|
||||||
|
Verify a session cookie and return its payload dict, or None if invalid/expired.
|
||||||
|
max_age defaults to SESSION_MAX_AGE_DAYS from config.
|
||||||
|
"""
|
||||||
|
if max_age is None:
|
||||||
|
max_age = app_settings.SESSION_MAX_AGE_DAYS * 86400
|
||||||
|
try:
|
||||||
|
return _serializer.loads(token, max_age=max_age)
|
||||||
|
except (BadSignature, SignatureExpired):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MFA tokens (short-lived, used between password OK and TOTP verification)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# MFA tokens use a distinct salt so they cannot be replayed as session tokens
|
||||||
|
_mfa_serializer = URLSafeTimedSerializer(
|
||||||
|
secret_key=app_settings.SECRET_KEY,
|
||||||
|
salt="mfa-challenge",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_mfa_token(user_id: int) -> str:
|
||||||
|
"""Create a short-lived signed token for the MFA challenge step."""
|
||||||
|
return _mfa_serializer.dumps({"uid": user_id})
|
||||||
|
|
||||||
|
|
||||||
|
def verify_mfa_token(token: str) -> int | None:
|
||||||
|
"""
|
||||||
|
Verify an MFA challenge token.
|
||||||
|
Returns the user_id on success, None if invalid or expired (5-minute TTL).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = _mfa_serializer.loads(
|
||||||
|
token, max_age=app_settings.MFA_TOKEN_MAX_AGE_SECONDS
|
||||||
|
)
|
||||||
|
return data["uid"]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user