Compare commits

..

No commits in common. "main" and "feature/event-invitations" have entirely different histories.

81 changed files with 1003 additions and 5826 deletions

View File

@ -21,15 +21,6 @@ ENVIRONMENT=development
# Timezone (applied to backend + db containers via env_file) # Timezone (applied to backend + db containers via env_file)
TZ=Australia/Perth 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 # Integrations
# ────────────────────────────────────── # ──────────────────────────────────────

View File

@ -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

View File

@ -4,20 +4,18 @@ A self-hosted, multi-user life administration app with a dark-themed UI and role
## Features ## Features
- **Multi-user RBAC** — Admin and standard user roles, per-user data isolation, admin portal with IAM, system config, and audit logs - **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 - **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) - **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 - **Calendar** - Multi-calendar system with month/week/day views, recurring events, drag-and-drop, event templates
- **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 - **Projects** - Project boards with kanban view, nested tasks/subtasks, comments, progress tracking
- **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 - **Reminders** - Time-based reminders with snooze, dismiss, recurrence, and real-time alert notifications (dashboard banner + toasts)
- **Projects** — Project boards with kanban view, nested tasks/subtasks, comments, progress tracking - **People** - Contact directory with avatar initials, favourites, birthday tracking, category filtering
- **Reminders** — Time-based reminders with snooze, dismiss, recurrence, and real-time alert notifications (dashboard banner + toasts) - **Locations** - Location management with OSM search integration, category filtering, frequent locations
- **People & Connections** — Contact directory with avatar initials, favourites, birthday tracking. Social connections via umbral name search with bidirectional Person records on accept - **Weather** - Dashboard weather widget with temperature, conditions, and contextual rain warnings
- **Locations** — Location management with OSM search integration, category filtering, frequent locations - **Settings** - Accent color picker (8 presets), first day of week, weather city, ntfy push notifications, TOTP two-factor auth, auto-lock, password management
- **Weather** — Dashboard weather widget with temperature, conditions, and contextual rain warnings - **Notifications** - ntfy push notifications for reminders (configurable per-user)
- **Settings** — Accent color picker (8 presets), first day of week, weather city, ntfy push notifications, TOTP two-factor auth, auto-lock, password management - **Admin Portal** - User management (create, delete, activate/deactivate, role assignment, password reset), system configuration (open registration, MFA enforcement), audit log viewer
- **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
## Tech Stack ## 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 | | Fonts | Sora (headings), DM Sans (body) via Google Fonts |
| State | TanStack Query v5, React Router v6 | | State | TanStack Query v5, React Router v6 |
| Backend | FastAPI, Python 3.12, Pydantic v2 | | 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 | | Auth | Argon2id hashing, DB-backed sessions (signed httpOnly cookies), TOTP MFA, CSRF middleware, role-based access control |
| Scheduler | APScheduler (async) for ntfy notification dispatch | | Scheduler | APScheduler (async) for ntfy notification dispatch |
| Deployment | Docker Compose (3 services), Nginx reverse proxy | | 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 ## 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 ### Production Hardening
@ -119,15 +137,21 @@ Before deploying to production, generate secure values for your `.env`:
```bash ```bash
# Generate a secure SECRET_KEY (64-char hex string) # Generate a secure SECRET_KEY (64-char hex string)
python3 -c "import secrets; print(secrets.token_hex(32))" python3 -c "import secrets; print(secrets.token_hex(32))"
# or: openssl rand -hex 32
# Generate a secure database password # Generate a secure database password
python3 -c "import secrets; print(secrets.token_urlsafe(24))" 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: 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) - 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`) - 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 ## 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/auth/*` | Login, logout, setup, register, status, password change, TOTP MFA |
| `/api/admin/*` | User management, system config, audit logs (admin only) | | `/api/admin/*` | User management, system config, audit logs (admin only) |
| `/api/todos/*` | Todos CRUD + toggle completion | | `/api/todos/*` | Todos CRUD + toggle completion |
| `/api/events/*` | Calendar events CRUD (incl. recurring) + event invitations | | `/api/events/*` | Calendar events CRUD (incl. recurring) |
| `/api/event-invitations/*` | Invitation responses, per-occurrence overrides, can_modify toggle, leave |
| `/api/event-templates/*` | Event templates CRUD | | `/api/event-templates/*` | Event templates CRUD |
| `/api/calendars/*` | User calendars CRUD + visibility | | `/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/reminders/*` | Reminders CRUD + dismiss + snooze + due alerts |
| `/api/projects/*` | Projects + nested tasks + comments CRUD | | `/api/projects/*` | Projects + nested tasks + comments CRUD |
| `/api/people/*` | People CRUD | | `/api/people/*` | People CRUD |
@ -152,8 +174,6 @@ All endpoints require authentication (signed session cookie) except auth routes
| `/api/dashboard` | Dashboard aggregation | | `/api/dashboard` | Dashboard aggregation |
| `/api/upcoming` | Unified upcoming items feed | | `/api/upcoming` | Unified upcoming items feed |
| `/api/weather/*` | Weather data proxy | | `/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`. API documentation is available at `/api/docs` (Swagger UI) when `ENVIRONMENT=development`.
@ -195,15 +215,15 @@ umbra/
│ ├── Dockerfile │ ├── Dockerfile
│ ├── requirements.txt │ ├── requirements.txt
│ ├── alembic.ini │ ├── alembic.ini
│ ├── alembic/versions/ # 56 migrations (001056) │ ├── alembic/versions/ # 37 migrations (001037)
│ └── app/ │ └── app/
│ ├── main.py # FastAPI app, CSRF middleware, router registration, health endpoint │ ├── main.py # FastAPI app, CSRF middleware, router registration, health endpoint
│ ├── config.py # Pydantic BaseSettings (DATABASE_URL, SECRET_KEY, CORS, etc.) │ ├── config.py # Pydantic BaseSettings (DATABASE_URL, SECRET_KEY, CORS, etc.)
│ ├── database.py # Async SQLAlchemy engine + session factory │ ├── database.py # Async SQLAlchemy engine + session factory
│ ├── models/ # 20 SQLAlchemy ORM models (incl. User, UserSession, EventInvitation, CalendarMember) │ ├── models/ # 18 SQLAlchemy ORM models (incl. User, UserSession, SystemConfig, AuditLog)
│ ├── schemas/ # 14 Pydantic v2 request/response schema modules │ ├── schemas/ # 13 Pydantic v2 request/response schema modules (incl. admin)
│ ├── routers/ # 16 API route handlers (incl. auth, admin, event_invitations, shared_calendars) │ ├── routers/ # 14 API route handlers (incl. auth, admin, totp)
│ ├── services/ # Auth (Argon2id), recurrence, TOTP, ntfy, audit, calendar_sharing, event_invitation, notification │ ├── services/ # Auth (Argon2id), recurrence, TOTP, ntfy, audit
│ └── jobs/ # APScheduler notification dispatch │ └── jobs/ # APScheduler notification dispatch
└── frontend/ └── frontend/
├── Dockerfile ├── Dockerfile
@ -213,22 +233,20 @@ umbra/
└── src/ └── src/
├── App.tsx # Routes, ProtectedRoute, AdminRoute auth guards ├── App.tsx # Routes, ProtectedRoute, AdminRoute auth guards
├── lib/ # api.ts (axios + 401 interceptor), date-utils.ts, utils.ts ├── 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 ├── types/ # TypeScript interfaces
└── components/ └── 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 ├── shared/ # EntityTable, EntityDetailPanel, CategoryFilterBar, CategoryAutocomplete, CopyableField
├── layout/ # AppLayout, Sidebar, LockOverlay ├── layout/ # AppLayout, Sidebar, LockOverlay
├── auth/ # LockScreen, AmbientBackground ├── auth/ # LockScreen, AmbientBackground
├── admin/ # AdminPortal, IAMPage, ConfigPage, AdminDashboardPage, CreateUserDialog, UserActionsMenu ├── admin/ # AdminPortal, IAMPage, ConfigPage, AdminDashboardPage, CreateUserDialog, UserActionsMenu, UserDetailSection
├── dashboard/ # DashboardPage + 8 widgets ├── dashboard/ # DashboardPage + 8 widgets
├── calendar/ # CalendarPage, CalendarSidebar, EventDetailPanel, InviteeSection, LeaveEventDialog, CalendarForm, EventForm, TemplateForm ├── calendar/ # CalendarPage, CalendarSidebar, CalendarForm, EventForm, TemplateForm
├── todos/ # TodosPage, TodoList, TodoItem, TodoForm, TodoDetailPanel ├── todos/ # TodosPage, TodoList, TodoItem, TodoForm
├── reminders/ # RemindersPage, ReminderList, ReminderItem, ReminderForm, SnoozeDropdown, AlertBanner ├── reminders/ # RemindersPage, ReminderList, ReminderItem, ReminderForm, SnoozeDropdown, AlertBanner
├── projects/ # ProjectsPage, ProjectCard, ProjectDetail, ProjectForm, KanbanBoard, TaskRow, TaskForm, TaskDetailPanel ├── projects/ # ProjectsPage, ProjectCard, ProjectDetail, ProjectForm, KanbanBoard, TaskRow, TaskForm, TaskDetailPanel
├── people/ # PeoplePage, PersonForm ├── people/ # PeoplePage, PersonForm
├── connections/ # ConnectionSearch, ConnectionRequestCard, ConnectionsTab
├── notifications/ # NotificationsPage, NotificationToaster
├── locations/ # LocationsPage, LocationForm ├── locations/ # LocationsPage, LocationForm
└── settings/ # SettingsPage, NtfySettingsSection, TotpSetupSection └── settings/ # SettingsPage, NtfySettingsSection, TotpSetupSection
``` ```

View File

@ -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

View File

@ -1,14 +1,2 @@
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
SECRET_KEY=your-secret-key-change-in-production 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

View File

