Compare commits
No commits in common. "a94485b13898d1b703648556f3f2c0bc63efb4ed" and "a73bd17f47d799127f14a3fd8e27d31e8495bdd4" have entirely different histories.
a94485b138
...
a73bd17f47
@ -1,44 +0,0 @@
|
|||||||
# Version control
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Python artifacts
|
|
||||||
__pycache__
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
*.egg-info
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
.eggs
|
|
||||||
|
|
||||||
# Virtual environments
|
|
||||||
.venv
|
|
||||||
venv
|
|
||||||
env
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# Environment files — never bake secrets into the image
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
tests
|
|
||||||
pytest.ini
|
|
||||||
.pytest_cache
|
|
||||||
.coverage
|
|
||||||
htmlcov
|
|
||||||
|
|
||||||
# Documentation
|
|
||||||
README.md
|
|
||||||
CHANGELOG.md
|
|
||||||
LICENSE
|
|
||||||
|
|
||||||
# Dev scripts
|
|
||||||
start.sh
|
|
||||||
|
|
||||||
# Docker files (no need to copy into the image)
|
|
||||||
Dockerfile
|
|
||||||
docker-compose*.yaml
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
# ── Build stage: compile C extensions ──────────────────────────────────
|
# ── Build stage: compile C extensions ──────────────────────────────────
|
||||||
FROM python:3.12.9-slim-bookworm AS builder
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@ -11,25 +11,24 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||||
|
|
||||||
# ── Runtime stage: lean production image ───────────────────────────────
|
# ── Runtime stage: lean production image ───────────────────────────────
|
||||||
FROM python:3.12.9-slim-bookworm
|
FROM python:3.12-slim
|
||||||
|
|
||||||
# Create non-root user first, then copy with correct ownership (DW-2)
|
|
||||||
RUN useradd -m -u 1000 appuser
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy pre-built Python packages from builder
|
# Copy pre-built Python packages from builder
|
||||||
COPY --from=builder /install /usr/local
|
COPY --from=builder /install /usr/local
|
||||||
|
|
||||||
# Copy application code with correct ownership — avoids redundant chown layer
|
# Copy application code
|
||||||
COPY --chown=appuser:appuser . .
|
COPY . .
|
||||||
|
|
||||||
# Make entrypoint executable
|
|
||||||
RUN chmod +x entrypoint.sh
|
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Use entrypoint with exec so uvicorn runs as PID 1 and receives signals (DW-5)
|
# Run migrations and start server
|
||||||
ENTRYPOINT ["./entrypoint.sh"]
|
# --no-server-header: suppresses uvicorn version disclosure
|
||||||
|
# --proxy-headers: reads X-Forwarded-Proto/For from reverse proxy so redirects use correct scheme
|
||||||
|
# --forwarded-allow-ips '*': trusts proxy headers from any IP (nginx is on Docker bridge network)
|
||||||
|
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header --proxy-headers --forwarded-allow-ips '*'"]
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
"""Add composite indexes for calendar_members and ntfy_sent
|
|
||||||
|
|
||||||
Revision ID: 053
|
|
||||||
Revises: 052
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "053"
|
|
||||||
down_revision = "052"
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
# AW-1: Hot query polled every 5s uses (user_id, status) together
|
|
||||||
op.create_index(
|
|
||||||
"ix_calendar_members_user_id_status",
|
|
||||||
"calendar_members",
|
|
||||||
["user_id", "status"],
|
|
||||||
)
|
|
||||||
# AS-6: Dedup lookup in notification dispatch uses (user_id, sent_at)
|
|
||||||
op.create_index(
|
|
||||||
"ix_ntfy_sent_user_id_sent_at",
|
|
||||||
"ntfy_sent",
|
|
||||||
["user_id", "sent_at"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
op.drop_index("ix_ntfy_sent_user_id_sent_at", table_name="ntfy_sent")
|
|
||||||
op.drop_index("ix_calendar_members_user_id_status", table_name="calendar_members")
|
|
||||||
@ -2,15 +2,11 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess
|
|||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# Create async engine with tuned pool (AW-7)
|
# Create async engine
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
settings.DATABASE_URL,
|
settings.DATABASE_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
future=True,
|
future=True
|
||||||
pool_size=10,
|
|
||||||
max_overflow=5,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
pool_recycle=1800,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create async session factory
|
# Create async session factory
|
||||||
|
|||||||
@ -56,8 +56,8 @@ async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]:
|
|||||||
|
|
||||||
|
|
||||||
async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
|
async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
|
||||||
"""Stage a sent record — caller must commit (AW-4: bulk commit per user)."""
|
|
||||||
db.add(NtfySent(notification_key=key, user_id=user_id))
|
db.add(NtfySent(notification_key=key, user_id=user_id))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
# ── Dispatch functions ────────────────────────────────────────────────────────
|
# ── Dispatch functions ────────────────────────────────────────────────────────
|
||||||
@ -239,20 +239,14 @@ async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime
|
|||||||
# Batch-fetch all sent keys once per user instead of one query per entity
|
# Batch-fetch all sent keys once per user instead of one query per entity
|
||||||
sent_keys = await _get_sent_keys(db, settings.user_id)
|
sent_keys = await _get_sent_keys(db, settings.user_id)
|
||||||
|
|
||||||
# AW-4: Commit after each category to preserve dedup records if a later
|
|
||||||
# category fails (prevents re-sending already-sent notifications)
|
|
||||||
if settings.ntfy_reminders_enabled:
|
if settings.ntfy_reminders_enabled:
|
||||||
await _dispatch_reminders(db, settings, now, sent_keys)
|
await _dispatch_reminders(db, settings, now, sent_keys)
|
||||||
await db.commit()
|
|
||||||
if settings.ntfy_events_enabled:
|
if settings.ntfy_events_enabled:
|
||||||
await _dispatch_events(db, settings, now, sent_keys)
|
await _dispatch_events(db, settings, now, sent_keys)
|
||||||
await db.commit()
|
|
||||||
if settings.ntfy_todos_enabled:
|
if settings.ntfy_todos_enabled:
|
||||||
await _dispatch_todos(db, settings, now.date(), sent_keys)
|
await _dispatch_todos(db, settings, now.date(), sent_keys)
|
||||||
await db.commit()
|
|
||||||
if settings.ntfy_projects_enabled:
|
if settings.ntfy_projects_enabled:
|
||||||
await _dispatch_projects(db, settings, now.date(), sent_keys)
|
await _dispatch_projects(db, settings, now.date(), sent_keys)
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def _purge_old_sent_records(db: AsyncSession) -> None:
|
async def _purge_old_sent_records(db: AsyncSession) -> None:
|
||||||
|
|||||||
@ -51,7 +51,7 @@ from app.schemas.admin import (
|
|||||||
UserListResponse,
|
UserListResponse,
|
||||||
)
|
)
|
||||||
from app.services.audit import get_client_ip, log_audit_event
|
from app.services.audit import get_client_ip, log_audit_event
|
||||||
from app.services.auth import ahash_password
|
from app.services.auth import hash_password
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Router — all endpoints inherit require_admin
|
# Router — all endpoints inherit require_admin
|
||||||
@ -225,7 +225,7 @@ async def create_user(
|
|||||||
new_user = User(
|
new_user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
umbral_name=data.username,
|
umbral_name=data.username,
|
||||||
password_hash=await ahash_password(data.password),
|
password_hash=hash_password(data.password),
|
||||||
role=data.role,
|
role=data.role,
|
||||||
email=email,
|
email=email,
|
||||||
first_name=data.first_name,
|
first_name=data.first_name,
|
||||||
@ -341,7 +341,7 @@ async def reset_user_password(
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
temp_password = secrets.token_urlsafe(16)
|
temp_password = secrets.token_urlsafe(16)
|
||||||
user.password_hash = await ahash_password(temp_password)
|
user.password_hash = hash_password(temp_password)
|
||||||
user.must_change_password = True
|
user.must_change_password = True
|
||||||
user.last_password_change_at = datetime.now()
|
user.last_password_change_at = datetime.now()
|
||||||
|
|
||||||
@ -740,18 +740,18 @@ async def admin_dashboard(
|
|||||||
_actor: User = Depends(get_current_user),
|
_actor: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Aggregate stats for the admin portal dashboard."""
|
"""Aggregate stats for the admin portal dashboard."""
|
||||||
# AW-6: Single conditional aggregation instead of 5 separate COUNT queries
|
total_users = await db.scalar(
|
||||||
user_stats = await db.execute(
|
sa.select(sa.func.count()).select_from(User)
|
||||||
sa.select(
|
)
|
||||||
sa.func.count().label("total"),
|
active_users = await db.scalar(
|
||||||
sa.func.count().filter(User.is_active == True).label("active"),
|
sa.select(sa.func.count()).select_from(User).where(User.is_active == True)
|
||||||
sa.func.count().filter(User.role == "admin").label("admins"),
|
)
|
||||||
sa.func.count().filter(User.totp_enabled == True).label("totp"),
|
admin_count = await db.scalar(
|
||||||
).select_from(User)
|
sa.select(sa.func.count()).select_from(User).where(User.role == "admin")
|
||||||
|
)
|
||||||
|
totp_count = await db.scalar(
|
||||||
|
sa.select(sa.func.count()).select_from(User).where(User.totp_enabled == True)
|
||||||
)
|
)
|
||||||
row = user_stats.one()
|
|
||||||
total_users, active_users, admin_count, totp_count = row.tuple()
|
|
||||||
|
|
||||||
active_sessions = await db.scalar(
|
active_sessions = await db.scalar(
|
||||||
sa.select(sa.func.count()).select_from(UserSession).where(
|
sa.select(sa.func.count()).select_from(UserSession).where(
|
||||||
UserSession.revoked == False,
|
UserSession.revoked == False,
|
||||||
|
|||||||
@ -37,9 +37,6 @@ from app.schemas.auth import (
|
|||||||
ProfileUpdate, ProfileResponse,
|
ProfileUpdate, ProfileResponse,
|
||||||
)
|
)
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
ahash_password,
|
|
||||||
averify_password,
|
|
||||||
averify_password_with_upgrade,
|
|
||||||
hash_password,
|
hash_password,
|
||||||
verify_password,
|
verify_password,
|
||||||
verify_password_with_upgrade,
|
verify_password_with_upgrade,
|
||||||
@ -104,22 +101,25 @@ async def get_current_user(
|
|||||||
if user_id is None or session_id is None:
|
if user_id is None or session_id is None:
|
||||||
raise HTTPException(status_code=401, detail="Malformed session token")
|
raise HTTPException(status_code=401, detail="Malformed session token")
|
||||||
|
|
||||||
# AC-1: Single JOIN query for session + user (was 2 sequential queries)
|
# Verify session is active in DB (covers revocation + expiry)
|
||||||
result = await db.execute(
|
session_result = await db.execute(
|
||||||
select(UserSession, User)
|
select(UserSession).where(
|
||||||
.join(User, UserSession.user_id == User.id)
|
|
||||||
.where(
|
|
||||||
UserSession.id == session_id,
|
UserSession.id == session_id,
|
||||||
UserSession.user_id == user_id,
|
UserSession.user_id == user_id,
|
||||||
UserSession.revoked == False,
|
UserSession.revoked == False,
|
||||||
UserSession.expires_at > datetime.now(),
|
UserSession.expires_at > datetime.now(),
|
||||||
User.is_active == True,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
row = result.one_or_none()
|
db_session = session_result.scalar_one_or_none()
|
||||||
if not row:
|
if not db_session:
|
||||||
raise HTTPException(status_code=401, detail="Session expired or user inactive")
|
raise HTTPException(status_code=401, detail="Session has been revoked or expired")
|
||||||
db_session, user = row.tuple()
|
|
||||||
|
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")
|
||||||
|
|
||||||
# L-03: Sliding window renewal — extend session if >1 day has elapsed since
|
# L-03: Sliding window renewal — extend session if >1 day has elapsed since
|
||||||
# last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day).
|
# last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day).
|
||||||
@ -149,26 +149,19 @@ async def get_current_user(
|
|||||||
|
|
||||||
|
|
||||||
async def get_current_settings(
|
async def get_current_settings(
|
||||||
request: Request,
|
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Settings:
|
) -> Settings:
|
||||||
"""
|
"""
|
||||||
Convenience dependency for routers that need Settings access.
|
Convenience dependency for routers that need Settings access.
|
||||||
Always chain after get_current_user — never use standalone.
|
Always chain after get_current_user — never use standalone.
|
||||||
|
|
||||||
AC-3: Cache in request.state so multiple dependencies don't re-query.
|
|
||||||
"""
|
"""
|
||||||
cached = getattr(request.state, "settings", None)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Settings).where(Settings.user_id == current_user.id)
|
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=500, detail="Settings not found for user")
|
raise HTTPException(status_code=500, detail="Settings not found for user")
|
||||||
request.state.settings = settings_obj
|
|
||||||
return settings_obj
|
return settings_obj
|
||||||
|
|
||||||
|
|
||||||
@ -306,7 +299,7 @@ async def setup(
|
|||||||
if user_count.scalar_one() > 0:
|
if user_count.scalar_one() > 0:
|
||||||
raise HTTPException(status_code=400, detail="Setup already completed")
|
raise HTTPException(status_code=400, detail="Setup already completed")
|
||||||
|
|
||||||
password_hash = await ahash_password(data.password)
|
password_hash = hash_password(data.password)
|
||||||
new_user = User(
|
new_user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
umbral_name=data.username,
|
umbral_name=data.username,
|
||||||
@ -359,12 +352,12 @@ async def login(
|
|||||||
if not user:
|
if not user:
|
||||||
# M-02: Run Argon2id against a dummy hash so the response time is
|
# M-02: Run Argon2id against a dummy hash so the response time is
|
||||||
# indistinguishable from a wrong-password attempt (prevents username enumeration).
|
# indistinguishable from a wrong-password attempt (prevents username enumeration).
|
||||||
await averify_password("x", _DUMMY_HASH)
|
verify_password("x", _DUMMY_HASH)
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
# M-02: Run password verification BEFORE lockout check so Argon2id always
|
# M-02: Run password verification BEFORE lockout check so Argon2id always
|
||||||
# executes — prevents distinguishing "locked" from "wrong password" via timing.
|
# executes — prevents distinguishing "locked" from "wrong password" via timing.
|
||||||
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
|
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
|
||||||
|
|
||||||
await _check_account_lockout(user)
|
await _check_account_lockout(user)
|
||||||
|
|
||||||
@ -472,7 +465,7 @@ async def register(
|
|||||||
if existing_email.scalar_one_or_none():
|
if existing_email.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
|
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
|
||||||
|
|
||||||
password_hash = await ahash_password(data.password)
|
password_hash = hash_password(data.password)
|
||||||
# SEC-01: Explicit field assignment — never **data.model_dump()
|
# SEC-01: Explicit field assignment — never **data.model_dump()
|
||||||
new_user = User(
|
new_user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
@ -637,7 +630,7 @@ async def verify_password(
|
|||||||
"""
|
"""
|
||||||
await _check_account_lockout(current_user)
|
await _check_account_lockout(current_user)
|
||||||
|
|
||||||
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
await _record_failed_login(db, current_user)
|
await _record_failed_login(db, current_user)
|
||||||
raise HTTPException(status_code=401, detail="Invalid password")
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
@ -663,7 +656,7 @@ async def change_password(
|
|||||||
"""Change the current user's password. Requires old password verification."""
|
"""Change the current user's password. Requires old password verification."""
|
||||||
await _check_account_lockout(current_user)
|
await _check_account_lockout(current_user)
|
||||||
|
|
||||||
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
|
valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
|
||||||
if not valid:
|
if not valid:
|
||||||
await _record_failed_login(db, current_user)
|
await _record_failed_login(db, current_user)
|
||||||
raise HTTPException(status_code=401, detail="Invalid current password")
|
raise HTTPException(status_code=401, detail="Invalid current password")
|
||||||
@ -671,7 +664,7 @@ async def change_password(
|
|||||||
if data.new_password == data.old_password:
|
if data.new_password == data.old_password:
|
||||||
raise HTTPException(status_code=400, detail="New password must be different from your current password")
|
raise HTTPException(status_code=400, detail="New password must be different from your current password")
|
||||||
|
|
||||||
current_user.password_hash = await ahash_password(data.new_password)
|
current_user.password_hash = hash_password(data.new_password)
|
||||||
current_user.last_password_change_at = datetime.now()
|
current_user.last_password_change_at = datetime.now()
|
||||||
|
|
||||||
# Clear forced password change flag if set (SEC-12)
|
# Clear forced password change flag if set (SEC-12)
|
||||||
|
|||||||
@ -294,20 +294,16 @@ async def reorder_tasks(
|
|||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
# AC-4: Batch-fetch all tasks in one query instead of N sequential queries
|
for item in items:
|
||||||
task_ids = [item.id for item in items]
|
|
||||||
task_result = await db.execute(
|
task_result = await db.execute(
|
||||||
select(ProjectTask).where(
|
select(ProjectTask).where(
|
||||||
ProjectTask.id.in_(task_ids),
|
ProjectTask.id == item.id,
|
||||||
ProjectTask.project_id == project_id,
|
ProjectTask.project_id == project_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tasks_by_id = {t.id: t for t in task_result.scalars().all()}
|
task = task_result.scalar_one_or_none()
|
||||||
|
if task:
|
||||||
order_map = {item.id: item.sort_order for item in items}
|
task.sort_order = item.sort_order
|
||||||
for task_id, task in tasks_by_id.items():
|
|
||||||
if task_id in order_map:
|
|
||||||
task.sort_order = order_map[task_id]
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,6 @@ Security:
|
|||||||
- Failed TOTP attempts increment user.failed_login_count (shared lockout counter)
|
- Failed TOTP attempts increment user.failed_login_count (shared lockout counter)
|
||||||
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
import uuid
|
import uuid
|
||||||
import secrets
|
import secrets
|
||||||
import logging
|
import logging
|
||||||
@ -38,7 +37,8 @@ from app.models.backup_code import BackupCode
|
|||||||
from app.routers.auth import get_current_user, _set_session_cookie
|
from app.routers.auth import get_current_user, _set_session_cookie
|
||||||
from app.services.audit import get_client_ip
|
from app.services.audit import get_client_ip
|
||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
averify_password_with_upgrade,
|
verify_password_with_upgrade,
|
||||||
|
hash_password,
|
||||||
verify_mfa_token,
|
verify_mfa_token,
|
||||||
verify_mfa_enforce_token,
|
verify_mfa_enforce_token,
|
||||||
create_session_token,
|
create_session_token,
|
||||||
@ -117,10 +117,8 @@ class EnforceConfirmRequest(BaseModel):
|
|||||||
|
|
||||||
async def _store_backup_codes(db: AsyncSession, user_id: int, plaintext_codes: list[str]) -> None:
|
async def _store_backup_codes(db: AsyncSession, user_id: int, plaintext_codes: list[str]) -> None:
|
||||||
"""Hash and insert backup codes for the given user."""
|
"""Hash and insert backup codes for the given user."""
|
||||||
# AC-2: Run Argon2id hashing in executor to avoid blocking event loop
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
for code in plaintext_codes:
|
for code in plaintext_codes:
|
||||||
code_hash = await loop.run_in_executor(None, _ph.hash, code)
|
code_hash = _ph.hash(code)
|
||||||
db.add(BackupCode(user_id=user_id, code_hash=code_hash))
|
db.add(BackupCode(user_id=user_id, code_hash=code_hash))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@ -147,12 +145,9 @@ async def _verify_backup_code(
|
|||||||
)
|
)
|
||||||
unused_codes = result.scalars().all()
|
unused_codes = result.scalars().all()
|
||||||
|
|
||||||
# AC-2: Run Argon2id verification in executor to avoid blocking event loop
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
for record in unused_codes:
|
for record in unused_codes:
|
||||||
try:
|
try:
|
||||||
matched = await loop.run_in_executor(None, _ph.verify, record.code_hash, submitted_code)
|
if _ph.verify(record.code_hash, submitted_code):
|
||||||
if matched:
|
|
||||||
record.used_at = datetime.now()
|
record.used_at = datetime.now()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return True
|
return True
|
||||||
@ -360,8 +355,7 @@ async def totp_disable(
|
|||||||
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||||
|
|
||||||
# Verify password (handles bcrypt→Argon2id upgrade transparently)
|
# Verify password (handles bcrypt→Argon2id upgrade transparently)
|
||||||
# AC-2: async wrapper to avoid blocking event loop
|
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||||
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
|
||||||
if not valid:
|
if not valid:
|
||||||
raise HTTPException(status_code=401, detail="Invalid password")
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
|
|
||||||
@ -397,8 +391,7 @@ async def regenerate_backup_codes(
|
|||||||
if not current_user.totp_enabled:
|
if not current_user.totp_enabled:
|
||||||
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||||
|
|
||||||
# AC-2: async wrapper to avoid blocking event loop
|
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
|
||||||
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
|
||||||
if not valid:
|
if not valid:
|
||||||
raise HTTPException(status_code=401, detail="Invalid password")
|
raise HTTPException(status_code=401, detail="Invalid password")
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,6 @@ Password strategy:
|
|||||||
- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
|
- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
|
||||||
rehashed to Argon2id on first successful use.
|
rehashed to Argon2id on first successful use.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||||
@ -78,28 +76,6 @@ def verify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str
|
|||||||
return valid, new_hash
|
return valid, new_hash
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Async wrappers — run CPU-bound Argon2id ops in a thread pool (AC-2/S-01)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def ahash_password(password: str) -> str:
|
|
||||||
"""Async wrapper for hash_password — runs Argon2id in executor."""
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
return await loop.run_in_executor(None, hash_password, password)
|
|
||||||
|
|
||||||
|
|
||||||
async def averify_password(password: str, hashed: str) -> bool:
|
|
||||||
"""Async wrapper for verify_password — runs Argon2id in executor."""
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
return await loop.run_in_executor(None, verify_password, password, hashed)
|
|
||||||
|
|
||||||
|
|
||||||
async def averify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str | None]:
|
|
||||||
"""Async wrapper for verify_password_with_upgrade — runs Argon2id in executor."""
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
return await loop.run_in_executor(None, verify_password_with_upgrade, password, hashed)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Session tokens
|
# Session tokens
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import logging
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import delete, select, text, update
|
from sqlalchemy import delete, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.calendar import Calendar
|
from app.models.calendar import Calendar
|
||||||
@ -24,29 +24,25 @@ async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int)
|
|||||||
"""
|
"""
|
||||||
Returns "owner" if the user owns the calendar, the permission string
|
Returns "owner" if the user owns the calendar, the permission string
|
||||||
if they are an accepted member, or None if they have no access.
|
if they are an accepted member, or None if they have no access.
|
||||||
|
|
||||||
AW-5: Single query with LEFT JOIN instead of 2 sequential queries.
|
|
||||||
"""
|
"""
|
||||||
result = await db.execute(
|
cal = await db.execute(
|
||||||
select(
|
select(Calendar).where(Calendar.id == calendar_id)
|
||||||
Calendar.user_id,
|
|
||||||
CalendarMember.permission,
|
|
||||||
)
|
)
|
||||||
.outerjoin(
|
calendar = cal.scalar_one_or_none()
|
||||||
CalendarMember,
|
if not calendar:
|
||||||
(CalendarMember.calendar_id == Calendar.id)
|
|
||||||
& (CalendarMember.user_id == user_id)
|
|
||||||
& (CalendarMember.status == "accepted"),
|
|
||||||
)
|
|
||||||
.where(Calendar.id == calendar_id)
|
|
||||||
)
|
|
||||||
row = result.one_or_none()
|
|
||||||
if not row:
|
|
||||||
return None
|
return None
|
||||||
owner_id, member_permission = row.tuple()
|
if calendar.user_id == user_id:
|
||||||
if owner_id == user_id:
|
|
||||||
return "owner"
|
return "owner"
|
||||||
return member_permission
|
|
||||||
|
member = await db.execute(
|
||||||
|
select(CalendarMember).where(
|
||||||
|
CalendarMember.calendar_id == calendar_id,
|
||||||
|
CalendarMember.user_id == user_id,
|
||||||
|
CalendarMember.status == "accepted",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = member.scalar_one_or_none()
|
||||||
|
return row.permission if row else None
|
||||||
|
|
||||||
|
|
||||||
async def require_permission(
|
async def require_permission(
|
||||||
@ -206,22 +202,16 @@ async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int
|
|||||||
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
||||||
)
|
)
|
||||||
|
|
||||||
# AC-5: Single aggregation query instead of N per-calendar checks
|
|
||||||
all_cal_ids = a_cal_ids + b_cal_ids
|
|
||||||
if all_cal_ids:
|
|
||||||
# Find which calendars still have members
|
|
||||||
has_members_result = await db.execute(
|
|
||||||
select(CalendarMember.calendar_id)
|
|
||||||
.where(CalendarMember.calendar_id.in_(all_cal_ids))
|
|
||||||
.group_by(CalendarMember.calendar_id)
|
|
||||||
)
|
|
||||||
cals_with_members = {row[0] for row in has_members_result.all()}
|
|
||||||
|
|
||||||
# Reset is_shared on calendars with no remaining members
|
# Reset is_shared on calendars with no remaining members
|
||||||
empty_cal_ids = [cid for cid in all_cal_ids if cid not in cals_with_members]
|
all_cal_ids = a_cal_ids + b_cal_ids
|
||||||
if empty_cal_ids:
|
for cal_id in all_cal_ids:
|
||||||
await db.execute(
|
remaining = await db.execute(
|
||||||
update(Calendar)
|
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
|
||||||
.where(Calendar.id.in_(empty_cal_ids))
|
|
||||||
.values(is_shared=False)
|
|
||||||
)
|
)
|
||||||
|
if not remaining.scalar_one_or_none():
|
||||||
|
cal_result = await db.execute(
|
||||||
|
select(Calendar).where(Calendar.id == cal_id)
|
||||||
|
)
|
||||||
|
cal = cal_result.scalar_one_or_none()
|
||||||
|
if cal:
|
||||||
|
cal.is_shared = False
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "Running database migrations..."
|
|
||||||
alembic upgrade head
|
|
||||||
|
|
||||||
echo "Starting uvicorn..."
|
|
||||||
exec uvicorn app.main:app \
|
|
||||||
--host 0.0.0.0 \
|
|
||||||
--port 8000 \
|
|
||||||
--no-server-header \
|
|
||||||
--proxy-headers \
|
|
||||||
--forwarded-allow-ips '*'
|
|
||||||
9
backend/start.sh
Normal file
9
backend/start.sh
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Run database migrations
|
||||||
|
echo "Running database migrations..."
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Start the FastAPI application
|
||||||
|
echo "Starting FastAPI application..."
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
@ -5,18 +5,11 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
|
||||||
- backend_net
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 512M
|
|
||||||
cpus: "1.0"
|
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
@ -25,20 +18,11 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
|
||||||
- backend_net
|
|
||||||
- frontend_net
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 512M
|
|
||||||
cpus: "1.0"
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
@ -48,24 +32,6 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
|
||||||
- frontend_net
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 128M
|
|
||||||
cpus: "0.5"
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|
||||||
networks:
|
|
||||||
backend_net:
|
|
||||||
driver: bridge
|
|
||||||
frontend_net:
|
|
||||||
driver: bridge
|
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
# Dependencies — rebuilt inside the container from lockfile
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
# Build output — rebuilt inside the container
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Version control
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
|
|
||||||
# Environment files
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# Documentation
|
|
||||||
*.md
|
|
||||||
LICENSE
|
|
||||||
|
|
||||||
# Docker files
|
|
||||||
Dockerfile
|
|
||||||
docker-compose*.yaml
|
|
||||||
@ -1,13 +1,13 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM node:20.18-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies from lockfile (DW-3)
|
# Install dependencies
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . .
|
COPY . .
|
||||||
@ -16,7 +16,7 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage — unprivileged nginx (runs as non-root, listens on 8080)
|
# Production stage — unprivileged nginx (runs as non-root, listens on 8080)
|
||||||
FROM nginxinc/nginx-unprivileged:1.27-alpine
|
FROM nginxinc/nginx-unprivileged:alpine
|
||||||
|
|
||||||
# Copy built files from build stage
|
# Copy built files from build stage
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|||||||
@ -41,7 +41,7 @@ server {
|
|||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
gzip_min_length 1024;
|
gzip_min_length 1024;
|
||||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json image/svg+xml;
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||||
|
|
||||||
# Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04)
|
# Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04)
|
||||||
location ~ /\.(?!well-known) {
|
location ~ /\.(?!well-known) {
|
||||||
@ -122,12 +122,28 @@ server {
|
|||||||
include /etc/nginx/proxy-params.conf;
|
include /etc/nginx/proxy-params.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
# API proxy (catch-all for non-rate-limited endpoints)
|
# API proxy
|
||||||
location /api {
|
location /api {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
include /etc/nginx/proxy-params.conf;
|
|
||||||
|
# PT-L01: Prevent browser caching of authenticated API responses
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
# Security headers (must be repeated — nginx add_header in a location block
|
||||||
|
# overrides server-level add_header directives, so all headers must be explicit)
|
||||||
|
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' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# SPA fallback - serve index.html for all routes
|
# SPA fallback - serve index.html for all routes
|
||||||
|
|||||||
@ -4,13 +4,3 @@ proxy_set_header Host $host;
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
|
|
||||||
# Security headers (repeated per location — nginx add_header in a location block
|
|
||||||
# overrides server-level directives, so all headers must be explicit)
|
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
|
||||||
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' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
|
||||||
|
|||||||
@ -3,24 +3,19 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import LockScreen from '@/components/auth/LockScreen';
|
import LockScreen from '@/components/auth/LockScreen';
|
||||||
import AppLayout from '@/components/layout/AppLayout';
|
import AppLayout from '@/components/layout/AppLayout';
|
||||||
|
import DashboardPage from '@/components/dashboard/DashboardPage';
|
||||||
|
import TodosPage from '@/components/todos/TodosPage';
|
||||||
|
import CalendarPage from '@/components/calendar/CalendarPage';
|
||||||
|
import RemindersPage from '@/components/reminders/RemindersPage';
|
||||||
|
import ProjectsPage from '@/components/projects/ProjectsPage';
|
||||||
|
import ProjectDetail from '@/components/projects/ProjectDetail';
|
||||||
|
import PeoplePage from '@/components/people/PeoplePage';
|
||||||
|
import LocationsPage from '@/components/locations/LocationsPage';
|
||||||
|
import SettingsPage from '@/components/settings/SettingsPage';
|
||||||
|
import NotificationsPage from '@/components/notifications/NotificationsPage';
|
||||||
|
|
||||||
// AS-2: Lazy-load all route components to reduce initial bundle parse time
|
|
||||||
const DashboardPage = lazy(() => import('@/components/dashboard/DashboardPage'));
|
|
||||||
const TodosPage = lazy(() => import('@/components/todos/TodosPage'));
|
|
||||||
const CalendarPage = lazy(() => import('@/components/calendar/CalendarPage'));
|
|
||||||
const RemindersPage = lazy(() => import('@/components/reminders/RemindersPage'));
|
|
||||||
const ProjectsPage = lazy(() => import('@/components/projects/ProjectsPage'));
|
|
||||||
const ProjectDetail = lazy(() => import('@/components/projects/ProjectDetail'));
|
|
||||||
const PeoplePage = lazy(() => import('@/components/people/PeoplePage'));
|
|
||||||
const LocationsPage = lazy(() => import('@/components/locations/LocationsPage'));
|
|
||||||
const SettingsPage = lazy(() => import('@/components/settings/SettingsPage'));
|
|
||||||
const NotificationsPage = lazy(() => import('@/components/notifications/NotificationsPage'));
|
|
||||||
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
||||||
|
|
||||||
const RouteFallback = () => (
|
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { authStatus, isLoading } = useAuth();
|
const { authStatus, isLoading } = useAuth();
|
||||||
|
|
||||||
@ -62,21 +57,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<Suspense fallback={<RouteFallback />}><DashboardPage /></Suspense>} />
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
<Route path="todos" element={<Suspense fallback={<RouteFallback />}><TodosPage /></Suspense>} />
|
<Route path="todos" element={<TodosPage />} />
|
||||||
<Route path="calendar" element={<Suspense fallback={<RouteFallback />}><CalendarPage /></Suspense>} />
|
<Route path="calendar" element={<CalendarPage />} />
|
||||||
<Route path="reminders" element={<Suspense fallback={<RouteFallback />}><RemindersPage /></Suspense>} />
|
<Route path="reminders" element={<RemindersPage />} />
|
||||||
<Route path="projects" element={<Suspense fallback={<RouteFallback />}><ProjectsPage /></Suspense>} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="projects/:id" element={<Suspense fallback={<RouteFallback />}><ProjectDetail /></Suspense>} />
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
<Route path="people" element={<Suspense fallback={<RouteFallback />}><PeoplePage /></Suspense>} />
|
<Route path="people" element={<PeoplePage />} />
|
||||||
<Route path="locations" element={<Suspense fallback={<RouteFallback />}><LocationsPage /></Suspense>} />
|
<Route path="locations" element={<LocationsPage />} />
|
||||||
<Route path="notifications" element={<Suspense fallback={<RouteFallback />}><NotificationsPage /></Suspense>} />
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
<Route path="settings" element={<Suspense fallback={<RouteFallback />}><SettingsPage /></Suspense>} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="admin/*"
|
path="admin/*"
|
||||||
element={
|
element={
|
||||||
<AdminRoute>
|
<AdminRoute>
|
||||||
<Suspense fallback={<RouteFallback />}>
|
<Suspense fallback={<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>}>
|
||||||
<AdminPortal />
|
<AdminPortal />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import FullCalendar from '@fullcalendar/react';
|
import FullCalendar from '@fullcalendar/react';
|
||||||
@ -206,28 +205,13 @@ export default function CalendarPage() {
|
|||||||
return () => el.removeEventListener('wheel', handleWheel);
|
return () => el.removeEventListener('wheel', handleWheel);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// AW-2: Track visible date range for scoped event fetching
|
|
||||||
// W-02 fix: Initialize from current month to avoid unscoped first fetch
|
|
||||||
const [visibleRange, setVisibleRange] = useState<{ start: string; end: string }>(() => {
|
|
||||||
const now = new Date();
|
|
||||||
const y = now.getFullYear();
|
|
||||||
const m = now.getMonth();
|
|
||||||
// FullCalendar month view typically fetches prev month to next month
|
|
||||||
const start = format(new Date(y, m - 1, 1), 'yyyy-MM-dd');
|
|
||||||
const end = format(new Date(y, m + 2, 0), 'yyyy-MM-dd');
|
|
||||||
return { start, end };
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: events = [] } = useQuery({
|
const { data: events = [] } = useQuery({
|
||||||
queryKey: ['calendar-events', visibleRange.start, visibleRange.end],
|
queryKey: ['calendar-events'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get<CalendarEvent[]>('/events', {
|
const { data } = await api.get<CalendarEvent[]>('/events');
|
||||||
params: { start: visibleRange.start, end: visibleRange.end },
|
|
||||||
});
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
// AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min
|
refetchInterval: 5_000,
|
||||||
refetchInterval: 30_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedEvent = useMemo(
|
const selectedEvent = useMemo(
|
||||||
@ -277,10 +261,7 @@ export default function CalendarPage() {
|
|||||||
allDay: boolean,
|
allDay: boolean,
|
||||||
revert: () => void,
|
revert: () => void,
|
||||||
) => {
|
) => {
|
||||||
// C-01 fix: match active query key which includes date range
|
queryClient.setQueryData<CalendarEvent[]>(['calendar-events'], (old) =>
|
||||||
queryClient.setQueryData<CalendarEvent[]>(
|
|
||||||
['calendar-events', visibleRange.start, visibleRange.end],
|
|
||||||
(old) =>
|
|
||||||
old?.map((e) =>
|
old?.map((e) =>
|
||||||
e.id === id
|
e.id === id
|
||||||
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay }
|
? { ...e, start_datetime: start, end_datetime: end, all_day: allDay }
|
||||||
@ -486,13 +467,6 @@ export default function CalendarPage() {
|
|||||||
const handleDatesSet = (arg: DatesSetArg) => {
|
const handleDatesSet = (arg: DatesSetArg) => {
|
||||||
setCalendarTitle(arg.view.title);
|
setCalendarTitle(arg.view.title);
|
||||||
setCurrentView(arg.view.type as CalendarView);
|
setCurrentView(arg.view.type as CalendarView);
|
||||||
// AW-2: Capture visible range for scoped event fetching
|
|
||||||
// C-02 fix: use format() not toISOString() to avoid UTC date shift
|
|
||||||
const start = format(arg.start, 'yyyy-MM-dd');
|
|
||||||
const end = format(arg.end, 'yyyy-MM-dd');
|
|
||||||
setVisibleRange((prev) =>
|
|
||||||
prev.start === start && prev.end === end ? prev : { start, end }
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
const navigatePrev = () => calendarRef.current?.getApi().prev();
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@ -34,75 +34,6 @@ function getGreeting(name?: string): string {
|
|||||||
return `Good night${suffix}`;
|
return `Good night${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AS-3: Isolated clock component — only this re-renders every minute,
|
|
||||||
// not all 8 dashboard widgets.
|
|
||||||
const ClockDisplay = memo(function ClockDisplay({ dataUpdatedAt, onRefresh }: {
|
|
||||||
dataUpdatedAt?: number;
|
|
||||||
onRefresh: () => void;
|
|
||||||
}) {
|
|
||||||
const [now, setNow] = useState(() => new Date());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let intervalId: ReturnType<typeof setInterval>;
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
function startClock() {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
clearInterval(intervalId);
|
|
||||||
setNow(new Date());
|
|
||||||
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
setNow(new Date());
|
|
||||||
intervalId = setInterval(() => setNow(new Date()), 60_000);
|
|
||||||
}, msUntilNextMinute);
|
|
||||||
}
|
|
||||||
|
|
||||||
startClock();
|
|
||||||
function handleVisibility() {
|
|
||||||
if (document.visibilityState === 'visible') startClock();
|
|
||||||
}
|
|
||||||
document.addEventListener('visibilitychange', handleVisibility);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
clearInterval(intervalId);
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibility);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updatedAgo = dataUpdatedAt
|
|
||||||
? (() => {
|
|
||||||
const mins = Math.floor((now.getTime() - dataUpdatedAt) / 60_000);
|
|
||||||
if (mins < 1) return 'just now';
|
|
||||||
if (mins === 1) return '1 min ago';
|
|
||||||
return `${mins} min ago`;
|
|
||||||
})()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<span className="tabular-nums">{format(now, 'h:mm a')}</span>
|
|
||||||
<span className="mx-1.5 text-muted-foreground/30">|</span>
|
|
||||||
{format(now, 'EEEE, MMMM d, yyyy')}
|
|
||||||
</p>
|
|
||||||
{updatedAgo && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground/40 text-xs">·</span>
|
|
||||||
<span className="text-muted-foreground/60 text-xs">Updated {updatedAgo}</span>
|
|
||||||
<button
|
|
||||||
onClick={onRefresh}
|
|
||||||
className="p-0.5 rounded text-muted-foreground/40 hover:text-accent transition-colors"
|
|
||||||
title="Refresh dashboard"
|
|
||||||
aria-label="Refresh dashboard"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -111,7 +42,38 @@ export default function DashboardPage() {
|
|||||||
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
// Clock state moved to <ClockDisplay /> (AS-3)
|
const [clockNow, setClockNow] = useState(() => new Date());
|
||||||
|
|
||||||
|
// Live clock — synced to the minute boundary, re-syncs after tab sleep/resume
|
||||||
|
useEffect(() => {
|
||||||
|
let intervalId: ReturnType<typeof setInterval>;
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
function startClock() {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
setClockNow(new Date());
|
||||||
|
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
setClockNow(new Date());
|
||||||
|
intervalId = setInterval(() => setClockNow(new Date()), 60_000);
|
||||||
|
}, msUntilNextMinute);
|
||||||
|
}
|
||||||
|
|
||||||
|
startClock();
|
||||||
|
|
||||||
|
// Re-sync when tab becomes visible again (after sleep/background throttle)
|
||||||
|
function handleVisibility() {
|
||||||
|
if (document.visibilityState === 'visible') startClock();
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Click outside to close dropdown
|
// Click outside to close dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -229,6 +191,15 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatedAgo = dataUpdatedAt
|
||||||
|
? (() => {
|
||||||
|
const mins = Math.floor((clockNow.getTime() - dataUpdatedAt) / 60_000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins === 1) return '1 min ago';
|
||||||
|
return `${mins} min ago`;
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header — greeting + date + quick add */}
|
{/* Header — greeting + date + quick add */}
|
||||||
@ -237,7 +208,27 @@ export default function DashboardPage() {
|
|||||||
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
||||||
{getGreeting(settings?.preferred_name || undefined)}
|
{getGreeting(settings?.preferred_name || undefined)}
|
||||||
</h1>
|
</h1>
|
||||||
<ClockDisplay dataUpdatedAt={dataUpdatedAt} onRefresh={handleRefresh} />
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
<span className="tabular-nums">{format(clockNow, 'h:mm a')}</span>
|
||||||
|
<span className="mx-1.5 text-muted-foreground/30">|</span>
|
||||||
|
{format(clockNow, 'EEEE, MMMM d, yyyy')}
|
||||||
|
</p>
|
||||||
|
{updatedAgo && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/40 text-xs">·</span>
|
||||||
|
<span className="text-muted-foreground/60 text-xs">Updated {updatedAgo}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="p-0.5 rounded text-muted-foreground/40 hover:text-accent transition-colors"
|
||||||
|
title="Refresh dashboard"
|
||||||
|
aria-label="Refresh dashboard"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Calendar, SharedCalendarMembership } from '@/types';
|
import type { Calendar, SharedCalendarMembership } from '@/types';
|
||||||
@ -16,26 +16,16 @@ export function useCalendars({ pollingEnabled = false }: UseCalendarsOptions = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// AS-4: Gate shared-calendar polling on whether user participates in sharing.
|
|
||||||
// Saves ~12 API calls/min for personal-only users.
|
|
||||||
// Use a ref to latch "has sharing" once discovered, so polling doesn't flicker.
|
|
||||||
const hasSharingRef = useRef(false);
|
|
||||||
const ownsShared = (ownedQuery.data ?? []).some((c) => c.is_shared);
|
|
||||||
if (ownsShared) hasSharingRef.current = true;
|
|
||||||
|
|
||||||
const sharedQuery = useQuery({
|
const sharedQuery = useQuery({
|
||||||
queryKey: ['calendars', 'shared'],
|
queryKey: ['calendars', 'shared'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
refetchInterval: pollingEnabled && hasSharingRef.current ? 5_000 : false,
|
refetchInterval: pollingEnabled ? 5_000 : false,
|
||||||
staleTime: 3_000,
|
staleTime: 3_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also latch if user is a member of others' shared calendars
|
|
||||||
if ((sharedQuery.data ?? []).length > 0) hasSharingRef.current = true;
|
|
||||||
|
|
||||||
const allCalendarIds = useMemo(() => {
|
const allCalendarIds = useMemo(() => {
|
||||||
const owned = (ownedQuery.data ?? []).map((c) => c.id);
|
const owned = (ownedQuery.data ?? []).map((c) => c.id);
|
||||||
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);
|
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);
|
||||||
|
|||||||
@ -9,25 +9,6 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
// AS-1: Split large dependencies into separate chunks for better caching
|
|
||||||
manualChunks: {
|
|
||||||
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
|
||||||
'vendor-query': ['@tanstack/react-query'],
|
|
||||||
'vendor-fullcalendar': [
|
|
||||||
'@fullcalendar/react',
|
|
||||||
'@fullcalendar/core',
|
|
||||||
'@fullcalendar/daygrid',
|
|
||||||
'@fullcalendar/timegrid',
|
|
||||||
'@fullcalendar/interaction',
|
|
||||||
],
|
|
||||||
'vendor-ui': ['sonner', 'lucide-react', 'date-fns'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user