Compare commits
No commits in common. "main" and "feature/user-connections" have entirely different histories.
main
...
feature/us
@ -21,15 +21,6 @@ ENVIRONMENT=development
|
||||
# Timezone (applied to backend + db containers via env_file)
|
||||
TZ=Australia/Perth
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# WebAuthn / Passkeys
|
||||
# ──────────────────────────────────────
|
||||
# REQUIRED for passkeys to work. Must match the domain users access UMBRA on.
|
||||
# RP_ID = eTLD+1 (no scheme, no port). ORIGIN = full origin with scheme.
|
||||
WEBAUTHN_RP_ID=umbra.example.com
|
||||
WEBAUTHN_RP_NAME=UMBRA
|
||||
WEBAUTHN_ORIGIN=https://umbra.example.com
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Integrations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
name: Build and Deploy UMBRA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY_HOST }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||
|
||||
- name: Build and push backend
|
||||
run: |
|
||||
docker build --pull \
|
||||
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest \
|
||||
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }} \
|
||||
./backend
|
||||
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest
|
||||
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }}
|
||||
|
||||
- name: Build and push frontend
|
||||
run: |
|
||||
docker build --pull \
|
||||
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest \
|
||||
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }} \
|
||||
./frontend
|
||||
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest
|
||||
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }}
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
# Spawn a short-lived container that mounts the host deploy path
|
||||
# and runs compose commands against the host Docker daemon.
|
||||
# DEPLOY_PATH is a Gitea variable — update it when moving hosts.
|
||||
docker run --rm \
|
||||
--network host \
|
||||
--security-opt label:disable \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ${{ vars.DEPLOY_PATH }}:/deploy \
|
||||
-w /deploy \
|
||||
docker:27-cli sh -c "
|
||||
docker compose -p umbra --env-file stack.env pull backend frontend &&
|
||||
docker compose -p umbra --env-file stack.env up -d db backend frontend
|
||||
"
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
echo "Waiting for services to start..."
|
||||
sleep 30
|
||||
curl -f http://localhost:${{ vars.DEPLOY_PORT }}/health || exit 1
|
||||
|
||||
- name: Prune old images
|
||||
if: success()
|
||||
run: docker image prune -f
|
||||
|
||||
- name: Notify success
|
||||
if: success()
|
||||
run: |
|
||||
curl -s \
|
||||
-H "Title: UMBRA Deploy Success" \
|
||||
-H "Tags: white_check_mark" \
|
||||
--data-binary @- https://ntfy.ghost6.xyz/claude <<'NTFY_EOF'
|
||||
Build ${{ github.sha }} deployed successfully to umbra.ghost6.xyz.
|
||||
Triggered by push to main.
|
||||
NTFY_EOF
|
||||
|
||||
- name: Notify failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -s \
|
||||
-H "Title: UMBRA Deploy FAILED" \
|
||||
-H "Tags: fire" \
|
||||
-H "Priority: high" \
|
||||
--data-binary @- https://ntfy.ghost6.xyz/claude <<'NTFY_EOF'
|
||||
Deploy failed for commit ${{ github.sha }}.
|
||||
Check Gitea Actions logs at git.sentinelforest.xyz.
|
||||
NTFY_EOF
|
||||
86
README.md
86
README.md
@ -4,20 +4,18 @@ A self-hosted, multi-user life administration app with a dark-themed UI and role
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-user RBAC** — Admin and standard user roles, per-user data isolation, admin portal with IAM, system config, and audit logs
|
||||
- **Dashboard** — Contextual greeting, week timeline, stat cards, upcoming events, weather widget, day briefing
|
||||
- **Todos** — Task management with priorities, due dates, recurrence, and grouped sections (overdue/today/upcoming)
|
||||
- **Calendar** — Multi-calendar system with month/week/day views, recurring events, drag-and-drop, event templates, shared calendars with permission-based access
|
||||
- **Shared Calendars** — Invite connections to calendars with granular permissions (read-only, create/modify, full access). Event locking prevents concurrent edits. Near-real-time sync via 5s polling
|
||||
- **Event Invitations** — Invite connections to individual events with RSVP (accept/tentative/decline), per-occurrence status overrides for recurring events, display calendar assignment, and optional edit access via can_modify toggle
|
||||
- **Projects** — Project boards with kanban view, nested tasks/subtasks, comments, progress tracking
|
||||
- **Reminders** — Time-based reminders with snooze, dismiss, recurrence, and real-time alert notifications (dashboard banner + toasts)
|
||||
- **People & Connections** — Contact directory with avatar initials, favourites, birthday tracking. Social connections via umbral name search with bidirectional Person records on accept
|
||||
- **Locations** — Location management with OSM search integration, category filtering, frequent locations
|
||||
- **Weather** — Dashboard weather widget with temperature, conditions, and contextual rain warnings
|
||||
- **Settings** — Accent color picker (8 presets), first day of week, weather city, ntfy push notifications, TOTP two-factor auth, auto-lock, password management
|
||||
- **Notifications** — In-app notification centre with toast popups, plus ntfy push notifications for reminders (configurable per-user)
|
||||
- **Admin Portal** — User management (create, delete, activate/deactivate, role assignment, password reset), system configuration (open registration, MFA enforcement), audit log viewer
|
||||
- **Multi-user RBAC** - Admin and standard user roles, per-user data isolation, admin portal with IAM, system config, and audit logs
|
||||
- **Dashboard** - Contextual greeting, week timeline, stat cards, upcoming events, weather widget, day briefing
|
||||
- **Todos** - Task management with priorities, due dates, recurrence, and grouped sections (overdue/today/upcoming)
|
||||
- **Calendar** - Multi-calendar system with month/week/day views, recurring events, drag-and-drop, event templates
|
||||
- **Projects** - Project boards with kanban view, nested tasks/subtasks, comments, progress tracking
|
||||
- **Reminders** - Time-based reminders with snooze, dismiss, recurrence, and real-time alert notifications (dashboard banner + toasts)
|
||||
- **People** - Contact directory with avatar initials, favourites, birthday tracking, category filtering
|
||||
- **Locations** - Location management with OSM search integration, category filtering, frequent locations
|
||||
- **Weather** - Dashboard weather widget with temperature, conditions, and contextual rain warnings
|
||||
- **Settings** - Accent color picker (8 presets), first day of week, weather city, ntfy push notifications, TOTP two-factor auth, auto-lock, password management
|
||||
- **Notifications** - ntfy push notifications for reminders (configurable per-user)
|
||||
- **Admin Portal** - User management (create, delete, activate/deactivate, role assignment, password reset), system configuration (open registration, MFA enforcement), audit log viewer
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@ -28,7 +26,7 @@ A self-hosted, multi-user life administration app with a dark-themed UI and role
|
||||
| Fonts | Sora (headings), DM Sans (body) via Google Fonts |
|
||||
| State | TanStack Query v5, React Router v6 |
|
||||
| Backend | FastAPI, Python 3.12, Pydantic v2 |
|
||||
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic (56 migrations) |
|
||||
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic (37 migrations) |
|
||||
| Auth | Argon2id hashing, DB-backed sessions (signed httpOnly cookies), TOTP MFA, CSRF middleware, role-based access control |
|
||||
| Scheduler | APScheduler (async) for ntfy notification dispatch |
|
||||
| Deployment | Docker Compose (3 services), Nginx reverse proxy |
|
||||
@ -110,7 +108,27 @@ A self-hosted, multi-user life administration app with a dark-themed UI and role
|
||||
|
||||
## Security
|
||||
|
||||
UMBRA is hardened by default with multi-user data isolation, role-based access control, CSRF protection, non-root containers, rate limiting, account lockout, optional TOTP MFA, and secure session management. Multiple penetration tests have been conducted with no exploitable findings.
|
||||
### Hardened by default
|
||||
|
||||
- **Multi-user data isolation** — all resources scoped by `user_id` with per-query filtering; pentest-verified (51+ test cases, 0 exploitable IDOR findings)
|
||||
- **Role-based access control** — `admin` and `standard` roles with `require_admin` dependency on all admin endpoints
|
||||
- **CSRF protection** — global `CSRFHeaderMiddleware` requires `X-Requested-With: XMLHttpRequest` on all mutating requests
|
||||
- **Input validation** — `extra="forbid"` on all Pydantic schemas prevents mass-assignment; `max_length` on all string fields; `ge=1, le=2147483647` on path IDs
|
||||
- **Non-root containers** — both backend (`appuser:1000`) and frontend (`nginx-unprivileged`) run as non-root
|
||||
- **No external backend port** — port 8000 is internal-only; all traffic flows through nginx
|
||||
- **Server version suppression** — `server_tokens off` (nginx) and `--no-server-header` (uvicorn)
|
||||
- **Rate limiting** — nginx `limit_req_zone` (10 req/min) on `/api/auth/login` (burst=5), `/verify-password` (burst=5), `/change-password` (burst=5), `/totp-verify` (burst=5), `/setup` (burst=3)
|
||||
- **DB-backed account lockout** — 10 failed attempts triggers 30-minute lock per account
|
||||
- **Inactive user blocking** — disabled accounts rejected at login (HTTP 403) without session creation, lockout reset, or last_login_at update
|
||||
- **Timing-safe login** — dummy Argon2id hash for non-existent users prevents username enumeration
|
||||
- **Password reuse prevention** — change-password endpoint rejects same password as old
|
||||
- **Dotfile blocking** — `/.env`, `/.git/config`, etc. return 404 (`.well-known` preserved for ACME)
|
||||
- **CSP headers** — Content-Security-Policy on all responses, scoped for Google Fonts
|
||||
- **CORS** — configurable origins with explicit method/header allowlists
|
||||
- **API docs disabled in production** — Swagger/ReDoc/OpenAPI only available when `ENVIRONMENT=development`
|
||||
- **Argon2id password hashing** with transparent bcrypt migration on first login
|
||||
- **DB-backed sessions** — revocable, with signed itsdangerous httpOnly cookies, 7-day sliding window with 30-day hard ceiling
|
||||
- **Optional TOTP MFA** — authenticator app support with backup codes, admin-enforced MFA for new users
|
||||
|
||||
### Production Hardening
|
||||
|
||||
@ -119,15 +137,21 @@ Before deploying to production, generate secure values for your `.env`:
|
||||
```bash
|
||||
# Generate a secure SECRET_KEY (64-char hex string)
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
# or: openssl rand -hex 32
|
||||
|
||||
# Generate a secure database password
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(24))"
|
||||
# or: openssl rand -base64 24
|
||||
|
||||
# Set ENVIRONMENT to disable Swagger/ReDoc and auto-enable secure cookies
|
||||
ENVIRONMENT=production
|
||||
```
|
||||
|
||||
Additionally for production:
|
||||
- Set `ENVIRONMENT=production` — disables API docs and auto-enables HTTPS-only session cookies
|
||||
- Place behind a reverse proxy with TLS termination (e.g., Caddy, Traefik, or nginx with Let's Encrypt)
|
||||
- Set `ENVIRONMENT=production` — this disables API docs and auto-enables HTTPS-only session cookies (`COOKIE_SECURE` derives from `ENVIRONMENT`; override with `COOKIE_SECURE=false` if running non-TLS prod behind a proxy)
|
||||
- Set `CORS_ORIGINS` to your actual domain (e.g., `https://umbra.example.com`)
|
||||
- Consider adding HSTS headers at the TLS-terminating proxy layer
|
||||
|
||||
## API Overview
|
||||
|
||||
@ -139,11 +163,9 @@ All endpoints require authentication (signed session cookie) except auth routes
|
||||
| `/api/auth/*` | Login, logout, setup, register, status, password change, TOTP MFA |
|
||||
| `/api/admin/*` | User management, system config, audit logs (admin only) |
|
||||
| `/api/todos/*` | Todos CRUD + toggle completion |
|
||||
| `/api/events/*` | Calendar events CRUD (incl. recurring) + event invitations |
|
||||
| `/api/event-invitations/*` | Invitation responses, per-occurrence overrides, can_modify toggle, leave |
|
||||
| `/api/events/*` | Calendar events CRUD (incl. recurring) |
|
||||
| `/api/event-templates/*` | Event templates CRUD |
|
||||
| `/api/calendars/*` | User calendars CRUD + visibility |
|
||||
| `/api/shared-calendars/*` | Shared calendar management, invitations, permissions, event locking |
|
||||
| `/api/reminders/*` | Reminders CRUD + dismiss + snooze + due alerts |
|
||||
| `/api/projects/*` | Projects + nested tasks + comments CRUD |
|
||||
| `/api/people/*` | People CRUD |
|
||||
@ -152,8 +174,6 @@ All endpoints require authentication (signed session cookie) except auth routes
|
||||
| `/api/dashboard` | Dashboard aggregation |
|
||||
| `/api/upcoming` | Unified upcoming items feed |
|
||||
| `/api/weather/*` | Weather data proxy |
|
||||
| `/api/connections/*` | Social connections (search, request, respond, manage) |
|
||||
| `/api/notifications/*` | In-app notifications (list, read, delete) |
|
||||
|
||||
API documentation is available at `/api/docs` (Swagger UI) when `ENVIRONMENT=development`.
|
||||
|
||||
@ -195,15 +215,15 @@ umbra/
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ ├── alembic.ini
|
||||
│ ├── alembic/versions/ # 56 migrations (001–056)
|
||||
│ ├── alembic/versions/ # 37 migrations (001–037)
|
||||
│ └── app/
|
||||
│ ├── main.py # FastAPI app, CSRF middleware, router registration, health endpoint
|
||||
│ ├── config.py # Pydantic BaseSettings (DATABASE_URL, SECRET_KEY, CORS, etc.)
|
||||
│ ├── database.py # Async SQLAlchemy engine + session factory
|
||||
│ ├── models/ # 20 SQLAlchemy ORM models (incl. User, UserSession, EventInvitation, CalendarMember)
|
||||
│ ├── schemas/ # 14 Pydantic v2 request/response schema modules
|
||||
│ ├── routers/ # 16 API route handlers (incl. auth, admin, event_invitations, shared_calendars)
|
||||
│ ├── services/ # Auth (Argon2id), recurrence, TOTP, ntfy, audit, calendar_sharing, event_invitation, notification
|
||||
│ ├── models/ # 18 SQLAlchemy ORM models (incl. User, UserSession, SystemConfig, AuditLog)
|
||||
│ ├── schemas/ # 13 Pydantic v2 request/response schema modules (incl. admin)
|
||||
│ ├── routers/ # 14 API route handlers (incl. auth, admin, totp)
|
||||
│ ├── services/ # Auth (Argon2id), recurrence, TOTP, ntfy, audit
|
||||
│ └── jobs/ # APScheduler notification dispatch
|
||||
└── frontend/
|
||||
├── Dockerfile
|
||||
@ -213,22 +233,20 @@ umbra/
|
||||
└── src/
|
||||
├── App.tsx # Routes, ProtectedRoute, AdminRoute auth guards
|
||||
├── lib/ # api.ts (axios + 401 interceptor), date-utils.ts, utils.ts
|
||||
├── hooks/ # useAuth, useAdmin, useSettings, useTheme, useCalendars, useConfirmAction, useConnections, useNotifications, useEventInvitations
|
||||
├── hooks/ # useAuth, useAdmin, useSettings, useTheme, useCalendars, useConfirmAction, useCategoryOrder, useTableVisibility
|
||||
├── types/ # TypeScript interfaces
|
||||
└── components/
|
||||
├── ui/ # 18 base components (Button, Dialog, Sheet, Card, Input, Select, Switch, DatePicker, ...)
|
||||
├── ui/ # 17 base components (Button, Dialog, Sheet, Card, Input, Select, Switch, etc.)
|
||||
├── shared/ # EntityTable, EntityDetailPanel, CategoryFilterBar, CategoryAutocomplete, CopyableField
|
||||
├── layout/ # AppLayout, Sidebar, LockOverlay
|
||||
├── auth/ # LockScreen, AmbientBackground
|
||||
├── admin/ # AdminPortal, IAMPage, ConfigPage, AdminDashboardPage, CreateUserDialog, UserActionsMenu
|
||||
├── admin/ # AdminPortal, IAMPage, ConfigPage, AdminDashboardPage, CreateUserDialog, UserActionsMenu, UserDetailSection
|
||||
├── dashboard/ # DashboardPage + 8 widgets
|
||||
├── calendar/ # CalendarPage, CalendarSidebar, EventDetailPanel, InviteeSection, LeaveEventDialog, CalendarForm, EventForm, TemplateForm
|
||||
├── todos/ # TodosPage, TodoList, TodoItem, TodoForm, TodoDetailPanel
|
||||
├── calendar/ # CalendarPage, CalendarSidebar, CalendarForm, EventForm, TemplateForm
|
||||
├── todos/ # TodosPage, TodoList, TodoItem, TodoForm
|
||||
├── reminders/ # RemindersPage, ReminderList, ReminderItem, ReminderForm, SnoozeDropdown, AlertBanner
|
||||
├── projects/ # ProjectsPage, ProjectCard, ProjectDetail, ProjectForm, KanbanBoard, TaskRow, TaskForm, TaskDetailPanel
|
||||
├── people/ # PeoplePage, PersonForm
|
||||
├── connections/ # ConnectionSearch, ConnectionRequestCard, ConnectionsTab
|
||||
├── notifications/ # NotificationsPage, NotificationToaster
|
||||
├── locations/ # LocationsPage, LocationForm
|
||||
└── settings/ # SettingsPage, NtfySettingsSection, TotpSetupSection
|
||||
```
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
capacity: 1
|
||||
timeout: 3h
|
||||
insecure: false
|
||||
|
||||
cache:
|
||||
enabled: false
|
||||
|
||||
container:
|
||||
network: host
|
||||
privileged: false
|
||||
options: "--security-opt label:disable"
|
||||
valid_volumes:
|
||||
- "**"
|
||||
|
||||
host:
|
||||
workdir_parent: /tmp/act_runner
|
||||
@ -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,14 +1,2 @@
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
ENVIRONMENT=development
|
||||
|
||||
# Public-facing URL (used for ntfy click links, CORS derivation)
|
||||
UMBRA_URL=http://localhost
|
||||
|
||||
# WebAuthn / Passkey authentication
|
||||
# RP_ID must be the eTLD+1 domain of the live site (e.g. umbra.ghost6.xyz)
|
||||
# ORIGIN must include the scheme (https://)
|
||||
# These defaults work for local development; override in production .env
|
||||
WEBAUTHN_RP_ID=localhost
|
||||
WEBAUTHN_RP_NAME=UMBRA
|
||||
WEBAUTHN_ORIGIN=http://localhost
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# ── Build stage: compile C extensions ──────────────────────────────────
|
||||
FROM python:3.12.9-slim-bookworm AS builder
|
||||
FROM python:3.12-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@ -11,25 +11,24 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
# ── Runtime stage: lean production image ───────────────────────────────
|
||||
FROM python:3.12.9-slim-bookworm
|
||||
|
||||
# Create non-root user first, then copy with correct ownership (DW-2)
|
||||
RUN useradd -m -u 1000 appuser
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy pre-built Python packages from builder
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Copy application code with correct ownership — avoids redundant chown layer
|
||||
COPY --chown=appuser:appuser . .
|
||||
|
||||
# Make entrypoint executable
|
||||
RUN chmod +x entrypoint.sh
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Use entrypoint with exec so uvicorn runs as PID 1 and receives signals (DW-5)
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
# Run migrations and start server
|
||||
# --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,37 +1,39 @@
|
||||
# UMBRA Backend
|
||||
|
||||
FastAPI backend for the UMBRA life management application with async SQLAlchemy, PostgreSQL, multi-user RBAC, and comprehensive security.
|
||||
A complete FastAPI backend for the UMBRA application with async SQLAlchemy, PostgreSQL, authentication, and comprehensive CRUD operations.
|
||||
|
||||
## Features
|
||||
|
||||
- **FastAPI** with async/await and Pydantic v2
|
||||
- **SQLAlchemy 2.0** async engine with `Mapped[]` types
|
||||
- **PostgreSQL 16** via asyncpg
|
||||
- **Alembic** database migrations (001-061)
|
||||
- **Authentication**: Argon2id passwords + signed httpOnly cookies + optional TOTP MFA + passkey (WebAuthn/FIDO2)
|
||||
- **Multi-user RBAC**: admin/standard roles, per-user resource scoping
|
||||
- **Session management**: DB-backed sessions, sliding window expiry, concurrent session cap
|
||||
- **Account security**: Account lockout (10 failures = 30-min lock), CSRF protection, rate limiting
|
||||
- **APScheduler** for background notification dispatch
|
||||
- **FastAPI** with async/await support
|
||||
- **SQLAlchemy 2.0** with async engine
|
||||
- **PostgreSQL** with asyncpg driver
|
||||
- **Alembic** for database migrations
|
||||
- **bcrypt** for password hashing
|
||||
- **itsdangerous** for session management
|
||||
- **PIN-based authentication** with secure session cookies
|
||||
- **Full CRUD operations** for all entities
|
||||
- **Dashboard** with aggregated data
|
||||
- **CORS enabled** for frontend integration
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── alembic/versions/ # 61 database migrations
|
||||
├── alembic/ # Database migrations
|
||||
│ ├── versions/ # Migration files
|
||||
│ ├── env.py # Alembic environment
|
||||
│ └── script.py.mako # Migration template
|
||||
├── app/
|
||||
│ ├── models/ # 21 SQLAlchemy 2.0 models
|
||||
│ ├── schemas/ # 14 Pydantic v2 schema modules
|
||||
│ ├── routers/ # 17 API routers
|
||||
│ ├── services/ # Auth, session, passkey, TOTP, audit, recurrence, etc.
|
||||
│ ├── jobs/ # APScheduler notification dispatch
|
||||
│ ├── config.py # Pydantic Settings (env vars)
|
||||
│ ├── database.py # Async engine + session factory
|
||||
│ └── main.py # FastAPI app + CSRF middleware
|
||||
├── requirements.txt
|
||||
├── Dockerfile
|
||||
├── alembic.ini
|
||||
└── start.sh
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ ├── routers/ # API route handlers
|
||||
│ ├── config.py # Configuration
|
||||
│ ├── database.py # Database setup
|
||||
│ └── main.py # FastAPI application
|
||||
├── requirements.txt # Python dependencies
|
||||
├── Dockerfile # Docker configuration
|
||||
├── alembic.ini # Alembic configuration
|
||||
└── start.sh # Startup script
|
||||
```
|
||||
|
||||
## Setup
|
||||
@ -39,87 +41,160 @@ backend/
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
Create a `.env` file:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/umbra
|
||||
SECRET_KEY=generate-a-strong-random-key
|
||||
ENVIRONMENT=production
|
||||
|
||||
# WebAuthn / Passkeys (required for passkey auth)
|
||||
WEBAUTHN_RP_ID=your-domain.com
|
||||
WEBAUTHN_RP_NAME=UMBRA
|
||||
WEBAUTHN_ORIGIN=https://your-domain.com
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
```
|
||||
|
||||
### 3. Run Migrations
|
||||
### 3. Create Database
|
||||
|
||||
```bash
|
||||
createdb umbra
|
||||
```
|
||||
|
||||
### 4. Run Migrations
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### 4. Start Server
|
||||
### 5. Start Server
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
# Using the start script
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
|
||||
# Or directly with uvicorn
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
## API Routes
|
||||
The API will be available at `http://localhost:8000`
|
||||
|
||||
All routes require authentication (signed session cookie) except `/api/auth/*` and `/health`.
|
||||
## API Documentation
|
||||
|
||||
| Prefix | Description |
|
||||
|--------|-------------|
|
||||
| `/api/auth` | Login, logout, register, setup, status, password, TOTP, passkeys |
|
||||
| `/api/admin` | User management, system config, audit logs (admin only) |
|
||||
| `/api/todos` | Task management with categories and priorities |
|
||||
| `/api/events` | Calendar events with recurrence support |
|
||||
| `/api/event-invitations` | Event invitation RSVP and management |
|
||||
| `/api/event-templates` | Reusable event templates |
|
||||
| `/api/calendars` | Calendar CRUD |
|
||||
| `/api/shared-calendars` | Calendar sharing with permission levels |
|
||||
| `/api/reminders` | Reminder management with snooze |
|
||||
| `/api/projects` | Projects with tasks, comments, and collaboration |
|
||||
| `/api/people` | Contact management |
|
||||
| `/api/locations` | Location management |
|
||||
| `/api/connections` | User connections (friend requests) |
|
||||
| `/api/notifications` | In-app notification centre |
|
||||
| `/api/settings` | User preferences and ntfy configuration |
|
||||
| `/api/dashboard` | Aggregated dashboard data |
|
||||
| `/api/weather` | Weather widget data |
|
||||
Interactive API documentation is available at:
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
|
||||
## Authentication
|
||||
## API Endpoints
|
||||
|
||||
UMBRA supports three authentication methods:
|
||||
### Authentication
|
||||
- `POST /api/auth/setup` - Initial PIN setup
|
||||
- `POST /api/auth/login` - Login with PIN
|
||||
- `POST /api/auth/logout` - Logout
|
||||
- `GET /api/auth/status` - Check auth status
|
||||
|
||||
1. **Password** (Argon2id) - Primary login method
|
||||
2. **TOTP MFA** - Optional second factor via authenticator apps
|
||||
3. **Passkeys** (WebAuthn/FIDO2) - Optional passwordless login via biometrics, security keys, or password managers
|
||||
### Todos
|
||||
- `GET /api/todos` - List todos (with filters)
|
||||
- `POST /api/todos` - Create todo
|
||||
- `GET /api/todos/{id}` - Get todo
|
||||
- `PUT /api/todos/{id}` - Update todo
|
||||
- `DELETE /api/todos/{id}` - Delete todo
|
||||
- `PATCH /api/todos/{id}/toggle` - Toggle completion
|
||||
|
||||
Passkey login bypasses TOTP (a passkey is inherently two-factor: possession + biometric/PIN).
|
||||
### Calendar Events
|
||||
- `GET /api/events` - List events (with date range)
|
||||
- `POST /api/events` - Create event
|
||||
- `GET /api/events/{id}` - Get event
|
||||
- `PUT /api/events/{id}` - Update event
|
||||
- `DELETE /api/events/{id}` - Delete event
|
||||
|
||||
## Security
|
||||
### Reminders
|
||||
- `GET /api/reminders` - List reminders (with filters)
|
||||
- `POST /api/reminders` - Create reminder
|
||||
- `GET /api/reminders/{id}` - Get reminder
|
||||
- `PUT /api/reminders/{id}` - Update reminder
|
||||
- `DELETE /api/reminders/{id}` - Delete reminder
|
||||
- `PATCH /api/reminders/{id}/dismiss` - Dismiss reminder
|
||||
|
||||
- CSRF protection via `X-Requested-With` header middleware
|
||||
- All Pydantic schemas use `extra="forbid"` (mass-assignment prevention)
|
||||
- Nginx rate limiting on auth, registration, and admin endpoints
|
||||
- DB-backed account lockout after 10 failed attempts
|
||||
- Timing-safe dummy hash for non-existent users (prevents enumeration)
|
||||
- SSRF validation on ntfy webhook URLs
|
||||
- Naive datetimes throughout (Docker runs UTC)
|
||||
### Projects
|
||||
- `GET /api/projects` - List projects
|
||||
- `POST /api/projects` - Create project
|
||||
- `GET /api/projects/{id}` - Get project
|
||||
- `PUT /api/projects/{id}` - Update project
|
||||
- `DELETE /api/projects/{id}` - Delete project
|
||||
- `GET /api/projects/{id}/tasks` - List project tasks
|
||||
- `POST /api/projects/{id}/tasks` - Create project task
|
||||
- `PUT /api/projects/{id}/tasks/{task_id}` - Update task
|
||||
- `DELETE /api/projects/{id}/tasks/{task_id}` - Delete task
|
||||
|
||||
### People
|
||||
- `GET /api/people` - List people (with search)
|
||||
- `POST /api/people` - Create person
|
||||
- `GET /api/people/{id}` - Get person
|
||||
- `PUT /api/people/{id}` - Update person
|
||||
- `DELETE /api/people/{id}` - Delete person
|
||||
|
||||
### Locations
|
||||
- `GET /api/locations` - List locations (with category filter)
|
||||
- `POST /api/locations` - Create location
|
||||
- `GET /api/locations/{id}` - Get location
|
||||
- `PUT /api/locations/{id}` - Update location
|
||||
- `DELETE /api/locations/{id}` - Delete location
|
||||
|
||||
### Settings
|
||||
- `GET /api/settings` - Get settings
|
||||
- `PUT /api/settings` - Update settings
|
||||
- `PUT /api/settings/pin` - Change PIN
|
||||
|
||||
### Dashboard
|
||||
- `GET /api/dashboard` - Get dashboard data
|
||||
- `GET /api/upcoming?days=7` - Get upcoming items
|
||||
|
||||
## Database Schema
|
||||
|
||||
The application uses the following tables:
|
||||
- `settings` - Application settings and PIN
|
||||
- `todos` - Task items
|
||||
- `calendar_events` - Calendar events
|
||||
- `reminders` - Reminders
|
||||
- `projects` - Projects
|
||||
- `project_tasks` - Tasks within projects
|
||||
- `people` - Contacts/people
|
||||
- `locations` - Physical locations
|
||||
|
||||
## Docker
|
||||
|
||||
The backend runs as non-root `appuser` in `python:3.12-slim`:
|
||||
Build and run with Docker:
|
||||
|
||||
```bash
|
||||
docker build -t umbra-backend .
|
||||
docker run -p 8000:8000 --env-file .env umbra-backend
|
||||
docker run -p 8000:8000 -e DATABASE_URL=... -e SECRET_KEY=... umbra-backend
|
||||
```
|
||||
|
||||
In production, use Docker Compose (see root `docker-compose.yaml`).
|
||||
## Development
|
||||
|
||||
### Create New Migration
|
||||
|
||||
```bash
|
||||
alembic revision --autogenerate -m "Description of changes"
|
||||
```
|
||||
|
||||
### Apply Migrations
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### Rollback Migration
|
||||
|
||||
```bash
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Change `SECRET_KEY` in production
|
||||
- Use strong PINs (minimum 4 digits recommended)
|
||||
- Session cookies are httpOnly and last 30 days
|
||||
- All API endpoints (except auth) require authentication
|
||||
- PINs are hashed with bcrypt before storage
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
"""Add is_shared to calendars
|
||||
|
||||
Revision ID: 047
|
||||
Revises: 046
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "047"
|
||||
down_revision = "046"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"calendars",
|
||||
sa.Column("is_shared", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("calendars", "is_shared")
|
||||
@ -1,47 +0,0 @@
|
||||
"""Create calendar_members table
|
||||
|
||||
Revision ID: 048
|
||||
Revises: 047
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "048"
|
||||
down_revision = "047"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"calendar_members",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("calendar_id", sa.Integer(), sa.ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("invited_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("permission", sa.String(20), nullable=False),
|
||||
sa.Column("can_add_others", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("local_color", sa.String(20), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("invited_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
sa.UniqueConstraint("calendar_id", "user_id", name="uq_calendar_members_cal_user"),
|
||||
sa.CheckConstraint(
|
||||
"permission IN ('read_only', 'create_modify', 'full_access')",
|
||||
name="ck_calendar_members_permission",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'rejected')",
|
||||
name="ck_calendar_members_status",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_calendar_members_user_id", "calendar_members", ["user_id"])
|
||||
op.create_index("ix_calendar_members_calendar_id", "calendar_members", ["calendar_id"])
|
||||
op.create_index("ix_calendar_members_status", "calendar_members", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_calendar_members_status", table_name="calendar_members")
|
||||
op.drop_index("ix_calendar_members_calendar_id", table_name="calendar_members")
|
||||
op.drop_index("ix_calendar_members_user_id", table_name="calendar_members")
|
||||
op.drop_table("calendar_members")
|
||||
@ -1,30 +0,0 @@
|
||||
"""Create event_locks table
|
||||
|
||||
Revision ID: 049
|
||||
Revises: 048
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "049"
|
||||
down_revision = "048"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"event_locks",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("event_id", sa.Integer(), sa.ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False, unique=True),
|
||||
sa.Column("locked_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("locked_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("is_permanent", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
op.create_index("ix_event_locks_expires_at", "event_locks", ["expires_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_event_locks_expires_at", table_name="event_locks")
|
||||
op.drop_table("event_locks")
|
||||
@ -1,33 +0,0 @@
|
||||
"""Expand notification type CHECK for calendar invite types
|
||||
|
||||
Revision ID: 050
|
||||
Revises: 049
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "050"
|
||||
down_revision = "049"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_OLD_TYPES = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
_NEW_TYPES = _OLD_TYPES + (
|
||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||
)
|
||||
|
||||
|
||||
def _check_sql(types: tuple) -> str:
|
||||
return f"type IN ({', '.join(repr(t) for t in types)})"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_NEW_TYPES))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_OLD_TYPES))
|
||||
@ -1,34 +0,0 @@
|
||||
"""Add updated_by to calendar_events and ensure updated_at index
|
||||
|
||||
Revision ID: 051
|
||||
Revises: 050
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "051"
|
||||
down_revision = "050"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"calendar_events",
|
||||
sa.Column(
|
||||
"updated_by",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_calendar_events_updated_at",
|
||||
"calendar_events",
|
||||
["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_calendar_events_updated_at", table_name="calendar_events")
|
||||
op.drop_column("calendar_events", "updated_by")
|
||||
@ -1,18 +0,0 @@
|
||||
"""add is_locked and locked_at to user_sessions
|
||||
|
||||
Revision ID: 052
|
||||
Revises: 051
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "052"
|
||||
down_revision = "051"
|
||||
|
||||
def upgrade():
|
||||
op.add_column("user_sessions", sa.Column("is_locked", sa.Boolean(), server_default="false", nullable=False))
|
||||
op.add_column("user_sessions", sa.Column("locked_at", sa.DateTime(), nullable=True))
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("user_sessions", "locked_at")
|
||||
op.drop_column("user_sessions", "is_locked")
|
||||
@ -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")
|
||||
@ -1,116 +0,0 @@
|
||||
"""Event invitations tables and notification types.
|
||||
|
||||
Revision ID: 054
|
||||
Revises: 053
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "054"
|
||||
down_revision = "053"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ── event_invitations table ──
|
||||
op.create_table(
|
||||
"event_invitations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"event_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("calendar_events.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"invited_by",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("invited_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("responded_at", sa.DateTime(), nullable=True),
|
||||
sa.UniqueConstraint("event_id", "user_id", name="uq_event_invitations_event_user"),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'tentative', 'declined')",
|
||||
name="ck_event_invitations_status",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_event_invitations_user_status",
|
||||
"event_invitations",
|
||||
["user_id", "status"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_event_invitations_event_id",
|
||||
"event_invitations",
|
||||
["event_id"],
|
||||
)
|
||||
|
||||
# ── event_invitation_overrides table ──
|
||||
op.create_table(
|
||||
"event_invitation_overrides",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"invitation_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("event_invitations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"occurrence_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("calendar_events.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("status", sa.String(20), nullable=False),
|
||||
sa.Column("responded_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("invitation_id", "occurrence_id", name="uq_invitation_override"),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('accepted', 'tentative', 'declined')",
|
||||
name="ck_invitation_override_status",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_invitation_overrides_lookup",
|
||||
"event_invitation_overrides",
|
||||
["invitation_id", "occurrence_id"],
|
||||
)
|
||||
|
||||
# ── Expand notification type check constraint ──
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_notifications_type",
|
||||
"notifications",
|
||||
"type IN ('connection_request', 'connection_accepted', 'connection_rejected', "
|
||||
"'calendar_invite', 'calendar_invite_accepted', 'calendar_invite_rejected', "
|
||||
"'event_invite', 'event_invite_response', "
|
||||
"'info', 'warning', 'reminder', 'system')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_invitation_overrides_lookup", table_name="event_invitation_overrides")
|
||||
op.drop_table("event_invitation_overrides")
|
||||
op.drop_index("ix_event_invitations_event_id", table_name="event_invitations")
|
||||
op.drop_index("ix_event_invitations_user_status", table_name="event_invitations")
|
||||
op.drop_table("event_invitations")
|
||||
|
||||
# Restore original notification type constraint
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_notifications_type",
|
||||
"notifications",
|
||||
"type IN ('connection_request', 'connection_accepted', 'connection_rejected', "
|
||||
"'calendar_invite', 'calendar_invite_accepted', 'calendar_invite_rejected', "
|
||||
"'info', 'warning', 'reminder', 'system')",
|
||||
)
|
||||
@ -1,51 +0,0 @@
|
||||
"""Add display_calendar_id to event_invitations.
|
||||
|
||||
Allows invitees to assign invited events to their own calendars
|
||||
for personal organization, color, and visibility control.
|
||||
|
||||
Revision ID: 055
|
||||
Revises: 054
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "055"
|
||||
down_revision = "054"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"event_invitations",
|
||||
sa.Column(
|
||||
"display_calendar_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("calendars.id", ondelete="SET NULL", name="fk_event_invitations_display_calendar_id"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_event_invitations_display_calendar",
|
||||
"event_invitations",
|
||||
["display_calendar_id"],
|
||||
)
|
||||
|
||||
# Backfill accepted/tentative invitations with each user's default calendar
|
||||
op.execute("""
|
||||
UPDATE event_invitations
|
||||
SET display_calendar_id = (
|
||||
SELECT c.id FROM calendars c
|
||||
WHERE c.user_id = event_invitations.user_id
|
||||
AND c.is_default = true
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE status IN ('accepted', 'tentative')
|
||||
AND display_calendar_id IS NULL
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_event_invitations_display_calendar", table_name="event_invitations")
|
||||
op.drop_column("event_invitations", "display_calendar_id")
|
||||
@ -1,26 +0,0 @@
|
||||
"""add can_modify to event_invitations
|
||||
|
||||
Revision ID: 056
|
||||
Revises: 055
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "056"
|
||||
down_revision = "055"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"event_invitations",
|
||||
sa.Column("can_modify", sa.Boolean(), server_default=sa.false(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("event_invitations", "can_modify")
|
||||
@ -1,49 +0,0 @@
|
||||
"""project collab prep: indexes, task version, comment user_id
|
||||
|
||||
Revision ID: 057
|
||||
Revises: 056
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "057"
|
||||
down_revision = "056"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1a. Performance indexes for project_tasks
|
||||
# Use IF NOT EXISTS to handle indexes that may already exist on the DB
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_id ON project_tasks (project_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_parent_task_id ON project_tasks (parent_task_id) WHERE parent_task_id IS NOT NULL")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_updated ON project_tasks (project_id, updated_at DESC)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_projects_user_updated ON projects (user_id, updated_at DESC)")
|
||||
|
||||
# 1b. Add user_id to task_comments for multi-user attribution
|
||||
op.add_column(
|
||||
"task_comments",
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
)
|
||||
|
||||
# 1c. Add version column to project_tasks for optimistic locking
|
||||
op.add_column(
|
||||
"project_tasks",
|
||||
sa.Column("version", sa.Integer(), server_default="1", nullable=False),
|
||||
)
|
||||
|
||||
# Calendar delta polling index (Phase 4 prep)
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_events_calendar_updated ON calendar_events (calendar_id, updated_at DESC)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_events_calendar_updated", table_name="calendar_events")
|
||||
op.drop_column("project_tasks", "version")
|
||||
op.drop_column("task_comments", "user_id")
|
||||
op.drop_index("ix_projects_user_updated", table_name="projects")
|
||||
op.drop_index("ix_project_tasks_project_updated", table_name="project_tasks")
|
||||
op.drop_index("ix_project_tasks_parent_task_id", table_name="project_tasks")
|
||||
op.drop_index("ix_project_tasks_project_id", table_name="project_tasks")
|
||||
@ -1,45 +0,0 @@
|
||||
"""add project_members table
|
||||
|
||||
Revision ID: 058
|
||||
Revises: 057
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "058"
|
||||
down_revision = "057"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"project_members",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("project_id", sa.Integer(), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("invited_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("permission", sa.String(20), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("source", sa.String(20), nullable=False, server_default="invited"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
sa.UniqueConstraint("project_id", "user_id", name="uq_project_members_proj_user"),
|
||||
sa.CheckConstraint("permission IN ('read_only', 'create_modify')", name="ck_project_members_permission"),
|
||||
sa.CheckConstraint("status IN ('pending', 'accepted', 'rejected')", name="ck_project_members_status"),
|
||||
sa.CheckConstraint("source IN ('invited', 'auto_assigned')", name="ck_project_members_source"),
|
||||
)
|
||||
op.create_index("ix_project_members_user_id", "project_members", ["user_id"])
|
||||
op.create_index("ix_project_members_project_id", "project_members", ["project_id"])
|
||||
op.create_index("ix_project_members_status", "project_members", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_project_members_status", table_name="project_members")
|
||||
op.drop_index("ix_project_members_project_id", table_name="project_members")
|
||||
op.drop_index("ix_project_members_user_id", table_name="project_members")
|
||||
op.drop_table("project_members")
|
||||
@ -1,35 +0,0 @@
|
||||
"""add project_task_assignments table
|
||||
|
||||
Revision ID: 059
|
||||
Revises: 058
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "059"
|
||||
down_revision = "058"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"project_task_assignments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("task_id", sa.Integer(), sa.ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("assigned_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("task_id", "user_id", name="uq_task_assignments_task_user"),
|
||||
)
|
||||
op.create_index("ix_task_assignments_task_id", "project_task_assignments", ["task_id"])
|
||||
op.create_index("ix_task_assignments_user_id", "project_task_assignments", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_task_assignments_user_id", table_name="project_task_assignments")
|
||||
op.drop_index("ix_task_assignments_task_id", table_name="project_task_assignments")
|
||||
op.drop_table("project_task_assignments")
|
||||
@ -1,36 +0,0 @@
|
||||
"""Expand notification type CHECK for project invite types
|
||||
|
||||
Revision ID: 060
|
||||
Revises: 059
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "060"
|
||||
down_revision = "059"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_OLD_TYPES = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||
"event_invite", "event_invite_response",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
_NEW_TYPES = _OLD_TYPES + (
|
||||
"project_invite", "project_invite_accepted", "project_invite_rejected",
|
||||
"task_assigned",
|
||||
)
|
||||
|
||||
|
||||
def _check_sql(types: tuple) -> str:
|
||||
return f"type IN ({', '.join(repr(t) for t in types)})"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_NEW_TYPES))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_OLD_TYPES))
|
||||
@ -1,40 +0,0 @@
|
||||
"""Add passkey_credentials table for WebAuthn/FIDO2 authentication
|
||||
|
||||
Revision ID: 061
|
||||
Revises: 060
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "061"
|
||||
down_revision = "060"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"passkey_credentials",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("credential_id", sa.Text, unique=True, nullable=False),
|
||||
sa.Column("public_key", sa.Text, nullable=False),
|
||||
sa.Column("sign_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("transports", sa.Text, nullable=True),
|
||||
sa.Column("backed_up", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime, server_default=sa.text("now()")),
|
||||
sa.Column("last_used_at", sa.DateTime, nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_passkey_credentials_user_id", "passkey_credentials", ["user_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("passkey_credentials")
|
||||
@ -1,40 +0,0 @@
|
||||
"""Passwordless login — add passwordless_enabled to users and allow_passwordless to system_config.
|
||||
|
||||
Revision ID: 062
|
||||
Revises: 061
|
||||
Create Date: 2026-03-18
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "062"
|
||||
down_revision = "061"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"passwordless_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"system_config",
|
||||
sa.Column(
|
||||
"allow_passwordless",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "passwordless_enabled")
|
||||
op.drop_column("system_config", "allow_passwordless")
|
||||
@ -30,12 +30,6 @@ class Settings(BaseSettings):
|
||||
# Concurrent session limit per user (oldest evicted when exceeded)
|
||||
MAX_SESSIONS_PER_USER: int = 10
|
||||
|
||||
# WebAuthn / Passkey configuration
|
||||
WEBAUTHN_RP_ID: str = "localhost" # eTLD+1 domain, e.g. "umbra.ghost6.xyz"
|
||||
WEBAUTHN_RP_NAME: str = "UMBRA"
|
||||
WEBAUTHN_ORIGIN: str = "http://localhost" # Full origin with scheme, e.g. "https://umbra.ghost6.xyz"
|
||||
WEBAUTHN_CHALLENGE_TTL: int = 60 # Challenge token lifetime in seconds
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
@ -53,9 +47,6 @@ class Settings(BaseSettings):
|
||||
self.CORS_ORIGINS = "http://localhost:5173"
|
||||
assert self.COOKIE_SECURE is not None # type narrowing
|
||||
assert self.CORS_ORIGINS is not None
|
||||
# Validate WebAuthn origin includes scheme (S-04)
|
||||
if not self.WEBAUTHN_ORIGIN.startswith(("http://", "https://")):
|
||||
raise ValueError("WEBAUTHN_ORIGIN must include scheme (http:// or https://)")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@ -2,15 +2,11 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from app.config import settings
|
||||
|
||||
# Create async engine with tuned pool (AW-7)
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
future=True,
|
||||
pool_size=10,
|
||||
max_overflow=5,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=1800,
|
||||
future=True
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
|
||||
@ -21,7 +21,6 @@ from app.models.notification import Notification as AppNotification
|
||||
from app.models.reminder import Reminder
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.event_lock import EventLock
|
||||
from app.models.todo import Todo
|
||||
from app.models.project import Project
|
||||
from app.models.ntfy_sent import NtfySent
|
||||
@ -56,8 +55,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:
|
||||
"""Stage a sent record — caller must commit (AW-4: bulk commit per user)."""
|
||||
db.add(NtfySent(notification_key=key, user_id=user_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Dispatch functions ────────────────────────────────────────────────────────
|
||||
@ -239,20 +238,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
|
||||
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:
|
||||
await _dispatch_reminders(db, settings, now, sent_keys)
|
||||
await db.commit()
|
||||
if settings.ntfy_events_enabled:
|
||||
await _dispatch_events(db, settings, now, sent_keys)
|
||||
await db.commit()
|
||||
if settings.ntfy_todos_enabled:
|
||||
await _dispatch_todos(db, settings, now.date(), sent_keys)
|
||||
await db.commit()
|
||||
if settings.ntfy_projects_enabled:
|
||||
await _dispatch_projects(db, settings, now.date(), sent_keys)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _purge_old_sent_records(db: AsyncSession) -> None:
|
||||
@ -307,18 +300,6 @@ async def _purge_resolved_requests(db: AsyncSession) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
|
||||
async def _purge_expired_locks(db: AsyncSession) -> None:
|
||||
"""Remove non-permanent event locks that have expired."""
|
||||
await db.execute(
|
||||
delete(EventLock).where(
|
||||
EventLock.is_permanent == False, # noqa: E712
|
||||
EventLock.expires_at < datetime.now(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def run_notification_dispatch() -> None:
|
||||
@ -362,7 +343,6 @@ async def run_notification_dispatch() -> None:
|
||||
await _purge_expired_sessions(db)
|
||||
await _purge_old_notifications(db)
|
||||
await _purge_resolved_requests(db)
|
||||
await _purge_expired_locks(db)
|
||||
|
||||
except Exception:
|
||||
# Broad catch: job failure must never crash the scheduler or the app
|
||||
|
||||
@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from app.config import settings
|
||||
from app.database import engine
|
||||
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router, passkeys as passkeys_router
|
||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router
|
||||
from app.jobs.notifications import run_notification_dispatch
|
||||
|
||||
# Import models so Alembic's autogenerate can discover them
|
||||
@ -20,10 +20,6 @@ from app.models import audit_log as _audit_log_model # noqa: F401
|
||||
from app.models import notification as _notification_model # noqa: F401
|
||||
from app.models import connection_request as _connection_request_model # noqa: F401
|
||||
from app.models import user_connection as _user_connection_model # noqa: F401
|
||||
from app.models import calendar_member as _calendar_member_model # noqa: F401
|
||||
from app.models import event_lock as _event_lock_model # noqa: F401
|
||||
from app.models import event_invitation as _event_invitation_model # noqa: F401
|
||||
from app.models import passkey_credential as _passkey_credential_model # noqa: F401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -50,8 +46,6 @@ class CSRFHeaderMiddleware:
|
||||
"/api/auth/totp-verify",
|
||||
"/api/auth/totp/enforce-setup",
|
||||
"/api/auth/totp/enforce-confirm",
|
||||
"/api/auth/passkeys/login/begin",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
})
|
||||
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
@ -137,13 +131,9 @@ app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
|
||||
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
|
||||
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
||||
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
||||
app.include_router(passkeys_router.router, prefix="/api/auth/passkeys", tags=["Passkeys"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
||||
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
||||
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
|
||||
app.include_router(event_invitations_router.events_router, prefix="/api/events", tags=["Event Invitations"])
|
||||
app.include_router(event_invitations_router.router, prefix="/api/event-invitations", tags=["Event Invitations"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@ -18,12 +18,6 @@ from app.models.audit_log import AuditLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.connection_request import ConnectionRequest
|
||||
from app.models.user_connection import UserConnection
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||
from app.models.project_member import ProjectMember
|
||||
from app.models.project_task_assignment import ProjectTaskAssignment
|
||||
from app.models.passkey_credential import PasskeyCredential
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
@ -46,11 +40,4 @@ __all__ = [
|
||||
"Notification",
|
||||
"ConnectionRequest",
|
||||
"UserConnection",
|
||||
"CalendarMember",
|
||||
"EventLock",
|
||||
"EventInvitation",
|
||||
"EventInvitationOverride",
|
||||
"ProjectMember",
|
||||
"ProjectTaskAssignment",
|
||||
"PasskeyCredential",
|
||||
]
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from typing import List
|
||||
from app.database import Base
|
||||
|
||||
|
||||
@ -20,9 +17,7 @@ class Calendar(Base):
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
is_visible: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
is_shared: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
events: Mapped[List["CalendarEvent"]] = relationship(back_populates="calendar")
|
||||
members: Mapped[List["CalendarMember"]] = relationship(back_populates="calendar", cascade="all, delete-orphan")
|
||||
|
||||
@ -32,11 +32,6 @@ class CalendarEvent(Base):
|
||||
# original_start: the originally computed occurrence datetime (children only)
|
||||
original_start: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||
|
||||
updated_by: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
from sqlalchemy import (
|
||||
Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index,
|
||||
String, UniqueConstraint, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class CalendarMember(Base):
|
||||
__tablename__ = "calendar_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_members_cal_user"),
|
||||
CheckConstraint(
|
||||
"permission IN ('read_only', 'create_modify', 'full_access')",
|
||||
name="ck_calendar_members_permission",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'rejected')",
|
||||
name="ck_calendar_members_status",
|
||||
),
|
||||
Index("ix_calendar_members_user_id", "user_id"),
|
||||
Index("ix_calendar_members_calendar_id", "calendar_id"),
|
||||
Index("ix_calendar_members_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
calendar_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
invited_by: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
permission: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
can_add_others: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
local_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
invited_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
calendar: Mapped["Calendar"] = relationship(back_populates="members", lazy="raise")
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||
inviter: Mapped[Optional["User"]] = relationship(
|
||||
foreign_keys=[invited_by], lazy="raise"
|
||||
)
|
||||
@ -1,77 +0,0 @@
|
||||
from sqlalchemy import (
|
||||
Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index,
|
||||
String, UniqueConstraint, false as sa_false, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class EventInvitation(Base):
|
||||
__tablename__ = "event_invitations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("event_id", "user_id", name="uq_event_invitations_event_user"),
|
||||
CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'tentative', 'declined')",
|
||||
name="ck_event_invitations_status",
|
||||
),
|
||||
Index("ix_event_invitations_user_status", "user_id", "status"),
|
||||
Index("ix_event_invitations_event_id", "event_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
event_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
invited_by: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
invited_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
responded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
display_calendar_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
can_modify: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default=sa_false()
|
||||
)
|
||||
|
||||
event: Mapped["CalendarEvent"] = relationship(lazy="raise")
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||
inviter: Mapped[Optional["User"]] = relationship(
|
||||
foreign_keys=[invited_by], lazy="raise"
|
||||
)
|
||||
display_calendar: Mapped[Optional["Calendar"]] = relationship(lazy="raise")
|
||||
overrides: Mapped[list["EventInvitationOverride"]] = relationship(
|
||||
lazy="raise", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class EventInvitationOverride(Base):
|
||||
__tablename__ = "event_invitation_overrides"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("invitation_id", "occurrence_id", name="uq_invitation_override"),
|
||||
CheckConstraint(
|
||||
"status IN ('accepted', 'tentative', 'declined')",
|
||||
name="ck_invitation_override_status",
|
||||
),
|
||||
Index("ix_invitation_overrides_lookup", "invitation_id", "occurrence_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
invitation_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("event_invitations.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
occurrence_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
responded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
@ -1,31 +0,0 @@
|
||||
from sqlalchemy import Boolean, DateTime, Integer, ForeignKey, Index, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class EventLock(Base):
|
||||
__tablename__ = "event_locks"
|
||||
__table_args__ = (Index("ix_event_locks_expires_at", "expires_at"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
event_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("calendar_events.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
locked_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
locked_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
is_permanent: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
|
||||
event: Mapped["CalendarEvent"] = relationship(lazy="selectin")
|
||||
holder: Mapped["User"] = relationship(foreign_keys=[locked_by], lazy="selectin")
|
||||
@ -5,12 +5,10 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
# Active: connection_request, connection_accepted
|
||||
# Reserved: connection_rejected, info, warning, reminder, system
|
||||
_NOTIFICATION_TYPES = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||
"event_invite", "event_invite_response",
|
||||
"project_invite", "project_invite_accepted", "project_invite_rejected",
|
||||
"task_assigned",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class PasskeyCredential(Base):
|
||||
__tablename__ = "passkey_credentials"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# base64url-encoded credential ID (spec allows up to 1023 bytes → ~1363 chars)
|
||||
credential_id: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
# base64url-encoded COSE public key
|
||||
public_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# Authenticator sign count for clone detection
|
||||
sign_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# User-assigned label (e.g. "MacBook Pro — Chrome")
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
# JSON array of transport hints (e.g. '["usb","hybrid"]')
|
||||
transports: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Whether the credential is backed up / synced across devices
|
||||
backed_up: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
@ -22,7 +22,6 @@ class Project(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
|
||||
tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan", passive_deletes=True, lazy="raise")
|
||||
todos: Mapped[List["Todo"]] = relationship(back_populates="project", lazy="raise")
|
||||
members: Mapped[List["ProjectMember"]] = relationship(back_populates="project", cascade="all, delete-orphan", passive_deletes=True, lazy="raise")
|
||||
# Relationships
|
||||
tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan")
|
||||
todos: Mapped[List["Todo"]] = relationship(back_populates="project")
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
from sqlalchemy import (
|
||||
CheckConstraint, DateTime, Integer, ForeignKey, Index,
|
||||
String, UniqueConstraint, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "user_id", name="uq_project_members_proj_user"),
|
||||
CheckConstraint(
|
||||
"permission IN ('read_only', 'create_modify')",
|
||||
name="ck_project_members_permission",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'rejected')",
|
||||
name="ck_project_members_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"source IN ('invited', 'auto_assigned')",
|
||||
name="ck_project_members_source",
|
||||
),
|
||||
Index("ix_project_members_user_id", "user_id"),
|
||||
Index("ix_project_members_project_id", "project_id"),
|
||||
Index("ix_project_members_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
invited_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
permission: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
source: Mapped[str] = mapped_column(String(20), nullable=False, default="invited")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember)
|
||||
project: Mapped["Project"] = relationship(back_populates="members", lazy="raise")
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||
inviter: Mapped[Optional["User"]] = relationship(
|
||||
foreign_keys=[invited_by], lazy="raise"
|
||||
)
|
||||
@ -1,4 +1,3 @@
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import String, Text, Integer, Date, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
|
||||
from datetime import datetime, date
|
||||
@ -21,33 +20,21 @@ class ProjectTask(Base):
|
||||
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||
person_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1, server_default=sa.text("1"))
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
|
||||
project: Mapped["Project"] = sa_relationship(back_populates="tasks", lazy="raise")
|
||||
person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks", lazy="raise")
|
||||
# Relationships
|
||||
project: Mapped["Project"] = sa_relationship(back_populates="tasks")
|
||||
person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks")
|
||||
parent_task: Mapped[Optional["ProjectTask"]] = sa_relationship(
|
||||
back_populates="subtasks",
|
||||
remote_side=[id],
|
||||
lazy="raise",
|
||||
)
|
||||
subtasks: Mapped[List["ProjectTask"]] = sa_relationship(
|
||||
back_populates="parent_task",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
lazy="raise",
|
||||
)
|
||||
comments: Mapped[List["TaskComment"]] = sa_relationship(
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
lazy="raise",
|
||||
)
|
||||
assignments: Mapped[List["ProjectTaskAssignment"]] = sa_relationship(
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
lazy="raise",
|
||||
)
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
from sqlalchemy import DateTime, Integer, ForeignKey, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ProjectTaskAssignment(Base):
|
||||
__tablename__ = "project_task_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("task_id", "user_id", name="uq_task_assignments_task_user"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
assigned_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1
|
||||
task: Mapped["ProjectTask"] = relationship(back_populates="assignments", lazy="raise")
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||
assigner: Mapped["User"] = relationship(foreign_keys=[assigned_by], lazy="raise")
|
||||
@ -18,10 +18,6 @@ class UserSession(Base):
|
||||
expires_at: Mapped[datetime] = mapped_column(nullable=False)
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Session lock — persists across page refresh
|
||||
is_locked: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
locked_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
|
||||
# Audit fields for security logging
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
@ -21,9 +21,6 @@ class SystemConfig(Base):
|
||||
enforce_mfa_new_users: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
allow_passwordless: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=func.now(), onupdate=func.now(), server_default=func.now()
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from sqlalchemy import Text, Integer, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
@ -12,12 +11,8 @@ class TaskComment(Base):
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(default=datetime.now)
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
|
||||
task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments", lazy="raise")
|
||||
user: Mapped[Optional["User"]] = sa_relationship(lazy="raise")
|
||||
# Relationships
|
||||
task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments")
|
||||
|
||||
@ -43,11 +43,6 @@ class User(Base):
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
|
||||
# Passwordless login — requires >= 2 passkeys registered
|
||||
passwordless_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
|
||||
# Audit
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
@ -22,8 +22,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.backup_code import BackupCode
|
||||
from app.models.session import UserSession
|
||||
from app.models.settings import Settings
|
||||
@ -45,14 +43,13 @@ from app.schemas.admin import (
|
||||
SystemConfigUpdate,
|
||||
ToggleActiveRequest,
|
||||
ToggleMfaEnforceRequest,
|
||||
TogglePasswordlessRequest,
|
||||
UpdateUserRoleRequest,
|
||||
UserDetailResponse,
|
||||
UserListItem,
|
||||
UserListResponse,
|
||||
)
|
||||
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
|
||||
@ -226,7 +223,7 @@ async def create_user(
|
||||
new_user = User(
|
||||
username=data.username,
|
||||
umbral_name=data.username,
|
||||
password_hash=await ahash_password(data.password),
|
||||
password_hash=hash_password(data.password),
|
||||
role=data.role,
|
||||
email=email,
|
||||
first_name=data.first_name,
|
||||
@ -342,7 +339,7 @@ async def reset_user_password(
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
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.last_password_change_at = datetime.now()
|
||||
|
||||
@ -621,106 +618,6 @@ async def list_user_sessions(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users/{user_id}/sharing-stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/users/{user_id}/sharing-stats")
|
||||
async def get_user_sharing_stats(
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_actor: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return sharing statistics for a user."""
|
||||
result = await db.execute(sa.select(User).where(User.id == user_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Calendars owned that are shared
|
||||
shared_owned = await db.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(Calendar)
|
||||
.where(Calendar.user_id == user_id, Calendar.is_shared == True)
|
||||
) or 0
|
||||
|
||||
# Calendars the user is a member of (accepted)
|
||||
member_of = await db.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(CalendarMember)
|
||||
.where(CalendarMember.user_id == user_id, CalendarMember.status == "accepted")
|
||||
) or 0
|
||||
|
||||
# Pending invites sent by this user
|
||||
pending_sent = await db.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(CalendarMember)
|
||||
.where(CalendarMember.invited_by == user_id, CalendarMember.status == "pending")
|
||||
) or 0
|
||||
|
||||
# Pending invites received by this user
|
||||
pending_received = await db.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(CalendarMember)
|
||||
.where(CalendarMember.user_id == user_id, CalendarMember.status == "pending")
|
||||
) or 0
|
||||
|
||||
return {
|
||||
"shared_calendars_owned": shared_owned,
|
||||
"calendars_member_of": member_of,
|
||||
"pending_invites_sent": pending_sent,
|
||||
"pending_invites_received": pending_received,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /users/{user_id}/passwordless
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.put("/users/{user_id}/passwordless")
|
||||
async def admin_toggle_passwordless(
|
||||
request: Request,
|
||||
data: TogglePasswordlessRequest,
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Admin-only: disable passwordless login for a user.
|
||||
Only enabled=False is allowed — admin cannot remotely enable passwordless.
|
||||
Revokes all sessions so the user must re-authenticate.
|
||||
"""
|
||||
if data.enabled:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Admin can only disable passwordless login, not enable it",
|
||||
)
|
||||
|
||||
_guard_self_action(actor, user_id, "toggle passwordless for")
|
||||
|
||||
result = await db.execute(sa.select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if not user.passwordless_enabled:
|
||||
raise HTTPException(status_code=409, detail="Passwordless login is not enabled for this user")
|
||||
|
||||
user.passwordless_enabled = False
|
||||
|
||||
revoked = await _revoke_all_sessions(db, user_id)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="admin.passwordless_disabled",
|
||||
actor_id=actor.id,
|
||||
target_id=user_id,
|
||||
detail={"sessions_revoked": revoked, "username": user.username},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"passwordless_enabled": False, "sessions_revoked": revoked}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /config
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -767,9 +664,6 @@ async def update_system_config(
|
||||
if data.enforce_mfa_new_users is not None:
|
||||
changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users
|
||||
config.enforce_mfa_new_users = data.enforce_mfa_new_users
|
||||
if data.allow_passwordless is not None:
|
||||
changes["allow_passwordless"] = data.allow_passwordless
|
||||
config.allow_passwordless = data.allow_passwordless
|
||||
|
||||
if changes:
|
||||
await log_audit_event(
|
||||
@ -794,18 +688,18 @@ async def admin_dashboard(
|
||||
_actor: User = Depends(get_current_user),
|
||||
):
|
||||
"""Aggregate stats for the admin portal dashboard."""
|
||||
# AW-6: Single conditional aggregation instead of 5 separate COUNT queries
|
||||
user_stats = await db.execute(
|
||||
sa.select(
|
||||
sa.func.count().label("total"),
|
||||
sa.func.count().filter(User.is_active == True).label("active"),
|
||||
sa.func.count().filter(User.role == "admin").label("admins"),
|
||||
sa.func.count().filter(User.totp_enabled == True).label("totp"),
|
||||
).select_from(User)
|
||||
total_users = await db.scalar(
|
||||
sa.select(sa.func.count()).select_from(User)
|
||||
)
|
||||
active_users = await db.scalar(
|
||||
sa.select(sa.func.count()).select_from(User).where(User.is_active == True)
|
||||
)
|
||||
admin_count = await db.scalar(
|
||||
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(
|
||||
sa.select(sa.func.count()).select_from(UserSession).where(
|
||||
UserSession.revoked == False,
|
||||
|
||||
@ -16,6 +16,7 @@ Security layers:
|
||||
4. bcrypt→Argon2id transparent upgrade on first login
|
||||
5. Role-based authorization via require_role() dependency factory
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
@ -24,12 +25,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.connection import sync_birthday_to_contacts
|
||||
from app.models.user import User
|
||||
from app.models.session import UserSession
|
||||
from app.models.settings import Settings
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.passkey_credential import PasskeyCredential
|
||||
from app.models.calendar import Calendar
|
||||
from app.schemas.auth import (
|
||||
SetupRequest, LoginRequest, RegisterRequest,
|
||||
@ -37,9 +36,6 @@ from app.schemas.auth import (
|
||||
ProfileUpdate, ProfileResponse,
|
||||
)
|
||||
from app.services.auth import (
|
||||
ahash_password,
|
||||
averify_password,
|
||||
averify_password_with_upgrade,
|
||||
hash_password,
|
||||
verify_password,
|
||||
verify_password_with_upgrade,
|
||||
@ -49,13 +45,6 @@ from app.services.auth import (
|
||||
create_mfa_enforce_token,
|
||||
)
|
||||
from app.services.audit import get_client_ip, log_audit_event
|
||||
from app.services.session import (
|
||||
set_session_cookie,
|
||||
check_account_lockout,
|
||||
record_failed_login,
|
||||
record_successful_login,
|
||||
create_db_session,
|
||||
)
|
||||
from app.config import settings as app_settings
|
||||
|
||||
router = APIRouter()
|
||||
@ -66,6 +55,22 @@ router = APIRouter()
|
||||
# is indistinguishable from a wrong-password attempt.
|
||||
_DUMMY_HASH = hash_password("timing-equalization-dummy")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cookie helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key="session",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=app_settings.COOKIE_SECURE,
|
||||
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth dependencies — export get_current_user and get_current_settings
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -95,22 +100,25 @@ async def get_current_user(
|
||||
if user_id is None or session_id is None:
|
||||
raise HTTPException(status_code=401, detail="Malformed session token")
|
||||
|
||||
# AC-1: Single JOIN query for session + user (was 2 sequential queries)
|
||||
result = await db.execute(
|
||||
select(UserSession, User)
|
||||
.join(User, UserSession.user_id == User.id)
|
||||
.where(
|
||||
# Verify session is active in DB (covers revocation + expiry)
|
||||
session_result = await db.execute(
|
||||
select(UserSession).where(
|
||||
UserSession.id == session_id,
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.revoked == False,
|
||||
UserSession.expires_at > datetime.now(),
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="Session expired or user inactive")
|
||||
db_session, user = row.tuple()
|
||||
db_session = session_result.scalar_one_or_none()
|
||||
if not db_session:
|
||||
raise HTTPException(status_code=401, detail="Session has been revoked or expired")
|
||||
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.is_active == True)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||
|
||||
# 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).
|
||||
@ -121,46 +129,25 @@ async def get_current_user(
|
||||
await db.flush()
|
||||
# Re-issue cookie with fresh signed token to reset browser max_age timer
|
||||
fresh_token = create_session_token(user_id, session_id)
|
||||
set_session_cookie(response, fresh_token)
|
||||
|
||||
# Stash session on request so lock/unlock endpoints can access it
|
||||
request.state.db_session = db_session
|
||||
|
||||
# Defense-in-depth: block API access while session is locked.
|
||||
# Exempt endpoints needed for unlocking, locking, checking status, and logout.
|
||||
if db_session.is_locked:
|
||||
lock_exempt = {
|
||||
"/api/auth/lock", "/api/auth/verify-password",
|
||||
"/api/auth/status", "/api/auth/logout",
|
||||
"/api/auth/passkeys/login/begin", "/api/auth/passkeys/login/complete",
|
||||
}
|
||||
if request.url.path not in lock_exempt:
|
||||
raise HTTPException(status_code=423, detail="Session is locked")
|
||||
_set_session_cookie(response, fresh_token)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_settings(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Settings:
|
||||
"""
|
||||
Convenience dependency for routers that need Settings access.
|
||||
Always chain after get_current_user — never use standalone.
|
||||
|
||||
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(
|
||||
select(Settings).where(Settings.user_id == current_user.id)
|
||||
)
|
||||
settings_obj = result.scalar_one_or_none()
|
||||
if not settings_obj:
|
||||
raise HTTPException(status_code=500, detail="Settings not found for user")
|
||||
request.state.settings = settings_obj
|
||||
return settings_obj
|
||||
|
||||
|
||||
@ -182,6 +169,82 @@ def require_role(*allowed_roles: str):
|
||||
require_admin = require_role("admin")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Account lockout helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _check_account_lockout(user: User) -> None:
|
||||
"""Raise HTTP 423 if the account is currently locked."""
|
||||
if user.locked_until and datetime.now() < user.locked_until:
|
||||
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=f"Account locked. Try again in {remaining} minutes.",
|
||||
)
|
||||
|
||||
|
||||
async def _record_failed_login(db: AsyncSession, user: User) -> None:
|
||||
"""Increment failure counter; lock account after 10 failures."""
|
||||
user.failed_login_count += 1
|
||||
if user.failed_login_count >= 10:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _record_successful_login(db: AsyncSession, user: User) -> None:
|
||||
"""Reset failure counter and update last_login_at."""
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session creation helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _create_db_session(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
ip: str,
|
||||
user_agent: str | None,
|
||||
) -> tuple[str, str]:
|
||||
"""Insert a UserSession row and return (session_id, signed_cookie_token)."""
|
||||
session_id = uuid.uuid4().hex
|
||||
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
||||
db_session = UserSession(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip[:45] if ip else None,
|
||||
user_agent=(user_agent or "")[:255] if user_agent else None,
|
||||
)
|
||||
db.add(db_session)
|
||||
await db.flush()
|
||||
|
||||
# Enforce concurrent session limit: revoke oldest sessions beyond the cap
|
||||
active_sessions = (
|
||||
await db.execute(
|
||||
select(UserSession)
|
||||
.where(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.revoked == False, # noqa: E712
|
||||
UserSession.expires_at > datetime.now(),
|
||||
)
|
||||
.order_by(UserSession.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
max_sessions = app_settings.MAX_SESSIONS_PER_USER
|
||||
if len(active_sessions) > max_sessions:
|
||||
for old_session in active_sessions[: len(active_sessions) - max_sessions]:
|
||||
old_session.revoked = True
|
||||
await db.flush()
|
||||
|
||||
token = create_session_token(user.id, session_id)
|
||||
return session_id, token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User bootstrapping helper (Settings + default calendars)
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -222,7 +285,7 @@ async def setup(
|
||||
if user_count.scalar_one() > 0:
|
||||
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(
|
||||
username=data.username,
|
||||
umbral_name=data.username,
|
||||
@ -237,8 +300,8 @@ async def setup(
|
||||
|
||||
ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, new_user, ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
_, token = await _create_db_session(db, new_user, ip, user_agent)
|
||||
_set_session_cookie(response, token)
|
||||
|
||||
await log_audit_event(
|
||||
db, action="auth.setup_complete", actor_id=new_user.id, ip=ip,
|
||||
@ -275,45 +338,26 @@ async def login(
|
||||
if not user:
|
||||
# M-02: Run Argon2id against a dummy hash so the response time is
|
||||
# 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")
|
||||
|
||||
# M-02: Run password verification BEFORE lockout check so Argon2id always
|
||||
# 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)
|
||||
|
||||
if not valid:
|
||||
remaining = await record_failed_login(db, user)
|
||||
await _record_failed_login(db, user)
|
||||
await log_audit_event(
|
||||
db, action="auth.login_failed", actor_id=user.id,
|
||||
detail={"reason": "invalid_password", "attempts_remaining": remaining}, ip=client_ip,
|
||||
detail={"reason": "invalid_password"}, ip=client_ip,
|
||||
)
|
||||
await db.commit()
|
||||
if remaining == 0:
|
||||
detail = "Account temporarily locked. Try again in 30 minutes."
|
||||
elif remaining <= 3:
|
||||
detail = f"Invalid username or password. {remaining} attempt(s) remaining before account locks."
|
||||
else:
|
||||
detail = "Invalid username or password"
|
||||
raise HTTPException(status_code=401, detail=detail)
|
||||
|
||||
# Block passwordless-only accounts from using the password login path.
|
||||
# Checked after password verification to avoid leaking account existence via timing.
|
||||
if user.passwordless_enabled:
|
||||
await log_audit_event(
|
||||
db, action="auth.login_blocked_passwordless", actor_id=user.id,
|
||||
detail={"reason": "passwordless_enabled"}, ip=client_ip,
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="This account uses passwordless login. Sign in with a passkey.",
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
# Block disabled accounts — checked AFTER password verification to avoid
|
||||
# leaking account-state info, and BEFORE record_successful_login so
|
||||
# leaking account-state info, and BEFORE _record_successful_login so
|
||||
# last_login_at and lockout counters are not reset for inactive users.
|
||||
if not user.is_active:
|
||||
await log_audit_event(
|
||||
@ -326,7 +370,7 @@ async def login(
|
||||
if new_hash:
|
||||
user.password_hash = new_hash
|
||||
|
||||
await record_successful_login(db, user)
|
||||
await _record_successful_login(db, user)
|
||||
|
||||
# SEC-03: MFA enforcement — block login entirely until MFA setup completes
|
||||
if user.mfa_enforce_pending and not user.totp_enabled:
|
||||
@ -344,7 +388,6 @@ async def login(
|
||||
# If TOTP is enabled, issue a short-lived MFA challenge token
|
||||
if user.totp_enabled:
|
||||
mfa_token = create_mfa_token(user.id)
|
||||
await db.commit()
|
||||
return {
|
||||
"authenticated": False,
|
||||
"totp_required": True,
|
||||
@ -355,8 +398,8 @@ async def login(
|
||||
if user.must_change_password:
|
||||
# Issue a session but flag the frontend to show password change
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, client_ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
_, token = await _create_db_session(db, user, client_ip, user_agent)
|
||||
_set_session_cookie(response, token)
|
||||
await db.commit()
|
||||
return {
|
||||
"authenticated": True,
|
||||
@ -364,8 +407,8 @@ async def login(
|
||||
}
|
||||
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, client_ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
_, token = await _create_db_session(db, user, client_ip, user_agent)
|
||||
_set_session_cookie(response, token)
|
||||
|
||||
await log_audit_event(
|
||||
db, action="auth.login_success", actor_id=user.id, ip=client_ip,
|
||||
@ -408,7 +451,7 @@ async def register(
|
||||
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.")
|
||||
|
||||
password_hash = await ahash_password(data.password)
|
||||
password_hash = hash_password(data.password)
|
||||
# SEC-01: Explicit field assignment — never **data.model_dump()
|
||||
new_user = User(
|
||||
username=data.username,
|
||||
@ -447,8 +490,8 @@ async def register(
|
||||
"mfa_token": enforce_token,
|
||||
}
|
||||
|
||||
_, token = await create_db_session(db, new_user, ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
_, token = await _create_db_session(db, new_user, ip, user_agent)
|
||||
_set_session_cookie(response, token)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Registration successful", "authenticated": True}
|
||||
@ -498,36 +541,32 @@ async def auth_status(
|
||||
authenticated = False
|
||||
role = None
|
||||
|
||||
is_locked = False
|
||||
|
||||
u = None
|
||||
if not setup_required and session_cookie:
|
||||
payload = verify_session_token(session_cookie)
|
||||
if payload:
|
||||
user_id = payload.get("uid")
|
||||
session_id = payload.get("sid")
|
||||
if user_id and session_id:
|
||||
# Single JOIN query (was 2 sequential queries — P-01 fix)
|
||||
result = await db.execute(
|
||||
select(UserSession, User)
|
||||
.join(User, UserSession.user_id == User.id)
|
||||
.where(
|
||||
session_result = await db.execute(
|
||||
select(UserSession).where(
|
||||
UserSession.id == session_id,
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.revoked == False,
|
||||
UserSession.expires_at > datetime.now(),
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row is not None:
|
||||
db_sess, u = row.tuple()
|
||||
if session_result.scalar_one_or_none() is not None:
|
||||
authenticated = True
|
||||
is_locked = db_sess.is_locked
|
||||
role = u.role
|
||||
user_obj_result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.is_active == True)
|
||||
)
|
||||
u = user_obj_result.scalar_one_or_none()
|
||||
if u:
|
||||
role = u.role
|
||||
else:
|
||||
authenticated = False
|
||||
|
||||
# Check registration availability
|
||||
config = None
|
||||
registration_open = False
|
||||
if not setup_required:
|
||||
config_result = await db.execute(
|
||||
@ -536,50 +575,18 @@ async def auth_status(
|
||||
config = config_result.scalar_one_or_none()
|
||||
registration_open = config.allow_registration if config else False
|
||||
|
||||
# Perf-3: Check passkey existence with EXISTS (not COUNT) — this endpoint
|
||||
# is polled every 15s. Count is derived from GET /auth/passkeys list instead.
|
||||
has_passkeys = False
|
||||
passwordless_enabled = False
|
||||
if authenticated and u:
|
||||
pk_result = await db.execute(
|
||||
select(PasskeyCredential.id).where(
|
||||
PasskeyCredential.user_id == u.id
|
||||
).limit(1)
|
||||
)
|
||||
has_passkeys = pk_result.scalar_one_or_none() is not None
|
||||
passwordless_enabled = u.passwordless_enabled
|
||||
|
||||
return {
|
||||
"authenticated": authenticated,
|
||||
"setup_required": setup_required,
|
||||
"role": role,
|
||||
"username": u.username if authenticated and u else None,
|
||||
"registration_open": registration_open,
|
||||
"is_locked": is_locked,
|
||||
"has_passkeys": has_passkeys,
|
||||
"passwordless_enabled": passwordless_enabled,
|
||||
"allow_passwordless": config.allow_passwordless if config else False,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/lock")
|
||||
async def lock_session(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark the current session as locked. Frontend must verify password to unlock."""
|
||||
db_session: UserSession = request.state.db_session
|
||||
db_session.is_locked = True
|
||||
db_session.locked_at = datetime.now()
|
||||
await db.commit()
|
||||
return {"locked": True}
|
||||
|
||||
|
||||
@router.post("/verify-password")
|
||||
async def verify_password_endpoint(
|
||||
async def verify_password(
|
||||
data: VerifyPasswordRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@ -587,22 +594,16 @@ async def verify_password_endpoint(
|
||||
Verify the current user's password without changing anything.
|
||||
Used by the frontend lock screen to re-authenticate without a full login.
|
||||
"""
|
||||
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:
|
||||
await record_failed_login(db, current_user)
|
||||
await db.commit()
|
||||
await _record_failed_login(db, current_user)
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
if new_hash:
|
||||
current_user.password_hash = new_hash
|
||||
|
||||
# Clear session lock on successful password verification
|
||||
db_session: UserSession = request.state.db_session
|
||||
db_session.is_locked = False
|
||||
db_session.locked_at = None
|
||||
await db.commit()
|
||||
await db.commit()
|
||||
|
||||
return {"verified": True}
|
||||
|
||||
@ -614,18 +615,17 @@ async def change_password(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""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:
|
||||
await record_failed_login(db, current_user)
|
||||
await db.commit()
|
||||
await _record_failed_login(db, current_user)
|
||||
raise HTTPException(status_code=401, detail="Invalid current password")
|
||||
|
||||
if data.new_password == data.old_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()
|
||||
|
||||
# Clear forced password change flag if set (SEC-12)
|
||||
@ -686,12 +686,6 @@ async def update_profile(
|
||||
current_user.email = update_data["email"]
|
||||
if "date_of_birth" in update_data:
|
||||
current_user.date_of_birth = update_data["date_of_birth"]
|
||||
settings_result = await db.execute(
|
||||
select(Settings).where(Settings.user_id == current_user.id)
|
||||
)
|
||||
user_settings = settings_result.scalar_one_or_none()
|
||||
share = user_settings.share_birthday if user_settings else False
|
||||
await sync_birthday_to_contacts(db, current_user.id, share_birthday=share, date_of_birth=update_data["date_of_birth"])
|
||||
if "umbral_name" in update_data:
|
||||
current_user.umbral_name = update_data["umbral_name"]
|
||||
|
||||
|
||||
@ -1,17 +1,12 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import select, update
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse
|
||||
from app.services.calendar_sharing import require_permission
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
@ -28,28 +23,7 @@ async def get_calendars(
|
||||
.where(Calendar.user_id == current_user.id)
|
||||
.order_by(Calendar.is_default.desc(), Calendar.name.asc())
|
||||
)
|
||||
calendars = result.scalars().all()
|
||||
|
||||
# Populate member_count for shared calendars
|
||||
cal_ids = [c.id for c in calendars if c.is_shared]
|
||||
count_map: dict[int, int] = {}
|
||||
if cal_ids:
|
||||
counts = await db.execute(
|
||||
select(CalendarMember.calendar_id, func.count())
|
||||
.where(
|
||||
CalendarMember.calendar_id.in_(cal_ids),
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
.group_by(CalendarMember.calendar_id)
|
||||
)
|
||||
count_map = dict(counts.all())
|
||||
|
||||
return [
|
||||
CalendarResponse.model_validate(c, from_attributes=True).model_copy(
|
||||
update={"member_count": count_map.get(c.id, 0)}
|
||||
)
|
||||
for c in calendars
|
||||
]
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/", response_model=CalendarResponse, status_code=201)
|
||||
@ -140,62 +114,3 @@ async def delete_calendar(
|
||||
await db.delete(calendar)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# DELTA POLLING
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class CalendarPollResponse(BaseModel):
|
||||
has_changes: bool
|
||||
calendar_updated_at: str | None = None
|
||||
changed_event_ids: list[int] = []
|
||||
|
||||
|
||||
@router.get("/{calendar_id}/poll", response_model=CalendarPollResponse)
|
||||
async def poll_calendar(
|
||||
calendar_id: int = Path(ge=1, le=2147483647),
|
||||
since: str = Query(..., description="ISO timestamp to check for changes since"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Lightweight poll endpoint — returns changed event IDs since timestamp."""
|
||||
await require_permission(db, calendar_id, current_user.id, "read_only")
|
||||
|
||||
try:
|
||||
since_dt = datetime.fromisoformat(since)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid ISO timestamp")
|
||||
|
||||
# Clamp to max 24h in the past to prevent expensive full-table scans
|
||||
from datetime import timedelta
|
||||
min_since = datetime.now() - timedelta(hours=24)
|
||||
if since_dt < min_since:
|
||||
since_dt = min_since
|
||||
|
||||
# Check calendar-level update
|
||||
cal_result = await db.execute(
|
||||
select(Calendar.updated_at).where(Calendar.id == calendar_id)
|
||||
)
|
||||
calendar_updated = cal_result.scalar_one_or_none()
|
||||
if not calendar_updated:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
|
||||
calendar_changed = calendar_updated > since_dt
|
||||
|
||||
# Check event-level changes using the ix_events_calendar_updated index
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent.id).where(
|
||||
CalendarEvent.calendar_id == calendar_id,
|
||||
CalendarEvent.updated_at > since_dt,
|
||||
)
|
||||
)
|
||||
changed_event_ids = [r[0] for r in event_result.all()]
|
||||
|
||||
has_changes = calendar_changed or len(changed_event_ids) > 0
|
||||
|
||||
return CalendarPollResponse(
|
||||
has_changes=has_changes,
|
||||
calendar_updated_at=calendar_updated.isoformat() if calendar_updated else None,
|
||||
changed_event_ids=changed_event_ids,
|
||||
)
|
||||
|
||||
@ -49,9 +49,7 @@ from app.services.connection import (
|
||||
resolve_shared_profile,
|
||||
send_connection_ntfy,
|
||||
)
|
||||
from app.services.calendar_sharing import cascade_on_disconnect
|
||||
from app.services.notification import create_notification
|
||||
from app.services.project_sharing import cascade_projects_on_disconnect
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -825,12 +823,6 @@ async def remove_connection(
|
||||
if reverse_conn:
|
||||
await db.delete(reverse_conn)
|
||||
|
||||
# Cascade: remove calendar memberships and event locks between these users
|
||||
await cascade_on_disconnect(db, current_user.id, counterpart_id)
|
||||
|
||||
# Cascade: remove project memberships and task assignments between these users
|
||||
await cascade_projects_on_disconnect(db, current_user.id, counterpart_id)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="connection.removed",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import false as sa_false, select, func, or_, case
|
||||
from sqlalchemy import select, func, or_
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
@ -8,12 +8,11 @@ from app.database import get_db
|
||||
from app.models.settings import Settings
|
||||
from app.models.todo import Todo
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.reminder import Reminder
|
||||
from app.models.project import Project
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user, get_current_settings
|
||||
from app.models.event_invitation import EventInvitation
|
||||
from app.services.calendar_sharing import get_accessible_event_scope
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -36,18 +35,14 @@ async def get_dashboard(
|
||||
today = client_date or date.today()
|
||||
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
|
||||
|
||||
# Fetch all accessible calendar IDs + invited event IDs
|
||||
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||
# Subquery: calendar IDs belonging to this user (for event scoping)
|
||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
|
||||
# Today's events (exclude parent templates — they are hidden, children are shown)
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
today_end = datetime.combine(today, datetime.max.time())
|
||||
events_query = select(CalendarEvent).where(
|
||||
or_(
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||
),
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.start_datetime >= today_start,
|
||||
CalendarEvent.start_datetime <= today_end,
|
||||
_not_parent_template,
|
||||
@ -55,22 +50,6 @@ async def get_dashboard(
|
||||
events_result = await db.execute(events_query)
|
||||
todays_events = events_result.scalars().all()
|
||||
|
||||
# Build invitation lookup for today's events
|
||||
invited_event_id_set = set(invited_event_ids)
|
||||
today_inv_map: dict[int, tuple[str, int | None]] = {}
|
||||
today_event_ids = [e.id for e in todays_events]
|
||||
parent_ids_in_today = [e.parent_event_id for e in todays_events if e.parent_event_id and e.parent_event_id in invited_event_id_set]
|
||||
inv_lookup_ids = list(set(today_event_ids + parent_ids_in_today) & invited_event_id_set)
|
||||
if inv_lookup_ids:
|
||||
inv_result = await db.execute(
|
||||
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where(
|
||||
EventInvitation.user_id == current_user.id,
|
||||
EventInvitation.event_id.in_(inv_lookup_ids),
|
||||
)
|
||||
)
|
||||
for eid, status, disp_cal_id in inv_result.all():
|
||||
today_inv_map[eid] = (status, disp_cal_id)
|
||||
|
||||
# Upcoming todos (not completed, with due date from today through upcoming_days)
|
||||
todos_query = select(Todo).where(
|
||||
Todo.user_id == current_user.id,
|
||||
@ -92,35 +71,31 @@ async def get_dashboard(
|
||||
reminders_result = await db.execute(reminders_query)
|
||||
active_reminders = reminders_result.scalars().all()
|
||||
|
||||
# Project stats — single GROUP BY query, derive total in Python
|
||||
projects_by_status_result = await db.execute(
|
||||
select(
|
||||
Project.status,
|
||||
func.count(Project.id).label("count"),
|
||||
).where(Project.user_id == current_user.id).group_by(Project.status)
|
||||
# Project stats (scoped to user)
|
||||
total_projects_result = await db.execute(
|
||||
select(func.count(Project.id)).where(Project.user_id == current_user.id)
|
||||
)
|
||||
total_projects = total_projects_result.scalar()
|
||||
|
||||
projects_by_status_query = select(
|
||||
Project.status,
|
||||
func.count(Project.id).label("count")
|
||||
).where(Project.user_id == current_user.id).group_by(Project.status)
|
||||
projects_by_status_result = await db.execute(projects_by_status_query)
|
||||
projects_by_status = {row[0]: row[1] for row in projects_by_status_result}
|
||||
total_projects = sum(projects_by_status.values())
|
||||
|
||||
# Todo counts: total and incomplete in a single query
|
||||
todo_counts_result = await db.execute(
|
||||
select(
|
||||
func.count(Todo.id).label("total"),
|
||||
func.count(case((Todo.completed == False, Todo.id))).label("incomplete"),
|
||||
).where(Todo.user_id == current_user.id)
|
||||
# Total incomplete todos count (scoped to user)
|
||||
total_incomplete_result = await db.execute(
|
||||
select(func.count(Todo.id)).where(
|
||||
Todo.user_id == current_user.id,
|
||||
Todo.completed == False,
|
||||
)
|
||||
)
|
||||
todo_row = todo_counts_result.one()
|
||||
total_todos = todo_row.total
|
||||
total_incomplete_todos = todo_row.incomplete
|
||||
total_incomplete_todos = total_incomplete_result.scalar()
|
||||
|
||||
# Starred events — no upper date bound so future events always appear in countdown.
|
||||
# _not_parent_template excludes recurring parent templates (children still show).
|
||||
# Starred events (upcoming, ordered by date, scoped to user's calendars)
|
||||
starred_query = select(CalendarEvent).where(
|
||||
or_(
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||
),
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.is_starred == True,
|
||||
CalendarEvent.start_datetime > today_start,
|
||||
_not_parent_template,
|
||||
@ -146,10 +121,7 @@ async def get_dashboard(
|
||||
"end_datetime": event.end_datetime,
|
||||
"all_day": event.all_day,
|
||||
"color": event.color,
|
||||
"is_starred": event.is_starred,
|
||||
"is_invited": (event.parent_event_id or event.id) in invited_event_id_set,
|
||||
"invitation_status": today_inv_map.get(event.parent_event_id or event.id, (None,))[0],
|
||||
"display_calendar_id": today_inv_map.get(event.parent_event_id or event.id, (None, None))[1],
|
||||
"is_starred": event.is_starred
|
||||
}
|
||||
for event in todays_events
|
||||
],
|
||||
@ -176,7 +148,6 @@ async def get_dashboard(
|
||||
"by_status": projects_by_status
|
||||
},
|
||||
"total_incomplete_todos": total_incomplete_todos,
|
||||
"total_todos": total_todos,
|
||||
"starred_events": starred_events_data
|
||||
}
|
||||
|
||||
@ -194,64 +165,42 @@ async def get_upcoming(
|
||||
cutoff_date = today + timedelta(days=days)
|
||||
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
|
||||
today_start = datetime.combine(today, datetime.min.time())
|
||||
overdue_floor = today - timedelta(days=30)
|
||||
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
|
||||
|
||||
# Fetch all accessible calendar IDs + invited event IDs
|
||||
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||
# Subquery: calendar IDs belonging to this user
|
||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
|
||||
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
|
||||
# Get upcoming todos with due dates (today onward only, scoped to user)
|
||||
todos_query = select(Todo).where(
|
||||
Todo.user_id == current_user.id,
|
||||
Todo.completed == False,
|
||||
Todo.due_date.isnot(None),
|
||||
Todo.due_date >= overdue_floor,
|
||||
Todo.due_date >= today,
|
||||
Todo.due_date <= cutoff_date
|
||||
)
|
||||
todos_result = await db.execute(todos_query)
|
||||
todos = todos_result.scalars().all()
|
||||
|
||||
# Get upcoming events (from today onward, exclude parent templates, scoped to user's calendars)
|
||||
events_query = select(CalendarEvent).where(
|
||||
or_(
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||
),
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.start_datetime >= today_start,
|
||||
CalendarEvent.start_datetime <= cutoff_datetime,
|
||||
_not_parent_template,
|
||||
)
|
||||
events_result = await db.execute(events_query)
|
||||
events = events_result.scalars().all()
|
||||
|
||||
# Get upcoming reminders (today onward only, scoped to user)
|
||||
reminders_query = select(Reminder).where(
|
||||
Reminder.user_id == current_user.id,
|
||||
Reminder.is_active == True,
|
||||
Reminder.is_dismissed == False,
|
||||
Reminder.remind_at >= overdue_floor_dt,
|
||||
Reminder.remind_at >= today_start,
|
||||
Reminder.remind_at <= cutoff_datetime
|
||||
)
|
||||
|
||||
# Execute queries sequentially (single session cannot run concurrent queries)
|
||||
todos_result = await db.execute(todos_query)
|
||||
todos = todos_result.scalars().all()
|
||||
|
||||
events_result = await db.execute(events_query)
|
||||
events = events_result.scalars().all()
|
||||
|
||||
reminders_result = await db.execute(reminders_query)
|
||||
reminders = reminders_result.scalars().all()
|
||||
|
||||
# Build invitation lookup for upcoming events
|
||||
invited_event_id_set_up = set(invited_event_ids)
|
||||
upcoming_inv_map: dict[int, tuple[str, int | None]] = {}
|
||||
up_parent_ids = list({e.parent_event_id or e.id for e in events} & invited_event_id_set_up)
|
||||
if up_parent_ids:
|
||||
up_inv_result = await db.execute(
|
||||
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where(
|
||||
EventInvitation.user_id == current_user.id,
|
||||
EventInvitation.event_id.in_(up_parent_ids),
|
||||
)
|
||||
)
|
||||
for eid, status, disp_cal_id in up_inv_result.all():
|
||||
upcoming_inv_map[eid] = (status, disp_cal_id)
|
||||
|
||||
# Combine into unified list
|
||||
upcoming_items: List[Dict[str, Any]] = []
|
||||
|
||||
@ -263,39 +212,28 @@ async def get_upcoming(
|
||||
"date": todo.due_date.isoformat() if todo.due_date else None,
|
||||
"datetime": None,
|
||||
"priority": todo.priority,
|
||||
"category": todo.category,
|
||||
"is_overdue": todo.due_date < today if todo.due_date else False,
|
||||
"category": todo.category
|
||||
})
|
||||
|
||||
for event in events:
|
||||
end_dt = event.end_datetime
|
||||
parent_id = event.parent_event_id or event.id
|
||||
is_inv = parent_id in invited_event_id_set_up
|
||||
upcoming_items.append({
|
||||
"type": "event",
|
||||
"id": event.id,
|
||||
"title": event.title,
|
||||
"date": event.start_datetime.date().isoformat(),
|
||||
"datetime": event.start_datetime.isoformat(),
|
||||
"end_datetime": end_dt.isoformat() if end_dt else None,
|
||||
"all_day": event.all_day,
|
||||
"color": event.color,
|
||||
"is_starred": event.is_starred,
|
||||
"is_invited": is_inv,
|
||||
"invitation_status": upcoming_inv_map.get(parent_id, (None,))[0] if is_inv else None,
|
||||
"display_calendar_id": upcoming_inv_map.get(parent_id, (None, None))[1] if is_inv else None,
|
||||
"is_starred": event.is_starred
|
||||
})
|
||||
|
||||
for reminder in reminders:
|
||||
remind_at_date = reminder.remind_at.date() if reminder.remind_at else None
|
||||
upcoming_items.append({
|
||||
"type": "reminder",
|
||||
"id": reminder.id,
|
||||
"title": reminder.title,
|
||||
"date": remind_at_date.isoformat() if remind_at_date else None,
|
||||
"datetime": reminder.remind_at.isoformat() if reminder.remind_at else None,
|
||||
"snoozed_until": reminder.snoozed_until.isoformat() if reminder.snoozed_until else None,
|
||||
"is_overdue": remind_at_date < today if remind_at_date else False,
|
||||
"date": reminder.remind_at.date().isoformat(),
|
||||
"datetime": reminder.remind_at.isoformat()
|
||||
})
|
||||
|
||||
# Sort by date/datetime
|
||||
|
||||
@ -1,307 +0,0 @@
|
||||
"""
|
||||
Event invitation endpoints — invite users to events, respond, override per-occurrence, leave.
|
||||
|
||||
Two routers:
|
||||
- events_router: mounted at /api/events for POST/GET /{event_id}/invitations
|
||||
- router: mounted at /api/event-invitations for respond/override/delete/pending
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.event_invitation import EventInvitation
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from sqlalchemy.orm import selectinload
|
||||
from app.schemas.event_invitation import (
|
||||
EventInvitationCreate,
|
||||
EventInvitationRespond,
|
||||
EventInvitationOverrideCreate,
|
||||
UpdateCanModify,
|
||||
UpdateDisplayCalendar,
|
||||
)
|
||||
from app.services.calendar_sharing import get_accessible_calendar_ids, get_user_permission
|
||||
from app.services.event_invitation import (
|
||||
send_event_invitations,
|
||||
respond_to_invitation,
|
||||
override_occurrence_status,
|
||||
dismiss_invitation,
|
||||
dismiss_invitation_by_owner,
|
||||
get_event_invitations,
|
||||
get_pending_invitations,
|
||||
)
|
||||
|
||||
# Mounted at /api/events — event-scoped invitation endpoints
|
||||
events_router = APIRouter()
|
||||
|
||||
# Mounted at /api/event-invitations — invitation-scoped endpoints
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _get_event_with_access_check(
|
||||
db: AsyncSession, event_id: int, user_id: int
|
||||
) -> CalendarEvent:
|
||||
"""Fetch event and verify the user has access (owner, shared member, or invitee)."""
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
# Check calendar access
|
||||
perm = await get_user_permission(db, event.calendar_id, user_id)
|
||||
if perm is not None:
|
||||
return event
|
||||
|
||||
# Check if invitee (also check parent for recurring children)
|
||||
event_ids_to_check = [event_id]
|
||||
if event.parent_event_id:
|
||||
event_ids_to_check.append(event.parent_event_id)
|
||||
|
||||
inv_result = await db.execute(
|
||||
select(EventInvitation.id).where(
|
||||
EventInvitation.event_id.in_(event_ids_to_check),
|
||||
EventInvitation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if inv_result.first() is not None:
|
||||
return event
|
||||
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
|
||||
# ── Event-scoped endpoints (mounted at /api/events) ──
|
||||
|
||||
|
||||
@events_router.post("/{event_id}/invitations", status_code=201)
|
||||
async def invite_to_event(
|
||||
body: EventInvitationCreate,
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Invite connected users to an event. Requires event ownership or create_modify+ permission."""
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
# Permission check: owner or create_modify+
|
||||
perm = await get_user_permission(db, event.calendar_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
if perm not in ("owner", "create_modify", "full_access"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission")
|
||||
|
||||
# For recurring child events, invite to the parent (series)
|
||||
target_event_id = event.parent_event_id if event.parent_event_id else event_id
|
||||
|
||||
invitations = await send_event_invitations(
|
||||
db=db,
|
||||
event_id=target_event_id,
|
||||
user_ids=body.user_ids,
|
||||
invited_by=current_user.id,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"invited": len(invitations), "event_id": target_event_id}
|
||||
|
||||
|
||||
@events_router.get("/{event_id}/invitations")
|
||||
async def list_event_invitations(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all invitees and their statuses for an event."""
|
||||
event = await _get_event_with_access_check(db, event_id, current_user.id)
|
||||
|
||||
# For recurring children, also fetch parent's invitations
|
||||
target_id = event.parent_event_id if event.parent_event_id else event_id
|
||||
invitations = await get_event_invitations(db, target_id)
|
||||
return invitations
|
||||
|
||||
|
||||
# ── Invitation-scoped endpoints (mounted at /api/event-invitations) ──
|
||||
|
||||
|
||||
@router.get("/pending")
|
||||
async def my_pending_invitations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get all pending event invitations for the current user."""
|
||||
return await get_pending_invitations(db, current_user.id)
|
||||
|
||||
|
||||
@router.put("/{invitation_id}/respond")
|
||||
async def respond_invitation(
|
||||
body: EventInvitationRespond,
|
||||
invitation_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Accept, tentative, or decline an event invitation."""
|
||||
invitation = await respond_to_invitation(
|
||||
db=db,
|
||||
invitation_id=invitation_id,
|
||||
user_id=current_user.id,
|
||||
status=body.status,
|
||||
)
|
||||
|
||||
# Build response before commit (ORM objects expire after commit)
|
||||
response_data = {
|
||||
"id": invitation.id,
|
||||
"event_id": invitation.event_id,
|
||||
"status": invitation.status,
|
||||
"responded_at": invitation.responded_at,
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
return response_data
|
||||
|
||||
|
||||
@router.put("/{invitation_id}/respond/{occurrence_id}")
|
||||
async def override_occurrence(
|
||||
body: EventInvitationOverrideCreate,
|
||||
invitation_id: int = Path(ge=1, le=2147483647),
|
||||
occurrence_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Override invitation status for a specific occurrence of a recurring event."""
|
||||
override = await override_occurrence_status(
|
||||
db=db,
|
||||
invitation_id=invitation_id,
|
||||
occurrence_id=occurrence_id,
|
||||
user_id=current_user.id,
|
||||
status=body.status,
|
||||
)
|
||||
|
||||
response_data = {
|
||||
"invitation_id": override.invitation_id,
|
||||
"occurrence_id": override.occurrence_id,
|
||||
"status": override.status,
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
return response_data
|
||||
|
||||
|
||||
@router.put("/{invitation_id}/display-calendar")
|
||||
async def update_display_calendar(
|
||||
body: UpdateDisplayCalendar,
|
||||
invitation_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Change the display calendar for an accepted/tentative invitation."""
|
||||
inv_result = await db.execute(
|
||||
select(EventInvitation).where(
|
||||
EventInvitation.id == invitation_id,
|
||||
EventInvitation.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
invitation = inv_result.scalar_one_or_none()
|
||||
if not invitation:
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
|
||||
if invitation.status not in ("accepted", "tentative"):
|
||||
raise HTTPException(status_code=400, detail="Can only set display calendar for accepted or tentative invitations")
|
||||
|
||||
# Verify calendar is accessible to this user
|
||||
accessible_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||
if body.calendar_id not in accessible_ids:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
|
||||
invitation.display_calendar_id = body.calendar_id
|
||||
|
||||
# Extract response before commit (ORM expiry rule)
|
||||
response_data = {
|
||||
"id": invitation.id,
|
||||
"event_id": invitation.event_id,
|
||||
"display_calendar_id": invitation.display_calendar_id,
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
return response_data
|
||||
|
||||
|
||||
@router.put("/{invitation_id}/can-modify")
|
||||
async def update_can_modify(
|
||||
body: UpdateCanModify,
|
||||
invitation_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Toggle can_modify on an invitation. Owner-only."""
|
||||
inv_result = await db.execute(
|
||||
select(EventInvitation)
|
||||
.options(selectinload(EventInvitation.event))
|
||||
.where(EventInvitation.id == invitation_id)
|
||||
)
|
||||
invitation = inv_result.scalar_one_or_none()
|
||||
if not invitation:
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
|
||||
# Only the calendar owner can toggle can_modify (W-03)
|
||||
perm = await get_user_permission(db, invitation.event.calendar_id, current_user.id)
|
||||
if perm != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can grant edit access")
|
||||
|
||||
invitation.can_modify = body.can_modify
|
||||
|
||||
response_data = {
|
||||
"id": invitation.id,
|
||||
"event_id": invitation.event_id,
|
||||
"can_modify": invitation.can_modify,
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
return response_data
|
||||
|
||||
|
||||
@router.delete("/{invitation_id}", status_code=204)
|
||||
async def leave_or_revoke_invitation(
|
||||
invitation_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Leave an event (invitee) or revoke an invitation (event owner).
|
||||
Invitees can only delete their own invitations.
|
||||
Event owners can delete any invitation for their events.
|
||||
"""
|
||||
inv_result = await db.execute(
|
||||
select(EventInvitation).where(EventInvitation.id == invitation_id)
|
||||
)
|
||||
invitation = inv_result.scalar_one_or_none()
|
||||
if not invitation:
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
|
||||
if invitation.user_id == current_user.id:
|
||||
# Invitee leaving
|
||||
await dismiss_invitation(db, invitation_id, current_user.id)
|
||||
else:
|
||||
# Check if current user is the event owner
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == invitation.event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
perm = await get_user_permission(db, event.calendar_id, current_user.id)
|
||||
if perm != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the event owner can revoke invitations")
|
||||
|
||||
await dismiss_invitation_by_owner(db, invitation_id)
|
||||
|
||||
await db.commit()
|
||||
return None
|
||||
@ -1,7 +1,7 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import false as sa_false, select, delete, or_
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import Optional, List, Any, Literal
|
||||
|
||||
@ -19,38 +19,13 @@ from app.schemas.calendar_event import (
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.recurrence import generate_occurrences
|
||||
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, get_accessible_event_scope, require_permission
|
||||
from app.services.event_invitation import get_invited_event_ids, get_invitation_overrides_for_user
|
||||
from app.models.event_invitation import EventInvitation
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _event_to_dict(
|
||||
event: CalendarEvent,
|
||||
is_invited: bool = False,
|
||||
invitation_status: str | None = None,
|
||||
invitation_id: int | None = None,
|
||||
display_calendar_id: int | None = None,
|
||||
display_calendar_name: str | None = None,
|
||||
display_calendar_color: str | None = None,
|
||||
can_modify: bool = False,
|
||||
has_active_invitees: bool = False,
|
||||
) -> dict:
|
||||
def _event_to_dict(event: CalendarEvent) -> dict:
|
||||
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
|
||||
# For invited events: use display calendar if set, otherwise fallback to "Invited"/gray
|
||||
if is_invited:
|
||||
if display_calendar_name:
|
||||
cal_name = display_calendar_name
|
||||
cal_color = display_calendar_color or "#6B7280"
|
||||
else:
|
||||
cal_name = "Invited"
|
||||
cal_color = "#6B7280"
|
||||
else:
|
||||
cal_name = event.calendar.name if event.calendar else ""
|
||||
cal_color = event.calendar.color if event.calendar else ""
|
||||
|
||||
d = {
|
||||
return {
|
||||
"id": event.id,
|
||||
"title": event.title,
|
||||
"description": event.description,
|
||||
@ -62,22 +37,15 @@ def _event_to_dict(
|
||||
"recurrence_rule": event.recurrence_rule,
|
||||
"is_starred": event.is_starred,
|
||||
"calendar_id": event.calendar_id,
|
||||
"calendar_name": cal_name,
|
||||
"calendar_color": cal_color,
|
||||
"calendar_name": event.calendar.name if event.calendar else "",
|
||||
"calendar_color": event.calendar.color if event.calendar else "",
|
||||
"is_virtual": False,
|
||||
"parent_event_id": event.parent_event_id,
|
||||
"is_recurring": event.is_recurring,
|
||||
"original_start": event.original_start,
|
||||
"created_at": event.created_at,
|
||||
"updated_at": event.updated_at,
|
||||
"is_invited": is_invited,
|
||||
"invitation_status": invitation_status,
|
||||
"invitation_id": invitation_id,
|
||||
"display_calendar_id": display_calendar_id,
|
||||
"can_modify": can_modify,
|
||||
"has_active_invitees": has_active_invitees,
|
||||
}
|
||||
return d
|
||||
|
||||
|
||||
def _birthday_events_for_range(
|
||||
@ -174,20 +142,13 @@ async def get_events(
|
||||
recurrence_rule IS NOT NULL) are excluded — their materialised children
|
||||
are what get displayed on the calendar.
|
||||
"""
|
||||
# Scope events through calendar ownership + shared memberships + invitations
|
||||
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||
|
||||
# Scope events through calendar ownership
|
||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
|
||||
query = (
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(
|
||||
or_(
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||
)
|
||||
)
|
||||
.where(CalendarEvent.calendar_id.in_(user_calendar_ids))
|
||||
)
|
||||
|
||||
# Exclude parent template rows — they are not directly rendered
|
||||
@ -204,93 +165,12 @@ async def get_events(
|
||||
if end:
|
||||
query = query.where(CalendarEvent.start_datetime <= end)
|
||||
|
||||
query = query.order_by(CalendarEvent.start_datetime.asc()).limit(2000)
|
||||
query = query.order_by(CalendarEvent.start_datetime.asc())
|
||||
|
||||
result = await db.execute(query)
|
||||
events = result.scalars().all()
|
||||
|
||||
# Build invitation lookup for the current user
|
||||
invited_event_id_set = set(invited_event_ids)
|
||||
invitation_map: dict[int, tuple[str, int, int | None, bool]] = {} # event_id -> (status, invitation_id, display_calendar_id, can_modify)
|
||||
if invited_event_ids:
|
||||
inv_result = await db.execute(
|
||||
select(
|
||||
EventInvitation.event_id,
|
||||
EventInvitation.status,
|
||||
EventInvitation.id,
|
||||
EventInvitation.display_calendar_id,
|
||||
EventInvitation.can_modify,
|
||||
).where(
|
||||
EventInvitation.user_id == current_user.id,
|
||||
EventInvitation.event_id.in_(invited_event_ids),
|
||||
)
|
||||
)
|
||||
for eid, status, inv_id, disp_cal_id, cm in inv_result.all():
|
||||
invitation_map[eid] = (status, inv_id, disp_cal_id, cm)
|
||||
|
||||
# Batch-fetch display calendars for invited events
|
||||
display_cal_ids = {t[2] for t in invitation_map.values() if t[2] is not None}
|
||||
display_cal_map: dict[int, dict] = {} # cal_id -> {name, color}
|
||||
if display_cal_ids:
|
||||
cal_result = await db.execute(
|
||||
select(Calendar.id, Calendar.name, Calendar.color).where(
|
||||
Calendar.id.in_(display_cal_ids),
|
||||
Calendar.id.in_(all_calendar_ids),
|
||||
)
|
||||
)
|
||||
for cal_id, cal_name, cal_color in cal_result.all():
|
||||
display_cal_map[cal_id] = {"name": cal_name, "color": cal_color}
|
||||
|
||||
# Get per-occurrence overrides for invited events
|
||||
all_event_ids = [e.id for e in events]
|
||||
override_map = await get_invitation_overrides_for_user(db, current_user.id, all_event_ids)
|
||||
|
||||
# Batch-fetch event IDs that have accepted/tentative invitees (for owner's shared icon)
|
||||
active_invitee_set: set[int] = set()
|
||||
if all_event_ids:
|
||||
active_inv_result = await db.execute(
|
||||
select(EventInvitation.event_id).where(
|
||||
EventInvitation.event_id.in_(all_event_ids),
|
||||
EventInvitation.status.in_(["accepted", "tentative"]),
|
||||
).distinct()
|
||||
)
|
||||
active_invitee_set = {r[0] for r in active_inv_result.all()}
|
||||
# Also mark parent events: if a parent has active invitees, all its children should show the icon
|
||||
parent_ids = {e.parent_event_id for e in events if e.parent_event_id and e.parent_event_id in active_invitee_set}
|
||||
if parent_ids:
|
||||
active_invitee_set.update(e.id for e in events if e.parent_event_id in active_invitee_set)
|
||||
|
||||
response: List[dict] = []
|
||||
for e in events:
|
||||
# Determine if this event is from an invitation
|
||||
parent_id = e.parent_event_id or e.id
|
||||
is_invited = parent_id in invited_event_id_set
|
||||
inv_status = None
|
||||
inv_id = None
|
||||
disp_cal_id = None
|
||||
disp_cal_name = None
|
||||
disp_cal_color = None
|
||||
inv_can_modify = False
|
||||
if is_invited and parent_id in invitation_map:
|
||||
inv_status, inv_id, disp_cal_id, inv_can_modify = invitation_map[parent_id]
|
||||
# Check for per-occurrence override
|
||||
if e.id in override_map:
|
||||
inv_status = override_map[e.id]
|
||||
# Resolve display calendar info
|
||||
if disp_cal_id and disp_cal_id in display_cal_map:
|
||||
disp_cal_name = display_cal_map[disp_cal_id]["name"]
|
||||
disp_cal_color = display_cal_map[disp_cal_id]["color"]
|
||||
response.append(_event_to_dict(
|
||||
e,
|
||||
is_invited=is_invited,
|
||||
invitation_status=inv_status,
|
||||
invitation_id=inv_id,
|
||||
display_calendar_id=disp_cal_id,
|
||||
display_calendar_name=disp_cal_name,
|
||||
display_calendar_color=disp_cal_color,
|
||||
can_modify=inv_can_modify,
|
||||
has_active_invitees=(parent_id in active_invitee_set or e.id in active_invitee_set),
|
||||
))
|
||||
response: List[dict] = [_event_to_dict(e) for e in events]
|
||||
|
||||
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
|
||||
bday_result = await db.execute(
|
||||
@ -339,13 +219,8 @@ async def create_event(
|
||||
if not data.get("calendar_id"):
|
||||
data["calendar_id"] = await _get_default_calendar_id(db, current_user.id)
|
||||
else:
|
||||
# SEC-04: verify ownership OR shared calendar permission
|
||||
cal_ownership_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == data["calendar_id"], Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_ownership_result.scalar_one_or_none():
|
||||
# Not owned — check shared calendar permission
|
||||
await require_permission(db, data["calendar_id"], current_user.id, "create_modify")
|
||||
# SEC-04: verify the target calendar belongs to the requesting user
|
||||
await _verify_calendar_ownership(db, data["calendar_id"], current_user.id)
|
||||
|
||||
# Serialize RecurrenceRule object to JSON string for DB storage
|
||||
# Exclude None values so defaults in recurrence service work correctly
|
||||
@ -354,12 +229,13 @@ async def create_event(
|
||||
|
||||
if rule_json:
|
||||
# Parent template: is_recurring=True, no parent_event_id
|
||||
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True, updated_by=current_user.id)
|
||||
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True)
|
||||
db.add(parent)
|
||||
await db.flush() # assign parent.id before generating children
|
||||
|
||||
children = generate_occurrences(parent)
|
||||
db.add_all(children)
|
||||
for child in children:
|
||||
db.add(child)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@ -382,7 +258,7 @@ async def create_event(
|
||||
return result.scalar_one()
|
||||
|
||||
else:
|
||||
new_event = CalendarEvent(**data, recurrence_rule=None, updated_by=current_user.id)
|
||||
new_event = CalendarEvent(**data, recurrence_rule=None)
|
||||
db.add(new_event)
|
||||
await db.commit()
|
||||
|
||||
@ -400,20 +276,14 @@ async def get_event(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
|
||||
invited_set = set(invited_event_ids)
|
||||
|
||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(
|
||||
CalendarEvent.id == event_id,
|
||||
or_(
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
|
||||
),
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
@ -431,79 +301,23 @@ async def update_event(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
|
||||
# Event invitees can VIEW events but must NOT be able to edit them
|
||||
# UNLESS they have can_modify=True (checked in fallback path below).
|
||||
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||
is_invited_editor = False
|
||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(
|
||||
CalendarEvent.id == event_id,
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
|
||||
if not event:
|
||||
# Fallback: check if user has can_modify invitation for this event
|
||||
# Must check both event_id (direct) and parent_event_id (recurring child)
|
||||
# because invitations are stored against the parent event
|
||||
target_event_result = await db.execute(
|
||||
select(CalendarEvent.parent_event_id).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
target_row = target_event_result.one_or_none()
|
||||
if not target_row:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
candidate_ids = [event_id]
|
||||
if target_row[0] is not None:
|
||||
candidate_ids.append(target_row[0])
|
||||
|
||||
inv_result = await db.execute(
|
||||
select(EventInvitation).where(
|
||||
EventInvitation.event_id.in_(candidate_ids),
|
||||
EventInvitation.user_id == current_user.id,
|
||||
EventInvitation.can_modify == True,
|
||||
EventInvitation.status.in_(["accepted", "tentative"]),
|
||||
)
|
||||
)
|
||||
inv = inv_result.scalar_one_or_none()
|
||||
if not inv:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
|
||||
# Load the event directly (bypassing calendar filter)
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
is_invited_editor = True
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
|
||||
update_data = event_update.model_dump(exclude_unset=True)
|
||||
|
||||
if is_invited_editor:
|
||||
# Invited editor restrictions — enforce BEFORE any data mutation
|
||||
# Field allowlist: invited editors can only modify event content, not structure
|
||||
INVITED_EDITOR_ALLOWED = {"title", "description", "start_datetime", "end_datetime", "all_day", "color", "edit_scope", "location_id"}
|
||||
disallowed = set(update_data.keys()) - INVITED_EDITOR_ALLOWED
|
||||
if disallowed:
|
||||
raise HTTPException(status_code=403, detail="Invited editors cannot modify: " + ", ".join(sorted(disallowed)))
|
||||
scope_peek = update_data.get("edit_scope")
|
||||
# Block all bulk-scope edits on recurring events (C-01/F-01)
|
||||
if event.is_recurring and scope_peek != "this":
|
||||
raise HTTPException(status_code=403, detail="Invited editors can only edit individual occurrences")
|
||||
else:
|
||||
# Standard calendar-access path: require create_modify+ permission
|
||||
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
|
||||
|
||||
# Lock check applies to both paths (uses owner's calendar_id)
|
||||
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
|
||||
|
||||
# Extract scope before applying fields to the model
|
||||
scope: Optional[str] = update_data.pop("edit_scope", None)
|
||||
|
||||
@ -512,24 +326,9 @@ async def update_event(
|
||||
if rule_obj is not None:
|
||||
update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None
|
||||
|
||||
if not is_invited_editor:
|
||||
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
|
||||
# Only verify ownership when the calendar is actually changing — members submitting
|
||||
# an unchanged calendar_id must not be rejected just because they aren't the owner.
|
||||
if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id:
|
||||
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id)
|
||||
|
||||
# M-01: Block non-owners from moving events off shared calendars
|
||||
if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id:
|
||||
source_cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == event.calendar_id)
|
||||
)
|
||||
source_cal = source_cal_result.scalar_one_or_none()
|
||||
if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only the calendar owner can move events between calendars",
|
||||
)
|
||||
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
|
||||
if "calendar_id" in update_data and update_data["calendar_id"] is not None:
|
||||
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id)
|
||||
|
||||
start = update_data.get("start_datetime", event.start_datetime)
|
||||
end_dt = update_data.get("end_datetime", event.end_datetime)
|
||||
@ -543,7 +342,6 @@ async def update_event(
|
||||
# Detach from parent so it's an independent event going forward
|
||||
event.parent_event_id = None
|
||||
event.is_recurring = False
|
||||
event.updated_by = current_user.id
|
||||
await db.commit()
|
||||
|
||||
elif scope == "this_and_future":
|
||||
@ -573,7 +371,6 @@ async def update_event(
|
||||
event.parent_event_id = None
|
||||
event.is_recurring = True
|
||||
event.original_start = None
|
||||
event.updated_by = current_user.id
|
||||
|
||||
# Inherit parent's recurrence_rule if none was provided in update
|
||||
if not event.recurrence_rule and parent_rule:
|
||||
@ -583,12 +380,12 @@ async def update_event(
|
||||
if event.recurrence_rule:
|
||||
await db.flush()
|
||||
children = generate_occurrences(event)
|
||||
db.add_all(children)
|
||||
for child in children:
|
||||
db.add(child)
|
||||
else:
|
||||
# This IS a parent — update it and regenerate all children
|
||||
for key, value in update_data.items():
|
||||
setattr(event, key, value)
|
||||
event.updated_by = current_user.id
|
||||
|
||||
# Delete all existing children and regenerate
|
||||
if event.recurrence_rule:
|
||||
@ -599,7 +396,8 @@ async def update_event(
|
||||
)
|
||||
await db.flush()
|
||||
children = generate_occurrences(event)
|
||||
db.add_all(children)
|
||||
for child in children:
|
||||
db.add(child)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@ -607,7 +405,6 @@ async def update_event(
|
||||
# No scope — plain update (non-recurring events or full-series metadata)
|
||||
for key, value in update_data.items():
|
||||
setattr(event, key, value)
|
||||
event.updated_by = current_user.id
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
@ -629,15 +426,12 @@ async def delete_event(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
|
||||
# Event invitees can VIEW events but must NOT be able to delete them.
|
||||
# Invitees use DELETE /api/event-invitations/{id} to leave instead.
|
||||
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
|
||||
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEvent).where(
|
||||
CalendarEvent.id == event_id,
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
@ -645,10 +439,6 @@ async def delete_event(
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
|
||||
# Shared calendar: require full_access+ and check lock
|
||||
await require_permission(db, event.calendar_id, current_user.id, "full_access")
|
||||
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
|
||||
|
||||
if scope == "this":
|
||||
# Delete just this one occurrence
|
||||
await db.delete(event)
|
||||
@ -658,13 +448,20 @@ async def delete_event(
|
||||
this_original_start = event.original_start or event.start_datetime
|
||||
|
||||
if parent_id is not None:
|
||||
# Delete this + all future siblings (original_start is always set on children)
|
||||
# Delete this + all future siblings
|
||||
await db.execute(
|
||||
delete(CalendarEvent).where(
|
||||
CalendarEvent.parent_event_id == parent_id,
|
||||
CalendarEvent.original_start >= this_original_start,
|
||||
)
|
||||
)
|
||||
# Ensure the target event itself is deleted (edge case: original_start fallback mismatch)
|
||||
existing = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
target = existing.scalar_one_or_none()
|
||||
if target:
|
||||
await db.delete(target)
|
||||
else:
|
||||
# This event IS the parent — delete it and all children (CASCADE handles children)
|
||||
await db.delete(event)
|
||||
|
||||
@ -8,7 +8,6 @@ import json
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.location import Location
|
||||
@ -58,7 +57,7 @@ async def search_locations(
|
||||
# Nominatim proxy search (run in thread executor to avoid blocking event loop)
|
||||
def _fetch_nominatim() -> list:
|
||||
encoded_q = urllib.parse.quote(q)
|
||||
url = f"https://nominatim.openstreetmap.org/search?q={encoded_q}&format=json&addressdetails=1&limit=5"
|
||||
url = f"https://nominatim.openstreetmap.org/search?q={encoded_q}&format=json&limit=5"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "UMBRA-LifeManager/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
@ -68,37 +67,9 @@ async def search_locations(
|
||||
osm_data = await loop.run_in_executor(None, _fetch_nominatim)
|
||||
for item in osm_data:
|
||||
display_name = item.get("display_name", "")
|
||||
addr = item.get("address", {})
|
||||
house_number = addr.get("house_number", "")
|
||||
road = addr.get("road", "")
|
||||
|
||||
# If Nominatim didn't return a house_number but the user's
|
||||
# query starts with one, preserve it from the original query.
|
||||
if not house_number and road:
|
||||
m = re.match(r"^(\d+[\w/-]*)\s+", q.strip())
|
||||
if m:
|
||||
house_number = m.group(1)
|
||||
|
||||
# Name = place/building label from Nominatim (e.g. "The Quadrant").
|
||||
# Falls back to street address if no distinct place name exists.
|
||||
osm_name = item.get("name", "")
|
||||
street = f"{house_number} {road}" if house_number and road else road
|
||||
if osm_name and osm_name != road:
|
||||
name = osm_name
|
||||
elif street:
|
||||
name = street
|
||||
else:
|
||||
name = display_name.split(",", 1)[0].strip()
|
||||
|
||||
# Address = full street address with suburb/state/postcode.
|
||||
addr_parts = []
|
||||
if street:
|
||||
addr_parts.append(street)
|
||||
for key in ("suburb", "city", "state", "postcode", "country"):
|
||||
val = addr.get(key, "")
|
||||
if val:
|
||||
addr_parts.append(val)
|
||||
address = ", ".join(addr_parts) if addr_parts else display_name
|
||||
name_parts = display_name.split(",", 1)
|
||||
name = name_parts[0].strip()
|
||||
address = name_parts[1].strip() if len(name_parts) > 1 else display_name
|
||||
results.append(
|
||||
LocationSearchResult(
|
||||
source="nominatim",
|
||||
|
||||
@ -1,675 +0,0 @@
|
||||
"""
|
||||
Passkey (WebAuthn/FIDO2) router.
|
||||
|
||||
Endpoints (all under /api/auth/passkeys — registered in main.py):
|
||||
|
||||
POST /register/begin — Start passkey registration (auth + password required)
|
||||
POST /register/complete — Complete registration ceremony (auth required)
|
||||
POST /login/begin — Start passkey authentication (public, CSRF-exempt)
|
||||
POST /login/complete — Complete authentication ceremony (public, CSRF-exempt)
|
||||
GET / — List registered passkeys (auth required)
|
||||
DELETE /{id} — Remove a passkey (auth + password required)
|
||||
|
||||
Security:
|
||||
- Challenge tokens signed with itsdangerous (60s TTL, single-use nonce)
|
||||
- Registration binds challenge to user_id, validated on complete (S-01)
|
||||
- Registration requires password re-entry (V-02)
|
||||
- Generic 401 on all auth failures (no credential enumeration)
|
||||
- Constant-time response on login/begin (V-03)
|
||||
- Failed passkey logins increment shared lockout counter
|
||||
- Passkey login bypasses TOTP (passkey IS 2FA)
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.passkey_credential import PasskeyCredential
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.services.audit import get_client_ip, log_audit_event
|
||||
from app.services.auth import averify_password_with_upgrade, verify_session_token
|
||||
from app.services.session import (
|
||||
create_db_session,
|
||||
set_session_cookie,
|
||||
check_account_lockout,
|
||||
record_failed_login,
|
||||
record_successful_login,
|
||||
)
|
||||
from app.services.passkey import (
|
||||
create_challenge_token,
|
||||
verify_challenge_token,
|
||||
build_registration_options,
|
||||
verify_registration as verify_registration_response_svc,
|
||||
build_authentication_options,
|
||||
verify_authentication as verify_authentication_response_svc,
|
||||
extract_credential_raw_id,
|
||||
)
|
||||
from app.models.session import UserSession
|
||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request/Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PasskeyRegisterBeginRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
password: str = Field(max_length=128)
|
||||
|
||||
|
||||
class PasskeyRegisterCompleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
credential: str = Field(max_length=8192)
|
||||
challenge_token: str = Field(max_length=2048)
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class PasskeyLoginBeginRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
username: str | None = Field(None, max_length=50)
|
||||
|
||||
|
||||
class PasskeyLoginCompleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
credential: str = Field(max_length=8192)
|
||||
challenge_token: str = Field(max_length=2048)
|
||||
unlock: bool = False
|
||||
|
||||
|
||||
class PasskeyDeleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
password: str = Field(max_length=128)
|
||||
|
||||
|
||||
class PasswordlessEnableRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
password: str = Field(max_length=128)
|
||||
|
||||
|
||||
class PasswordlessDisableRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
credential: str = Field(max_length=8192)
|
||||
challenge_token: str = Field(max_length=2048)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration endpoints (authenticated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/register/begin")
|
||||
async def passkey_register_begin(
|
||||
data: PasskeyRegisterBeginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Start passkey registration. Requires password re-entry (V-02)."""
|
||||
# V-02: Verify password before allowing registration
|
||||
valid, new_hash = await averify_password_with_upgrade(
|
||||
data.password, current_user.password_hash
|
||||
)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
if new_hash:
|
||||
current_user.password_hash = new_hash
|
||||
await db.commit()
|
||||
|
||||
# Load existing credential IDs for exclusion
|
||||
result = await db.execute(
|
||||
select(PasskeyCredential.credential_id).where(
|
||||
PasskeyCredential.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
existing_ids = [
|
||||
base64url_to_bytes(row[0]) for row in result.all()
|
||||
]
|
||||
|
||||
options_json, challenge = build_registration_options(
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
existing_credential_ids=existing_ids,
|
||||
)
|
||||
token = create_challenge_token(challenge, user_id=current_user.id)
|
||||
|
||||
return {
|
||||
"options": json.loads(options_json),
|
||||
"challenge_token": token,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register/complete")
|
||||
async def passkey_register_complete(
|
||||
data: PasskeyRegisterCompleteRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Complete passkey registration ceremony."""
|
||||
# Verify challenge token — cross-check user binding (S-01) + single-use nonce (V-01)
|
||||
challenge = verify_challenge_token(
|
||||
data.challenge_token, expected_user_id=current_user.id
|
||||
)
|
||||
if challenge is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired challenge")
|
||||
|
||||
try:
|
||||
verified = verify_registration_response_svc(
|
||||
credential_json=data.credential,
|
||||
challenge=challenge,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Passkey registration verification failed: %s", e)
|
||||
raise HTTPException(status_code=400, detail="Registration verification failed")
|
||||
|
||||
# Store credential
|
||||
credential_id_b64 = bytes_to_base64url(verified.credential_id)
|
||||
|
||||
# Check for duplicate (race condition safety)
|
||||
existing = await db.execute(
|
||||
select(PasskeyCredential).where(
|
||||
PasskeyCredential.credential_id == credential_id_b64
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Credential already registered")
|
||||
|
||||
# Extract transport hints if available
|
||||
transports_json = None
|
||||
if hasattr(verified, 'credential_device_type'):
|
||||
pass # py_webauthn doesn't expose transports on VerifiedRegistration
|
||||
# Transports come from the browser response — parse from credential JSON
|
||||
try:
|
||||
cred_data = json.loads(data.credential)
|
||||
if "response" in cred_data and "transports" in cred_data["response"]:
|
||||
transports_json = json.dumps(cred_data["response"]["transports"])
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Determine backup state from py_webauthn flags
|
||||
backed_up = getattr(verified, 'credential_backed_up', False)
|
||||
|
||||
new_credential = PasskeyCredential(
|
||||
user_id=current_user.id,
|
||||
credential_id=credential_id_b64,
|
||||
public_key=bytes_to_base64url(verified.credential_public_key),
|
||||
sign_count=verified.sign_count,
|
||||
name=data.name,
|
||||
transports=transports_json,
|
||||
backed_up=backed_up,
|
||||
)
|
||||
db.add(new_credential)
|
||||
|
||||
# B-02: If user has mfa_enforce_pending, clear it (passkey = MFA)
|
||||
if current_user.mfa_enforce_pending:
|
||||
current_user.mfa_enforce_pending = False
|
||||
|
||||
# Extract response data BEFORE commit (ORM expiry rule)
|
||||
response_data = {
|
||||
"id": None, # will be set after flush
|
||||
"name": new_credential.name,
|
||||
"created_at": None,
|
||||
"backed_up": backed_up,
|
||||
}
|
||||
|
||||
await db.flush()
|
||||
response_data["id"] = new_credential.id
|
||||
response_data["created_at"] = str(new_credential.created_at) if new_credential.created_at else None
|
||||
|
||||
await log_audit_event(
|
||||
db, action="passkey.registered", actor_id=current_user.id,
|
||||
detail={"credential_name": data.name},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authentication endpoints (unauthenticated — CSRF-exempt)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/login/begin")
|
||||
async def passkey_login_begin(
|
||||
data: PasskeyLoginBeginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Start passkey authentication. CSRF-exempt, public endpoint."""
|
||||
credential_data = None
|
||||
|
||||
if data.username:
|
||||
# Look up user's credentials for allowCredentials
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == data.username.lower().strip())
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
cred_result = await db.execute(
|
||||
select(
|
||||
PasskeyCredential.credential_id,
|
||||
PasskeyCredential.transports,
|
||||
).where(PasskeyCredential.user_id == user.id)
|
||||
)
|
||||
rows = cred_result.all()
|
||||
if rows:
|
||||
credential_data = []
|
||||
for row in rows:
|
||||
cid_bytes = base64url_to_bytes(row[0])
|
||||
transports = json.loads(row[1]) if row[1] else None
|
||||
credential_data.append((cid_bytes, transports))
|
||||
else:
|
||||
# F-01: User not found — run a no-op DB query to equalize timing with
|
||||
# the credential fetch that executes for existing users. Without this,
|
||||
# the absence of the second query makes the "no user" path measurably
|
||||
# faster, leaking whether the username exists.
|
||||
await db.execute(
|
||||
select(PasskeyCredential.credential_id).where(
|
||||
PasskeyCredential.user_id == 0
|
||||
).limit(1)
|
||||
)
|
||||
|
||||
# V-03: Generate options regardless of whether user exists or has passkeys.
|
||||
# Identical response shape prevents timing enumeration.
|
||||
options_json, challenge = build_authentication_options(
|
||||
credential_ids_and_transports=credential_data,
|
||||
)
|
||||
token = create_challenge_token(challenge)
|
||||
|
||||
return {
|
||||
"options": json.loads(options_json),
|
||||
"challenge_token": token,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login/complete")
|
||||
async def passkey_login_complete(
|
||||
data: PasskeyLoginCompleteRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Complete passkey authentication. CSRF-exempt, public endpoint."""
|
||||
# Verify challenge token (60s TTL, single-use nonce V-01)
|
||||
challenge = verify_challenge_token(data.challenge_token)
|
||||
if challenge is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Parse credential_id from browser response (S-02: shared helper)
|
||||
raw_id_b64 = extract_credential_raw_id(data.credential)
|
||||
if not raw_id_b64:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Look up credential + user in a single JOIN query (W-1 perf fix)
|
||||
result = await db.execute(
|
||||
select(PasskeyCredential, User)
|
||||
.join(User, User.id == PasskeyCredential.user_id)
|
||||
.where(PasskeyCredential.credential_id == raw_id_b64)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
credential, user = row.tuple()
|
||||
|
||||
# Check account lockout (C-03)
|
||||
await check_account_lockout(user)
|
||||
|
||||
# Check active status (C-03)
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Verify the authentication response
|
||||
try:
|
||||
verified = verify_authentication_response_svc(
|
||||
credential_json=data.credential,
|
||||
challenge=challenge,
|
||||
credential_public_key=base64url_to_bytes(credential.public_key),
|
||||
credential_current_sign_count=credential.sign_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Passkey authentication verification failed for user %s: %s", user.id, e)
|
||||
# Increment failed login counter (shared with password auth)
|
||||
remaining = await record_failed_login(db, user)
|
||||
await log_audit_event(
|
||||
db, action="passkey.login_failed", actor_id=user.id,
|
||||
detail={"reason": "verification_failed", "attempts_remaining": remaining},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
# Generic message for all failures — don't leak lockout state (C-02/F-02)
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Update sign count (log anomaly but don't fail — S-05)
|
||||
new_sign_count = verified.new_sign_count
|
||||
if new_sign_count < credential.sign_count and credential.sign_count > 0:
|
||||
logger.warning(
|
||||
"Sign count anomaly for user %s credential %s: expected >= %d, got %d",
|
||||
user.id, credential.id, credential.sign_count, new_sign_count,
|
||||
)
|
||||
await log_audit_event(
|
||||
db, action="passkey.sign_count_anomaly", actor_id=user.id,
|
||||
detail={
|
||||
"credential_id": credential.id,
|
||||
"expected": credential.sign_count,
|
||||
"received": new_sign_count,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
credential.sign_count = new_sign_count
|
||||
credential.last_used_at = datetime.now()
|
||||
|
||||
# Passkey unlock — re-authenticate into a locked session instead of creating a new one
|
||||
if data.unlock:
|
||||
session_cookie = request.cookies.get("session")
|
||||
payload = verify_session_token(session_cookie) if session_cookie else None
|
||||
if not payload or payload.get("uid") != user.id:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
sess_result = await db.execute(
|
||||
select(UserSession).where(
|
||||
UserSession.id == payload["sid"],
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.revoked == False,
|
||||
)
|
||||
)
|
||||
db_sess = sess_result.scalar_one_or_none()
|
||||
if not db_sess:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
db_sess.is_locked = False
|
||||
db_sess.locked_at = None
|
||||
# Reset failed login counter on successful passkey unlock (W-02)
|
||||
await record_successful_login(db, user)
|
||||
await log_audit_event(
|
||||
db, action="passkey.unlock_success", actor_id=user.id,
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
return {"unlocked": True}
|
||||
|
||||
# Record successful login
|
||||
await record_successful_login(db, user)
|
||||
|
||||
# Create session (shared service — enforces session cap)
|
||||
client_ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, client_ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
|
||||
# Handle special flags for passkey login
|
||||
result_data: dict = {"authenticated": True}
|
||||
# W-05: Passkey login auto-clears must_change_password — user can't provide
|
||||
# old password in the forced-change form since they authenticated via passkey.
|
||||
if user.must_change_password:
|
||||
user.must_change_password = False
|
||||
# Passkey satisfies MFA — if mfa_enforce_pending, clear it (before commit)
|
||||
if user.mfa_enforce_pending:
|
||||
user.mfa_enforce_pending = False
|
||||
|
||||
await log_audit_event(
|
||||
db, action="passkey.login_success", actor_id=user.id,
|
||||
detail={"credential_name": credential.name},
|
||||
ip=client_ip,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return result_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Passwordless toggle endpoints (authenticated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.put("/passwordless/enable")
|
||||
async def passwordless_enable(
|
||||
data: PasswordlessEnableRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Enable passwordless login for the current user.
|
||||
|
||||
Requirements:
|
||||
- System config must have allow_passwordless = True
|
||||
- User must have >= 2 registered passkeys
|
||||
- Password confirmation required
|
||||
"""
|
||||
# Verify password first
|
||||
valid, new_hash = await averify_password_with_upgrade(
|
||||
data.password, current_user.password_hash
|
||||
)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
if new_hash:
|
||||
current_user.password_hash = new_hash
|
||||
|
||||
# Check system config
|
||||
config_result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.id == 1)
|
||||
)
|
||||
config = config_result.scalar_one_or_none()
|
||||
if not config or not config.allow_passwordless:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Passwordless login is not enabled on this system",
|
||||
)
|
||||
|
||||
# Require >= 2 passkeys as safety net (can't get locked out)
|
||||
pk_count_result = await db.execute(
|
||||
select(func.count()).select_from(PasskeyCredential).where(
|
||||
PasskeyCredential.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
pk_count = pk_count_result.scalar_one()
|
||||
if pk_count < 2:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="At least 2 passkeys must be registered before enabling passwordless login",
|
||||
)
|
||||
|
||||
current_user.passwordless_enabled = True
|
||||
|
||||
await log_audit_event(
|
||||
db, action="passkey.passwordless_enabled", actor_id=current_user.id,
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"passwordless_enabled": True}
|
||||
|
||||
|
||||
@router.post("/passwordless/disable/begin")
|
||||
async def passwordless_disable_begin(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Begin the passkey authentication ceremony to disable passwordless login.
|
||||
Returns challenge options for the browser to present to the authenticator.
|
||||
"""
|
||||
# Load user's credentials for allowCredentials
|
||||
cred_result = await db.execute(
|
||||
select(
|
||||
PasskeyCredential.credential_id,
|
||||
PasskeyCredential.transports,
|
||||
).where(PasskeyCredential.user_id == current_user.id)
|
||||
)
|
||||
rows = cred_result.all()
|
||||
|
||||
credential_data = None
|
||||
if rows:
|
||||
credential_data = []
|
||||
for row in rows:
|
||||
cid_bytes = base64url_to_bytes(row[0])
|
||||
transports = json.loads(row[1]) if row[1] else None
|
||||
credential_data.append((cid_bytes, transports))
|
||||
|
||||
options_json, challenge = build_authentication_options(
|
||||
credential_ids_and_transports=credential_data,
|
||||
)
|
||||
# Bind challenge to this user so complete endpoint can cross-check
|
||||
token = create_challenge_token(challenge, user_id=current_user.id)
|
||||
|
||||
return {
|
||||
"options": json.loads(options_json),
|
||||
"challenge_token": token,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/passwordless/disable")
|
||||
async def passwordless_disable(
|
||||
data: PasswordlessDisableRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Complete passkey authentication to disable passwordless login.
|
||||
Verifies the credential belongs to the current user.
|
||||
"""
|
||||
# Verify challenge token — user-bound (single-use nonce V-01, cross-user binding S-01)
|
||||
challenge = verify_challenge_token(
|
||||
data.challenge_token, expected_user_id=current_user.id
|
||||
)
|
||||
if challenge is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired challenge")
|
||||
|
||||
# Parse rawId from credential (S-02: shared helper)
|
||||
raw_id_b64 = extract_credential_raw_id(data.credential)
|
||||
if not raw_id_b64:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Look up credential — verify ownership (IDOR prevention)
|
||||
cred_result = await db.execute(
|
||||
select(PasskeyCredential).where(
|
||||
PasskeyCredential.credential_id == raw_id_b64,
|
||||
PasskeyCredential.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
credential = cred_result.scalar_one_or_none()
|
||||
if not credential:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Verify the authentication response
|
||||
try:
|
||||
verified = verify_authentication_response_svc(
|
||||
credential_json=data.credential,
|
||||
challenge=challenge,
|
||||
credential_public_key=base64url_to_bytes(credential.public_key),
|
||||
credential_current_sign_count=credential.sign_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Passwordless disable: auth verification failed for user %s: %s",
|
||||
current_user.id, e,
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Update sign count
|
||||
credential.sign_count = verified.new_sign_count
|
||||
credential.last_used_at = datetime.now()
|
||||
|
||||
current_user.passwordless_enabled = False
|
||||
|
||||
await log_audit_event(
|
||||
db, action="passkey.passwordless_disabled", actor_id=current_user.id,
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"passwordless_enabled": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Management endpoints (authenticated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/")
|
||||
async def list_passkeys(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all passkeys for the current user."""
|
||||
result = await db.execute(
|
||||
select(PasskeyCredential)
|
||||
.where(PasskeyCredential.user_id == current_user.id)
|
||||
.order_by(PasskeyCredential.created_at.desc())
|
||||
)
|
||||
credentials = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"created_at": str(c.created_at) if c.created_at else None,
|
||||
"last_used_at": str(c.last_used_at) if c.last_used_at else None,
|
||||
"backed_up": c.backed_up,
|
||||
}
|
||||
for c in credentials
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{credential_id}")
|
||||
async def delete_passkey(
|
||||
request: Request,
|
||||
credential_id: int = Path(ge=1, le=2147483647),
|
||||
data: PasskeyDeleteRequest = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a passkey. Requires password confirmation (S-06)."""
|
||||
# Verify password
|
||||
valid, new_hash = await averify_password_with_upgrade(
|
||||
data.password, current_user.password_hash
|
||||
)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
if new_hash:
|
||||
current_user.password_hash = new_hash
|
||||
|
||||
# Look up credential — verify ownership (IDOR prevention)
|
||||
result = await db.execute(
|
||||
select(PasskeyCredential).where(
|
||||
PasskeyCredential.id == credential_id,
|
||||
PasskeyCredential.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
credential = result.scalar_one_or_none()
|
||||
if not credential:
|
||||
raise HTTPException(status_code=404, detail="Passkey not found")
|
||||
|
||||
# Guard: passwordless users must retain at least 2 passkeys
|
||||
if current_user.passwordless_enabled:
|
||||
pk_count_result = await db.execute(
|
||||
select(func.count()).select_from(PasskeyCredential).where(
|
||||
PasskeyCredential.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
pk_count = pk_count_result.scalar_one()
|
||||
if pk_count <= 2:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot delete: passwordless requires at least 2 passkeys",
|
||||
)
|
||||
|
||||
cred_name = credential.name
|
||||
await db.delete(credential)
|
||||
|
||||
await log_audit_event(
|
||||
db, action="passkey.deleted", actor_id=current_user.id,
|
||||
detail={"credential_name": cred_name, "credential_db_id": credential_id},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Passkey removed"}
|
||||
@ -1,31 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete as sa_delete, select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from datetime import date, datetime, timedelta
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from datetime import date, timedelta
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.project import Project
|
||||
from app.models.project_task import ProjectTask
|
||||
from app.models.task_comment import TaskComment
|
||||
from app.models.project_member import ProjectMember
|
||||
from app.models.project_task_assignment import ProjectTaskAssignment
|
||||
from app.models.settings import Settings
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse
|
||||
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
|
||||
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse
|
||||
from app.schemas.project_member import (
|
||||
ProjectMemberInvite, ProjectMemberUpdate, ProjectMemberRespond, ProjectMemberResponse,
|
||||
)
|
||||
from app.schemas.project_task_assignment import TaskAssignmentCreate, TaskAssignmentResponse
|
||||
from app.services.project_sharing import (
|
||||
get_project_permission, require_project_permission, get_accessible_project_ids,
|
||||
validate_project_connections, get_effective_task_permission, ensure_auto_membership,
|
||||
cleanup_auto_membership, ASSIGNEE_ALLOWED_FIELDS,
|
||||
)
|
||||
from app.services.notification import create_notification
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
@ -33,67 +20,39 @@ router = APIRouter()
|
||||
|
||||
|
||||
class ReorderItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
id: int
|
||||
sort_order: int
|
||||
|
||||
|
||||
def _project_load_options():
|
||||
"""All load options needed for project responses (tasks + subtasks + comments + assignments)."""
|
||||
"""All load options needed for project responses (tasks + subtasks + comments at each level)."""
|
||||
return [
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.comments),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
selectinload(Project.members),
|
||||
]
|
||||
|
||||
|
||||
def _task_load_options():
|
||||
"""All load options needed for task responses."""
|
||||
return [
|
||||
selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(ProjectTask.comments),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
|
||||
selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
]
|
||||
|
||||
|
||||
async def _get_user_name(db: AsyncSession, user_id: int) -> str | None:
|
||||
"""Get display name for a user from settings.preferred_name or user.username."""
|
||||
result = await db.execute(
|
||||
select(Settings.preferred_name, User.username)
|
||||
.outerjoin(Settings, Settings.user_id == User.id)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
preferred, username = row.tuple()
|
||||
return preferred or username
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# PROJECT CRUD
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_model=List[ProjectResponse])
|
||||
async def get_projects(
|
||||
tracked: Optional[bool] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all projects the user owns or has accepted membership in."""
|
||||
accessible_ids = await get_accessible_project_ids(db, current_user.id)
|
||||
if not accessible_ids:
|
||||
return []
|
||||
|
||||
"""Get all projects with their tasks. Optionally filter by tracked status."""
|
||||
query = (
|
||||
select(Project)
|
||||
.options(*_project_load_options())
|
||||
.where(Project.id.in_(accessible_ids))
|
||||
.where(Project.user_id == current_user.id)
|
||||
.order_by(Project.created_at.desc())
|
||||
)
|
||||
if tracked is not None:
|
||||
@ -112,10 +71,6 @@ async def get_tracked_tasks(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get tasks and subtasks from tracked projects with due dates within the next N days."""
|
||||
accessible_ids = await get_accessible_project_ids(db, current_user.id)
|
||||
if not accessible_ids:
|
||||
return []
|
||||
|
||||
today = date.today()
|
||||
cutoff = today + timedelta(days=days)
|
||||
|
||||
@ -127,7 +82,7 @@ async def get_tracked_tasks(
|
||||
selectinload(ProjectTask.parent_task),
|
||||
)
|
||||
.where(
|
||||
Project.id.in_(accessible_ids),
|
||||
Project.user_id == current_user.id,
|
||||
Project.is_tracked == True,
|
||||
ProjectTask.due_date.isnot(None),
|
||||
ProjectTask.due_date >= today,
|
||||
@ -154,31 +109,6 @@ async def get_tracked_tasks(
|
||||
]
|
||||
|
||||
|
||||
@router.get("/shared", response_model=List[ProjectResponse])
|
||||
async def get_shared_projects(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List projects where user is an accepted member (not owner)."""
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember.project_id).where(
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
project_ids = [r[0] for r in member_result.all()]
|
||||
if not project_ids:
|
||||
return []
|
||||
|
||||
result = await db.execute(
|
||||
select(Project)
|
||||
.options(*_project_load_options())
|
||||
.where(Project.id.in_(project_ids))
|
||||
.order_by(Project.created_at.desc())
|
||||
)
|
||||
return result.scalars().unique().all()
|
||||
|
||||
|
||||
@router.post("/", response_model=ProjectResponse, status_code=201)
|
||||
async def create_project(
|
||||
project: ProjectCreate,
|
||||
@ -190,6 +120,7 @@ async def create_project(
|
||||
db.add(new_project)
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with eagerly loaded tasks for response serialization
|
||||
query = select(Project).options(*_project_load_options()).where(Project.id == new_project.id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one()
|
||||
@ -202,12 +133,10 @@ async def get_project(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a specific project by ID with its tasks."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
|
||||
query = (
|
||||
select(Project)
|
||||
.options(*_project_load_options())
|
||||
.where(Project.id == project_id)
|
||||
.where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
project = result.scalar_one_or_none()
|
||||
@ -225,10 +154,10 @@ async def update_project(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a project. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
"""Update a project."""
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
@ -241,6 +170,7 @@ async def update_project(
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with eagerly loaded tasks for response serialization
|
||||
query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one()
|
||||
@ -252,10 +182,10 @@ async def delete_project(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a project and all its tasks. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
"""Delete a project and all its tasks."""
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
@ -267,10 +197,6 @@ async def delete_project(
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# TASK CRUD (permission-aware)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
|
||||
async def get_project_tasks(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
@ -278,7 +204,14 @@ async def get_project_tasks(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get top-level tasks for a specific project (subtasks are nested)."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
# Verify project ownership first
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
query = (
|
||||
select(ProjectTask)
|
||||
@ -302,8 +235,15 @@ async def create_project_task(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new task or subtask for a project. Requires create_modify permission."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
"""Create a new task or subtask for a project."""
|
||||
# Verify project ownership first
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Validate parent_task_id if creating a subtask
|
||||
if task.parent_task_id is not None:
|
||||
@ -327,6 +267,7 @@ async def create_project_task(
|
||||
db.add(new_task)
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with subtasks loaded
|
||||
query = (
|
||||
select(ProjectTask)
|
||||
.options(*_task_load_options())
|
||||
@ -343,23 +284,26 @@ async def reorder_tasks(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Bulk update sort_order for tasks. Requires create_modify permission."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
|
||||
# AC-4: Batch-fetch all tasks in one query instead of N sequential queries
|
||||
task_ids = [item.id for item in items]
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
ProjectTask.id.in_(task_ids),
|
||||
ProjectTask.project_id == project_id,
|
||||
)
|
||||
"""Bulk update sort_order for tasks."""
|
||||
# Verify project ownership first
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
tasks_by_id = {t.id: t for t in task_result.scalars().all()}
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
order_map = {item.id: item.sort_order for item in items}
|
||||
for task_id, task in tasks_by_id.items():
|
||||
if task_id in order_map:
|
||||
task.sort_order = order_map[task_id]
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
for item in items:
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
ProjectTask.id == item.id,
|
||||
ProjectTask.project_id == project_id
|
||||
)
|
||||
)
|
||||
task = task_result.scalar_one_or_none()
|
||||
if task:
|
||||
task.sort_order = item.sort_order
|
||||
|
||||
await db.commit()
|
||||
|
||||
@ -374,12 +318,13 @@ async def update_project_task(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a project task. Permission checked at project and task level."""
|
||||
perm, project_perm = await get_effective_task_permission(db, current_user.id, task_id, project_id)
|
||||
if perm is None:
|
||||
"""Update a project task."""
|
||||
# Verify project ownership first, then fetch task scoped to that project
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if perm == "read_only":
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
@ -394,28 +339,12 @@ async def update_project_task(
|
||||
|
||||
update_data = task_update.model_dump(exclude_unset=True)
|
||||
|
||||
# SEC-P02: Assignees (non-owner, non-project-member with create_modify) restricted to content fields
|
||||
if project_perm not in ("owner", "create_modify"):
|
||||
# This user's create_modify comes from task assignment — enforce allowlist
|
||||
disallowed = set(update_data.keys()) - ASSIGNEE_ALLOWED_FIELDS
|
||||
if disallowed:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Task assignees cannot modify: {', '.join(sorted(disallowed))}",
|
||||
)
|
||||
|
||||
# Optimistic locking: if version provided, check it matches
|
||||
client_version = update_data.pop("version", None)
|
||||
if client_version is not None and task.version != client_version:
|
||||
raise HTTPException(status_code=409, detail="Task was modified by another user")
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(task, key, value)
|
||||
|
||||
task.version += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with subtasks loaded
|
||||
query = (
|
||||
select(ProjectTask)
|
||||
.options(*_task_load_options())
|
||||
@ -432,8 +361,13 @@ async def delete_project_task(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a project task (cascades to subtasks). Requires create_modify permission."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
"""Delete a project task (cascades to subtasks)."""
|
||||
# Verify project ownership first, then fetch task scoped to that project
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
@ -452,10 +386,6 @@ async def delete_project_task(
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# COMMENTS (permission-aware)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201)
|
||||
async def create_task_comment(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
@ -464,8 +394,13 @@ async def create_task_comment(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Add a comment to a task. All members can comment (read_only minimum)."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
"""Add a comment to a task."""
|
||||
# Verify project ownership first, then fetch task scoped to that project
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
@ -478,23 +413,12 @@ async def create_task_comment(
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
new_comment = TaskComment(task_id=task_id, user_id=current_user.id, content=comment.content)
|
||||
new_comment = TaskComment(task_id=task_id, content=comment.content)
|
||||
db.add(new_comment)
|
||||
|
||||
# Get author name before commit
|
||||
author_name = await _get_user_name(db, current_user.id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_comment)
|
||||
|
||||
return TaskCommentResponse(
|
||||
id=new_comment.id,
|
||||
task_id=new_comment.task_id,
|
||||
user_id=new_comment.user_id,
|
||||
author_name=author_name,
|
||||
content=new_comment.content,
|
||||
created_at=new_comment.created_at,
|
||||
)
|
||||
return new_comment
|
||||
|
||||
|
||||
@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
|
||||
@ -505,9 +429,12 @@ async def delete_task_comment(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a task comment. Comment author or project owner only."""
|
||||
perm = await get_project_permission(db, project_id, current_user.id)
|
||||
if perm is None:
|
||||
"""Delete a task comment."""
|
||||
# Verify project ownership first, then fetch comment scoped through task
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
result = await db.execute(
|
||||
@ -521,484 +448,7 @@ async def delete_task_comment(
|
||||
if not comment:
|
||||
raise HTTPException(status_code=404, detail="Comment not found")
|
||||
|
||||
# Only comment author or project owner can delete
|
||||
if comment.user_id != current_user.id and perm != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the comment author or project owner can delete this comment")
|
||||
|
||||
await db.delete(comment)
|
||||
await db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# MEMBERSHIP ROUTES
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/members", response_model=List[ProjectMemberResponse], status_code=201)
|
||||
async def invite_members(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
invite: ProjectMemberInvite = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Invite connection(s) to a project. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
# Validate connections
|
||||
await validate_project_connections(db, current_user.id, invite.user_ids)
|
||||
|
||||
# Check pending invite cap (max 10 pending per project)
|
||||
pending_count_result = await db.execute(
|
||||
select(ProjectMember.id).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.status == "pending",
|
||||
)
|
||||
)
|
||||
pending_count = len(pending_count_result.all())
|
||||
if pending_count + len(invite.user_ids) > 10:
|
||||
raise HTTPException(status_code=400, detail="Maximum 10 pending invites per project")
|
||||
|
||||
# Filter out self and existing members
|
||||
existing_result = await db.execute(
|
||||
select(ProjectMember.user_id).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id.in_(invite.user_ids),
|
||||
)
|
||||
)
|
||||
existing_user_ids = {r[0] for r in existing_result.all()}
|
||||
|
||||
# Get project for notifications
|
||||
project_result = await db.execute(select(Project.name).where(Project.id == project_id))
|
||||
project_name = project_result.scalar_one()
|
||||
|
||||
inviter_name = await _get_user_name(db, current_user.id)
|
||||
created_members = []
|
||||
|
||||
for uid in invite.user_ids:
|
||||
if uid == current_user.id or uid in existing_user_ids:
|
||||
continue
|
||||
|
||||
member = ProjectMember(
|
||||
project_id=project_id,
|
||||
user_id=uid,
|
||||
invited_by=current_user.id,
|
||||
permission=invite.permission,
|
||||
status="pending",
|
||||
source="invited",
|
||||
)
|
||||
db.add(member)
|
||||
created_members.append(member)
|
||||
|
||||
# In-app notification
|
||||
await create_notification(
|
||||
db, uid, "project_invite",
|
||||
f"Project invitation from {inviter_name}",
|
||||
f"You've been invited to collaborate on \"{project_name}\"",
|
||||
data={"project_id": project_id},
|
||||
source_type="project_member",
|
||||
)
|
||||
|
||||
await db.flush() # Assign IDs before commit (ORM objects expire after commit)
|
||||
member_ids = [m.id for m in created_members]
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with relationships
|
||||
if not created_members:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(
|
||||
selectinload(ProjectMember.user),
|
||||
selectinload(ProjectMember.inviter),
|
||||
)
|
||||
.where(ProjectMember.id.in_(member_ids))
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
# Build response with names
|
||||
responses = []
|
||||
for m in members:
|
||||
resp = ProjectMemberResponse.model_validate(m)
|
||||
resp.user_name = m.user.username
|
||||
resp.inviter_name = m.inviter.username if m.inviter else None
|
||||
responses.append(resp)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
@router.get("/{project_id}/members", response_model=List[ProjectMemberResponse])
|
||||
async def get_members(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List members + statuses. Any member can view."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(
|
||||
selectinload(ProjectMember.user),
|
||||
selectinload(ProjectMember.inviter),
|
||||
)
|
||||
.where(ProjectMember.project_id == project_id)
|
||||
.order_by(ProjectMember.created_at.asc())
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
# Batch-fetch settings for preferred_name
|
||||
user_ids = [m.user_id for m in members] + [m.invited_by for m in members]
|
||||
settings_result = await db.execute(
|
||||
select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids))
|
||||
)
|
||||
name_map = {r[0]: r[1] for r in settings_result.all()}
|
||||
|
||||
responses = []
|
||||
for m in members:
|
||||
resp = ProjectMemberResponse.model_validate(m)
|
||||
resp.user_name = name_map.get(m.user_id) or m.user.username
|
||||
resp.inviter_name = name_map.get(m.invited_by) or (m.inviter.username if m.inviter else None)
|
||||
responses.append(resp)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
@router.patch("/{project_id}/members/{user_id}", response_model=ProjectMemberResponse)
|
||||
async def update_member_permission(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
update: ProjectMemberUpdate = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a member's permission level. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter))
|
||||
.where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
member.permission = update.permission
|
||||
|
||||
# Extract response data BEFORE commit (ORM objects expire after commit)
|
||||
resp = ProjectMemberResponse.model_validate(member)
|
||||
resp.user_name = member.user.username
|
||||
resp.inviter_name = member.inviter.username if member.inviter else None
|
||||
|
||||
await db.commit()
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@router.delete("/{project_id}/members/{user_id}", status_code=204)
|
||||
async def remove_member(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a member. Owner or self (leave project)."""
|
||||
perm = await get_project_permission(db, project_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Only owner can remove others; anyone can remove themselves
|
||||
if user_id != current_user.id and perm != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the project owner can remove members")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
# Remove task assignments for this user in this project
|
||||
await db.execute(
|
||||
sa_delete(ProjectTaskAssignment).where(
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
ProjectTaskAssignment.task_id.in_(
|
||||
select(ProjectTask.id).where(ProjectTask.project_id == project_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await db.delete(member)
|
||||
await db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/memberships/{project_id}/respond", response_model=ProjectMemberResponse)
|
||||
async def respond_to_invite(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
respond: ProjectMemberRespond = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Accept or reject a project invite."""
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter))
|
||||
.where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.status == "pending",
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="No pending invitation found")
|
||||
|
||||
# Extract response data before any mutations (ORM objects expire after commit)
|
||||
resp = ProjectMemberResponse.model_validate(member)
|
||||
resp.user_name = member.user.username
|
||||
resp.inviter_name = member.inviter.username if member.inviter else None
|
||||
|
||||
if respond.response == "accepted":
|
||||
member.status = "accepted"
|
||||
member.accepted_at = datetime.now()
|
||||
|
||||
# Get project owner for notification
|
||||
project_result = await db.execute(
|
||||
select(Project.user_id, Project.name).where(Project.id == project_id)
|
||||
)
|
||||
project_row = project_result.one()
|
||||
owner_id, project_name = project_row.tuple()
|
||||
|
||||
responder_name = await _get_user_name(db, current_user.id)
|
||||
|
||||
await create_notification(
|
||||
db, owner_id, "project_invite_accepted",
|
||||
f"{responder_name} joined your project",
|
||||
f"{responder_name} accepted the invitation to \"{project_name}\"",
|
||||
data={"project_id": project_id},
|
||||
source_type="project_member",
|
||||
)
|
||||
|
||||
resp.status = "accepted"
|
||||
else:
|
||||
# Rejected — delete the row to prevent accumulation (W-06)
|
||||
await db.delete(member)
|
||||
resp.status = "rejected"
|
||||
|
||||
await db.commit()
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# TASK ASSIGNMENT ROUTES
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/tasks/{task_id}/assignments", response_model=List[TaskAssignmentResponse], status_code=201)
|
||||
async def assign_users_to_task(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
task_id: int = Path(ge=1, le=2147483647),
|
||||
assignment: TaskAssignmentCreate = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Assign user(s) to a task. Requires create_modify on project or be owner."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
|
||||
# Verify task exists in project
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
ProjectTask.id == task_id,
|
||||
ProjectTask.project_id == project_id,
|
||||
)
|
||||
)
|
||||
task = task_result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Get project owner for connection validation
|
||||
project_result = await db.execute(
|
||||
select(Project.user_id, Project.name).where(Project.id == project_id)
|
||||
)
|
||||
project_row = project_result.one()
|
||||
owner_id, project_name = project_row.tuple()
|
||||
|
||||
# Validate connections (all assignees must be connections of the project owner)
|
||||
non_owner_ids = [uid for uid in assignment.user_ids if uid != owner_id]
|
||||
if non_owner_ids:
|
||||
await validate_project_connections(db, owner_id, non_owner_ids)
|
||||
|
||||
# Filter out existing assignments
|
||||
existing_result = await db.execute(
|
||||
select(ProjectTaskAssignment.user_id).where(
|
||||
ProjectTaskAssignment.task_id == task_id,
|
||||
ProjectTaskAssignment.user_id.in_(assignment.user_ids),
|
||||
)
|
||||
)
|
||||
existing_user_ids = {r[0] for r in existing_result.all()}
|
||||
|
||||
assigner_name = await _get_user_name(db, current_user.id)
|
||||
created = []
|
||||
|
||||
for uid in assignment.user_ids:
|
||||
if uid in existing_user_ids:
|
||||
continue
|
||||
|
||||
# Auto-membership: ensure user has ProjectMember row
|
||||
if uid != owner_id:
|
||||
await ensure_auto_membership(db, project_id, uid, current_user.id)
|
||||
|
||||
new_assignment = ProjectTaskAssignment(
|
||||
task_id=task_id,
|
||||
user_id=uid,
|
||||
assigned_by=current_user.id,
|
||||
)
|
||||
db.add(new_assignment)
|
||||
created.append(new_assignment)
|
||||
|
||||
# Notify assignee (don't notify self)
|
||||
if uid != current_user.id:
|
||||
await create_notification(
|
||||
db, uid, "task_assigned",
|
||||
f"Task assigned by {assigner_name}",
|
||||
f"You've been assigned to \"{task.title}\" in \"{project_name}\"",
|
||||
data={"project_id": project_id, "task_id": task_id},
|
||||
source_type="task_assignment",
|
||||
)
|
||||
|
||||
await db.flush() # Assign IDs before commit (ORM objects expire after commit)
|
||||
assignment_ids = [a.id for a in created]
|
||||
await db.commit()
|
||||
|
||||
if not created:
|
||||
return []
|
||||
|
||||
# Re-fetch with user info
|
||||
result = await db.execute(
|
||||
select(ProjectTaskAssignment)
|
||||
.options(selectinload(ProjectTaskAssignment.user))
|
||||
.where(ProjectTaskAssignment.id.in_(assignment_ids))
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
|
||||
# Get names
|
||||
user_ids = [a.user_id for a in assignments]
|
||||
settings_result = await db.execute(
|
||||
select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids))
|
||||
)
|
||||
name_map = {r[0]: r[1] for r in settings_result.all()}
|
||||
|
||||
return [
|
||||
TaskAssignmentResponse(
|
||||
id=a.id,
|
||||
task_id=a.task_id,
|
||||
user_id=a.user_id,
|
||||
assigned_by=a.assigned_by,
|
||||
user_name=name_map.get(a.user_id) or a.user.username,
|
||||
created_at=a.created_at,
|
||||
)
|
||||
for a in assignments
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{project_id}/tasks/{task_id}/assignments/{user_id}", status_code=204)
|
||||
async def remove_task_assignment(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
task_id: int = Path(ge=1, le=2147483647),
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a task assignment. Owner, create_modify member, or the assignee themselves."""
|
||||
perm = await get_project_permission(db, project_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Self-unassign is always allowed; otherwise need create_modify or owner
|
||||
if user_id != current_user.id and perm not in ("owner", "create_modify"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission")
|
||||
|
||||
result = await db.execute(
|
||||
sa_delete(ProjectTaskAssignment)
|
||||
.where(
|
||||
ProjectTaskAssignment.task_id == task_id,
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
)
|
||||
.returning(ProjectTaskAssignment.id)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Cleanup auto-membership if no more assignments
|
||||
await cleanup_auto_membership(db, project_id, user_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# DELTA POLLING
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class PollResponse(BaseModel):
|
||||
has_changes: bool
|
||||
project_updated_at: str | None = None
|
||||
changed_task_ids: list[int] = []
|
||||
|
||||
|
||||
@router.get("/{project_id}/poll", response_model=PollResponse)
|
||||
async def poll_project(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
since: str = Query(..., description="ISO timestamp to check for changes since"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Lightweight poll endpoint — returns changed task IDs since timestamp."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
|
||||
try:
|
||||
since_dt = datetime.fromisoformat(since)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid ISO timestamp")
|
||||
|
||||
# Clamp to max 24h in the past to prevent expensive full-table scans
|
||||
min_since = datetime.now() - timedelta(hours=24)
|
||||
if since_dt < min_since:
|
||||
since_dt = min_since
|
||||
|
||||
# Check project-level update
|
||||
proj_result = await db.execute(
|
||||
select(Project.updated_at).where(Project.id == project_id)
|
||||
)
|
||||
project_updated = proj_result.scalar_one_or_none()
|
||||
if not project_updated:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
project_changed = project_updated > since_dt
|
||||
|
||||
# Check task-level changes using the index
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask.id).where(
|
||||
ProjectTask.project_id == project_id,
|
||||
ProjectTask.updated_at > since_dt,
|
||||
)
|
||||
)
|
||||
changed_task_ids = [r[0] for r in task_result.all()]
|
||||
|
||||
has_changes = project_changed or len(changed_task_ids) > 0
|
||||
|
||||
return PollResponse(
|
||||
has_changes=has_changes,
|
||||
project_updated_at=project_updated.isoformat() if project_updated else None,
|
||||
changed_task_ids=changed_task_ids,
|
||||
)
|
||||
|
||||
@ -7,7 +7,6 @@ from app.models.settings import Settings
|
||||
from app.models.user import User
|
||||
from app.schemas.settings import SettingsUpdate, SettingsResponse
|
||||
from app.routers.auth import get_current_user, get_current_settings
|
||||
from app.services.connection import sync_birthday_to_contacts
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -79,7 +78,6 @@ async def get_settings(
|
||||
async def update_settings(
|
||||
settings_update: SettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_settings: Settings = Depends(get_current_settings)
|
||||
):
|
||||
"""Update settings."""
|
||||
@ -93,18 +91,9 @@ async def update_settings(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
old_share_birthday = current_settings.share_birthday
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(current_settings, key, value)
|
||||
|
||||
if "share_birthday" in update_data and update_data["share_birthday"] != old_share_birthday:
|
||||
await sync_birthday_to_contacts(
|
||||
db, current_user.id,
|
||||
share_birthday=update_data["share_birthday"],
|
||||
date_of_birth=current_user.date_of_birth,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_settings)
|
||||
|
||||
|
||||
@ -1,871 +0,0 @@
|
||||
"""
|
||||
Shared calendars router — invites, membership, locks, sync.
|
||||
|
||||
All endpoints live under /api/shared-calendars.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy import delete, func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
from app.models.settings import Settings
|
||||
from app.models.user import User
|
||||
from app.models.user_connection import UserConnection
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.shared_calendar import (
|
||||
CalendarInviteResponse,
|
||||
CalendarMemberResponse,
|
||||
InviteMemberRequest,
|
||||
LockStatusResponse,
|
||||
RespondInviteRequest,
|
||||
SyncResponse,
|
||||
UpdateLocalColorRequest,
|
||||
UpdateMemberRequest,
|
||||
)
|
||||
from app.services.audit import get_client_ip, log_audit_event
|
||||
from app.services.calendar_sharing import (
|
||||
PERMISSION_RANK,
|
||||
acquire_lock,
|
||||
get_user_permission,
|
||||
release_lock,
|
||||
require_permission,
|
||||
)
|
||||
from app.services.notification import create_notification
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PENDING_INVITE_CAP = 10
|
||||
|
||||
|
||||
# -- Helpers ---------------------------------------------------------------
|
||||
|
||||
async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None:
|
||||
result = await db.execute(select(Settings).where(Settings.user_id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _build_member_response(member: CalendarMember) -> dict:
|
||||
return {
|
||||
"id": member.id,
|
||||
"calendar_id": member.calendar_id,
|
||||
"user_id": member.user_id,
|
||||
"umbral_name": member.user.umbral_name if member.user else "",
|
||||
"preferred_name": None,
|
||||
"permission": member.permission,
|
||||
"can_add_others": member.can_add_others,
|
||||
"local_color": member.local_color,
|
||||
"status": member.status,
|
||||
"invited_at": member.invited_at,
|
||||
"accepted_at": member.accepted_at,
|
||||
}
|
||||
|
||||
|
||||
# -- GET / — List accepted memberships ------------------------------------
|
||||
|
||||
@router.get("/")
|
||||
async def list_shared_calendars(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List calendars the current user has accepted membership in."""
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
.options(selectinload(CalendarMember.calendar))
|
||||
.order_by(CalendarMember.accepted_at.desc())
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"calendar_id": m.calendar_id,
|
||||
"calendar_name": m.calendar.name if m.calendar else "",
|
||||
"calendar_color": m.calendar.color if m.calendar else "",
|
||||
"local_color": m.local_color,
|
||||
"permission": m.permission,
|
||||
"can_add_others": m.can_add_others,
|
||||
"is_owner": False,
|
||||
}
|
||||
for m in members
|
||||
]
|
||||
|
||||
|
||||
# -- POST /{cal_id}/invite — Invite via connection_id ---------------------
|
||||
|
||||
@router.post("/{cal_id}/invite", status_code=201)
|
||||
async def invite_member(
|
||||
body: InviteMemberRequest,
|
||||
request: Request,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Invite a connected user to a shared calendar."""
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id)
|
||||
)
|
||||
calendar = cal_result.scalar_one_or_none()
|
||||
if not calendar:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
|
||||
is_owner = calendar.user_id == current_user.id
|
||||
inviter_perm = "owner" if is_owner else None
|
||||
|
||||
if not is_owner:
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
if not member.can_add_others:
|
||||
raise HTTPException(status_code=403, detail="You do not have permission to invite others")
|
||||
if PERMISSION_RANK.get(member.permission, 0) < PERMISSION_RANK.get("create_modify", 0):
|
||||
raise HTTPException(status_code=403, detail="Read-only members cannot invite others")
|
||||
inviter_perm = member.permission
|
||||
|
||||
# Permission ceiling
|
||||
if inviter_perm != "owner":
|
||||
if PERMISSION_RANK.get(body.permission, 0) > PERMISSION_RANK.get(inviter_perm, 0):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Cannot grant a permission level higher than your own",
|
||||
)
|
||||
|
||||
# Resolve connection_id -> connected user
|
||||
conn_result = await db.execute(
|
||||
select(UserConnection).where(
|
||||
UserConnection.id == body.connection_id,
|
||||
UserConnection.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
connection = conn_result.scalar_one_or_none()
|
||||
if not connection:
|
||||
raise HTTPException(status_code=404, detail="Connection not found")
|
||||
|
||||
target_user_id = connection.connected_user_id
|
||||
|
||||
# W-03: Verify bidirectional connection still active
|
||||
reverse_conn = await db.execute(
|
||||
select(UserConnection.id).where(
|
||||
UserConnection.user_id == target_user_id,
|
||||
UserConnection.connected_user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if not reverse_conn.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Connection is no longer active")
|
||||
|
||||
if target_user_id == calendar.user_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot invite the calendar owner")
|
||||
|
||||
target_result = await db.execute(
|
||||
select(User).where(User.id == target_user_id)
|
||||
)
|
||||
target = target_result.scalar_one_or_none()
|
||||
if not target or not target.is_active:
|
||||
raise HTTPException(status_code=404, detail="Target user not found or inactive")
|
||||
|
||||
existing = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.user_id == target_user_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="User already invited or is a member")
|
||||
|
||||
pending_count = await db.scalar(
|
||||
select(func.count())
|
||||
.select_from(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.status == "pending",
|
||||
)
|
||||
) or 0
|
||||
if pending_count >= PENDING_INVITE_CAP:
|
||||
raise HTTPException(status_code=429, detail="Too many pending invites for this calendar")
|
||||
|
||||
if not calendar.is_shared:
|
||||
calendar.is_shared = True
|
||||
|
||||
new_member = CalendarMember(
|
||||
calendar_id=cal_id,
|
||||
user_id=target_user_id,
|
||||
invited_by=current_user.id,
|
||||
permission=body.permission,
|
||||
can_add_others=body.can_add_others,
|
||||
status="pending",
|
||||
)
|
||||
db.add(new_member)
|
||||
await db.flush()
|
||||
|
||||
inviter_settings = await _get_settings_for_user(db, current_user.id)
|
||||
inviter_display = (inviter_settings.preferred_name if inviter_settings else None) or current_user.umbral_name
|
||||
|
||||
cal_name = calendar.name
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=target_user_id,
|
||||
type="calendar_invite",
|
||||
title="Calendar Invite",
|
||||
message=f"{inviter_display} invited you to '{cal_name}'",
|
||||
data={"calendar_id": cal_id, "calendar_name": cal_name},
|
||||
source_type="calendar_invite",
|
||||
source_id=new_member.id,
|
||||
)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.invite_sent",
|
||||
actor_id=current_user.id,
|
||||
target_id=target_user_id,
|
||||
detail={
|
||||
"calendar_id": cal_id,
|
||||
"calendar_name": cal_name,
|
||||
"permission": body.permission,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
response = {
|
||||
"message": "Invite sent",
|
||||
"member_id": new_member.id,
|
||||
"calendar_id": cal_id,
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
return response
|
||||
|
||||
|
||||
# -- PUT /invites/{id}/respond — Accept or reject -------------------------
|
||||
|
||||
@router.put("/invites/{invite_id}/respond")
|
||||
async def respond_to_invite(
|
||||
body: RespondInviteRequest,
|
||||
request: Request,
|
||||
invite_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Accept or reject a calendar invite."""
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.id == invite_id,
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "pending",
|
||||
)
|
||||
.options(selectinload(CalendarMember.calendar))
|
||||
)
|
||||
invite = result.scalar_one_or_none()
|
||||
if not invite:
|
||||
raise HTTPException(status_code=404, detail="Invite not found or already resolved")
|
||||
|
||||
calendar_name = invite.calendar.name if invite.calendar else "Unknown"
|
||||
calendar_owner_id = invite.calendar.user_id if invite.calendar else None
|
||||
inviter_id = invite.invited_by
|
||||
|
||||
if body.action == "accept":
|
||||
invite.status = "accepted"
|
||||
invite.accepted_at = datetime.now()
|
||||
|
||||
notify_user_id = inviter_id or calendar_owner_id
|
||||
if notify_user_id:
|
||||
user_settings = await _get_settings_for_user(db, current_user.id)
|
||||
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=notify_user_id,
|
||||
type="calendar_invite_accepted",
|
||||
title="Invite Accepted",
|
||||
message=f"{display} accepted your invite to '{calendar_name}'",
|
||||
data={"calendar_id": invite.calendar_id},
|
||||
source_type="calendar_invite",
|
||||
source_id=invite.id,
|
||||
)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.invite_accepted",
|
||||
actor_id=current_user.id,
|
||||
detail={"calendar_id": invite.calendar_id, "calendar_name": calendar_name},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Invite accepted"}
|
||||
|
||||
else:
|
||||
member_id = invite.id
|
||||
calendar_id = invite.calendar_id
|
||||
|
||||
notify_user_id = inviter_id or calendar_owner_id
|
||||
if notify_user_id:
|
||||
user_settings = await _get_settings_for_user(db, current_user.id)
|
||||
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=notify_user_id,
|
||||
type="calendar_invite_rejected",
|
||||
title="Invite Rejected",
|
||||
message=f"{display} declined your invite to '{calendar_name}'",
|
||||
data={"calendar_id": calendar_id},
|
||||
source_type="calendar_invite",
|
||||
source_id=member_id,
|
||||
)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.invite_rejected",
|
||||
actor_id=current_user.id,
|
||||
detail={"calendar_id": calendar_id, "calendar_name": calendar_name},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.delete(invite)
|
||||
await db.commit()
|
||||
return {"message": "Invite rejected"}
|
||||
|
||||
|
||||
# -- GET /invites/incoming — Pending invites -------------------------------
|
||||
|
||||
@router.get("/invites/incoming", response_model=list[CalendarInviteResponse])
|
||||
async def get_incoming_invites(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List pending calendar invites for the current user."""
|
||||
offset = (page - 1) * per_page
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "pending",
|
||||
)
|
||||
.options(
|
||||
selectinload(CalendarMember.calendar),
|
||||
selectinload(CalendarMember.inviter),
|
||||
)
|
||||
.order_by(CalendarMember.invited_at.desc())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
)
|
||||
invites = result.scalars().all()
|
||||
|
||||
# Batch-fetch owner names to avoid N+1
|
||||
owner_ids = list({inv.calendar.user_id for inv in invites if inv.calendar})
|
||||
if owner_ids:
|
||||
owner_result = await db.execute(
|
||||
select(User.id, User.umbral_name).where(User.id.in_(owner_ids))
|
||||
)
|
||||
owner_names = {row.id: row.umbral_name for row in owner_result.all()}
|
||||
else:
|
||||
owner_names = {}
|
||||
|
||||
responses = []
|
||||
for inv in invites:
|
||||
owner_name = owner_names.get(inv.calendar.user_id, "") if inv.calendar else ""
|
||||
|
||||
responses.append(CalendarInviteResponse(
|
||||
id=inv.id,
|
||||
calendar_id=inv.calendar_id,
|
||||
calendar_name=inv.calendar.name if inv.calendar else "",
|
||||
calendar_color=inv.calendar.color if inv.calendar else "",
|
||||
owner_umbral_name=owner_name,
|
||||
inviter_umbral_name=inv.inviter.umbral_name if inv.inviter else "",
|
||||
permission=inv.permission,
|
||||
invited_at=inv.invited_at,
|
||||
))
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
# -- GET /{cal_id}/members — Member list -----------------------------------
|
||||
|
||||
@router.get("/{cal_id}/members", response_model=list[CalendarMemberResponse])
|
||||
async def list_members(
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all members of a shared calendar. Requires membership or ownership."""
|
||||
perm = await get_user_permission(db, cal_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(CalendarMember.calendar_id == cal_id)
|
||||
.options(selectinload(CalendarMember.user))
|
||||
.order_by(CalendarMember.invited_at.asc())
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
user_ids = [m.user_id for m in members]
|
||||
if user_ids:
|
||||
settings_result = await db.execute(
|
||||
select(Settings.user_id, Settings.preferred_name)
|
||||
.where(Settings.user_id.in_(user_ids))
|
||||
)
|
||||
pref_names = {row.user_id: row.preferred_name for row in settings_result.all()}
|
||||
else:
|
||||
pref_names = {}
|
||||
|
||||
responses = []
|
||||
for m in members:
|
||||
resp = _build_member_response(m)
|
||||
resp["preferred_name"] = pref_names.get(m.user_id)
|
||||
responses.append(resp)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
# -- PUT /{cal_id}/members/{mid} — Update permission (owner only) ----------
|
||||
|
||||
@router.put("/{cal_id}/members/{member_id}")
|
||||
async def update_member(
|
||||
body: UpdateMemberRequest,
|
||||
request: Request,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
member_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update a member permission or can_add_others. Owner only."""
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id, Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can update members")
|
||||
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.id == member_id,
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
if "permission" in update_data:
|
||||
member.permission = update_data["permission"]
|
||||
if "can_add_others" in update_data:
|
||||
member.can_add_others = update_data["can_add_others"]
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.member_updated",
|
||||
actor_id=current_user.id,
|
||||
target_id=member.user_id,
|
||||
detail={"calendar_id": cal_id, "member_id": member_id, "changes": update_data},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Member updated"}
|
||||
|
||||
|
||||
# -- DELETE /{cal_id}/members/{mid} — Remove member or leave ---------------
|
||||
|
||||
@router.delete("/{cal_id}/members/{member_id}", status_code=204)
|
||||
async def remove_member(
|
||||
request: Request,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
member_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove a member or leave a shared calendar."""
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.id == member_id,
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id)
|
||||
)
|
||||
calendar = cal_result.scalar_one_or_none()
|
||||
|
||||
is_self = member.user_id == current_user.id
|
||||
is_owner = calendar and calendar.user_id == current_user.id
|
||||
|
||||
if not is_self and not is_owner:
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can remove other members")
|
||||
|
||||
target_user_id = member.user_id
|
||||
|
||||
await db.execute(
|
||||
delete(EventLock).where(
|
||||
EventLock.locked_by == target_user_id,
|
||||
EventLock.event_id.in_(
|
||||
select(CalendarEvent.id).where(CalendarEvent.calendar_id == cal_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await db.delete(member)
|
||||
|
||||
remaining = await db.execute(
|
||||
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
|
||||
)
|
||||
if not remaining.scalar_one_or_none() and calendar:
|
||||
calendar.is_shared = False
|
||||
|
||||
action = "calendar.member_left" if is_self else "calendar.member_removed"
|
||||
await log_audit_event(
|
||||
db,
|
||||
action=action,
|
||||
actor_id=current_user.id,
|
||||
target_id=target_user_id,
|
||||
detail={"calendar_id": cal_id, "member_id": member_id},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# -- PUT /{cal_id}/members/me/color — Update local color -------------------
|
||||
|
||||
@router.put("/{cal_id}/members/me/color")
|
||||
async def update_local_color(
|
||||
body: UpdateLocalColorRequest,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update the current user local color for a shared calendar."""
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Membership not found")
|
||||
|
||||
member.local_color = body.local_color
|
||||
await db.commit()
|
||||
return {"message": "Color updated"}
|
||||
|
||||
|
||||
# -- GET /sync — Sync endpoint ---------------------------------------------
|
||||
|
||||
@router.get("/sync", response_model=SyncResponse)
|
||||
async def sync_shared_calendars(
|
||||
since: datetime = Query(...),
|
||||
calendar_ids: Optional[str] = Query(None, description="Comma-separated calendar IDs"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Sync events and member changes since a given timestamp. Cap 500 events."""
|
||||
MAX_EVENTS = 500
|
||||
|
||||
# Cap since to 7 days ago to prevent unbounded scans
|
||||
floor = datetime.now() - timedelta(days=7)
|
||||
if since < floor:
|
||||
since = floor
|
||||
|
||||
cal_id_list: list[int] = []
|
||||
if calendar_ids:
|
||||
for part in calendar_ids.split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
cal_id_list.append(int(part))
|
||||
cal_id_list = cal_id_list[:50] # Cap to prevent unbounded IN clause
|
||||
|
||||
owned_ids_result = await db.execute(
|
||||
select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
)
|
||||
owned_ids = {row[0] for row in owned_ids_result.all()}
|
||||
|
||||
member_ids_result = await db.execute(
|
||||
select(CalendarMember.calendar_id).where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
member_ids = {row[0] for row in member_ids_result.all()}
|
||||
|
||||
accessible = owned_ids | member_ids
|
||||
if cal_id_list:
|
||||
accessible = accessible & set(cal_id_list)
|
||||
|
||||
if not accessible:
|
||||
return SyncResponse(events=[], member_changes=[], server_time=datetime.now())
|
||||
|
||||
accessible_list = list(accessible)
|
||||
|
||||
events_result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.where(
|
||||
CalendarEvent.calendar_id.in_(accessible_list),
|
||||
CalendarEvent.updated_at >= since,
|
||||
)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.order_by(CalendarEvent.updated_at.desc())
|
||||
.limit(MAX_EVENTS + 1)
|
||||
)
|
||||
events = events_result.scalars().all()
|
||||
truncated = len(events) > MAX_EVENTS
|
||||
events = events[:MAX_EVENTS]
|
||||
|
||||
event_dicts = []
|
||||
for e in events:
|
||||
event_dicts.append({
|
||||
"id": e.id,
|
||||
"title": e.title,
|
||||
"start_datetime": e.start_datetime.isoformat() if e.start_datetime else None,
|
||||
"end_datetime": e.end_datetime.isoformat() if e.end_datetime else None,
|
||||
"all_day": e.all_day,
|
||||
"calendar_id": e.calendar_id,
|
||||
"calendar_name": e.calendar.name if e.calendar else "",
|
||||
"updated_at": e.updated_at.isoformat() if e.updated_at else None,
|
||||
"updated_by": e.updated_by,
|
||||
})
|
||||
|
||||
members_result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.calendar_id.in_(accessible_list),
|
||||
(CalendarMember.invited_at >= since) | (CalendarMember.accepted_at >= since),
|
||||
)
|
||||
.options(selectinload(CalendarMember.user))
|
||||
)
|
||||
member_changes = members_result.scalars().all()
|
||||
|
||||
member_dicts = []
|
||||
for m in member_changes:
|
||||
member_dicts.append({
|
||||
"id": m.id,
|
||||
"calendar_id": m.calendar_id,
|
||||
"user_id": m.user_id,
|
||||
"umbral_name": m.user.umbral_name if m.user else "",
|
||||
"permission": m.permission,
|
||||
"status": m.status,
|
||||
"invited_at": m.invited_at.isoformat() if m.invited_at else None,
|
||||
"accepted_at": m.accepted_at.isoformat() if m.accepted_at else None,
|
||||
})
|
||||
|
||||
return SyncResponse(
|
||||
events=event_dicts,
|
||||
member_changes=member_dicts,
|
||||
server_time=datetime.now(),
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
|
||||
# -- Event Lock Endpoints --------------------------------------------------
|
||||
|
||||
@router.post("/events/{event_id}/lock")
|
||||
async def lock_event(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Acquire a 5-minute editing lock on an event."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
|
||||
|
||||
lock = await acquire_lock(db, event_id, current_user.id)
|
||||
|
||||
# Build response BEFORE commit — ORM objects expire after commit
|
||||
response = {
|
||||
"locked": True,
|
||||
"locked_by_name": current_user.umbral_name,
|
||||
"expires_at": lock.expires_at,
|
||||
"is_permanent": lock.is_permanent,
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}/lock", status_code=204)
|
||||
async def unlock_event(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Release a lock. Only the holder or calendar owner can release."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
# SC-01: Verify caller has access to this calendar before revealing lock state
|
||||
perm = await get_user_permission(db, event.calendar_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock).where(EventLock.event_id == event_id)
|
||||
)
|
||||
lock = lock_result.scalar_one_or_none()
|
||||
if not lock:
|
||||
return None
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == event.calendar_id)
|
||||
)
|
||||
calendar = cal_result.scalar_one_or_none()
|
||||
is_owner = calendar and calendar.user_id == current_user.id
|
||||
|
||||
if lock.locked_by != current_user.id and not is_owner:
|
||||
raise HTTPException(status_code=403, detail="Only the lock holder or calendar owner can release")
|
||||
|
||||
await db.delete(lock)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/events/{event_id}/lock", response_model=LockStatusResponse)
|
||||
async def get_lock_status(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get the lock status of an event."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
perm = await get_user_permission(db, event.calendar_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock)
|
||||
.where(EventLock.event_id == event_id)
|
||||
.options(selectinload(EventLock.holder))
|
||||
)
|
||||
lock = lock_result.scalar_one_or_none()
|
||||
|
||||
if not lock:
|
||||
return LockStatusResponse(locked=False)
|
||||
|
||||
now = datetime.now()
|
||||
if not lock.is_permanent and lock.expires_at and lock.expires_at < now:
|
||||
await db.delete(lock)
|
||||
await db.commit()
|
||||
return LockStatusResponse(locked=False)
|
||||
|
||||
return LockStatusResponse(
|
||||
locked=True,
|
||||
locked_by_name=lock.holder.umbral_name if lock.holder else None,
|
||||
expires_at=lock.expires_at,
|
||||
is_permanent=lock.is_permanent,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/owner-lock")
|
||||
async def set_permanent_lock(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Set a permanent lock on an event. Calendar owner only."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can set permanent locks")
|
||||
|
||||
now = datetime.now()
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent)
|
||||
VALUES (:event_id, :user_id, :now, NULL, true)
|
||||
ON CONFLICT (event_id)
|
||||
DO UPDATE SET
|
||||
locked_by = :user_id,
|
||||
locked_at = :now,
|
||||
expires_at = NULL,
|
||||
is_permanent = true
|
||||
"""),
|
||||
{"event_id": event_id, "user_id": current_user.id, "now": now},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Permanent lock set", "is_permanent": True}
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}/owner-lock", status_code=204)
|
||||
async def remove_permanent_lock(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove a permanent lock. Calendar owner only."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can remove permanent locks")
|
||||
|
||||
await db.execute(
|
||||
delete(EventLock).where(
|
||||
EventLock.event_id == event_id,
|
||||
EventLock.is_permanent == True,
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return None
|
||||
@ -17,10 +17,10 @@ Security:
|
||||
- Failed TOTP attempts increment user.failed_login_count (shared lockout counter)
|
||||
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
@ -31,21 +31,17 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.session import UserSession
|
||||
from app.models.totp_usage import TOTPUsage
|
||||
from app.models.backup_code import BackupCode
|
||||
from app.routers.auth import get_current_user
|
||||
from app.routers.auth import get_current_user, _set_session_cookie
|
||||
from app.services.audit import get_client_ip
|
||||
from app.services.auth import (
|
||||
averify_password_with_upgrade,
|
||||
verify_password_with_upgrade,
|
||||
hash_password,
|
||||
verify_mfa_token,
|
||||
verify_mfa_enforce_token,
|
||||
)
|
||||
from app.services.session import (
|
||||
create_db_session,
|
||||
set_session_cookie,
|
||||
check_account_lockout,
|
||||
record_failed_login,
|
||||
record_successful_login,
|
||||
create_session_token,
|
||||
)
|
||||
from app.services.totp import (
|
||||
generate_totp_secret,
|
||||
@ -56,7 +52,7 @@ from app.services.totp import (
|
||||
generate_qr_base64,
|
||||
generate_backup_codes,
|
||||
)
|
||||
|
||||
from app.config import settings as app_settings
|
||||
|
||||
# Argon2id for backup code hashing — treat each code like a password
|
||||
from argon2 import PasswordHasher
|
||||
@ -121,10 +117,8 @@ class EnforceConfirmRequest(BaseModel):
|
||||
|
||||
async def _store_backup_codes(db: AsyncSession, user_id: int, plaintext_codes: list[str]) -> None:
|
||||
"""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:
|
||||
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))
|
||||
await db.commit()
|
||||
|
||||
@ -151,12 +145,9 @@ async def _verify_backup_code(
|
||||
)
|
||||
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:
|
||||
try:
|
||||
matched = await loop.run_in_executor(None, _ph.verify, record.code_hash, submitted_code)
|
||||
if matched:
|
||||
if _ph.verify(record.code_hash, submitted_code):
|
||||
record.used_at = datetime.now()
|
||||
await db.commit()
|
||||
return True
|
||||
@ -166,6 +157,29 @@ async def _verify_backup_code(
|
||||
return False
|
||||
|
||||
|
||||
async def _create_full_session(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
request: Request,
|
||||
) -> str:
|
||||
"""Create a UserSession row and return the signed cookie token."""
|
||||
session_id = uuid.uuid4().hex
|
||||
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
||||
ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
|
||||
db_session = UserSession(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip[:45] if ip else None,
|
||||
user_agent=(user_agent or "")[:255] if user_agent else None,
|
||||
)
|
||||
db.add(db_session)
|
||||
await db.commit()
|
||||
return create_session_token(user.id, session_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -269,55 +283,60 @@ async def totp_verify(
|
||||
raise HTTPException(status_code=400, detail="TOTP not configured for this account")
|
||||
|
||||
# Check account lockout (shared counter with password failures)
|
||||
await check_account_lockout(user)
|
||||
if user.locked_until and datetime.now() < user.locked_until:
|
||||
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=f"Account locked. Try again in {remaining} minutes.",
|
||||
)
|
||||
|
||||
# --- Backup code path ---
|
||||
if data.backup_code:
|
||||
normalized = data.backup_code.strip().upper()
|
||||
valid = await _verify_backup_code(db, user.id, normalized)
|
||||
if not valid:
|
||||
remaining = await record_failed_login(db, user)
|
||||
user.failed_login_count += 1
|
||||
if user.failed_login_count >= 10:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||
await db.commit()
|
||||
if remaining == 0:
|
||||
raise HTTPException(status_code=401, detail="Account temporarily locked. Try again in 30 minutes.")
|
||||
raise HTTPException(status_code=401, detail="Invalid backup code")
|
||||
|
||||
# Backup code accepted — reset lockout counter and issue session
|
||||
await record_successful_login(db, user)
|
||||
|
||||
ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
token = await _create_full_session(db, user, request)
|
||||
_set_session_cookie(response, token)
|
||||
return {"authenticated": True}
|
||||
|
||||
# --- TOTP code path ---
|
||||
matched_window = verify_totp_code(user.totp_secret, data.code)
|
||||
if matched_window is None:
|
||||
remaining = await record_failed_login(db, user)
|
||||
user.failed_login_count += 1
|
||||
if user.failed_login_count >= 10:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||
await db.commit()
|
||||
if remaining == 0:
|
||||
raise HTTPException(status_code=401, detail="Account temporarily locked. Try again in 30 minutes.")
|
||||
raise HTTPException(status_code=401, detail="Invalid code")
|
||||
|
||||
# Replay prevention — record (user_id, code, actual_matching_window)
|
||||
totp_record = TOTPUsage(user_id=user.id, code=data.code, window=matched_window)
|
||||
db.add(totp_record)
|
||||
try:
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=401, detail="Code already used — wait for the next code")
|
||||
|
||||
# Success — reset lockout counter, update last_login_at, issue full session
|
||||
await record_successful_login(db, user)
|
||||
|
||||
ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
token = await _create_full_session(db, user, request)
|
||||
_set_session_cookie(response, token)
|
||||
return {"authenticated": True}
|
||||
|
||||
|
||||
@ -336,8 +355,7 @@ async def totp_disable(
|
||||
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||
|
||||
# Verify password (handles bcrypt→Argon2id upgrade transparently)
|
||||
# AC-2: async wrapper to avoid blocking event loop
|
||||
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:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
@ -373,8 +391,7 @@ async def regenerate_backup_codes(
|
||||
if not current_user.totp_enabled:
|
||||
raise HTTPException(status_code=400, detail="TOTP is not enabled")
|
||||
|
||||
# AC-2: async wrapper to avoid blocking event loop
|
||||
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:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
@ -489,11 +506,9 @@ async def enforce_confirm_totp(
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
# Issue a full session (now uses shared session service with cap enforcement)
|
||||
ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
# Issue a full session
|
||||
token = await _create_full_session(db, user, request)
|
||||
_set_session_cookie(response, token)
|
||||
|
||||
return {"authenticated": True}
|
||||
|
||||
|
||||
@ -30,7 +30,6 @@ class UserListItem(BaseModel):
|
||||
last_password_change_at: Optional[datetime] = None
|
||||
totp_enabled: bool
|
||||
mfa_enforce_pending: bool
|
||||
passwordless_enabled: bool = False
|
||||
created_at: datetime
|
||||
active_sessions: int = 0
|
||||
|
||||
@ -108,7 +107,6 @@ class ToggleMfaEnforceRequest(BaseModel):
|
||||
class SystemConfigResponse(BaseModel):
|
||||
allow_registration: bool
|
||||
enforce_mfa_new_users: bool
|
||||
allow_passwordless: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@ -117,12 +115,6 @@ class SystemConfigUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
allow_registration: Optional[bool] = None
|
||||
enforce_mfa_new_users: Optional[bool] = None
|
||||
allow_passwordless: Optional[bool] = None
|
||||
|
||||
|
||||
class TogglePasswordlessRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
enabled: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -25,13 +25,7 @@ class CalendarResponse(BaseModel):
|
||||
is_default: bool
|
||||
is_system: bool
|
||||
is_visible: bool
|
||||
is_shared: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
owner_umbral_name: Optional[str] = None
|
||||
my_permission: Optional[str] = None
|
||||
my_can_add_others: bool = False
|
||||
my_local_color: Optional[str] = None
|
||||
member_count: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import json as _json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
@ -17,20 +17,6 @@ class RecurrenceRule(BaseModel):
|
||||
# monthly_date
|
||||
day: Optional[int] = Field(None, ge=1, le=31)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_required_fields(self):
|
||||
"""Enforce required fields per rule type."""
|
||||
if self.type == "every_n_days" and self.interval is None:
|
||||
raise ValueError("every_n_days rule requires 'interval'")
|
||||
if self.type == "weekly" and self.weekday is None:
|
||||
raise ValueError("weekly rule requires 'weekday'")
|
||||
if self.type == "monthly_nth_weekday":
|
||||
if self.week is None or self.weekday is None:
|
||||
raise ValueError("monthly_nth_weekday rule requires both 'week' and 'weekday'")
|
||||
if self.type == "monthly_date" and self.day is None:
|
||||
raise ValueError("monthly_date rule requires 'day'")
|
||||
return self
|
||||
|
||||
|
||||
def _coerce_recurrence_rule(v):
|
||||
"""Accept None, dict, RecurrenceRule, or JSON/legacy strings gracefully."""
|
||||
@ -61,10 +47,10 @@ class CalendarEventCreate(BaseModel):
|
||||
end_datetime: datetime
|
||||
all_day: bool = False
|
||||
color: Optional[str] = Field(None, max_length=20)
|
||||
location_id: Optional[int] = Field(None, ge=1, le=2147483647)
|
||||
location_id: Optional[int] = None
|
||||
recurrence_rule: Optional[RecurrenceRule] = None
|
||||
is_starred: bool = False
|
||||
calendar_id: Optional[int] = Field(None, ge=1, le=2147483647)
|
||||
calendar_id: Optional[int] = None # If None, server assigns default calendar
|
||||
|
||||
@field_validator("recurrence_rule", mode="before")
|
||||
@classmethod
|
||||
@ -81,10 +67,10 @@ class CalendarEventUpdate(BaseModel):
|
||||
end_datetime: Optional[datetime] = None
|
||||
all_day: Optional[bool] = None
|
||||
color: Optional[str] = Field(None, max_length=20)
|
||||
location_id: Optional[int] = Field(None, ge=1, le=2147483647)
|
||||
location_id: Optional[int] = None
|
||||
recurrence_rule: Optional[RecurrenceRule] = None
|
||||
is_starred: Optional[bool] = None
|
||||
calendar_id: Optional[int] = Field(None, ge=1, le=2147483647)
|
||||
calendar_id: Optional[int] = None
|
||||
# Controls which occurrences an edit applies to; absent = non-recurring or whole-series
|
||||
edit_scope: Optional[Literal["this", "this_and_future"]] = None
|
||||
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
from typing import Annotated, Literal, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class EventInvitationCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(..., min_length=1, max_length=20)
|
||||
|
||||
|
||||
class EventInvitationRespond(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
status: Literal["accepted", "tentative", "declined"]
|
||||
|
||||
|
||||
class EventInvitationOverrideCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
status: Literal["accepted", "tentative", "declined"]
|
||||
|
||||
|
||||
class UpdateDisplayCalendar(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
calendar_id: Annotated[int, Field(ge=1, le=2147483647)]
|
||||
|
||||
|
||||
class UpdateCanModify(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
can_modify: bool
|
||||
|
||||
|
||||
class EventInvitationResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
event_id: int
|
||||
user_id: int
|
||||
invited_by: Optional[int]
|
||||
status: str
|
||||
invited_at: datetime
|
||||
responded_at: Optional[datetime]
|
||||
invitee_name: Optional[str] = None
|
||||
invitee_umbral_name: Optional[str] = None
|
||||
can_modify: bool = False
|
||||
@ -1,11 +1,8 @@
|
||||
import logging
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List, Literal
|
||||
from app.schemas.project_task import ProjectTaskResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "review", "on_hold"]
|
||||
|
||||
|
||||
@ -33,44 +30,18 @@ class ProjectUpdate(BaseModel):
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int = 0
|
||||
name: str
|
||||
description: Optional[str]
|
||||
status: str
|
||||
color: Optional[str]
|
||||
due_date: Optional[date]
|
||||
is_tracked: bool
|
||||
member_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tasks: List[ProjectTaskResponse] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def compute_member_count(cls, data): # type: ignore[override]
|
||||
"""Compute member_count from eagerly loaded members relationship."""
|
||||
if hasattr(data, "members"):
|
||||
try:
|
||||
data = dict(
|
||||
id=data.id,
|
||||
user_id=data.user_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
status=data.status,
|
||||
color=data.color,
|
||||
due_date=data.due_date,
|
||||
is_tracked=data.is_tracked,
|
||||
member_count=len([m for m in data.members if m.status == "accepted"]),
|
||||
created_at=data.created_at,
|
||||
updated_at=data.updated_at,
|
||||
tasks=data.tasks,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("member_count compute skipped: %s", exc)
|
||||
return data
|
||||
|
||||
|
||||
class TrackedTaskResponse(BaseModel):
|
||||
id: int
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
from typing import Annotated, Optional, Literal
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime
|
||||
|
||||
MemberPermission = Literal["read_only", "create_modify"]
|
||||
MemberStatus = Literal["pending", "accepted", "rejected"]
|
||||
InviteResponse = Literal["accepted", "rejected"]
|
||||
|
||||
|
||||
class ProjectMemberInvite(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=10)
|
||||
permission: MemberPermission = "create_modify"
|
||||
|
||||
|
||||
class ProjectMemberUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
permission: MemberPermission
|
||||
|
||||
|
||||
class ProjectMemberRespond(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
response: InviteResponse
|
||||
|
||||
|
||||
class ProjectMemberResponse(BaseModel):
|
||||
id: int
|
||||
project_id: int
|
||||
user_id: int
|
||||
invited_by: int
|
||||
permission: str
|
||||
status: str
|
||||
source: str
|
||||
user_name: str | None = None
|
||||
inviter_name: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
accepted_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@ -2,7 +2,6 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List, Literal
|
||||
from app.schemas.task_comment import TaskCommentResponse
|
||||
from app.schemas.project_task_assignment import TaskAssignmentResponse
|
||||
|
||||
TaskStatus = Literal["pending", "in_progress", "completed", "blocked", "review", "on_hold"]
|
||||
TaskPriority = Literal["none", "low", "medium", "high"]
|
||||
@ -31,7 +30,6 @@ class ProjectTaskUpdate(BaseModel):
|
||||
due_date: Optional[date] = None
|
||||
person_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
version: Optional[int] = None # For optimistic locking
|
||||
|
||||
|
||||
class ProjectTaskResponse(BaseModel):
|
||||
@ -45,12 +43,10 @@ class ProjectTaskResponse(BaseModel):
|
||||
due_date: Optional[date]
|
||||
person_id: Optional[int]
|
||||
sort_order: int
|
||||
version: int = 1
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
subtasks: List["ProjectTaskResponse"] = []
|
||||
comments: List[TaskCommentResponse] = []
|
||||
assignments: List[TaskAssignmentResponse] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
from typing import Annotated
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TaskAssignmentCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class TaskAssignmentResponse(BaseModel):
|
||||
id: int
|
||||
task_id: int
|
||||
user_id: int
|
||||
assigned_by: int
|
||||
user_name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_user_name(cls, data): # type: ignore[override]
|
||||
"""Populate user_name from eagerly loaded user relationship."""
|
||||
if hasattr(data, "user") and data.user is not None and not getattr(data, "user_name", None):
|
||||
# Build dict from ORM columns so new fields are auto-included
|
||||
cols = {c.key: getattr(data, c.key) for c in data.__table__.columns}
|
||||
cols["user_name"] = data.user.username
|
||||
return cols
|
||||
return data
|
||||
@ -27,7 +27,7 @@ class ReminderUpdate(BaseModel):
|
||||
class ReminderSnooze(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
minutes: int = Field(ge=1, le=1440)
|
||||
minutes: Literal[5, 10, 15]
|
||||
client_now: Optional[datetime] = None
|
||||
|
||||
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
import re
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from typing import Optional, Literal
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class InviteMemberRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
connection_id: int = Field(ge=1, le=2147483647)
|
||||
permission: Literal["read_only", "create_modify", "full_access"]
|
||||
can_add_others: bool = False
|
||||
|
||||
|
||||
class RespondInviteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
action: Literal["accept", "reject"]
|
||||
|
||||
|
||||
class UpdateMemberRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
permission: Optional[Literal["read_only", "create_modify", "full_access"]] = None
|
||||
can_add_others: Optional[bool] = None
|
||||
|
||||
|
||||
class UpdateLocalColorRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
local_color: Optional[str] = Field(None, max_length=20)
|
||||
|
||||
@field_validator("local_color")
|
||||
@classmethod
|
||||
def validate_color(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is not None and not re.match(r"^#[0-9a-fA-F]{6}$", v):
|
||||
raise ValueError("Color must be a hex color code (#RRGGBB)")
|
||||
return v
|
||||
|
||||
|
||||
|
||||
class CalendarMemberResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
calendar_id: int
|
||||
user_id: int
|
||||
umbral_name: str
|
||||
preferred_name: Optional[str] = None
|
||||
permission: str
|
||||
can_add_others: bool
|
||||
local_color: Optional[str] = None
|
||||
status: str
|
||||
invited_at: datetime
|
||||
accepted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CalendarInviteResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
calendar_id: int
|
||||
calendar_name: str
|
||||
calendar_color: str
|
||||
owner_umbral_name: str
|
||||
inviter_umbral_name: str
|
||||
permission: str
|
||||
invited_at: datetime
|
||||
|
||||
|
||||
class LockStatusResponse(BaseModel):
|
||||
locked: bool
|
||||
locked_by_name: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
is_permanent: bool = False
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
events: list[dict]
|
||||
member_changes: list[dict]
|
||||
server_time: datetime
|
||||
truncated: bool = False
|
||||
@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@ -11,19 +11,7 @@ class TaskCommentCreate(BaseModel):
|
||||
class TaskCommentResponse(BaseModel):
|
||||
id: int
|
||||
task_id: int
|
||||
user_id: int | None = None
|
||||
author_name: str | None = None
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_author_name(cls, data): # type: ignore[override]
|
||||
"""Populate author_name from eagerly loaded user relationship."""
|
||||
if hasattr(data, "user") and data.user is not None and not getattr(data, "author_name", None):
|
||||
cols = {c.key: getattr(data, c.key) for c in data.__table__.columns}
|
||||
cols["author_name"] = data.user.username
|
||||
return cols
|
||||
return data
|
||||
|
||||
@ -6,8 +6,6 @@ Password strategy:
|
||||
- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
|
||||
rehashed to Argon2id on first successful use.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,281 +0,0 @@
|
||||
"""
|
||||
Calendar sharing service — permission checks, lock management, disconnect cascade.
|
||||
|
||||
All functions accept an AsyncSession and do NOT commit — callers manage transactions.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, literal_column, select, text, union_all, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERMISSION_RANK = {"read_only": 1, "create_modify": 2, "full_access": 3}
|
||||
LOCK_DURATION_MINUTES = 5
|
||||
|
||||
|
||||
async def get_accessible_calendar_ids(user_id: int, db: AsyncSession) -> list[int]:
|
||||
"""Return all calendar IDs the user can access (owned + accepted shared memberships)."""
|
||||
result = await db.execute(
|
||||
select(Calendar.id).where(Calendar.user_id == user_id)
|
||||
.union(
|
||||
select(CalendarMember.calendar_id).where(
|
||||
CalendarMember.user_id == user_id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
)
|
||||
return [r[0] for r in result.all()]
|
||||
|
||||
|
||||
async def get_accessible_event_scope(
|
||||
user_id: int, db: AsyncSession
|
||||
) -> tuple[list[int], list[int]]:
|
||||
"""
|
||||
Returns (calendar_ids, invited_parent_event_ids) in a single DB round-trip.
|
||||
calendar_ids: all calendars the user can access (owned + accepted shared).
|
||||
invited_parent_event_ids: event IDs where the user has a non-declined invitation.
|
||||
"""
|
||||
from app.models.event_invitation import EventInvitation
|
||||
|
||||
result = await db.execute(
|
||||
union_all(
|
||||
select(literal_column("'c'").label("kind"), Calendar.id.label("val"))
|
||||
.where(Calendar.user_id == user_id),
|
||||
select(literal_column("'c'"), CalendarMember.calendar_id)
|
||||
.where(
|
||||
CalendarMember.user_id == user_id,
|
||||
CalendarMember.status == "accepted",
|
||||
),
|
||||
select(literal_column("'i'"), EventInvitation.event_id)
|
||||
.where(
|
||||
EventInvitation.user_id == user_id,
|
||||
EventInvitation.status != "declined",
|
||||
),
|
||||
)
|
||||
)
|
||||
cal_ids: list[int] = []
|
||||
inv_ids: list[int] = []
|
||||
for kind, val in result.all():
|
||||
if kind == "c":
|
||||
cal_ids.append(val)
|
||||
else:
|
||||
inv_ids.append(val)
|
||||
return cal_ids, inv_ids
|
||||
|
||||
|
||||
async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None:
|
||||
"""
|
||||
Returns "owner" if the user owns the calendar, the permission string
|
||||
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(
|
||||
select(
|
||||
Calendar.user_id,
|
||||
CalendarMember.permission,
|
||||
)
|
||||
.outerjoin(
|
||||
CalendarMember,
|
||||
(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
|
||||
owner_id, member_permission = row.tuple()
|
||||
if owner_id == user_id:
|
||||
return "owner"
|
||||
return member_permission
|
||||
|
||||
|
||||
async def require_permission(
|
||||
db: AsyncSession, calendar_id: int, user_id: int, min_level: str
|
||||
) -> str:
|
||||
"""
|
||||
Raises 403 if the user lacks at least min_level permission.
|
||||
Returns the actual permission string (or "owner").
|
||||
"""
|
||||
perm = await get_user_permission(db, calendar_id, user_id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
if perm == "owner":
|
||||
return "owner"
|
||||
if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission on this calendar")
|
||||
return perm
|
||||
|
||||
|
||||
async def acquire_lock(db: AsyncSession, event_id: int, user_id: int) -> EventLock:
|
||||
"""
|
||||
Atomic INSERT ON CONFLICT — acquires a 5-minute lock on the event.
|
||||
Only succeeds if no unexpired lock exists or the existing lock is held by the same user.
|
||||
Permanent locks are never overwritten — if the same user holds one, it is returned as-is.
|
||||
Returns the lock or raises 423 Locked.
|
||||
"""
|
||||
# Check for existing permanent lock first
|
||||
existing = await db.execute(
|
||||
select(EventLock).where(EventLock.event_id == event_id)
|
||||
)
|
||||
existing_lock = existing.scalar_one_or_none()
|
||||
if existing_lock and existing_lock.is_permanent:
|
||||
if existing_lock.locked_by == user_id:
|
||||
# Owner holds permanent lock — return it without downgrading
|
||||
return existing_lock
|
||||
raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner")
|
||||
|
||||
now = datetime.now()
|
||||
expires = now + timedelta(minutes=LOCK_DURATION_MINUTES)
|
||||
|
||||
result = await db.execute(
|
||||
text("""
|
||||
INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent)
|
||||
VALUES (:event_id, :user_id, :now, :expires, false)
|
||||
ON CONFLICT (event_id)
|
||||
DO UPDATE SET
|
||||
locked_by = :user_id,
|
||||
locked_at = :now,
|
||||
expires_at = :expires,
|
||||
is_permanent = false
|
||||
WHERE event_locks.expires_at < :now
|
||||
OR event_locks.locked_by = :user_id
|
||||
RETURNING id, event_id, locked_by, locked_at, expires_at, is_permanent
|
||||
"""),
|
||||
{"event_id": event_id, "user_id": user_id, "now": now, "expires": expires},
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=423, detail="Event is locked by another user")
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock).where(EventLock.id == row.id)
|
||||
)
|
||||
return lock_result.scalar_one()
|
||||
|
||||
|
||||
async def release_lock(db: AsyncSession, event_id: int, user_id: int) -> None:
|
||||
"""Delete the lock only if held by this user."""
|
||||
await db.execute(
|
||||
delete(EventLock).where(
|
||||
EventLock.event_id == event_id,
|
||||
EventLock.locked_by == user_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def check_lock_for_edit(
|
||||
db: AsyncSession, event_id: int, user_id: int, calendar_id: int
|
||||
) -> None:
|
||||
"""
|
||||
For shared calendars: verify no active lock by another user blocks this edit.
|
||||
For personal (non-shared) calendars: no-op.
|
||||
"""
|
||||
cal_result = await db.execute(
|
||||
select(Calendar.is_shared).where(Calendar.id == calendar_id)
|
||||
)
|
||||
is_shared = cal_result.scalar_one_or_none()
|
||||
if not is_shared:
|
||||
return
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock).where(EventLock.event_id == event_id)
|
||||
)
|
||||
lock = lock_result.scalar_one_or_none()
|
||||
if not lock:
|
||||
return
|
||||
now = datetime.now()
|
||||
if lock.is_permanent and lock.locked_by != user_id:
|
||||
raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner")
|
||||
if lock.locked_by != user_id and (lock.expires_at is None or lock.expires_at > now):
|
||||
raise HTTPException(status_code=423, detail="Event is locked by another user")
|
||||
|
||||
|
||||
async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int) -> None:
|
||||
"""
|
||||
When a connection is severed:
|
||||
1. Delete CalendarMember rows where one user is a member of the other's calendars
|
||||
2. Delete EventLock rows held by the disconnected user on affected calendars
|
||||
3. Reset is_shared=False on calendars with no remaining members
|
||||
"""
|
||||
# Find calendars owned by each user
|
||||
a_cal_ids_result = await db.execute(
|
||||
select(Calendar.id).where(Calendar.user_id == user_a_id)
|
||||
)
|
||||
a_cal_ids = [row[0] for row in a_cal_ids_result.all()]
|
||||
|
||||
b_cal_ids_result = await db.execute(
|
||||
select(Calendar.id).where(Calendar.user_id == user_b_id)
|
||||
)
|
||||
b_cal_ids = [row[0] for row in b_cal_ids_result.all()]
|
||||
|
||||
# Delete user_b's memberships on user_a's calendars + locks
|
||||
if a_cal_ids:
|
||||
await db.execute(
|
||||
delete(CalendarMember).where(
|
||||
CalendarMember.calendar_id.in_(a_cal_ids),
|
||||
CalendarMember.user_id == user_b_id,
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
text("""
|
||||
DELETE FROM event_locks
|
||||
WHERE locked_by = :user_id
|
||||
AND event_id IN (
|
||||
SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids)
|
||||
)
|
||||
"""),
|
||||
{"user_id": user_b_id, "cal_ids": a_cal_ids},
|
||||
)
|
||||
|
||||
# Delete user_a's memberships on user_b's calendars + locks
|
||||
if b_cal_ids:
|
||||
await db.execute(
|
||||
delete(CalendarMember).where(
|
||||
CalendarMember.calendar_id.in_(b_cal_ids),
|
||||
CalendarMember.user_id == user_a_id,
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
text("""
|
||||
DELETE FROM event_locks
|
||||
WHERE locked_by = :user_id
|
||||
AND event_id IN (
|
||||
SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids)
|
||||
)
|
||||
"""),
|
||||
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
||||
)
|
||||
|
||||
# Clean up event invitations between the two users
|
||||
from app.services.event_invitation import cascade_event_invitations_on_disconnect
|
||||
await cascade_event_invitations_on_disconnect(db, user_a_id, user_b_id)
|
||||
|
||||
# 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
|
||||
empty_cal_ids = [cid for cid in all_cal_ids if cid not in cals_with_members]
|
||||
if empty_cal_ids:
|
||||
await db.execute(
|
||||
update(Calendar)
|
||||
.where(Calendar.id.in_(empty_cal_ids))
|
||||
.values(is_shared=False)
|
||||
)
|
||||
@ -9,7 +9,6 @@ from datetime import date as date_type
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.person import Person
|
||||
@ -135,25 +134,6 @@ def create_person_from_connection(
|
||||
)
|
||||
|
||||
|
||||
|
||||
async def sync_birthday_to_contacts(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
share_birthday: bool,
|
||||
date_of_birth: Optional[date_type],
|
||||
) -> None:
|
||||
"""Sync user's DOB to all Person records where linked_user_id == user_id.
|
||||
Caller passes resolved values — no internal re-query."""
|
||||
new_birthday = date_of_birth if share_birthday else None
|
||||
|
||||
result = await db.execute(
|
||||
update(Person)
|
||||
.where(Person.linked_user_id == user_id)
|
||||
.values(birthday=new_birthday)
|
||||
)
|
||||
logger.info("sync_birthday_to_contacts user_id=%s updated %s person(s)", user_id, result.rowcount)
|
||||
|
||||
|
||||
async def detach_umbral_contact(person: Person) -> None:
|
||||
"""Convert an umbral contact back to a standard contact. Does NOT commit.
|
||||
|
||||
|
||||
@ -1,421 +0,0 @@
|
||||
"""
|
||||
Event invitation service — send, respond, override, dismiss invitations.
|
||||
|
||||
All functions accept an AsyncSession and do NOT commit — callers manage transactions.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||
from app.models.user_connection import UserConnection
|
||||
from app.models.settings import Settings
|
||||
from app.models.user import User
|
||||
from app.services.notification import create_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def validate_connections(
|
||||
db: AsyncSession, inviter_id: int, user_ids: list[int]
|
||||
) -> None:
|
||||
"""Verify bidirectional connections exist for all invitees. Raises 404 on failure."""
|
||||
if not user_ids:
|
||||
return
|
||||
result = await db.execute(
|
||||
select(UserConnection.connected_user_id).where(
|
||||
UserConnection.user_id == inviter_id,
|
||||
UserConnection.connected_user_id.in_(user_ids),
|
||||
)
|
||||
)
|
||||
connected_ids = {r[0] for r in result.all()}
|
||||
missing = set(user_ids) - connected_ids
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail="One or more users not found in your connections")
|
||||
|
||||
|
||||
async def send_event_invitations(
|
||||
db: AsyncSession,
|
||||
event_id: int,
|
||||
user_ids: list[int],
|
||||
invited_by: int,
|
||||
) -> list[EventInvitation]:
|
||||
"""
|
||||
Bulk-insert invitations for an event. Skips self-invites and existing invitations.
|
||||
Creates in-app notifications for each invitee.
|
||||
"""
|
||||
# Remove self from list
|
||||
user_ids = [uid for uid in user_ids if uid != invited_by]
|
||||
if not user_ids:
|
||||
raise HTTPException(status_code=400, detail="Cannot invite yourself")
|
||||
|
||||
# Validate connections
|
||||
await validate_connections(db, invited_by, user_ids)
|
||||
|
||||
# Check existing invitations to skip duplicates
|
||||
existing_result = await db.execute(
|
||||
select(EventInvitation.user_id).where(
|
||||
EventInvitation.event_id == event_id,
|
||||
EventInvitation.user_id.in_(user_ids),
|
||||
)
|
||||
)
|
||||
existing_ids = {r[0] for r in existing_result.all()}
|
||||
|
||||
# Cap: max 20 invitations per event
|
||||
count_result = await db.execute(
|
||||
select(func.count(EventInvitation.id)).where(EventInvitation.event_id == event_id)
|
||||
)
|
||||
current_count = count_result.scalar_one()
|
||||
new_ids = [uid for uid in user_ids if uid not in existing_ids]
|
||||
if current_count + len(new_ids) > 20:
|
||||
raise HTTPException(status_code=400, detail="Maximum 20 invitations per event")
|
||||
|
||||
if not new_ids:
|
||||
return []
|
||||
|
||||
# Fetch event title for notifications
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent.title, CalendarEvent.start_datetime).where(
|
||||
CalendarEvent.id == event_id
|
||||
)
|
||||
)
|
||||
event_row = event_result.one_or_none()
|
||||
event_title = event_row[0] if event_row else "an event"
|
||||
event_start = event_row[1] if event_row else None
|
||||
|
||||
# Fetch inviter's name
|
||||
inviter_settings = await db.execute(
|
||||
select(Settings.preferred_name).where(Settings.user_id == invited_by)
|
||||
)
|
||||
inviter_name_row = inviter_settings.one_or_none()
|
||||
inviter_name = inviter_name_row[0] if inviter_name_row and inviter_name_row[0] else "Someone"
|
||||
|
||||
invitations = []
|
||||
for uid in new_ids:
|
||||
inv = EventInvitation(
|
||||
event_id=event_id,
|
||||
user_id=uid,
|
||||
invited_by=invited_by,
|
||||
status="pending",
|
||||
)
|
||||
db.add(inv)
|
||||
invitations.append(inv)
|
||||
|
||||
# Flush to populate invitation IDs before creating notifications
|
||||
await db.flush()
|
||||
|
||||
for inv in invitations:
|
||||
start_str = event_start.strftime("%b %d, %I:%M %p") if event_start else ""
|
||||
await create_notification(
|
||||
db=db,
|
||||
user_id=inv.user_id,
|
||||
type="event_invite",
|
||||
title="Event Invitation",
|
||||
message=f"{inviter_name} invited you to {event_title}" + (f" · {start_str}" if start_str else ""),
|
||||
data={"event_id": event_id, "event_title": event_title, "invitation_id": inv.id},
|
||||
source_type="event_invitation",
|
||||
source_id=event_id,
|
||||
)
|
||||
|
||||
return invitations
|
||||
|
||||
|
||||
async def respond_to_invitation(
|
||||
db: AsyncSession,
|
||||
invitation_id: int,
|
||||
user_id: int,
|
||||
status: str,
|
||||
) -> EventInvitation:
|
||||
"""Update invitation status. Returns the updated invitation."""
|
||||
result = await db.execute(
|
||||
select(EventInvitation)
|
||||
.options(selectinload(EventInvitation.event))
|
||||
.where(
|
||||
EventInvitation.id == invitation_id,
|
||||
EventInvitation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
invitation = result.scalar_one_or_none()
|
||||
if not invitation:
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
|
||||
# Build response data before modifying
|
||||
event_title = invitation.event.title
|
||||
old_status = invitation.status
|
||||
|
||||
invitation.status = status
|
||||
invitation.responded_at = datetime.now()
|
||||
|
||||
# Clear can_modify on decline (F-02: prevent silent re-grant)
|
||||
if status == "declined":
|
||||
invitation.can_modify = False
|
||||
|
||||
# Auto-assign display calendar on accept/tentative (atomic: only if not already set)
|
||||
if status in ("accepted", "tentative"):
|
||||
default_cal = await db.execute(
|
||||
select(Calendar.id).where(
|
||||
Calendar.user_id == user_id,
|
||||
Calendar.is_default == True,
|
||||
).limit(1)
|
||||
)
|
||||
default_cal_id = default_cal.scalar_one_or_none()
|
||||
if default_cal_id and invitation.display_calendar_id is None:
|
||||
# Atomic: only set if still NULL (race-safe)
|
||||
await db.execute(
|
||||
update(EventInvitation)
|
||||
.where(
|
||||
EventInvitation.id == invitation_id,
|
||||
EventInvitation.display_calendar_id == None,
|
||||
)
|
||||
.values(display_calendar_id=default_cal_id)
|
||||
)
|
||||
invitation.display_calendar_id = default_cal_id
|
||||
|
||||
# Notify the inviter only if status actually changed (prevents duplicate notifications)
|
||||
if invitation.invited_by and old_status != status:
|
||||
status_label = {"accepted": "Going", "tentative": "Tentative", "declined": "Declined"}
|
||||
# Fetch responder name
|
||||
responder_settings = await db.execute(
|
||||
select(Settings.preferred_name).where(Settings.user_id == user_id)
|
||||
)
|
||||
responder_row = responder_settings.one_or_none()
|
||||
responder_name = responder_row[0] if responder_row and responder_row[0] else "Someone"
|
||||
|
||||
await create_notification(
|
||||
db=db,
|
||||
user_id=invitation.invited_by,
|
||||
type="event_invite_response",
|
||||
title="Event RSVP",
|
||||
message=f"{responder_name} is {status_label.get(status, status)} for {event_title}",
|
||||
data={"event_id": invitation.event_id, "status": status},
|
||||
source_type="event_invitation",
|
||||
source_id=invitation.event_id,
|
||||
)
|
||||
|
||||
return invitation
|
||||
|
||||
|
||||
async def override_occurrence_status(
|
||||
db: AsyncSession,
|
||||
invitation_id: int,
|
||||
occurrence_id: int,
|
||||
user_id: int,
|
||||
status: str,
|
||||
) -> EventInvitationOverride:
|
||||
"""Create or update a per-occurrence status override."""
|
||||
# Verify invitation belongs to user
|
||||
inv_result = await db.execute(
|
||||
select(EventInvitation).where(
|
||||
EventInvitation.id == invitation_id,
|
||||
EventInvitation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
invitation = inv_result.scalar_one_or_none()
|
||||
if not invitation:
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
if invitation.status not in ("accepted", "tentative"):
|
||||
raise HTTPException(status_code=400, detail="Must accept or tentatively accept the invitation first")
|
||||
|
||||
# Verify occurrence belongs to the invited event's series
|
||||
occ_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == occurrence_id)
|
||||
)
|
||||
occurrence = occ_result.scalar_one_or_none()
|
||||
if not occurrence:
|
||||
raise HTTPException(status_code=404, detail="Occurrence not found")
|
||||
|
||||
# Occurrence must be the event itself OR a child of the invited event
|
||||
if occurrence.id != invitation.event_id and occurrence.parent_event_id != invitation.event_id:
|
||||
raise HTTPException(status_code=400, detail="Occurrence does not belong to this event series")
|
||||
|
||||
# Upsert override
|
||||
existing = await db.execute(
|
||||
select(EventInvitationOverride).where(
|
||||
EventInvitationOverride.invitation_id == invitation_id,
|
||||
EventInvitationOverride.occurrence_id == occurrence_id,
|
||||
)
|
||||
)
|
||||
override = existing.scalar_one_or_none()
|
||||
if override:
|
||||
override.status = status
|
||||
override.responded_at = datetime.now()
|
||||
else:
|
||||
override = EventInvitationOverride(
|
||||
invitation_id=invitation_id,
|
||||
occurrence_id=occurrence_id,
|
||||
status=status,
|
||||
responded_at=datetime.now(),
|
||||
)
|
||||
db.add(override)
|
||||
|
||||
return override
|
||||
|
||||
|
||||
async def dismiss_invitation(
|
||||
db: AsyncSession,
|
||||
invitation_id: int,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Delete an invitation (invitee leaving or owner revoking)."""
|
||||
result = await db.execute(
|
||||
delete(EventInvitation).where(
|
||||
EventInvitation.id == invitation_id,
|
||||
EventInvitation.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
|
||||
|
||||
async def dismiss_invitation_by_owner(
|
||||
db: AsyncSession,
|
||||
invitation_id: int,
|
||||
) -> None:
|
||||
"""Delete an invitation by the event owner (revoking)."""
|
||||
result = await db.execute(
|
||||
delete(EventInvitation).where(EventInvitation.id == invitation_id)
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Invitation not found")
|
||||
|
||||
|
||||
async def get_event_invitations(
|
||||
db: AsyncSession,
|
||||
event_id: int,
|
||||
) -> list[dict]:
|
||||
"""Get all invitations for an event with invitee names."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
EventInvitation,
|
||||
Settings.preferred_name,
|
||||
User.umbral_name,
|
||||
)
|
||||
.join(User, EventInvitation.user_id == User.id)
|
||||
.outerjoin(Settings, Settings.user_id == User.id)
|
||||
.where(EventInvitation.event_id == event_id)
|
||||
.order_by(EventInvitation.invited_at.asc())
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": inv.id,
|
||||
"event_id": inv.event_id,
|
||||
"user_id": inv.user_id,
|
||||
"invited_by": inv.invited_by,
|
||||
"status": inv.status,
|
||||
"invited_at": inv.invited_at,
|
||||
"responded_at": inv.responded_at,
|
||||
"invitee_name": preferred_name or umbral_name or "Unknown",
|
||||
"invitee_umbral_name": umbral_name or "Unknown",
|
||||
"can_modify": inv.can_modify,
|
||||
}
|
||||
for inv, preferred_name, umbral_name in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_invited_event_ids(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> list[int]:
|
||||
"""Return event IDs where user has a non-declined invitation."""
|
||||
result = await db.execute(
|
||||
select(EventInvitation.event_id).where(
|
||||
EventInvitation.user_id == user_id,
|
||||
EventInvitation.status != "declined",
|
||||
)
|
||||
)
|
||||
return [r[0] for r in result.all()]
|
||||
|
||||
|
||||
async def get_pending_invitations(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> list[dict]:
|
||||
"""Return pending invitations for the current user."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
EventInvitation,
|
||||
CalendarEvent.title,
|
||||
CalendarEvent.start_datetime,
|
||||
Settings.preferred_name,
|
||||
)
|
||||
.join(CalendarEvent, EventInvitation.event_id == CalendarEvent.id)
|
||||
.outerjoin(
|
||||
User, EventInvitation.invited_by == User.id
|
||||
)
|
||||
.outerjoin(
|
||||
Settings, Settings.user_id == User.id
|
||||
)
|
||||
.where(
|
||||
EventInvitation.user_id == user_id,
|
||||
EventInvitation.status == "pending",
|
||||
)
|
||||
.order_by(EventInvitation.invited_at.desc())
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": inv.id,
|
||||
"event_id": inv.event_id,
|
||||
"event_title": title,
|
||||
"event_start": start_dt,
|
||||
"invited_by_name": inviter_name or "Someone",
|
||||
"invited_at": inv.invited_at,
|
||||
"status": inv.status,
|
||||
}
|
||||
for inv, title, start_dt, inviter_name in rows
|
||||
]
|
||||
|
||||
|
||||
async def get_invitation_overrides_for_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
event_ids: list[int],
|
||||
) -> dict[int, str]:
|
||||
"""
|
||||
For a list of occurrence event IDs, return a map of occurrence_id -> override status.
|
||||
Used to annotate event listings with per-occurrence invitation status.
|
||||
"""
|
||||
if not event_ids:
|
||||
return {}
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
EventInvitationOverride.occurrence_id,
|
||||
EventInvitationOverride.status,
|
||||
)
|
||||
.join(EventInvitation, EventInvitationOverride.invitation_id == EventInvitation.id)
|
||||
.where(
|
||||
EventInvitation.user_id == user_id,
|
||||
EventInvitationOverride.occurrence_id.in_(event_ids),
|
||||
)
|
||||
)
|
||||
return {r[0]: r[1] for r in result.all()}
|
||||
|
||||
|
||||
async def cascade_event_invitations_on_disconnect(
|
||||
db: AsyncSession,
|
||||
user_a_id: int,
|
||||
user_b_id: int,
|
||||
) -> None:
|
||||
"""Delete event invitations between two users when connection is severed."""
|
||||
# Delete invitations where A invited B
|
||||
await db.execute(
|
||||
delete(EventInvitation).where(
|
||||
EventInvitation.invited_by == user_a_id,
|
||||
EventInvitation.user_id == user_b_id,
|
||||
)
|
||||
)
|
||||
# Delete invitations where B invited A
|
||||
await db.execute(
|
||||
delete(EventInvitation).where(
|
||||
EventInvitation.invited_by == user_b_id,
|
||||
EventInvitation.user_id == user_a_id,
|
||||
)
|
||||
)
|
||||
@ -1,240 +0,0 @@
|
||||
"""
|
||||
Passkey (WebAuthn/FIDO2) service.
|
||||
|
||||
Handles challenge token creation/verification (itsdangerous + nonce replay protection)
|
||||
and wraps py_webauthn library calls for registration and authentication ceremonies.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import threading
|
||||
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
|
||||
from webauthn import (
|
||||
generate_registration_options,
|
||||
verify_registration_response,
|
||||
generate_authentication_options,
|
||||
verify_authentication_response,
|
||||
options_to_json,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
PublicKeyCredentialDescriptor,
|
||||
AuthenticatorSelectionCriteria,
|
||||
AuthenticatorTransport,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
AttestationConveyancePreference,
|
||||
)
|
||||
from webauthn.helpers import (
|
||||
bytes_to_base64url,
|
||||
base64url_to_bytes,
|
||||
parse_registration_credential_json,
|
||||
parse_authentication_credential_json,
|
||||
)
|
||||
|
||||
from app.config import settings as app_settings
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential JSON helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_credential_raw_id(credential_json: str) -> str | None:
|
||||
"""Extract the base64url-encoded rawId from a WebAuthn credential JSON string.
|
||||
|
||||
Returns None if parsing fails.
|
||||
"""
|
||||
try:
|
||||
cred_data = json.loads(credential_json)
|
||||
return cred_data.get("rawId") or cred_data.get("id") or None
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Challenge token management (itsdangerous + nonce replay protection V-01)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_challenge_serializer = URLSafeTimedSerializer(
|
||||
secret_key=app_settings.SECRET_KEY,
|
||||
salt="webauthn-challenge-v1",
|
||||
)
|
||||
|
||||
# Thread-safe nonce cache for single-use enforcement.
|
||||
# Keys: nonce string, Values: expiry timestamp.
|
||||
# NOTE: This is process-local. If scaling to multiple uvicorn workers,
|
||||
# move nonce tracking to Redis or a DB table with unique constraint.
|
||||
# Current deployment: single worker (Dockerfile --workers 1).
|
||||
_used_nonces: dict[str, float] = {}
|
||||
_nonce_lock = threading.Lock()
|
||||
|
||||
|
||||
def create_challenge_token(challenge: bytes, user_id: int | None = None) -> str:
|
||||
"""Sign challenge + nonce + optional user_id. Returns opaque token string."""
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
payload = {
|
||||
"ch": base64.b64encode(challenge).decode(),
|
||||
"n": nonce,
|
||||
}
|
||||
if user_id is not None:
|
||||
payload["uid"] = user_id
|
||||
return _challenge_serializer.dumps(payload)
|
||||
|
||||
|
||||
def verify_challenge_token(token: str, expected_user_id: int | None = None) -> bytes | None:
|
||||
"""Verify token (TTL from config), enforce single-use via nonce.
|
||||
|
||||
If expected_user_id provided, cross-check user binding (for registration).
|
||||
Returns challenge bytes or None on failure.
|
||||
"""
|
||||
try:
|
||||
data = _challenge_serializer.loads(
|
||||
token, max_age=app_settings.WEBAUTHN_CHALLENGE_TTL
|
||||
)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
|
||||
nonce = data.get("n")
|
||||
if not nonce:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
with _nonce_lock:
|
||||
# Lazy cleanup of expired nonces
|
||||
expired = [k for k, v in _used_nonces.items() if v <= now]
|
||||
for k in expired:
|
||||
del _used_nonces[k]
|
||||
|
||||
# Check for replay
|
||||
if nonce in _used_nonces:
|
||||
return None
|
||||
|
||||
# Mark nonce as used
|
||||
_used_nonces[nonce] = now + app_settings.WEBAUTHN_CHALLENGE_TTL
|
||||
|
||||
# Cross-check user binding for registration tokens
|
||||
if expected_user_id is not None:
|
||||
if data.get("uid") != expected_user_id:
|
||||
return None
|
||||
|
||||
return base64.b64decode(data["ch"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# py_webauthn wrappers
|
||||
# All synchronous — ECDSA P-256 verification is ~0.1ms, faster than executor overhead.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_registration_options(
|
||||
user_id: int,
|
||||
username: str,
|
||||
existing_credential_ids: list[bytes],
|
||||
) -> tuple[str, bytes]:
|
||||
"""Generate WebAuthn registration options.
|
||||
|
||||
Returns (options_json_str, challenge_bytes).
|
||||
"""
|
||||
exclude_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=cid)
|
||||
for cid in existing_credential_ids
|
||||
]
|
||||
|
||||
options = generate_registration_options(
|
||||
rp_id=app_settings.WEBAUTHN_RP_ID,
|
||||
rp_name=app_settings.WEBAUTHN_RP_NAME,
|
||||
user_id=str(user_id).encode(),
|
||||
user_name=username,
|
||||
attestation=AttestationConveyancePreference.NONE,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
exclude_credentials=exclude_credentials,
|
||||
timeout=60000,
|
||||
)
|
||||
|
||||
options_json = options_to_json(options)
|
||||
return options_json, options.challenge
|
||||
|
||||
|
||||
def verify_registration(
|
||||
credential_json: str,
|
||||
challenge: bytes,
|
||||
) -> "VerifiedRegistration":
|
||||
"""Verify a registration response from the browser.
|
||||
|
||||
Returns VerifiedRegistration on success, raises on failure.
|
||||
"""
|
||||
credential = parse_registration_credential_json(credential_json)
|
||||
return verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=app_settings.WEBAUTHN_RP_ID,
|
||||
expected_origin=app_settings.WEBAUTHN_ORIGIN,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
|
||||
def build_authentication_options(
|
||||
credential_ids_and_transports: list[tuple[bytes, list[str] | None]] | None = None,
|
||||
) -> tuple[str, bytes]:
|
||||
"""Generate WebAuthn authentication options.
|
||||
|
||||
If credential_ids_and_transports provided, includes allowCredentials.
|
||||
Otherwise, allows discoverable credential flow.
|
||||
Returns (options_json_str, challenge_bytes).
|
||||
"""
|
||||
allow_credentials = None
|
||||
if credential_ids_and_transports:
|
||||
allow_credentials = []
|
||||
for cid, transports in credential_ids_and_transports:
|
||||
transport_list = None
|
||||
if transports:
|
||||
transport_list = [
|
||||
AuthenticatorTransport(t)
|
||||
for t in transports
|
||||
if t in [e.value for e in AuthenticatorTransport]
|
||||
]
|
||||
allow_credentials.append(
|
||||
PublicKeyCredentialDescriptor(
|
||||
id=cid,
|
||||
transports=transport_list or None,
|
||||
)
|
||||
)
|
||||
|
||||
options = generate_authentication_options(
|
||||
rp_id=app_settings.WEBAUTHN_RP_ID,
|
||||
allow_credentials=allow_credentials,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
timeout=60000,
|
||||
)
|
||||
|
||||
options_json = options_to_json(options)
|
||||
return options_json, options.challenge
|
||||
|
||||
|
||||
def verify_authentication(
|
||||
credential_json: str,
|
||||
challenge: bytes,
|
||||
credential_public_key: bytes,
|
||||
credential_current_sign_count: int,
|
||||
) -> "VerifiedAuthentication":
|
||||
"""Verify an authentication response from the browser.
|
||||
|
||||
Returns VerifiedAuthentication on success, raises on failure.
|
||||
Sign count anomalies are NOT hard-failed — caller should log and continue.
|
||||
"""
|
||||
credential = parse_authentication_credential_json(credential_json)
|
||||
return verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=app_settings.WEBAUTHN_RP_ID,
|
||||
expected_origin=app_settings.WEBAUTHN_ORIGIN,
|
||||
credential_public_key=credential_public_key,
|
||||
credential_current_sign_count=credential_current_sign_count,
|
||||
require_user_verification=False,
|
||||
)
|
||||
@ -1,269 +0,0 @@
|
||||
"""
|
||||
Project sharing service — permission checks, auto-membership, disconnect cascade.
|
||||
|
||||
All functions accept an AsyncSession and do NOT commit — callers manage transactions.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.project import Project
|
||||
from app.models.project_member import ProjectMember
|
||||
from app.models.project_task import ProjectTask
|
||||
from app.models.project_task_assignment import ProjectTaskAssignment
|
||||
from app.models.user_connection import UserConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERMISSION_RANK = {"read_only": 1, "create_modify": 2}
|
||||
|
||||
# Fields task assignees (from assignment, not project membership) may edit
|
||||
ASSIGNEE_ALLOWED_FIELDS = {"title", "description", "status", "priority", "due_date"}
|
||||
|
||||
|
||||
async def get_project_permission(
|
||||
db: AsyncSession, project_id: int, user_id: int
|
||||
) -> str | None:
|
||||
"""
|
||||
Returns 'owner', 'create_modify', 'read_only', or None.
|
||||
Single query with LEFT JOIN (mirrors calendar_sharing pattern).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(
|
||||
Project.user_id,
|
||||
ProjectMember.permission,
|
||||
)
|
||||
.outerjoin(
|
||||
ProjectMember,
|
||||
(ProjectMember.project_id == Project.id)
|
||||
& (ProjectMember.user_id == user_id)
|
||||
& (ProjectMember.status == "accepted"),
|
||||
)
|
||||
.where(Project.id == project_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
owner_id, member_permission = row.tuple()
|
||||
if owner_id == user_id:
|
||||
return "owner"
|
||||
return member_permission
|
||||
|
||||
|
||||
async def require_project_permission(
|
||||
db: AsyncSession, project_id: int, user_id: int, min_level: str
|
||||
) -> str:
|
||||
"""
|
||||
Raises 404 if project doesn't exist or user has no access.
|
||||
Raises 403 if user has insufficient permission.
|
||||
Returns the actual permission string (or 'owner').
|
||||
"""
|
||||
perm = await get_project_permission(db, project_id, user_id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if perm == "owner":
|
||||
return "owner"
|
||||
if min_level == "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the project owner can perform this action")
|
||||
if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission on this project")
|
||||
return perm
|
||||
|
||||
|
||||
async def get_accessible_project_ids(db: AsyncSession, user_id: int) -> set[int]:
|
||||
"""Returns owned + accepted membership project IDs."""
|
||||
result = await db.execute(
|
||||
select(Project.id).where(Project.user_id == user_id)
|
||||
.union(
|
||||
select(ProjectMember.project_id).where(
|
||||
ProjectMember.user_id == user_id,
|
||||
ProjectMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
)
|
||||
return {r[0] for r in result.all()}
|
||||
|
||||
|
||||
async def validate_project_connections(
|
||||
db: AsyncSession, owner_id: int, user_ids: list[int]
|
||||
) -> None:
|
||||
"""Validates all target users are active connections of the owner. Raises 400 on failure."""
|
||||
if not user_ids:
|
||||
return
|
||||
result = await db.execute(
|
||||
select(UserConnection.connected_user_id).where(
|
||||
UserConnection.user_id == owner_id,
|
||||
UserConnection.connected_user_id.in_(user_ids),
|
||||
)
|
||||
)
|
||||
connected = {r[0] for r in result.all()}
|
||||
missing = set(user_ids) - connected
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Users {sorted(missing)} are not your connections",
|
||||
)
|
||||
|
||||
|
||||
async def get_effective_task_permission(
|
||||
db: AsyncSession, user_id: int, task_id: int, project_id: int
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
Returns (effective_permission, project_level_permission) for a specific task.
|
||||
1. Get project-level permission (owner/create_modify/read_only)
|
||||
2. If user is assigned to THIS task → max(project_perm, create_modify)
|
||||
3. If task has parent and user assigned to PARENT → same as above
|
||||
4. Return (effective, project_level)
|
||||
"""
|
||||
project_perm = await get_project_permission(db, project_id, user_id)
|
||||
if project_perm is None:
|
||||
return None, None
|
||||
if project_perm == "owner":
|
||||
return "owner", "owner"
|
||||
|
||||
# Check direct assignment on this task
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask.parent_task_id).where(ProjectTask.id == task_id)
|
||||
)
|
||||
task_row = task_result.one_or_none()
|
||||
if not task_row:
|
||||
return project_perm, project_perm
|
||||
|
||||
parent_task_id = task_row[0]
|
||||
|
||||
# Check assignment on this task or its parent
|
||||
check_task_ids = [task_id]
|
||||
if parent_task_id is not None:
|
||||
check_task_ids.append(parent_task_id)
|
||||
|
||||
assignment_result = await db.execute(
|
||||
select(ProjectTaskAssignment.id).where(
|
||||
ProjectTaskAssignment.task_id.in_(check_task_ids),
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
).limit(1)
|
||||
)
|
||||
if assignment_result.scalar_one_or_none() is not None:
|
||||
# Assignment grants at least create_modify
|
||||
if PERMISSION_RANK.get(project_perm, 0) >= PERMISSION_RANK["create_modify"]:
|
||||
return project_perm, project_perm
|
||||
return "create_modify", project_perm
|
||||
|
||||
return project_perm, project_perm
|
||||
|
||||
|
||||
async def ensure_auto_membership(
|
||||
db: AsyncSession, project_id: int, user_id: int, invited_by: int
|
||||
) -> None:
|
||||
"""
|
||||
When assigning a user to a task, ensure they have a ProjectMember row.
|
||||
If none exists, create one with read_only + auto_assigned + accepted (no invite flow).
|
||||
"""
|
||||
existing = await db.execute(
|
||||
select(ProjectMember.id).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
return
|
||||
|
||||
member = ProjectMember(
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
invited_by=invited_by,
|
||||
permission="read_only",
|
||||
status="accepted",
|
||||
source="auto_assigned",
|
||||
accepted_at=datetime.now(),
|
||||
)
|
||||
db.add(member)
|
||||
|
||||
|
||||
async def cleanup_auto_membership(
|
||||
db: AsyncSession, project_id: int, user_id: int
|
||||
) -> None:
|
||||
"""
|
||||
After removing a task assignment, check if user has any remaining assignments
|
||||
in this project. If not and membership is auto_assigned, remove it.
|
||||
"""
|
||||
remaining = await db.execute(
|
||||
select(ProjectTaskAssignment.id)
|
||||
.join(ProjectTask, ProjectTaskAssignment.task_id == ProjectTask.id)
|
||||
.where(
|
||||
ProjectTask.project_id == project_id,
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if remaining.scalar_one_or_none() is not None:
|
||||
return # Still has assignments
|
||||
|
||||
# Remove auto_assigned membership only
|
||||
await db.execute(
|
||||
delete(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
ProjectMember.source == "auto_assigned",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def cascade_projects_on_disconnect(
|
||||
db: AsyncSession, user_a_id: int, user_b_id: int
|
||||
) -> None:
|
||||
"""
|
||||
When a connection is severed:
|
||||
1. Find all ProjectMember rows where one user is a member of the other's projects
|
||||
2. Find all ProjectTaskAssignment rows for those memberships
|
||||
3. Remove assignments, then remove memberships
|
||||
"""
|
||||
# Single query: find projects owned by each user
|
||||
result = await db.execute(
|
||||
select(Project.id, Project.user_id).where(
|
||||
Project.user_id.in_([user_a_id, user_b_id])
|
||||
)
|
||||
)
|
||||
a_proj_ids: list[int] = []
|
||||
b_proj_ids: list[int] = []
|
||||
for proj_id, owner_id in result.all():
|
||||
if owner_id == user_a_id:
|
||||
a_proj_ids.append(proj_id)
|
||||
else:
|
||||
b_proj_ids.append(proj_id)
|
||||
|
||||
# Remove user_b's assignments + memberships on user_a's projects
|
||||
if a_proj_ids:
|
||||
await db.execute(
|
||||
delete(ProjectTaskAssignment).where(
|
||||
ProjectTaskAssignment.user_id == user_b_id,
|
||||
ProjectTaskAssignment.task_id.in_(
|
||||
select(ProjectTask.id).where(ProjectTask.project_id.in_(a_proj_ids))
|
||||
),
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(ProjectMember).where(
|
||||
ProjectMember.project_id.in_(a_proj_ids),
|
||||
ProjectMember.user_id == user_b_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Remove user_a's assignments + memberships on user_b's projects
|
||||
if b_proj_ids:
|
||||
await db.execute(
|
||||
delete(ProjectTaskAssignment).where(
|
||||
ProjectTaskAssignment.user_id == user_a_id,
|
||||
ProjectTaskAssignment.task_id.in_(
|
||||
select(ProjectTask.id).where(ProjectTask.project_id.in_(b_proj_ids))
|
||||
),
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(ProjectMember).where(
|
||||
ProjectMember.project_id.in_(b_proj_ids),
|
||||
ProjectMember.user_id == user_a_id,
|
||||
)
|
||||
)
|
||||
@ -10,9 +10,6 @@ from typing import Optional
|
||||
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
|
||||
# Hard cap: never generate more than 730 child events regardless of horizon_days.
|
||||
MAX_OCCURRENCES = 730
|
||||
|
||||
|
||||
def _nth_weekday_of_month(year: int, month: int, weekday: int, week: int) -> Optional[datetime]:
|
||||
"""
|
||||
@ -102,12 +99,8 @@ def generate_occurrences(
|
||||
interval: int = _rule_int(rule, "interval", 1)
|
||||
if interval < 1:
|
||||
interval = 1
|
||||
# Adaptive horizon: cap daily-ish events (interval < 7) to 90 days
|
||||
effective_horizon = horizon if interval >= 7 else min(horizon, parent_start + timedelta(days=90))
|
||||
current = parent_start + timedelta(days=interval)
|
||||
while current < effective_horizon:
|
||||
if len(occurrences) >= MAX_OCCURRENCES:
|
||||
break
|
||||
while current < horizon:
|
||||
occurrences.append(_make_child(current))
|
||||
current += timedelta(days=interval)
|
||||
|
||||
@ -119,8 +112,6 @@ def generate_occurrences(
|
||||
days_ahead = 7
|
||||
current = parent_start + timedelta(days=days_ahead)
|
||||
while current < horizon:
|
||||
if len(occurrences) >= MAX_OCCURRENCES:
|
||||
break
|
||||
occurrences.append(_make_child(current))
|
||||
current += timedelta(weeks=1)
|
||||
|
||||
@ -135,8 +126,6 @@ def generate_occurrences(
|
||||
month = 1
|
||||
year += 1
|
||||
while True:
|
||||
if len(occurrences) >= MAX_OCCURRENCES:
|
||||
break
|
||||
target = _nth_weekday_of_month(year, month, weekday, week)
|
||||
if target is None:
|
||||
# Skip months where Nth weekday doesn't exist
|
||||
@ -168,8 +157,6 @@ def generate_occurrences(
|
||||
month = 1
|
||||
year += 1
|
||||
while True:
|
||||
if len(occurrences) >= MAX_OCCURRENCES:
|
||||
break
|
||||
# Some months don't have day 29-31
|
||||
try:
|
||||
occ_start = datetime(
|
||||
|
||||
@ -1,121 +0,0 @@
|
||||
"""
|
||||
Shared session management service.
|
||||
|
||||
Consolidates session creation, cookie handling, and account lockout logic
|
||||
that was previously duplicated between auth.py and totp.py routers.
|
||||
All auth paths (password, TOTP, passkey) use these functions to ensure
|
||||
consistent session cap enforcement and lockout behavior.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.session import UserSession
|
||||
from app.services.auth import create_session_token
|
||||
from app.config import settings as app_settings
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, token: str) -> None:
|
||||
"""Set httpOnly secure signed cookie on response."""
|
||||
response.set_cookie(
|
||||
key="session",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=app_settings.COOKIE_SECURE,
|
||||
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
async def check_account_lockout(user: User) -> None:
|
||||
"""Raise HTTP 401 if the account is currently locked.
|
||||
|
||||
Uses 401 (same status as wrong-password) so that status-code analysis
|
||||
cannot distinguish a locked account from an invalid credential (F-02).
|
||||
"""
|
||||
if user.locked_until and datetime.now() < user.locked_until:
|
||||
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=f"Account temporarily locked. Try again in {remaining} minutes.",
|
||||
)
|
||||
|
||||
|
||||
async def record_failed_login(db: AsyncSession, user: User) -> int:
|
||||
"""Increment failure counter; lock account after 10 failures.
|
||||
|
||||
Returns the number of attempts remaining before lockout (0 = just locked).
|
||||
Does NOT commit — caller owns the transaction boundary.
|
||||
"""
|
||||
user.failed_login_count += 1
|
||||
remaining = max(0, 10 - user.failed_login_count)
|
||||
if user.failed_login_count >= 10:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||
await db.flush()
|
||||
return remaining
|
||||
|
||||
|
||||
async def record_successful_login(db: AsyncSession, user: User) -> None:
|
||||
"""Reset failure counter and update last_login_at.
|
||||
|
||||
Does NOT commit — caller owns the transaction boundary.
|
||||
"""
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now()
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def create_db_session(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
ip: str,
|
||||
user_agent: str | None,
|
||||
) -> tuple[str, str]:
|
||||
"""Insert a UserSession row and return (session_id, signed_cookie_token).
|
||||
|
||||
Enforces MAX_SESSIONS_PER_USER by revoking oldest sessions beyond the cap.
|
||||
"""
|
||||
session_id = uuid.uuid4().hex
|
||||
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
||||
db_session = UserSession(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip[:45] if ip else None,
|
||||
user_agent=(user_agent or "")[:255] if user_agent else None,
|
||||
)
|
||||
db.add(db_session)
|
||||
await db.flush()
|
||||
|
||||
# Enforce concurrent session limit: revoke oldest sessions beyond the cap.
|
||||
# Perf-2: Query IDs only, bulk-update instead of loading full ORM objects.
|
||||
max_sessions = app_settings.MAX_SESSIONS_PER_USER
|
||||
active_ids = (
|
||||
await db.execute(
|
||||
select(UserSession.id)
|
||||
.where(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.revoked == False, # noqa: E712
|
||||
UserSession.expires_at > datetime.now(),
|
||||
)
|
||||
.order_by(UserSession.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
if len(active_ids) > max_sessions:
|
||||
ids_to_revoke = active_ids[: len(active_ids) - max_sessions]
|
||||
await db.execute(
|
||||
update(UserSession)
|
||||
.where(UserSession.id.in_(ids_to_revoke))
|
||||
.values(revoked=True)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
token = create_session_token(user.id, session_id)
|
||||
return session_id, token
|
||||
@ -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 '*'
|
||||
@ -15,4 +15,3 @@ python-dateutil==2.9.0
|
||||
itsdangerous==2.2.0
|
||||
httpx==0.27.2
|
||||
apscheduler==3.10.4
|
||||
webauthn>=2.1.0,<3
|
||||
|
||||
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
|
||||
@ -2,81 +2,36 @@ services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
env_file: .env
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- backend_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "1.0"
|
||||
|
||||
backend:
|
||||
image: git.sentinelforest.xyz/rohskiddo/umbra-backend:main-latest
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- ENVIRONMENT=${ENVIRONMENT:-production}
|
||||
- UMBRA_URL=${UMBRA_URL:-https://umbra.ghost6.xyz}
|
||||
- OPENWEATHERMAP_API_KEY=${OPENWEATHERMAP_API_KEY:-}
|
||||
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-umbra.ghost6.xyz}
|
||||
- WEBAUTHN_RP_NAME=${WEBAUTHN_RP_NAME:-UMBRA}
|
||||
- WEBAUTHN_ORIGIN=${WEBAUTHN_ORIGIN:-https://umbra.ghost6.xyz}
|
||||
env_file: .env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- backend_net
|
||||
- frontend_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
||||
interval: 30s
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "1.0"
|
||||
|
||||
frontend:
|
||||
image: git.sentinelforest.xyz/rohskiddo/umbra-frontend:main-latest
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:8080"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- frontend_net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:8080/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: "0.5"
|
||||
|
||||
volumes:
|
||||
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
|
||||
FROM node:20.18-alpine AS build
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies from lockfile (DW-3)
|
||||
RUN npm ci
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
@ -16,7 +16,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# 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 --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
@ -3,33 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>UMBRA</title>
|
||||
<!-- Static style tag — survives Vite's head cleanup (unlike dynamically created elements).
|
||||
The inline script below populates it with accent color from localStorage cache. -->
|
||||
<style id="umbra-accent"></style>
|
||||
<script>
|
||||
// Populate the static style tag with cached accent color before first paint.
|
||||
// Uses textContent (safe from XSS) and !important (beats @layer base defaults).
|
||||
(function() {
|
||||
var h = '187', s = '85.7%', l = '53.3%';
|
||||
try {
|
||||
var c = localStorage.getItem('umbra-accent-color');
|
||||
if (c) {
|
||||
var p = JSON.parse(c);
|
||||
if (p.h && /^\d+$/.test(p.h)) h = p.h;
|
||||
if (p.s && /^\d+\.?\d*%$/.test(p.s)) s = p.s;
|
||||
if (p.l && /^\d+\.?\d*%$/.test(p.l)) l = p.l;
|
||||
}
|
||||
} catch(e) {}
|
||||
document.getElementById('umbra-accent').textContent =
|
||||
':root{--accent-h:' + h + ' !important;--accent-s:' + s + ' !important;--accent-l:' + l + ' !important}';
|
||||
})();
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet" />
|
||||
|
||||
@ -4,16 +4,9 @@ limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m;
|
||||
# Admin API — generous for legitimate use but still guards against scraping/brute-force
|
||||
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m;
|
||||
# Calendar sharing endpoints
|
||||
limit_req_zone $binary_remote_addr zone=cal_invite_limit:10m rate=5r/m;
|
||||
limit_req_zone $binary_remote_addr zone=cal_sync_limit:10m rate=15r/m;
|
||||
# Connection endpoints — prevent search enumeration and request spam
|
||||
limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
|
||||
# Event creation — recurrence amplification means 1 POST = up to 90-365 child rows
|
||||
limit_req_zone $binary_remote_addr zone=event_create_limit:10m rate=30r/m;
|
||||
# Health endpoint — lightweight but rate-limited for resilience
|
||||
limit_req_zone $binary_remote_addr zone=health_limit:1m rate=30r/m;
|
||||
|
||||
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
|
||||
map $http_x_forwarded_proto $forwarded_proto {
|
||||
@ -31,14 +24,13 @@ server {
|
||||
# Suppress nginx version in Server header
|
||||
server_tokens off;
|
||||
|
||||
# ── Real client IP restoration (PT-01 / F-03) ─────────────────────
|
||||
# ── Real client IP restoration (PT-01) ────────────────────────────
|
||||
# Pangolin (TLS-terminating reverse proxy) connects via Docker bridge.
|
||||
# Restore the real client IP from X-Forwarded-For so that limit_req_zone
|
||||
# (which keys on $binary_remote_addr) throttles per-client, not per-proxy.
|
||||
# Restricted to RFC 1918 ranges only — trusting 0.0.0.0/0 would allow an
|
||||
# external client to spoof X-Forwarded-For and bypass rate limiting (F-03).
|
||||
set_real_ip_from 172.16.0.0/12;
|
||||
set_real_ip_from 10.0.0.0/8;
|
||||
# Safe to trust all sources: nginx is only reachable via Docker networking,
|
||||
# never directly internet-facing. Tighten if deployment model changes.
|
||||
set_real_ip_from 0.0.0.0/0;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
|
||||
@ -46,7 +38,7 @@ server {
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
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)
|
||||
location ~ /\.(?!well-known) {
|
||||
@ -86,36 +78,6 @@ server {
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Passkey authentication — rate-limited (C-04)
|
||||
location /api/auth/passkeys/login/begin {
|
||||
limit_req zone=auth_limit burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
location /api/auth/passkeys/login/complete {
|
||||
limit_req zone=auth_limit burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
# Passkey registration — authenticated, lower burst
|
||||
location /api/auth/passkeys/register/begin {
|
||||
limit_req zone=auth_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
location /api/auth/passkeys/register/complete {
|
||||
limit_req zone=auth_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Passwordless toggle — enable accepts password, rate-limit against brute force
|
||||
location /api/auth/passkeys/passwordless {
|
||||
limit_req zone=auth_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# SEC-14: Rate-limit public registration endpoint
|
||||
location /api/auth/register {
|
||||
limit_req zone=register_limit burst=3 nodelay;
|
||||
@ -137,19 +99,6 @@ server {
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Calendar invite — rate-limited to prevent invite spam
|
||||
location ~ /api/shared-calendars/\d+/invite {
|
||||
limit_req zone=cal_invite_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Calendar sync — rate-limited to prevent excessive polling
|
||||
location /api/shared-calendars/sync {
|
||||
limit_req zone=cal_sync_limit burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
# Admin API — rate-limited separately from general /api traffic
|
||||
location /api/admin/ {
|
||||
limit_req zone=admin_limit burst=10 nodelay;
|
||||
@ -157,28 +106,28 @@ server {
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Event creation — rate-limited to prevent DB flooding via recurrence amplification.
|
||||
# Note: exact match applies to GET+POST; 30r/m with burst=10 is generous enough
|
||||
# for polling (2r/m) and won't affect reads even with multiple tabs.
|
||||
location = /api/events {
|
||||
limit_req zone=event_create_limit burst=10 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Health endpoint — proxied to backend for external uptime monitoring
|
||||
location = /health {
|
||||
limit_req zone=health_limit burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# API proxy (catch-all for non-rate-limited endpoints)
|
||||
# API proxy
|
||||
location /api {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_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;
|
||||
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
|
||||
@ -193,7 +142,7 @@ server {
|
||||
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'; frame-ancestors 'none'; upgrade-insecure-requests;" 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;
|
||||
}
|
||||
|
||||
@ -201,8 +150,8 @@ server {
|
||||
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'; frame-ancestors 'none'; upgrade-insecure-requests;" 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;
|
||||
# PT-I03: Restrict unnecessary browser APIs
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
}
|
||||
|
||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@ -16,7 +16,6 @@
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/react": "^6.1.15",
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@ -1349,22 +1348,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-10.0.0.tgz",
|
||||
"integrity": "sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/types": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/types": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz",
|
||||
"integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.20",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/react": "^6.1.15",
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@ -4,13 +4,3 @@ 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;
|
||||
|
||||
# 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'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 10h.01"/>
|
||||
<path d="M15 10h.01"/>
|
||||
<path d="M12 2a8 8 0 0 0-8 8v12l3-3 2.5 2.5L12 19l2.5 2.5L17 19l3 3V10a8 8 0 0 0-8-8z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 324 B |
@ -3,29 +3,28 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import LockScreen from '@/components/auth/LockScreen';
|
||||
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 RouteFallback = () => (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>
|
||||
);
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { authStatus, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-dvh bg-background" />;
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authStatus?.authenticated) {
|
||||
@ -39,7 +38,11 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const { authStatus, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="h-dvh bg-background" />;
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authStatus?.authenticated || authStatus?.role !== 'admin') {
|
||||
@ -62,21 +65,21 @@ function App() {
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Suspense fallback={<RouteFallback />}><DashboardPage /></Suspense>} />
|
||||
<Route path="todos" element={<Suspense fallback={<RouteFallback />}><TodosPage /></Suspense>} />
|
||||
<Route path="calendar" element={<Suspense fallback={<RouteFallback />}><CalendarPage /></Suspense>} />
|
||||
<Route path="reminders" element={<Suspense fallback={<RouteFallback />}><RemindersPage /></Suspense>} />
|
||||
<Route path="projects" element={<Suspense fallback={<RouteFallback />}><ProjectsPage /></Suspense>} />
|
||||
<Route path="projects/:id" element={<Suspense fallback={<RouteFallback />}><ProjectDetail /></Suspense>} />
|
||||
<Route path="people" element={<Suspense fallback={<RouteFallback />}><PeoplePage /></Suspense>} />
|
||||
<Route path="locations" element={<Suspense fallback={<RouteFallback />}><LocationsPage /></Suspense>} />
|
||||
<Route path="notifications" element={<Suspense fallback={<RouteFallback />}><NotificationsPage /></Suspense>} />
|
||||
<Route path="settings" element={<Suspense fallback={<RouteFallback />}><SettingsPage /></Suspense>} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="todos" element={<TodosPage />} />
|
||||
<Route path="calendar" element={<CalendarPage />} />
|
||||
<Route path="reminders" element={<RemindersPage />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="people" element={<PeoplePage />} />
|
||||
<Route path="locations" element={<LocationsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="admin/*"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>}>
|
||||
<AdminPortal />
|
||||
</Suspense>
|
||||
</AdminRoute>
|
||||
|
||||
@ -23,9 +23,9 @@ export default function AdminDashboardPage() {
|
||||
dashboard ? dashboard.total_users - dashboard.active_users : null;
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
|
||||
<div className="px-6 py-6 space-y-6 animate-fade-in">
|
||||
{/* Stats grid */}
|
||||
<div className="grid gap-2.5 grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-5">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
@ -94,10 +94,10 @@ export default function AdminDashboardPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Username
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
When
|
||||
</th>
|
||||
</tr>
|
||||
@ -111,8 +111,8 @@ export default function AdminDashboardPage() {
|
||||
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
|
||||
)}
|
||||
>
|
||||
<td className="px-3 lg:px-5 py-2.5 font-medium">{entry.username}</td>
|
||||
<td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground">
|
||||
<td className="px-5 py-2.5 font-medium">{entry.username}</td>
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground">
|
||||
{getRelativeTime(entry.last_login_at)}
|
||||
</td>
|
||||
</tr>
|
||||
@ -142,16 +142,16 @@ export default function AdminDashboardPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Actor
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Target
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
When
|
||||
</th>
|
||||
</tr>
|
||||
@ -165,7 +165,7 @@ export default function AdminDashboardPage() {
|
||||
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
|
||||
)}
|
||||
>
|
||||
<td className="px-3 lg:px-5 py-2.5">
|
||||
<td className="px-5 py-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
|
||||
@ -175,15 +175,15 @@ export default function AdminDashboardPage() {
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-2.5 text-xs font-medium hidden sm:table-cell">
|
||||
<td className="px-5 py-2.5 text-xs font-medium">
|
||||
{entry.actor_username ?? (
|
||||
<span className="text-muted-foreground italic">system</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground hidden sm:table-cell">
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground">
|
||||
{entry.target_username ?? '—'}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{getRelativeTime(entry.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -17,37 +17,32 @@ export default function AdminPortal() {
|
||||
return (
|
||||
<div className="flex flex-col h-full animate-fade-in">
|
||||
{/* Portal header with tab navigation */}
|
||||
<div className="shrink-0 border-b bg-card overflow-hidden">
|
||||
<div className="px-3 md:px-6 h-14 md:h-16 flex items-center gap-2 md:gap-4">
|
||||
<div className="flex items-center gap-2 shrink-0 md:mr-6">
|
||||
<div className="shrink-0 border-b bg-card">
|
||||
<div className="px-6 h-16 flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 mr-6">
|
||||
<div className="p-1.5 rounded-md bg-red-500/10">
|
||||
<ShieldCheck className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<h1 className="font-heading text-base md:text-2xl font-bold tracking-tight">
|
||||
<span className="hidden md:inline">Admin Portal</span>
|
||||
<span className="md:hidden">Admin</span>
|
||||
</h1>
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight">Admin Portal</h1>
|
||||
</div>
|
||||
|
||||
{/* Horizontal tab navigation — evenly spaced on mobile, left-aligned on desktop */}
|
||||
<nav className="flex items-center justify-evenly md:justify-start flex-1 md:flex-none md:gap-1 h-full min-w-0 overflow-hidden">
|
||||
{/* Horizontal tab navigation */}
|
||||
<nav className="flex items-center gap-1 h-full">
|
||||
{tabs.map(({ label, path, icon: Icon }) => {
|
||||
const isActive = location.pathname.startsWith(path);
|
||||
return (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'flex items-center justify-center md:justify-start gap-1.5 px-2.5 md:px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px whitespace-nowrap',
|
||||
'flex items-center gap-2 px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px',
|
||||
isActive
|
||||
? 'text-accent border-accent'
|
||||
: 'text-muted-foreground hover:text-foreground border-transparent'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -54,7 +54,7 @@ export default function ConfigPage() {
|
||||
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
|
||||
<div className="px-6 py-6 space-y-6 animate-fade-in">
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@ -75,7 +75,7 @@ export default function ConfigPage() {
|
||||
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Filter:</span>
|
||||
</div>
|
||||
<div className="w-36 sm:w-52">
|
||||
<div className="w-52">
|
||||
<Select
|
||||
value={filterAction}
|
||||
onChange={(e) => {
|
||||
@ -129,22 +129,22 @@ export default function ConfigPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Time
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Actor
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Target
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
IP
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Detail
|
||||
</th>
|
||||
</tr>
|
||||
@ -158,15 +158,15 @@ export default function ConfigPage() {
|
||||
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
|
||||
)}
|
||||
>
|
||||
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{getRelativeTime(entry.created_at)}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3 text-xs font-medium hidden sm:table-cell">
|
||||
<td className="px-5 py-3 text-xs font-medium">
|
||||
{entry.actor_username ?? (
|
||||
<span className="text-muted-foreground italic">system</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3">
|
||||
<td className="px-5 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
|
||||
@ -176,13 +176,13 @@ export default function ConfigPage() {
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground hidden sm:table-cell">
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground">
|
||||
{entry.target_username ?? '—'}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground font-mono hidden lg:table-cell">
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground font-mono">
|
||||
{entry.ip_address ?? '—'}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground max-w-xs truncate hidden lg:table-cell">
|
||||
<td className="px-5 py-3 text-xs text-muted-foreground max-w-xs truncate">
|
||||
{entry.detail ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -81,7 +81,7 @@ export default function IAMPage() {
|
||||
);
|
||||
}, [users, searchQuery]);
|
||||
|
||||
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users' | 'allow_passwordless', value: boolean) => {
|
||||
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => {
|
||||
try {
|
||||
await updateConfig.mutateAsync({ [key]: value });
|
||||
toast.success('System settings updated');
|
||||
@ -95,7 +95,7 @@ export default function IAMPage() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
|
||||
<div className="px-6 py-6 space-y-6 animate-fade-in">
|
||||
{/* Stats row */}
|
||||
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
@ -123,9 +123,9 @@ export default function IAMPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User table — relative z-10 so action dropdowns render above sibling cards */}
|
||||
<Card className="relative z-10">
|
||||
<CardHeader className="flex-row items-center justify-between flex-wrap gap-2 md:gap-3">
|
||||
{/* User table */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<Users className="h-4 w-4 text-accent" />
|
||||
@ -139,12 +139,12 @@ export default function IAMPage() {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search users..."
|
||||
className="pl-8 h-8 w-32 sm:w-48 text-xs"
|
||||
className="pl-8 h-8 w-48 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Create User</span>
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@ -164,34 +164,34 @@ export default function IAMPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Username
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Umbral Name
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Last Login
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
MFA
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Sessions
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-3 lg:px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
<th className="px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
@ -211,17 +211,17 @@ export default function IAMPage() {
|
||||
)
|
||||
)}
|
||||
>
|
||||
<td className="px-3 lg:px-5 py-3 font-medium">{user.username}</td>
|
||||
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
|
||||
<td className="px-5 py-3 font-medium">{user.username}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
{user.umbral_name || user.username}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
{user.email || '—'}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3">
|
||||
<td className="px-5 py-3">
|
||||
<RoleBadge role={user.role} />
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3">
|
||||
<td className="px-5 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
|
||||
@ -233,10 +233,10 @@ export default function IAMPage() {
|
||||
{user.is_active ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
{user.last_login_at ? getRelativeTime(user.last_login_at) : '—'}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3 hidden lg:table-cell">
|
||||
<td className="px-5 py-3">
|
||||
{user.totp_enabled ? (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
|
||||
On
|
||||
@ -249,13 +249,13 @@ export default function IAMPage() {
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs tabular-nums hidden lg:table-cell">
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs tabular-nums">
|
||||
{user.active_sessions}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
{getRelativeTime(user.created_at)}
|
||||
</td>
|
||||
<td className="px-3 lg:px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<td className="px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<UserActionsMenu user={user} currentUsername={authStatus?.username ?? null} />
|
||||
</td>
|
||||
</tr>
|
||||
@ -320,20 +320,6 @@ export default function IAMPage() {
|
||||
disabled={updateConfig.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Allow Passwordless Login</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow users to enable passkey-only login, skipping the password prompt entirely.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.allow_passwordless ?? false}
|
||||
onCheckedChange={(v) => handleConfigToggle('allow_passwordless', v)}
|
||||
disabled={updateConfig.isPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ShieldOff,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
@ -24,7 +23,6 @@ import {
|
||||
useToggleUserActive,
|
||||
useRevokeSessions,
|
||||
useDeleteUser,
|
||||
useDisablePasswordless,
|
||||
getErrorMessage,
|
||||
} from '@/hooks/useAdmin';
|
||||
import type { AdminUserDetail, UserRole } from '@/types';
|
||||
@ -55,7 +53,6 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
||||
const toggleActive = useToggleUserActive();
|
||||
const revokeSessions = useRevokeSessions();
|
||||
const deleteUser = useDeleteUser();
|
||||
const disablePasswordless = useDisablePasswordless();
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
@ -105,10 +102,6 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
||||
}
|
||||
});
|
||||
|
||||
const disablePasswordlessConfirm = useConfirmAction(() => {
|
||||
handleAction(() => disablePasswordless.mutateAsync(user.id), 'Passwordless login disabled');
|
||||
});
|
||||
|
||||
const isLoading =
|
||||
updateRole.isPending ||
|
||||
resetPassword.isPending ||
|
||||
@ -117,8 +110,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
||||
removeMfaEnforcement.isPending ||
|
||||
toggleActive.isPending ||
|
||||
revokeSessions.isPending ||
|
||||
deleteUser.isPending ||
|
||||
disablePasswordless.isPending;
|
||||
deleteUser.isPending;
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
@ -155,7 +147,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
||||
|
||||
{roleSubmenuOpen && (
|
||||
<div
|
||||
className="absolute left-0 top-full sm:left-auto sm:right-full sm:top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1"
|
||||
className="absolute right-full top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1"
|
||||
onMouseEnter={() => setRoleSubmenuOpen(true)}
|
||||
onMouseLeave={() => setRoleSubmenuOpen(false)}
|
||||
>
|
||||
@ -266,21 +258,6 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
||||
</button>
|
||||
)}
|
||||
|
||||
{user.passwordless_enabled && (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
||||
disablePasswordlessConfirm.confirming
|
||||
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
|
||||
: 'hover:bg-card-elevated'
|
||||
)}
|
||||
onClick={disablePasswordlessConfirm.handleClick}
|
||||
>
|
||||
<ShieldOff className="h-4 w-4" />
|
||||
{disablePasswordlessConfirm.confirming ? 'Sure? Click to confirm' : 'Disable Passwordless'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="my-1 border-t border-border" />
|
||||
|
||||
{/* Disable / Enable Account */}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { X, User, ShieldCheck, Share2, Loader2 } from 'lucide-react';
|
||||
import { X, User, ShieldCheck, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAdminUserDetail, useAdminSharingStats, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
|
||||
import { useAdminUserDetail, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
|
||||
import { getRelativeTime } from '@/lib/date-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UserRole } from '@/types';
|
||||
@ -57,7 +57,6 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean })
|
||||
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
|
||||
const { data: user, isLoading, error } = useAdminUserDetail(userId);
|
||||
const updateRole = useUpdateRole();
|
||||
const { data: sharingStats } = useAdminSharingStats(userId);
|
||||
|
||||
const handleRoleChange = async (newRole: UserRole) => {
|
||||
if (!user || newRole === user.role) return;
|
||||
@ -71,15 +70,15 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||
<Card>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="col-span-1">
|
||||
<CardContent className="p-5 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-5 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<Card className="col-span-1">
|
||||
<CardContent className="p-5 space-y-3">
|
||||
{Array.from({ length: 7 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-5 w-full" />
|
||||
@ -109,9 +108,9 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{/* User Information (read-only) */}
|
||||
<Card>
|
||||
<Card className="col-span-1">
|
||||
<CardHeader className="flex-row items-center justify-between pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
@ -152,7 +151,7 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
</Card>
|
||||
|
||||
{/* Security & Permissions */}
|
||||
<Card>
|
||||
<Card className="col-span-1">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
@ -168,7 +167,7 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
<Select
|
||||
value={user.role}
|
||||
onChange={(e) => handleRoleChange(e.target.value as UserRole)}
|
||||
className="h-6 text-xs py-0 px-1.5 w-auto min-w-[100px] sm:min-w-[120px]"
|
||||
className="h-6 text-xs py-0 px-1.5 w-auto min-w-[120px]"
|
||||
disabled={updateRole.isPending}
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
@ -193,18 +192,6 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Passwordless"
|
||||
value={
|
||||
user.passwordless_enabled ? (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
|
||||
Enabled
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Off</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Must Change Pwd"
|
||||
value={user.must_change_password ? 'Yes' : 'No'}
|
||||
@ -231,24 +218,6 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sharing Stats */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<Share2 className="h-3.5 w-3.5 text-accent" />
|
||||
</div>
|
||||
<CardTitle className="text-sm">Sharing</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-0.5">
|
||||
<DetailRow label="Calendars Shared" value={String(sharingStats?.shared_calendars_owned ?? 0)} />
|
||||
<DetailRow label="Member Of" value={String(sharingStats?.calendars_member_of ?? 0)} />
|
||||
<DetailRow label="Invites Sent" value={String(sharingStats?.pending_invites_sent ?? 0)} />
|
||||
<DetailRow label="Invites Received" value={String(sharingStats?.pending_invites_received ?? 0)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertTriangle, Copy, Fingerprint, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
|
||||
import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -10,7 +10,6 @@ import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AmbientBackground from './AmbientBackground';
|
||||
import type { TotpSetupResponse } from '@/types';
|
||||
|
||||
@ -48,8 +47,6 @@ export default function LockScreen() {
|
||||
isRegisterPending,
|
||||
isSetupPending,
|
||||
isTotpPending,
|
||||
passkeyLogin,
|
||||
isPasskeyLoginPending,
|
||||
} = useAuth();
|
||||
|
||||
// ── Shared credential fields ──
|
||||
@ -86,31 +83,6 @@ export default function LockScreen() {
|
||||
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
|
||||
const [isForcePwPending, setIsForcePwPending] = useState(false);
|
||||
|
||||
// ── Passkey support (U-01: browser feature detection, not per-user) ──
|
||||
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
setLoginError(null);
|
||||
try {
|
||||
const result = await passkeyLogin();
|
||||
if (result?.must_change_password) {
|
||||
setMode('force_pw');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
toast.info('Passkey not recognized. Try your password.');
|
||||
} else if (error.name === 'AbortError') {
|
||||
// User cancelled — silent
|
||||
} else {
|
||||
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
|
||||
}
|
||||
} else {
|
||||
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Redirect authenticated users (no pending MFA flows)
|
||||
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
@ -155,10 +127,11 @@ export default function LockScreen() {
|
||||
// mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status;
|
||||
if (status === 403) {
|
||||
if (status === 423) {
|
||||
setLoginError(error.response.data?.detail || 'Account locked. Try again later.');
|
||||
} else if (status === 403) {
|
||||
setLoginError(error.response.data?.detail || 'Account is disabled. Contact an administrator.');
|
||||
} else {
|
||||
// 401 covers both wrong password and account lockout (backend embeds detail string)
|
||||
setLoginError(getErrorMessage(error, 'Invalid username or password'));
|
||||
}
|
||||
}
|
||||
@ -518,28 +491,18 @@ export default function LockScreen() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loginError && (() => {
|
||||
const isLockWarning =
|
||||
loginError.includes('remaining') || loginError.includes('temporarily locked');
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border px-3 py-2 mb-4',
|
||||
isLockWarning
|
||||
? 'bg-amber-500/10 border-amber-500/30'
|
||||
: 'bg-red-500/10 border-red-500/30'
|
||||
)}
|
||||
>
|
||||
{isLockWarning
|
||||
? <Lock className="h-4 w-4 text-amber-400 shrink-0" aria-hidden="true" />
|
||||
: <AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />}
|
||||
<p className={cn('text-xs', isLockWarning ? 'text-amber-400' : 'text-red-400')}>
|
||||
{loginError}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{loginError && (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-red-500/30',
|
||||
'bg-red-500/10 px-3 py-2 mb-4'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
|
||||
<p className="text-xs text-red-400">{loginError}</p>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleCredentialSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" required>Username</Label>
|
||||
@ -598,30 +561,6 @@ export default function LockScreen() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Passkey login — shown when browser supports WebAuthn (U-01) */}
|
||||
{!isSetup && supportsWebAuthn && (
|
||||
<>
|
||||
<div className="relative my-4">
|
||||
<Separator />
|
||||
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={isPasskeyLoginPending}
|
||||
aria-label="Sign in with a passkey"
|
||||
>
|
||||
{isPasskeyLoginPending
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Fingerprint className="h-4 w-4" />}
|
||||
Sign in with a passkey
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Open registration link — only shown on login screen when enabled */}
|
||||
{!isSetup && registrationOpen && (
|
||||
<div className="mt-4 text-center">
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState, FormEvent, useCallback } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { Calendar, CalendarMemberInfo, CalendarPermission, Connection } from '@/types';
|
||||
import type { Calendar } from '@/types';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -14,11 +14,6 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PermissionToggle from './PermissionToggle';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
||||
import CalendarMemberSearch from './CalendarMemberSearch';
|
||||
import CalendarMemberList from './CalendarMemberList';
|
||||
|
||||
interface CalendarFormProps {
|
||||
calendar: Calendar | null;
|
||||
@ -26,8 +21,14 @@ interface CalendarFormProps {
|
||||
}
|
||||
|
||||
const colorSwatches = [
|
||||
'#3b82f6', '#ef4444', '#f97316', '#eab308',
|
||||
'#22c55e', '#8b5cf6', '#ec4899', '#06b6d4',
|
||||
'#3b82f6', // blue
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
'#22c55e', // green
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
];
|
||||
|
||||
export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
@ -35,23 +36,6 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
const [name, setName] = useState(calendar?.name || '');
|
||||
const [color, setColor] = useState(calendar?.color || '#3b82f6');
|
||||
|
||||
const [pendingInvite, setPendingInvite] = useState<{ conn: Connection; permission: CalendarPermission } | null>(null);
|
||||
|
||||
const { connections } = useConnections();
|
||||
const { invite, isInviting, updateMember, removeMember } = useSharedCalendars();
|
||||
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['calendar-members', calendar?.id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<CalendarMemberInfo[]>(
|
||||
`/shared-calendars/${calendar!.id}/members`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
enabled: !!calendar?.is_shared,
|
||||
});
|
||||
const members = membersQuery.data ?? [];
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (calendar) {
|
||||
@ -94,42 +78,11 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
mutation.mutate();
|
||||
};
|
||||
|
||||
const handleSelectConnection = useCallback((conn: Connection) => {
|
||||
setPendingInvite({ conn, permission: 'read_only' });
|
||||
}, []);
|
||||
|
||||
const handleSendInvite = async () => {
|
||||
if (!calendar || !pendingInvite) return;
|
||||
await invite({
|
||||
calendarId: calendar.id,
|
||||
connectionId: pendingInvite.conn.id,
|
||||
permission: pendingInvite.permission,
|
||||
canAddOthers: false,
|
||||
});
|
||||
setPendingInvite(null);
|
||||
};
|
||||
|
||||
const handleUpdatePermission = async (memberId: number, permission: CalendarPermission) => {
|
||||
if (!calendar) return;
|
||||
await updateMember({ calendarId: calendar.id, memberId, permission });
|
||||
};
|
||||
|
||||
const handleUpdateCanAddOthers = async (memberId: number, canAddOthers: boolean) => {
|
||||
if (!calendar) return;
|
||||
await updateMember({ calendarId: calendar.id, memberId, canAddOthers });
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId: number) => {
|
||||
if (!calendar) return;
|
||||
await removeMember({ calendarId: calendar.id, memberId });
|
||||
};
|
||||
|
||||
const canDelete = calendar && !calendar.is_default && !calendar.is_system;
|
||||
const showSharing = calendar && !calendar.is_system;
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className={calendar?.is_shared && showSharing ? 'max-w-3xl' : undefined}>
|
||||
<DialogContent>
|
||||
<DialogClose onClick={onClose} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
|
||||
@ -155,7 +108,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className="h-6 w-6 rounded-full border-2 transition-all duration-150 hover:scale-110"
|
||||
className="h-8 w-8 rounded-full border-2 transition-all duration-150 hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: color === c ? 'hsl(0 0% 98%)' : 'transparent',
|
||||
@ -166,72 +119,6 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSharing && (
|
||||
<>
|
||||
{calendar?.is_shared && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="mb-0">Members</Label>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
You (Owner)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pendingInvite ? (
|
||||
<div
|
||||
className="rounded-lg border border-border bg-card-elevated p-4 space-y-3 animate-fade-in"
|
||||
style={{ borderLeftWidth: '3px', borderLeftColor: 'hsl(var(--accent-color) / 0.5)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingInvite(null)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<PermissionToggle
|
||||
value={pendingInvite.permission}
|
||||
onChange={(p) => setPendingInvite((prev) => prev ? { ...prev, permission: p } : null)}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSendInvite}
|
||||
disabled={isInviting}
|
||||
>
|
||||
{isInviting ? 'Sending...' : 'Send Invite'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CalendarMemberSearch
|
||||
connections={connections}
|
||||
existingMembers={members}
|
||||
onSelect={handleSelectConnection}
|
||||
isLoading={isInviting}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CalendarMemberList
|
||||
members={members}
|
||||
isLoading={membersQuery.isLoading}
|
||||
isOwner={true}
|
||||
onUpdatePermission={handleUpdatePermission}
|
||||
onUpdateCanAddOthers={handleUpdateCanAddOthers}
|
||||
onRemove={handleRemoveMember}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{canDelete && (
|
||||
<Button
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
|
||||
import CalendarMemberRow from './CalendarMemberRow';
|
||||
|
||||
interface CalendarMemberListProps {
|
||||
members: CalendarMemberInfo[];
|
||||
isLoading?: boolean;
|
||||
isOwner: boolean;
|
||||
readOnly?: boolean;
|
||||
onUpdatePermission?: (memberId: number, permission: CalendarPermission) => void;
|
||||
onUpdateCanAddOthers?: (memberId: number, canAddOthers: boolean) => void;
|
||||
onRemove?: (memberId: number) => void;
|
||||
}
|
||||
|
||||
export default function CalendarMemberList({
|
||||
members,
|
||||
isLoading = false,
|
||||
isOwner,
|
||||
readOnly = false,
|
||||
onUpdatePermission,
|
||||
onUpdateCanAddOthers,
|
||||
onRemove,
|
||||
}: CalendarMemberListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground py-2">
|
||||
Search your connections to add members
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 max-h-72 overflow-y-auto">
|
||||
{members.map((member) => (
|
||||
<CalendarMemberRow
|
||||
key={member.id}
|
||||
member={member}
|
||||
isOwner={isOwner}
|
||||
readOnly={readOnly}
|
||||
onUpdatePermission={onUpdatePermission}
|
||||
onUpdateCanAddOthers={onUpdateCanAddOthers}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
import { X, UserPlus } from 'lucide-react';
|
||||
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import PermissionBadge from './PermissionBadge';
|
||||
import PermissionToggle from './PermissionToggle';
|
||||
|
||||
interface CalendarMemberRowProps {
|
||||
member: CalendarMemberInfo;
|
||||
isOwner: boolean;
|
||||
readOnly?: boolean;
|
||||
onUpdatePermission?: (memberId: number, permission: CalendarPermission) => void;
|
||||
onUpdateCanAddOthers?: (memberId: number, canAddOthers: boolean) => void;
|
||||
onRemove?: (memberId: number) => void;
|
||||
}
|
||||
|
||||
export default function CalendarMemberRow({
|
||||
member,
|
||||
isOwner,
|
||||
readOnly = false,
|
||||
onUpdatePermission,
|
||||
onUpdateCanAddOthers,
|
||||
onRemove,
|
||||
}: CalendarMemberRowProps) {
|
||||
const { confirming, handleClick: handleRemoveClick } = useConfirmAction(
|
||||
() => onRemove?.(member.id)
|
||||
);
|
||||
|
||||
const displayName = member.preferred_name || member.umbral_name;
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border p-3 transition-all duration-200 hover:border-border/80">
|
||||
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-sm text-violet-400 font-medium">{initial}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 truncate">
|
||||
<span className="text-sm font-medium truncate">{displayName}</span>
|
||||
{member.preferred_name && (
|
||||
<span className="text-xs text-violet-400 truncate shrink-0">{member.umbral_name}</span>
|
||||
)}
|
||||
{member.status === 'pending' && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-400 font-medium shrink-0">
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{readOnly ? (
|
||||
<PermissionBadge permission={member.permission} />
|
||||
) : isOwner ? (
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
<PermissionToggle
|
||||
value={member.permission}
|
||||
onChange={(p) => onUpdatePermission?.(member.id, p)}
|
||||
/>
|
||||
{(member.permission === 'create_modify' || member.permission === 'full_access') && (
|
||||
<label className="flex items-center gap-1.5 cursor-pointer shrink-0" title="Can add others">
|
||||
<Checkbox
|
||||
checked={member.can_add_others}
|
||||
onChange={() => onUpdateCanAddOthers?.(member.id, !member.can_add_others)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveClick}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={confirming ? 'Click again to confirm' : 'Remove member'}
|
||||
>
|
||||
{confirming ? (
|
||||
<span className="text-[10px] text-destructive font-medium px-1">Sure?</span>
|
||||
) : (
|
||||
<X className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<PermissionBadge permission={member.permission} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Search, Loader2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Connection, CalendarMemberInfo } from '@/types';
|
||||
|
||||
interface CalendarMemberSearchProps {
|
||||
connections: Connection[];
|
||||
existingMembers: CalendarMemberInfo[];
|
||||
onSelect: (connection: Connection) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function CalendarMemberSearch({
|
||||
connections,
|
||||
existingMembers,
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
}: CalendarMemberSearchProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setFocused(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const existingUserIds = new Set(existingMembers.map((m) => m.user_id));
|
||||
|
||||
const filtered = connections.filter((c) => {
|
||||
if (existingUserIds.has(c.connected_user_id)) return false;
|
||||
if (!query.trim()) return true;
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
c.connected_umbral_name.toLowerCase().includes(q) ||
|
||||
(c.connected_preferred_name?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
});
|
||||
|
||||
const handleSelect = (connection: Connection) => {
|
||||
onSelect(connection);
|
||||
setQuery('');
|
||||
setFocused(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="relative">
|
||||
{isLoading ? (
|
||||
<Loader2 className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<Input
|
||||
placeholder="Search connections to invite..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
className="pl-8 h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{focused && filtered.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden max-h-40 overflow-y-auto">
|
||||
{filtered.map((conn) => {
|
||||
const displayName = conn.connected_preferred_name || conn.connected_umbral_name;
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
return (
|
||||
<button
|
||||
key={conn.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleSelect(conn)}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
<div className="h-6 w-6 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs text-violet-400 font-medium">{initial}</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-medium truncate block">{displayName}</span>
|
||||
{conn.connected_preferred_name && (
|
||||
<span className="text-xs text-muted-foreground">{conn.connected_umbral_name}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{focused && query.trim() && filtered.length === 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg p-3">
|
||||
<p className="text-xs text-muted-foreground text-center">No matching connections</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user