Compare commits
No commits in common. "27c65ce40da61e5af04398c3b4a669206ea36888" and "81edf81d13089b3514ea148ba8215fad13a63d80" have entirely different histories.
27c65ce40d
...
81edf81d13
@ -1,12 +1,9 @@
|
||||
import sys
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/umbra"
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ENVIRONMENT: str = "development"
|
||||
COOKIE_SECURE: bool = False
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
@ -16,17 +13,3 @@ class Settings(BaseSettings):
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@ -5,7 +5,7 @@ from app.config import settings
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
echo=True,
|
||||
future=True
|
||||
)
|
||||
|
||||
@ -27,6 +27,7 @@ async def get_db() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
@ -2,13 +2,17 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
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
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: Create tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
# Shutdown: Clean up resources
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@ -22,7 +26,7 @@ app = FastAPI(
|
||||
# CORS configuration for development
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"],
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
|
||||
@ -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 import select
|
||||
from typing import Optional
|
||||
from collections import defaultdict
|
||||
import time
|
||||
import bcrypt
|
||||
from itsdangerous import TimestampSigner, BadSignature
|
||||
|
||||
@ -17,35 +15,6 @@ router = APIRouter()
|
||||
# Initialize signer for session management
|
||||
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:
|
||||
"""Hash a PIN using bcrypt."""
|
||||
@ -71,18 +40,6 @@ def verify_session_token(token: str) -> Optional[int]:
|
||||
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(
|
||||
session_cookie: Optional[str] = Cookie(None, alias="session"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
@ -91,10 +48,6 @@ async def get_current_session(
|
||||
if not session_cookie:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
# Check if session has been revoked
|
||||
if session_cookie in _revoked_sessions:
|
||||
raise HTTPException(status_code=401, detail="Session has been revoked")
|
||||
|
||||
user_id = verify_session_token(session_cookie)
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired session")
|
||||
@ -115,7 +68,7 @@ async def setup_pin(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""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()
|
||||
|
||||
if existing:
|
||||
@ -129,7 +82,13 @@ async def setup_pin(
|
||||
|
||||
# Create session
|
||||
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}
|
||||
|
||||
@ -137,14 +96,10 @@ async def setup_pin(
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
data: SettingsCreate,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""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))
|
||||
settings_obj = result.scalar_one_or_none()
|
||||
|
||||
@ -152,33 +107,25 @@ async def login(
|
||||
raise HTTPException(status_code=400, detail="Setup required")
|
||||
|
||||
if not verify_pin(data.pin, settings_obj.pin_hash):
|
||||
_record_failed_attempt(client_ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid PIN")
|
||||
|
||||
# Clear failed attempts on successful login
|
||||
_failed_attempts.pop(client_ip, None)
|
||||
|
||||
# Create session
|
||||
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}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
response: Response,
|
||||
session_cookie: Optional[str] = Cookie(None, alias="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"
|
||||
)
|
||||
async def logout(response: Response):
|
||||
"""Clear session cookie."""
|
||||
response.delete_cookie(key="session")
|
||||
return {"message": "Logout successful"}
|
||||
|
||||
|
||||
@ -195,11 +142,8 @@ async def auth_status(
|
||||
authenticated = False
|
||||
|
||||
if not setup_required and session_cookie:
|
||||
if session_cookie in _revoked_sessions:
|
||||
authenticated = False
|
||||
else:
|
||||
user_id = verify_session_token(session_cookie)
|
||||
authenticated = user_id is not None
|
||||
user_id = verify_session_token(session_cookie)
|
||||
authenticated = user_id is not None
|
||||
|
||||
return {
|
||||
"authenticated": authenticated,
|
||||
|
||||
@ -132,11 +132,9 @@ async def get_upcoming(
|
||||
todos_result = await db.execute(todos_query)
|
||||
todos = todos_result.scalars().all()
|
||||
|
||||
# Get upcoming events (from today onward)
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
# Get upcoming events
|
||||
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 = events_result.scalars().all()
|
||||
|
||||
@ -24,10 +24,10 @@ async def get_events(
|
||||
query = select(CalendarEvent)
|
||||
|
||||
if start:
|
||||
query = query.where(CalendarEvent.end_datetime >= start)
|
||||
query = query.where(CalendarEvent.start_datetime >= start)
|
||||
|
||||
if end:
|
||||
query = query.where(CalendarEvent.start_datetime <= end)
|
||||
query = query.where(CalendarEvent.end_datetime <= end)
|
||||
|
||||
query = query.order_by(CalendarEvent.start_datetime.asc())
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.todo import Todo
|
||||
@ -95,7 +95,7 @@ async def update_todo(
|
||||
# Handle completion timestamp
|
||||
if "completed" in update_data:
|
||||
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"]:
|
||||
update_data["completed_at"] = None
|
||||
|
||||
@ -141,7 +141,7 @@ async def toggle_todo(
|
||||
raise HTTPException(status_code=404, detail="Todo not found")
|
||||
|
||||
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.refresh(todo)
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List, Literal
|
||||
from typing import Optional, List
|
||||
from app.schemas.project_task import ProjectTaskResponse
|
||||
|
||||
ProjectStatus = Literal["not_started", "in_progress", "completed"]
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
status: ProjectStatus = "not_started"
|
||||
status: str = "not_started"
|
||||
color: Optional[str] = None
|
||||
due_date: Optional[date] = None
|
||||
|
||||
@ -17,7 +15,7 @@ class ProjectCreate(BaseModel):
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[ProjectStatus] = None
|
||||
status: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
due_date: Optional[date] = None
|
||||
|
||||
|
||||
@ -1,16 +1,13 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List, Literal
|
||||
|
||||
TaskStatus = Literal["pending", "in_progress", "completed"]
|
||||
TaskPriority = Literal["low", "medium", "high"]
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class ProjectTaskCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
status: TaskStatus = "pending"
|
||||
priority: TaskPriority = "medium"
|
||||
status: str = "pending"
|
||||
priority: str = "medium"
|
||||
due_date: Optional[date] = None
|
||||
person_id: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
@ -20,8 +17,8 @@ class ProjectTaskCreate(BaseModel):
|
||||
class ProjectTaskUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[TaskStatus] = None
|
||||
priority: Optional[TaskPriority] = None
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
due_date: Optional[date] = None
|
||||
person_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
@ -1,29 +1,13 @@
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
AccentColor = Literal["cyan", "blue", "green", "purple", "red", "orange", "pink", "yellow"]
|
||||
|
||||
|
||||
def _validate_pin_length(v: str, label: str = "PIN") -> str:
|
||||
if len(v) < 4:
|
||||
raise ValueError(f'{label} must be at least 4 characters')
|
||||
if len(v) > 72:
|
||||
raise ValueError(f'{label} must be at most 72 characters')
|
||||
return v
|
||||
|
||||
|
||||
class SettingsCreate(BaseModel):
|
||||
pin: str
|
||||
|
||||
@field_validator('pin')
|
||||
@classmethod
|
||||
def pin_length(cls, v: str) -> str:
|
||||
return _validate_pin_length(v)
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
accent_color: Optional[AccentColor] = None
|
||||
accent_color: str | None = None
|
||||
upcoming_days: int | None = None
|
||||
|
||||
|
||||
@ -40,8 +24,3 @@ class SettingsResponse(BaseModel):
|
||||
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")
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, Literal
|
||||
|
||||
TodoPriority = Literal["low", "medium", "high"]
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class TodoCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
priority: TodoPriority = "medium"
|
||||
priority: str = "medium"
|
||||
due_date: Optional[date] = None
|
||||
category: Optional[str] = None
|
||||
recurrence_rule: Optional[str] = None
|
||||
@ -18,7 +16,7 @@ class TodoCreate(BaseModel):
|
||||
class TodoUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
priority: Optional[TodoPriority] = None
|
||||
priority: Optional[str] = None
|
||||
due_date: Optional[date] = None
|
||||
completed: Optional[bool] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
@ -32,15 +32,10 @@ server {
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
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
|
||||
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;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useNavigate, Navigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -15,11 +14,6 @@ export default function LockScreen() {
|
||||
const [pin, setPin] = useState('');
|
||||
const [confirmPin, setConfirmPin] = useState('');
|
||||
|
||||
// Redirect authenticated users to dashboard
|
||||
if (authStatus?.authenticated) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -36,15 +30,15 @@ export default function LockScreen() {
|
||||
await setup(pin);
|
||||
toast.success('PIN created successfully');
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to create PIN'));
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to create PIN');
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await login(pin);
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Invalid PIN'));
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.detail || 'Invalid PIN');
|
||||
setPin('');
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,8 +17,7 @@ export default function DashboardPage() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: async () => {
|
||||
const now = new Date();
|
||||
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const { data } = await api.get<DashboardData>(`/dashboard?client_date=${today}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
@ -155,10 +155,7 @@ export default function ProjectDetail() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (!window.confirm('Delete this project and all its tasks?')) return;
|
||||
deleteProjectMutation.mutate();
|
||||
}}
|
||||
onClick={() => deleteProjectMutation.mutate()}
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
>
|
||||
Delete Project
|
||||
@ -263,10 +260,7 @@ export default function ProjectDetail() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (!window.confirm('Delete this task and all its subtasks?')) return;
|
||||
deleteTaskMutation.mutate(task.id);
|
||||
}}
|
||||
onClick={() => deleteTaskMutation.mutate(task.id)}
|
||||
disabled={deleteTaskMutation.isPending}
|
||||
title="Delete task"
|
||||
>
|
||||
@ -327,10 +321,7 @@ export default function ProjectDetail() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (!window.confirm('Delete this subtask?')) return;
|
||||
deleteTaskMutation.mutate(subtask.id);
|
||||
}}
|
||||
onClick={() => deleteTaskMutation.mutate(subtask.id)}
|
||||
disabled={deleteTaskMutation.isPending}
|
||||
title="Delete subtask"
|
||||
>
|
||||
|
||||
@ -93,7 +93,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
|
||||
<Select
|
||||
id="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="in_progress">In Progress</option>
|
||||
|
||||
@ -109,7 +109,7 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
|
||||
<Select
|
||||
id="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="in_progress">In Progress</option>
|
||||
@ -122,7 +122,7 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
|
||||
<Select
|
||||
id="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="medium">Medium</option>
|
||||
|
||||
@ -27,7 +27,7 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
title: reminder?.title || '',
|
||||
description: reminder?.description || '',
|
||||
remind_at: reminder?.remind_at ? reminder.remind_at.slice(0, 16) : '',
|
||||
remind_at: reminder?.remind_at || '',
|
||||
recurrence_rule: reminder?.recurrence_rule || '',
|
||||
});
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ const api = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
|
||||
70
progress.md
70
progress.md
@ -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: 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
|
||||
- [x] Backend: Settings router (get/update settings, change PIN)
|
||||
- [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)
|
||||
|
||||
### Critical (blocks deployment):
|
||||
@ -219,8 +152,7 @@ backend/
|
||||
│ ├── env.py
|
||||
│ ├── script.py.mako
|
||||
│ └── versions/
|
||||
│ ├── 001_initial_migration.py
|
||||
│ └── 002_add_subtasks.py
|
||||
│ └── 001_initial_migration.py
|
||||
└── app/
|
||||
├── main.py
|
||||
├── config.py
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user