Compare commits

..

No commits in common. "27c65ce40da61e5af04398c3b4a669206ea36888" and "81edf81d13089b3514ea148ba8215fad13a63d80" have entirely different histories.

20 changed files with 64 additions and 252 deletions

View File

@ -1,12 +1,9 @@
import sys
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/umbra" DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/umbra"
SECRET_KEY: str = "your-secret-key-change-in-production" SECRET_KEY: str = "your-secret-key-change-in-production"
ENVIRONMENT: str = "development"
COOKIE_SECURE: bool = False
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
@ -16,17 +13,3 @@ class Settings(BaseSettings):
settings = Settings() settings = Settings()
if settings.SECRET_KEY == "your-secret-key-change-in-production":
if settings.ENVIRONMENT != "development":
print(
"FATAL: Default SECRET_KEY detected in non-development environment. "
"Set a unique SECRET_KEY in .env immediately.",
file=sys.stderr,
)
sys.exit(1)
else:
print(
"WARNING: Using default SECRET_KEY. Set SECRET_KEY in .env for production.",
file=sys.stderr,
)

View File

@ -5,7 +5,7 @@ from app.config import settings
# Create async engine # Create async engine
engine = create_async_engine( engine = create_async_engine(
settings.DATABASE_URL, settings.DATABASE_URL,
echo=False, echo=True,
future=True future=True
) )
@ -27,6 +27,7 @@ async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
try: try:
yield session yield session
await session.commit()
except Exception: except Exception:
await session.rollback() await session.rollback()
raise raise

View File