@ -1,37 +1,39 @@
# UMBRA Backend # 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 ## Features
- **FastAPI** with async/await and Pydantic v2 - **FastAPI** with async/await support
- **SQLAlchemy 2.0** async engine with `Mapped[]` types - **SQLAlchemy 2.0** with async engine
- **PostgreSQL 16** via asyncpg - **PostgreSQL** with asyncpg driver
- **Alembic** database migrations (001-061) - **Alembic** for database migrations
- **Authentication**: Argon2id passwords + signed httpOnly cookies + optional TOTP MFA + passkey (WebAuthn/FIDO2) - **bcrypt** for password hashing
- **Multi-user RBAC**: admin/standard roles, per-user resource scoping - **itsdangerous** for session management
- **Session management**: DB-backed sessions, sliding window expiry, concurrent session cap - **PIN-based authentication** with secure session cookies
- **Account security**: Account lockout (10 failures = 30-min lock), CSRF protection, rate limiting - **Full CRUD operations** for all entities
- **APScheduler** for background notification dispatch - **Dashboard** with aggregated data
- **CORS enabled** for frontend integration
## Project Structure ## Project Structure
``` ```
backend/ backend/
├── alembic/versions/ # 61 database migrations ├── alembic/ # Database migrations
│ ├── versions/ # Migration files
│ ├── env.py # Alembic environment
│ └── script.py.mako # Migration template
├── app/ ├── app/
│ ├── models/ # 21 SQLAlchemy 2.0 models │ ├── models/ # SQLAlchemy models
│ ├── schemas/ # 14 Pydantic v2 schema modules │ ├── schemas/ # Pydantic schemas
│ ├── routers/ # 17 API routers │ ├── routers/ # API route handlers
│ ├── services/ # Auth, session, passkey, TOTP, audit, recurrence, etc. │ ├── config.py # Configuration
│ ├── jobs/ # APScheduler notification dispatch │ ├── database.py # Database setup
│ ├── config.py # Pydantic Settings (env vars) │ └── main.py # FastAPI application
│ ├── database.py # Async engine + session factory ├── requirements.txt # Python dependencies
│ └── main.py # FastAPI app + CSRF middleware ├── Dockerfile # Docker configuration
├── requirements.txt ├── alembic.ini # Alembic configuration
├── Dockerfile └── start.sh # Startup script
├── alembic.ini
└── start.sh
``` ```
## Setup ## Setup
@ -39,87 +41,160 @@ backend/
### 1. Install Dependencies ### 1. Install Dependencies
```bash ```bash
cd backend
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### 2. Configure Environment ### 2. Configure Environment
Copy `.env.example` to `.env` and configure: Create a `.env` file:
```bash ```bash
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/umbra DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
SECRET_KEY=generate-a-strong-random-key SECRET_KEY=your-secret-key-change-in-production
ENVIRONMENT=production
# WebAuthn / Passkeys (required for passkey auth)
WEBAUTHN_RP_ID=your-domain.com
WEBAUTHN_RP_NAME=UMBRA
WEBAUTHN_ORIGIN=https://your-domain.com
``` ```
### 3. Run Migrations ### 3. Create Database
```bash
createdb umbra
```
### 4. Run Migrations
```bash ```bash
alembic upgrade head alembic upgrade head
``` ```
### 4. Start Server ### 5. Start Server
```bash ```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 | Interactive API documentation is available at:
|--------|-------------| - **Swagger UI**: http://localhost:8000/docs
| `/api/auth` | Login, logout, register, setup, status, password, TOTP, passkeys | - **ReDoc**: http://localhost:8000/redoc
| `/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 |
## 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 ### Todos
2. **TOTP MFA** - Optional second factor via authenticator apps - `GET /api/todos` - List todos (with filters)
3. **Passkeys** (WebAuthn/FIDO2) - Optional passwordless login via biometrics, security keys, or password managers - `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 ### Projects
- All Pydantic schemas use `extra="forbid"` (mass-assignment prevention) - `GET /api/projects` - List projects
- Nginx rate limiting on auth, registration, and admin endpoints - `POST /api/projects` - Create project
- DB-backed account lockout after 10 failed attempts - `GET /api/projects/{id}` - Get project
- Timing-safe dummy hash for non-existent users (prevents enumeration) - `PUT /api/projects/{id}` - Update project
- SSRF validation on ntfy webhook URLs - `DELETE /api/projects/{id}` - Delete project
- Naive datetimes throughout (Docker runs UTC) - `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 ## Docker
The backend runs as non-root `appuser` in `python:3.12-slim`: Build and run with Docker:
```bash ```bash
docker build -t umbra-backend . 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

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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))

View File

@ -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")

View File

@ -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")

View File

@ -30,12 +30,6 @@ class Settings(BaseSettings):
# Concurrent session limit per user (oldest evicted when exceeded) # Concurrent session limit per user (oldest evicted when exceeded)
MAX_SESSIONS_PER_USER: int = 10 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( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
@ -53,9 +47,6 @@ class Settings(BaseSettings):
self.CORS_ORIGINS = "http://localhost:5173" self.CORS_ORIGINS = "http://localhost:5173"
assert self.COOKIE_SECURE is not None # type narrowing assert self.COOKIE_SECURE is not None # type narrowing
assert self.CORS_ORIGINS is not None 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 return self

View File

@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings from app.config import settings
from app.database import engine from app.database import engine
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
from app.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, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router
from app.jobs.notifications import run_notification_dispatch from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them # Import models so Alembic's autogenerate can discover them
@ -23,7 +23,6 @@ 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 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_lock as _event_lock_model # noqa: F401
from app.models import event_invitation as _event_invitation_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 +49,6 @@ class CSRFHeaderMiddleware:
"/api/auth/totp-verify", "/api/auth/totp-verify",
"/api/auth/totp/enforce-setup", "/api/auth/totp/enforce-setup",
"/api/auth/totp/enforce-confirm", "/api/auth/totp/enforce-confirm",
"/api/auth/passkeys/login/begin",
"/api/auth/passkeys/login/complete",
}) })
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) _MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
@ -137,7 +134,6 @@ app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"]) 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(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"]) 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(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"]) 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(connections_router.router, prefix="/api/connections", tags=["Connections"])

View File

@ -21,9 +21,6 @@ from app.models.user_connection import UserConnection
from app.models.calendar_member import CalendarMember from app.models.calendar_member import CalendarMember
from app.models.event_lock import EventLock from app.models.event_lock import EventLock
from app.models.event_invitation import EventInvitation, EventInvitationOverride 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__ = [ __all__ = [
"Settings", "Settings",
@ -50,7 +47,4 @@ __all__ = [
"EventLock", "EventLock",
"EventInvitation", "EventInvitation",
"EventInvitationOverride", "EventInvitationOverride",
"ProjectMember",
"ProjectTaskAssignment",
"PasskeyCredential",
] ]

View File

@ -9,8 +9,6 @@ _NOTIFICATION_TYPES = (
"connection_request", "connection_accepted", "connection_rejected", "connection_request", "connection_accepted", "connection_rejected",
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected", "calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
"event_invite", "event_invite_response", "event_invite", "event_invite_response",
"project_invite", "project_invite_accepted", "project_invite_rejected",
"task_assigned",
"info", "warning", "reminder", "system", "info", "warning", "reminder", "system",
) )

View File

@ -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)

View File

@ -22,7 +22,6 @@ class Project(Base):
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern) # Relationships
tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan", passive_deletes=True, lazy="raise") tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan")
todos: Mapped[List["Todo"]] = relationship(back_populates="project", lazy="raise") todos: Mapped[List["Todo"]] = relationship(back_populates="project")
members: Mapped[List["ProjectMember"]] = relationship(back_populates="project", cascade="all, delete-orphan", passive_deletes=True, lazy="raise")

View File

@ -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"
)

View File

@ -1,4 +1,3 @@
import sqlalchemy as sa
from sqlalchemy import String, Text, Integer, Date, ForeignKey, func from sqlalchemy import String, Text, Integer, Date, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
from datetime import datetime, date from datetime import datetime, date
@ -21,33 +20,21 @@ class ProjectTask(Base):
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) 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) 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) 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()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern) # Relationships
project: Mapped["Project"] = sa_relationship(back_populates="tasks", lazy="raise") project: Mapped["Project"] = sa_relationship(back_populates="tasks")
person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks", lazy="raise") person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks")
parent_task: Mapped[Optional["ProjectTask"]] = sa_relationship( parent_task: Mapped[Optional["ProjectTask"]] = sa_relationship(
back_populates="subtasks", back_populates="subtasks",
remote_side=[id], remote_side=[id],
lazy="raise",
) )
subtasks: Mapped[List["ProjectTask"]] = sa_relationship( subtasks: Mapped[List["ProjectTask"]] = sa_relationship(
back_populates="parent_task", back_populates="parent_task",
cascade="all, delete-orphan", cascade="all, delete-orphan",
passive_deletes=True,
lazy="raise",
) )
comments: Mapped[List["TaskComment"]] = sa_relationship( comments: Mapped[List["TaskComment"]] = sa_relationship(
back_populates="task", back_populates="task",
cascade="all, delete-orphan", 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",
) )

View File

@ -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")

View File

@ -21,9 +21,6 @@ class SystemConfig(Base):
enforce_mfa_new_users: Mapped[bool] = mapped_column( enforce_mfa_new_users: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false" 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()) created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
default=func.now(), onupdate=func.now(), server_default=func.now() default=func.now(), onupdate=func.now(), server_default=func.now()

View File

@ -1,7 +1,6 @@
from sqlalchemy import Text, Integer, ForeignKey from sqlalchemy import Text, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
from datetime import datetime from datetime import datetime
from typing import Optional
from app.database import Base from app.database import Base
@ -12,12 +11,8 @@ class TaskComment(Base):
task_id: Mapped[int] = mapped_column( task_id: Mapped[int] = mapped_column(
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True 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) content: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(default=datetime.now) created_at: Mapped[datetime] = mapped_column(default=datetime.now)
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern) # Relationships
task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments", lazy="raise") task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments")
user: Mapped[Optional["User"]] = sa_relationship(lazy="raise")

View File

@ -43,11 +43,6 @@ class User(Base):
Boolean, default=False, server_default="false" 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 # Audit
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -45,7 +45,6 @@ from app.schemas.admin import (
SystemConfigUpdate, SystemConfigUpdate,
ToggleActiveRequest, ToggleActiveRequest,
ToggleMfaEnforceRequest, ToggleMfaEnforceRequest,
TogglePasswordlessRequest,
UpdateUserRoleRequest, UpdateUserRoleRequest,
UserDetailResponse, UserDetailResponse,
UserListItem, UserListItem,
@ -671,56 +670,6 @@ async def get_user_sharing_stats(
"pending_invites_received": pending_received, "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 # GET /config
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -767,9 +716,6 @@ async def update_system_config(
if data.enforce_mfa_new_users is not None: if data.enforce_mfa_new_users is not None:
changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users
config.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: if changes:
await log_audit_event( await log_audit_event(

View File

@ -16,6 +16,7 @@ Security layers:
4. bcryptArgon2id transparent upgrade on first login 4. bcryptArgon2id transparent upgrade on first login
5. Role-based authorization via require_role() dependency factory 5. Role-based authorization via require_role() dependency factory
""" """
import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@ -29,7 +30,6 @@ from app.models.user import User
from app.models.session import UserSession from app.models.session import UserSession
from app.models.settings import Settings from app.models.settings import Settings
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
from app.models.passkey_credential import PasskeyCredential
from app.models.calendar import Calendar from app.models.calendar import Calendar
from app.schemas.auth import ( from app.schemas.auth import (
SetupRequest, LoginRequest, RegisterRequest, SetupRequest, LoginRequest, RegisterRequest,
@ -49,13 +49,6 @@ from app.services.auth import (
create_mfa_enforce_token, create_mfa_enforce_token,
) )
from app.services.audit import get_client_ip, log_audit_event from app.services.audit import get_client_ip, log_audit_event
from app.services.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 from app.config import settings as app_settings
router = APIRouter() router = APIRouter()
@ -66,6 +59,22 @@ router = APIRouter()
# is indistinguishable from a wrong-password attempt. # is indistinguishable from a wrong-password attempt.
_DUMMY_HASH = hash_password("timing-equalization-dummy") _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 # Auth dependencies — export get_current_user and get_current_settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -121,7 +130,7 @@ async def get_current_user(
await db.flush() await db.flush()
# Re-issue cookie with fresh signed token to reset browser max_age timer # Re-issue cookie with fresh signed token to reset browser max_age timer
fresh_token = create_session_token(user_id, session_id) fresh_token = create_session_token(user_id, session_id)
set_session_cookie(response, fresh_token) _set_session_cookie(response, fresh_token)
# Stash session on request so lock/unlock endpoints can access it # Stash session on request so lock/unlock endpoints can access it
request.state.db_session = db_session request.state.db_session = db_session
@ -132,7 +141,6 @@ async def get_current_user(
lock_exempt = { lock_exempt = {
"/api/auth/lock", "/api/auth/verify-password", "/api/auth/lock", "/api/auth/verify-password",
"/api/auth/status", "/api/auth/logout", "/api/auth/status", "/api/auth/logout",
"/api/auth/passkeys/login/begin", "/api/auth/passkeys/login/complete",
} }
if request.url.path not in lock_exempt: if request.url.path not in lock_exempt:
raise HTTPException(status_code=423, detail="Session is locked") raise HTTPException(status_code=423, detail="Session is locked")
@ -182,6 +190,82 @@ def require_role(*allowed_roles: str):
require_admin = require_role("admin") 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) # User bootstrapping helper (Settings + default calendars)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -237,8 +321,8 @@ async def setup(
ip = get_client_ip(request) ip = get_client_ip(request)
user_agent = request.headers.get("user-agent") user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, new_user, ip, user_agent) _, token = await _create_db_session(db, new_user, ip, user_agent)
set_session_cookie(response, token) _set_session_cookie(response, token)
await log_audit_event( await log_audit_event(
db, action="auth.setup_complete", actor_id=new_user.id, ip=ip, db, action="auth.setup_complete", actor_id=new_user.id, ip=ip,
@ -282,38 +366,19 @@ async def login(
# executes — prevents distinguishing "locked" from "wrong password" via timing. # executes — prevents distinguishing "locked" from "wrong password" via timing.
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash) valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
await check_account_lockout(user) await _check_account_lockout(user)
if not valid: if not valid:
remaining = await record_failed_login(db, user) await _record_failed_login(db, user)
await log_audit_event( await log_audit_event(
db, action="auth.login_failed", actor_id=user.id, 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() await db.commit()
if remaining == 0: raise HTTPException(status_code=401, detail="Invalid username or password")
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.",
)
# Block disabled accounts — checked AFTER password verification to avoid # 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. # last_login_at and lockout counters are not reset for inactive users.
if not user.is_active: if not user.is_active:
await log_audit_event( await log_audit_event(
@ -326,7 +391,7 @@ async def login(
if new_hash: if new_hash:
user.password_hash = 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 # SEC-03: MFA enforcement — block login entirely until MFA setup completes
if user.mfa_enforce_pending and not user.totp_enabled: if user.mfa_enforce_pending and not user.totp_enabled:
@ -344,7 +409,6 @@ async def login(
# If TOTP is enabled, issue a short-lived MFA challenge token # If TOTP is enabled, issue a short-lived MFA challenge token
if user.totp_enabled: if user.totp_enabled:
mfa_token = create_mfa_token(user.id) mfa_token = create_mfa_token(user.id)
await db.commit()
return { return {
"authenticated": False, "authenticated": False,
"totp_required": True, "totp_required": True,
@ -355,8 +419,8 @@ async def login(
if user.must_change_password: if user.must_change_password:
# Issue a session but flag the frontend to show password change # Issue a session but flag the frontend to show password change
user_agent = request.headers.get("user-agent") user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, user, client_ip, user_agent) _, token = await _create_db_session(db, user, client_ip, user_agent)
set_session_cookie(response, token) _set_session_cookie(response, token)
await db.commit() await db.commit()
return { return {
"authenticated": True, "authenticated": True,
@ -364,8 +428,8 @@ async def login(
} }
user_agent = request.headers.get("user-agent") user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, user, client_ip, user_agent) _, token = await _create_db_session(db, user, client_ip, user_agent)
set_session_cookie(response, token) _set_session_cookie(response, token)
await log_audit_event( await log_audit_event(
db, action="auth.login_success", actor_id=user.id, ip=client_ip, db, action="auth.login_success", actor_id=user.id, ip=client_ip,
@ -447,8 +511,8 @@ async def register(
"mfa_token": enforce_token, "mfa_token": enforce_token,
} }
_, token = await create_db_session(db, new_user, ip, user_agent) _, token = await _create_db_session(db, new_user, ip, user_agent)
set_session_cookie(response, token) _set_session_cookie(response, token)
await db.commit() await db.commit()
return {"message": "Registration successful", "authenticated": True} return {"message": "Registration successful", "authenticated": True}
@ -500,34 +564,34 @@ async def auth_status(
is_locked = False is_locked = False
u = None
if not setup_required and session_cookie: if not setup_required and session_cookie:
payload = verify_session_token(session_cookie) payload = verify_session_token(session_cookie)
if payload: if payload:
user_id = payload.get("uid") user_id = payload.get("uid")
session_id = payload.get("sid") session_id = payload.get("sid")
if user_id and session_id: if user_id and session_id:
# Single JOIN query (was 2 sequential queries — P-01 fix) session_result = await db.execute(
result = await db.execute( select(UserSession).where(
select(UserSession, User)
.join(User, UserSession.user_id == User.id)
.where(
UserSession.id == session_id, UserSession.id == session_id,
UserSession.user_id == user_id, UserSession.user_id == user_id,
UserSession.revoked == False, UserSession.revoked == False,
UserSession.expires_at > datetime.now(), UserSession.expires_at > datetime.now(),
User.is_active == True,
) )
) )
row = result.one_or_none() db_sess = session_result.scalar_one_or_none()
if row is not None: if db_sess is not None:
db_sess, u = row.tuple()
authenticated = True authenticated = True
is_locked = db_sess.is_locked 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 # Check registration availability
config = None
registration_open = False registration_open = False
if not setup_required: if not setup_required:
config_result = await db.execute( config_result = await db.execute(
@ -536,19 +600,6 @@ async def auth_status(
config = config_result.scalar_one_or_none() config = config_result.scalar_one_or_none()
registration_open = config.allow_registration if config else False 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 { return {
"authenticated": authenticated, "authenticated": authenticated,
"setup_required": setup_required, "setup_required": setup_required,
@ -556,9 +607,6 @@ async def auth_status(
"username": u.username if authenticated and u else None, "username": u.username if authenticated and u else None,
"registration_open": registration_open, "registration_open": registration_open,
"is_locked": is_locked, "is_locked": is_locked,
"has_passkeys": has_passkeys,
"passwordless_enabled": passwordless_enabled,
"allow_passwordless": config.allow_passwordless if config else False,
} }
@ -577,7 +625,7 @@ async def lock_session(
@router.post("/verify-password") @router.post("/verify-password")
async def verify_password_endpoint( async def verify_password(
data: VerifyPasswordRequest, data: VerifyPasswordRequest,
request: Request, request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@ -587,12 +635,11 @@ async def verify_password_endpoint(
Verify the current user's password without changing anything. Verify the current user's password without changing anything.
Used by the frontend lock screen to re-authenticate without a full login. 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 = await averify_password_with_upgrade(data.password, current_user.password_hash)
if not valid: if not valid:
await record_failed_login(db, current_user) await _record_failed_login(db, current_user)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid password") raise HTTPException(status_code=401, detail="Invalid password")
if new_hash: if new_hash:
@ -614,12 +661,11 @@ async def change_password(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Change the current user's password. Requires old password verification.""" """Change the current user's password. Requires old password verification."""
await check_account_lockout(current_user) await _check_account_lockout(current_user)
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash) valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
if not valid: if not valid:
await record_failed_login(db, current_user) await _record_failed_login(db, current_user)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid current password") raise HTTPException(status_code=401, detail="Invalid current password")
if data.new_password == data.old_password: if data.new_password == data.old_password:

View File

@ -1,7 +1,4 @@
from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from typing import List from typing import List
@ -11,7 +8,6 @@ from app.models.calendar import Calendar
from app.models.calendar_event import CalendarEvent from app.models.calendar_event import CalendarEvent
from app.models.calendar_member import CalendarMember from app.models.calendar_member import CalendarMember
from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse 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.routers.auth import get_current_user
from app.models.user import User from app.models.user import User
@ -140,62 +136,3 @@ async def delete_calendar(
await db.delete(calendar) await db.delete(calendar)
await db.commit() await db.commit()
return None 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,
)

View File

@ -51,7 +51,6 @@ from app.services.connection import (
) )
from app.services.calendar_sharing import cascade_on_disconnect from app.services.calendar_sharing import cascade_on_disconnect
from app.services.notification import create_notification from app.services.notification import create_notification
from app.services.project_sharing import cascade_projects_on_disconnect
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -828,9 +827,6 @@ async def remove_connection(
# Cascade: remove calendar memberships and event locks between these users # Cascade: remove calendar memberships and event locks between these users
await cascade_on_disconnect(db, current_user.id, counterpart_id) 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( await log_audit_event(
db, db,
action="connection.removed", action="connection.removed",

View File

@ -8,7 +8,6 @@ import json
import urllib.request import urllib.request
import urllib.parse import urllib.parse
import logging import logging
import re
from app.database import get_db from app.database import get_db
from app.models.location import Location 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) # Nominatim proxy search (run in thread executor to avoid blocking event loop)
def _fetch_nominatim() -> list: def _fetch_nominatim() -> list:
encoded_q = urllib.parse.quote(q) 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"}) req = urllib.request.Request(url, headers={"User-Agent": "UMBRA-LifeManager/1.0"})
with urllib.request.urlopen(req, timeout=5) as resp: with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read().decode()) return json.loads(resp.read().decode())
@ -68,37 +67,9 @@ async def search_locations(
osm_data = await loop.run_in_executor(None, _fetch_nominatim) osm_data = await loop.run_in_executor(None, _fetch_nominatim)
for item in osm_data: for item in osm_data:
display_name = item.get("display_name", "") display_name = item.get("display_name", "")
addr = item.get("address", {}) name_parts = display_name.split(",", 1)
house_number = addr.get("house_number", "") name = name_parts[0].strip()
road = addr.get("road", "") address = name_parts[1].strip() if len(name_parts) > 1 else display_name
# 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
results.append( results.append(
LocationSearchResult( LocationSearchResult(
source="nominatim", source="nominatim",

View File

@ -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"}

View File

@ -1,31 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession 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 sqlalchemy.orm import selectinload
from typing import List, Optional from typing import List, Optional
from datetime import date, datetime, timedelta from datetime import date, timedelta
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from app.database import get_db from app.database import get_db
from app.models.project import Project from app.models.project import Project
from app.models.project_task import ProjectTask from app.models.project_task import ProjectTask
from app.models.task_comment import TaskComment 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 import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse
from app.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.routers.auth import get_current_user
from app.models.user import User from app.models.user import User
@ -39,61 +26,34 @@ class ReorderItem(BaseModel):
def _project_load_options(): 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 [ return [
selectinload(Project.tasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user), selectinload(Project.tasks).selectinload(ProjectTask.comments),
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user), selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks), 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(): def _task_load_options():
"""All load options needed for task responses.""" """All load options needed for task responses."""
return [ return [
selectinload(ProjectTask.comments).selectinload(TaskComment.user), selectinload(ProjectTask.comments),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user), selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks), 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]) @router.get("/", response_model=List[ProjectResponse])
async def get_projects( async def get_projects(
tracked: Optional[bool] = Query(None), tracked: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get all projects the user owns or has accepted membership in.""" """Get all projects with their tasks. Optionally filter by tracked status."""
accessible_ids = await get_accessible_project_ids(db, current_user.id)
if not accessible_ids:
return []
query = ( query = (
select(Project) select(Project)
.options(*_project_load_options()) .options(*_project_load_options())
.where(Project.id.in_(accessible_ids)) .where(Project.user_id == current_user.id)
.order_by(Project.created_at.desc()) .order_by(Project.created_at.desc())
) )
if tracked is not None: if tracked is not None:
@ -112,10 +72,6 @@ async def get_tracked_tasks(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get tasks and subtasks from tracked projects with due dates within the next N days.""" """Get tasks and subtasks from tracked projects with due dates within the next N days."""
accessible_ids = await get_accessible_project_ids(db, current_user.id)
if not accessible_ids:
return []
today = date.today() today = date.today()
cutoff = today + timedelta(days=days) cutoff = today + timedelta(days=days)
@ -127,7 +83,7 @@ async def get_tracked_tasks(
selectinload(ProjectTask.parent_task), selectinload(ProjectTask.parent_task),
) )
.where( .where(
Project.id.in_(accessible_ids), Project.user_id == current_user.id,
Project.is_tracked == True, Project.is_tracked == True,
ProjectTask.due_date.isnot(None), ProjectTask.due_date.isnot(None),
ProjectTask.due_date >= today, ProjectTask.due_date >= today,
@ -154,31 +110,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) @router.post("/", response_model=ProjectResponse, status_code=201)
async def create_project( async def create_project(
project: ProjectCreate, project: ProjectCreate,
@ -190,6 +121,7 @@ async def create_project(
db.add(new_project) db.add(new_project)
await db.commit() 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) query = select(Project).options(*_project_load_options()).where(Project.id == new_project.id)
result = await db.execute(query) result = await db.execute(query)
return result.scalar_one() return result.scalar_one()
@ -202,12 +134,10 @@ async def get_project(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get a specific project by ID with its tasks.""" """Get a specific project by ID with its tasks."""
await require_project_permission(db, project_id, current_user.id, "read_only")
query = ( query = (
select(Project) select(Project)
.options(*_project_load_options()) .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) result = await db.execute(query)
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
@ -225,10 +155,10 @@ async def update_project(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Update a project. Owner only.""" """Update a project."""
await require_project_permission(db, project_id, current_user.id, "owner") result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
result = await db.execute(select(Project).where(Project.id == project_id)) )
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
@ -241,6 +171,7 @@ async def update_project(
await db.commit() await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization
query = select(Project).options(*_project_load_options()).where(Project.id == project_id) query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
result = await db.execute(query) result = await db.execute(query)
return result.scalar_one() return result.scalar_one()
@ -252,10 +183,10 @@ async def delete_project(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a project and all its tasks. Owner only.""" """Delete a project and all its tasks."""
await require_project_permission(db, project_id, current_user.id, "owner") result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
result = await db.execute(select(Project).where(Project.id == project_id)) )
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
@ -267,10 +198,6 @@ async def delete_project(
return None return None
# ──────────────────────────────────────────────
# TASK CRUD (permission-aware)
# ──────────────────────────────────────────────
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse]) @router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
async def get_project_tasks( async def get_project_tasks(
project_id: int = Path(ge=1, le=2147483647), project_id: int = Path(ge=1, le=2147483647),
@ -278,7 +205,14 @@ async def get_project_tasks(
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Get top-level tasks for a specific project (subtasks are nested).""" """Get top-level tasks for a specific project (subtasks are nested)."""
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 = ( query = (
select(ProjectTask) select(ProjectTask)
@ -302,8 +236,15 @@ async def create_project_task(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Create a new task or subtask for a project. Requires create_modify permission.""" """Create a new task or subtask for a project."""
await require_project_permission(db, project_id, current_user.id, "create_modify") # 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 # Validate parent_task_id if creating a subtask
if task.parent_task_id is not None: if task.parent_task_id is not None:
@ -327,6 +268,7 @@ async def create_project_task(
db.add(new_task) db.add(new_task)
await db.commit() await db.commit()
# Re-fetch with subtasks loaded
query = ( query = (
select(ProjectTask) select(ProjectTask)
.options(*_task_load_options()) .options(*_task_load_options())
@ -343,8 +285,15 @@ async def reorder_tasks(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Bulk update sort_order for tasks. Requires create_modify permission.""" """Bulk update sort_order for tasks."""
await require_project_permission(db, project_id, current_user.id, "create_modify") # 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")
# AC-4: Batch-fetch all tasks in one query instead of N sequential queries # AC-4: Batch-fetch all tasks in one query instead of N sequential queries
task_ids = [item.id for item in items] task_ids = [item.id for item in items]
@ -374,12 +323,13 @@ async def update_project_task(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Update a project task. Permission checked at project and task level.""" """Update a project task."""
perm, project_perm = await get_effective_task_permission(db, current_user.id, task_id, project_id) # Verify project ownership first, then fetch task scoped to that project
if perm is None: 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") 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( result = await db.execute(
select(ProjectTask).where( select(ProjectTask).where(
@ -394,28 +344,12 @@ async def update_project_task(
update_data = task_update.model_dump(exclude_unset=True) 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(): for key, value in update_data.items():
setattr(task, key, value) setattr(task, key, value)
task.version += 1
await db.commit() await db.commit()
# Re-fetch with subtasks loaded
query = ( query = (
select(ProjectTask) select(ProjectTask)
.options(*_task_load_options()) .options(*_task_load_options())
@ -432,8 +366,13 @@ async def delete_project_task(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a project task (cascades to subtasks). Requires create_modify permission.""" """Delete a project task (cascades to subtasks)."""
await require_project_permission(db, project_id, current_user.id, "create_modify") # 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( result = await db.execute(
select(ProjectTask).where( select(ProjectTask).where(
@ -452,10 +391,6 @@ async def delete_project_task(
return None return None
# ──────────────────────────────────────────────
# COMMENTS (permission-aware)
# ──────────────────────────────────────────────
@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201) @router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201)
async def create_task_comment( async def create_task_comment(
project_id: int = Path(ge=1, le=2147483647), project_id: int = Path(ge=1, le=2147483647),
@ -464,8 +399,13 @@ async def create_task_comment(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Add a comment to a task. All members can comment (read_only minimum).""" """Add a comment to a task."""
await require_project_permission(db, project_id, current_user.id, "read_only") # 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( result = await db.execute(
select(ProjectTask).where( select(ProjectTask).where(
@ -478,23 +418,12 @@ async def create_task_comment(
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") 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) db.add(new_comment)
# Get author name before commit
author_name = await _get_user_name(db, current_user.id)
await db.commit() await db.commit()
await db.refresh(new_comment) await db.refresh(new_comment)
return TaskCommentResponse( return new_comment
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,
)
@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204) @router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
@ -505,9 +434,12 @@ async def delete_task_comment(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""Delete a task comment. Comment author or project owner only.""" """Delete a task comment."""
perm = await get_project_permission(db, project_id, current_user.id) # Verify project ownership first, then fetch comment scoped through task
if perm is None: 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") raise HTTPException(status_code=404, detail="Project not found")
result = await db.execute( result = await db.execute(
@ -521,484 +453,7 @@ async def delete_task_comment(
if not comment: if not comment:
raise HTTPException(status_code=404, detail="Comment not found") 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.delete(comment)
await db.commit() await db.commit()
return None 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,
)

View File

@ -18,9 +18,10 @@ Security:
- totp-verify uses mfa_token (not session cookie) user is not yet authenticated - totp-verify uses mfa_token (not session cookie) user is not yet authenticated
""" """
import asyncio import asyncio
import uuid
import secrets import secrets
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
@ -31,21 +32,16 @@ from sqlalchemy.exc import IntegrityError
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
from app.models.session import UserSession
from app.models.totp_usage import TOTPUsage from app.models.totp_usage import TOTPUsage
from app.models.backup_code import BackupCode 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.audit import get_client_ip
from app.services.auth import ( from app.services.auth import (
averify_password_with_upgrade, averify_password_with_upgrade,
verify_mfa_token, verify_mfa_token,
verify_mfa_enforce_token, verify_mfa_enforce_token,
) create_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.totp import ( from app.services.totp import (
generate_totp_secret, generate_totp_secret,
@ -56,7 +52,7 @@ from app.services.totp import (
generate_qr_base64, generate_qr_base64,
generate_backup_codes, generate_backup_codes,
) )
from app.config import settings as app_settings
# Argon2id for backup code hashing — treat each code like a password # Argon2id for backup code hashing — treat each code like a password
from argon2 import PasswordHasher from argon2 import PasswordHasher
@ -166,6 +162,29 @@ async def _verify_backup_code(
return False 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 # Routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -269,55 +288,60 @@ async def totp_verify(
raise HTTPException(status_code=400, detail="TOTP not configured for this account") raise HTTPException(status_code=400, detail="TOTP not configured for this account")
# Check account lockout (shared counter with password failures) # 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 --- # --- Backup code path ---
if data.backup_code: if data.backup_code:
normalized = data.backup_code.strip().upper() normalized = data.backup_code.strip().upper()
valid = await _verify_backup_code(db, user.id, normalized) valid = await _verify_backup_code(db, user.id, normalized)
if not valid: 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() 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") raise HTTPException(status_code=401, detail="Invalid backup code")
# Backup code accepted — reset lockout counter and issue session # Backup code accepted — reset lockout counter and issue session
await record_successful_login(db, user) user.failed_login_count = 0
user.locked_until = None
ip = get_client_ip(request) user.last_login_at = datetime.now()
user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, user, ip, user_agent)
set_session_cookie(response, token)
await db.commit() await db.commit()
token = await _create_full_session(db, user, request)
_set_session_cookie(response, token)
return {"authenticated": True} return {"authenticated": True}
# --- TOTP code path --- # --- TOTP code path ---
matched_window = verify_totp_code(user.totp_secret, data.code) matched_window = verify_totp_code(user.totp_secret, data.code)
if matched_window is None: 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() 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") raise HTTPException(status_code=401, detail="Invalid code")
# Replay prevention — record (user_id, code, actual_matching_window) # Replay prevention — record (user_id, code, actual_matching_window)
totp_record = TOTPUsage(user_id=user.id, code=data.code, window=matched_window) totp_record = TOTPUsage(user_id=user.id, code=data.code, window=matched_window)
db.add(totp_record) db.add(totp_record)
try: try:
await db.flush() await db.commit()
except IntegrityError: except IntegrityError:
await db.rollback() await db.rollback()
raise HTTPException(status_code=401, detail="Code already used — wait for the next code") 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 # Success — reset lockout counter, update last_login_at, issue full session
await record_successful_login(db, user) user.failed_login_count = 0
user.locked_until = None
ip = get_client_ip(request) user.last_login_at = datetime.now()
user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, user, ip, user_agent)
set_session_cookie(response, token)
await db.commit() await db.commit()
token = await _create_full_session(db, user, request)
_set_session_cookie(response, token)
return {"authenticated": True} return {"authenticated": True}
@ -489,11 +513,9 @@ async def enforce_confirm_totp(
user.last_login_at = datetime.now() user.last_login_at = datetime.now()
await db.commit() await db.commit()
# Issue a full session (now uses shared session service with cap enforcement) # Issue a full session
ip = get_client_ip(request) token = await _create_full_session(db, user, request)
user_agent = request.headers.get("user-agent") _set_session_cookie(response, token)
_, token = await create_db_session(db, user, ip, user_agent)
set_session_cookie(response, token)
return {"authenticated": True} return {"authenticated": True}

View File

@ -30,7 +30,6 @@ class UserListItem(BaseModel):
last_password_change_at: Optional[datetime] = None last_password_change_at: Optional[datetime] = None
totp_enabled: bool totp_enabled: bool
mfa_enforce_pending: bool mfa_enforce_pending: bool
passwordless_enabled: bool = False
created_at: datetime created_at: datetime
active_sessions: int = 0 active_sessions: int = 0
@ -108,7 +107,6 @@ class ToggleMfaEnforceRequest(BaseModel):
class SystemConfigResponse(BaseModel): class SystemConfigResponse(BaseModel):
allow_registration: bool allow_registration: bool
enforce_mfa_new_users: bool enforce_mfa_new_users: bool
allow_passwordless: bool = False
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -117,12 +115,6 @@ class SystemConfigUpdate(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
allow_registration: Optional[bool] = None allow_registration: Optional[bool] = None
enforce_mfa_new_users: 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -1,11 +1,8 @@
import logging from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List, Literal from typing import Optional, List, Literal
from app.schemas.project_task import ProjectTaskResponse from app.schemas.project_task import ProjectTaskResponse
logger = logging.getLogger(__name__)
ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "review", "on_hold"] ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "review", "on_hold"]
@ -33,44 +30,18 @@ class ProjectUpdate(BaseModel):
class ProjectResponse(BaseModel): class ProjectResponse(BaseModel):
id: int id: int
user_id: int = 0
name: str name: str
description: Optional[str] description: Optional[str]
status: str status: str
color: Optional[str] color: Optional[str]
due_date: Optional[date] due_date: Optional[date]
is_tracked: bool is_tracked: bool
member_count: int = 0
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
tasks: List[ProjectTaskResponse] = [] tasks: List[ProjectTaskResponse] = []
model_config = ConfigDict(from_attributes=True) 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): class TrackedTaskResponse(BaseModel):
id: int id: int

View File

@ -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)

View File

@ -2,7 +2,6 @@ from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime, date from datetime import datetime, date
from typing import Optional, List, Literal from typing import Optional, List, Literal
from app.schemas.task_comment import TaskCommentResponse 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"] TaskStatus = Literal["pending", "in_progress", "completed", "blocked", "review", "on_hold"]
TaskPriority = Literal["none", "low", "medium", "high"] TaskPriority = Literal["none", "low", "medium", "high"]
@ -31,7 +30,6 @@ class ProjectTaskUpdate(BaseModel):
due_date: Optional[date] = None due_date: Optional[date] = None
person_id: Optional[int] = None person_id: Optional[int] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
version: Optional[int] = None # For optimistic locking
class ProjectTaskResponse(BaseModel): class ProjectTaskResponse(BaseModel):
@ -45,12 +43,10 @@ class ProjectTaskResponse(BaseModel):
due_date: Optional[date] due_date: Optional[date]
person_id: Optional[int] person_id: Optional[int]
sort_order: int sort_order: int
version: int = 1
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
subtasks: List["ProjectTaskResponse"] = [] subtasks: List["ProjectTaskResponse"] = []
comments: List[TaskCommentResponse] = [] comments: List[TaskCommentResponse] = []
assignments: List[TaskAssignmentResponse] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -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

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime from datetime import datetime
@ -11,19 +11,7 @@ class TaskCommentCreate(BaseModel):
class TaskCommentResponse(BaseModel): class TaskCommentResponse(BaseModel):
id: int id: int
task_id: int task_id: int
user_id: int | None = None
author_name: str | None = None
content: str content: str
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) 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

View File

@ -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,
)

View File

@ -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,
)
)

View File

@ -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

View File

@ -15,4 +15,3 @@ python-dateutil==2.9.0
itsdangerous==2.2.0 itsdangerous==2.2.0
httpx==0.27.2 httpx==0.27.2
apscheduler==3.10.4 apscheduler==3.10.4
webauthn>=2.1.0,<3

View File

@ -2,10 +2,7 @@ services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
environment: env_file: .env
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks: networks:
@ -22,17 +19,9 @@ services:
cpus: "1.0" cpus: "1.0"
backend: backend:
image: git.sentinelforest.xyz/rohskiddo/umbra-backend:main-latest build: ./backend
restart: unless-stopped restart: unless-stopped
environment: env_file: .env
- 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}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@ -41,7 +30,7 @@ services:
- frontend_net - frontend_net
healthcheck: healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 30s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 30s start_period: 30s
@ -52,7 +41,7 @@ services:
cpus: "1.0" cpus: "1.0"
frontend: frontend:
image: git.sentinelforest.xyz/rohskiddo/umbra-frontend:main-latest build: ./frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:8080" - "80:8080"
@ -62,7 +51,7 @@ services:
networks: networks:
- frontend_net - frontend_net
healthcheck: healthcheck:
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:8080/"] test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 3 retries: 3

View File

@ -7,7 +7,6 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#09090b" /> <meta name="theme-color" content="#09090b" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>UMBRA</title> <title>UMBRA</title>
<!-- Static style tag — survives Vite's head cleanup (unlike dynamically created elements). <!-- Static style tag — survives Vite's head cleanup (unlike dynamically created elements).
The inline script below populates it with accent color from localStorage cache. --> The inline script below populates it with accent color from localStorage cache. -->

View File

@ -12,8 +12,6 @@ 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; 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 # 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; 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 # Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
map $http_x_forwarded_proto $forwarded_proto { map $http_x_forwarded_proto $forwarded_proto {
@ -31,14 +29,13 @@ server {
# Suppress nginx version in Server header # Suppress nginx version in Server header
server_tokens off; 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. # Pangolin (TLS-terminating reverse proxy) connects via Docker bridge.
# Restore the real client IP from X-Forwarded-For so that limit_req_zone # 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. # (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 # Safe to trust all sources: nginx is only reachable via Docker networking,
# external client to spoof X-Forwarded-For and bypass rate limiting (F-03). # never directly internet-facing. Tighten if deployment model changes.
set_real_ip_from 172.16.0.0/12; set_real_ip_from 0.0.0.0/0;
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For; real_ip_header X-Forwarded-For;
real_ip_recursive on; real_ip_recursive on;
@ -86,36 +83,6 @@ server {
include /etc/nginx/proxy-params.conf; 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 # SEC-14: Rate-limit public registration endpoint
location /api/auth/register { location /api/auth/register {
limit_req zone=register_limit burst=3 nodelay; limit_req zone=register_limit burst=3 nodelay;
@ -166,13 +133,6 @@ server {
include /etc/nginx/proxy-params.conf; 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 (catch-all for non-rate-limited endpoints)
location /api { location /api {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@ -193,7 +153,7 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header 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; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
} }
@ -201,8 +161,8 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header 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; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# PT-I03: Restrict unnecessary browser APIs # 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;
} }

View File

@ -16,7 +16,6 @@
"@fullcalendar/interaction": "^6.1.15", "@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/react": "^6.1.15", "@fullcalendar/react": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15",
"@simplewebauthn/browser": "^10.0.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -1349,22 +1348,6 @@
"win32" "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": { "node_modules/@tanstack/query-core": {
"version": "5.90.20", "version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",

View File

@ -17,7 +17,6 @@
"@fullcalendar/interaction": "^6.1.15", "@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/react": "^6.1.15", "@fullcalendar/react": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15", "@fullcalendar/timegrid": "^6.1.15",
"@simplewebauthn/browser": "^10.0.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@ -11,6 +11,6 @@ add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header 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; 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; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;

View File

@ -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

View File

@ -81,7 +81,7 @@ export default function IAMPage() {
); );
}, [users, searchQuery]); }, [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 { try {
await updateConfig.mutateAsync({ [key]: value }); await updateConfig.mutateAsync({ [key]: value });
toast.success('System settings updated'); toast.success('System settings updated');
@ -123,8 +123,8 @@ export default function IAMPage() {
/> />
</div> </div>
{/* User table — relative z-10 so action dropdowns render above sibling cards */} {/* User table */}
<Card className="relative z-10"> <Card>
<CardHeader className="flex-row items-center justify-between flex-wrap gap-2 md:gap-3"> <CardHeader className="flex-row items-center justify-between flex-wrap gap-2 md:gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10"> <div className="p-1.5 rounded-md bg-accent/10">
@ -160,7 +160,7 @@ export default function IAMPage() {
{searchQuery ? 'No users match your search.' : 'No users found.'} {searchQuery ? 'No users match your search.' : 'No users found.'}
</p> </p>
) : ( ) : (
<div> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-card-elevated/50"> <tr className="border-b border-border bg-card-elevated/50">
@ -320,20 +320,6 @@ export default function IAMPage() {
disabled={updateConfig.isPending} disabled={updateConfig.isPending}
/> />
</div> </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> </CardContent>

View File

@ -11,7 +11,6 @@ import {
ChevronRight, ChevronRight,
Loader2, Loader2,
Trash2, Trash2,
ShieldOff,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction'; import { useConfirmAction } from '@/hooks/useConfirmAction';
@ -24,7 +23,6 @@ import {
useToggleUserActive, useToggleUserActive,
useRevokeSessions, useRevokeSessions,
useDeleteUser, useDeleteUser,
useDisablePasswordless,
getErrorMessage, getErrorMessage,
} from '@/hooks/useAdmin'; } from '@/hooks/useAdmin';
import type { AdminUserDetail, UserRole } from '@/types'; import type { AdminUserDetail, UserRole } from '@/types';
@ -55,7 +53,6 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
const toggleActive = useToggleUserActive(); const toggleActive = useToggleUserActive();
const revokeSessions = useRevokeSessions(); const revokeSessions = useRevokeSessions();
const deleteUser = useDeleteUser(); const deleteUser = useDeleteUser();
const disablePasswordless = useDisablePasswordless();
// Close on outside click // Close on outside click
useEffect(() => { 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 = const isLoading =
updateRole.isPending || updateRole.isPending ||
resetPassword.isPending || resetPassword.isPending ||
@ -117,8 +110,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
removeMfaEnforcement.isPending || removeMfaEnforcement.isPending ||
toggleActive.isPending || toggleActive.isPending ||
revokeSessions.isPending || revokeSessions.isPending ||
deleteUser.isPending || deleteUser.isPending;
disablePasswordless.isPending;
return ( return (
<div ref={menuRef} className="relative"> <div ref={menuRef} className="relative">
@ -266,21 +258,6 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
</button> </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" /> <div className="my-1 border-t border-border" />
{/* Disable / Enable Account */} {/* Disable / Enable Account */}

View File

@ -193,18 +193,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 <DetailRow
label="Must Change Pwd" label="Must Change Pwd"
value={user.must_change_password ? 'Yes' : 'No'} value={user.must_change_password ? 'Yes' : 'No'}

View File

@ -1,7 +1,7 @@
import { useState, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { toast } from 'sonner'; 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 { useAuth } from '@/hooks/useAuth';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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 { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
import AmbientBackground from './AmbientBackground'; import AmbientBackground from './AmbientBackground';
import type { TotpSetupResponse } from '@/types'; import type { TotpSetupResponse } from '@/types';
@ -48,8 +47,6 @@ export default function LockScreen() {
isRegisterPending, isRegisterPending,
isSetupPending, isSetupPending,
isTotpPending, isTotpPending,
passkeyLogin,
isPasskeyLoginPending,
} = useAuth(); } = useAuth();
// ── Shared credential fields ── // ── Shared credential fields ──
@ -86,31 +83,6 @@ export default function LockScreen() {
const [forcedConfirmPassword, setForcedConfirmPassword] = useState(''); const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
const [isForcePwPending, setIsForcePwPending] = useState(false); 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) // Redirect authenticated users (no pending MFA flows)
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') { if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
@ -155,10 +127,11 @@ export default function LockScreen() {
// mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically // mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically
} catch (error: any) { } catch (error: any) {
const status = error?.response?.status; 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.'); setLoginError(error.response.data?.detail || 'Account is disabled. Contact an administrator.');
} else { } else {
// 401 covers both wrong password and account lockout (backend embeds detail string)
setLoginError(getErrorMessage(error, 'Invalid username or password')); setLoginError(getErrorMessage(error, 'Invalid username or password'));
} }
} }
@ -518,28 +491,18 @@ export default function LockScreen() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loginError && (() => { {loginError && (
const isLockWarning = <div
loginError.includes('remaining') || loginError.includes('temporarily locked'); role="alert"
return ( className={cn(
<div 'flex items-center gap-2 rounded-md border border-red-500/30',
role="alert" 'bg-red-500/10 px-3 py-2 mb-4'
className={cn( )}
'flex items-center gap-2 rounded-md border px-3 py-2 mb-4', >
isLockWarning <AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
? 'bg-amber-500/10 border-amber-500/30' <p className="text-xs text-red-400">{loginError}</p>
: 'bg-red-500/10 border-red-500/30' </div>
)} )}
>
{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>
);
})()}
<form onSubmit={handleCredentialSubmit} className="space-y-4"> <form onSubmit={handleCredentialSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username" required>Username</Label> <Label htmlFor="username" required>Username</Label>
@ -598,30 +561,6 @@ export default function LockScreen() {
</Button> </Button>
</form> </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 */} {/* Open registration link — only shown on login screen when enabled */}
{!isSetup && registrationOpen && ( {!isSetup && registrationOpen && (
<div className="mt-4 text-center"> <div className="mt-4 text-center">

View File

@ -51,10 +51,7 @@ export default function CalendarPage() {
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null); const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
const { settings } = useSettings(); const { settings } = useSettings();
const firstDayOfWeek = settings?.first_day_of_week ?? 0;
const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true }); const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true });
const [currentDate, setCurrentDate] = useState<string>(() => format(new Date(), 'yyyy-MM-dd'));
const [navKey, setNavKey] = useState(0);
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set()); const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
const calendarContainerRef = useRef<HTMLDivElement>(null); const calendarContainerRef = useRef<HTMLDivElement>(null);
@ -110,10 +107,6 @@ export default function CalendarPage() {
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth)); localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth));
}, [sidebarWidth]); }, [sidebarWidth]);
const handleMiniCalClick = useCallback((dateStr: string) => {
calendarRef.current?.getApi().gotoDate(dateStr);
}, []);
// Location data for event panel // Location data for event panel
const { data: locations = [] } = useQuery({ const { data: locations = [] } = useQuery({
queryKey: ['locations'], queryKey: ['locations'],
@ -194,13 +187,12 @@ export default function CalendarPage() {
return () => cancelAnimationFrame(rafId); return () => cancelAnimationFrame(rafId);
}, [panelOpen]); }, [panelOpen]);
// Scroll wheel navigation in month view (disabled when detail panel is open) // Scroll wheel navigation in month view
useEffect(() => { useEffect(() => {
const el = calendarContainerRef.current; const el = calendarContainerRef.current;
if (!el) return; if (!el) return;
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
if (panelOpen) return;
// Skip wheel navigation on touch devices (let them scroll normally) // Skip wheel navigation on touch devices (let them scroll normally)
if ('ontouchstart' in window) return; if ('ontouchstart' in window) return;
const api = calendarRef.current?.getApi(); const api = calendarRef.current?.getApi();
@ -215,7 +207,7 @@ export default function CalendarPage() {
}; };
el.addEventListener('wheel', handleWheel, { passive: false }); el.addEventListener('wheel', handleWheel, { passive: false });
return () => el.removeEventListener('wheel', handleWheel); return () => el.removeEventListener('wheel', handleWheel);
}, [panelOpen]); }, []);
// AW-2: Track visible date range for scoped event fetching // AW-2: Track visible date range for scoped event fetching
// W-02 fix: Initialize from current month to avoid unscoped first fetch // W-02 fix: Initialize from current month to avoid unscoped first fetch
@ -520,10 +512,6 @@ export default function CalendarPage() {
setVisibleRange((prev) => setVisibleRange((prev) =>
prev.start === start && prev.end === end ? prev : { start, end } prev.start === start && prev.end === end ? prev : { start, end }
); );
// Track current date anchor for mini calendar sync
setCurrentDate(format(arg.view.currentStart, 'yyyy-MM-dd'));
// Increment nav key so mini calendar clears selection even when month doesn't change
setNavKey((k) => k + 1);
}; };
const navigatePrev = () => calendarRef.current?.getApi().prev(); const navigatePrev = () => calendarRef.current?.getApi().prev();
@ -602,7 +590,7 @@ export default function CalendarPage() {
return ( return (
<div className="flex h-full overflow-hidden animate-fade-in"> <div className="flex h-full overflow-hidden animate-fade-in">
<div className="hidden lg:flex lg:flex-row shrink-0"> <div className="hidden lg:flex lg:flex-row shrink-0">
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} onDateClick={handleMiniCalClick} currentDate={currentDate} firstDayOfWeek={firstDayOfWeek} navKey={navKey} /> <CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} />
<div <div
onMouseDown={handleSidebarMouseDown} onMouseDown={handleSidebarMouseDown}
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150" className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150"
@ -613,7 +601,7 @@ export default function CalendarPage() {
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}> <Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
<SheetContent className="w-72 p-0"> <SheetContent className="w-72 p-0">
<SheetClose onClick={() => setMobileSidebarOpen(false)} /> <SheetClose onClick={() => setMobileSidebarOpen(false)} />
<CalendarSidebar onUseTemplate={(tmpl) => { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} onDateClick={(dateStr) => { setMobileSidebarOpen(false); handleMiniCalClick(dateStr); }} currentDate={currentDate} firstDayOfWeek={firstDayOfWeek} navKey={navKey} /> <CalendarSidebar onUseTemplate={(tmpl) => { setMobileSidebarOpen(false); handleUseTemplate(tmpl); }} onSharedVisibilityChange={setVisibleSharedIds} width={288} />
</SheetContent> </SheetContent>
</Sheet> </Sheet>
)} )}
@ -723,12 +711,12 @@ export default function CalendarPage() {
> >
<div className="h-full"> <div className="h-full">
<FullCalendar <FullCalendar
key={`fc-${firstDayOfWeek}`} key={`fc-${settings?.first_day_of_week ?? 0}`}
ref={calendarRef} ref={calendarRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]} plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth" initialView="dayGridMonth"
headerToolbar={false} headerToolbar={false}
firstDay={firstDayOfWeek} firstDay={settings?.first_day_of_week ?? 0}
events={calendarEvents} events={calendarEvents}
editable={true} editable={true}
selectable={true} selectable={true}

View File

@ -10,19 +10,14 @@ import { Checkbox } from '@/components/ui/checkbox';
import CalendarForm from './CalendarForm'; import CalendarForm from './CalendarForm';
import TemplateForm from './TemplateForm'; import TemplateForm from './TemplateForm';
import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedCalendarSection'; import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedCalendarSection';
import MiniCalendar from './MiniCalendar';
interface CalendarSidebarProps { interface CalendarSidebarProps {
onUseTemplate?: (template: EventTemplate) => void; onUseTemplate?: (template: EventTemplate) => void;
onSharedVisibilityChange?: (visibleIds: Set<number>) => void; onSharedVisibilityChange?: (visibleIds: Set<number>) => void;
width: number; width: number;
onDateClick?: (dateStr: string) => void;
currentDate?: string;
firstDayOfWeek?: number;
navKey?: number;
} }
const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width, onDateClick, currentDate, firstDayOfWeek, navKey }, ref) { const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width }, ref) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars(); const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@ -100,36 +95,20 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
return ( return (
<div ref={ref} className="shrink-0 border-r bg-card flex flex-col" style={{ width }}> <div ref={ref} className="shrink-0 border-r bg-card flex flex-col" style={{ width }}>
<div className="h-16 px-4 border-b flex items-center shrink-0"> <div className="h-16 px-4 border-b flex items-center justify-between shrink-0">
<span className="text-sm font-semibold font-heading text-foreground">Calendars</span> <span className="text-sm font-semibold font-heading text-foreground">Calendars</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => { setEditingCalendar(null); setShowForm(true); }}
>
<Plus className="h-4 w-4" />
</Button>
</div> </div>
{onDateClick && (
<div className="border-b shrink-0">
<MiniCalendar
onDateClick={onDateClick}
currentDate={currentDate}
firstDayOfWeek={firstDayOfWeek}
navKey={navKey}
/>
</div>
)}
<div className="flex-1 overflow-y-auto p-3 space-y-4"> <div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Owned calendars list (non-shared only) */} {/* Owned calendars list (non-shared only) */}
<div className="space-y-1.5"> <div className="space-y-0.5">
<div className="flex items-center justify-between px-2">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
My Calendars
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => { setEditingCalendar(null); setShowForm(true); }}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="space-y-0.5">
{calendars.filter((c) => !c.is_shared).map((cal) => ( {calendars.filter((c) => !c.is_shared).map((cal) => (
<div <div
key={cal.id} key={cal.id}
@ -159,7 +138,6 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
</div> </div>
))} ))}
</div> </div>
</div>
{/* Shared calendars section -- owned + member */} {/* Shared calendars section -- owned + member */}
{(calendars.some((c) => c.is_shared) || sharedCalendars.length > 0) && ( {(calendars.some((c) => c.is_shared) || sharedCalendars.length > 0) && (

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
@ -284,17 +284,6 @@ export default function EventDetailPanel({
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null); const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null); const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
const [locationSearch, setLocationSearch] = useState(''); const [locationSearch, setLocationSearch] = useState('');
const descRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize description textarea to fit content
useEffect(() => {
const el = descRef.current;
if (!el) return;
requestAnimationFrame(() => {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
});
}, [editState.description, isEditing]);
// Poll lock status in view mode for shared events (Stream A: real-time lock awareness) // Poll lock status in view mode for shared events (Stream A: real-time lock awareness)
// lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly. // lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly.
@ -378,11 +367,11 @@ export default function EventDetailPanel({
end_datetime: endDt, end_datetime: endDt,
all_day: data.all_day, all_day: data.all_day,
location_id: data.location_id ? parseInt(data.location_id) : null, location_id: data.location_id ? parseInt(data.location_id) : null,
is_starred: data.is_starred,
recurrence_rule: rule,
}; };
// Invited editors are restricted to the backend allowlist — omit fields they cannot modify // Invited editors cannot change calendars — omit calendar_id from payload
if (!canModifyAsInvitee) { if (!canModifyAsInvitee) {
payload.is_starred = data.is_starred;
payload.recurrence_rule = rule;
payload.calendar_id = data.calendar_id ? parseInt(data.calendar_id) : null; payload.calendar_id = data.calendar_id ? parseInt(data.calendar_id) : null;
} }
@ -550,8 +539,7 @@ export default function EventDetailPanel({
: event?.title || ''; : event?.title || '';
return ( return (
// onWheel stopPropagation: defence-in-depth with CalendarPage's panelOpen guard to prevent month-scroll bleed <div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden" onWheel={(e) => e.stopPropagation()}>
{/* Header */} {/* Header */}
<div className="px-5 py-4 border-b border-border shrink-0"> <div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@ -591,32 +579,32 @@ export default function EventDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={() => setScopeStep(null)} onClick={() => setScopeStep(null)}
title="Cancel" title="Cancel"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
) : (isEditing || isCreating) ? ( ) : (isEditing || isCreating) ? (
<> <>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-green-400 hover:text-green-300" className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave} onClick={handleEditSave}
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
title="Save" title="Save"
> >
<Save className="h-4 w-4" /> <Save className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={handleEditCancel} onClick={handleEditCancel}
title="Cancel" title="Cancel"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</> </>
) : ( ) : (
@ -628,12 +616,12 @@ export default function EventDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={handleEditStart} onClick={handleEditStart}
disabled={isAcquiringLock || !!activeLockInfo} disabled={isAcquiringLock || !!activeLockInfo}
title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'} title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'}
> >
{isAcquiringLock ? <Loader2 className="h-4 w-4 animate-spin" /> : <Pencil className="h-4 w-4" />} {isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
</Button> </Button>
)} )}
{/* Leave button for invited events */} {/* Leave button for invited events */}
@ -641,11 +629,11 @@ export default function EventDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive" className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => setShowLeaveDialog(true)} onClick={() => setShowLeaveDialog(true)}
title="Leave event" title="Leave event"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
{/* Delete button for own events */} {/* Delete button for own events */}
@ -664,12 +652,12 @@ export default function EventDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive" className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteStart} onClick={handleDeleteStart}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
title="Delete event" title="Delete event"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
) )
)} )}
@ -678,11 +666,11 @@ export default function EventDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={onClose} onClick={onClose}
title="Close panel" title="Close panel"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</> </>
)} )}
@ -733,7 +721,7 @@ export default function EventDetailPanel({
</div> </div>
) : (isEditing || isCreating) ? ( ) : (isEditing || isCreating) ? (
/* Edit / Create mode */ /* Edit / Create mode */
<div className="flex flex-col gap-3 h-full"> <div className="space-y-4">
{/* Title (only shown in body for create mode; edit mode has it in header) */} {/* Title (only shown in body for create mode; edit mode has it in header) */}
{isCreating && ( {isCreating && (
<div className="space-y-1"> <div className="space-y-1">
@ -749,49 +737,58 @@ export default function EventDetailPanel({
</div> </div>
)} )}
{/* All day + Date row */} <div className="space-y-1">
<div className="space-y-2"> <Label htmlFor="panel-desc">Description</Label>
<div className="flex items-center gap-2"> <Textarea
<Checkbox id="panel-desc"
id="panel-allday" value={editState.description}
checked={editState.all_day} onChange={(e) => updateField('description', e.target.value)}
onChange={(e) => { placeholder="Add a description..."
const checked = (e.target as HTMLInputElement).checked; rows={3}
updateField('all_day', checked); className="text-sm resize-none"
updateField('start_datetime', formatForInput(editState.start_datetime, checked, '09:00')); />
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00')); </div>
}}
<div className="flex items-center gap-2">
<Checkbox
id="panel-allday"
checked={editState.all_day}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked;
updateField('all_day', checked);
updateField('start_datetime', formatForInput(editState.start_datetime, checked, '09:00'));
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
}}
/>
<Label htmlFor="panel-allday">All day event</Label>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-start" required>Start</Label>
<DatePicker
variant="input"
id="panel-start"
mode={editState.all_day ? 'date' : 'datetime'}
value={editState.start_datetime}
onChange={(v) => updateField('start_datetime', v)}
className="text-xs"
required
/> />
<Label htmlFor="panel-allday" className="text-xs">All day</Label>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="space-y-1">
<div className="space-y-1"> <Label htmlFor="panel-end">End</Label>
<Label htmlFor="panel-start" required>Start</Label> <DatePicker
<DatePicker variant="input"
variant="input" id="panel-end"
id="panel-start" mode={editState.all_day ? 'date' : 'datetime'}
mode={editState.all_day ? 'date' : 'datetime'} value={editState.end_datetime}
value={editState.start_datetime} onChange={(v) => updateField('end_datetime', v)}
onChange={(v) => updateField('start_datetime', v)} className="text-xs"
className="text-xs" />
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="panel-end">End</Label>
<DatePicker
variant="input"
id="panel-end"
mode={editState.all_day ? 'date' : 'datetime'}
value={editState.end_datetime}
onChange={(v) => updateField('end_datetime', v)}
className="text-xs"
/>
</div>
</div> </div>
</div> </div>
{/* Calendar + Location row */}
<div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}> <div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
{!canModifyAsInvitee && ( {!canModifyAsInvitee && (
<div className="space-y-1"> <div className="space-y-1">
@ -840,32 +837,22 @@ export default function EventDetailPanel({
</div> </div>
</div> </div>
{/* Recurrence + Star row */} {/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */}
{!canModifyAsInvitee && ( {!canModifyAsInvitee && (
<div className="grid grid-cols-2 gap-3 items-end"> <div className="space-y-1">
<div className="space-y-1"> <Label htmlFor="panel-recurrence">Recurrence</Label>
<Label htmlFor="panel-recurrence">Recurrence</Label> <Select
<Select id="panel-recurrence"
id="panel-recurrence" value={editState.recurrence_type}
value={editState.recurrence_type} onChange={(e) => updateField('recurrence_type', e.target.value)}
onChange={(e) => updateField('recurrence_type', e.target.value)} className="text-xs"
className="text-xs" >
> <option value="">None</option>
<option value="">None</option> <option value="every_n_days">Every X days</option>
<option value="every_n_days">Every X days</option> <option value="weekly">Weekly</option>
<option value="weekly">Weekly</option> <option value="monthly_nth_weekday">Monthly (nth weekday)</option>
<option value="monthly_nth_weekday">Monthly (nth weekday)</option> <option value="monthly_date">Monthly (date)</option>
<option value="monthly_date">Monthly (date)</option> </Select>
</Select>
</div>
<div className="flex items-center gap-2 pb-2">
<Checkbox
id="panel-starred"
checked={editState.is_starred}
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
/>
<Label htmlFor="panel-starred" className="text-xs">Starred</Label>
</div>
</div> </div>
)} )}
@ -937,17 +924,23 @@ export default function EventDetailPanel({
</div> </div>
)} )}
{/* Description — fills remaining space */} <div className="flex items-center gap-2">
<div className="flex flex-col flex-1 min-h-0 space-y-1"> <Checkbox
<Label htmlFor="panel-desc">Description</Label> id="panel-starred"
<Textarea checked={editState.is_starred}
ref={descRef} onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
id="panel-desc"
value={editState.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
className="text-sm flex-1 min-h-[80px]"
/> />
<Label htmlFor="panel-starred">Star this event</Label>
</div>
{/* Save / Cancel buttons at bottom of form */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={handleEditCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
</Button>
</div> </div>
</div> </div>
) : ( ) : (
@ -1063,17 +1056,15 @@ export default function EventDetailPanel({
</div> </div>
{/* Description — full width */} {/* Description — full width */}
<div className="space-y-1"> {event?.description && (
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider"> <div className="space-y-1">
<AlignLeft className="h-3 w-3" /> <div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
Description <AlignLeft className="h-3 w-3" />
</div> Description
{event?.description ? ( </div>
<p className="text-sm whitespace-pre-wrap">{event.description}</p> <p className="text-sm whitespace-pre-wrap">{event.description}</p>
) : ( </div>
<p className="text-sm text-muted-foreground"></p> )}
)}
</div>
{/* Invitee section — view mode */} {/* Invitee section — view mode */}
{event && !event.is_virtual && ( {event && !event.is_virtual && (

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, FormEvent } from 'react'; import { useState, FormEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
@ -141,17 +141,6 @@ export default function EventForm({ event, templateData, templateName, initialSt
const existingLocation = locations.find((l) => l.id === source?.location_id); const existingLocation = locations.find((l) => l.id === source?.location_id);
const [locationSearch, setLocationSearch] = useState(existingLocation?.name || ''); const [locationSearch, setLocationSearch] = useState(existingLocation?.name || '');
// Auto-resize description textarea
const descRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = descRef.current;
if (!el) return;
requestAnimationFrame(() => {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
});
}, [formData.description]);
const selectableCalendars = calendars.filter((c) => !c.is_system); const selectableCalendars = calendars.filter((c) => !c.is_system);
const buildRecurrenceRule = (): RecurrenceRule | null => { const buildRecurrenceRule = (): RecurrenceRule | null => {
@ -266,12 +255,10 @@ export default function EventForm({ event, templateData, templateName, initialSt
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<Textarea <Textarea
ref={descRef}
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Add a description..." className="min-h-[80px] flex-1"
className="min-h-[80px] text-sm"
/> />
</div> </div>

View File

@ -1,166 +0,0 @@
import { useState, useEffect, useMemo, useCallback, memo } from 'react';
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, format, isSameDay, isSameMonth, isToday,
addMonths, subMonths, parse,
} from 'date-fns';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface MiniCalendarProps {
onDateClick: (dateStr: string) => void;
currentDate?: string;
firstDayOfWeek?: number;
navKey?: number;
}
const DAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
function buildGrid(displayed: Date, firstDay: number) {
const monthStart = startOfMonth(displayed);
const monthEnd = endOfMonth(displayed);
const gridStart = startOfWeek(monthStart, { weekStartsOn: firstDay as 0 | 1 | 2 | 3 | 4 | 5 | 6 });
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: firstDay as 0 | 1 | 2 | 3 | 4 | 5 | 6 });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}
function getOrderedLabels(firstDay: number) {
return [...DAY_LABELS.slice(firstDay), ...DAY_LABELS.slice(0, firstDay)];
}
const MiniCalendar = memo(function MiniCalendar({
onDateClick,
currentDate,
firstDayOfWeek = 0,
navKey,
}: MiniCalendarProps) {
const REF_DATE = useMemo(() => new Date(), []);
const parseDate = useCallback(
(dateStr: string) => parse(dateStr, 'yyyy-MM-dd', REF_DATE),
[REF_DATE]
);
const [displayedMonth, setDisplayedMonth] = useState(() =>
currentDate ? startOfMonth(parseDate(currentDate)) : startOfMonth(new Date())
);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
// Sync displayed month when main calendar navigates across months
useEffect(() => {
if (!currentDate) return;
const incoming = startOfMonth(parseDate(currentDate));
setDisplayedMonth((prev) =>
prev.getTime() === incoming.getTime() ? prev : incoming
);
}, [currentDate, parseDate]);
// Clear selection on any toolbar navigation (today/prev/next)
// navKey increments on every datesSet, even when the month doesn't change
useEffect(() => {
setSelectedDate(null);
}, [navKey]);
const days = useMemo(
() => buildGrid(displayedMonth, firstDayOfWeek),
[displayedMonth, firstDayOfWeek]
);
const orderedLabels = useMemo(
() => getOrderedLabels(firstDayOfWeek),
[firstDayOfWeek]
);
const handlePrev = useCallback(() => setDisplayedMonth((m) => subMonths(m, 1)), []);
const handleNext = useCallback(() => setDisplayedMonth((m) => addMonths(m, 1)), []);
const handleDayClick = useCallback((day: Date) => {
const dateStr = format(day, 'yyyy-MM-dd');
setSelectedDate(dateStr);
setDisplayedMonth((prev) => isSameMonth(day, prev) ? prev : startOfMonth(day));
onDateClick(dateStr);
}, [onDateClick]);
const selectedDateObj = useMemo(
() => selectedDate ? parseDate(selectedDate) : null,
[selectedDate, parseDate]
);
return (
<div className="px-3 pt-3 pb-2 max-w-[280px] mx-auto">
{/* Month header with navigation */}
<div className="flex items-center justify-between mb-1.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handlePrev}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<span className="text-sm font-medium font-heading select-none">
{format(displayedMonth, 'MMMM yyyy')}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleNext}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
{/* Day-of-week headers */}
<div className="grid grid-cols-7 mb-0.5">
{orderedLabels.map((label) => (
<span
key={label}
className="text-[10px] text-muted-foreground text-center select-none"
>
{label}
</span>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{days.map((day) => {
const isCurrentMonth = isSameMonth(day, displayedMonth);
const today = isToday(day);
const isSelected = selectedDateObj ? isSameDay(day, selectedDateObj) : false;
return (
<button
key={format(day, 'yyyy-MM-dd')}
type="button"
aria-label={format(day, 'EEEE, MMMM d, yyyy')}
onClick={() => handleDayClick(day)}
className={[
'h-7 text-xs flex items-center justify-center rounded-md transition-colors duration-100',
isSelected
? 'font-medium'
: today
? 'font-semibold'
: isCurrentMonth
? 'text-foreground hover:bg-accent/10'
: 'text-muted-foreground/40 hover:bg-accent/10',
].join(' ')}
style={
isSelected
? { backgroundColor: 'hsl(var(--accent-color))', color: 'hsl(var(--accent-foreground))' }
: today && !isSelected
? { backgroundColor: 'hsl(var(--accent-color) / 0.2)', color: 'hsl(var(--accent-color))' }
: undefined
}
>
{format(day, 'd')}
</button>
);
})}
</div>
</div>
);
});
export default MiniCalendar;

View File

@ -23,7 +23,7 @@ export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBanner
{alerts.length} {alerts.length}
</span> </span>
</div> </div>
<div className="divide-y divide-border"> <div className="divide-y divide-border max-h-48 overflow-y-auto">
{alerts.map((alert) => ( {alerts.map((alert) => (
<div <div
key={alert.id} key={alert.id}

View File

@ -1,9 +1,7 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { toast } from 'sonner';
import { Menu } from 'lucide-react'; import { Menu } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useAuth } from '@/hooks/useAuth';
import { usePrefetch } from '@/hooks/usePrefetch'; import { usePrefetch } from '@/hooks/usePrefetch';
import { AlertsProvider } from '@/hooks/useAlerts'; import { AlertsProvider } from '@/hooks/useAlerts';
import { LockProvider, useLock } from '@/hooks/useLock'; import { LockProvider, useLock } from '@/hooks/useLock';
@ -19,27 +17,7 @@ function AppContent({ mobileOpen, setMobileOpen }: {
setMobileOpen: (v: boolean) => void; setMobileOpen: (v: boolean) => void;
}) { }) {
const { isLocked, isLockResolved } = useLock(); const { isLocked, isLockResolved } = useLock();
const { hasPasskeys } = useAuth();
const navigate = useNavigate();
usePrefetch(isLockResolved && !isLocked); usePrefetch(isLockResolved && !isLocked);
// Post-login passkey prompt — show once per session if user has no passkeys
useEffect(() => {
if (
isLockResolved && !isLocked && !hasPasskeys &&
window.PublicKeyCredential &&
!sessionStorage.getItem('passkey-prompt-shown')
) {
sessionStorage.setItem('passkey-prompt-shown', '1');
toast.info('Simplify your login \u2014 set up a passkey in Settings', {
duration: 8000,
action: {
label: 'Set up',
onClick: () => navigate('/settings?tab=security'),
},
});
}
}, [isLockResolved, isLocked, hasPasskeys, navigate]);
const [collapsed, setCollapsed] = useState(() => { const [collapsed, setCollapsed] = useState(() => {
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); } try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
catch { return false; } catch { return false; }

View File

@ -1,43 +1,34 @@
import { useState, FormEvent, useEffect, useRef } from 'react'; import { useState, FormEvent, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Lock, Loader2, Fingerprint } from 'lucide-react'; import { Lock, Loader2 } from 'lucide-react';
import { useLock } from '@/hooks/useLock'; import { useLock } from '@/hooks/useLock';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import api, { getErrorMessage } from '@/lib/api'; import { getErrorMessage } from '@/lib/api';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import AmbientBackground from '@/components/auth/AmbientBackground'; import AmbientBackground from '@/components/auth/AmbientBackground';
export default function LockOverlay() { export default function LockOverlay() {
const { isLocked, unlock, unlockWithPasskey } = useLock(); const { isLocked, unlock } = useLock();
const { logout, passwordlessEnabled, hasPasskeys, authStatus } = useAuth(); const { logout } = useAuth();
const { settings } = useSettings(); const { settings } = useSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isUnlocking, setIsUnlocking] = useState(false); const [isUnlocking, setIsUnlocking] = useState(false);
const [isPasskeyUnlocking, setIsPasskeyUnlocking] = useState(false);
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Derive from auth query — has_passkeys covers both owners and any registered passkey // Focus password input when lock activates
const userHasPasskeys = authStatus?.has_passkeys ?? hasPasskeys;
const showPasskeyButton = userHasPasskeys && supportsWebAuthn;
// When passwordless is enabled: passkey is the primary unlock method
// When passwordless is disabled: show password form, optionally with passkey secondary
const showPasswordForm = !passwordlessEnabled;
// Focus password input when lock activates (only when password form is visible)
useEffect(() => { useEffect(() => {
if (isLocked && showPasswordForm) { if (isLocked) {
setPassword(''); setPassword('');
// Small delay to let the overlay render
const t = setTimeout(() => inputRef.current?.focus(), 100); const t = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(t); return () => clearTimeout(t);
} }
}, [isLocked, showPasswordForm]); }, [isLocked]);
if (!isLocked) return null; if (!isLocked) return null;
@ -59,29 +50,6 @@ export default function LockOverlay() {
} }
}; };
const handlePasskeyUnlock = async () => {
setIsPasskeyUnlocking(true);
try {
const { startAuthentication } = await import('@simplewebauthn/browser');
const { data: beginResp } = await api.post('/auth/passkeys/login/begin', {});
const credential = await startAuthentication(beginResp.options);
await api.post('/auth/passkeys/login/complete', {
credential: JSON.stringify(credential),
challenge_token: beginResp.challenge_token,
unlock: true,
});
unlockWithPasskey();
} catch (error) {
if (error instanceof Error && error.name === 'NotAllowedError') {
toast.error('Passkey not recognized');
} else if (error instanceof Error && error.name !== 'AbortError') {
toast.error(getErrorMessage(error, 'Unlock failed'));
}
} finally {
setIsPasskeyUnlocking(false);
}
};
const handleSwitchAccount = async () => { const handleSwitchAccount = async () => {
await logout(); await logout();
navigate('/login'); navigate('/login');
@ -107,87 +75,29 @@ export default function LockOverlay() {
)} )}
</div> </div>
{/* Passwordless-primary mode: passkey button only */} {/* Password form */}
{passwordlessEnabled && showPasskeyButton && ( <form onSubmit={handleUnlock} className="w-full space-y-4">
<Button <Input
type="button" ref={inputRef}
className="w-full gap-2" type="password"
onClick={handlePasskeyUnlock} aria-label="Password"
disabled={isPasskeyUnlocking} value={password}
aria-label="Unlock with passkey" onChange={(e) => setPassword(e.target.value)}
> placeholder="Enter password to unlock"
{isPasskeyUnlocking ? ( autoComplete="current-password"
className="text-center"
/>
<Button type="submit" className="w-full" disabled={isUnlocking}>
{isUnlocking ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Verifying passkey Unlocking
</> </>
) : ( ) : (
<> 'Unlock'
<Fingerprint className="h-4 w-4" />
Unlock with passkey
</>
)} )}
</Button> </Button>
)} </form>
{/* Password form — shown when passwordless is off */}
{showPasswordForm && (
<>
<form onSubmit={handleUnlock} className="w-full space-y-4">
<Input
ref={inputRef}
type="password"
aria-label="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password to unlock"
autoComplete="current-password"
className="text-center"
/>
<Button type="submit" className="w-full" disabled={isUnlocking}>
{isUnlocking ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Unlocking
</>
) : (
'Unlock'
)}
</Button>
</form>
{/* Passkey secondary option */}
{showPasskeyButton && (
<div className="w-full flex flex-col items-center gap-4">
<div className="w-full flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground shrink-0">or</span>
<Separator className="flex-1" />
</div>
<Button
type="button"
variant="outline"
className="w-full gap-2"
onClick={handlePasskeyUnlock}
disabled={isPasskeyUnlocking}
aria-label="Unlock with passkey"
>
{isPasskeyUnlocking ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verifying passkey
</>
) : (
<>
<Fingerprint className="h-4 w-4" />
Use a passkey
</>
)}
</Button>
</div>
)}
</>
)}
{/* Switch account link */} {/* Switch account link */}
<button <button

View File

@ -1,7 +1,6 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Check, X, Bell, UserPlus, Calendar, Clock, FolderKanban, ClipboardList } from 'lucide-react'; import { Check, X, Bell, UserPlus, Calendar, Clock } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections'; import { useConnections } from '@/hooks/useConnections';
@ -27,9 +26,6 @@ export default function NotificationToaster() {
respondRef.current = respond; respondRef.current = respond;
const markReadRef = useRef(markRead); const markReadRef = useRef(markRead);
markReadRef.current = markRead; markReadRef.current = markRead;
const navigate = useNavigate();
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
const handleConnectionRespond = useCallback( const handleConnectionRespond = useCallback(
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => { async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
@ -127,38 +123,6 @@ export default function NotificationToaster() {
[], [],
); );
const handleProjectInviteRespond = useCallback(
async (projectId: number, action: 'accepted' | 'rejected', toastId: string | number, notificationId: number) => {
const key = `proj-${projectId}`;
if (respondingRef.current.has(key)) return;
respondingRef.current.add(key);
toast.dismiss(toastId);
const loadingId = toast.loading(
action === 'accepted' ? 'Accepting project invite\u2026' : 'Declining invite\u2026',
);
try {
await api.post(`/projects/memberships/${projectId}/respond`, { response: action });
toast.dismiss(loadingId);
toast.success(action === 'accepted' ? 'Project invite accepted' : 'Project invite declined');
markReadRef.current([notificationId]).catch(() => {});
queryClient.invalidateQueries({ queryKey: ['projects'] });
} catch (err) {
toast.dismiss(loadingId);
if (axios.isAxiosError(err) && err.response?.status === 409) {
toast.success('Already responded');
markReadRef.current([notificationId]).catch(() => {});
} else {
toast.error(getErrorMessage(err, 'Failed to respond to project invite'));
}
} finally {
respondingRef.current.delete(key);
}
},
[],
);
// Track unread count changes to force-refetch the list // Track unread count changes to force-refetch the list
useEffect(() => { useEffect(() => {
if (unreadCount > prevUnreadRef.current && initializedRef.current) { if (unreadCount > prevUnreadRef.current && initializedRef.current) {
@ -177,7 +141,7 @@ export default function NotificationToaster() {
initializedRef.current = true; initializedRef.current = true;
// Toast actionable unread notifications on login so the user can act immediately // Toast actionable unread notifications on login so the user can act immediately
const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite', 'project_invite', 'task_assigned']); const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite']);
const actionable = notifications.filter( const actionable = notifications.filter(
(n) => !n.is_read && actionableTypes.has(n.type), (n) => !n.is_read && actionableTypes.has(n.type),
); );
@ -191,10 +155,6 @@ export default function NotificationToaster() {
showCalendarInviteToast(notification); showCalendarInviteToast(notification);
} else if (notification.type === 'event_invite' && notification.data) { } else if (notification.type === 'event_invite' && notification.data) {
showEventInviteToast(notification); showEventInviteToast(notification);
} else if (notification.type === 'project_invite' && notification.data) {
showProjectInviteToast(notification);
} else if (notification.type === 'task_assigned' && notification.data) {
showTaskAssignedToast(notification);
} }
}); });
return; return;
@ -223,9 +183,6 @@ export default function NotificationToaster() {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['event-invitations'] }); queryClient.invalidateQueries({ queryKey: ['event-invitations'] });
} }
if (newNotifications.some((n) => n.type === 'project_invite' || n.type === 'project_invite_accepted' || n.type === 'task_assigned')) {
queryClient.invalidateQueries({ queryKey: ['projects'] });
}
// Show toasts // Show toasts
newNotifications.forEach((notification) => { newNotifications.forEach((notification) => {
@ -235,10 +192,6 @@ export default function NotificationToaster() {
showCalendarInviteToast(notification); showCalendarInviteToast(notification);
} else if (notification.type === 'event_invite' && notification.data) { } else if (notification.type === 'event_invite' && notification.data) {
showEventInviteToast(notification); showEventInviteToast(notification);
} else if (notification.type === 'project_invite' && notification.data) {
showProjectInviteToast(notification);
} else if (notification.type === 'task_assigned' && notification.data) {
showTaskAssignedToast(notification);
} else { } else {
toast(notification.title || 'New Notification', { toast(notification.title || 'New Notification', {
description: notification.message || undefined, description: notification.message || undefined,
@ -247,7 +200,7 @@ export default function NotificationToaster() {
}); });
} }
}); });
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond, handleProjectInviteRespond]); }, [notifications, handleConnectionRespond, handleCalendarInviteRespond, handleEventInviteRespond]);
const showConnectionRequestToast = (notification: AppNotification) => { const showConnectionRequestToast = (notification: AppNotification) => {
const requestId = notification.source_id!; const requestId = notification.source_id!;
@ -407,96 +360,5 @@ export default function NotificationToaster() {
); );
}; };
const showTaskAssignedToast = (notification: AppNotification) => {
const data = notification.data as Record<string, unknown> | undefined;
const projectId = data?.project_id as number | undefined;
const toastKey = `task-assigned-${notification.id}`;
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-blue-500/15 flex items-center justify-center shrink-0">
<ClipboardList className="h-4 w-4 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Task Assigned</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || 'You were assigned to a task'}
</p>
<div className="flex items-center gap-2 mt-3">
{projectId && (
<button
onClick={() => {
toast.dismiss(id);
markReadRef.current([notification.id]).catch(() => {});
navigateRef.current(`/projects/${projectId}`);
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
View Project
</button>
)}
<button
onClick={() => {
toast.dismiss(id);
markReadRef.current([notification.id]).catch(() => {});
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
),
{ id: toastKey, duration: 15000 },
);
};
const showProjectInviteToast = (notification: AppNotification) => {
const data = notification.data as Record<string, unknown> | undefined;
const projectId = data?.project_id as number | undefined;
if (!projectId) return;
const toastKey = `project-invite-${notification.id}`;
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-purple-500/15 flex items-center justify-center shrink-0">
<FolderKanban className="h-4 w-4 text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Project Invitation</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || 'You were invited to collaborate on a project'}
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => handleProjectInviteRespond(projectId, 'accepted', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
<Check className="h-3.5 w-3.5" />
Accept
</button>
<button
onClick={() => handleProjectInviteRespond(projectId, 'rejected', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
<X className="h-3.5 w-3.5" />
Decline
</button>
</div>
</div>
</div>
</div>
),
{ id: toastKey, duration: 30000 },
);
};
return null; return null;
} }

View File

@ -1,7 +1,7 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock, FolderKanban } from 'lucide-react'; import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar, Clock } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
@ -22,9 +22,6 @@ const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' }, calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' },
event_invite: { icon: Calendar, color: 'text-purple-400' }, event_invite: { icon: Calendar, color: 'text-purple-400' },
event_invite_response: { icon: Calendar, color: 'text-green-400' }, event_invite_response: { icon: Calendar, color: 'text-green-400' },
project_invite: { icon: FolderKanban, color: 'text-purple-400' },
project_invite_accepted: { icon: FolderKanban, color: 'text-green-400' },
task_assigned: { icon: FolderKanban, color: 'text-blue-400' },
info: { icon: Info, color: 'text-blue-400' }, info: { icon: Info, color: 'text-blue-400' },
warning: { icon: AlertCircle, color: 'text-amber-400' }, warning: { icon: AlertCircle, color: 'text-amber-400' },
}; };
@ -47,7 +44,6 @@ export default function NotificationsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [filter, setFilter] = useState<Filter>('all'); const [filter, setFilter] = useState<Filter>('all');
const [respondingEventInvite, setRespondingEventInvite] = useState<number | null>(null); const [respondingEventInvite, setRespondingEventInvite] = useState<number | null>(null);
const [respondingProjectInvite, setRespondingProjectInvite] = useState<number | null>(null);
// Build a set of pending connection request IDs for quick lookup // Build a set of pending connection request IDs for quick lookup
const pendingInviteIds = useMemo( const pendingInviteIds = useMemo(
@ -198,36 +194,6 @@ export default function NotificationsPage() {
} }
}; };
const handleProjectInviteRespond = async (
notification: AppNotification,
action: 'accepted' | 'rejected',
) => {
const data = notification.data as Record<string, unknown> | undefined;
const projectId = data?.project_id as number | undefined;
if (!projectId) return;
setRespondingProjectInvite(notification.id);
try {
await api.post(`/projects/memberships/${projectId}/respond`, { response: action });
toast.success(action === 'accepted' ? 'Project invite accepted' : 'Project invite declined');
queryClient.invalidateQueries({ queryKey: ['projects'] });
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 409) {
toast.success('Already responded');
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
} else {
toast.error(getErrorMessage(err, 'Failed to respond'));
}
} finally {
setRespondingProjectInvite(null);
}
};
const handleNotificationClick = async (notification: AppNotification) => { const handleNotificationClick = async (notification: AppNotification) => {
// Don't navigate for pending connection requests — let user act inline // Don't navigate for pending connection requests — let user act inline
if ( if (
@ -241,10 +207,6 @@ export default function NotificationsPage() {
if (notification.type === 'event_invite' && !notification.is_read) { if (notification.type === 'event_invite' && !notification.is_read) {
return; return;
} }
// Don't navigate for unread project invites — let user act inline
if (notification.type === 'project_invite' && !notification.is_read) {
return;
}
if (!notification.is_read) { if (!notification.is_read) {
await markRead([notification.id]).catch(() => {}); await markRead([notification.id]).catch(() => {});
} }
@ -256,11 +218,6 @@ export default function NotificationsPage() {
if (notification.type === 'event_invite' || notification.type === 'event_invite_response') { if (notification.type === 'event_invite' || notification.type === 'event_invite_response') {
navigate('/calendar'); navigate('/calendar');
} }
// Navigate to project for project-related notifications
if (['project_invite', 'project_invite_accepted', 'task_assigned'].includes(notification.type)) {
const projectId = (notification.data as Record<string, unknown>)?.project_id;
if (projectId) navigate(`/projects/${projectId}`);
}
}; };
return ( return (
@ -451,31 +408,6 @@ export default function NotificationsPage() {
</div> </div>
)} )}
{/* Project invite actions (inline) */}
{notification.type === 'project_invite' &&
!notification.is_read && (
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="sm"
onClick={(e) => { e.stopPropagation(); handleProjectInviteRespond(notification, 'accepted'); }}
disabled={respondingProjectInvite === notification.id}
className="gap-1 h-7 text-xs"
>
{respondingProjectInvite === notification.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
Accept
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleProjectInviteRespond(notification, 'rejected'); }}
disabled={respondingProjectInvite === notification.id}
className="h-7 text-xs"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* Timestamp + actions */} {/* Timestamp + actions */}
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">

View File

@ -1,286 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { ChevronDown, UserCircle, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TaskAssignment, ProjectMember } from "@/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function initials(name: string | null | undefined): string {
if (!name) return "?";
return name
.split(" ")
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? "")
.join("");
}
interface AvatarCircleProps {
name: string | null | undefined;
className?: string;
}
function AvatarCircle({ name, className }: AvatarCircleProps) {
return (
<span
className={cn(
"w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0",
className
)}
aria-hidden="true"
>
<span className="text-[10px] font-medium text-muted-foreground leading-none">
{initials(name)}
</span>
</span>
);
}
function roleBadge(
userId: number,
ownerId: number,
permission: ProjectMember["permission"]
): { label: string; className: string } {
if (userId === ownerId)
return { label: "Owner", className: "text-accent" };
if (permission === "create_modify")
return { label: "Editor", className: "text-muted-foreground" };
return { label: "Viewer", className: "text-muted-foreground/60" };
}
// ---------------------------------------------------------------------------
// AssigneeAvatars — stacked display for TaskRow / KanbanBoard
// ---------------------------------------------------------------------------
export function AssigneeAvatars({
assignments,
max = 3,
}: {
assignments: TaskAssignment[];
max?: number;
}) {
const visible = assignments.slice(0, max);
const overflow = assignments.length - max;
const allNames = assignments.map((a) => a.user_name ?? "Unknown").join(", ");
return (
<span
className="flex items-center"
title={allNames}
aria-label={`Assigned to: ${allNames}`}
>
{visible.map((a) => (
<span
key={a.user_id}
className="w-5 h-5 -ml-1.5 first:ml-0 rounded-full bg-muted border border-background flex items-center justify-center shrink-0"
>
<span className="text-[9px] font-medium text-muted-foreground leading-none">
{initials(a.user_name)}
</span>
</span>
))}
{overflow > 0 && (
<span className="w-5 h-5 -ml-1.5 rounded-full bg-muted border border-background flex items-center justify-center shrink-0">
<span className="text-[9px] font-medium text-muted-foreground leading-none">
+{overflow}
</span>
</span>
)}
</span>
);
}
// ---------------------------------------------------------------------------
// AssignmentPicker
// ---------------------------------------------------------------------------
interface AssignmentPickerProps {
currentAssignments: TaskAssignment[];
members: ProjectMember[];
currentUserId: number;
ownerId: number;
onAssign: (userIds: number[]) => void;
onUnassign: (userId: number) => void;
disabled?: boolean;
}
export function AssignmentPicker({
currentAssignments,
members,
currentUserId,
ownerId,
onAssign,
onUnassign,
disabled = false,
}: AssignmentPickerProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
const assignedIds = new Set(currentAssignments.map((a) => a.user_id));
// Build ordered member list: current user first, then rest alphabetically
const sortedMembers = [...members].sort((a, b) => {
if (a.user_id === currentUserId) return -1;
if (b.user_id === currentUserId) return 1;
return (a.user_name ?? "").localeCompare(b.user_name ?? "");
});
const availableMembers = sortedMembers.filter(
(m) => !assignedIds.has(m.user_id)
);
function handleSelect(userId: number) {
onAssign([userId]);
setOpen(false);
}
function handleRemove(e: React.MouseEvent, userId: number) {
e.stopPropagation();
onUnassign(userId);
}
function handleTriggerClick() {
if (disabled) return;
setOpen((prev) => !prev);
}
function handleTriggerKeyDown(e: React.KeyboardEvent) {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen((prev) => !prev);
}
if (e.key === "Escape") setOpen(false);
}
const hasAssignees = currentAssignments.length > 0;
return (
<div ref={containerRef} className="relative">
{/* Trigger area */}
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-haspopup="listbox"
aria-expanded={open}
aria-disabled={disabled}
onClick={handleTriggerClick}
onKeyDown={handleTriggerKeyDown}
className={cn(
"flex flex-wrap items-center gap-1 min-h-[28px] px-1.5 py-1 rounded-md",
"border border-transparent transition-colors",
disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:border-border hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)}
>
{hasAssignees ? (
<>
{currentAssignments.map((a) => (
<span
key={a.user_id}
className="flex items-center gap-1 bg-secondary rounded-full pl-1 pr-1.5 py-0.5"
>
<AvatarCircle name={a.user_name} />
<span className="text-xs text-secondary-foreground leading-none max-w-[80px] truncate">
{a.user_id === currentUserId ? "Me" : (a.user_name ?? "Unknown")}
</span>
{!disabled && (
<button
type="button"
aria-label={`Remove ${a.user_name ?? "user"}`}
onClick={(e) => handleRemove(e, a.user_id)}
className="text-muted-foreground hover:text-foreground transition-colors ml-0.5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-full"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
{!disabled && availableMembers.length > 0 && (
<ChevronDown
className={cn(
"w-3.5 h-3.5 text-muted-foreground/60 transition-transform ml-0.5 shrink-0",
open && "rotate-180"
)}
aria-hidden="true"
/>
)}
</>
) : (
<span className="flex items-center gap-1.5 text-muted-foreground/50 text-xs select-none">
<UserCircle className="w-4 h-4 shrink-0" aria-hidden="true" />
Assign...
<ChevronDown
className={cn(
"w-3.5 h-3.5 transition-transform",
open && "rotate-180"
)}
aria-hidden="true"
/>
</span>
)}
</div>
{/* Dropdown */}
{open && availableMembers.length > 0 && (
<ul
role="listbox"
aria-label="Available members"
className="border border-border bg-card shadow-lg rounded-lg absolute z-50 top-full left-0 mt-1 min-w-[180px] max-w-[240px] py-1 overflow-hidden"
>
{availableMembers.map((m) => {
const badge = roleBadge(m.user_id, ownerId, m.permission);
const displayName =
m.user_id === currentUserId
? "Me"
: (m.user_name ?? "Unknown");
return (
<li key={m.user_id}>
<button
type="button"
role="option"
aria-selected={false}
onClick={() => handleSelect(m.user_id)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-muted/50 transition-colors text-left focus-visible:outline-none focus-visible:bg-muted/50"
>
<AvatarCircle name={m.user_name} />
<span className="flex-1 truncate text-foreground">
{displayName}
</span>
<span className={cn("text-[11px] shrink-0", badge.className)}>
{badge.label}
</span>
</button>
</li>
);
})}
</ul>
)}
{/* Empty state when all members assigned */}
{open && availableMembers.length === 0 && (
<div className="border border-border bg-card shadow-lg rounded-lg absolute z-50 top-full left-0 mt-1 min-w-[180px] py-2 px-3">
<p className="text-xs text-muted-foreground">All members assigned</p>
</div>
)}
</div>
);
}

View File

@ -1,21 +1,17 @@
import { useState, useCallback } from 'react';
import { import {
DndContext, DndContext,
closestCenter, closestCorners,
PointerSensor, PointerSensor,
TouchSensor, TouchSensor,
useSensor, useSensor,
useSensors, useSensors,
type DragStartEvent,
type DragEndEvent, type DragEndEvent,
useDroppable, useDroppable,
useDraggable, useDraggable,
DragOverlay,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import type { ProjectTask } from '@/types'; import type { ProjectTask } from '@/types';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { AssigneeAvatars } from './AssignmentPicker';
const COLUMNS: { id: string; label: string; color: string }[] = [ const COLUMNS: { id: string; label: string; color: string }[] = [
{ id: 'pending', label: 'Pending', color: 'text-gray-400' }, { id: 'pending', label: 'Pending', color: 'text-gray-400' },
@ -46,13 +42,11 @@ function KanbanColumn({
column, column,
tasks, tasks,
selectedTaskId, selectedTaskId,
draggingId,
onSelectTask, onSelectTask,
}: { }: {
column: (typeof COLUMNS)[0]; column: (typeof COLUMNS)[0];
tasks: ProjectTask[]; tasks: ProjectTask[];
selectedTaskId: number | null; selectedTaskId: number | null;
draggingId: number | null;
onSelectTask: (taskId: number) => void; onSelectTask: (taskId: number) => void;
}) { }) {
const { setNodeRef, isOver } = useDroppable({ id: column.id }); const { setNodeRef, isOver } = useDroppable({ id: column.id });
@ -83,7 +77,6 @@ function KanbanColumn({
key={task.id} key={task.id}
task={task} task={task}
isSelected={selectedTaskId === task.id} isSelected={selectedTaskId === task.id}
isDragSource={draggingId === task.id}
onSelect={() => onSelectTask(task.id)} onSelect={() => onSelectTask(task.id)}
/> />
))} ))}
@ -92,19 +85,41 @@ function KanbanColumn({
); );
} }
// Card content — shared between in-place card and drag overlay function KanbanCard({
function CardContent({ task, isSelected, ghost }: { task: ProjectTask; isSelected: boolean; ghost?: boolean }) { task,
isSelected,
onSelect,
}: {
task: ProjectTask;
isSelected: boolean;
onSelect: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
data: { task },
});
const style = transform
? {
transform: `translate(${transform.x}px, ${transform.y}px)`,
opacity: isDragging ? 0.5 : 1,
}
: undefined;
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0; const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
const totalSubtasks = task.subtasks?.length ?? 0; const totalSubtasks = task.subtasks?.length ?? 0;
return ( return (
<div <div
className={`rounded-md border p-3 ${ ref={setNodeRef}
ghost style={style}
? 'border-accent/20 bg-accent/5 opacity-40' {...listeners}
: isSelected {...attributes}
? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10' onClick={onSelect}
: 'border-border bg-card hover:bg-card-elevated hover:border-accent/20' className={`rounded-md border p-3 cursor-pointer transition-all duration-150 ${
isSelected
? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10'
: 'border-border bg-card hover:bg-card-elevated hover:border-accent/20'
}`} }`}
> >
<p className="text-sm font-medium leading-tight mb-2">{task.title}</p> <p className="text-sm font-medium leading-tight mb-2">{task.title}</p>
@ -125,40 +140,6 @@ function CardContent({ task, isSelected, ghost }: { task: ProjectTask; isSelecte
</span> </span>
)} )}
</div> </div>
{task.assignments && task.assignments.length > 0 && (
<div className="flex justify-end mt-2">
<AssigneeAvatars assignments={task.assignments} max={2} />
</div>
)}
</div>
);
}
function KanbanCard({
task,
isSelected,
isDragSource,
onSelect,
}: {
task: ProjectTask;
isSelected: boolean;
isDragSource: boolean;
onSelect: () => void;
}) {
const { attributes, listeners, setNodeRef } = useDraggable({
id: task.id,
data: { task },
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
onClick={onSelect}
className="cursor-pointer"
>
<CardContent task={task} isSelected={isSelected} ghost={isDragSource} />
</div> </div>
); );
} }
@ -171,24 +152,16 @@ export default function KanbanBoard({
onStatusChange, onStatusChange,
onBackToAllTasks, onBackToAllTasks,
}: KanbanBoardProps) { }: KanbanBoardProps) {
const [draggingId, setDraggingId] = useState<number | null>(null);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ,
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }) useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
); );
// Subtask view is driven by kanbanParentTask (decoupled from selected task)
const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0; const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks; const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
const draggingTask = draggingId ? activeTasks.find((t) => t.id === draggingId) ?? null : null; const handleDragEnd = (event: DragEndEvent) => {
const handleDragStart = useCallback((event: DragStartEvent) => {
setDraggingId(event.active.id as number);
}, []);
const handleDragEnd = useCallback((event: DragEndEvent) => {
setDraggingId(null);
const { active, over } = event; const { active, over } = event;
if (!over) return; if (!over) return;
@ -199,11 +172,7 @@ export default function KanbanBoard({
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) { if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
onStatusChange(taskId, newStatus); onStatusChange(taskId, newStatus);
} }
}, [activeTasks, onStatusChange]); };
const handleDragCancel = useCallback(() => {
setDraggingId(null);
}, []);
const tasksByStatus = COLUMNS.map((col) => ({ const tasksByStatus = COLUMNS.map((col) => ({
column: col, column: col,
@ -230,10 +199,8 @@ export default function KanbanBoard({
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
> >
<div className="flex gap-3 overflow-x-auto pb-2"> <div className="flex gap-3 overflow-x-auto pb-2">
{tasksByStatus.map(({ column, tasks: colTasks }) => ( {tasksByStatus.map(({ column, tasks: colTasks }) => (
@ -242,20 +209,10 @@ export default function KanbanBoard({
column={column} column={column}
tasks={colTasks} tasks={colTasks}
selectedTaskId={selectedTaskId} selectedTaskId={selectedTaskId}
draggingId={draggingId}
onSelectTask={onSelectTask} onSelectTask={onSelectTask}
/> />
))} ))}
</div> </div>
{/* Floating overlay — renders above everything, no layout impact */}
<DragOverlay dropAnimation={null}>
{draggingTask ? (
<div className="w-[200px] opacity-95 shadow-lg shadow-black/30 rotate-[2deg]">
<CardContent task={draggingTask} isSelected={false} />
</div>
) : null}
</DragOverlay>
</DndContext> </DndContext>
</div> </div>
); );

View File

@ -2,10 +2,9 @@ import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { format, isPast, parseISO } from 'date-fns'; import { format, isPast, parseISO } from 'date-fns';
import { Calendar, Pin, Users } from 'lucide-react'; import { Calendar, Pin } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { statusColors, statusLabels } from './constants'; import { statusColors, statusLabels } from './constants';
@ -18,8 +17,6 @@ interface ProjectCardProps {
export default function ProjectCard({ project }: ProjectCardProps) { export default function ProjectCard({ project }: ProjectCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { settings } = useSettings();
const isShared = project.user_id !== (settings?.user_id ?? 0);
const toggleTrackMutation = useMutation({ const toggleTrackMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -50,22 +47,20 @@ export default function ProjectCard({ project }: ProjectCardProps) {
className="cursor-pointer hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200 relative" className="cursor-pointer hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200 relative"
onClick={() => navigate(`/projects/${project.id}`)} onClick={() => navigate(`/projects/${project.id}`)}
> >
{!isShared && ( <button
<button onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); toggleTrackMutation.mutate();
toggleTrackMutation.mutate(); }}
}} className={`absolute top-3 right-3 p-1 rounded-md transition-colors z-10 ${
className={`absolute top-3 right-3 p-1 rounded-md transition-colors z-10 ${ project.is_tracked
project.is_tracked ? 'text-accent hover:bg-accent/10'
? 'text-accent hover:bg-accent/10' : 'text-muted-foreground/40 hover:text-muted-foreground hover:bg-card-elevated'
: 'text-muted-foreground/40 hover:text-muted-foreground hover:bg-card-elevated' }`}
}`} title={project.is_tracked ? 'Untrack project' : 'Track project'}
title={project.is_tracked ? 'Untrack project' : 'Track project'} >
> <Pin className={`h-3.5 w-3.5 ${project.is_tracked ? 'fill-current' : ''}`} />
<Pin className={`h-3.5 w-3.5 ${project.is_tracked ? 'fill-current' : ''}`} /> </button>
</button>
)}
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-2 pr-6"> <div className="flex items-start justify-between gap-2 pr-6">
<CardTitle className="font-heading text-lg font-semibold">{project.name}</CardTitle> <CardTitle className="font-heading text-lg font-semibold">{project.name}</CardTitle>
@ -100,17 +95,6 @@ export default function ProjectCard({ project }: ProjectCardProps) {
Due {format(parseISO(project.due_date), 'MMM d, yyyy')} Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
</div> </div>
)} )}
{/* Sharing indicator — shows for both owner and shared users */}
{project.member_count > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-2">
<Users className="h-3.5 w-3.5" />
<span>
{isShared
? 'Shared with you'
: `${project.member_count} member${project.member_count !== 1 ? 's' : ''}`}
</span>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -23,25 +23,21 @@ import { CSS } from '@dnd-kit/utilities';
import { import {
ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin, ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin,
Calendar, CheckCircle2, PlayCircle, AlertTriangle, Calendar, CheckCircle2, PlayCircle, AlertTriangle,
List, Columns3, ArrowUpDown, Users, Eye, List, Columns3, ArrowUpDown,
} from 'lucide-react'; } from 'lucide-react';
import axios from 'axios';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Project, ProjectTask, ProjectMember } from '@/types'; import type { Project, ProjectTask } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
import { ListSkeleton } from '@/components/ui/skeleton'; import { ListSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { useSettings } from '@/hooks/useSettings';
import { useDeltaPoll } from '@/hooks/useDeltaPoll';
import TaskRow from './TaskRow'; import TaskRow from './TaskRow';
import TaskDetailPanel from './TaskDetailPanel'; import TaskDetailPanel from './TaskDetailPanel';
import KanbanBoard from './KanbanBoard'; import KanbanBoard from './KanbanBoard';
import TaskForm from './TaskForm'; import TaskForm from './TaskForm';
import ProjectForm from './ProjectForm'; import ProjectForm from './ProjectForm';
import { ProjectShareSheet } from './ProjectShareSheet';
import { statusColors, statusLabels } from './constants'; import { statusColors, statusLabels } from './constants';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
@ -115,9 +111,6 @@ export default function ProjectDetail() {
const [kanbanParentTaskId, setKanbanParentTaskId] = useState<number | null>(null); const [kanbanParentTaskId, setKanbanParentTaskId] = useState<number | null>(null);
const [sortMode, setSortMode] = useState<SortMode>('manual'); const [sortMode, setSortMode] = useState<SortMode>('manual');
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [showShareSheet, setShowShareSheet] = useState(false);
const { settings } = useSettings();
const currentUserId = settings?.user_id ?? 0;
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
@ -141,53 +134,17 @@ export default function ProjectDetail() {
}, },
}); });
// Permission derivation
const isOwner = project ? project.user_id === currentUserId : true;
const isShared = project ? project.user_id !== currentUserId : false;
// For now, if they can see the project but don't own it, check if they can edit
// The backend enforces actual permissions — this is just UI gating
const canManageProject = isOwner;
// Delta polling for real-time sync — only for shared projects (P-04)
const pollKey = useMemo(() => ['projects', id], [id]);
useDeltaPoll(
id && project && project.member_count > 0 ? `/projects/${id}/poll` : null,
pollKey,
5000,
);
// Fetch members for shared projects
const { data: members = [] } = useQuery<ProjectMember[]>({
queryKey: ['project-members', id],
queryFn: async () => {
const { data } = await api.get(`/projects/${id}/members`);
return data;
},
enabled: !!id,
});
const acceptedMembers = useMemo(() => members.filter((m) => m.status === 'accepted'), [members]);
const myPermission = useMemo(
() => acceptedMembers.find((m) => m.user_id === currentUserId)?.permission ?? null,
[acceptedMembers, currentUserId]
);
const canEditTasks = isOwner || myPermission === 'create_modify';
const toggleTaskMutation = useMutation({ const toggleTaskMutation = useMutation({
mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => { mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
const newStatus = status === 'completed' ? 'pending' : 'completed'; const newStatus = status === 'completed' ? 'pending' : 'completed';
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus, version }); const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus });
return data; return data;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] }); queryClient.invalidateQueries({ queryKey: ['projects', id] });
}, },
onError: (error) => { onError: () => {
if (axios.isAxiosError(error) && error.response?.status === 409) { toast.error('Failed to update task');
toast.error('Task was modified by another user — please refresh');
queryClient.invalidateQueries({ queryKey: ['projects', id] });
} else {
toast.error('Failed to update task');
}
}, },
}); });
@ -200,9 +157,6 @@ export default function ProjectDetail() {
toast.success('Task deleted'); toast.success('Task deleted');
setSelectedTaskId(null); setSelectedTaskId(null);
}, },
onError: () => {
toast.error('Failed to delete task');
},
}); });
const deleteProjectMutation = useMutation({ const deleteProjectMutation = useMutation({
@ -245,20 +199,15 @@ export default function ProjectDetail() {
}); });
const updateTaskStatusMutation = useMutation({ const updateTaskStatusMutation = useMutation({
mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => { mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status, version }); const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status });
return data; return data;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] }); queryClient.invalidateQueries({ queryKey: ['projects', id] });
}, },
onError: (error) => { onError: () => {
if (axios.isAxiosError(error) && error.response?.status === 409) { toast.error('Failed to update task status');
toast.error('Task was modified by another user — please refresh');
queryClient.invalidateQueries({ queryKey: ['projects', id] });
} else {
toast.error('Failed to update task status');
}
}, },
}); });
@ -440,57 +389,31 @@ export default function ProjectDetail() {
<Badge className={`shrink-0 hidden sm:inline-flex ${statusColors[project.status]}`}> <Badge className={`shrink-0 hidden sm:inline-flex ${statusColors[project.status]}`}>
{statusLabels[project.status]} {statusLabels[project.status]}
</Badge> </Badge>
{/* Permission badge for non-owners */}
{isShared && (
<Badge className="shrink-0 bg-blue-500/10 text-blue-400 border-0">
{myPermission === 'create_modify' ? 'Editor' : 'Viewer'}
</Badge>
)}
{canManageProject && (
<Button
variant="ghost"
size="icon"
onClick={() => toggleTrackMutation.mutate()}
disabled={toggleTrackMutation.isPending}
className={`shrink-0 ${project.is_tracked ? 'text-accent' : 'text-muted-foreground'}`}
title={project.is_tracked ? 'Untrack project' : 'Track project'}
>
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="shrink-0 text-muted-foreground relative" onClick={() => toggleTrackMutation.mutate()}
onClick={() => setShowShareSheet(true)} disabled={toggleTrackMutation.isPending}
title="Project members" className={`shrink-0 ${project.is_tracked ? 'text-accent' : 'text-muted-foreground'}`}
title={project.is_tracked ? 'Untrack project' : 'Track project'}
> >
<Users className="h-4 w-4" /> <Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
{acceptedMembers.length > 0 && ( </Button>
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-accent text-[9px] font-bold flex items-center justify-center text-background"> <Button variant="outline" size="sm" className="shrink-0" onClick={() => setShowProjectForm(true)}>
{acceptedMembers.length} <Pencil className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Edit</span>
</span> </Button>
)} <Button
variant="ghost"
size="sm"
className="shrink-0 text-destructive hover:bg-destructive/10"
onClick={() => {
if (!window.confirm('Delete this project and all its tasks?')) return;
deleteProjectMutation.mutate();
}}
disabled={deleteProjectMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
</Button> </Button>
{canManageProject && (
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setShowProjectForm(true)}>
<Pencil className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Edit</span>
</Button>
)}
{canManageProject && (
<Button
variant="ghost"
size="sm"
className="shrink-0 text-destructive hover:bg-destructive/10"
onClick={() => {
if (!window.confirm('Delete this project and all its tasks?')) return;
deleteProjectMutation.mutate();
}}
disabled={deleteProjectMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
</Button>
)}
</div> </div>
{/* Content area */} {/* Content area */}
@ -568,14 +491,6 @@ export default function ProjectDetail() {
</Card> </Card>
</div> </div>
{/* Read-only banner for viewers */}
{isShared && !canEditTasks && (
<div className="mx-4 md:mx-6 mb-3 px-3 py-2 rounded-md bg-secondary/50 border border-border flex items-center gap-2">
<Eye className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground">You have view-only access to this project</span>
</div>
)}
{/* Task list header + view controls */} {/* Task list header + view controls */}
<div className="px-4 md:px-6 pb-3 flex items-center justify-between flex-wrap gap-2 shrink-0"> <div className="px-4 md:px-6 pb-3 flex items-center justify-between flex-wrap gap-2 shrink-0">
<h2 className="font-heading text-lg font-semibold">Tasks</h2> <h2 className="font-heading text-lg font-semibold">Tasks</h2>
@ -620,12 +535,10 @@ export default function ProjectDetail() {
</div> </div>
)} )}
{canEditTasks && ( <Button size="sm" onClick={() => openTaskForm(null, null)}>
<Button size="sm" onClick={() => openTaskForm(null, null)}> <Plus className="mr-2 h-3.5 w-3.5" />
<Plus className="mr-2 h-3.5 w-3.5" /> Add Task
Add Task </Button>
</Button>
)}
</div> </div>
</div> </div>
@ -648,10 +561,9 @@ export default function ProjectDetail() {
selectedTaskId={selectedTaskId} selectedTaskId={selectedTaskId}
kanbanParentTask={kanbanParentTask} kanbanParentTask={kanbanParentTask}
onSelectTask={handleKanbanSelectTask} onSelectTask={handleKanbanSelectTask}
onStatusChange={(taskId, status) => { onStatusChange={(taskId, status) =>
const t = allTasks.find(tt => tt.id === taskId) ?? allTasks.flatMap(tt => tt.subtasks || []).find(st => st.id === taskId); updateTaskStatusMutation.mutate({ taskId, status })
updateTaskStatusMutation.mutate({ taskId, status, version: t?.version ?? 1 }); }
}}
onBackToAllTasks={handleBackToAllTasks} onBackToAllTasks={handleBackToAllTasks}
/> />
) : ( ) : (
@ -683,7 +595,6 @@ export default function ProjectDetail() {
toggleTaskMutation.mutate({ toggleTaskMutation.mutate({
taskId: task.id, taskId: task.id,
status: task.status, status: task.status,
version: task.version,
}) })
} }
togglePending={toggleTaskMutation.isPending} togglePending={toggleTaskMutation.isPending}
@ -704,7 +615,6 @@ export default function ProjectDetail() {
toggleTaskMutation.mutate({ toggleTaskMutation.mutate({
taskId: subtask.id, taskId: subtask.id,
status: subtask.status, status: subtask.status,
version: subtask.version,
}) })
} }
togglePending={toggleTaskMutation.isPending} togglePending={toggleTaskMutation.isPending}
@ -731,10 +641,6 @@ export default function ProjectDetail() {
<TaskDetailPanel <TaskDetailPanel
task={selectedTask} task={selectedTask}
projectId={parseInt(id!)} projectId={parseInt(id!)}
members={acceptedMembers}
currentUserId={currentUserId}
ownerId={project?.user_id ?? 0}
canAssign={canEditTasks}
onDelete={handleDeleteTask} onDelete={handleDeleteTask}
onAddSubtask={(parentId) => openTaskForm(null, parentId)} onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onClose={() => setSelectedTaskId(null)} onClose={() => setSelectedTaskId(null)}
@ -792,14 +698,6 @@ export default function ProjectDetail() {
onClose={() => setShowProjectForm(false)} onClose={() => setShowProjectForm(false)}
/> />
)} )}
<ProjectShareSheet
open={showShareSheet}
onOpenChange={setShowShareSheet}
projectId={parseInt(id!)}
isOwner={isOwner}
ownerName={settings?.preferred_name || 'Owner'}
/>
</div> </div>
); );
} }

View File

@ -1,310 +0,0 @@
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Users, UserPlus, Search, X, Crown, Eye, Pencil } from 'lucide-react';
import { toast } from 'sonner';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import api from '@/lib/api';
import { useConnections } from '@/hooks/useConnections';
import type { ProjectMember, ProjectPermission, Connection } from '@/types';
interface ProjectShareSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: number;
isOwner: boolean;
ownerName: string;
}
function AvatarCircle({ name }: { name: string }) {
const letter = (name ?? '?').charAt(0).toUpperCase();
return (
<div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center shrink-0">
<span className="text-xs font-medium text-muted-foreground">{letter}</span>
</div>
);
}
function PermissionBadge({ permission }: { permission: ProjectPermission }) {
if (permission === 'create_modify') {
return (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-blue-500/10 text-blue-400 flex items-center gap-1">
<Pencil className="h-2.5 w-2.5" />
Editor
</span>
);
}
return (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-500/10 text-gray-400 flex items-center gap-1">
<Eye className="h-2.5 w-2.5" />
Viewer
</span>
);
}
export function ProjectShareSheet({ open, onOpenChange, projectId, isOwner, ownerName }: ProjectShareSheetProps) {
const queryClient = useQueryClient();
const { connections } = useConnections();
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [invitePermission, setInvitePermission] = useState<ProjectPermission>('create_modify');
const membersQuery = useQuery<ProjectMember[]>({
queryKey: ['project-members', projectId],
queryFn: async () => {
const { data } = await api.get(`/projects/${projectId}/members`);
return data;
},
enabled: open,
});
const members = membersQuery.data ?? [];
const acceptedMembers = members.filter((m) => m.status === 'accepted');
const pendingMembers = members.filter((m) => m.status === 'pending');
const existingMemberIds = useMemo(
() => new Set(members.map((m) => m.user_id)),
[members]
);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['project-members', projectId] });
queryClient.invalidateQueries({ queryKey: ['project', projectId] });
};
const inviteMutation = useMutation({
mutationFn: (payload: { user_ids: number[]; permission: ProjectPermission }) =>
api.post(`/projects/${projectId}/members`, payload),
onSuccess: () => { toast.success('Invites sent'); setSelectedIds([]); invalidate(); },
onError: () => toast.error('Failed to send invites'),
});
const updateMutation = useMutation({
mutationFn: ({ userId, permission }: { userId: number; permission: ProjectPermission }) =>
api.patch(`/projects/${projectId}/members/${userId}`, { permission }),
onSuccess: () => { toast.success('Permission updated'); invalidate(); },
onError: () => toast.error('Failed to update permission'),
});
const removeMutation = useMutation({
mutationFn: (userId: number) => api.delete(`/projects/${projectId}/members/${userId}`),
onSuccess: () => { toast.success('Member removed'); invalidate(); },
onError: () => toast.error('Failed to remove member'),
});
const searchResults = useMemo(() => {
if (!search.trim()) return [];
const q = search.toLowerCase();
return (connections as Connection[])
.filter(
(c) =>
!existingMemberIds.has(c.connected_user_id) &&
!selectedIds.includes(c.connected_user_id) &&
((c.connected_preferred_name?.toLowerCase().includes(q)) ||
c.connected_umbral_name.toLowerCase().includes(q))
)
.slice(0, 6);
}, [search, connections, existingMemberIds, selectedIds]);
const selectedConnections = (connections as Connection[]).filter((c) =>
selectedIds.includes(c.connected_user_id)
);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
Project Members
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
{/* Current Members */}
<div className="space-y-2">
<p className="text-[11px] text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Users className="h-3 w-3" />
Members ({acceptedMembers.length + 1})
</p>
{/* Owner row — always first */}
<div className="flex items-center gap-2 py-1">
<AvatarCircle name={ownerName} />
<span className="text-sm flex-1 truncate">{ownerName}</span>
<span
className="px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1"
style={{ color: 'hsl(var(--accent-color))', backgroundColor: 'hsl(var(--accent-color) / 0.1)' }}
>
<Crown className="h-2.5 w-2.5" />
Owner
</span>
</div>
{/* Accepted members */}
{acceptedMembers.map((member) => (
<div key={member.id} className="flex items-center gap-2 py-1">
<AvatarCircle name={member.user_name ?? 'Unknown'} />
<span className="text-sm flex-1 truncate">{member.user_name ?? 'Unknown'}</span>
{isOwner ? (
<select
value={member.permission}
onChange={(e) =>
updateMutation.mutate({
userId: member.user_id,
permission: e.target.value as ProjectPermission,
})
}
disabled={updateMutation.isPending}
className="text-[11px] bg-transparent border border-border rounded px-1.5 py-0.5 text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="read_only">Viewer</option>
<option value="create_modify">Editor</option>
</select>
) : (
<PermissionBadge permission={member.permission} />
)}
{isOwner && (
<button
type="button"
onClick={() => removeMutation.mutate(member.user_id)}
disabled={removeMutation.isPending}
title="Remove member"
className="p-1 rounded text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{/* Pending invites */}
{pendingMembers.length > 0 && (
<>
<p className="text-[11px] text-muted-foreground uppercase tracking-wider mt-3">
Pending Invites ({pendingMembers.length})
</p>
{pendingMembers.map((member) => (
<div key={member.id} className="flex items-center gap-2 py-1 opacity-60">
<AvatarCircle name={member.user_name ?? 'Unknown'} />
<span className="text-sm flex-1 truncate">{member.user_name ?? 'Unknown'}</span>
<span className="text-[10px] text-muted-foreground">Pending</span>
{isOwner && (
<button
type="button"
onClick={() => removeMutation.mutate(member.user_id)}
disabled={removeMutation.isPending}
title="Cancel invite"
className="p-1 rounded text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</>
)}
</div>
{/* Invite section (owner only) */}
{isOwner && (
<div className="space-y-2">
<p className="text-[11px] text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<UserPlus className="h-3 w-3" />
Invite Connections
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground shrink-0">Invite as</span>
<Select
value={invitePermission}
onChange={(e) => setInvitePermission(e.target.value as ProjectPermission)}
className="h-7 text-xs py-0"
>
<option value="create_modify">Editor</option>
<option value="read_only">Viewer</option>
</Select>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
onBlur={() => setSearch('')}
placeholder="Search connections..."
className="h-8 pl-8 text-xs"
/>
{search.trim() && searchResults.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg overflow-hidden">
{searchResults.map((conn) => (
<button
key={conn.connected_user_id}
type="button"
onMouseDown={(e) => { e.preventDefault(); setSelectedIds(p => [...p, conn.connected_user_id]); setSearch(''); }}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
>
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">
{conn.connected_preferred_name || conn.connected_umbral_name}
</span>
{conn.connected_preferred_name && (
<span className="text-[11px] text-muted-foreground">
@{conn.connected_umbral_name}
</span>
)}
</div>
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
</button>
))}
</div>
)}
{search.trim() && searchResults.length === 0 && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border bg-card shadow-lg p-3">
<p className="text-xs text-muted-foreground text-center">No connections found</p>
</div>
)}
</div>
{selectedConnections.length > 0 && (
<div className="space-y-1">
{selectedConnections.map((conn) => (
<div key={conn.connected_user_id} className="flex items-center gap-2 py-1">
<AvatarCircle name={conn.connected_preferred_name || conn.connected_umbral_name} />
<span className="text-sm flex-1 truncate">
{conn.connected_preferred_name || conn.connected_umbral_name}
</span>
<button
type="button"
onClick={() => setSelectedIds(p => p.filter(id => id !== conn.connected_user_id))}
className="p-0.5 rounded hover:bg-card-elevated text-muted-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
<Button
size="sm"
onClick={() => inviteMutation.mutate({ user_ids: selectedIds, permission: invitePermission })}
disabled={inviteMutation.isPending}
className="w-full mt-1"
>
{inviteMutation.isPending
? 'Sending...'
: `Invite ${selectedIds.length === 1 ? '1 Person' : `${selectedIds.length} People`}`}
</Button>
</div>
)}
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}

View File

@ -1,16 +1,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { format, formatDistanceToNow, parseISO } from 'date-fns'; import { format, formatDistanceToNow, parseISO } from 'date-fns';
import { import {
Pencil, Trash2, Plus, MessageSquare, ClipboardList, Pencil, Trash2, Plus, MessageSquare, ClipboardList,
Calendar, User, Flag, Activity, Send, X, Save, Calendar, User, Flag, Activity, Send, X, Save,
} from 'lucide-react'; } from 'lucide-react';
import axios from 'axios';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import { formatUpdatedAt } from '@/components/shared/utils'; import { formatUpdatedAt } from '@/components/shared/utils';
import type { ProjectTask, TaskComment, ProjectMember } from '@/types'; import type { ProjectTask, TaskComment, Person } from '@/types';
import { AssignmentPicker } from './AssignmentPicker';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@ -47,10 +45,6 @@ const priorityColors: Record<string, string> = {
interface TaskDetailPanelProps { interface TaskDetailPanelProps {
task: ProjectTask | null; task: ProjectTask | null;
projectId: number; projectId: number;
members?: ProjectMember[];
currentUserId?: number;
ownerId?: number;
canAssign?: boolean;
onDelete: (taskId: number) => void; onDelete: (taskId: number) => void;
onAddSubtask: (parentId: number) => void; onAddSubtask: (parentId: number) => void;
onClose?: () => void; onClose?: () => void;
@ -87,10 +81,6 @@ function buildEditState(task: ProjectTask): EditState {
export default function TaskDetailPanel({ export default function TaskDetailPanel({
task, task,
projectId, projectId,
members = [],
currentUserId = 0,
ownerId = 0,
canAssign = false,
onDelete, onDelete,
onAddSubtask, onAddSubtask,
onClose, onClose,
@ -103,24 +93,20 @@ export default function TaskDetailPanel({
task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' } task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' }
); );
// Build a combined members list that includes the owner for the AssignmentPicker const { data: people = [] } = useQuery({
const allMembers: ProjectMember[] = [ queryKey: ['people'],
// Synthetic owner entry so they appear in the picker queryFn: async () => {
...(ownerId ? [{ const { data } = await api.get<Person[]>('/people');
id: 0, project_id: projectId, user_id: ownerId, invited_by: ownerId, return data;
permission: 'create_modify' as const, status: 'accepted' as const, },
source: 'invited' as const, user_name: currentUserId === ownerId ? 'Me (Owner)' : 'Owner', });
inviter_name: null, created_at: '', updated_at: '', accepted_at: null,
}] : []),
...members.filter(m => m.status === 'accepted'),
];
// --- Mutations --- // --- Mutations ---
const toggleSubtaskMutation = useMutation({ const toggleSubtaskMutation = useMutation({
mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => { mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
const newStatus = status === 'completed' ? 'pending' : 'completed'; const newStatus = status === 'completed' ? 'pending' : 'completed';
const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus, version }); const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus });
return data; return data;
}, },
onSuccess: () => { onSuccess: () => {
@ -139,12 +125,7 @@ export default function TaskDetailPanel({
toast.success('Task updated'); toast.success('Task updated');
}, },
onError: (error) => { onError: (error) => {
if (axios.isAxiosError(error) && error.response?.status === 409) { toast.error(getErrorMessage(error, 'Failed to update task'));
toast.error('Task was modified by another user — please refresh');
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
} else {
toast.error(getErrorMessage(error, 'Failed to update task'));
}
}, },
}); });
@ -188,30 +169,6 @@ export default function TaskDetailPanel({
}, },
}); });
const assignMutation = useMutation({
mutationFn: async (userIds: number[]) => {
await api.post(`/projects/${projectId}/tasks/${task!.id}/assignments`, { user_ids: userIds });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to assign'));
},
});
const unassignMutation = useMutation({
mutationFn: async (userId: number) => {
await api.delete(`/projects/${projectId}/tasks/${task!.id}/assignments/${userId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to unassign'));
},
});
// --- Handlers --- // --- Handlers ---
const handleAddComment = () => { const handleAddComment = () => {
@ -240,7 +197,6 @@ export default function TaskDetailPanel({
due_date: editState.due_date || null, due_date: editState.due_date || null,
person_id: editState.person_id ? Number(editState.person_id) : null, person_id: editState.person_id ? Number(editState.person_id) : null,
description: editState.description || null, description: editState.description || null,
version: task.version,
}; };
updateTaskMutation.mutate(payload); updateTaskMutation.mutate(payload);
}; };
@ -261,6 +217,7 @@ export default function TaskDetailPanel({
); );
} }
const assignedPerson = task.person_id ? people.find((p) => p.id === task.person_id) : null;
const comments = task.comments || []; const comments = task.comments || [];
return ( return (
@ -285,21 +242,21 @@ export default function TaskDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-green-400 hover:text-green-300" className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave} onClick={handleEditSave}
disabled={updateTaskMutation.isPending} disabled={updateTaskMutation.isPending}
title="Save changes" title="Save changes"
> >
<Save className="h-4 w-4" /> <Save className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={handleEditCancel} onClick={handleEditCancel}
title="Cancel editing" title="Cancel editing"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</> </>
) : ( ) : (
@ -307,30 +264,30 @@ export default function TaskDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={handleEditStart} onClick={handleEditStart}
title="Edit task" title="Edit task"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive" className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(task.id)} onClick={() => onDelete(task.id)}
title="Delete task" title="Delete task"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
{onClose && ( {onClose && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={onClose} onClick={onClose}
title="Close panel" title="Close panel"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
</> </>
@ -419,34 +376,21 @@ export default function TaskDetailPanel({
<User className="h-3 w-3" /> <User className="h-3 w-3" />
Assigned Assigned
</div> </div>
{canAssign ? ( {isEditing ? (
<AssignmentPicker <Select
currentAssignments={task.assignments ?? []} value={editState.person_id}
members={allMembers} onChange={(e) => setEditState((s) => ({ ...s, person_id: e.target.value }))}
currentUserId={currentUserId} className="h-8 text-xs"
ownerId={ownerId} >
onAssign={(userIds) => assignMutation.mutate(userIds)} <option value="">Unassigned</option>
onUnassign={(userId) => unassignMutation.mutate(userId)} {people.map((p) => (
disabled={assignMutation.isPending || unassignMutation.isPending} <option key={p.id} value={String(p.id)}>
/> {p.name}
) : task.assignments && task.assignments.length > 0 ? ( </option>
<div className="flex flex-wrap gap-1.5">
{task.assignments.map((a) => (
<span
key={a.user_id}
className="inline-flex items-center gap-1.5 bg-secondary rounded-full pl-1 pr-2.5 py-0.5"
>
<span className="w-5 h-5 rounded-full bg-muted flex items-center justify-center shrink-0">
<span className="text-[9px] font-medium text-muted-foreground">
{(a.user_name ?? '?').charAt(0).toUpperCase()}
</span>
</span>
<span className="text-xs">{a.user_name ?? 'Unknown'}</span>
</span>
))} ))}
</div> </Select>
) : ( ) : (
<p className="text-sm text-muted-foreground/50">Unassigned</p> <p className="text-sm">{assignedPerson ? assignedPerson.name : '—'}</p>
)} )}
</div> </div>
</div> </div>
@ -514,7 +458,6 @@ export default function TaskDetailPanel({
toggleSubtaskMutation.mutate({ toggleSubtaskMutation.mutate({
taskId: subtask.id, taskId: subtask.id,
status: subtask.status, status: subtask.status,
version: subtask.version,
}) })
} }
disabled={toggleSubtaskMutation.isPending} disabled={toggleSubtaskMutation.isPending}
@ -562,7 +505,7 @@ export default function TaskDetailPanel({
{/* Comments */} {/* Comments */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<MessageSquare className="h-4 w-4 text-muted-foreground" /> <MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider"> <h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
Comments Comments
{comments.length > 0 && ( {comments.length > 0 && (
@ -579,9 +522,6 @@ export default function TaskDetailPanel({
<p className="text-sm whitespace-pre-wrap">{comment.content}</p> <p className="text-sm whitespace-pre-wrap">{comment.content}</p>
<div className="flex items-center justify-between mt-1.5"> <div className="flex items-center justify-between mt-1.5">
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{comment.author_name && (
<span className="font-medium text-foreground/70 mr-1">{comment.author_name}</span>
)}
{formatDistanceToNow(parseISO(comment.created_at), { addSuffix: true })} {formatDistanceToNow(parseISO(comment.created_at), { addSuffix: true })}
</span> </span>
<Button <Button

View File

@ -3,7 +3,6 @@ import { ChevronRight, GripVertical } from 'lucide-react';
import type { ProjectTask } from '@/types'; import type { ProjectTask } from '@/types';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { AssigneeAvatars } from './AssignmentPicker';
const taskStatusColors: Record<string, string> = { const taskStatusColors: Record<string, string> = {
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20', pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
@ -135,22 +134,6 @@ export default function TaskRow({
{hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'} {hasSubtasks ? `${completedSubtasks}/${task.subtasks.length}` : '—'}
</span> </span>
{/* Assigned column */}
<span className="hidden sm:flex items-center gap-1.5 shrink-0 w-24 justify-end">
{task.assignments && task.assignments.length > 0 ? (
<>
<AssigneeAvatars assignments={task.assignments} max={2} />
<span className="text-[11px] text-muted-foreground truncate max-w-[60px]">
{task.assignments.length === 1
? (task.assignments[0].user_name ?? 'Unknown')
: `${task.assignments.length} people`}
</span>
</>
) : (
<span className="text-[11px] text-muted-foreground/30">unassigned</span>
)}
</span>
{/* Mobile-only: compact priority dot + overdue indicator */} {/* Mobile-only: compact priority dot + overdue indicator */}
<div className="flex items-center gap-1.5 sm:hidden shrink-0"> <div className="flex items-center gap-1.5 sm:hidden shrink-0">
<div className={`h-2 w-2 rounded-full ${ <div className={`h-2 w-2 rounded-full ${

View File

@ -236,21 +236,21 @@ export default function ReminderDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-green-400 hover:text-green-300" className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave} onClick={handleEditSave}
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
title="Save" title="Save"
> >
<Save className="h-4 w-4" /> <Save className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={handleEditCancel} onClick={handleEditCancel}
title="Cancel" title="Cancel"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</> </>
) : ( ) : (
@ -259,22 +259,22 @@ export default function ReminderDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 hover:bg-orange-500/10 hover:text-orange-400" className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
onClick={() => dismissMutation.mutate()} onClick={() => dismissMutation.mutate()}
disabled={dismissMutation.isPending} disabled={dismissMutation.isPending}
title="Dismiss reminder" title="Dismiss reminder"
> >
<BellOff className="h-4 w-4" /> <BellOff className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={handleEditStart} onClick={handleEditStart}
title="Edit reminder" title="Edit reminder"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
{confirmingDelete ? ( {confirmingDelete ? (
<Button <Button
@ -290,22 +290,22 @@ export default function ReminderDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive" className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick} onClick={handleDeleteClick}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
title="Delete reminder" title="Delete reminder"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={onClose} onClick={onClose}
title="Close panel" title="Close panel"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</> </>
)} )}

View File

@ -1,600 +0,0 @@
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Fingerprint, Loader2, Trash2, Cloud, ShieldOff } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { useAuth } from '@/hooks/useAuth';
import type { PasskeyCredential } from '@/types';
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString(undefined, {
day: 'numeric', month: 'short', year: 'numeric',
});
}
function detectDeviceName(): string {
const ua = navigator.userAgent;
let browser = 'Browser';
if (ua.includes('Chrome') && !ua.includes('Edg')) browser = 'Chrome';
else if (ua.includes('Safari') && !ua.includes('Chrome')) browser = 'Safari';
else if (ua.includes('Firefox')) browser = 'Firefox';
else if (ua.includes('Edg')) browser = 'Edge';
let os = '';
if (ua.includes('Mac')) os = 'macOS';
else if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Linux')) os = 'Linux';
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
else if (ua.includes('Android')) os = 'Android';
return os ? `${os} \u2014 ${browser}` : browser;
}
interface DeleteConfirmProps {
credential: PasskeyCredential;
onDelete: (id: number, password: string) => void;
isDeleting: boolean;
}
function PasskeyDeleteButton({ credential, onDelete, isDeleting }: DeleteConfirmProps) {
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [password, setPassword] = useState('');
const { confirming, handleClick } = useConfirmAction(() => {
setShowPasswordDialog(true);
});
const handleSubmitDelete = () => {
onDelete(credential.id, password);
setPassword('');
setShowPasswordDialog(false);
};
return (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-400"
onClick={handleClick}
disabled={isDeleting}
aria-label={`Remove passkey ${credential.name}`}
>
{confirming ? (
<span className="text-xs font-medium text-red-400">Sure?</span>
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
<Dialog open={showPasswordDialog} onOpenChange={setShowPasswordDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove passkey</DialogTitle>
<DialogDescription>
Enter your password to remove "{credential.name}".
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-passkey-password">Password</Label>
<Input
id="delete-passkey-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && password) handleSubmitDelete(); }}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPasswordDialog(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleSubmitDelete}
disabled={!password || isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default function PasskeySection() {
const queryClient = useQueryClient();
const { passwordlessEnabled, allowPasswordless } = useAuth();
// Registration state
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [ceremonyState, setCeremonyState] = useState<'password' | 'waiting' | 'naming'>('password');
const [registerPassword, setRegisterPassword] = useState('');
const [passkeyName, setPasskeyName] = useState('');
const [pendingCredential, setPendingCredential] = useState<{
credential: string;
challenge_token: string;
} | null>(null);
// Passwordless enable state
const [enableDialogOpen, setEnableDialogOpen] = useState(false);
const [enablePassword, setEnablePassword] = useState('');
// Passwordless disable state
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
const passkeysQuery = useQuery({
queryKey: ['passkeys'],
queryFn: async () => {
const { data } = await api.get<PasskeyCredential[]>('/auth/passkeys');
return data;
},
});
const registerMutation = useMutation({
mutationFn: async ({ password }: { password: string }) => {
const { startRegistration } = await import('@simplewebauthn/browser');
// Step 1: Get registration options (requires password V-02)
const { data: beginResp } = await api.post('/auth/passkeys/register/begin', { password });
// Step 2: Browser WebAuthn ceremony
setCeremonyState('waiting');
const credential = await startRegistration(beginResp.options);
return {
credential: JSON.stringify(credential),
challenge_token: beginResp.challenge_token,
};
},
onSuccess: (data) => {
setPendingCredential(data);
setPasskeyName(detectDeviceName());
setCeremonyState('naming');
},
onError: (error: unknown) => {
if (error instanceof Error && error.name === 'NotAllowedError') {
toast.info('Passkey setup cancelled');
} else if (error instanceof Error && error.name === 'AbortError') {
toast.info('Cancelled');
} else {
toast.error(getErrorMessage(error, 'Failed to create passkey'));
}
setRegisterDialogOpen(false);
resetRegisterState();
},
});
const completeMutation = useMutation({
mutationFn: async ({ credential, challenge_token, name }: {
credential: string; challenge_token: string; name: string;
}) => {
const { data } = await api.post('/auth/passkeys/register/complete', {
credential, challenge_token, name,
});
return data;
},
onSuccess: () => {
toast.success('Passkey registered');
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
queryClient.invalidateQueries({ queryKey: ['auth'] });
setRegisterDialogOpen(false);
resetRegisterState();
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, 'Failed to save passkey'));
},
});
const deleteMutation = useMutation({
mutationFn: async ({ id, password }: { id: number; password: string }) => {
await api.delete(`/auth/passkeys/${id}`, { data: { password } });
},
onSuccess: () => {
toast.success('Passkey removed');
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, 'Failed to remove passkey'));
},
});
const enablePasswordlessMutation = useMutation({
mutationFn: async ({ password }: { password: string }) => {
const { data } = await api.put('/auth/passkeys/passwordless/enable', { password });
return data;
},
onSuccess: () => {
toast.success('Passwordless login enabled');
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
setEnableDialogOpen(false);
setEnablePassword('');
},
onError: (error: unknown) => {
toast.error(getErrorMessage(error, 'Failed to enable passwordless login'));
},
});
const disablePasswordlessMutation = useMutation({
mutationFn: async () => {
const { startAuthentication } = await import('@simplewebauthn/browser');
const { data: beginResp } = await api.post('/auth/passkeys/passwordless/disable/begin', {});
const credential = await startAuthentication(beginResp.options);
const { data } = await api.put('/auth/passkeys/passwordless/disable', {
credential: JSON.stringify(credential),
challenge_token: beginResp.challenge_token,
});
return data;
},
onSuccess: () => {
toast.success('Passwordless login disabled');
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
setDisableDialogOpen(false);
},
onError: (error: unknown) => {
if (error instanceof Error && error.name === 'NotAllowedError') {
toast.error('Passkey not recognized');
} else if (error instanceof Error && error.name === 'AbortError') {
toast.info('Cancelled');
} else {
toast.error(getErrorMessage(error, 'Failed to disable passwordless login'));
}
setDisableDialogOpen(false);
// W-03: Invalidate to resync switch state after failed/cancelled ceremony
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
});
const resetRegisterState = useCallback(() => {
setCeremonyState('password');
setRegisterPassword('');
setPasskeyName('');
setPendingCredential(null);
}, []);
const handleStartRegister = () => {
resetRegisterState();
setRegisterDialogOpen(true);
};
const handlePasswordSubmit = () => {
if (!registerPassword) return;
registerMutation.mutate({ password: registerPassword });
};
const handleSaveName = () => {
if (!pendingCredential || !passkeyName.trim()) return;
completeMutation.mutate({
...pendingCredential,
name: passkeyName.trim(),
});
};
const handleDelete = (id: number, password: string) => {
deleteMutation.mutate({ id, password });
};
const passkeys = passkeysQuery.data ?? [];
const hasPasskeys = passkeys.length > 0;
return (
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<Fingerprint className="h-4 w-4 text-accent" />
</div>
<div className="space-y-0.5">
<Label>Passkeys</Label>
<p className="text-xs text-muted-foreground">
Sign in with your fingerprint, face, or security key
</p>
</div>
</div>
{hasPasskeys && (
<span className="inline-flex items-center rounded-full bg-green-500/10 px-2.5 py-0.5 text-xs font-semibold text-green-400">
{passkeys.length} registered
</span>
)}
</div>
{hasPasskeys && (
<ul className="space-y-1" aria-live="polite">
{passkeys.map((pk) => (
<li
key={pk.id}
className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
>
<div className="p-1.5 rounded-md bg-accent/10">
<Fingerprint className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{pk.name}</span>
{pk.backed_up && (
<Cloud className="h-3 w-3 text-muted-foreground shrink-0" aria-label="Synced" />
)}
</div>
<span className="text-xs text-muted-foreground">
Added {formatDate(pk.created_at)} · Last used {formatRelativeTime(pk.last_used_at)}
</span>
</div>
<PasskeyDeleteButton
credential={pk}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending && (deleteMutation.variables as { id: number } | undefined)?.id === pk.id}
/>
</li>
))}
</ul>
)}
<Separator />
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleStartRegister}
>
<Fingerprint className="h-4 w-4" />
{hasPasskeys ? 'Add another passkey' : 'Add a passkey'}
</Button>
{/* Passwordless login section — hidden when admin hasn't enabled the feature */}
{(allowPasswordless || passwordlessEnabled) && <Separator />}
{(allowPasswordless || passwordlessEnabled) && (
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Fingerprint className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium">Passwordless Login</Label>
</div>
<p className="text-xs text-muted-foreground">
Skip the password prompt and unlock the app using a passkey only.
</p>
{passkeys.length < 2 && !passwordlessEnabled && (
<p className="text-xs text-amber-400">
Requires at least 2 registered passkeys as a fallback.
</p>
)}
</div>
<Switch
checked={passwordlessEnabled}
onCheckedChange={(checked) => {
if (checked) {
setEnablePassword('');
setEnableDialogOpen(true);
} else {
setDisableDialogOpen(true);
disablePasswordlessMutation.mutate();
}
}}
disabled={(!passwordlessEnabled && passkeys.length < 2) || enablePasswordlessMutation.isPending || disablePasswordlessMutation.isPending}
aria-label="Toggle passwordless login"
/>
</div>
)}
{/* Enable passwordless dialog */}
<Dialog open={enableDialogOpen} onOpenChange={(open) => {
if (!open) { setEnableDialogOpen(false); setEnablePassword(''); }
}}>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="p-2 rounded-lg bg-accent/10">
<Fingerprint className="h-5 w-5 text-accent" />
</div>
<DialogTitle>Enable Passwordless Login</DialogTitle>
</div>
<DialogDescription>
Confirm your password to enable passkey-only login.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="enable-passwordless-password">Password</Label>
<Input
id="enable-passwordless-password"
type="password"
value={enablePassword}
onChange={(e) => setEnablePassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && enablePassword) {
enablePasswordlessMutation.mutate({ password: enablePassword });
}
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setEnableDialogOpen(false); setEnablePassword(''); }}>
Cancel
</Button>
<Button
onClick={() => enablePasswordlessMutation.mutate({ password: enablePassword })}
disabled={!enablePassword || enablePasswordlessMutation.isPending}
>
{enablePasswordlessMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Enable'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Disable passwordless dialog */}
<Dialog open={disableDialogOpen} onOpenChange={(open) => {
if (!open && !disablePasswordlessMutation.isPending) {
setDisableDialogOpen(false);
}
}}>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="p-2 rounded-lg bg-orange-500/10">
<ShieldOff className="h-5 w-5 text-orange-400" />
</div>
<DialogTitle>Disable Passwordless Login</DialogTitle>
</div>
<DialogDescription>
Verify with your passkey to disable passwordless login.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
{disablePasswordlessMutation.isPending ? (
<>
<Loader2 className="h-8 w-8 animate-spin text-accent" />
<p className="text-sm text-muted-foreground text-center">
Follow your browser's prompt to verify your passkey
</p>
</>
) : (
<p className="text-sm text-muted-foreground text-center">
Ready to verify your passkey
</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDisableDialogOpen(false)}
disabled={disablePasswordlessMutation.isPending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Registration ceremony dialog */}
<Dialog
open={registerDialogOpen}
onOpenChange={(open) => {
if (!open) {
setRegisterDialogOpen(false);
resetRegisterState();
}
}}
>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="p-2 rounded-lg bg-accent/10">
<Fingerprint className="h-5 w-5 text-accent" />
</div>
<DialogTitle>
{ceremonyState === 'password' && 'Add a passkey'}
{ceremonyState === 'waiting' && 'Creating passkey'}
{ceremonyState === 'naming' && 'Name your passkey'}
</DialogTitle>
</div>
</DialogHeader>
{ceremonyState === 'password' && (
<>
<DialogDescription>
Enter your password to add a passkey to your account.
</DialogDescription>
<div className="space-y-2">
<Label htmlFor="register-passkey-password">Password</Label>
<Input
id="register-passkey-password"
type="password"
value={registerPassword}
onChange={(e) => setRegisterPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && registerPassword) handlePasswordSubmit(); }}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRegisterDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handlePasswordSubmit}
disabled={!registerPassword || registerMutation.isPending}
>
{registerMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Continue'}
</Button>
</DialogFooter>
</>
)}
{ceremonyState === 'waiting' && (
<div className="flex flex-col items-center gap-4 py-6">
<Loader2 className="h-8 w-8 animate-spin text-accent" />
<p className="text-sm text-muted-foreground text-center">
Follow your browser's prompt to create a passkey
</p>
</div>
)}
{ceremonyState === 'naming' && (
<>
<div className="space-y-2">
<Label htmlFor="passkey-name">Name</Label>
<Input
id="passkey-name"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && passkeyName.trim()) handleSaveName(); }}
maxLength={100}
autoFocus
/>
<p className="text-xs text-muted-foreground">
Give this passkey a name to help you identify it later.
</p>
</div>
<DialogFooter>
<Button
onClick={handleSaveName}
disabled={!passkeyName.trim() || completeMutation.isPending}
>
{completeMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}

View File

@ -5,7 +5,6 @@ import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import TotpSetupSection from './TotpSetupSection'; import TotpSetupSection from './TotpSetupSection';
import PasskeySection from './PasskeySection';
import type { Settings } from '@/types'; import type { Settings } from '@/types';
interface SecurityTabProps { interface SecurityTabProps {
@ -87,9 +86,6 @@ export default function SecurityTab({ settings, updateSettings, isUpdating }: Se
</CardContent> </CardContent>
</Card> </Card>
{/* Passkeys */}
<PasskeySection />
{/* Password + TOTP */} {/* Password + TOTP */}
<TotpSetupSection bare /> <TotpSetupSection bare />
</div> </div>

View File

@ -61,7 +61,7 @@ export function EntityDetailPanel<T>({
size="icon" size="icon"
onClick={onToggleFavourite} onClick={onToggleFavourite}
aria-label={isFavourite ? `Remove from ${favouriteLabel}s` : `Add to ${favouriteLabel}s`} aria-label={isFavourite ? `Remove from ${favouriteLabel}s` : `Add to ${favouriteLabel}s`}
className={`h-8 w-8 ${isFavourite ? 'text-yellow-400' : 'text-muted-foreground'}`} className={`h-7 w-7 ${isFavourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
> >
{isFavourite ? ( {isFavourite ? (
<Star className="h-4 w-4 fill-yellow-400" /> <Star className="h-4 w-4 fill-yellow-400" />
@ -75,7 +75,7 @@ export function EntityDetailPanel<T>({
size="icon" size="icon"
onClick={onClose} onClick={onClose}
aria-label="Close panel" aria-label="Close panel"
className="h-8 w-8" className="h-7 w-7"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>

View File

@ -272,21 +272,21 @@ export default function TodoDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-green-400 hover:text-green-300" className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave} onClick={handleEditSave}
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
title="Save" title="Save"
> >
<Save className="h-4 w-4" /> <Save className="h-3.5 w-3.5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={handleEditCancel} onClick={handleEditCancel}
title="Cancel" title="Cancel"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</> </>
) : ( ) : (
@ -294,11 +294,11 @@ export default function TodoDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={handleEditStart} onClick={handleEditStart}
title="Edit todo" title="Edit todo"
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
{confirmingDelete ? ( {confirmingDelete ? (
<Button <Button
@ -314,22 +314,22 @@ export default function TodoDetailPanel({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive" className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick} onClick={handleDeleteClick}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
title="Delete todo" title="Delete todo"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-7 w-7"
onClick={onClose} onClick={onClose}
title="Close panel" title="Close panel"
> >
<X className="h-4 w-4" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</> </>
)} )}

View File

@ -203,12 +203,5 @@ export function useUpdateConfig() {
}); });
} }
export function useDisablePasswordless() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.put(`/admin/users/${userId}/passwordless`, { enabled: false });
return data;
});
}
// Re-export getErrorMessage for convenience in admin components // Re-export getErrorMessage for convenience in admin components
export { getErrorMessage }; export { getErrorMessage };

View File

@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { AuthStatus, LoginResponse, PasskeyLoginResponse } from '@/types'; import type { AuthStatus, LoginResponse } from '@/types';
export function useAuth() { export function useAuth() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -96,30 +96,6 @@ export function useAuth() {
}, },
}); });
const passkeyLoginMutation = useMutation({
mutationFn: async () => {
const { startAuthentication } = await import('@simplewebauthn/browser');
const { data: beginResp } = await api.post('/auth/passkeys/login/begin', {});
const credential = await startAuthentication(beginResp.options);
const { data } = await api.post<PasskeyLoginResponse>('/auth/passkeys/login/complete', {
credential: JSON.stringify(credential),
challenge_token: beginResp.challenge_token,
});
return data;
},
onSuccess: (data) => {
setMfaToken(null);
setMfaSetupRequired(false);
if (!data?.must_change_password) {
queryClient.setQueryData(['auth'], (old: AuthStatus | undefined) => {
if (!old) return old;
return { ...old, authenticated: true };
});
}
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
});
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const { data } = await api.post('/auth/logout'); const { data } = await api.post('/auth/logout');
@ -149,11 +125,5 @@ export function useAuth() {
isRegisterPending: registerMutation.isPending, isRegisterPending: registerMutation.isPending,
isTotpPending: totpVerifyMutation.isPending, isTotpPending: totpVerifyMutation.isPending,
isSetupPending: setupMutation.isPending, isSetupPending: setupMutation.isPending,
passkeyLogin: passkeyLoginMutation.mutateAsync,
isPasskeyLoginPending: passkeyLoginMutation.isPending,
hasPasskeys: authQuery.data?.has_passkeys ?? false,
passkeyCount: 0, // Derived from passkeys list query in PasskeySection, not auth/status
passwordlessEnabled: authQuery.data?.passwordless_enabled ?? false,
allowPasswordless: authQuery.data?.allow_passwordless ?? false,
}; };
} }

View File

@ -1,56 +0,0 @@
import { useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import api from '../lib/api';
import { toLocalDatetime } from '../lib/date-utils';
interface PollResponse {
has_changes: boolean;
[key: string]: unknown;
}
/**
* Lightweight delta poll hook. Polls a cheap endpoint every `interval` ms.
* When changes are detected, invalidates the specified query key.
*
* HARD REQUIREMENT: refetchIntervalInBackground must be true so
* background tabs still receive updates (mirrors useNotifications pattern).
*/
export function useDeltaPoll(
endpoint: string | null,
queryKeyToInvalidate: unknown[],
interval: number = 5000,
) {
const queryClient = useQueryClient();
const sinceRef = useRef<string>(toLocalDatetime());
// Store key in ref to decouple from useEffect deps (prevents infinite loop
// if caller passes an inline array literal instead of a memoized one)
const keyRef = useRef(queryKeyToInvalidate);
keyRef.current = queryKeyToInvalidate;
const { data } = useQuery<PollResponse>({
queryKey: ['delta-poll', endpoint],
queryFn: async () => {
const { data } = await api.get(endpoint!, {
params: { since: sinceRef.current },
});
return data;
},
enabled: !!endpoint,
refetchInterval: interval,
refetchIntervalInBackground: true, // HARD REQUIREMENT — do not remove
staleTime: 0,
});
useEffect(() => {
if (data?.has_changes) {
queryClient.invalidateQueries({ queryKey: keyRef.current });
sinceRef.current = toLocalDatetime();
}
}, [data, queryClient]);
const updateSince = (timestamp: string) => {
sinceRef.current = timestamp;
};
return { updateSince };
}

View File

@ -17,7 +17,6 @@ interface LockContextValue {
isLockResolved: boolean; isLockResolved: boolean;
lock: () => Promise<void>; lock: () => Promise<void>;
unlock: (password: string) => Promise<void>; unlock: (password: string) => Promise<void>;
unlockWithPasskey: () => void;
} }
const LockContext = createContext<LockContextValue | null>(null); const LockContext = createContext<LockContextValue | null>(null);
@ -95,14 +94,6 @@ export function LockProvider({ children }: { children: ReactNode }) {
} }
}, [queryClient]); }, [queryClient]);
const unlockWithPasskey = useCallback(() => {
setIsLocked(false);
lastActivityRef.current = Date.now();
queryClient.setQueryData<AuthStatus>(['auth'], (old) =>
old ? { ...old, is_locked: false } : old
);
}, [queryClient]);
// Auto-lock idle timer // Auto-lock idle timer
useEffect(() => { useEffect(() => {
const enabled = settings?.auto_lock_enabled ?? false; const enabled = settings?.auto_lock_enabled ?? false;
@ -156,7 +147,7 @@ export function LockProvider({ children }: { children: ReactNode }) {
}, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]); }, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]);
return ( return (
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock, unlockWithPasskey }}> <LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock }}>
{children} {children}
</LockContext.Provider> </LockContext.Provider>
); );

View File

@ -15,7 +15,7 @@ api.interceptors.response.use(
if (error.response?.status === 401) { if (error.response?.status === 401) {
const url = error.config?.url || ''; const url = error.config?.url || '';
// Don't redirect on auth endpoints — they legitimately return 401 // Don't redirect on auth endpoints — they legitimately return 401
const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password', '/auth/passkeys/login/begin', '/auth/passkeys/login/complete']; const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password'];
if (!authEndpoints.some(ep => url.startsWith(ep))) { if (!authEndpoints.some(ep => url.startsWith(ep))) {
window.location.href = '/login'; window.location.href = '/login';
} }

View File

@ -137,14 +137,12 @@ export interface Reminder {
export interface Project { export interface Project {
id: number; id: number;
user_id: number;
name: string; name: string;
description?: string; description?: string;
status: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold'; status: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold';
color?: string; color?: string;
due_date?: string; due_date?: string;
is_tracked: boolean; is_tracked: boolean;
member_count: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
tasks: ProjectTask[]; tasks: ProjectTask[];
@ -164,21 +162,10 @@ export interface TrackedTask {
export interface TaskComment { export interface TaskComment {
id: number; id: number;
task_id: number; task_id: number;
user_id?: number | null;
author_name?: string | null;
content: string; content: string;
created_at: string; created_at: string;
} }
export interface TaskAssignment {
id: number;
task_id: number;
user_id: number;
assigned_by: number;
user_name?: string | null;
created_at: string;
}
export interface ProjectTask { export interface ProjectTask {
id: number; id: number;
project_id: number; project_id: number;
@ -190,12 +177,10 @@ export interface ProjectTask {
due_date?: string; due_date?: string;
person_id?: number; person_id?: number;
sort_order: number; sort_order: number;
version: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
subtasks: ProjectTask[]; subtasks: ProjectTask[];
comments: TaskComment[]; comments: TaskComment[];
assignments: TaskAssignment[];
} }
export interface Person { export interface Person {
@ -243,23 +228,6 @@ export interface AuthStatus {
username: string | null; username: string | null;
registration_open: boolean; registration_open: boolean;
is_locked: boolean; is_locked: boolean;
has_passkeys: boolean;
passwordless_enabled: boolean;
allow_passwordless: boolean;
}
export interface PasskeyCredential {
id: number;
name: string;
created_at: string | null;
last_used_at: string | null;
backed_up: boolean;
}
export interface PasskeyLoginResponse {
authenticated?: true;
must_change_password?: boolean;
unlocked?: boolean;
} }
// Login response discriminated union // Login response discriminated union
@ -296,7 +264,6 @@ export interface AdminUser {
last_password_change_at: string | null; last_password_change_at: string | null;
totp_enabled: boolean; totp_enabled: boolean;
mfa_enforce_pending: boolean; mfa_enforce_pending: boolean;
passwordless_enabled: boolean;
created_at: string; created_at: string;
} }
@ -311,7 +278,6 @@ export interface AdminUserDetail extends AdminUser {
export interface SystemConfig { export interface SystemConfig {
allow_registration: boolean; allow_registration: boolean;
enforce_mfa_new_users: boolean; enforce_mfa_new_users: boolean;
allow_passwordless: boolean;
} }
export interface AuditLogEntry { export interface AuditLogEntry {
@ -557,22 +523,3 @@ export interface EventLockInfo {
expires_at: string | null; expires_at: string | null;
is_permanent: boolean; is_permanent: boolean;
} }
// ── Project Sharing ──────────────────────────────────────────────
export type ProjectPermission = 'read_only' | 'create_modify';
export interface ProjectMember {
id: number;
project_id: number;
user_id: number;
invited_by: number;
permission: ProjectPermission;
status: 'pending' | 'accepted' | 'rejected';
source: 'invited' | 'auto_assigned';
user_name?: string | null;
inviter_name?: string | null;
created_at: string;
updated_at: string;
accepted_at?: string | null;
}