Compare commits
No commits in common. "main" and "feature/event-invitations" have entirely different histories.
main
...
feature/ev
@ -21,15 +21,6 @@ ENVIRONMENT=development
|
||||
# Timezone (applied to backend + db containers via env_file)
|
||||
TZ=Australia/Perth
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# WebAuthn / Passkeys
|
||||
# ──────────────────────────────────────
|
||||
# REQUIRED for passkeys to work. Must match the domain users access UMBRA on.
|
||||
# RP_ID = eTLD+1 (no scheme, no port). ORIGIN = full origin with scheme.
|
||||
WEBAUTHN_RP_ID=umbra.example.com
|
||||
WEBAUTHN_RP_NAME=UMBRA
|
||||
WEBAUTHN_ORIGIN=https://umbra.example.com
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Integrations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
name: Build and Deploy UMBRA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY_HOST }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||
|
||||
- name: Build and push backend
|
||||
run: |
|
||||
docker build --pull \
|
||||
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest \
|
||||
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }} \
|
||||
./backend
|
||||
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest
|
||||
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }}
|
||||
|
||||
- name: Build and push frontend
|
||||
run: |
|
||||
docker build --pull \
|
||||
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest \
|
||||
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }} \
|
||||
./frontend
|
||||
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest
|
||||
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }}
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
# Spawn a short-lived container that mounts the host deploy path
|
||||
# and runs compose commands against the host Docker daemon.
|
||||
# DEPLOY_PATH is a Gitea variable — update it when moving hosts.
|
||||
docker run --rm \
|
||||
--network host \
|
||||
--security-opt label:disable \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ${{ vars.DEPLOY_PATH }}:/deploy \
|
||||
-w /deploy \
|
||||
docker:27-cli sh -c "
|
||||
docker compose -p umbra --env-file stack.env pull backend frontend &&
|
||||
docker compose -p umbra --env-file stack.env up -d db backend frontend
|
||||
"
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
echo "Waiting for services to start..."
|
||||
sleep 30
|
||||
curl -f http://localhost:${{ vars.DEPLOY_PORT }}/health || exit 1
|
||||
|
||||
- name: Prune old images
|
||||
if: success()
|
||||
run: docker image prune -f
|
||||
|
||||
- name: Notify success
|
||||
if: success()
|
||||
run: |
|
||||
curl -s \
|
||||
-H "Title: UMBRA Deploy Success" \
|
||||
-H "Tags: white_check_mark" \
|
||||
--data-binary @- https://ntfy.ghost6.xyz/claude <<'NTFY_EOF'
|
||||
Build ${{ github.sha }} deployed successfully to umbra.ghost6.xyz.
|
||||
Triggered by push to main.
|
||||
NTFY_EOF
|
||||
|
||||
- name: Notify failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -s \
|
||||
-H "Title: UMBRA Deploy FAILED" \
|
||||
-H "Tags: fire" \
|
||||
-H "Priority: high" \
|
||||
--data-binary @- https://ntfy.ghost6.xyz/claude <<'NTFY_EOF'
|
||||
Deploy failed for commit ${{ github.sha }}.
|
||||
Check Gitea Actions logs at git.sentinelforest.xyz.
|
||||
NTFY_EOF
|
||||
86
README.md
86
README.md
@ -4,20 +4,18 @@ A self-hosted, multi-user life administration app with a dark-themed UI and role
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-user RBAC** — Admin and standard user roles, per-user data isolation, admin portal with IAM, system config, and audit logs
|
||||
- **Dashboard** — Contextual greeting, week timeline, stat cards, upcoming events, weather widget, day briefing
|
||||
- **Todos** — Task management with priorities, due dates, recurrence, and grouped sections (overdue/today/upcoming)
|
||||
- **Calendar** — Multi-calendar system with month/week/day views, recurring events, drag-and-drop, event templates, shared calendars with permission-based access
|
||||
- **Shared Calendars** — Invite connections to calendars with granular permissions (read-only, create/modify, full access). Event locking prevents concurrent edits. Near-real-time sync via 5s polling
|
||||
- **Event Invitations** — Invite connections to individual events with RSVP (accept/tentative/decline), per-occurrence status overrides for recurring events, display calendar assignment, and optional edit access via can_modify toggle
|
||||
- **Projects** — Project boards with kanban view, nested tasks/subtasks, comments, progress tracking
|
||||
- **Reminders** — Time-based reminders with snooze, dismiss, recurrence, and real-time alert notifications (dashboard banner + toasts)
|
||||
- **People & Connections** — Contact directory with avatar initials, favourites, birthday tracking. Social connections via umbral name search with bidirectional Person records on accept
|
||||
- **Locations** — Location management with OSM search integration, category filtering, frequent locations
|
||||
- **Weather** — Dashboard weather widget with temperature, conditions, and contextual rain warnings
|
||||
- **Settings** — Accent color picker (8 presets), first day of week, weather city, ntfy push notifications, TOTP two-factor auth, auto-lock, password management
|
||||
- **Notifications** — In-app notification centre with toast popups, plus ntfy push notifications for reminders (configurable per-user)
|
||||
- **Admin Portal** — User management (create, delete, activate/deactivate, role assignment, password reset), system configuration (open registration, MFA enforcement), audit log viewer
|
||||
- **Multi-user RBAC** - Admin and standard user roles, per-user data isolation, admin portal with IAM, system config, and audit logs
|
||||
- **Dashboard** - Contextual greeting, week timeline, stat cards, upcoming events, weather widget, day briefing
|
||||
- **Todos** - Task management with priorities, due dates, recurrence, and grouped sections (overdue/today/upcoming)
|
||||
- **Calendar** - Multi-calendar system with month/week/day views, recurring events, drag-and-drop, event templates
|
||||
- **Projects** - Project boards with kanban view, nested tasks/subtasks, comments, progress tracking
|
||||
- **Reminders** - Time-based reminders with snooze, dismiss, recurrence, and real-time alert notifications (dashboard banner + toasts)
|
||||
- **People** - Contact directory with avatar initials, favourites, birthday tracking, category filtering
|
||||
- **Locations** - Location management with OSM search integration, category filtering, frequent locations
|
||||
- **Weather** - Dashboard weather widget with temperature, conditions, and contextual rain warnings
|
||||
- **Settings** - Accent color picker (8 presets), first day of week, weather city, ntfy push notifications, TOTP two-factor auth, auto-lock, password management
|
||||
- **Notifications** - ntfy push notifications for reminders (configurable per-user)
|
||||
- **Admin Portal** - User management (create, delete, activate/deactivate, role assignment, password reset), system configuration (open registration, MFA enforcement), audit log viewer
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@ -28,7 +26,7 @@ A self-hosted, multi-user life administration app with a dark-themed UI and role
|
||||
| Fonts | Sora (headings), DM Sans (body) via Google Fonts |
|
||||
| State | TanStack Query v5, React Router v6 |
|
||||
| Backend | FastAPI, Python 3.12, Pydantic v2 |
|
||||
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic (56 migrations) |
|
||||
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic (37 migrations) |
|
||||
| Auth | Argon2id hashing, DB-backed sessions (signed httpOnly cookies), TOTP MFA, CSRF middleware, role-based access control |
|
||||
| Scheduler | APScheduler (async) for ntfy notification dispatch |
|
||||
| Deployment | Docker Compose (3 services), Nginx reverse proxy |
|
||||
@ -110,7 +108,27 @@ A self-hosted, multi-user life administration app with a dark-themed UI and role
|
||||
|
||||
## Security
|
||||
|
||||
UMBRA is hardened by default with multi-user data isolation, role-based access control, CSRF protection, non-root containers, rate limiting, account lockout, optional TOTP MFA, and secure session management. Multiple penetration tests have been conducted with no exploitable findings.
|
||||
### Hardened by default
|
||||
|
||||
- **Multi-user data isolation** — all resources scoped by `user_id` with per-query filtering; pentest-verified (51+ test cases, 0 exploitable IDOR findings)
|
||||
- **Role-based access control** — `admin` and `standard` roles with `require_admin` dependency on all admin endpoints
|
||||
- **CSRF protection** — global `CSRFHeaderMiddleware` requires `X-Requested-With: XMLHttpRequest` on all mutating requests
|
||||
- **Input validation** — `extra="forbid"` on all Pydantic schemas prevents mass-assignment; `max_length` on all string fields; `ge=1, le=2147483647` on path IDs
|
||||
- **Non-root containers** — both backend (`appuser:1000`) and frontend (`nginx-unprivileged`) run as non-root
|
||||
- **No external backend port** — port 8000 is internal-only; all traffic flows through nginx
|
||||
- **Server version suppression** — `server_tokens off` (nginx) and `--no-server-header` (uvicorn)
|
||||
- **Rate limiting** — nginx `limit_req_zone` (10 req/min) on `/api/auth/login` (burst=5), `/verify-password` (burst=5), `/change-password` (burst=5), `/totp-verify` (burst=5), `/setup` (burst=3)
|
||||
- **DB-backed account lockout** — 10 failed attempts triggers 30-minute lock per account
|
||||
- **Inactive user blocking** — disabled accounts rejected at login (HTTP 403) without session creation, lockout reset, or last_login_at update
|
||||
- **Timing-safe login** — dummy Argon2id hash for non-existent users prevents username enumeration
|
||||
- **Password reuse prevention** — change-password endpoint rejects same password as old
|
||||
- **Dotfile blocking** — `/.env`, `/.git/config`, etc. return 404 (`.well-known` preserved for ACME)
|
||||
- **CSP headers** — Content-Security-Policy on all responses, scoped for Google Fonts
|
||||
- **CORS** — configurable origins with explicit method/header allowlists
|
||||
- **API docs disabled in production** — Swagger/ReDoc/OpenAPI only available when `ENVIRONMENT=development`
|
||||
- **Argon2id password hashing** with transparent bcrypt migration on first login
|
||||
- **DB-backed sessions** — revocable, with signed itsdangerous httpOnly cookies, 7-day sliding window with 30-day hard ceiling
|
||||
- **Optional TOTP MFA** — authenticator app support with backup codes, admin-enforced MFA for new users
|
||||
|
||||
### Production Hardening
|
||||
|
||||
@ -119,15 +137,21 @@ Before deploying to production, generate secure values for your `.env`:
|
||||
```bash
|
||||
# Generate a secure SECRET_KEY (64-char hex string)
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
# or: openssl rand -hex 32
|
||||
|
||||
# Generate a secure database password
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(24))"
|
||||
# or: openssl rand -base64 24
|
||||
|
||||
# Set ENVIRONMENT to disable Swagger/ReDoc and auto-enable secure cookies
|
||||
ENVIRONMENT=production
|
||||
```
|
||||
|
||||
Additionally for production:
|
||||
- Set `ENVIRONMENT=production` — disables API docs and auto-enables HTTPS-only session cookies
|
||||
- Place behind a reverse proxy with TLS termination (e.g., Caddy, Traefik, or nginx with Let's Encrypt)
|
||||
- Set `ENVIRONMENT=production` — this disables API docs and auto-enables HTTPS-only session cookies (`COOKIE_SECURE` derives from `ENVIRONMENT`; override with `COOKIE_SECURE=false` if running non-TLS prod behind a proxy)
|
||||
- Set `CORS_ORIGINS` to your actual domain (e.g., `https://umbra.example.com`)
|
||||
- Consider adding HSTS headers at the TLS-terminating proxy layer
|
||||
|
||||
## API Overview
|
||||
|
||||
@ -139,11 +163,9 @@ All endpoints require authentication (signed session cookie) except auth routes
|
||||
| `/api/auth/*` | Login, logout, setup, register, status, password change, TOTP MFA |
|
||||
| `/api/admin/*` | User management, system config, audit logs (admin only) |
|
||||
| `/api/todos/*` | Todos CRUD + toggle completion |
|
||||
| `/api/events/*` | Calendar events CRUD (incl. recurring) + event invitations |
|
||||
| `/api/event-invitations/*` | Invitation responses, per-occurrence overrides, can_modify toggle, leave |
|
||||
| `/api/events/*` | Calendar events CRUD (incl. recurring) |
|
||||
| `/api/event-templates/*` | Event templates CRUD |
|
||||
| `/api/calendars/*` | User calendars CRUD + visibility |
|
||||
| `/api/shared-calendars/*` | Shared calendar management, invitations, permissions, event locking |
|
||||
| `/api/reminders/*` | Reminders CRUD + dismiss + snooze + due alerts |
|
||||
| `/api/projects/*` | Projects + nested tasks + comments CRUD |
|
||||
| `/api/people/*` | People CRUD |
|
||||
@ -152,8 +174,6 @@ All endpoints require authentication (signed session cookie) except auth routes
|
||||
| `/api/dashboard` | Dashboard aggregation |
|
||||
| `/api/upcoming` | Unified upcoming items feed |
|
||||
| `/api/weather/*` | Weather data proxy |
|
||||
| `/api/connections/*` | Social connections (search, request, respond, manage) |
|
||||
| `/api/notifications/*` | In-app notifications (list, read, delete) |
|
||||
|
||||
API documentation is available at `/api/docs` (Swagger UI) when `ENVIRONMENT=development`.
|
||||
|
||||
@ -195,15 +215,15 @@ umbra/
|
||||
│ ├── Dockerfile
|
||||
│ ├── requirements.txt
|
||||
│ ├── alembic.ini
|
||||
│ ├── alembic/versions/ # 56 migrations (001–056)
|
||||
│ ├── alembic/versions/ # 37 migrations (001–037)
|
||||
│ └── app/
|
||||
│ ├── main.py # FastAPI app, CSRF middleware, router registration, health endpoint
|
||||
│ ├── config.py # Pydantic BaseSettings (DATABASE_URL, SECRET_KEY, CORS, etc.)
|
||||
│ ├── database.py # Async SQLAlchemy engine + session factory
|
||||
│ ├── models/ # 20 SQLAlchemy ORM models (incl. User, UserSession, EventInvitation, CalendarMember)
|
||||
│ ├── schemas/ # 14 Pydantic v2 request/response schema modules
|
||||
│ ├── routers/ # 16 API route handlers (incl. auth, admin, event_invitations, shared_calendars)
|
||||
│ ├── services/ # Auth (Argon2id), recurrence, TOTP, ntfy, audit, calendar_sharing, event_invitation, notification
|
||||
│ ├── models/ # 18 SQLAlchemy ORM models (incl. User, UserSession, SystemConfig, AuditLog)
|
||||
│ ├── schemas/ # 13 Pydantic v2 request/response schema modules (incl. admin)
|
||||
│ ├── routers/ # 14 API route handlers (incl. auth, admin, totp)
|
||||
│ ├── services/ # Auth (Argon2id), recurrence, TOTP, ntfy, audit
|
||||
│ └── jobs/ # APScheduler notification dispatch
|
||||
└── frontend/
|
||||
├── Dockerfile
|
||||
@ -213,22 +233,20 @@ umbra/
|
||||
└── src/
|
||||
├── App.tsx # Routes, ProtectedRoute, AdminRoute auth guards
|
||||
├── lib/ # api.ts (axios + 401 interceptor), date-utils.ts, utils.ts
|
||||
├── hooks/ # useAuth, useAdmin, useSettings, useTheme, useCalendars, useConfirmAction, useConnections, useNotifications, useEventInvitations
|
||||
├── hooks/ # useAuth, useAdmin, useSettings, useTheme, useCalendars, useConfirmAction, useCategoryOrder, useTableVisibility
|
||||
├── types/ # TypeScript interfaces
|
||||
└── components/
|
||||
├── ui/ # 18 base components (Button, Dialog, Sheet, Card, Input, Select, Switch, DatePicker, ...)
|
||||
├── ui/ # 17 base components (Button, Dialog, Sheet, Card, Input, Select, Switch, etc.)
|
||||
├── shared/ # EntityTable, EntityDetailPanel, CategoryFilterBar, CategoryAutocomplete, CopyableField
|
||||
├── layout/ # AppLayout, Sidebar, LockOverlay
|
||||
├── auth/ # LockScreen, AmbientBackground
|
||||
├── admin/ # AdminPortal, IAMPage, ConfigPage, AdminDashboardPage, CreateUserDialog, UserActionsMenu
|
||||
├── admin/ # AdminPortal, IAMPage, ConfigPage, AdminDashboardPage, CreateUserDialog, UserActionsMenu, UserDetailSection
|
||||
├── dashboard/ # DashboardPage + 8 widgets
|
||||
├── calendar/ # CalendarPage, CalendarSidebar, EventDetailPanel, InviteeSection, LeaveEventDialog, CalendarForm, EventForm, TemplateForm
|
||||
├── todos/ # TodosPage, TodoList, TodoItem, TodoForm, TodoDetailPanel
|
||||
├── calendar/ # CalendarPage, CalendarSidebar, CalendarForm, EventForm, TemplateForm
|
||||
├── todos/ # TodosPage, TodoList, TodoItem, TodoForm
|
||||
├── reminders/ # RemindersPage, ReminderList, ReminderItem, ReminderForm, SnoozeDropdown, AlertBanner
|
||||
├── projects/ # ProjectsPage, ProjectCard, ProjectDetail, ProjectForm, KanbanBoard, TaskRow, TaskForm, TaskDetailPanel
|
||||
├── people/ # PeoplePage, PersonForm
|
||||
├── connections/ # ConnectionSearch, ConnectionRequestCard, ConnectionsTab
|
||||
├── notifications/ # NotificationsPage, NotificationToaster
|
||||
├── locations/ # LocationsPage, LocationForm
|
||||
└── settings/ # SettingsPage, NtfySettingsSection, TotpSetupSection
|
||||
```
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
log:
|
||||
level: info
|
||||
|
||||
runner:
|
||||
capacity: 1
|
||||
timeout: 3h
|
||||
insecure: false
|
||||
|
||||
cache:
|
||||
enabled: false
|
||||
|
||||
container:
|
||||
network: host
|
||||
privileged: false
|
||||
options: "--security-opt label:disable"
|
||||
valid_volumes:
|
||||
- "**"
|
||||
|
||||
host:
|
||||
workdir_parent: /tmp/act_runner
|
||||
@ -1,14 +1,2 @@
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
ENVIRONMENT=development
|
||||
|
||||
# Public-facing URL (used for ntfy click links, CORS derivation)
|
||||
UMBRA_URL=http://localhost
|
||||
|
||||
# WebAuthn / Passkey authentication
|
||||
# RP_ID must be the eTLD+1 domain of the live site (e.g. umbra.ghost6.xyz)
|
||||
# ORIGIN must include the scheme (https://)
|
||||
# These defaults work for local development; override in production .env
|
||||
WEBAUTHN_RP_ID=localhost
|
||||
WEBAUTHN_RP_NAME=UMBRA
|
||||
WEBAUTHN_ORIGIN=http://localhost
|
||||
|
||||
@ -1,37 +1,39 @@
|
||||
# UMBRA Backend
|
||||
|
||||
FastAPI backend for the UMBRA life management application with async SQLAlchemy, PostgreSQL, multi-user RBAC, and comprehensive security.
|
||||
A complete FastAPI backend for the UMBRA application with async SQLAlchemy, PostgreSQL, authentication, and comprehensive CRUD operations.
|
||||
|
||||
## Features
|
||||
|
||||
- **FastAPI** with async/await and Pydantic v2
|
||||
- **SQLAlchemy 2.0** async engine with `Mapped[]` types
|
||||
- **PostgreSQL 16** via asyncpg
|
||||
- **Alembic** database migrations (001-061)
|
||||
- **Authentication**: Argon2id passwords + signed httpOnly cookies + optional TOTP MFA + passkey (WebAuthn/FIDO2)
|
||||
- **Multi-user RBAC**: admin/standard roles, per-user resource scoping
|
||||
- **Session management**: DB-backed sessions, sliding window expiry, concurrent session cap
|
||||
- **Account security**: Account lockout (10 failures = 30-min lock), CSRF protection, rate limiting
|
||||
- **APScheduler** for background notification dispatch
|
||||
- **FastAPI** with async/await support
|
||||
- **SQLAlchemy 2.0** with async engine
|
||||
- **PostgreSQL** with asyncpg driver
|
||||
- **Alembic** for database migrations
|
||||
- **bcrypt** for password hashing
|
||||
- **itsdangerous** for session management
|
||||
- **PIN-based authentication** with secure session cookies
|
||||
- **Full CRUD operations** for all entities
|
||||
- **Dashboard** with aggregated data
|
||||
- **CORS enabled** for frontend integration
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── alembic/versions/ # 61 database migrations
|
||||
├── alembic/ # Database migrations
|
||||
│ ├── versions/ # Migration files
|
||||
│ ├── env.py # Alembic environment
|
||||
│ └── script.py.mako # Migration template
|
||||
├── app/
|
||||
│ ├── models/ # 21 SQLAlchemy 2.0 models
|
||||
│ ├── schemas/ # 14 Pydantic v2 schema modules
|
||||
│ ├── routers/ # 17 API routers
|
||||
│ ├── services/ # Auth, session, passkey, TOTP, audit, recurrence, etc.
|
||||
│ ├── jobs/ # APScheduler notification dispatch
|
||||
│ ├── config.py # Pydantic Settings (env vars)
|
||||
│ ├── database.py # Async engine + session factory
|
||||
│ └── main.py # FastAPI app + CSRF middleware
|
||||
├── requirements.txt
|
||||
├── Dockerfile
|
||||
├── alembic.ini
|
||||
└── start.sh
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── schemas/ # Pydantic schemas
|
||||
│ ├── routers/ # API route handlers
|
||||
│ ├── config.py # Configuration
|
||||
│ ├── database.py # Database setup
|
||||
│ └── main.py # FastAPI application
|
||||
├── requirements.txt # Python dependencies
|
||||
├── Dockerfile # Docker configuration
|
||||
├── alembic.ini # Alembic configuration
|
||||
└── start.sh # Startup script
|
||||
```
|
||||
|
||||
## Setup
|
||||
@ -39,87 +41,160 @@ backend/
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
Create a `.env` file:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/umbra
|
||||
SECRET_KEY=generate-a-strong-random-key
|
||||
ENVIRONMENT=production
|
||||
|
||||
# WebAuthn / Passkeys (required for passkey auth)
|
||||
WEBAUTHN_RP_ID=your-domain.com
|
||||
WEBAUTHN_RP_NAME=UMBRA
|
||||
WEBAUTHN_ORIGIN=https://your-domain.com
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
|
||||
SECRET_KEY=your-secret-key-change-in-production
|
||||
```
|
||||
|
||||
### 3. Run Migrations
|
||||
### 3. Create Database
|
||||
|
||||
```bash
|
||||
createdb umbra
|
||||
```
|
||||
|
||||
### 4. Run Migrations
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### 4. Start Server
|
||||
### 5. Start Server
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
# Using the start script
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
|
||||
# Or directly with uvicorn
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
## API Routes
|
||||
The API will be available at `http://localhost:8000`
|
||||
|
||||
All routes require authentication (signed session cookie) except `/api/auth/*` and `/health`.
|
||||
## API Documentation
|
||||
|
||||
| Prefix | Description |
|
||||
|--------|-------------|
|
||||
| `/api/auth` | Login, logout, register, setup, status, password, TOTP, passkeys |
|
||||
| `/api/admin` | User management, system config, audit logs (admin only) |
|
||||
| `/api/todos` | Task management with categories and priorities |
|
||||
| `/api/events` | Calendar events with recurrence support |
|
||||
| `/api/event-invitations` | Event invitation RSVP and management |
|
||||
| `/api/event-templates` | Reusable event templates |
|
||||
| `/api/calendars` | Calendar CRUD |
|
||||
| `/api/shared-calendars` | Calendar sharing with permission levels |
|
||||
| `/api/reminders` | Reminder management with snooze |
|
||||
| `/api/projects` | Projects with tasks, comments, and collaboration |
|
||||
| `/api/people` | Contact management |
|
||||
| `/api/locations` | Location management |
|
||||
| `/api/connections` | User connections (friend requests) |
|
||||
| `/api/notifications` | In-app notification centre |
|
||||
| `/api/settings` | User preferences and ntfy configuration |
|
||||
| `/api/dashboard` | Aggregated dashboard data |
|
||||
| `/api/weather` | Weather widget data |
|
||||
Interactive API documentation is available at:
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
|
||||
## Authentication
|
||||
## API Endpoints
|
||||
|
||||
UMBRA supports three authentication methods:
|
||||
### Authentication
|
||||
- `POST /api/auth/setup` - Initial PIN setup
|
||||
- `POST /api/auth/login` - Login with PIN
|
||||
- `POST /api/auth/logout` - Logout
|
||||
- `GET /api/auth/status` - Check auth status
|
||||
|
||||
1. **Password** (Argon2id) - Primary login method
|
||||
2. **TOTP MFA** - Optional second factor via authenticator apps
|
||||
3. **Passkeys** (WebAuthn/FIDO2) - Optional passwordless login via biometrics, security keys, or password managers
|
||||
### Todos
|
||||
- `GET /api/todos` - List todos (with filters)
|
||||
- `POST /api/todos` - Create todo
|
||||
- `GET /api/todos/{id}` - Get todo
|
||||
- `PUT /api/todos/{id}` - Update todo
|
||||
- `DELETE /api/todos/{id}` - Delete todo
|
||||
- `PATCH /api/todos/{id}/toggle` - Toggle completion
|
||||
|
||||
Passkey login bypasses TOTP (a passkey is inherently two-factor: possession + biometric/PIN).
|
||||
### Calendar Events
|
||||
- `GET /api/events` - List events (with date range)
|
||||
- `POST /api/events` - Create event
|
||||
- `GET /api/events/{id}` - Get event
|
||||
- `PUT /api/events/{id}` - Update event
|
||||
- `DELETE /api/events/{id}` - Delete event
|
||||
|
||||
## Security
|
||||
### Reminders
|
||||
- `GET /api/reminders` - List reminders (with filters)
|
||||
- `POST /api/reminders` - Create reminder
|
||||
- `GET /api/reminders/{id}` - Get reminder
|
||||
- `PUT /api/reminders/{id}` - Update reminder
|
||||
- `DELETE /api/reminders/{id}` - Delete reminder
|
||||
- `PATCH /api/reminders/{id}/dismiss` - Dismiss reminder
|
||||
|
||||
- CSRF protection via `X-Requested-With` header middleware
|
||||
- All Pydantic schemas use `extra="forbid"` (mass-assignment prevention)
|
||||
- Nginx rate limiting on auth, registration, and admin endpoints
|
||||
- DB-backed account lockout after 10 failed attempts
|
||||
- Timing-safe dummy hash for non-existent users (prevents enumeration)
|
||||
- SSRF validation on ntfy webhook URLs
|
||||
- Naive datetimes throughout (Docker runs UTC)
|
||||
### Projects
|
||||
- `GET /api/projects` - List projects
|
||||
- `POST /api/projects` - Create project
|
||||
- `GET /api/projects/{id}` - Get project
|
||||
- `PUT /api/projects/{id}` - Update project
|
||||
- `DELETE /api/projects/{id}` - Delete project
|
||||
- `GET /api/projects/{id}/tasks` - List project tasks
|
||||
- `POST /api/projects/{id}/tasks` - Create project task
|
||||
- `PUT /api/projects/{id}/tasks/{task_id}` - Update task
|
||||
- `DELETE /api/projects/{id}/tasks/{task_id}` - Delete task
|
||||
|
||||
### People
|
||||
- `GET /api/people` - List people (with search)
|
||||
- `POST /api/people` - Create person
|
||||
- `GET /api/people/{id}` - Get person
|
||||
- `PUT /api/people/{id}` - Update person
|
||||
- `DELETE /api/people/{id}` - Delete person
|
||||
|
||||
### Locations
|
||||
- `GET /api/locations` - List locations (with category filter)
|
||||
- `POST /api/locations` - Create location
|
||||
- `GET /api/locations/{id}` - Get location
|
||||
- `PUT /api/locations/{id}` - Update location
|
||||
- `DELETE /api/locations/{id}` - Delete location
|
||||
|
||||
### Settings
|
||||
- `GET /api/settings` - Get settings
|
||||
- `PUT /api/settings` - Update settings
|
||||
- `PUT /api/settings/pin` - Change PIN
|
||||
|
||||
### Dashboard
|
||||
- `GET /api/dashboard` - Get dashboard data
|
||||
- `GET /api/upcoming?days=7` - Get upcoming items
|
||||
|
||||
## Database Schema
|
||||
|
||||
The application uses the following tables:
|
||||
- `settings` - Application settings and PIN
|
||||
- `todos` - Task items
|
||||
- `calendar_events` - Calendar events
|
||||
- `reminders` - Reminders
|
||||
- `projects` - Projects
|
||||
- `project_tasks` - Tasks within projects
|
||||
- `people` - Contacts/people
|
||||
- `locations` - Physical locations
|
||||
|
||||
## Docker
|
||||
|
||||
The backend runs as non-root `appuser` in `python:3.12-slim`:
|
||||
Build and run with Docker:
|
||||
|
||||
```bash
|
||||
docker build -t umbra-backend .
|
||||
docker run -p 8000:8000 --env-file .env umbra-backend
|
||||
docker run -p 8000:8000 -e DATABASE_URL=... -e SECRET_KEY=... umbra-backend
|
||||
```
|
||||
|
||||
In production, use Docker Compose (see root `docker-compose.yaml`).
|
||||
## Development
|
||||
|
||||
### Create New Migration
|
||||
|
||||
```bash
|
||||
alembic revision --autogenerate -m "Description of changes"
|
||||
```
|
||||
|
||||
### Apply Migrations
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### Rollback Migration
|
||||
|
||||
```bash
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Change `SECRET_KEY` in production
|
||||
- Use strong PINs (minimum 4 digits recommended)
|
||||
- Session cookies are httpOnly and last 30 days
|
||||
- All API endpoints (except auth) require authentication
|
||||
- PINs are hashed with bcrypt before storage
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
"""project collab prep: indexes, task version, comment user_id
|
||||
|
||||
Revision ID: 057
|
||||
Revises: 056
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "057"
|
||||
down_revision = "056"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1a. Performance indexes for project_tasks
|
||||
# Use IF NOT EXISTS to handle indexes that may already exist on the DB
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_id ON project_tasks (project_id)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_parent_task_id ON project_tasks (parent_task_id) WHERE parent_task_id IS NOT NULL")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_updated ON project_tasks (project_id, updated_at DESC)")
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_projects_user_updated ON projects (user_id, updated_at DESC)")
|
||||
|
||||
# 1b. Add user_id to task_comments for multi-user attribution
|
||||
op.add_column(
|
||||
"task_comments",
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
)
|
||||
|
||||
# 1c. Add version column to project_tasks for optimistic locking
|
||||
op.add_column(
|
||||
"project_tasks",
|
||||
sa.Column("version", sa.Integer(), server_default="1", nullable=False),
|
||||
)
|
||||
|
||||
# Calendar delta polling index (Phase 4 prep)
|
||||
op.execute("CREATE INDEX IF NOT EXISTS ix_events_calendar_updated ON calendar_events (calendar_id, updated_at DESC)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_events_calendar_updated", table_name="calendar_events")
|
||||
op.drop_column("project_tasks", "version")
|
||||
op.drop_column("task_comments", "user_id")
|
||||
op.drop_index("ix_projects_user_updated", table_name="projects")
|
||||
op.drop_index("ix_project_tasks_project_updated", table_name="project_tasks")
|
||||
op.drop_index("ix_project_tasks_parent_task_id", table_name="project_tasks")
|
||||
op.drop_index("ix_project_tasks_project_id", table_name="project_tasks")
|
||||
@ -1,45 +0,0 @@
|
||||
"""add project_members table
|
||||
|
||||
Revision ID: 058
|
||||
Revises: 057
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "058"
|
||||
down_revision = "057"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"project_members",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("project_id", sa.Integer(), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("invited_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("permission", sa.String(20), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("source", sa.String(20), nullable=False, server_default="invited"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
sa.UniqueConstraint("project_id", "user_id", name="uq_project_members_proj_user"),
|
||||
sa.CheckConstraint("permission IN ('read_only', 'create_modify')", name="ck_project_members_permission"),
|
||||
sa.CheckConstraint("status IN ('pending', 'accepted', 'rejected')", name="ck_project_members_status"),
|
||||
sa.CheckConstraint("source IN ('invited', 'auto_assigned')", name="ck_project_members_source"),
|
||||
)
|
||||
op.create_index("ix_project_members_user_id", "project_members", ["user_id"])
|
||||
op.create_index("ix_project_members_project_id", "project_members", ["project_id"])
|
||||
op.create_index("ix_project_members_status", "project_members", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_project_members_status", table_name="project_members")
|
||||
op.drop_index("ix_project_members_project_id", table_name="project_members")
|
||||
op.drop_index("ix_project_members_user_id", table_name="project_members")
|
||||
op.drop_table("project_members")
|
||||
@ -1,35 +0,0 @@
|
||||
"""add project_task_assignments table
|
||||
|
||||
Revision ID: 059
|
||||
Revises: 058
|
||||
Create Date: 2025-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "059"
|
||||
down_revision = "058"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"project_task_assignments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("task_id", sa.Integer(), sa.ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("assigned_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("task_id", "user_id", name="uq_task_assignments_task_user"),
|
||||
)
|
||||
op.create_index("ix_task_assignments_task_id", "project_task_assignments", ["task_id"])
|
||||
op.create_index("ix_task_assignments_user_id", "project_task_assignments", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_task_assignments_user_id", table_name="project_task_assignments")
|
||||
op.drop_index("ix_task_assignments_task_id", table_name="project_task_assignments")
|
||||
op.drop_table("project_task_assignments")
|
||||
@ -1,36 +0,0 @@
|
||||
"""Expand notification type CHECK for project invite types
|
||||
|
||||
Revision ID: 060
|
||||
Revises: 059
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "060"
|
||||
down_revision = "059"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_OLD_TYPES = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||
"event_invite", "event_invite_response",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
_NEW_TYPES = _OLD_TYPES + (
|
||||
"project_invite", "project_invite_accepted", "project_invite_rejected",
|
||||
"task_assigned",
|
||||
)
|
||||
|
||||
|
||||
def _check_sql(types: tuple) -> str:
|
||||
return f"type IN ({', '.join(repr(t) for t in types)})"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_NEW_TYPES))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_OLD_TYPES))
|
||||
@ -1,40 +0,0 @@
|
||||
"""Add passkey_credentials table for WebAuthn/FIDO2 authentication
|
||||
|
||||
Revision ID: 061
|
||||
Revises: 060
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "061"
|
||||
down_revision = "060"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"passkey_credentials",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("credential_id", sa.Text, unique=True, nullable=False),
|
||||
sa.Column("public_key", sa.Text, nullable=False),
|
||||
sa.Column("sign_count", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("transports", sa.Text, nullable=True),
|
||||
sa.Column("backed_up", sa.Boolean, nullable=False, server_default="false"),
|
||||
sa.Column("created_at", sa.DateTime, server_default=sa.text("now()")),
|
||||
sa.Column("last_used_at", sa.DateTime, nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_passkey_credentials_user_id", "passkey_credentials", ["user_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("passkey_credentials")
|
||||
@ -1,40 +0,0 @@
|
||||
"""Passwordless login — add passwordless_enabled to users and allow_passwordless to system_config.
|
||||
|
||||
Revision ID: 062
|
||||
Revises: 061
|
||||
Create Date: 2026-03-18
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "062"
|
||||
down_revision = "061"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"passwordless_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"system_config",
|
||||
sa.Column(
|
||||
"allow_passwordless",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "passwordless_enabled")
|
||||
op.drop_column("system_config", "allow_passwordless")
|
||||
@ -30,12 +30,6 @@ class Settings(BaseSettings):
|
||||
# Concurrent session limit per user (oldest evicted when exceeded)
|
||||
MAX_SESSIONS_PER_USER: int = 10
|
||||
|
||||
# WebAuthn / Passkey configuration
|
||||
WEBAUTHN_RP_ID: str = "localhost" # eTLD+1 domain, e.g. "umbra.ghost6.xyz"
|
||||
WEBAUTHN_RP_NAME: str = "UMBRA"
|
||||
WEBAUTHN_ORIGIN: str = "http://localhost" # Full origin with scheme, e.g. "https://umbra.ghost6.xyz"
|
||||
WEBAUTHN_CHALLENGE_TTL: int = 60 # Challenge token lifetime in seconds
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
@ -53,9 +47,6 @@ class Settings(BaseSettings):
|
||||
self.CORS_ORIGINS = "http://localhost:5173"
|
||||
assert self.COOKIE_SECURE is not None # type narrowing
|
||||
assert self.CORS_ORIGINS is not None
|
||||
# Validate WebAuthn origin includes scheme (S-04)
|
||||
if not self.WEBAUTHN_ORIGIN.startswith(("http://", "https://")):
|
||||
raise ValueError("WEBAUTHN_ORIGIN must include scheme (http:// or https://)")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from app.config import settings
|
||||
from app.database import engine
|
||||
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router, passkeys as passkeys_router
|
||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router
|
||||
from app.jobs.notifications import run_notification_dispatch
|
||||
|
||||
# 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 event_lock as _event_lock_model # noqa: F401
|
||||
from app.models import event_invitation as _event_invitation_model # noqa: F401
|
||||
from app.models import passkey_credential as _passkey_credential_model # noqa: F401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -50,8 +49,6 @@ class CSRFHeaderMiddleware:
|
||||
"/api/auth/totp-verify",
|
||||
"/api/auth/totp/enforce-setup",
|
||||
"/api/auth/totp/enforce-confirm",
|
||||
"/api/auth/passkeys/login/begin",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
})
|
||||
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
@ -137,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(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
||||
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
||||
app.include_router(passkeys_router.router, prefix="/api/auth/passkeys", tags=["Passkeys"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
||||
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
||||
|
||||
@ -21,9 +21,6 @@ from app.models.user_connection import UserConnection
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
from app.models.event_invitation import EventInvitation, EventInvitationOverride
|
||||
from app.models.project_member import ProjectMember
|
||||
from app.models.project_task_assignment import ProjectTaskAssignment
|
||||
from app.models.passkey_credential import PasskeyCredential
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
@ -50,7 +47,4 @@ __all__ = [
|
||||
"EventLock",
|
||||
"EventInvitation",
|
||||
"EventInvitationOverride",
|
||||
"ProjectMember",
|
||||
"ProjectTaskAssignment",
|
||||
"PasskeyCredential",
|
||||
]
|
||||
|
||||
@ -9,8 +9,6 @@ _NOTIFICATION_TYPES = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||
"event_invite", "event_invite_response",
|
||||
"project_invite", "project_invite_accepted", "project_invite_rejected",
|
||||
"task_assigned",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class PasskeyCredential(Base):
|
||||
__tablename__ = "passkey_credentials"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# base64url-encoded credential ID (spec allows up to 1023 bytes → ~1363 chars)
|
||||
credential_id: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
# base64url-encoded COSE public key
|
||||
public_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# Authenticator sign count for clone detection
|
||||
sign_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
# User-assigned label (e.g. "MacBook Pro — Chrome")
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
# JSON array of transport hints (e.g. '["usb","hybrid"]')
|
||||
transports: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Whether the credential is backed up / synced across devices
|
||||
backed_up: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
@ -22,7 +22,6 @@ class Project(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
|
||||
tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan", passive_deletes=True, lazy="raise")
|
||||
todos: Mapped[List["Todo"]] = relationship(back_populates="project", lazy="raise")
|
||||
members: Mapped[List["ProjectMember"]] = relationship(back_populates="project", cascade="all, delete-orphan", passive_deletes=True, lazy="raise")
|
||||
# Relationships
|
||||
tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan")
|
||||
todos: Mapped[List["Todo"]] = relationship(back_populates="project")
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
from sqlalchemy import (
|
||||
CheckConstraint, DateTime, Integer, ForeignKey, Index,
|
||||
String, UniqueConstraint, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("project_id", "user_id", name="uq_project_members_proj_user"),
|
||||
CheckConstraint(
|
||||
"permission IN ('read_only', 'create_modify')",
|
||||
name="ck_project_members_permission",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'rejected')",
|
||||
name="ck_project_members_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"source IN ('invited', 'auto_assigned')",
|
||||
name="ck_project_members_source",
|
||||
),
|
||||
Index("ix_project_members_user_id", "user_id"),
|
||||
Index("ix_project_members_project_id", "project_id"),
|
||||
Index("ix_project_members_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
invited_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
permission: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
source: Mapped[str] = mapped_column(String(20), nullable=False, default="invited")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember)
|
||||
project: Mapped["Project"] = relationship(back_populates="members", lazy="raise")
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||
inviter: Mapped[Optional["User"]] = relationship(
|
||||
foreign_keys=[invited_by], lazy="raise"
|
||||
)
|
||||
@ -1,4 +1,3 @@
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import String, Text, Integer, Date, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
|
||||
from datetime import datetime, date
|
||||
@ -21,33 +20,21 @@ class ProjectTask(Base):
|
||||
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||
person_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1, server_default=sa.text("1"))
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
|
||||
project: Mapped["Project"] = sa_relationship(back_populates="tasks", lazy="raise")
|
||||
person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks", lazy="raise")
|
||||
# Relationships
|
||||
project: Mapped["Project"] = sa_relationship(back_populates="tasks")
|
||||
person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks")
|
||||
parent_task: Mapped[Optional["ProjectTask"]] = sa_relationship(
|
||||
back_populates="subtasks",
|
||||
remote_side=[id],
|
||||
lazy="raise",
|
||||
)
|
||||
subtasks: Mapped[List["ProjectTask"]] = sa_relationship(
|
||||
back_populates="parent_task",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
lazy="raise",
|
||||
)
|
||||
comments: Mapped[List["TaskComment"]] = sa_relationship(
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
lazy="raise",
|
||||
)
|
||||
assignments: Mapped[List["ProjectTaskAssignment"]] = sa_relationship(
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
lazy="raise",
|
||||
)
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
from sqlalchemy import DateTime, Integer, ForeignKey, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ProjectTaskAssignment(Base):
|
||||
__tablename__ = "project_task_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("task_id", "user_id", name="uq_task_assignments_task_user"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
assigned_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1
|
||||
task: Mapped["ProjectTask"] = relationship(back_populates="assignments", lazy="raise")
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||
assigner: Mapped["User"] = relationship(foreign_keys=[assigned_by], lazy="raise")
|
||||
@ -21,9 +21,6 @@ class SystemConfig(Base):
|
||||
enforce_mfa_new_users: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
allow_passwordless: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
default=func.now(), onupdate=func.now(), server_default=func.now()
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from sqlalchemy import Text, Integer, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
@ -12,12 +11,8 @@ class TaskComment(Base):
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(default=datetime.now)
|
||||
|
||||
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember pattern)
|
||||
task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments", lazy="raise")
|
||||
user: Mapped[Optional["User"]] = sa_relationship(lazy="raise")
|
||||
# Relationships
|
||||
task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments")
|
||||
|
||||
@ -43,11 +43,6 @@ class User(Base):
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
|
||||
# Passwordless login — requires >= 2 passkeys registered
|
||||
passwordless_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
|
||||
# Audit
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
@ -45,7 +45,6 @@ from app.schemas.admin import (
|
||||
SystemConfigUpdate,
|
||||
ToggleActiveRequest,
|
||||
ToggleMfaEnforceRequest,
|
||||
TogglePasswordlessRequest,
|
||||
UpdateUserRoleRequest,
|
||||
UserDetailResponse,
|
||||
UserListItem,
|
||||
@ -671,56 +670,6 @@ async def get_user_sharing_stats(
|
||||
"pending_invites_received": pending_received,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /users/{user_id}/passwordless
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.put("/users/{user_id}/passwordless")
|
||||
async def admin_toggle_passwordless(
|
||||
request: Request,
|
||||
data: TogglePasswordlessRequest,
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
actor: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Admin-only: disable passwordless login for a user.
|
||||
Only enabled=False is allowed — admin cannot remotely enable passwordless.
|
||||
Revokes all sessions so the user must re-authenticate.
|
||||
"""
|
||||
if data.enabled:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Admin can only disable passwordless login, not enable it",
|
||||
)
|
||||
|
||||
_guard_self_action(actor, user_id, "toggle passwordless for")
|
||||
|
||||
result = await db.execute(sa.select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if not user.passwordless_enabled:
|
||||
raise HTTPException(status_code=409, detail="Passwordless login is not enabled for this user")
|
||||
|
||||
user.passwordless_enabled = False
|
||||
|
||||
revoked = await _revoke_all_sessions(db, user_id)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="admin.passwordless_disabled",
|
||||
actor_id=actor.id,
|
||||
target_id=user_id,
|
||||
detail={"sessions_revoked": revoked, "username": user.username},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"passwordless_enabled": False, "sessions_revoked": revoked}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /config
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -767,9 +716,6 @@ async def update_system_config(
|
||||
if data.enforce_mfa_new_users is not None:
|
||||
changes["enforce_mfa_new_users"] = data.enforce_mfa_new_users
|
||||
config.enforce_mfa_new_users = data.enforce_mfa_new_users
|
||||
if data.allow_passwordless is not None:
|
||||
changes["allow_passwordless"] = data.allow_passwordless
|
||||
config.allow_passwordless = data.allow_passwordless
|
||||
|
||||
if changes:
|
||||
await log_audit_event(
|
||||
|
||||
@ -16,6 +16,7 @@ Security layers:
|
||||
4. bcrypt→Argon2id transparent upgrade on first login
|
||||
5. Role-based authorization via require_role() dependency factory
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
@ -29,7 +30,6 @@ from app.models.user import User
|
||||
from app.models.session import UserSession
|
||||
from app.models.settings import Settings
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.passkey_credential import PasskeyCredential
|
||||
from app.models.calendar import Calendar
|
||||
from app.schemas.auth import (
|
||||
SetupRequest, LoginRequest, RegisterRequest,
|
||||
@ -49,13 +49,6 @@ from app.services.auth import (
|
||||
create_mfa_enforce_token,
|
||||
)
|
||||
from app.services.audit import get_client_ip, log_audit_event
|
||||
from app.services.session import (
|
||||
set_session_cookie,
|
||||
check_account_lockout,
|
||||
record_failed_login,
|
||||
record_successful_login,
|
||||
create_db_session,
|
||||
)
|
||||
from app.config import settings as app_settings
|
||||
|
||||
router = APIRouter()
|
||||
@ -66,6 +59,22 @@ router = APIRouter()
|
||||
# is indistinguishable from a wrong-password attempt.
|
||||
_DUMMY_HASH = hash_password("timing-equalization-dummy")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cookie helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key="session",
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=app_settings.COOKIE_SECURE,
|
||||
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth dependencies — export get_current_user and get_current_settings
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -121,7 +130,7 @@ async def get_current_user(
|
||||
await db.flush()
|
||||
# Re-issue cookie with fresh signed token to reset browser max_age timer
|
||||
fresh_token = create_session_token(user_id, session_id)
|
||||
set_session_cookie(response, fresh_token)
|
||||
_set_session_cookie(response, fresh_token)
|
||||
|
||||
# Stash session on request so lock/unlock endpoints can access it
|
||||
request.state.db_session = db_session
|
||||
@ -132,7 +141,6 @@ async def get_current_user(
|
||||
lock_exempt = {
|
||||
"/api/auth/lock", "/api/auth/verify-password",
|
||||
"/api/auth/status", "/api/auth/logout",
|
||||
"/api/auth/passkeys/login/begin", "/api/auth/passkeys/login/complete",
|
||||
}
|
||||
if request.url.path not in lock_exempt:
|
||||
raise HTTPException(status_code=423, detail="Session is locked")
|
||||
@ -182,6 +190,82 @@ def require_role(*allowed_roles: str):
|
||||
require_admin = require_role("admin")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Account lockout helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _check_account_lockout(user: User) -> None:
|
||||
"""Raise HTTP 423 if the account is currently locked."""
|
||||
if user.locked_until and datetime.now() < user.locked_until:
|
||||
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=f"Account locked. Try again in {remaining} minutes.",
|
||||
)
|
||||
|
||||
|
||||
async def _record_failed_login(db: AsyncSession, user: User) -> None:
|
||||
"""Increment failure counter; lock account after 10 failures."""
|
||||
user.failed_login_count += 1
|
||||
if user.failed_login_count >= 10:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _record_successful_login(db: AsyncSession, user: User) -> None:
|
||||
"""Reset failure counter and update last_login_at."""
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session creation helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _create_db_session(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
ip: str,
|
||||
user_agent: str | None,
|
||||
) -> tuple[str, str]:
|
||||
"""Insert a UserSession row and return (session_id, signed_cookie_token)."""
|
||||
session_id = uuid.uuid4().hex
|
||||
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
||||
db_session = UserSession(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip[:45] if ip else None,
|
||||
user_agent=(user_agent or "")[:255] if user_agent else None,
|
||||
)
|
||||
db.add(db_session)
|
||||
await db.flush()
|
||||
|
||||
# Enforce concurrent session limit: revoke oldest sessions beyond the cap
|
||||
active_sessions = (
|
||||
await db.execute(
|
||||
select(UserSession)
|
||||
.where(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.revoked == False, # noqa: E712
|
||||
UserSession.expires_at > datetime.now(),
|
||||
)
|
||||
.order_by(UserSession.created_at.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
max_sessions = app_settings.MAX_SESSIONS_PER_USER
|
||||
if len(active_sessions) > max_sessions:
|
||||
for old_session in active_sessions[: len(active_sessions) - max_sessions]:
|
||||
old_session.revoked = True
|
||||
await db.flush()
|
||||
|
||||
token = create_session_token(user.id, session_id)
|
||||
return session_id, token
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User bootstrapping helper (Settings + default calendars)
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -237,8 +321,8 @@ async def setup(
|
||||
|
||||
ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, new_user, ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
_, token = await _create_db_session(db, new_user, ip, user_agent)
|
||||
_set_session_cookie(response, token)
|
||||
|
||||
await log_audit_event(
|
||||
db, action="auth.setup_complete", actor_id=new_user.id, ip=ip,
|
||||
@ -282,38 +366,19 @@ async def login(
|
||||
# executes — prevents distinguishing "locked" from "wrong password" via timing.
|
||||
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:
|
||||
remaining = await record_failed_login(db, user)
|
||||
await _record_failed_login(db, user)
|
||||
await log_audit_event(
|
||||
db, action="auth.login_failed", actor_id=user.id,
|
||||
detail={"reason": "invalid_password", "attempts_remaining": remaining}, ip=client_ip,
|
||||
detail={"reason": "invalid_password"}, ip=client_ip,
|
||||
)
|
||||
await db.commit()
|
||||
if remaining == 0:
|
||||
detail = "Account temporarily locked. Try again in 30 minutes."
|
||||
elif remaining <= 3:
|
||||
detail = f"Invalid username or password. {remaining} attempt(s) remaining before account locks."
|
||||
else:
|
||||
detail = "Invalid username or password"
|
||||
raise HTTPException(status_code=401, detail=detail)
|
||||
|
||||
# Block passwordless-only accounts from using the password login path.
|
||||
# Checked after password verification to avoid leaking account existence via timing.
|
||||
if user.passwordless_enabled:
|
||||
await log_audit_event(
|
||||
db, action="auth.login_blocked_passwordless", actor_id=user.id,
|
||||
detail={"reason": "passwordless_enabled"}, ip=client_ip,
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="This account uses passwordless login. Sign in with a passkey.",
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
# Block disabled accounts — checked AFTER password verification to avoid
|
||||
# leaking account-state info, and BEFORE record_successful_login so
|
||||
# leaking account-state info, and BEFORE _record_successful_login so
|
||||
# last_login_at and lockout counters are not reset for inactive users.
|
||||
if not user.is_active:
|
||||
await log_audit_event(
|
||||
@ -326,7 +391,7 @@ async def login(
|
||||
if new_hash:
|
||||
user.password_hash = new_hash
|
||||
|
||||
await record_successful_login(db, user)
|
||||
await _record_successful_login(db, user)
|
||||
|
||||
# SEC-03: MFA enforcement — block login entirely until MFA setup completes
|
||||
if user.mfa_enforce_pending and not user.totp_enabled:
|
||||
@ -344,7 +409,6 @@ async def login(
|
||||
# If TOTP is enabled, issue a short-lived MFA challenge token
|
||||
if user.totp_enabled:
|
||||
mfa_token = create_mfa_token(user.id)
|
||||
await db.commit()
|
||||
return {
|
||||
"authenticated": False,
|
||||
"totp_required": True,
|
||||
@ -355,8 +419,8 @@ async def login(
|
||||
if user.must_change_password:
|
||||
# Issue a session but flag the frontend to show password change
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, client_ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
_, token = await _create_db_session(db, user, client_ip, user_agent)
|
||||
_set_session_cookie(response, token)
|
||||
await db.commit()
|
||||
return {
|
||||
"authenticated": True,
|
||||
@ -364,8 +428,8 @@ async def login(
|
||||
}
|
||||
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, client_ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
_, token = await _create_db_session(db, user, client_ip, user_agent)
|
||||
_set_session_cookie(response, token)
|
||||
|
||||
await log_audit_event(
|
||||
db, action="auth.login_success", actor_id=user.id, ip=client_ip,
|
||||
@ -447,8 +511,8 @@ async def register(
|
||||
"mfa_token": enforce_token,
|
||||
}
|
||||
|
||||
_, token = await create_db_session(db, new_user, ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
_, token = await _create_db_session(db, new_user, ip, user_agent)
|
||||
_set_session_cookie(response, token)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Registration successful", "authenticated": True}
|
||||
@ -500,34 +564,34 @@ async def auth_status(
|
||||
|
||||
is_locked = False
|
||||
|
||||
u = None
|
||||
if not setup_required and session_cookie:
|
||||
payload = verify_session_token(session_cookie)
|
||||
if payload:
|
||||
user_id = payload.get("uid")
|
||||
session_id = payload.get("sid")
|
||||
if user_id and session_id:
|
||||
# Single JOIN query (was 2 sequential queries — P-01 fix)
|
||||
result = await db.execute(
|
||||
select(UserSession, User)
|
||||
.join(User, UserSession.user_id == User.id)
|
||||
.where(
|
||||
session_result = await db.execute(
|
||||
select(UserSession).where(
|
||||
UserSession.id == session_id,
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.revoked == False,
|
||||
UserSession.expires_at > datetime.now(),
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row is not None:
|
||||
db_sess, u = row.tuple()
|
||||
db_sess = session_result.scalar_one_or_none()
|
||||
if db_sess is not None:
|
||||
authenticated = True
|
||||
is_locked = db_sess.is_locked
|
||||
role = u.role
|
||||
user_obj_result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.is_active == True)
|
||||
)
|
||||
u = user_obj_result.scalar_one_or_none()
|
||||
if u:
|
||||
role = u.role
|
||||
else:
|
||||
authenticated = False
|
||||
|
||||
# Check registration availability
|
||||
config = None
|
||||
registration_open = False
|
||||
if not setup_required:
|
||||
config_result = await db.execute(
|
||||
@ -536,19 +600,6 @@ async def auth_status(
|
||||
config = config_result.scalar_one_or_none()
|
||||
registration_open = config.allow_registration if config else False
|
||||
|
||||
# Perf-3: Check passkey existence with EXISTS (not COUNT) — this endpoint
|
||||
# is polled every 15s. Count is derived from GET /auth/passkeys list instead.
|
||||
has_passkeys = False
|
||||
passwordless_enabled = False
|
||||
if authenticated and u:
|
||||
pk_result = await db.execute(
|
||||
select(PasskeyCredential.id).where(
|
||||
PasskeyCredential.user_id == u.id
|
||||
).limit(1)
|
||||
)
|
||||
has_passkeys = pk_result.scalar_one_or_none() is not None
|
||||
passwordless_enabled = u.passwordless_enabled
|
||||
|
||||
return {
|
||||
"authenticated": authenticated,
|
||||
"setup_required": setup_required,
|
||||
@ -556,9 +607,6 @@ async def auth_status(
|
||||
"username": u.username if authenticated and u else None,
|
||||
"registration_open": registration_open,
|
||||
"is_locked": is_locked,
|
||||
"has_passkeys": has_passkeys,
|
||||
"passwordless_enabled": passwordless_enabled,
|
||||
"allow_passwordless": config.allow_passwordless if config else False,
|
||||
}
|
||||
|
||||
|
||||
@ -577,7 +625,7 @@ async def lock_session(
|
||||
|
||||
|
||||
@router.post("/verify-password")
|
||||
async def verify_password_endpoint(
|
||||
async def verify_password(
|
||||
data: VerifyPasswordRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@ -587,12 +635,11 @@ async def verify_password_endpoint(
|
||||
Verify the current user's password without changing anything.
|
||||
Used by the frontend lock screen to re-authenticate without a full login.
|
||||
"""
|
||||
await check_account_lockout(current_user)
|
||||
await _check_account_lockout(current_user)
|
||||
|
||||
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
|
||||
if not valid:
|
||||
await record_failed_login(db, current_user)
|
||||
await db.commit()
|
||||
await _record_failed_login(db, current_user)
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
|
||||
if new_hash:
|
||||
@ -614,12 +661,11 @@ async def change_password(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Change the current user's password. Requires old password verification."""
|
||||
await check_account_lockout(current_user)
|
||||
await _check_account_lockout(current_user)
|
||||
|
||||
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
|
||||
if not valid:
|
||||
await record_failed_login(db, current_user)
|
||||
await db.commit()
|
||||
await _record_failed_login(db, current_user)
|
||||
raise HTTPException(status_code=401, detail="Invalid current password")
|
||||
|
||||
if data.new_password == data.old_password:
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import func, select, update
|
||||
from typing import List
|
||||
@ -11,7 +8,6 @@ from app.models.calendar import Calendar
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse
|
||||
from app.services.calendar_sharing import require_permission
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
@ -140,62 +136,3 @@ async def delete_calendar(
|
||||
await db.delete(calendar)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# DELTA POLLING
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class CalendarPollResponse(BaseModel):
|
||||
has_changes: bool
|
||||
calendar_updated_at: str | None = None
|
||||
changed_event_ids: list[int] = []
|
||||
|
||||
|
||||
@router.get("/{calendar_id}/poll", response_model=CalendarPollResponse)
|
||||
async def poll_calendar(
|
||||
calendar_id: int = Path(ge=1, le=2147483647),
|
||||
since: str = Query(..., description="ISO timestamp to check for changes since"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Lightweight poll endpoint — returns changed event IDs since timestamp."""
|
||||
await require_permission(db, calendar_id, current_user.id, "read_only")
|
||||
|
||||
try:
|
||||
since_dt = datetime.fromisoformat(since)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid ISO timestamp")
|
||||
|
||||
# Clamp to max 24h in the past to prevent expensive full-table scans
|
||||
from datetime import timedelta
|
||||
min_since = datetime.now() - timedelta(hours=24)
|
||||
if since_dt < min_since:
|
||||
since_dt = min_since
|
||||
|
||||
# Check calendar-level update
|
||||
cal_result = await db.execute(
|
||||
select(Calendar.updated_at).where(Calendar.id == calendar_id)
|
||||
)
|
||||
calendar_updated = cal_result.scalar_one_or_none()
|
||||
if not calendar_updated:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
|
||||
calendar_changed = calendar_updated > since_dt
|
||||
|
||||
# Check event-level changes using the ix_events_calendar_updated index
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent.id).where(
|
||||
CalendarEvent.calendar_id == calendar_id,
|
||||
CalendarEvent.updated_at > since_dt,
|
||||
)
|
||||
)
|
||||
changed_event_ids = [r[0] for r in event_result.all()]
|
||||
|
||||
has_changes = calendar_changed or len(changed_event_ids) > 0
|
||||
|
||||
return CalendarPollResponse(
|
||||
has_changes=has_changes,
|
||||
calendar_updated_at=calendar_updated.isoformat() if calendar_updated else None,
|
||||
changed_event_ids=changed_event_ids,
|
||||
)
|
||||
|
||||
@ -51,7 +51,6 @@ from app.services.connection import (
|
||||
)
|
||||
from app.services.calendar_sharing import cascade_on_disconnect
|
||||
from app.services.notification import create_notification
|
||||
from app.services.project_sharing import cascade_projects_on_disconnect
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -828,9 +827,6 @@ async def remove_connection(
|
||||
# Cascade: remove calendar memberships and event locks between these users
|
||||
await cascade_on_disconnect(db, current_user.id, counterpart_id)
|
||||
|
||||
# Cascade: remove project memberships and task assignments between these users
|
||||
await cascade_projects_on_disconnect(db, current_user.id, counterpart_id)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="connection.removed",
|
||||
|
||||
@ -8,7 +8,6 @@ import json
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.location import Location
|
||||
@ -58,7 +57,7 @@ async def search_locations(
|
||||
# Nominatim proxy search (run in thread executor to avoid blocking event loop)
|
||||
def _fetch_nominatim() -> list:
|
||||
encoded_q = urllib.parse.quote(q)
|
||||
url = f"https://nominatim.openstreetmap.org/search?q={encoded_q}&format=json&addressdetails=1&limit=5"
|
||||
url = f"https://nominatim.openstreetmap.org/search?q={encoded_q}&format=json&limit=5"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "UMBRA-LifeManager/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
@ -68,37 +67,9 @@ async def search_locations(
|
||||
osm_data = await loop.run_in_executor(None, _fetch_nominatim)
|
||||
for item in osm_data:
|
||||
display_name = item.get("display_name", "")
|
||||
addr = item.get("address", {})
|
||||
house_number = addr.get("house_number", "")
|
||||
road = addr.get("road", "")
|
||||
|
||||
# If Nominatim didn't return a house_number but the user's
|
||||
# query starts with one, preserve it from the original query.
|
||||
if not house_number and road:
|
||||
m = re.match(r"^(\d+[\w/-]*)\s+", q.strip())
|
||||
if m:
|
||||
house_number = m.group(1)
|
||||
|
||||
# Name = place/building label from Nominatim (e.g. "The Quadrant").
|
||||
# Falls back to street address if no distinct place name exists.
|
||||
osm_name = item.get("name", "")
|
||||
street = f"{house_number} {road}" if house_number and road else road
|
||||
if osm_name and osm_name != road:
|
||||
name = osm_name
|
||||
elif street:
|
||||
name = street
|
||||
else:
|
||||
name = display_name.split(",", 1)[0].strip()
|
||||
|
||||
# Address = full street address with suburb/state/postcode.
|
||||
addr_parts = []
|
||||
if street:
|
||||
addr_parts.append(street)
|
||||
for key in ("suburb", "city", "state", "postcode", "country"):
|
||||
val = addr.get(key, "")
|
||||
if val:
|
||||
addr_parts.append(val)
|
||||
address = ", ".join(addr_parts) if addr_parts else display_name
|
||||
name_parts = display_name.split(",", 1)
|
||||
name = name_parts[0].strip()
|
||||
address = name_parts[1].strip() if len(name_parts) > 1 else display_name
|
||||
results.append(
|
||||
LocationSearchResult(
|
||||
source="nominatim",
|
||||
|
||||
@ -1,675 +0,0 @@
|
||||
"""
|
||||
Passkey (WebAuthn/FIDO2) router.
|
||||
|
||||
Endpoints (all under /api/auth/passkeys — registered in main.py):
|
||||
|
||||
POST /register/begin — Start passkey registration (auth + password required)
|
||||
POST /register/complete — Complete registration ceremony (auth required)
|
||||
POST /login/begin — Start passkey authentication (public, CSRF-exempt)
|
||||
POST /login/complete — Complete authentication ceremony (public, CSRF-exempt)
|
||||
GET / — List registered passkeys (auth required)
|
||||
DELETE /{id} — Remove a passkey (auth + password required)
|
||||
|
||||
Security:
|
||||
- Challenge tokens signed with itsdangerous (60s TTL, single-use nonce)
|
||||
- Registration binds challenge to user_id, validated on complete (S-01)
|
||||
- Registration requires password re-entry (V-02)
|
||||
- Generic 401 on all auth failures (no credential enumeration)
|
||||
- Constant-time response on login/begin (V-03)
|
||||
- Failed passkey logins increment shared lockout counter
|
||||
- Passkey login bypasses TOTP (passkey IS 2FA)
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.passkey_credential import PasskeyCredential
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.user import User
|
||||
from app.routers.auth import get_current_user
|
||||
from app.services.audit import get_client_ip, log_audit_event
|
||||
from app.services.auth import averify_password_with_upgrade, verify_session_token
|
||||
from app.services.session import (
|
||||
create_db_session,
|
||||
set_session_cookie,
|
||||
check_account_lockout,
|
||||
record_failed_login,
|
||||
record_successful_login,
|
||||
)
|
||||
from app.services.passkey import (
|
||||
create_challenge_token,
|
||||
verify_challenge_token,
|
||||
build_registration_options,
|
||||
verify_registration as verify_registration_response_svc,
|
||||
build_authentication_options,
|
||||
verify_authentication as verify_authentication_response_svc,
|
||||
extract_credential_raw_id,
|
||||
)
|
||||
from app.models.session import UserSession
|
||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request/Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PasskeyRegisterBeginRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
password: str = Field(max_length=128)
|
||||
|
||||
|
||||
class PasskeyRegisterCompleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
credential: str = Field(max_length=8192)
|
||||
challenge_token: str = Field(max_length=2048)
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class PasskeyLoginBeginRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
username: str | None = Field(None, max_length=50)
|
||||
|
||||
|
||||
class PasskeyLoginCompleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
credential: str = Field(max_length=8192)
|
||||
challenge_token: str = Field(max_length=2048)
|
||||
unlock: bool = False
|
||||
|
||||
|
||||
class PasskeyDeleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
password: str = Field(max_length=128)
|
||||
|
||||
|
||||
class PasswordlessEnableRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
password: str = Field(max_length=128)
|
||||
|
||||
|
||||
class PasswordlessDisableRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
credential: str = Field(max_length=8192)
|
||||
challenge_token: str = Field(max_length=2048)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration endpoints (authenticated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/register/begin")
|
||||
async def passkey_register_begin(
|
||||
data: PasskeyRegisterBeginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Start passkey registration. Requires password re-entry (V-02)."""
|
||||
# V-02: Verify password before allowing registration
|
||||
valid, new_hash = await averify_password_with_upgrade(
|
||||
data.password, current_user.password_hash
|
||||
)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
if new_hash:
|
||||
current_user.password_hash = new_hash
|
||||
await db.commit()
|
||||
|
||||
# Load existing credential IDs for exclusion
|
||||
result = await db.execute(
|
||||
select(PasskeyCredential.credential_id).where(
|
||||
PasskeyCredential.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
existing_ids = [
|
||||
base64url_to_bytes(row[0]) for row in result.all()
|
||||
]
|
||||
|
||||
options_json, challenge = build_registration_options(
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
existing_credential_ids=existing_ids,
|
||||
)
|
||||
token = create_challenge_token(challenge, user_id=current_user.id)
|
||||
|
||||
return {
|
||||
"options": json.loads(options_json),
|
||||
"challenge_token": token,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register/complete")
|
||||
async def passkey_register_complete(
|
||||
data: PasskeyRegisterCompleteRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Complete passkey registration ceremony."""
|
||||
# Verify challenge token — cross-check user binding (S-01) + single-use nonce (V-01)
|
||||
challenge = verify_challenge_token(
|
||||
data.challenge_token, expected_user_id=current_user.id
|
||||
)
|
||||
if challenge is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired challenge")
|
||||
|
||||
try:
|
||||
verified = verify_registration_response_svc(
|
||||
credential_json=data.credential,
|
||||
challenge=challenge,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Passkey registration verification failed: %s", e)
|
||||
raise HTTPException(status_code=400, detail="Registration verification failed")
|
||||
|
||||
# Store credential
|
||||
credential_id_b64 = bytes_to_base64url(verified.credential_id)
|
||||
|
||||
# Check for duplicate (race condition safety)
|
||||
existing = await db.execute(
|
||||
select(PasskeyCredential).where(
|
||||
PasskeyCredential.credential_id == credential_id_b64
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="Credential already registered")
|
||||
|
||||
# Extract transport hints if available
|
||||
transports_json = None
|
||||
if hasattr(verified, 'credential_device_type'):
|
||||
pass # py_webauthn doesn't expose transports on VerifiedRegistration
|
||||
# Transports come from the browser response — parse from credential JSON
|
||||
try:
|
||||
cred_data = json.loads(data.credential)
|
||||
if "response" in cred_data and "transports" in cred_data["response"]:
|
||||
transports_json = json.dumps(cred_data["response"]["transports"])
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Determine backup state from py_webauthn flags
|
||||
backed_up = getattr(verified, 'credential_backed_up', False)
|
||||
|
||||
new_credential = PasskeyCredential(
|
||||
user_id=current_user.id,
|
||||
credential_id=credential_id_b64,
|
||||
public_key=bytes_to_base64url(verified.credential_public_key),
|
||||
sign_count=verified.sign_count,
|
||||
name=data.name,
|
||||
transports=transports_json,
|
||||
backed_up=backed_up,
|
||||
)
|
||||
db.add(new_credential)
|
||||
|
||||
# B-02: If user has mfa_enforce_pending, clear it (passkey = MFA)
|
||||
if current_user.mfa_enforce_pending:
|
||||
current_user.mfa_enforce_pending = False
|
||||
|
||||
# Extract response data BEFORE commit (ORM expiry rule)
|
||||
response_data = {
|
||||
"id": None, # will be set after flush
|
||||
"name": new_credential.name,
|
||||
"created_at": None,
|
||||
"backed_up": backed_up,
|
||||
}
|
||||
|
||||
await db.flush()
|
||||
response_data["id"] = new_credential.id
|
||||
response_data["created_at"] = str(new_credential.created_at) if new_credential.created_at else None
|
||||
|
||||
await log_audit_event(
|
||||
db, action="passkey.registered", actor_id=current_user.id,
|
||||
detail={"credential_name": data.name},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authentication endpoints (unauthenticated — CSRF-exempt)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/login/begin")
|
||||
async def passkey_login_begin(
|
||||
data: PasskeyLoginBeginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Start passkey authentication. CSRF-exempt, public endpoint."""
|
||||
credential_data = None
|
||||
|
||||
if data.username:
|
||||
# Look up user's credentials for allowCredentials
|
||||
result = await db.execute(
|
||||
select(User).where(User.username == data.username.lower().strip())
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
cred_result = await db.execute(
|
||||
select(
|
||||
PasskeyCredential.credential_id,
|
||||
PasskeyCredential.transports,
|
||||
).where(PasskeyCredential.user_id == user.id)
|
||||
)
|
||||
rows = cred_result.all()
|
||||
if rows:
|
||||
credential_data = []
|
||||
for row in rows:
|
||||
cid_bytes = base64url_to_bytes(row[0])
|
||||
transports = json.loads(row[1]) if row[1] else None
|
||||
credential_data.append((cid_bytes, transports))
|
||||
else:
|
||||
# F-01: User not found — run a no-op DB query to equalize timing with
|
||||
# the credential fetch that executes for existing users. Without this,
|
||||
# the absence of the second query makes the "no user" path measurably
|
||||
# faster, leaking whether the username exists.
|
||||
await db.execute(
|
||||
select(PasskeyCredential.credential_id).where(
|
||||
PasskeyCredential.user_id == 0
|
||||
).limit(1)
|
||||
)
|
||||
|
||||
# V-03: Generate options regardless of whether user exists or has passkeys.
|
||||
# Identical response shape prevents timing enumeration.
|
||||
options_json, challenge = build_authentication_options(
|
||||
credential_ids_and_transports=credential_data,
|
||||
)
|
||||
token = create_challenge_token(challenge)
|
||||
|
||||
return {
|
||||
"options": json.loads(options_json),
|
||||
"challenge_token": token,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login/complete")
|
||||
async def passkey_login_complete(
|
||||
data: PasskeyLoginCompleteRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Complete passkey authentication. CSRF-exempt, public endpoint."""
|
||||
# Verify challenge token (60s TTL, single-use nonce V-01)
|
||||
challenge = verify_challenge_token(data.challenge_token)
|
||||
if challenge is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Parse credential_id from browser response (S-02: shared helper)
|
||||
raw_id_b64 = extract_credential_raw_id(data.credential)
|
||||
if not raw_id_b64:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Look up credential + user in a single JOIN query (W-1 perf fix)
|
||||
result = await db.execute(
|
||||
select(PasskeyCredential, User)
|
||||
.join(User, User.id == PasskeyCredential.user_id)
|
||||
.where(PasskeyCredential.credential_id == raw_id_b64)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
credential, user = row.tuple()
|
||||
|
||||
# Check account lockout (C-03)
|
||||
await check_account_lockout(user)
|
||||
|
||||
# Check active status (C-03)
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Verify the authentication response
|
||||
try:
|
||||
verified = verify_authentication_response_svc(
|
||||
credential_json=data.credential,
|
||||
challenge=challenge,
|
||||
credential_public_key=base64url_to_bytes(credential.public_key),
|
||||
credential_current_sign_count=credential.sign_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Passkey authentication verification failed for user %s: %s", user.id, e)
|
||||
# Increment failed login counter (shared with password auth)
|
||||
remaining = await record_failed_login(db, user)
|
||||
await log_audit_event(
|
||||
db, action="passkey.login_failed", actor_id=user.id,
|
||||
detail={"reason": "verification_failed", "attempts_remaining": remaining},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
# Generic message for all failures — don't leak lockout state (C-02/F-02)
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Update sign count (log anomaly but don't fail — S-05)
|
||||
new_sign_count = verified.new_sign_count
|
||||
if new_sign_count < credential.sign_count and credential.sign_count > 0:
|
||||
logger.warning(
|
||||
"Sign count anomaly for user %s credential %s: expected >= %d, got %d",
|
||||
user.id, credential.id, credential.sign_count, new_sign_count,
|
||||
)
|
||||
await log_audit_event(
|
||||
db, action="passkey.sign_count_anomaly", actor_id=user.id,
|
||||
detail={
|
||||
"credential_id": credential.id,
|
||||
"expected": credential.sign_count,
|
||||
"received": new_sign_count,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
credential.sign_count = new_sign_count
|
||||
credential.last_used_at = datetime.now()
|
||||
|
||||
# Passkey unlock — re-authenticate into a locked session instead of creating a new one
|
||||
if data.unlock:
|
||||
session_cookie = request.cookies.get("session")
|
||||
payload = verify_session_token(session_cookie) if session_cookie else None
|
||||
if not payload or payload.get("uid") != user.id:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
sess_result = await db.execute(
|
||||
select(UserSession).where(
|
||||
UserSession.id == payload["sid"],
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.revoked == False,
|
||||
)
|
||||
)
|
||||
db_sess = sess_result.scalar_one_or_none()
|
||||
if not db_sess:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
db_sess.is_locked = False
|
||||
db_sess.locked_at = None
|
||||
# Reset failed login counter on successful passkey unlock (W-02)
|
||||
await record_successful_login(db, user)
|
||||
await log_audit_event(
|
||||
db, action="passkey.unlock_success", actor_id=user.id,
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
return {"unlocked": True}
|
||||
|
||||
# Record successful login
|
||||
await record_successful_login(db, user)
|
||||
|
||||
# Create session (shared service — enforces session cap)
|
||||
client_ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, client_ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
|
||||
# Handle special flags for passkey login
|
||||
result_data: dict = {"authenticated": True}
|
||||
# W-05: Passkey login auto-clears must_change_password — user can't provide
|
||||
# old password in the forced-change form since they authenticated via passkey.
|
||||
if user.must_change_password:
|
||||
user.must_change_password = False
|
||||
# Passkey satisfies MFA — if mfa_enforce_pending, clear it (before commit)
|
||||
if user.mfa_enforce_pending:
|
||||
user.mfa_enforce_pending = False
|
||||
|
||||
await log_audit_event(
|
||||
db, action="passkey.login_success", actor_id=user.id,
|
||||
detail={"credential_name": credential.name},
|
||||
ip=client_ip,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return result_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Passwordless toggle endpoints (authenticated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.put("/passwordless/enable")
|
||||
async def passwordless_enable(
|
||||
data: PasswordlessEnableRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Enable passwordless login for the current user.
|
||||
|
||||
Requirements:
|
||||
- System config must have allow_passwordless = True
|
||||
- User must have >= 2 registered passkeys
|
||||
- Password confirmation required
|
||||
"""
|
||||
# Verify password first
|
||||
valid, new_hash = await averify_password_with_upgrade(
|
||||
data.password, current_user.password_hash
|
||||
)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
if new_hash:
|
||||
current_user.password_hash = new_hash
|
||||
|
||||
# Check system config
|
||||
config_result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.id == 1)
|
||||
)
|
||||
config = config_result.scalar_one_or_none()
|
||||
if not config or not config.allow_passwordless:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Passwordless login is not enabled on this system",
|
||||
)
|
||||
|
||||
# Require >= 2 passkeys as safety net (can't get locked out)
|
||||
pk_count_result = await db.execute(
|
||||
select(func.count()).select_from(PasskeyCredential).where(
|
||||
PasskeyCredential.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
pk_count = pk_count_result.scalar_one()
|
||||
if pk_count < 2:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="At least 2 passkeys must be registered before enabling passwordless login",
|
||||
)
|
||||
|
||||
current_user.passwordless_enabled = True
|
||||
|
||||
await log_audit_event(
|
||||
db, action="passkey.passwordless_enabled", actor_id=current_user.id,
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"passwordless_enabled": True}
|
||||
|
||||
|
||||
@router.post("/passwordless/disable/begin")
|
||||
async def passwordless_disable_begin(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Begin the passkey authentication ceremony to disable passwordless login.
|
||||
Returns challenge options for the browser to present to the authenticator.
|
||||
"""
|
||||
# Load user's credentials for allowCredentials
|
||||
cred_result = await db.execute(
|
||||
select(
|
||||
PasskeyCredential.credential_id,
|
||||
PasskeyCredential.transports,
|
||||
).where(PasskeyCredential.user_id == current_user.id)
|
||||
)
|
||||
rows = cred_result.all()
|
||||
|
||||
credential_data = None
|
||||
if rows:
|
||||
credential_data = []
|
||||
for row in rows:
|
||||
cid_bytes = base64url_to_bytes(row[0])
|
||||
transports = json.loads(row[1]) if row[1] else None
|
||||
credential_data.append((cid_bytes, transports))
|
||||
|
||||
options_json, challenge = build_authentication_options(
|
||||
credential_ids_and_transports=credential_data,
|
||||
)
|
||||
# Bind challenge to this user so complete endpoint can cross-check
|
||||
token = create_challenge_token(challenge, user_id=current_user.id)
|
||||
|
||||
return {
|
||||
"options": json.loads(options_json),
|
||||
"challenge_token": token,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/passwordless/disable")
|
||||
async def passwordless_disable(
|
||||
data: PasswordlessDisableRequest,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Complete passkey authentication to disable passwordless login.
|
||||
Verifies the credential belongs to the current user.
|
||||
"""
|
||||
# Verify challenge token — user-bound (single-use nonce V-01, cross-user binding S-01)
|
||||
challenge = verify_challenge_token(
|
||||
data.challenge_token, expected_user_id=current_user.id
|
||||
)
|
||||
if challenge is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired challenge")
|
||||
|
||||
# Parse rawId from credential (S-02: shared helper)
|
||||
raw_id_b64 = extract_credential_raw_id(data.credential)
|
||||
if not raw_id_b64:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Look up credential — verify ownership (IDOR prevention)
|
||||
cred_result = await db.execute(
|
||||
select(PasskeyCredential).where(
|
||||
PasskeyCredential.credential_id == raw_id_b64,
|
||||
PasskeyCredential.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
credential = cred_result.scalar_one_or_none()
|
||||
if not credential:
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Verify the authentication response
|
||||
try:
|
||||
verified = verify_authentication_response_svc(
|
||||
credential_json=data.credential,
|
||||
challenge=challenge,
|
||||
credential_public_key=base64url_to_bytes(credential.public_key),
|
||||
credential_current_sign_count=credential.sign_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Passwordless disable: auth verification failed for user %s: %s",
|
||||
current_user.id, e,
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Update sign count
|
||||
credential.sign_count = verified.new_sign_count
|
||||
credential.last_used_at = datetime.now()
|
||||
|
||||
current_user.passwordless_enabled = False
|
||||
|
||||
await log_audit_event(
|
||||
db, action="passkey.passwordless_disabled", actor_id=current_user.id,
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"passwordless_enabled": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Management endpoints (authenticated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/")
|
||||
async def list_passkeys(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all passkeys for the current user."""
|
||||
result = await db.execute(
|
||||
select(PasskeyCredential)
|
||||
.where(PasskeyCredential.user_id == current_user.id)
|
||||
.order_by(PasskeyCredential.created_at.desc())
|
||||
)
|
||||
credentials = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"created_at": str(c.created_at) if c.created_at else None,
|
||||
"last_used_at": str(c.last_used_at) if c.last_used_at else None,
|
||||
"backed_up": c.backed_up,
|
||||
}
|
||||
for c in credentials
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{credential_id}")
|
||||
async def delete_passkey(
|
||||
request: Request,
|
||||
credential_id: int = Path(ge=1, le=2147483647),
|
||||
data: PasskeyDeleteRequest = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete a passkey. Requires password confirmation (S-06)."""
|
||||
# Verify password
|
||||
valid, new_hash = await averify_password_with_upgrade(
|
||||
data.password, current_user.password_hash
|
||||
)
|
||||
if not valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid password")
|
||||
if new_hash:
|
||||
current_user.password_hash = new_hash
|
||||
|
||||
# Look up credential — verify ownership (IDOR prevention)
|
||||
result = await db.execute(
|
||||
select(PasskeyCredential).where(
|
||||
PasskeyCredential.id == credential_id,
|
||||
PasskeyCredential.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
credential = result.scalar_one_or_none()
|
||||
if not credential:
|
||||
raise HTTPException(status_code=404, detail="Passkey not found")
|
||||
|
||||
# Guard: passwordless users must retain at least 2 passkeys
|
||||
if current_user.passwordless_enabled:
|
||||
pk_count_result = await db.execute(
|
||||
select(func.count()).select_from(PasskeyCredential).where(
|
||||
PasskeyCredential.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
pk_count = pk_count_result.scalar_one()
|
||||
if pk_count <= 2:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot delete: passwordless requires at least 2 passkeys",
|
||||
)
|
||||
|
||||
cred_name = credential.name
|
||||
await db.delete(credential)
|
||||
|
||||
await log_audit_event(
|
||||
db, action="passkey.deleted", actor_id=current_user.id,
|
||||
detail={"credential_name": cred_name, "credential_db_id": credential_id},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Passkey removed"}
|
||||
@ -1,31 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete as sa_delete, select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import date, timedelta
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.project import Project
|
||||
from app.models.project_task import ProjectTask
|
||||
from app.models.task_comment import TaskComment
|
||||
from app.models.project_member import ProjectMember
|
||||
from app.models.project_task_assignment import ProjectTaskAssignment
|
||||
from app.models.settings import Settings
|
||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse
|
||||
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
|
||||
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse
|
||||
from app.schemas.project_member import (
|
||||
ProjectMemberInvite, ProjectMemberUpdate, ProjectMemberRespond, ProjectMemberResponse,
|
||||
)
|
||||
from app.schemas.project_task_assignment import TaskAssignmentCreate, TaskAssignmentResponse
|
||||
from app.services.project_sharing import (
|
||||
get_project_permission, require_project_permission, get_accessible_project_ids,
|
||||
validate_project_connections, get_effective_task_permission, ensure_auto_membership,
|
||||
cleanup_auto_membership, ASSIGNEE_ALLOWED_FIELDS,
|
||||
)
|
||||
from app.services.notification import create_notification
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
@ -39,61 +26,34 @@ class ReorderItem(BaseModel):
|
||||
|
||||
|
||||
def _project_load_options():
|
||||
"""All load options needed for project responses (tasks + subtasks + comments + assignments)."""
|
||||
"""All load options needed for project responses (tasks + subtasks + comments at each level)."""
|
||||
return [
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.comments),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
selectinload(Project.members),
|
||||
]
|
||||
|
||||
|
||||
def _task_load_options():
|
||||
"""All load options needed for task responses."""
|
||||
return [
|
||||
selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
|
||||
selectinload(ProjectTask.comments),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
|
||||
selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments).selectinload(ProjectTaskAssignment.user),
|
||||
]
|
||||
|
||||
|
||||
async def _get_user_name(db: AsyncSession, user_id: int) -> str | None:
|
||||
"""Get display name for a user from settings.preferred_name or user.username."""
|
||||
result = await db.execute(
|
||||
select(Settings.preferred_name, User.username)
|
||||
.outerjoin(Settings, Settings.user_id == User.id)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
preferred, username = row.tuple()
|
||||
return preferred or username
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# PROJECT CRUD
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/", response_model=List[ProjectResponse])
|
||||
async def get_projects(
|
||||
tracked: Optional[bool] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all projects the user owns or has accepted membership in."""
|
||||
accessible_ids = await get_accessible_project_ids(db, current_user.id)
|
||||
if not accessible_ids:
|
||||
return []
|
||||
|
||||
"""Get all projects with their tasks. Optionally filter by tracked status."""
|
||||
query = (
|
||||
select(Project)
|
||||
.options(*_project_load_options())
|
||||
.where(Project.id.in_(accessible_ids))
|
||||
.where(Project.user_id == current_user.id)
|
||||
.order_by(Project.created_at.desc())
|
||||
)
|
||||
if tracked is not None:
|
||||
@ -112,10 +72,6 @@ async def get_tracked_tasks(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get tasks and subtasks from tracked projects with due dates within the next N days."""
|
||||
accessible_ids = await get_accessible_project_ids(db, current_user.id)
|
||||
if not accessible_ids:
|
||||
return []
|
||||
|
||||
today = date.today()
|
||||
cutoff = today + timedelta(days=days)
|
||||
|
||||
@ -127,7 +83,7 @@ async def get_tracked_tasks(
|
||||
selectinload(ProjectTask.parent_task),
|
||||
)
|
||||
.where(
|
||||
Project.id.in_(accessible_ids),
|
||||
Project.user_id == current_user.id,
|
||||
Project.is_tracked == True,
|
||||
ProjectTask.due_date.isnot(None),
|
||||
ProjectTask.due_date >= today,
|
||||
@ -154,31 +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)
|
||||
async def create_project(
|
||||
project: ProjectCreate,
|
||||
@ -190,6 +121,7 @@ async def create_project(
|
||||
db.add(new_project)
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with eagerly loaded tasks for response serialization
|
||||
query = select(Project).options(*_project_load_options()).where(Project.id == new_project.id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one()
|
||||
@ -202,12 +134,10 @@ async def get_project(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get a specific project by ID with its tasks."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
|
||||
query = (
|
||||
select(Project)
|
||||
.options(*_project_load_options())
|
||||
.where(Project.id == project_id)
|
||||
.where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
project = result.scalar_one_or_none()
|
||||
@ -225,10 +155,10 @@ async def update_project(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a project. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
"""Update a project."""
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
@ -241,6 +171,7 @@ async def update_project(
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with eagerly loaded tasks for response serialization
|
||||
query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one()
|
||||
@ -252,10 +183,10 @@ async def delete_project(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a project and all its tasks. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
"""Delete a project and all its tasks."""
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
@ -267,10 +198,6 @@ async def delete_project(
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# TASK CRUD (permission-aware)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
|
||||
async def get_project_tasks(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
@ -278,7 +205,14 @@ async def get_project_tasks(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get top-level tasks for a specific project (subtasks are nested)."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
# Verify project ownership first
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
query = (
|
||||
select(ProjectTask)
|
||||
@ -302,8 +236,15 @@ async def create_project_task(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new task or subtask for a project. Requires create_modify permission."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
"""Create a new task or subtask for a project."""
|
||||
# Verify project ownership first
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Validate parent_task_id if creating a subtask
|
||||
if task.parent_task_id is not None:
|
||||
@ -327,6 +268,7 @@ async def create_project_task(
|
||||
db.add(new_task)
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with subtasks loaded
|
||||
query = (
|
||||
select(ProjectTask)
|
||||
.options(*_task_load_options())
|
||||
@ -343,8 +285,15 @@ async def reorder_tasks(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Bulk update sort_order for tasks. Requires create_modify permission."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
"""Bulk update sort_order for tasks."""
|
||||
# Verify project ownership first
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
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
|
||||
task_ids = [item.id for item in items]
|
||||
@ -374,12 +323,13 @@ async def update_project_task(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a project task. Permission checked at project and task level."""
|
||||
perm, project_perm = await get_effective_task_permission(db, current_user.id, task_id, project_id)
|
||||
if perm is None:
|
||||
"""Update a project task."""
|
||||
# Verify project ownership first, then fetch task scoped to that project
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if perm == "read_only":
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
@ -394,28 +344,12 @@ async def update_project_task(
|
||||
|
||||
update_data = task_update.model_dump(exclude_unset=True)
|
||||
|
||||
# SEC-P02: Assignees (non-owner, non-project-member with create_modify) restricted to content fields
|
||||
if project_perm not in ("owner", "create_modify"):
|
||||
# This user's create_modify comes from task assignment — enforce allowlist
|
||||
disallowed = set(update_data.keys()) - ASSIGNEE_ALLOWED_FIELDS
|
||||
if disallowed:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Task assignees cannot modify: {', '.join(sorted(disallowed))}",
|
||||
)
|
||||
|
||||
# Optimistic locking: if version provided, check it matches
|
||||
client_version = update_data.pop("version", None)
|
||||
if client_version is not None and task.version != client_version:
|
||||
raise HTTPException(status_code=409, detail="Task was modified by another user")
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(task, key, value)
|
||||
|
||||
task.version += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with subtasks loaded
|
||||
query = (
|
||||
select(ProjectTask)
|
||||
.options(*_task_load_options())
|
||||
@ -432,8 +366,13 @@ async def delete_project_task(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a project task (cascades to subtasks). Requires create_modify permission."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
"""Delete a project task (cascades to subtasks)."""
|
||||
# Verify project ownership first, then fetch task scoped to that project
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
@ -452,10 +391,6 @@ async def delete_project_task(
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# COMMENTS (permission-aware)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201)
|
||||
async def create_task_comment(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
@ -464,8 +399,13 @@ async def create_task_comment(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Add a comment to a task. All members can comment (read_only minimum)."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
"""Add a comment to a task."""
|
||||
# Verify project ownership first, then fetch task scoped to that project
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
@ -478,23 +418,12 @@ async def create_task_comment(
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
new_comment = TaskComment(task_id=task_id, user_id=current_user.id, content=comment.content)
|
||||
new_comment = TaskComment(task_id=task_id, content=comment.content)
|
||||
db.add(new_comment)
|
||||
|
||||
# Get author name before commit
|
||||
author_name = await _get_user_name(db, current_user.id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_comment)
|
||||
|
||||
return TaskCommentResponse(
|
||||
id=new_comment.id,
|
||||
task_id=new_comment.task_id,
|
||||
user_id=new_comment.user_id,
|
||||
author_name=author_name,
|
||||
content=new_comment.content,
|
||||
created_at=new_comment.created_at,
|
||||
)
|
||||
return new_comment
|
||||
|
||||
|
||||
@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
|
||||
@ -505,9 +434,12 @@ async def delete_task_comment(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a task comment. Comment author or project owner only."""
|
||||
perm = await get_project_permission(db, project_id, current_user.id)
|
||||
if perm is None:
|
||||
"""Delete a task comment."""
|
||||
# Verify project ownership first, then fetch comment scoped through task
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
|
||||
)
|
||||
if not project_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
result = await db.execute(
|
||||
@ -521,484 +453,7 @@ async def delete_task_comment(
|
||||
if not comment:
|
||||
raise HTTPException(status_code=404, detail="Comment not found")
|
||||
|
||||
# Only comment author or project owner can delete
|
||||
if comment.user_id != current_user.id and perm != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the comment author or project owner can delete this comment")
|
||||
|
||||
await db.delete(comment)
|
||||
await db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# MEMBERSHIP ROUTES
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/members", response_model=List[ProjectMemberResponse], status_code=201)
|
||||
async def invite_members(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
invite: ProjectMemberInvite = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Invite connection(s) to a project. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
# Validate connections
|
||||
await validate_project_connections(db, current_user.id, invite.user_ids)
|
||||
|
||||
# Check pending invite cap (max 10 pending per project)
|
||||
pending_count_result = await db.execute(
|
||||
select(ProjectMember.id).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.status == "pending",
|
||||
)
|
||||
)
|
||||
pending_count = len(pending_count_result.all())
|
||||
if pending_count + len(invite.user_ids) > 10:
|
||||
raise HTTPException(status_code=400, detail="Maximum 10 pending invites per project")
|
||||
|
||||
# Filter out self and existing members
|
||||
existing_result = await db.execute(
|
||||
select(ProjectMember.user_id).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id.in_(invite.user_ids),
|
||||
)
|
||||
)
|
||||
existing_user_ids = {r[0] for r in existing_result.all()}
|
||||
|
||||
# Get project for notifications
|
||||
project_result = await db.execute(select(Project.name).where(Project.id == project_id))
|
||||
project_name = project_result.scalar_one()
|
||||
|
||||
inviter_name = await _get_user_name(db, current_user.id)
|
||||
created_members = []
|
||||
|
||||
for uid in invite.user_ids:
|
||||
if uid == current_user.id or uid in existing_user_ids:
|
||||
continue
|
||||
|
||||
member = ProjectMember(
|
||||
project_id=project_id,
|
||||
user_id=uid,
|
||||
invited_by=current_user.id,
|
||||
permission=invite.permission,
|
||||
status="pending",
|
||||
source="invited",
|
||||
)
|
||||
db.add(member)
|
||||
created_members.append(member)
|
||||
|
||||
# In-app notification
|
||||
await create_notification(
|
||||
db, uid, "project_invite",
|
||||
f"Project invitation from {inviter_name}",
|
||||
f"You've been invited to collaborate on \"{project_name}\"",
|
||||
data={"project_id": project_id},
|
||||
source_type="project_member",
|
||||
)
|
||||
|
||||
await db.flush() # Assign IDs before commit (ORM objects expire after commit)
|
||||
member_ids = [m.id for m in created_members]
|
||||
await db.commit()
|
||||
|
||||
# Re-fetch with relationships
|
||||
if not created_members:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(
|
||||
selectinload(ProjectMember.user),
|
||||
selectinload(ProjectMember.inviter),
|
||||
)
|
||||
.where(ProjectMember.id.in_(member_ids))
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
# Build response with names
|
||||
responses = []
|
||||
for m in members:
|
||||
resp = ProjectMemberResponse.model_validate(m)
|
||||
resp.user_name = m.user.username
|
||||
resp.inviter_name = m.inviter.username if m.inviter else None
|
||||
responses.append(resp)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
@router.get("/{project_id}/members", response_model=List[ProjectMemberResponse])
|
||||
async def get_members(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List members + statuses. Any member can view."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(
|
||||
selectinload(ProjectMember.user),
|
||||
selectinload(ProjectMember.inviter),
|
||||
)
|
||||
.where(ProjectMember.project_id == project_id)
|
||||
.order_by(ProjectMember.created_at.asc())
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
# Batch-fetch settings for preferred_name
|
||||
user_ids = [m.user_id for m in members] + [m.invited_by for m in members]
|
||||
settings_result = await db.execute(
|
||||
select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids))
|
||||
)
|
||||
name_map = {r[0]: r[1] for r in settings_result.all()}
|
||||
|
||||
responses = []
|
||||
for m in members:
|
||||
resp = ProjectMemberResponse.model_validate(m)
|
||||
resp.user_name = name_map.get(m.user_id) or m.user.username
|
||||
resp.inviter_name = name_map.get(m.invited_by) or (m.inviter.username if m.inviter else None)
|
||||
responses.append(resp)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
@router.patch("/{project_id}/members/{user_id}", response_model=ProjectMemberResponse)
|
||||
async def update_member_permission(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
update: ProjectMemberUpdate = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update a member's permission level. Owner only."""
|
||||
await require_project_permission(db, project_id, current_user.id, "owner")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter))
|
||||
.where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
member.permission = update.permission
|
||||
|
||||
# Extract response data BEFORE commit (ORM objects expire after commit)
|
||||
resp = ProjectMemberResponse.model_validate(member)
|
||||
resp.user_name = member.user.username
|
||||
resp.inviter_name = member.inviter.username if member.inviter else None
|
||||
|
||||
await db.commit()
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
@router.delete("/{project_id}/members/{user_id}", status_code=204)
|
||||
async def remove_member(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a member. Owner or self (leave project)."""
|
||||
perm = await get_project_permission(db, project_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Only owner can remove others; anyone can remove themselves
|
||||
if user_id != current_user.id and perm != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the project owner can remove members")
|
||||
|
||||
result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
# Remove task assignments for this user in this project
|
||||
await db.execute(
|
||||
sa_delete(ProjectTaskAssignment).where(
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
ProjectTaskAssignment.task_id.in_(
|
||||
select(ProjectTask.id).where(ProjectTask.project_id == project_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await db.delete(member)
|
||||
await db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/memberships/{project_id}/respond", response_model=ProjectMemberResponse)
|
||||
async def respond_to_invite(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
respond: ProjectMemberRespond = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Accept or reject a project invite."""
|
||||
result = await db.execute(
|
||||
select(ProjectMember)
|
||||
.options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter))
|
||||
.where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id,
|
||||
ProjectMember.status == "pending",
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="No pending invitation found")
|
||||
|
||||
# Extract response data before any mutations (ORM objects expire after commit)
|
||||
resp = ProjectMemberResponse.model_validate(member)
|
||||
resp.user_name = member.user.username
|
||||
resp.inviter_name = member.inviter.username if member.inviter else None
|
||||
|
||||
if respond.response == "accepted":
|
||||
member.status = "accepted"
|
||||
member.accepted_at = datetime.now()
|
||||
|
||||
# Get project owner for notification
|
||||
project_result = await db.execute(
|
||||
select(Project.user_id, Project.name).where(Project.id == project_id)
|
||||
)
|
||||
project_row = project_result.one()
|
||||
owner_id, project_name = project_row.tuple()
|
||||
|
||||
responder_name = await _get_user_name(db, current_user.id)
|
||||
|
||||
await create_notification(
|
||||
db, owner_id, "project_invite_accepted",
|
||||
f"{responder_name} joined your project",
|
||||
f"{responder_name} accepted the invitation to \"{project_name}\"",
|
||||
data={"project_id": project_id},
|
||||
source_type="project_member",
|
||||
)
|
||||
|
||||
resp.status = "accepted"
|
||||
else:
|
||||
# Rejected — delete the row to prevent accumulation (W-06)
|
||||
await db.delete(member)
|
||||
resp.status = "rejected"
|
||||
|
||||
await db.commit()
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# TASK ASSIGNMENT ROUTES
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/tasks/{task_id}/assignments", response_model=List[TaskAssignmentResponse], status_code=201)
|
||||
async def assign_users_to_task(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
task_id: int = Path(ge=1, le=2147483647),
|
||||
assignment: TaskAssignmentCreate = ...,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Assign user(s) to a task. Requires create_modify on project or be owner."""
|
||||
await require_project_permission(db, project_id, current_user.id, "create_modify")
|
||||
|
||||
# Verify task exists in project
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask).where(
|
||||
ProjectTask.id == task_id,
|
||||
ProjectTask.project_id == project_id,
|
||||
)
|
||||
)
|
||||
task = task_result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Get project owner for connection validation
|
||||
project_result = await db.execute(
|
||||
select(Project.user_id, Project.name).where(Project.id == project_id)
|
||||
)
|
||||
project_row = project_result.one()
|
||||
owner_id, project_name = project_row.tuple()
|
||||
|
||||
# Validate connections (all assignees must be connections of the project owner)
|
||||
non_owner_ids = [uid for uid in assignment.user_ids if uid != owner_id]
|
||||
if non_owner_ids:
|
||||
await validate_project_connections(db, owner_id, non_owner_ids)
|
||||
|
||||
# Filter out existing assignments
|
||||
existing_result = await db.execute(
|
||||
select(ProjectTaskAssignment.user_id).where(
|
||||
ProjectTaskAssignment.task_id == task_id,
|
||||
ProjectTaskAssignment.user_id.in_(assignment.user_ids),
|
||||
)
|
||||
)
|
||||
existing_user_ids = {r[0] for r in existing_result.all()}
|
||||
|
||||
assigner_name = await _get_user_name(db, current_user.id)
|
||||
created = []
|
||||
|
||||
for uid in assignment.user_ids:
|
||||
if uid in existing_user_ids:
|
||||
continue
|
||||
|
||||
# Auto-membership: ensure user has ProjectMember row
|
||||
if uid != owner_id:
|
||||
await ensure_auto_membership(db, project_id, uid, current_user.id)
|
||||
|
||||
new_assignment = ProjectTaskAssignment(
|
||||
task_id=task_id,
|
||||
user_id=uid,
|
||||
assigned_by=current_user.id,
|
||||
)
|
||||
db.add(new_assignment)
|
||||
created.append(new_assignment)
|
||||
|
||||
# Notify assignee (don't notify self)
|
||||
if uid != current_user.id:
|
||||
await create_notification(
|
||||
db, uid, "task_assigned",
|
||||
f"Task assigned by {assigner_name}",
|
||||
f"You've been assigned to \"{task.title}\" in \"{project_name}\"",
|
||||
data={"project_id": project_id, "task_id": task_id},
|
||||
source_type="task_assignment",
|
||||
)
|
||||
|
||||
await db.flush() # Assign IDs before commit (ORM objects expire after commit)
|
||||
assignment_ids = [a.id for a in created]
|
||||
await db.commit()
|
||||
|
||||
if not created:
|
||||
return []
|
||||
|
||||
# Re-fetch with user info
|
||||
result = await db.execute(
|
||||
select(ProjectTaskAssignment)
|
||||
.options(selectinload(ProjectTaskAssignment.user))
|
||||
.where(ProjectTaskAssignment.id.in_(assignment_ids))
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
|
||||
# Get names
|
||||
user_ids = [a.user_id for a in assignments]
|
||||
settings_result = await db.execute(
|
||||
select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids))
|
||||
)
|
||||
name_map = {r[0]: r[1] for r in settings_result.all()}
|
||||
|
||||
return [
|
||||
TaskAssignmentResponse(
|
||||
id=a.id,
|
||||
task_id=a.task_id,
|
||||
user_id=a.user_id,
|
||||
assigned_by=a.assigned_by,
|
||||
user_name=name_map.get(a.user_id) or a.user.username,
|
||||
created_at=a.created_at,
|
||||
)
|
||||
for a in assignments
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{project_id}/tasks/{task_id}/assignments/{user_id}", status_code=204)
|
||||
async def remove_task_assignment(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
task_id: int = Path(ge=1, le=2147483647),
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Remove a task assignment. Owner, create_modify member, or the assignee themselves."""
|
||||
perm = await get_project_permission(db, project_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Self-unassign is always allowed; otherwise need create_modify or owner
|
||||
if user_id != current_user.id and perm not in ("owner", "create_modify"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission")
|
||||
|
||||
result = await db.execute(
|
||||
sa_delete(ProjectTaskAssignment)
|
||||
.where(
|
||||
ProjectTaskAssignment.task_id == task_id,
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
)
|
||||
.returning(ProjectTaskAssignment.id)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Cleanup auto-membership if no more assignments
|
||||
await cleanup_auto_membership(db, project_id, user_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# DELTA POLLING
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
class PollResponse(BaseModel):
|
||||
has_changes: bool
|
||||
project_updated_at: str | None = None
|
||||
changed_task_ids: list[int] = []
|
||||
|
||||
|
||||
@router.get("/{project_id}/poll", response_model=PollResponse)
|
||||
async def poll_project(
|
||||
project_id: int = Path(ge=1, le=2147483647),
|
||||
since: str = Query(..., description="ISO timestamp to check for changes since"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Lightweight poll endpoint — returns changed task IDs since timestamp."""
|
||||
await require_project_permission(db, project_id, current_user.id, "read_only")
|
||||
|
||||
try:
|
||||
since_dt = datetime.fromisoformat(since)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid ISO timestamp")
|
||||
|
||||
# Clamp to max 24h in the past to prevent expensive full-table scans
|
||||
min_since = datetime.now() - timedelta(hours=24)
|
||||
if since_dt < min_since:
|
||||
since_dt = min_since
|
||||
|
||||
# Check project-level update
|
||||
proj_result = await db.execute(
|
||||
select(Project.updated_at).where(Project.id == project_id)
|
||||
)
|
||||
project_updated = proj_result.scalar_one_or_none()
|
||||
if not project_updated:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
project_changed = project_updated > since_dt
|
||||
|
||||
# Check task-level changes using the index
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask.id).where(
|
||||
ProjectTask.project_id == project_id,
|
||||
ProjectTask.updated_at > since_dt,
|
||||
)
|
||||
)
|
||||
changed_task_ids = [r[0] for r in task_result.all()]
|
||||
|
||||
has_changes = project_changed or len(changed_task_ids) > 0
|
||||
|
||||
return PollResponse(
|
||||
has_changes=has_changes,
|
||||
project_updated_at=project_updated.isoformat() if project_updated else None,
|
||||
changed_task_ids=changed_task_ids,
|
||||
)
|
||||
|
||||
@ -18,9 +18,10 @@ Security:
|
||||
- totp-verify uses mfa_token (not session cookie) — user is not yet authenticated
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
@ -31,21 +32,16 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.session import UserSession
|
||||
from app.models.totp_usage import TOTPUsage
|
||||
from app.models.backup_code import BackupCode
|
||||
from app.routers.auth import get_current_user
|
||||
from app.routers.auth import get_current_user, _set_session_cookie
|
||||
from app.services.audit import get_client_ip
|
||||
from app.services.auth import (
|
||||
averify_password_with_upgrade,
|
||||
verify_mfa_token,
|
||||
verify_mfa_enforce_token,
|
||||
)
|
||||
from app.services.session import (
|
||||
create_db_session,
|
||||
set_session_cookie,
|
||||
check_account_lockout,
|
||||
record_failed_login,
|
||||
record_successful_login,
|
||||
create_session_token,
|
||||
)
|
||||
from app.services.totp import (
|
||||
generate_totp_secret,
|
||||
@ -56,7 +52,7 @@ from app.services.totp import (
|
||||
generate_qr_base64,
|
||||
generate_backup_codes,
|
||||
)
|
||||
|
||||
from app.config import settings as app_settings
|
||||
|
||||
# Argon2id for backup code hashing — treat each code like a password
|
||||
from argon2 import PasswordHasher
|
||||
@ -166,6 +162,29 @@ async def _verify_backup_code(
|
||||
return False
|
||||
|
||||
|
||||
async def _create_full_session(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
request: Request,
|
||||
) -> str:
|
||||
"""Create a UserSession row and return the signed cookie token."""
|
||||
session_id = uuid.uuid4().hex
|
||||
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
|
||||
ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
|
||||
db_session = UserSession(
|
||||
id=session_id,
|
||||
user_id=user.id,
|
||||
expires_at=expires_at,
|
||||
ip_address=ip[:45] if ip else None,
|
||||
user_agent=(user_agent or "")[:255] if user_agent else None,
|
||||
)
|
||||
db.add(db_session)
|
||||
await db.commit()
|
||||
return create_session_token(user.id, session_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -269,55 +288,60 @@ async def totp_verify(
|
||||
raise HTTPException(status_code=400, detail="TOTP not configured for this account")
|
||||
|
||||
# Check account lockout (shared counter with password failures)
|
||||
await check_account_lockout(user)
|
||||
if user.locked_until and datetime.now() < user.locked_until:
|
||||
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=f"Account locked. Try again in {remaining} minutes.",
|
||||
)
|
||||
|
||||
# --- Backup code path ---
|
||||
if data.backup_code:
|
||||
normalized = data.backup_code.strip().upper()
|
||||
valid = await _verify_backup_code(db, user.id, normalized)
|
||||
if not valid:
|
||||
remaining = await record_failed_login(db, user)
|
||||
user.failed_login_count += 1
|
||||
if user.failed_login_count >= 10:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||
await db.commit()
|
||||
if remaining == 0:
|
||||
raise HTTPException(status_code=401, detail="Account temporarily locked. Try again in 30 minutes.")
|
||||
raise HTTPException(status_code=401, detail="Invalid backup code")
|
||||
|
||||
# Backup code accepted — reset lockout counter and issue session
|
||||
await record_successful_login(db, user)
|
||||
|
||||
ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
token = await _create_full_session(db, user, request)
|
||||
_set_session_cookie(response, token)
|
||||
return {"authenticated": True}
|
||||
|
||||
# --- TOTP code path ---
|
||||
matched_window = verify_totp_code(user.totp_secret, data.code)
|
||||
if matched_window is None:
|
||||
remaining = await record_failed_login(db, user)
|
||||
user.failed_login_count += 1
|
||||
if user.failed_login_count >= 10:
|
||||
user.locked_until = datetime.now() + timedelta(minutes=30)
|
||||
await db.commit()
|
||||
if remaining == 0:
|
||||
raise HTTPException(status_code=401, detail="Account temporarily locked. Try again in 30 minutes.")
|
||||
raise HTTPException(status_code=401, detail="Invalid code")
|
||||
|
||||
# Replay prevention — record (user_id, code, actual_matching_window)
|
||||
totp_record = TOTPUsage(user_id=user.id, code=data.code, window=matched_window)
|
||||
db.add(totp_record)
|
||||
try:
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=401, detail="Code already used — wait for the next code")
|
||||
|
||||
# Success — reset lockout counter, update last_login_at, issue full session
|
||||
await record_successful_login(db, user)
|
||||
|
||||
ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
token = await _create_full_session(db, user, request)
|
||||
_set_session_cookie(response, token)
|
||||
return {"authenticated": True}
|
||||
|
||||
|
||||
@ -489,11 +513,9 @@ async def enforce_confirm_totp(
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
# Issue a full session (now uses shared session service with cap enforcement)
|
||||
ip = get_client_ip(request)
|
||||
user_agent = request.headers.get("user-agent")
|
||||
_, token = await create_db_session(db, user, ip, user_agent)
|
||||
set_session_cookie(response, token)
|
||||
# Issue a full session
|
||||
token = await _create_full_session(db, user, request)
|
||||
_set_session_cookie(response, token)
|
||||
|
||||
return {"authenticated": True}
|
||||
|
||||
|
||||
@ -30,7 +30,6 @@ class UserListItem(BaseModel):
|
||||
last_password_change_at: Optional[datetime] = None
|
||||
totp_enabled: bool
|
||||
mfa_enforce_pending: bool
|
||||
passwordless_enabled: bool = False
|
||||
created_at: datetime
|
||||
active_sessions: int = 0
|
||||
|
||||
@ -108,7 +107,6 @@ class ToggleMfaEnforceRequest(BaseModel):
|
||||
class SystemConfigResponse(BaseModel):
|
||||
allow_registration: bool
|
||||
enforce_mfa_new_users: bool
|
||||
allow_passwordless: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@ -117,12 +115,6 @@ class SystemConfigUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
allow_registration: Optional[bool] = None
|
||||
enforce_mfa_new_users: Optional[bool] = None
|
||||
allow_passwordless: Optional[bool] = None
|
||||
|
||||
|
||||
class TogglePasswordlessRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
enabled: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
import logging
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List, Literal
|
||||
from app.schemas.project_task import ProjectTaskResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "review", "on_hold"]
|
||||
|
||||
|
||||
@ -33,44 +30,18 @@ class ProjectUpdate(BaseModel):
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: int
|
||||
user_id: int = 0
|
||||
name: str
|
||||
description: Optional[str]
|
||||
status: str
|
||||
color: Optional[str]
|
||||
due_date: Optional[date]
|
||||
is_tracked: bool
|
||||
member_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tasks: List[ProjectTaskResponse] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def compute_member_count(cls, data): # type: ignore[override]
|
||||
"""Compute member_count from eagerly loaded members relationship."""
|
||||
if hasattr(data, "members"):
|
||||
try:
|
||||
data = dict(
|
||||
id=data.id,
|
||||
user_id=data.user_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
status=data.status,
|
||||
color=data.color,
|
||||
due_date=data.due_date,
|
||||
is_tracked=data.is_tracked,
|
||||
member_count=len([m for m in data.members if m.status == "accepted"]),
|
||||
created_at=data.created_at,
|
||||
updated_at=data.updated_at,
|
||||
tasks=data.tasks,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("member_count compute skipped: %s", exc)
|
||||
return data
|
||||
|
||||
|
||||
class TrackedTaskResponse(BaseModel):
|
||||
id: int
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
from typing import Annotated, Optional, Literal
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime
|
||||
|
||||
MemberPermission = Literal["read_only", "create_modify"]
|
||||
MemberStatus = Literal["pending", "accepted", "rejected"]
|
||||
InviteResponse = Literal["accepted", "rejected"]
|
||||
|
||||
|
||||
class ProjectMemberInvite(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=10)
|
||||
permission: MemberPermission = "create_modify"
|
||||
|
||||
|
||||
class ProjectMemberUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
permission: MemberPermission
|
||||
|
||||
|
||||
class ProjectMemberRespond(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
response: InviteResponse
|
||||
|
||||
|
||||
class ProjectMemberResponse(BaseModel):
|
||||
id: int
|
||||
project_id: int
|
||||
user_id: int
|
||||
invited_by: int
|
||||
permission: str
|
||||
status: str
|
||||
source: str
|
||||
user_name: str | None = None
|
||||
inviter_name: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
accepted_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@ -2,7 +2,6 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List, Literal
|
||||
from app.schemas.task_comment import TaskCommentResponse
|
||||
from app.schemas.project_task_assignment import TaskAssignmentResponse
|
||||
|
||||
TaskStatus = Literal["pending", "in_progress", "completed", "blocked", "review", "on_hold"]
|
||||
TaskPriority = Literal["none", "low", "medium", "high"]
|
||||
@ -31,7 +30,6 @@ class ProjectTaskUpdate(BaseModel):
|
||||
due_date: Optional[date] = None
|
||||
person_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
version: Optional[int] = None # For optimistic locking
|
||||
|
||||
|
||||
class ProjectTaskResponse(BaseModel):
|
||||
@ -45,12 +43,10 @@ class ProjectTaskResponse(BaseModel):
|
||||
due_date: Optional[date]
|
||||
person_id: Optional[int]
|
||||
sort_order: int
|
||||
version: int = 1
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
subtasks: List["ProjectTaskResponse"] = []
|
||||
comments: List[TaskCommentResponse] = []
|
||||
assignments: List[TaskAssignmentResponse] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
from typing import Annotated
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TaskAssignmentCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class TaskAssignmentResponse(BaseModel):
|
||||
id: int
|
||||
task_id: int
|
||||
user_id: int
|
||||
assigned_by: int
|
||||
user_name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_user_name(cls, data): # type: ignore[override]
|
||||
"""Populate user_name from eagerly loaded user relationship."""
|
||||
if hasattr(data, "user") and data.user is not None and not getattr(data, "user_name", None):
|
||||
# Build dict from ORM columns so new fields are auto-included
|
||||
cols = {c.key: getattr(data, c.key) for c in data.__table__.columns}
|
||||
cols["user_name"] = data.user.username
|
||||
return cols
|
||||
return data
|
||||
@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@ -11,19 +11,7 @@ class TaskCommentCreate(BaseModel):
|
||||
class TaskCommentResponse(BaseModel):
|
||||
id: int
|
||||
task_id: int
|
||||
user_id: int | None = None
|
||||
author_name: str | None = None
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_author_name(cls, data): # type: ignore[override]
|
||||
"""Populate author_name from eagerly loaded user relationship."""
|
||||
if hasattr(data, "user") and data.user is not None and not getattr(data, "author_name", None):
|
||||
cols = {c.key: getattr(data, c.key) for c in data.__table__.columns}
|
||||
cols["author_name"] = data.user.username
|
||||
return cols
|
||||
return data
|
||||
|
||||
@ -1,240 +0,0 @@
|
||||
"""
|
||||
Passkey (WebAuthn/FIDO2) service.
|
||||
|
||||
Handles challenge token creation/verification (itsdangerous + nonce replay protection)
|
||||
and wraps py_webauthn library calls for registration and authentication ceremonies.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import threading
|
||||
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
|
||||
from webauthn import (
|
||||
generate_registration_options,
|
||||
verify_registration_response,
|
||||
generate_authentication_options,
|
||||
verify_authentication_response,
|
||||
options_to_json,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
PublicKeyCredentialDescriptor,
|
||||
AuthenticatorSelectionCriteria,
|
||||
AuthenticatorTransport,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
AttestationConveyancePreference,
|
||||
)
|
||||
from webauthn.helpers import (
|
||||
bytes_to_base64url,
|
||||
base64url_to_bytes,
|
||||
parse_registration_credential_json,
|
||||
parse_authentication_credential_json,
|
||||
)
|
||||
|
||||
from app.config import settings as app_settings
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential JSON helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def extract_credential_raw_id(credential_json: str) -> str | None:
|
||||
"""Extract the base64url-encoded rawId from a WebAuthn credential JSON string.
|
||||
|
||||
Returns None if parsing fails.
|
||||
"""
|
||||
try:
|
||||
cred_data = json.loads(credential_json)
|
||||
return cred_data.get("rawId") or cred_data.get("id") or None
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Challenge token management (itsdangerous + nonce replay protection V-01)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_challenge_serializer = URLSafeTimedSerializer(
|
||||
secret_key=app_settings.SECRET_KEY,
|
||||
salt="webauthn-challenge-v1",
|
||||
)
|
||||
|
||||
# Thread-safe nonce cache for single-use enforcement.
|
||||
# Keys: nonce string, Values: expiry timestamp.
|
||||
# NOTE: This is process-local. If scaling to multiple uvicorn workers,
|
||||
# move nonce tracking to Redis or a DB table with unique constraint.
|
||||
# Current deployment: single worker (Dockerfile --workers 1).
|
||||
_used_nonces: dict[str, float] = {}
|
||||
_nonce_lock = threading.Lock()
|
||||
|
||||
|
||||
def create_challenge_token(challenge: bytes, user_id: int | None = None) -> str:
|
||||
"""Sign challenge + nonce + optional user_id. Returns opaque token string."""
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
payload = {
|
||||
"ch": base64.b64encode(challenge).decode(),
|
||||
"n": nonce,
|
||||
}
|
||||
if user_id is not None:
|
||||
payload["uid"] = user_id
|
||||
return _challenge_serializer.dumps(payload)
|
||||
|
||||
|
||||
def verify_challenge_token(token: str, expected_user_id: int | None = None) -> bytes | None:
|
||||
"""Verify token (TTL from config), enforce single-use via nonce.
|
||||
|
||||
If expected_user_id provided, cross-check user binding (for registration).
|
||||
Returns challenge bytes or None on failure.
|
||||
"""
|
||||
try:
|
||||
data = _challenge_serializer.loads(
|
||||
token, max_age=app_settings.WEBAUTHN_CHALLENGE_TTL
|
||||
)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
|
||||
nonce = data.get("n")
|
||||
if not nonce:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
with _nonce_lock:
|
||||
# Lazy cleanup of expired nonces
|
||||
expired = [k for k, v in _used_nonces.items() if v <= now]
|
||||
for k in expired:
|
||||
del _used_nonces[k]
|
||||
|
||||
# Check for replay
|
||||
if nonce in _used_nonces:
|
||||
return None
|
||||
|
||||
# Mark nonce as used
|
||||
_used_nonces[nonce] = now + app_settings.WEBAUTHN_CHALLENGE_TTL
|
||||
|
||||
# Cross-check user binding for registration tokens
|
||||
if expected_user_id is not None:
|
||||
if data.get("uid") != expected_user_id:
|
||||
return None
|
||||
|
||||
return base64.b64decode(data["ch"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# py_webauthn wrappers
|
||||
# All synchronous — ECDSA P-256 verification is ~0.1ms, faster than executor overhead.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_registration_options(
|
||||
user_id: int,
|
||||
username: str,
|
||||
existing_credential_ids: list[bytes],
|
||||
) -> tuple[str, bytes]:
|
||||
"""Generate WebAuthn registration options.
|
||||
|
||||
Returns (options_json_str, challenge_bytes).
|
||||
"""
|
||||
exclude_credentials = [
|
||||
PublicKeyCredentialDescriptor(id=cid)
|
||||
for cid in existing_credential_ids
|
||||
]
|
||||
|
||||
options = generate_registration_options(
|
||||
rp_id=app_settings.WEBAUTHN_RP_ID,
|
||||
rp_name=app_settings.WEBAUTHN_RP_NAME,
|
||||
user_id=str(user_id).encode(),
|
||||
user_name=username,
|
||||
attestation=AttestationConveyancePreference.NONE,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
exclude_credentials=exclude_credentials,
|
||||
timeout=60000,
|
||||
)
|
||||
|
||||
options_json = options_to_json(options)
|
||||
return options_json, options.challenge
|
||||
|
||||
|
||||
def verify_registration(
|
||||
credential_json: str,
|
||||
challenge: bytes,
|
||||
) -> "VerifiedRegistration":
|
||||
"""Verify a registration response from the browser.
|
||||
|
||||
Returns VerifiedRegistration on success, raises on failure.
|
||||
"""
|
||||
credential = parse_registration_credential_json(credential_json)
|
||||
return verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=app_settings.WEBAUTHN_RP_ID,
|
||||
expected_origin=app_settings.WEBAUTHN_ORIGIN,
|
||||
require_user_verification=False,
|
||||
)
|
||||
|
||||
|
||||
def build_authentication_options(
|
||||
credential_ids_and_transports: list[tuple[bytes, list[str] | None]] | None = None,
|
||||
) -> tuple[str, bytes]:
|
||||
"""Generate WebAuthn authentication options.
|
||||
|
||||
If credential_ids_and_transports provided, includes allowCredentials.
|
||||
Otherwise, allows discoverable credential flow.
|
||||
Returns (options_json_str, challenge_bytes).
|
||||
"""
|
||||
allow_credentials = None
|
||||
if credential_ids_and_transports:
|
||||
allow_credentials = []
|
||||
for cid, transports in credential_ids_and_transports:
|
||||
transport_list = None
|
||||
if transports:
|
||||
transport_list = [
|
||||
AuthenticatorTransport(t)
|
||||
for t in transports
|
||||
if t in [e.value for e in AuthenticatorTransport]
|
||||
]
|
||||
allow_credentials.append(
|
||||
PublicKeyCredentialDescriptor(
|
||||
id=cid,
|
||||
transports=transport_list or None,
|
||||
)
|
||||
)
|
||||
|
||||
options = generate_authentication_options(
|
||||
rp_id=app_settings.WEBAUTHN_RP_ID,
|
||||
allow_credentials=allow_credentials,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
timeout=60000,
|
||||
)
|
||||
|
||||
options_json = options_to_json(options)
|
||||
return options_json, options.challenge
|
||||
|
||||
|
||||
def verify_authentication(
|
||||
credential_json: str,
|
||||
challenge: bytes,
|
||||
credential_public_key: bytes,
|
||||
credential_current_sign_count: int,
|
||||
) -> "VerifiedAuthentication":
|
||||
"""Verify an authentication response from the browser.
|
||||
|
||||
Returns VerifiedAuthentication on success, raises on failure.
|
||||
Sign count anomalies are NOT hard-failed — caller should log and continue.
|
||||
"""
|
||||
credential = parse_authentication_credential_json(credential_json)
|
||||
return verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=app_settings.WEBAUTHN_RP_ID,
|
||||
expected_origin=app_settings.WEBAUTHN_ORIGIN,
|
||||
credential_public_key=credential_public_key,
|
||||
credential_current_sign_count=credential_current_sign_count,
|
||||
require_user_verification=False,
|
||||
)
|
||||
@ -1,269 +0,0 @@
|
||||
"""
|
||||
Project sharing service — permission checks, auto-membership, disconnect cascade.
|
||||
|
||||
All functions accept an AsyncSession and do NOT commit — callers manage transactions.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.project import Project
|
||||
from app.models.project_member import ProjectMember
|
||||
from app.models.project_task import ProjectTask
|
||||
from app.models.project_task_assignment import ProjectTaskAssignment
|
||||
from app.models.user_connection import UserConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERMISSION_RANK = {"read_only": 1, "create_modify": 2}
|
||||
|
||||
# Fields task assignees (from assignment, not project membership) may edit
|
||||
ASSIGNEE_ALLOWED_FIELDS = {"title", "description", "status", "priority", "due_date"}
|
||||
|
||||
|
||||
async def get_project_permission(
|
||||
db: AsyncSession, project_id: int, user_id: int
|
||||
) -> str | None:
|
||||
"""
|
||||
Returns 'owner', 'create_modify', 'read_only', or None.
|
||||
Single query with LEFT JOIN (mirrors calendar_sharing pattern).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(
|
||||
Project.user_id,
|
||||
ProjectMember.permission,
|
||||
)
|
||||
.outerjoin(
|
||||
ProjectMember,
|
||||
(ProjectMember.project_id == Project.id)
|
||||
& (ProjectMember.user_id == user_id)
|
||||
& (ProjectMember.status == "accepted"),
|
||||
)
|
||||
.where(Project.id == project_id)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if not row:
|
||||
return None
|
||||
owner_id, member_permission = row.tuple()
|
||||
if owner_id == user_id:
|
||||
return "owner"
|
||||
return member_permission
|
||||
|
||||
|
||||
async def require_project_permission(
|
||||
db: AsyncSession, project_id: int, user_id: int, min_level: str
|
||||
) -> str:
|
||||
"""
|
||||
Raises 404 if project doesn't exist or user has no access.
|
||||
Raises 403 if user has insufficient permission.
|
||||
Returns the actual permission string (or 'owner').
|
||||
"""
|
||||
perm = await get_project_permission(db, project_id, user_id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if perm == "owner":
|
||||
return "owner"
|
||||
if min_level == "owner":
|
||||
raise HTTPException(status_code=403, detail="Only the project owner can perform this action")
|
||||
if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission on this project")
|
||||
return perm
|
||||
|
||||
|
||||
async def get_accessible_project_ids(db: AsyncSession, user_id: int) -> set[int]:
|
||||
"""Returns owned + accepted membership project IDs."""
|
||||
result = await db.execute(
|
||||
select(Project.id).where(Project.user_id == user_id)
|
||||
.union(
|
||||
select(ProjectMember.project_id).where(
|
||||
ProjectMember.user_id == user_id,
|
||||
ProjectMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
)
|
||||
return {r[0] for r in result.all()}
|
||||
|
||||
|
||||
async def validate_project_connections(
|
||||
db: AsyncSession, owner_id: int, user_ids: list[int]
|
||||
) -> None:
|
||||
"""Validates all target users are active connections of the owner. Raises 400 on failure."""
|
||||
if not user_ids:
|
||||
return
|
||||
result = await db.execute(
|
||||
select(UserConnection.connected_user_id).where(
|
||||
UserConnection.user_id == owner_id,
|
||||
UserConnection.connected_user_id.in_(user_ids),
|
||||
)
|
||||
)
|
||||
connected = {r[0] for r in result.all()}
|
||||
missing = set(user_ids) - connected
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Users {sorted(missing)} are not your connections",
|
||||
)
|
||||
|
||||
|
||||
async def get_effective_task_permission(
|
||||
db: AsyncSession, user_id: int, task_id: int, project_id: int
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
Returns (effective_permission, project_level_permission) for a specific task.
|
||||
1. Get project-level permission (owner/create_modify/read_only)
|
||||
2. If user is assigned to THIS task → max(project_perm, create_modify)
|
||||
3. If task has parent and user assigned to PARENT → same as above
|
||||
4. Return (effective, project_level)
|
||||
"""
|
||||
project_perm = await get_project_permission(db, project_id, user_id)
|
||||
if project_perm is None:
|
||||
return None, None
|
||||
if project_perm == "owner":
|
||||
return "owner", "owner"
|
||||
|
||||
# Check direct assignment on this task
|
||||
task_result = await db.execute(
|
||||
select(ProjectTask.parent_task_id).where(ProjectTask.id == task_id)
|
||||
)
|
||||
task_row = task_result.one_or_none()
|
||||
if not task_row:
|
||||
return project_perm, project_perm
|
||||
|
||||
parent_task_id = task_row[0]
|
||||
|
||||
# Check assignment on this task or its parent
|
||||
check_task_ids = [task_id]
|
||||
if parent_task_id is not None:
|
||||
check_task_ids.append(parent_task_id)
|
||||
|
||||
assignment_result = await db.execute(
|
||||
select(ProjectTaskAssignment.id).where(
|
||||
ProjectTaskAssignment.task_id.in_(check_task_ids),
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
).limit(1)
|
||||
)
|
||||
if assignment_result.scalar_one_or_none() is not None:
|
||||
# Assignment grants at least create_modify
|
||||
if PERMISSION_RANK.get(project_perm, 0) >= PERMISSION_RANK["create_modify"]:
|
||||
return project_perm, project_perm
|
||||
return "create_modify", project_perm
|
||||
|
||||
return project_perm, project_perm
|
||||
|
||||
|
||||
async def ensure_auto_membership(
|
||||
db: AsyncSession, project_id: int, user_id: int, invited_by: int
|
||||
) -> None:
|
||||
"""
|
||||
When assigning a user to a task, ensure they have a ProjectMember row.
|
||||
If none exists, create one with read_only + auto_assigned + accepted (no invite flow).
|
||||
"""
|
||||
existing = await db.execute(
|
||||
select(ProjectMember.id).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
return
|
||||
|
||||
member = ProjectMember(
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
invited_by=invited_by,
|
||||
permission="read_only",
|
||||
status="accepted",
|
||||
source="auto_assigned",
|
||||
accepted_at=datetime.now(),
|
||||
)
|
||||
db.add(member)
|
||||
|
||||
|
||||
async def cleanup_auto_membership(
|
||||
db: AsyncSession, project_id: int, user_id: int
|
||||
) -> None:
|
||||
"""
|
||||
After removing a task assignment, check if user has any remaining assignments
|
||||
in this project. If not and membership is auto_assigned, remove it.
|
||||
"""
|
||||
remaining = await db.execute(
|
||||
select(ProjectTaskAssignment.id)
|
||||
.join(ProjectTask, ProjectTaskAssignment.task_id == ProjectTask.id)
|
||||
.where(
|
||||
ProjectTask.project_id == project_id,
|
||||
ProjectTaskAssignment.user_id == user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if remaining.scalar_one_or_none() is not None:
|
||||
return # Still has assignments
|
||||
|
||||
# Remove auto_assigned membership only
|
||||
await db.execute(
|
||||
delete(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user_id,
|
||||
ProjectMember.source == "auto_assigned",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def cascade_projects_on_disconnect(
|
||||
db: AsyncSession, user_a_id: int, user_b_id: int
|
||||
) -> None:
|
||||
"""
|
||||
When a connection is severed:
|
||||
1. Find all ProjectMember rows where one user is a member of the other's projects
|
||||
2. Find all ProjectTaskAssignment rows for those memberships
|
||||
3. Remove assignments, then remove memberships
|
||||
"""
|
||||
# Single query: find projects owned by each user
|
||||
result = await db.execute(
|
||||
select(Project.id, Project.user_id).where(
|
||||
Project.user_id.in_([user_a_id, user_b_id])
|
||||
)
|
||||
)
|
||||
a_proj_ids: list[int] = []
|
||||
b_proj_ids: list[int] = []
|
||||
for proj_id, owner_id in result.all():
|
||||
if owner_id == user_a_id:
|
||||
a_proj_ids.append(proj_id)
|
||||
else:
|
||||
b_proj_ids.append(proj_id)
|
||||
|
||||
# Remove user_b's assignments + memberships on user_a's projects
|
||||
if a_proj_ids:
|
||||
await db.execute(
|
||||
delete(ProjectTaskAssignment).where(
|
||||
ProjectTaskAssignment.user_id == user_b_id,
|
||||
ProjectTaskAssignment.task_id.in_(
|
||||
select(ProjectTask.id).where(ProjectTask.project_id.in_(a_proj_ids))
|
||||
),
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(ProjectMember).where(
|
||||
ProjectMember.project_id.in_(a_proj_ids),
|
||||
ProjectMember.user_id == user_b_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Remove user_a's assignments + memberships on user_b's projects
|
||||
if b_proj_ids:
|
||||
await db.execute(
|
||||
delete(ProjectTaskAssignment).where(
|
||||
ProjectTaskAssignment.user_id == user_a_id,
|
||||
ProjectTaskAssignment.task_id.in_(
|
||||
select(ProjectTask.id).where(ProjectTask.project_id.in_(b_proj_ids))
|
||||
),
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(ProjectMember).where(
|
||||
ProjectMember.project_id.in_(b_proj_ids),
|
||||
ProjectMember.user_id == user_a_id,
|
||||
)
|
||||
)
|
||||
@ -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
|
||||
@ -15,4 +15,3 @@ python-dateutil==2.9.0
|
||||
itsdangerous==2.2.0
|
||||
httpx==0.27.2
|
||||
apscheduler==3.10.4
|
||||
webauthn>=2.1.0,<3
|
||||
|
||||
@ -2,10 +2,7 @@ services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
env_file: .env
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@ -22,17 +19,9 @@ services:
|
||||
cpus: "1.0"
|
||||
|
||||
backend:
|
||||
image: git.sentinelforest.xyz/rohskiddo/umbra-backend:main-latest
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- ENVIRONMENT=${ENVIRONMENT:-production}
|
||||
- UMBRA_URL=${UMBRA_URL:-https://umbra.ghost6.xyz}
|
||||
- OPENWEATHERMAP_API_KEY=${OPENWEATHERMAP_API_KEY:-}
|
||||
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-umbra.ghost6.xyz}
|
||||
- WEBAUTHN_RP_NAME=${WEBAUTHN_RP_NAME:-UMBRA}
|
||||
- WEBAUTHN_ORIGIN=${WEBAUTHN_ORIGIN:-https://umbra.ghost6.xyz}
|
||||
env_file: .env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@ -41,7 +30,7 @@ services:
|
||||
- frontend_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
|
||||
interval: 30s
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
@ -52,7 +41,7 @@ services:
|
||||
cpus: "1.0"
|
||||
|
||||
frontend:
|
||||
image: git.sentinelforest.xyz/rohskiddo/umbra-frontend:main-latest
|
||||
build: ./frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:8080"
|
||||
@ -62,7 +51,7 @@ services:
|
||||
networks:
|
||||
- frontend_net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:8080/"]
|
||||
test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>UMBRA</title>
|
||||
<!-- Static style tag — survives Vite's head cleanup (unlike dynamically created elements).
|
||||
The inline script below populates it with accent color from localStorage cache. -->
|
||||
|
||||
@ -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;
|
||||
# Event creation — recurrence amplification means 1 POST = up to 90-365 child rows
|
||||
limit_req_zone $binary_remote_addr zone=event_create_limit:10m rate=30r/m;
|
||||
# Health endpoint — lightweight but rate-limited for resilience
|
||||
limit_req_zone $binary_remote_addr zone=health_limit:1m rate=30r/m;
|
||||
|
||||
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
|
||||
map $http_x_forwarded_proto $forwarded_proto {
|
||||
@ -31,14 +29,13 @@ server {
|
||||
# Suppress nginx version in Server header
|
||||
server_tokens off;
|
||||
|
||||
# ── Real client IP restoration (PT-01 / F-03) ─────────────────────
|
||||
# ── Real client IP restoration (PT-01) ────────────────────────────
|
||||
# Pangolin (TLS-terminating reverse proxy) connects via Docker bridge.
|
||||
# Restore the real client IP from X-Forwarded-For so that limit_req_zone
|
||||
# (which keys on $binary_remote_addr) throttles per-client, not per-proxy.
|
||||
# Restricted to RFC 1918 ranges only — trusting 0.0.0.0/0 would allow an
|
||||
# external client to spoof X-Forwarded-For and bypass rate limiting (F-03).
|
||||
set_real_ip_from 172.16.0.0/12;
|
||||
set_real_ip_from 10.0.0.0/8;
|
||||
# Safe to trust all sources: nginx is only reachable via Docker networking,
|
||||
# never directly internet-facing. Tighten if deployment model changes.
|
||||
set_real_ip_from 0.0.0.0/0;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
|
||||
@ -86,36 +83,6 @@ server {
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Passkey authentication — rate-limited (C-04)
|
||||
location /api/auth/passkeys/login/begin {
|
||||
limit_req zone=auth_limit burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
location /api/auth/passkeys/login/complete {
|
||||
limit_req zone=auth_limit burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
# Passkey registration — authenticated, lower burst
|
||||
location /api/auth/passkeys/register/begin {
|
||||
limit_req zone=auth_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
location /api/auth/passkeys/register/complete {
|
||||
limit_req zone=auth_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Passwordless toggle — enable accepts password, rate-limit against brute force
|
||||
location /api/auth/passkeys/passwordless {
|
||||
limit_req zone=auth_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# SEC-14: Rate-limit public registration endpoint
|
||||
location /api/auth/register {
|
||||
limit_req zone=register_limit burst=3 nodelay;
|
||||
@ -166,13 +133,6 @@ server {
|
||||
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)
|
||||
location /api {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@ -193,7 +153,7 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
}
|
||||
|
||||
@ -201,8 +161,8 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
# PT-I03: Restrict unnecessary browser APIs
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
|
||||
}
|
||||
|
||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@ -16,7 +16,6 @@
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/react": "^6.1.15",
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@ -1349,22 +1348,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-10.0.0.tgz",
|
||||
"integrity": "sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/types": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/types": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz",
|
||||
"integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.20",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/react": "^6.1.15",
|
||||
"@fullcalendar/timegrid": "^6.1.15",
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@ -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-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
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;
|
||||
|
||||
@ -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 |
@ -81,7 +81,7 @@ export default function IAMPage() {
|
||||
);
|
||||
}, [users, searchQuery]);
|
||||
|
||||
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users' | 'allow_passwordless', value: boolean) => {
|
||||
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => {
|
||||
try {
|
||||
await updateConfig.mutateAsync({ [key]: value });
|
||||
toast.success('System settings updated');
|
||||
@ -123,8 +123,8 @@ export default function IAMPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User table — relative z-10 so action dropdowns render above sibling cards */}
|
||||
<Card className="relative z-10">
|
||||
{/* User table */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between flex-wrap gap-2 md:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<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.'}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-card-elevated/50">
|
||||
@ -320,20 +320,6 @@ export default function IAMPage() {
|
||||
disabled={updateConfig.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">Allow Passwordless Login</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow users to enable passkey-only login, skipping the password prompt entirely.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.allow_passwordless ?? false}
|
||||
onCheckedChange={(v) => handleConfigToggle('allow_passwordless', v)}
|
||||
disabled={updateConfig.isPending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ShieldOff,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
@ -24,7 +23,6 @@ import {
|
||||
useToggleUserActive,
|
||||
useRevokeSessions,
|
||||
useDeleteUser,
|
||||
useDisablePasswordless,
|
||||
getErrorMessage,
|
||||
} from '@/hooks/useAdmin';
|
||||
import type { AdminUserDetail, UserRole } from '@/types';
|
||||
@ -55,7 +53,6 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
||||
const toggleActive = useToggleUserActive();
|
||||
const revokeSessions = useRevokeSessions();
|
||||
const deleteUser = useDeleteUser();
|
||||
const disablePasswordless = useDisablePasswordless();
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
@ -105,10 +102,6 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
||||
}
|
||||
});
|
||||
|
||||
const disablePasswordlessConfirm = useConfirmAction(() => {
|
||||
handleAction(() => disablePasswordless.mutateAsync(user.id), 'Passwordless login disabled');
|
||||
});
|
||||
|
||||
const isLoading =
|
||||
updateRole.isPending ||
|
||||
resetPassword.isPending ||
|
||||
@ -117,8 +110,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
||||
removeMfaEnforcement.isPending ||
|
||||
toggleActive.isPending ||
|
||||
revokeSessions.isPending ||
|
||||
deleteUser.isPending ||
|
||||
disablePasswordless.isPending;
|
||||
deleteUser.isPending;
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
@ -266,21 +258,6 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
|
||||
</button>
|
||||
)}
|
||||
|
||||
{user.passwordless_enabled && (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
||||
disablePasswordlessConfirm.confirming
|
||||
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
|
||||
: 'hover:bg-card-elevated'
|
||||
)}
|
||||
onClick={disablePasswordlessConfirm.handleClick}
|
||||
>
|
||||
<ShieldOff className="h-4 w-4" />
|
||||
{disablePasswordlessConfirm.confirming ? 'Sure? Click to confirm' : 'Disable Passwordless'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="my-1 border-t border-border" />
|
||||
|
||||
{/* Disable / Enable Account */}
|
||||
|
||||
@ -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
|
||||
label="Must Change Pwd"
|
||||
value={user.must_change_password ? 'Yes' : 'No'}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertTriangle, Copy, Fingerprint, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
|
||||
import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -10,7 +10,6 @@ import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AmbientBackground from './AmbientBackground';
|
||||
import type { TotpSetupResponse } from '@/types';
|
||||
|
||||
@ -48,8 +47,6 @@ export default function LockScreen() {
|
||||
isRegisterPending,
|
||||
isSetupPending,
|
||||
isTotpPending,
|
||||
passkeyLogin,
|
||||
isPasskeyLoginPending,
|
||||
} = useAuth();
|
||||
|
||||
// ── Shared credential fields ──
|
||||
@ -86,31 +83,6 @@ export default function LockScreen() {
|
||||
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
|
||||
const [isForcePwPending, setIsForcePwPending] = useState(false);
|
||||
|
||||
// ── Passkey support (U-01: browser feature detection, not per-user) ──
|
||||
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
setLoginError(null);
|
||||
try {
|
||||
const result = await passkeyLogin();
|
||||
if (result?.must_change_password) {
|
||||
setMode('force_pw');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
toast.info('Passkey not recognized. Try your password.');
|
||||
} else if (error.name === 'AbortError') {
|
||||
// User cancelled — silent
|
||||
} else {
|
||||
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
|
||||
}
|
||||
} else {
|
||||
toast.error(getErrorMessage(error, 'Passkey login failed. Try your password.'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Redirect authenticated users (no pending MFA flows)
|
||||
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
@ -155,10 +127,11 @@ export default function LockScreen() {
|
||||
// mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status;
|
||||
if (status === 403) {
|
||||
if (status === 423) {
|
||||
setLoginError(error.response.data?.detail || 'Account locked. Try again later.');
|
||||
} else if (status === 403) {
|
||||
setLoginError(error.response.data?.detail || 'Account is disabled. Contact an administrator.');
|
||||
} else {
|
||||
// 401 covers both wrong password and account lockout (backend embeds detail string)
|
||||
setLoginError(getErrorMessage(error, 'Invalid username or password'));
|
||||
}
|
||||
}
|
||||
@ -518,28 +491,18 @@ export default function LockScreen() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loginError && (() => {
|
||||
const isLockWarning =
|
||||
loginError.includes('remaining') || loginError.includes('temporarily locked');
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border px-3 py-2 mb-4',
|
||||
isLockWarning
|
||||
? 'bg-amber-500/10 border-amber-500/30'
|
||||
: 'bg-red-500/10 border-red-500/30'
|
||||
)}
|
||||
>
|
||||
{isLockWarning
|
||||
? <Lock className="h-4 w-4 text-amber-400 shrink-0" aria-hidden="true" />
|
||||
: <AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />}
|
||||
<p className={cn('text-xs', isLockWarning ? 'text-amber-400' : 'text-red-400')}>
|
||||
{loginError}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{loginError && (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-red-500/30',
|
||||
'bg-red-500/10 px-3 py-2 mb-4'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
|
||||
<p className="text-xs text-red-400">{loginError}</p>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleCredentialSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" required>Username</Label>
|
||||
@ -598,30 +561,6 @@ export default function LockScreen() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Passkey login — shown when browser supports WebAuthn (U-01) */}
|
||||
{!isSetup && supportsWebAuthn && (
|
||||
<>
|
||||
<div className="relative my-4">
|
||||
<Separator />
|
||||
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={handlePasskeyLogin}
|
||||
disabled={isPasskeyLoginPending}
|
||||
aria-label="Sign in with a passkey"
|
||||
>
|
||||
{isPasskeyLoginPending
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Fingerprint className="h-4 w-4" />}
|
||||
Sign in with a passkey
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Open registration link — only shown on login screen when enabled */}
|
||||
{!isSetup && registrationOpen && (
|
||||
<div className="mt-4 text-center">
|
||||
|
||||
@ -51,10 +51,7 @@ export default function CalendarPage() {
|
||||
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const firstDayOfWeek = settings?.first_day_of_week ?? 0;
|
||||
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 calendarContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -110,10 +107,6 @@ export default function CalendarPage() {
|
||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth));
|
||||
}, [sidebarWidth]);
|
||||
|
||||
const handleMiniCalClick = useCallback((dateStr: string) => {
|
||||
calendarRef.current?.getApi().gotoDate(dateStr);
|
||||
}, []);
|
||||
|
||||
// Location data for event panel
|
||||
const { data: locations = [] } = useQuery({
|
||||
queryKey: ['locations'],
|
||||
@ -194,13 +187,12 @@ export default function CalendarPage() {
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [panelOpen]);
|
||||
|
||||
// Scroll wheel navigation in month view (disabled when detail panel is open)
|
||||
// Scroll wheel navigation in month view
|
||||
useEffect(() => {
|
||||
const el = calendarContainerRef.current;
|
||||
if (!el) return;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (panelOpen) return;
|
||||
// Skip wheel navigation on touch devices (let them scroll normally)
|
||||
if ('ontouchstart' in window) return;
|
||||
const api = calendarRef.current?.getApi();
|
||||
@ -215,7 +207,7 @@ export default function CalendarPage() {
|
||||
};
|
||||
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', handleWheel);
|
||||
}, [panelOpen]);
|
||||
}, []);
|
||||
|
||||
// AW-2: Track visible date range for scoped event fetching
|
||||
// W-02 fix: Initialize from current month to avoid unscoped first fetch
|
||||
@ -520,10 +512,6 @@ export default function CalendarPage() {
|
||||
setVisibleRange((prev) =>
|
||||
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();
|
||||
@ -602,7 +590,7 @@ export default function CalendarPage() {
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden animate-fade-in">
|
||||
<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
|
||||
onMouseDown={handleSidebarMouseDown}
|
||||
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}>
|
||||
<SheetContent className="w-72 p-0">
|
||||
<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>
|
||||
</Sheet>
|
||||
)}
|
||||
@ -723,12 +711,12 @@ export default function CalendarPage() {
|
||||
>
|
||||
<div className="h-full">
|
||||
<FullCalendar
|
||||
key={`fc-${firstDayOfWeek}`}
|
||||
key={`fc-${settings?.first_day_of_week ?? 0}`}
|
||||
ref={calendarRef}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
headerToolbar={false}
|
||||
firstDay={firstDayOfWeek}
|
||||
firstDay={settings?.first_day_of_week ?? 0}
|
||||
events={calendarEvents}
|
||||
editable={true}
|
||||
selectable={true}
|
||||
|
||||
@ -10,19 +10,14 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import CalendarForm from './CalendarForm';
|
||||
import TemplateForm from './TemplateForm';
|
||||
import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedCalendarSection';
|
||||
import MiniCalendar from './MiniCalendar';
|
||||
|
||||
interface CalendarSidebarProps {
|
||||
onUseTemplate?: (template: EventTemplate) => void;
|
||||
onSharedVisibilityChange?: (visibleIds: Set<number>) => void;
|
||||
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 { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@ -100,36 +95,20 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => { setEditingCalendar(null); setShowForm(true); }}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</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">
|
||||
{/* Owned calendars list (non-shared only) */}
|
||||
<div className="space-y-1.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">
|
||||
<div className="space-y-0.5">
|
||||
{calendars.filter((c) => !c.is_shared).map((cal) => (
|
||||
<div
|
||||
key={cal.id}
|
||||
@ -159,7 +138,6 @@ const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(functio
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shared calendars section -- owned + member */}
|
||||
{(calendars.some((c) => c.is_shared) || sharedCalendars.length > 0) && (
|
||||
|
||||
@ -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 { toast } from 'sonner';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
@ -284,17 +284,6 @@ export default function EventDetailPanel({
|
||||
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
|
||||
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
||||
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)
|
||||
// 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,
|
||||
all_day: data.all_day,
|
||||
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) {
|
||||
payload.is_starred = data.is_starred;
|
||||
payload.recurrence_rule = rule;
|
||||
payload.calendar_id = data.calendar_id ? parseInt(data.calendar_id) : null;
|
||||
}
|
||||
|
||||
@ -550,8 +539,7 @@ export default function EventDetailPanel({
|
||||
: event?.title || '';
|
||||
|
||||
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" onWheel={(e) => e.stopPropagation()}>
|
||||
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@ -591,32 +579,32 @@ export default function EventDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setScopeStep(null)}
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (isEditing || isCreating) ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
disabled={saveMutation.isPending}
|
||||
title="Save"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditCancel}
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@ -628,12 +616,12 @@ export default function EventDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditStart}
|
||||
disabled={isAcquiringLock || !!activeLockInfo}
|
||||
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>
|
||||
)}
|
||||
{/* Leave button for invited events */}
|
||||
@ -641,11 +629,11 @@ export default function EventDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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)}
|
||||
title="Leave event"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Delete button for own events */}
|
||||
@ -664,12 +652,12 @@ export default function EventDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
disabled={deleteMutation.isPending}
|
||||
title="Delete event"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
@ -678,11 +666,11 @@ export default function EventDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={onClose}
|
||||
title="Close panel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@ -733,7 +721,7 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
) : (isEditing || isCreating) ? (
|
||||
/* 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) */}
|
||||
{isCreating && (
|
||||
<div className="space-y-1">
|
||||
@ -749,49 +737,58 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All day + Date row */}
|
||||
<div className="space-y-2">
|
||||
<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'));
|
||||
}}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-desc">Description</Label>
|
||||
<Textarea
|
||||
id="panel-desc"
|
||||
value={editState.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder="Add a description..."
|
||||
rows={3}
|
||||
className="text-sm resize-none"
|
||||
/>
|
||||
</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 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
|
||||
/>
|
||||
</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 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>
|
||||
|
||||
{/* Calendar + Location row */}
|
||||
<div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
|
||||
{!canModifyAsInvitee && (
|
||||
<div className="space-y-1">
|
||||
@ -840,32 +837,22 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence + Star row */}
|
||||
{/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */}
|
||||
{!canModifyAsInvitee && (
|
||||
<div className="grid grid-cols-2 gap-3 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
||||
<Select
|
||||
id="panel-recurrence"
|
||||
value={editState.recurrence_type}
|
||||
onChange={(e) => updateField('recurrence_type', e.target.value)}
|
||||
className="text-xs"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="every_n_days">Every X days</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
|
||||
<option value="monthly_date">Monthly (date)</option>
|
||||
</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 className="space-y-1">
|
||||
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
||||
<Select
|
||||
id="panel-recurrence"
|
||||
value={editState.recurrence_type}
|
||||
onChange={(e) => updateField('recurrence_type', e.target.value)}
|
||||
className="text-xs"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="every_n_days">Every X days</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
|
||||
<option value="monthly_date">Monthly (date)</option>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -937,17 +924,23 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description — fills remaining space */}
|
||||
<div className="flex flex-col flex-1 min-h-0 space-y-1">
|
||||
<Label htmlFor="panel-desc">Description</Label>
|
||||
<Textarea
|
||||
ref={descRef}
|
||||
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]"
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="panel-starred"
|
||||
checked={editState.is_starred}
|
||||
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<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>
|
||||
) : (
|
||||
@ -1063,17 +1056,15 @@ export default function EventDetailPanel({
|
||||
</div>
|
||||
|
||||
{/* Description — full width */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
<AlignLeft className="h-3 w-3" />
|
||||
Description
|
||||
</div>
|
||||
{event?.description ? (
|
||||
{event?.description && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
<AlignLeft className="h-3 w-3" />
|
||||
Description
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{event.description}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">—</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invitee section — view mode */}
|
||||
{event && !event.is_virtual && (
|
||||
|
||||
@ -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 { toast } from 'sonner';
|
||||
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 [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 buildRecurrenceRule = (): RecurrenceRule | null => {
|
||||
@ -266,12 +255,10 @@ export default function EventForm({ event, templateData, templateName, initialSt
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
ref={descRef}
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Add a description..."
|
||||
className="min-h-[80px] text-sm"
|
||||
className="min-h-[80px] flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -23,7 +23,7 @@ export default function AlertBanner({ alerts, onDismiss, onSnooze }: AlertBanner
|
||||
{alerts.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="divide-y divide-border max-h-48 overflow-y-auto">
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { usePrefetch } from '@/hooks/usePrefetch';
|
||||
import { AlertsProvider } from '@/hooks/useAlerts';
|
||||
import { LockProvider, useLock } from '@/hooks/useLock';
|
||||
@ -19,27 +17,7 @@ function AppContent({ mobileOpen, setMobileOpen }: {
|
||||
setMobileOpen: (v: boolean) => void;
|
||||
}) {
|
||||
const { isLocked, isLockResolved } = useLock();
|
||||
const { hasPasskeys } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
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(() => {
|
||||
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
|
||||
catch { return false; }
|
||||
|
||||
@ -1,43 +1,34 @@
|
||||
import { useState, FormEvent, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Lock, Loader2, Fingerprint } from 'lucide-react';
|
||||
import { Lock, Loader2 } from 'lucide-react';
|
||||
import { useLock } from '@/hooks/useLock';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AmbientBackground from '@/components/auth/AmbientBackground';
|
||||
|
||||
export default function LockOverlay() {
|
||||
const { isLocked, unlock, unlockWithPasskey } = useLock();
|
||||
const { logout, passwordlessEnabled, hasPasskeys, authStatus } = useAuth();
|
||||
const { isLocked, unlock } = useLock();
|
||||
const { logout } = useAuth();
|
||||
const { settings } = useSettings();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [isUnlocking, setIsUnlocking] = useState(false);
|
||||
const [isPasskeyUnlocking, setIsPasskeyUnlocking] = useState(false);
|
||||
const [supportsWebAuthn] = useState(() => !!window.PublicKeyCredential);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Derive from auth query — has_passkeys covers both owners and any registered passkey
|
||||
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)
|
||||
// Focus password input when lock activates
|
||||
useEffect(() => {
|
||||
if (isLocked && showPasswordForm) {
|
||||
if (isLocked) {
|
||||
setPassword('');
|
||||
// Small delay to let the overlay render
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 100);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [isLocked, showPasswordForm]);
|
||||
}, [isLocked]);
|
||||
|
||||
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 () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
@ -107,87 +75,29 @@ export default function LockOverlay() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Passwordless-primary mode: passkey button only */}
|
||||
{passwordlessEnabled && showPasskeyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full gap-2"
|
||||
onClick={handlePasskeyUnlock}
|
||||
disabled={isPasskeyUnlocking}
|
||||
aria-label="Unlock with passkey"
|
||||
>
|
||||
{isPasskeyUnlocking ? (
|
||||
{/* Password form */}
|
||||
<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" />
|
||||
Verifying passkey
|
||||
Unlocking
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
Unlock with passkey
|
||||
</>
|
||||
'Unlock'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Switch account link */}
|
||||
<button
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
@ -27,9 +26,6 @@ export default function NotificationToaster() {
|
||||
respondRef.current = respond;
|
||||
const markReadRef = useRef(markRead);
|
||||
markReadRef.current = markRead;
|
||||
const navigate = useNavigate();
|
||||
const navigateRef = useRef(navigate);
|
||||
navigateRef.current = navigate;
|
||||
|
||||
const handleConnectionRespond = useCallback(
|
||||
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
|
||||
useEffect(() => {
|
||||
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
||||
@ -177,7 +141,7 @@ export default function NotificationToaster() {
|
||||
initializedRef.current = true;
|
||||
|
||||
// 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(
|
||||
(n) => !n.is_read && actionableTypes.has(n.type),
|
||||
);
|
||||
@ -191,10 +155,6 @@ export default function NotificationToaster() {
|
||||
showCalendarInviteToast(notification);
|
||||
} else if (notification.type === 'event_invite' && notification.data) {
|
||||
showEventInviteToast(notification);
|
||||
} else if (notification.type === 'project_invite' && notification.data) {
|
||||
showProjectInviteToast(notification);
|
||||
} else if (notification.type === 'task_assigned' && notification.data) {
|
||||
showTaskAssignedToast(notification);
|
||||
}
|
||||
});
|
||||
return;
|
||||
@ -223,9 +183,6 @@ export default function NotificationToaster() {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
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
|
||||
newNotifications.forEach((notification) => {
|
||||
@ -235,10 +192,6 @@ export default function NotificationToaster() {
|
||||
showCalendarInviteToast(notification);
|
||||
} else if (notification.type === 'event_invite' && notification.data) {
|
||||
showEventInviteToast(notification);
|
||||
} else if (notification.type === 'project_invite' && notification.data) {
|
||||
showProjectInviteToast(notification);
|
||||
} else if (notification.type === 'task_assigned' && notification.data) {
|
||||
showTaskAssignedToast(notification);
|
||||
} else {
|
||||
toast(notification.title || 'New Notification', {
|
||||
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 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;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 { toast } from 'sonner';
|
||||
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' },
|
||||
event_invite: { icon: Calendar, color: 'text-purple-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' },
|
||||
warning: { icon: AlertCircle, color: 'text-amber-400' },
|
||||
};
|
||||
@ -47,7 +44,6 @@ export default function NotificationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
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
|
||||
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) => {
|
||||
// Don't navigate for pending connection requests — let user act inline
|
||||
if (
|
||||
@ -241,10 +207,6 @@ export default function NotificationsPage() {
|
||||
if (notification.type === 'event_invite' && !notification.is_read) {
|
||||
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) {
|
||||
await markRead([notification.id]).catch(() => {});
|
||||
}
|
||||
@ -256,11 +218,6 @@ export default function NotificationsPage() {
|
||||
if (notification.type === 'event_invite' || notification.type === 'event_invite_response') {
|
||||
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 (
|
||||
@ -451,31 +408,6 @@ export default function NotificationsPage() {
|
||||
</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 */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,262 +1,219 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
useDroppable,
|
||||
useDraggable,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { ProjectTask } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AssigneeAvatars } from './AssignmentPicker';
|
||||
|
||||
const COLUMNS: { id: string; label: string; color: string }[] = [
|
||||
{ id: 'pending', label: 'Pending', color: 'text-gray-400' },
|
||||
{ id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
|
||||
{ id: 'blocked', label: 'Blocked', color: 'text-red-400' },
|
||||
{ id: 'on_hold', label: 'On Hold', color: 'text-orange-400' },
|
||||
{ id: 'review', label: 'Review', color: 'text-yellow-400' },
|
||||
{ id: 'completed', label: 'Completed', color: 'text-green-400' },
|
||||
];
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
none: 'bg-gray-500/20 text-gray-400',
|
||||
low: 'bg-green-500/20 text-green-400',
|
||||
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||
high: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: ProjectTask[];
|
||||
selectedTaskId: number | null;
|
||||
kanbanParentTask?: ProjectTask | null;
|
||||
onSelectTask: (taskId: number) => void;
|
||||
onStatusChange: (taskId: number, status: string) => void;
|
||||
onBackToAllTasks?: () => void;
|
||||
}
|
||||
|
||||
function KanbanColumn({
|
||||
column,
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
draggingId,
|
||||
onSelectTask,
|
||||
}: {
|
||||
column: (typeof COLUMNS)[0];
|
||||
tasks: ProjectTask[];
|
||||
selectedTaskId: number | null;
|
||||
draggingId: number | null;
|
||||
onSelectTask: (taskId: number) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: column.id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex-1 min-w-[160px] md:min-w-[200px] rounded-lg border transition-colors duration-150 ${
|
||||
isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
|
||||
}`}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="px-3 py-2.5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm font-semibold font-heading ${column.color}`}>
|
||||
{column.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="p-2 space-y-2 min-h-[100px]">
|
||||
{tasks.map((task) => (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={selectedTaskId === task.id}
|
||||
isDragSource={draggingId === task.id}
|
||||
onSelect={() => onSelectTask(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card content — shared between in-place card and drag overlay
|
||||
function CardContent({ task, isSelected, ghost }: { task: ProjectTask; isSelected: boolean; ghost?: boolean }) {
|
||||
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
|
||||
const totalSubtasks = task.subtasks?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border p-3 ${
|
||||
ghost
|
||||
? 'border-accent/20 bg-accent/5 opacity-40'
|
||||
: 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>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? priorityColors.none}`}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
{task.due_date && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{format(parseISO(task.due_date), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
{totalSubtasks > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{completedSubtasks}/{totalSubtasks}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KanbanBoard({
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
kanbanParentTask,
|
||||
onSelectTask,
|
||||
onStatusChange,
|
||||
onBackToAllTasks,
|
||||
}: KanbanBoardProps) {
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
|
||||
);
|
||||
|
||||
const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
|
||||
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
|
||||
|
||||
const draggingTask = draggingId ? activeTasks.find((t) => t.id === draggingId) ?? null : null;
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setDraggingId(event.active.id as number);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
setDraggingId(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const taskId = active.id as number;
|
||||
const newStatus = over.id as string;
|
||||
|
||||
const task = activeTasks.find((t) => t.id === taskId);
|
||||
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
|
||||
onStatusChange(taskId, newStatus);
|
||||
}
|
||||
}, [activeTasks, onStatusChange]);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setDraggingId(null);
|
||||
}, []);
|
||||
|
||||
const tasksByStatus = COLUMNS.map((col) => ({
|
||||
column: col,
|
||||
tasks: activeTasks.filter((t) => t.status === col.id),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Subtask view header */}
|
||||
{isSubtaskView && kanbanParentTask && (
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<button
|
||||
onClick={onBackToAllTasks}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||
>
|
||||
Back to all tasks
|
||||
</button>
|
||||
<span className="text-muted-foreground text-xs">/</span>
|
||||
<span className="text-xs text-foreground font-medium">
|
||||
Subtasks of: {kanbanParentTask.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{tasksByStatus.map(({ column, tasks: colTasks }) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
tasks={colTasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
draggingId={draggingId}
|
||||
onSelectTask={onSelectTask}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import {
|
||||
DndContext,
|
||||
closestCorners,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
useDroppable,
|
||||
useDraggable,
|
||||
} from '@dnd-kit/core';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { ProjectTask } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
const COLUMNS: { id: string; label: string; color: string }[] = [
|
||||
{ id: 'pending', label: 'Pending', color: 'text-gray-400' },
|
||||
{ id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
|
||||
{ id: 'blocked', label: 'Blocked', color: 'text-red-400' },
|
||||
{ id: 'on_hold', label: 'On Hold', color: 'text-orange-400' },
|
||||
{ id: 'review', label: 'Review', color: 'text-yellow-400' },
|
||||
{ id: 'completed', label: 'Completed', color: 'text-green-400' },
|
||||
];
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
none: 'bg-gray-500/20 text-gray-400',
|
||||
low: 'bg-green-500/20 text-green-400',
|
||||
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||
high: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: ProjectTask[];
|
||||
selectedTaskId: number | null;
|
||||
kanbanParentTask?: ProjectTask | null;
|
||||
onSelectTask: (taskId: number) => void;
|
||||
onStatusChange: (taskId: number, status: string) => void;
|
||||
onBackToAllTasks?: () => void;
|
||||
}
|
||||
|
||||
function KanbanColumn({
|
||||
column,
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
}: {
|
||||
column: (typeof COLUMNS)[0];
|
||||
tasks: ProjectTask[];
|
||||
selectedTaskId: number | null;
|
||||
onSelectTask: (taskId: number) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: column.id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex-1 min-w-[160px] md:min-w-[200px] rounded-lg border transition-colors duration-150 ${
|
||||
isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
|
||||
}`}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="px-3 py-2.5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm font-semibold font-heading ${column.color}`}>
|
||||
{column.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="p-2 space-y-2 min-h-[100px]">
|
||||
{tasks.map((task) => (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={selectedTaskId === task.id}
|
||||
onSelect={() => onSelectTask(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCard({
|
||||
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 totalSubtasks = task.subtasks?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onClick={onSelect}
|
||||
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>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? priorityColors.none}`}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
{task.due_date && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{format(parseISO(task.due_date), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
{totalSubtasks > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{completedSubtasks}/{totalSubtasks}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KanbanBoard({
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
kanbanParentTask,
|
||||
onSelectTask,
|
||||
onStatusChange,
|
||||
onBackToAllTasks,
|
||||
}: KanbanBoardProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
,
|
||||
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 activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const taskId = active.id as number;
|
||||
const newStatus = over.id as string;
|
||||
|
||||
const task = activeTasks.find((t) => t.id === taskId);
|
||||
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
|
||||
onStatusChange(taskId, newStatus);
|
||||
}
|
||||
};
|
||||
|
||||
const tasksByStatus = COLUMNS.map((col) => ({
|
||||
column: col,
|
||||
tasks: activeTasks.filter((t) => t.status === col.id),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Subtask view header */}
|
||||
{isSubtaskView && kanbanParentTask && (
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<button
|
||||
onClick={onBackToAllTasks}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
|
||||
>
|
||||
Back to all tasks
|
||||
</button>
|
||||
<span className="text-muted-foreground text-xs">/</span>
|
||||
<span className="text-xs text-foreground font-medium">
|
||||
Subtasks of: {kanbanParentTask.title}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{tasksByStatus.map(({ column, tasks: colTasks }) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
tasks={colTasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={onSelectTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,10 +2,9 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
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 type { Project } from '@/types';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { statusColors, statusLabels } from './constants';
|
||||
@ -18,8 +17,6 @@ interface ProjectCardProps {
|
||||
export default function ProjectCard({ project }: ProjectCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { settings } = useSettings();
|
||||
const isShared = project.user_id !== (settings?.user_id ?? 0);
|
||||
|
||||
const toggleTrackMutation = useMutation({
|
||||
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"
|
||||
onClick={() => navigate(`/projects/${project.id}`)}
|
||||
>
|
||||
{!isShared && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTrackMutation.mutate();
|
||||
}}
|
||||
className={`absolute top-3 right-3 p-1 rounded-md transition-colors z-10 ${
|
||||
project.is_tracked
|
||||
? 'text-accent hover:bg-accent/10'
|
||||
: 'text-muted-foreground/40 hover:text-muted-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
title={project.is_tracked ? 'Untrack project' : 'Track project'}
|
||||
>
|
||||
<Pin className={`h-3.5 w-3.5 ${project.is_tracked ? 'fill-current' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTrackMutation.mutate();
|
||||
}}
|
||||
className={`absolute top-3 right-3 p-1 rounded-md transition-colors z-10 ${
|
||||
project.is_tracked
|
||||
? 'text-accent hover:bg-accent/10'
|
||||
: 'text-muted-foreground/40 hover:text-muted-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
title={project.is_tracked ? 'Untrack project' : 'Track project'}
|
||||
>
|
||||
<Pin className={`h-3.5 w-3.5 ${project.is_tracked ? 'fill-current' : ''}`} />
|
||||
</button>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2 pr-6">
|
||||
<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')}
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -23,25 +23,21 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin,
|
||||
Calendar, CheckCircle2, PlayCircle, AlertTriangle,
|
||||
List, Columns3, ArrowUpDown, Users, Eye,
|
||||
List, Columns3, ArrowUpDown,
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { useDeltaPoll } from '@/hooks/useDeltaPoll';
|
||||
import TaskRow from './TaskRow';
|
||||
import TaskDetailPanel from './TaskDetailPanel';
|
||||
import KanbanBoard from './KanbanBoard';
|
||||
import TaskForm from './TaskForm';
|
||||
import ProjectForm from './ProjectForm';
|
||||
import { ProjectShareSheet } from './ProjectShareSheet';
|
||||
import { statusColors, statusLabels } from './constants';
|
||||
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
|
||||
|
||||
@ -115,9 +111,6 @@ export default function ProjectDetail() {
|
||||
const [kanbanParentTaskId, setKanbanParentTaskId] = useState<number | null>(null);
|
||||
const [sortMode, setSortMode] = useState<SortMode>('manual');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [showShareSheet, setShowShareSheet] = useState(false);
|
||||
const { settings } = useSettings();
|
||||
const currentUserId = settings?.user_id ?? 0;
|
||||
|
||||
const sensors = useSensors(
|
||||
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({
|
||||
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 { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus, version });
|
||||
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
toast.error('Task was modified by another user — please refresh');
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||
} else {
|
||||
toast.error('Failed to update task');
|
||||
}
|
||||
onError: () => {
|
||||
toast.error('Failed to update task');
|
||||
},
|
||||
});
|
||||
|
||||
@ -200,9 +157,6 @@ export default function ProjectDetail() {
|
||||
toast.success('Task deleted');
|
||||
setSelectedTaskId(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to delete task');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteProjectMutation = useMutation({
|
||||
@ -245,20 +199,15 @@ export default function ProjectDetail() {
|
||||
});
|
||||
|
||||
const updateTaskStatusMutation = useMutation({
|
||||
mutationFn: async ({ taskId, status, version }: { taskId: number; status: string; version: number }) => {
|
||||
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status, version });
|
||||
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
|
||||
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
toast.error('Task was modified by another user — please refresh');
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||
} else {
|
||||
toast.error('Failed to update task status');
|
||||
}
|
||||
onError: () => {
|
||||
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]}`}>
|
||||
{statusLabels[project.status]}
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground relative"
|
||||
onClick={() => setShowShareSheet(true)}
|
||||
title="Project members"
|
||||
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'}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
{acceptedMembers.length > 0 && (
|
||||
<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">
|
||||
{acceptedMembers.length}
|
||||
</span>
|
||||
)}
|
||||
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
|
||||
</Button>
|
||||
<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>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{/* Content area */}
|
||||
@ -568,14 +491,6 @@ export default function ProjectDetail() {
|
||||
</Card>
|
||||
</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 */}
|
||||
<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>
|
||||
@ -620,12 +535,10 @@ export default function ProjectDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEditTasks && (
|
||||
<Button size="sm" onClick={() => openTaskForm(null, null)}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Add Task
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => openTaskForm(null, null)}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
Add Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -648,10 +561,9 @@ export default function ProjectDetail() {
|
||||
selectedTaskId={selectedTaskId}
|
||||
kanbanParentTask={kanbanParentTask}
|
||||
onSelectTask={handleKanbanSelectTask}
|
||||
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, version: t?.version ?? 1 });
|
||||
}}
|
||||
onStatusChange={(taskId, status) =>
|
||||
updateTaskStatusMutation.mutate({ taskId, status })
|
||||
}
|
||||
onBackToAllTasks={handleBackToAllTasks}
|
||||
/>
|
||||
) : (
|
||||
@ -683,7 +595,6 @@ export default function ProjectDetail() {
|
||||
toggleTaskMutation.mutate({
|
||||
taskId: task.id,
|
||||
status: task.status,
|
||||
version: task.version,
|
||||
})
|
||||
}
|
||||
togglePending={toggleTaskMutation.isPending}
|
||||
@ -704,7 +615,6 @@ export default function ProjectDetail() {
|
||||
toggleTaskMutation.mutate({
|
||||
taskId: subtask.id,
|
||||
status: subtask.status,
|
||||
version: subtask.version,
|
||||
})
|
||||
}
|
||||
togglePending={toggleTaskMutation.isPending}
|
||||
@ -731,10 +641,6 @@ export default function ProjectDetail() {
|
||||
<TaskDetailPanel
|
||||
task={selectedTask}
|
||||
projectId={parseInt(id!)}
|
||||
members={acceptedMembers}
|
||||
currentUserId={currentUserId}
|
||||
ownerId={project?.user_id ?? 0}
|
||||
canAssign={canEditTasks}
|
||||
onDelete={handleDeleteTask}
|
||||
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
||||
onClose={() => setSelectedTaskId(null)}
|
||||
@ -792,14 +698,6 @@ export default function ProjectDetail() {
|
||||
onClose={() => setShowProjectForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProjectShareSheet
|
||||
open={showShareSheet}
|
||||
onOpenChange={setShowShareSheet}
|
||||
projectId={parseInt(id!)}
|
||||
isOwner={isOwner}
|
||||
ownerName={settings?.preferred_name || 'Owner'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { format, formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import {
|
||||
Pencil, Trash2, Plus, MessageSquare, ClipboardList,
|
||||
Calendar, User, Flag, Activity, Send, X, Save,
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import { formatUpdatedAt } from '@/components/shared/utils';
|
||||
import type { ProjectTask, TaskComment, ProjectMember } from '@/types';
|
||||
import { AssignmentPicker } from './AssignmentPicker';
|
||||
import type { ProjectTask, TaskComment, Person } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@ -47,10 +45,6 @@ const priorityColors: Record<string, string> = {
|
||||
interface TaskDetailPanelProps {
|
||||
task: ProjectTask | null;
|
||||
projectId: number;
|
||||
members?: ProjectMember[];
|
||||
currentUserId?: number;
|
||||
ownerId?: number;
|
||||
canAssign?: boolean;
|
||||
onDelete: (taskId: number) => void;
|
||||
onAddSubtask: (parentId: number) => void;
|
||||
onClose?: () => void;
|
||||
@ -87,10 +81,6 @@ function buildEditState(task: ProjectTask): EditState {
|
||||
export default function TaskDetailPanel({
|
||||
task,
|
||||
projectId,
|
||||
members = [],
|
||||
currentUserId = 0,
|
||||
ownerId = 0,
|
||||
canAssign = false,
|
||||
onDelete,
|
||||
onAddSubtask,
|
||||
onClose,
|
||||
@ -103,24 +93,20 @@ export default function TaskDetailPanel({
|
||||
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 allMembers: ProjectMember[] = [
|
||||
// Synthetic owner entry so they appear in the picker
|
||||
...(ownerId ? [{
|
||||
id: 0, project_id: projectId, user_id: ownerId, invited_by: ownerId,
|
||||
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'),
|
||||
];
|
||||
const { data: people = [] } = useQuery({
|
||||
queryKey: ['people'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<Person[]>('/people');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// --- Mutations ---
|
||||
|
||||
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 { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus, version });
|
||||
const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { status: newStatus });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -139,12 +125,7 @@ export default function TaskDetailPanel({
|
||||
toast.success('Task updated');
|
||||
},
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
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'));
|
||||
}
|
||||
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 ---
|
||||
|
||||
const handleAddComment = () => {
|
||||
@ -240,7 +197,6 @@ export default function TaskDetailPanel({
|
||||
due_date: editState.due_date || null,
|
||||
person_id: editState.person_id ? Number(editState.person_id) : null,
|
||||
description: editState.description || null,
|
||||
version: task.version,
|
||||
};
|
||||
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 || [];
|
||||
|
||||
return (
|
||||
@ -285,21 +242,21 @@ export default function TaskDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
disabled={updateTaskMutation.isPending}
|
||||
title="Save changes"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditCancel}
|
||||
title="Cancel editing"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@ -307,30 +264,30 @@ export default function TaskDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditStart}
|
||||
title="Edit task"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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)}
|
||||
title="Delete task"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={onClose}
|
||||
title="Close panel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@ -419,34 +376,21 @@ export default function TaskDetailPanel({
|
||||
<User className="h-3 w-3" />
|
||||
Assigned
|
||||
</div>
|
||||
{canAssign ? (
|
||||
<AssignmentPicker
|
||||
currentAssignments={task.assignments ?? []}
|
||||
members={allMembers}
|
||||
currentUserId={currentUserId}
|
||||
ownerId={ownerId}
|
||||
onAssign={(userIds) => assignMutation.mutate(userIds)}
|
||||
onUnassign={(userId) => unassignMutation.mutate(userId)}
|
||||
disabled={assignMutation.isPending || unassignMutation.isPending}
|
||||
/>
|
||||
) : task.assignments && task.assignments.length > 0 ? (
|
||||
<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>
|
||||
{isEditing ? (
|
||||
<Select
|
||||
value={editState.person_id}
|
||||
onChange={(e) => setEditState((s) => ({ ...s, person_id: e.target.value }))}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{people.map((p) => (
|
||||
<option key={p.id} value={String(p.id)}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/50">Unassigned</p>
|
||||
<p className="text-sm">{assignedPerson ? assignedPerson.name : '—'}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -514,7 +458,6 @@ export default function TaskDetailPanel({
|
||||
toggleSubtaskMutation.mutate({
|
||||
taskId: subtask.id,
|
||||
status: subtask.status,
|
||||
version: subtask.version,
|
||||
})
|
||||
}
|
||||
disabled={toggleSubtaskMutation.isPending}
|
||||
@ -562,7 +505,7 @@ export default function TaskDetailPanel({
|
||||
{/* Comments */}
|
||||
<div className="space-y-3">
|
||||
<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">
|
||||
Comments
|
||||
{comments.length > 0 && (
|
||||
@ -579,9 +522,6 @@ export default function TaskDetailPanel({
|
||||
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<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 })}
|
||||
</span>
|
||||
<Button
|
||||
|
||||
@ -3,7 +3,6 @@ import { ChevronRight, GripVertical } from 'lucide-react';
|
||||
import type { ProjectTask } from '@/types';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AssigneeAvatars } from './AssignmentPicker';
|
||||
|
||||
const taskStatusColors: Record<string, string> = {
|
||||
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}` : '—'}
|
||||
</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 */}
|
||||
<div className="flex items-center gap-1.5 sm:hidden shrink-0">
|
||||
<div className={`h-2 w-2 rounded-full ${
|
||||
|
||||
@ -236,21 +236,21 @@ export default function ReminderDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
disabled={saveMutation.isPending}
|
||||
title="Save"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditCancel}
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@ -259,22 +259,22 @@ export default function ReminderDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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()}
|
||||
disabled={dismissMutation.isPending}
|
||||
title="Dismiss reminder"
|
||||
>
|
||||
<BellOff className="h-4 w-4" />
|
||||
<BellOff className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditStart}
|
||||
title="Edit reminder"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{confirmingDelete ? (
|
||||
<Button
|
||||
@ -290,22 +290,22 @@ export default function ReminderDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
disabled={deleteMutation.isPending}
|
||||
title="Delete reminder"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={onClose}
|
||||
title="Close panel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,6 @@ import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import TotpSetupSection from './TotpSetupSection';
|
||||
import PasskeySection from './PasskeySection';
|
||||
import type { Settings } from '@/types';
|
||||
|
||||
interface SecurityTabProps {
|
||||
@ -87,9 +86,6 @@ export default function SecurityTab({ settings, updateSettings, isUpdating }: Se
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Passkeys */}
|
||||
<PasskeySection />
|
||||
|
||||
{/* Password + TOTP */}
|
||||
<TotpSetupSection bare />
|
||||
</div>
|
||||
|
||||
@ -61,7 +61,7 @@ export function EntityDetailPanel<T>({
|
||||
size="icon"
|
||||
onClick={onToggleFavourite}
|
||||
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 ? (
|
||||
<Star className="h-4 w-4 fill-yellow-400" />
|
||||
@ -75,7 +75,7 @@ export function EntityDetailPanel<T>({
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@ -272,21 +272,21 @@ export default function TodoDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
disabled={saveMutation.isPending}
|
||||
title="Save"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditCancel}
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@ -294,11 +294,11 @@ export default function TodoDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditStart}
|
||||
title="Edit todo"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{confirmingDelete ? (
|
||||
<Button
|
||||
@ -314,22 +314,22 @@ export default function TodoDetailPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}
|
||||
disabled={deleteMutation.isPending}
|
||||
title="Delete todo"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
onClick={onClose}
|
||||
title="Close panel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
export { getErrorMessage };
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { AuthStatus, LoginResponse, PasskeyLoginResponse } from '@/types';
|
||||
import type { AuthStatus, LoginResponse } from '@/types';
|
||||
|
||||
export function useAuth() {
|
||||
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({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.post('/auth/logout');
|
||||
@ -149,11 +125,5 @@ export function useAuth() {
|
||||
isRegisterPending: registerMutation.isPending,
|
||||
isTotpPending: totpVerifyMutation.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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
@ -17,7 +17,6 @@ interface LockContextValue {
|
||||
isLockResolved: boolean;
|
||||
lock: () => Promise<void>;
|
||||
unlock: (password: string) => Promise<void>;
|
||||
unlockWithPasskey: () => void;
|
||||
}
|
||||
|
||||
const LockContext = createContext<LockContextValue | null>(null);
|
||||
@ -95,14 +94,6 @@ export function LockProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
return (
|
||||
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock, unlockWithPasskey }}>
|
||||
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock }}>
|
||||
{children}
|
||||
</LockContext.Provider>
|
||||
);
|
||||
|
||||
@ -15,7 +15,7 @@ api.interceptors.response.use(
|
||||
if (error.response?.status === 401) {
|
||||
const url = error.config?.url || '';
|
||||
// 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))) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
@ -137,14 +137,12 @@ export interface Reminder {
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
user_id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'review' | 'on_hold';
|
||||
color?: string;
|
||||
due_date?: string;
|
||||
is_tracked: boolean;
|
||||
member_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tasks: ProjectTask[];
|
||||
@ -164,21 +162,10 @@ export interface TrackedTask {
|
||||
export interface TaskComment {
|
||||
id: number;
|
||||
task_id: number;
|
||||
user_id?: number | null;
|
||||
author_name?: string | null;
|
||||
content: 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 {
|
||||
id: number;
|
||||
project_id: number;
|
||||
@ -190,12 +177,10 @@ export interface ProjectTask {
|
||||
due_date?: string;
|
||||
person_id?: number;
|
||||
sort_order: number;
|
||||
version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
subtasks: ProjectTask[];
|
||||
comments: TaskComment[];
|
||||
assignments: TaskAssignment[];
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
@ -243,23 +228,6 @@ export interface AuthStatus {
|
||||
username: string | null;
|
||||
registration_open: 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
|
||||
@ -296,7 +264,6 @@ export interface AdminUser {
|
||||
last_password_change_at: string | null;
|
||||
totp_enabled: boolean;
|
||||
mfa_enforce_pending: boolean;
|
||||
passwordless_enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@ -311,7 +278,6 @@ export interface AdminUserDetail extends AdminUser {
|
||||
export interface SystemConfig {
|
||||
allow_registration: boolean;
|
||||
enforce_mfa_new_users: boolean;
|
||||
allow_passwordless: boolean;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
@ -557,22 +523,3 @@ export interface EventLockInfo {
|
||||
expires_at: string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user