@ -2,13 +2,17 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from app.database import engine from app.database import engine, Base
from app.routers import auth, todos, events, reminders, projects, people, locations, settings as settings_router, dashboard from app.routers import auth, todos, events, reminders, projects, people, locations, settings as settings_router, dashboard
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup: Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield yield
# Shutdown: Clean up resources
await engine.dispose() await engine.dispose()
@ -22,7 +26,7 @@ app = FastAPI(
# CORS configuration for development # CORS configuration for development
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173"], allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],

View File

@ -1,9 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Response, Cookie, Request from fastapi import APIRouter, Depends, HTTPException, 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 typing import Optional
from collections import defaultdict
import time
import bcrypt import bcrypt
from itsdangerous import TimestampSigner, BadSignature from itsdangerous import TimestampSigner, BadSignature
@ -17,35 +15,6 @@ router = APIRouter()
# Initialize signer for session management # Initialize signer for session management
signer = TimestampSigner(app_settings.SECRET_KEY) signer = TimestampSigner(app_settings.SECRET_KEY)
# Brute-force protection: track failed login attempts per IP
_failed_attempts: dict[str, list[float]] = defaultdict(list)
_MAX_ATTEMPTS = 5
_WINDOW_SECONDS = 300 # 5-minute lockout window
# Server-side session revocation (in-memory, sufficient for single-user app)
_revoked_sessions: set[str] = set()
def _check_rate_limit(ip: str) -> None:
"""Raise 429 if IP has exceeded failed login attempts."""
now = time.time()
attempts = _failed_attempts[ip]
# Prune old entries outside the window
_failed_attempts[ip] = [t for t in attempts if now - t < _WINDOW_SECONDS]
# Remove the key entirely if no recent attempts remain
if not _failed_attempts[ip]:
del _failed_attempts[ip]
elif len(_failed_attempts[ip]) >= _MAX_ATTEMPTS:
raise HTTPException(
status_code=429,
detail="Too many failed login attempts. Try again in a few minutes.",
)
def _record_failed_attempt(ip: str) -> None:
"""Record a failed login attempt for the given IP."""
_failed_attempts[ip].append(time.time())
def hash_pin(pin: str) -> str: def hash_pin(pin: str) -> str:
"""Hash a PIN using bcrypt.""" """Hash a PIN using bcrypt."""
@ -71,18 +40,6 @@ def verify_session_token(token: str) -> Optional[int]:
return None return None
def _set_session_cookie(response: Response, token: str) -> None:
"""Set the session cookie with secure defaults."""
response.set_cookie(
key="session",
value=token,
httponly=True,
secure=app_settings.COOKIE_SECURE,
max_age=86400 * 30, # 30 days
samesite="lax",
)
async def get_current_session( async def get_current_session(
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)
@ -91,10 +48,6 @@ async def get_current_session(
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
if session_cookie in _revoked_sessions:
raise HTTPException(status_code=401, detail="Session has been revoked")
user_id = verify_session_token(session_cookie) user_id = verify_session_token(session_cookie)
if user_id is None: 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")
@ -115,7 +68,7 @@ async def setup_pin(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Create initial PIN. Only works if no settings exist.""" """Create initial PIN. Only works if no settings exist."""
result = await db.execute(select(Settings).with_for_update()) result = await db.execute(select(Settings))
existing = result.scalar_one_or_none() existing = result.scalar_one_or_none()
if existing: if existing:
@ -129,7 +82,13 @@ async def setup_pin(
# Create session # Create session
token = create_session_token(new_settings.id) token = create_session_token(new_settings.id)
_set_session_cookie(response, token) response.set_cookie(
key="session",
value=token,
httponly=True,
max_age=86400 * 30, # 30 days
samesite="lax"
)
return {"message": "Setup completed successfully", "authenticated": True} return {"message": "Setup completed successfully", "authenticated": True}
@ -137,14 +96,10 @@ async def setup_pin(
@router.post("/login") @router.post("/login")
async def login( async def login(
data: SettingsCreate, data: SettingsCreate,
request: Request,
response: Response, response: Response,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Verify PIN and create session.""" """Verify PIN and create session."""
client_ip = request.client.host if request.client else "unknown"
_check_rate_limit(client_ip)
result = await db.execute(select(Settings)) result = await db.execute(select(Settings))
settings_obj = result.scalar_one_or_none() settings_obj = result.scalar_one_or_none()
@ -152,33 +107,25 @@ async def login(
raise HTTPException(status_code=400, detail="Setup required") raise HTTPException(status_code=400, detail="Setup required")
if not verify_pin(data.pin, settings_obj.pin_hash): if not verify_pin(data.pin, settings_obj.pin_hash):
_record_failed_attempt(client_ip)
raise HTTPException(status_code=401, detail="Invalid PIN") raise HTTPException(status_code=401, detail="Invalid PIN")
# Clear failed attempts on successful login
_failed_attempts.pop(client_ip, None)
# Create session # Create session
token = create_session_token(settings_obj.id) token = create_session_token(settings_obj.id)
_set_session_cookie(response, token) response.set_cookie(
key="session",
value=token,
httponly=True,
max_age=86400 * 30, # 30 days
samesite="lax"
)
return {"message": "Login successful", "authenticated": True} return {"message": "Login successful", "authenticated": True}
@router.post("/logout") @router.post("/logout")
async def logout( async def logout(response: Response):
response: Response, """Clear session cookie."""
session_cookie: Optional[str] = Cookie(None, alias="session") response.delete_cookie(key="session")
):
"""Clear session cookie and invalidate server-side session."""
if session_cookie:
_revoked_sessions.add(session_cookie)
response.delete_cookie(
key="session",
httponly=True,
secure=app_settings.COOKIE_SECURE,
samesite="lax"
)
return {"message": "Logout successful"} return {"message": "Logout successful"}
@ -195,11 +142,8 @@ async def auth_status(
authenticated = False authenticated = False
if not setup_required and session_cookie: if not setup_required and session_cookie:
if session_cookie in _revoked_sessions: user_id = verify_session_token(session_cookie)
authenticated = False authenticated = user_id is not None
else:
user_id = verify_session_token(session_cookie)
authenticated = user_id is not None
return { return {
"authenticated": authenticated, "authenticated": authenticated,

View File

@ -132,11 +132,9 @@ async def get_upcoming(
todos_result = await db.execute(todos_query) todos_result = await db.execute(todos_query)
todos = todos_result.scalars().all() todos = todos_result.scalars().all()
# Get upcoming events (from today onward) # Get upcoming events
today_start = datetime.combine(today, datetime.min.time())
events_query = select(CalendarEvent).where( events_query = select(CalendarEvent).where(
CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= cutoff_datetime
CalendarEvent.start_datetime <= cutoff_datetime,
) )
events_result = await db.execute(events_query) events_result = await db.execute(events_query)
events = events_result.scalars().all() events = events_result.scalars().all()

View File

@ -24,10 +24,10 @@ async def get_events(
query = select(CalendarEvent) query = select(CalendarEvent)
if start: if start:
query = query.where(CalendarEvent.end_datetime >= start) query = query.where(CalendarEvent.start_datetime >= start)
if end: if end:
query = query.where(CalendarEvent.start_datetime <= end) query = query.where(CalendarEvent.end_datetime <= end)
query = query.order_by(CalendarEvent.start_datetime.asc()) query = query.order_by(CalendarEvent.start_datetime.asc())

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime, timezone
from app.database import get_db from app.database import get_db
from app.models.todo import Todo from app.models.todo import Todo
@ -95,7 +95,7 @@ async def update_todo(
# Handle completion timestamp # Handle completion timestamp
if "completed" in update_data: if "completed" in update_data:
if update_data["completed"] and not todo.completed: if update_data["completed"] and not todo.completed:
update_data["completed_at"] = datetime.now() update_data["completed_at"] = datetime.now(timezone.utc)
elif not update_data["completed"]: elif not update_data["completed"]:
update_data["completed_at"] = None update_data["completed_at"] = None
@ -141,7 +141,7 @@ async def toggle_todo(
raise HTTPException(status_code=404, detail="Todo not found") raise HTTPException(status_code=404, detail="Todo not found")
todo.completed = not todo.completed todo.completed = not todo.completed
todo.completed_at = datetime.now() if todo.completed else None todo.completed_at = datetime.now(timezone.utc) if todo.completed else None
await db.commit() await db.commit()
await db.refresh(todo) await db.refresh(todo)

View File

@ -1,15 +1,13 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List, Literal from typing import Optional, List
from app.schemas.project_task import ProjectTaskResponse from app.schemas.project_task import ProjectTaskResponse
ProjectStatus = Literal["not_started", "in_progress", "completed"]
class ProjectCreate(BaseModel): class ProjectCreate(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
status: ProjectStatus = "not_started" status: str = "not_started"
color: Optional[str] = None color: Optional[str] = None
due_date: Optional[date] = None due_date: Optional[date] = None
@ -17,7 +15,7 @@ class ProjectCreate(BaseModel):
class ProjectUpdate(BaseModel): class ProjectUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
status: Optional[ProjectStatus] = None status: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
due_date: Optional[date] = None due_date: Optional[date] = None

View File

@ -1,16 +1,13 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List, Literal from typing import Optional, List
TaskStatus = Literal["pending", "in_progress", "completed"]
TaskPriority = Literal["low", "medium", "high"]
class ProjectTaskCreate(BaseModel): class ProjectTaskCreate(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
status: TaskStatus = "pending" status: str = "pending"
priority: TaskPriority = "medium" priority: str = "medium"
due_date: Optional[date] = None due_date: Optional[date] = None
person_id: Optional[int] = None person_id: Optional[int] = None
sort_order: int = 0 sort_order: int = 0
@ -20,8 +17,8 @@ class ProjectTaskCreate(BaseModel):
class ProjectTaskUpdate(BaseModel): class ProjectTaskUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
status: Optional[TaskStatus] = None status: Optional[str] = None
priority: Optional[TaskPriority] = None priority: Optional[str] = None
due_date: Optional[date] = None due_date: Optional[date] = None
person_id: Optional[int] = None person_id: Optional[int] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None

View File

@ -1,29 +1,13 @@
from pydantic import BaseModel, ConfigDict, field_validator from pydantic import BaseModel, ConfigDict
from datetime import datetime from datetime import datetime
from typing import Literal, Optional
AccentColor = Literal["cyan", "blue", "green", "purple", "red", "orange", "pink", "yellow"]
def _validate_pin_length(v: str, label: str = "PIN") -> str:
if len(v) < 4:
raise ValueError(f'{label} must be at least 4 characters')
if len(v) > 72:
raise ValueError(f'{label} must be at most 72 characters')
return v
class SettingsCreate(BaseModel): class SettingsCreate(BaseModel):
pin: str 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: str | None = None
upcoming_days: int | None = None upcoming_days: int | None = None
@ -40,8 +24,3 @@ class SettingsResponse(BaseModel):
class ChangePinRequest(BaseModel): class ChangePinRequest(BaseModel):
old_pin: str old_pin: str
new_pin: str new_pin: str
@field_validator('new_pin')
@classmethod
def new_pin_length(cls, v: str) -> str:
return _validate_pin_length(v, "New PIN")

View File

@ -1,14 +1,12 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, Literal from typing import Optional
TodoPriority = Literal["low", "medium", "high"]
class TodoCreate(BaseModel): class TodoCreate(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
priority: TodoPriority = "medium" priority: str = "medium"
due_date: Optional[date] = None due_date: Optional[date] = None
category: Optional[str] = None category: Optional[str] = None
recurrence_rule: Optional[str] = None recurrence_rule: Optional[str] = None
@ -18,7 +16,7 @@ class TodoCreate(BaseModel):
class TodoUpdate(BaseModel): class TodoUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
priority: Optional[TodoPriority] = None priority: Optional[str] = None
due_date: Optional[date] = None due_date: Optional[date] = None
completed: Optional[bool] = None completed: Optional[bool] = None
category: Optional[str] = None category: Optional[str] = None

View File

@ -32,15 +32,10 @@ server {
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always;
} }
# Security headers # Security headers
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';" always;
} }

View File

@ -1,9 +1,8 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { useNavigate, Navigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Lock } from 'lucide-react'; import { Lock } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
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';
@ -15,11 +14,6 @@ export default function LockScreen() {
const [pin, setPin] = useState(''); const [pin, setPin] = useState('');
const [confirmPin, setConfirmPin] = useState(''); const [confirmPin, setConfirmPin] = useState('');
// Redirect authenticated users to dashboard
if (authStatus?.authenticated) {
return <Navigate to="/dashboard" replace />;
}
const handleSubmit = async (e: FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -36,15 +30,15 @@ export default function LockScreen() {
await setup(pin); await setup(pin);
toast.success('PIN created successfully'); toast.success('PIN created successfully');
navigate('/dashboard'); navigate('/dashboard');
} catch (error) { } catch (error: any) {
toast.error(getErrorMessage(error, 'Failed to create PIN')); toast.error(error.response?.data?.detail || 'Failed to create PIN');
} }
} else { } else {
try { try {
await login(pin); await login(pin);
navigate('/dashboard'); navigate('/dashboard');
} catch (error) { } catch (error: any) {
toast.error(getErrorMessage(error, 'Invalid PIN')); toast.error(error.response?.data?.detail || 'Invalid PIN');
setPin(''); setPin('');
} }
} }

View File

@ -17,8 +17,7 @@ export default function DashboardPage() {
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['dashboard'], queryKey: ['dashboard'],
queryFn: async () => { queryFn: async () => {
const now = new Date(); const today = new Date().toISOString().split('T')[0];
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const { data } = await api.get<DashboardData>(`/dashboard?client_date=${today}`); const { data } = await api.get<DashboardData>(`/dashboard?client_date=${today}`);
return data; return data;
}, },

View File

@ -155,10 +155,7 @@ export default function ProjectDetail() {
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => { onClick={() => deleteProjectMutation.mutate()}
if (!window.confirm('Delete this project and all its tasks?')) return;
deleteProjectMutation.mutate();
}}
disabled={deleteProjectMutation.isPending} disabled={deleteProjectMutation.isPending}
> >
Delete Project Delete Project
@ -263,10 +260,7 @@ export default function ProjectDetail() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => { onClick={() => deleteTaskMutation.mutate(task.id)}
if (!window.confirm('Delete this task and all its subtasks?')) return;
deleteTaskMutation.mutate(task.id);
}}
disabled={deleteTaskMutation.isPending} disabled={deleteTaskMutation.isPending}
title="Delete task" title="Delete task"
> >
@ -327,10 +321,7 @@ export default function ProjectDetail() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => { onClick={() => deleteTaskMutation.mutate(subtask.id)}
if (!window.confirm('Delete this subtask?')) return;
deleteTaskMutation.mutate(subtask.id);
}}
disabled={deleteTaskMutation.isPending} disabled={deleteTaskMutation.isPending}
title="Delete subtask" title="Delete subtask"
> >

View File

@ -93,7 +93,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
<Select <Select
id="status" id="status"
value={formData.status} value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as Project['status'] })} onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
> >
<option value="not_started">Not Started</option> <option value="not_started">Not Started</option>
<option value="in_progress">In Progress</option> <option value="in_progress">In Progress</option>

View File

@ -109,7 +109,7 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
<Select <Select
id="status" id="status"
value={formData.status} value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as ProjectTask['status'] })} onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
> >
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="in_progress">In Progress</option> <option value="in_progress">In Progress</option>
@ -122,7 +122,7 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
<Select <Select
id="priority" id="priority"
value={formData.priority} value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as ProjectTask['priority'] })} onChange={(e) => setFormData({ ...formData, priority: e.target.value as any })}
> >
<option value="low">Low</option> <option value="low">Low</option>
<option value="medium">Medium</option> <option value="medium">Medium</option>

View File

@ -27,7 +27,7 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: reminder?.title || '', title: reminder?.title || '',
description: reminder?.description || '', description: reminder?.description || '',
remind_at: reminder?.remind_at ? reminder.remind_at.slice(0, 16) : '', remind_at: reminder?.remind_at || '',
recurrence_rule: reminder?.recurrence_rule || '', recurrence_rule: reminder?.recurrence_rule || '',
}); });

View File

@ -5,7 +5,6 @@ const api = axios.create({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
withCredentials: true,
}); });
api.interceptors.response.use( api.interceptors.response.use(

View File

@ -64,15 +64,6 @@ Personal life administration web app with dark theme, accent color customization
- [x] Frontend: `CalendarWidget` (today's events with color indicators) - [x] Frontend: `CalendarWidget` (today's events with color indicators)
- [x] Frontend: Active reminders section in dashboard - [x] Frontend: Active reminders section in dashboard
### Phase 6b: Project Subtasks
- [x] Backend: Self-referencing `parent_task_id` FK on `project_tasks` with CASCADE delete
- [x] Backend: Alembic migration `002_add_subtasks.py`
- [x] Backend: Schema updates — `parent_task_id` in create, nested `subtasks` in response, `model_rebuild()`
- [x] Backend: Chained `selectinload` for two-level subtask loading, parent validation on create
- [x] Frontend: `ProjectTask` type updated with `parent_task_id` and `subtasks`
- [x] Frontend: `TaskForm` accepts `parentTaskId` prop, context-aware dialog title
- [x] Frontend: `ProjectDetail` — expand/collapse chevrons, subtask progress bars, indented subtask cards
### Phase 7: Settings & Polish ### Phase 7: Settings & Polish
- [x] Backend: Settings router (get/update settings, change PIN) - [x] Backend: Settings router (get/update settings, change PIN)
- [x] Frontend: `SettingsPage` (accent color picker, upcoming range, PIN change) - [x] Frontend: `SettingsPage` (accent color picker, upcoming range, PIN change)
@ -128,64 +119,6 @@ These were found during first Docker build and integration testing:
--- ---
## Code Review Findings — Round 1 (Senior Review)
### Critical:
- [x] C1: CORS `allow_origins=["*"]` with `allow_credentials=True` — already restricted to `["http://localhost:5173"]` (`main.py`)
- [x] C2: `datetime.now(timezone.utc)` in naive column — changed to `datetime.now()` (`todos.py`)
- [x] C3: Session cookie missing `secure` flag — added `secure=True` + `_set_session_cookie` helper (`auth.py`)
- [x] C4: No PIN length validation on backend — added `field_validator` for min 4 chars (`schemas/settings.py`)
### High:
- [x] H1: No brute-force protection on login — added in-memory rate limiting (5 attempts / 5 min) (`auth.py`)
- [x] H2: `echo=True` on SQLAlchemy engine — set to `False` (`database.py`)
- [x] H3: Double commit pattern — removed auto-commit from `get_db`, routers handle commits (`database.py`)
- [ ] H4: `Person.relationship` column shadows SQLAlchemy name — deferred (requires migration + schema changes across stack)
- [x] H5: Upcoming events missing lower bound filter — added `>= today_start` (`dashboard.py`)
- [x] H6: `ReminderForm.tsx` doesn't slice `remind_at` — added `.slice(0, 16)` for datetime-local input
### Medium:
- [x] M1: Default `SECRET_KEY` is predictable — added stderr warning on startup (`config.py`)
- [x] M3: `create_all` in lifespan conflicts with Alembic — removed (`main.py`)
- [x] M6: No confirmation dialog before destructive actions — added `window.confirm()` on all delete buttons
- [x] M7: Authenticated users can still navigate to `/login` — added `Navigate` redirect in `LockScreen.tsx`
- [x] L1: Error handling in LockScreen used `error: any` — replaced with `getErrorMessage` helper
## Code Review Findings — Round 2 (Senior Review)
### Critical:
- [x] C1: Default SECRET_KEY only warns, doesn't block production — added env-aware fail-fast (`config.py`)
- [x] C2: `secure=True` cookie breaks HTTP development — made configurable via `COOKIE_SECURE` setting (`auth.py`, `config.py`)
- [x] C3: No enum validation on status/priority fields — added `Literal` types (`schemas/project_task.py`, `todo.py`, `project.py`)
- [x] C4: Race condition in PIN setup (TOCTOU) — added `select().with_for_update()` (`auth.py`)
### High:
- [x] H1: Rate limiter memory leak — added stale key cleanup, `del` empty entries (`auth.py`)
- [ ] H2: Dashboard runs 7 sequential DB queries — deferred (asyncpg single-session limitation)
- [ ] H3: Subtask eager loading fragile at 2 levels — accepted (business logic enforces single nesting)
- [x] H4: No `withCredentials` on Axios for Vite dev — added to `api.ts`
- [x] H5: Logout doesn't invalidate session server-side — added in-memory `_revoked_sessions` set (`auth.py`)
### Medium:
- [ ] M1: TodosPage fetches all then filters client-side — deferred (acceptable for personal app scale)
- [x] M2: Dashboard uses `.toISOString()` violating CLAUDE.md rules — replaced with local date formatter (`DashboardPage.tsx`)
- [x] M3: No CSP header in nginx — added CSP + Referrer-Policy, removed deprecated X-XSS-Protection (`nginx.conf`)
- [x] M4: Event date filtering misses range-spanning events — fixed range overlap logic (`events.py`)
- [x] M5: `accent_color` accepts arbitrary strings — added `Literal` validation for allowed colors (`schemas/settings.py`)
- [x] M6: Logout `delete_cookie` doesn't match `set_cookie` attributes — matched all cookie params (`auth.py`)
- [x] M7: bcrypt silently truncates PIN at 72 bytes — added max 72 char validation (`schemas/settings.py`)
### Low:
- [x] L1: `as any` type casts in frontend forms — replaced with proper `Type['field']` casts (`TaskForm.tsx`, `ProjectForm.tsx`)
- [x] L2: Unused imports in `events.py` — false positive, all imports are used
- [ ] L3: Upcoming endpoint mixes date/datetime string sorting — deferred (works correctly for ISO format)
- [ ] L4: Backend port 8000 exposed directly, bypassing nginx — deferred (useful for dev)
- [ ] L5: `parseInt(id!)` without NaN validation — deferred (low risk, route-level protection)
- [x] L6: `X-XSS-Protection` header is deprecated — removed, replaced with CSP (`nginx.conf`)
- [x] L7: Missing `Referrer-Policy` header — added `strict-origin-when-cross-origin` (`nginx.conf`)
---
## Outstanding Items (Resume Here If Halted) ## Outstanding Items (Resume Here If Halted)
### Critical (blocks deployment): ### Critical (blocks deployment):
@ -219,8 +152,7 @@ backend/
│ ├── env.py │ ├── env.py
│ ├── script.py.mako │ ├── script.py.mako
│ └── versions/ │ └── versions/
│ ├── 001_initial_migration.py │ └── 001_initial_migration.py
│ └── 002_add_subtasks.py
└── app/ └── app/
├── main.py ├── main.py
├── config.py ├── config.py