Compare commits

..

229 Commits

Author SHA1 Message Date
373030b81a Fix health check: use DEPLOY_PORT variable for host port
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 43s
The frontend port varies per deployment (80 on dev, 8088 on
dedicated host). Use a Gitea variable so it works across environments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:33:56 +08:00
c96004f91d Fix CI/CD deploy: use stack.env for Portainer-managed stacks
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 49s
Portainer stores environment variables in stack.env, not .env.
Add --env-file stack.env to compose commands in the deploy step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:24:41 +08:00
3496cf0f26 Action performance audit findings
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 11m24s
- Add /health proxy block with rate limiting for external uptime monitoring
- Fix Permissions-Policy on API responses: add passkey directives
- Strengthen CSP: add frame-ancestors 'none' + upgrade-insecure-requests
- Relax backend healthcheck interval from 10s to 30s (reduce overhead)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 20:03:07 +08:00
e5869e0b19 Fix frontend healthcheck: use 127.0.0.1 instead of localhost
All checks were successful
Build and Deploy UMBRA / build-and-deploy (push) Successful in 52s
Alpine resolves localhost to IPv6 [::1] but nginx only listens on
IPv4, causing the healthcheck to fail with connection refused.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:55:06 +08:00
3075495d1c Remove build: directives — images pulled from Gitea registry only
All checks were successful
Build and Deploy UMBRA / build-and-deploy (push) Successful in 57s
All builds now go through the CI/CD pipeline. The compose file
only needs image: to pull pre-built images from the registry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:42:02 +08:00
d945de3837 Switch compose from env_file to environment blocks
All checks were successful
Build and Deploy UMBRA / build-and-deploy (push) Successful in 52s
Replace env_file: .env with explicit environment: variables using
${VAR} substitution. Works with both .env files (local dev) and
Portainer's environment UI (no .env file needed on the host).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:37:56 +08:00
329c057632 Remove act_runner from main docker-compose.yaml
All checks were successful
Build and Deploy UMBRA / build-and-deploy (push) Successful in 51s
The runner is CI/CD infrastructure, not part of the application.
Self-hosters cloning UMBRA don't need a runner. The runner now
lives as a standalone stack (documented in .claude/docs/).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:34:55 +08:00
ae3dc4e9db Fix CI/CD deploy: don't recreate the runner during deploy
All checks were successful
Build and Deploy UMBRA / build-and-deploy (push) Successful in 57s
docker compose up -d was recreating act_runner, killing the job
mid-execution. Explicitly target db, backend, frontend only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:46:21 +08:00
70cf033fdc Fix CI/CD deploy: use -p umbra to match existing project name
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 10m31s
The docker:cli container's working dir /deploy caused compose to
create a new 'deploy' project instead of updating the existing
'umbra' stack. Adding -p umbra ensures it manages the right containers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:53:56 +08:00
618afeb336 Apply docker specialist review: pin image, fresh bases, longer wait
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 12s
- Pin deploy container to docker:27-cli (avoid compose version drift)
- Add --pull to both docker build commands (keep base images fresh)
- Increase health check sleep to 30s (backend start_period is 30s)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:44:41 +08:00
76b19cd33a Fix CI/CD deploy: mount host DEPLOY_PATH for compose access
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 10s
The job container can't access the host filesystem directly.
Spawn a docker:cli container that mounts the host's DEPLOY_PATH
(where docker-compose.yaml and .env live) and runs compose commands.

Requires DEPLOY_PATH variable in Gitea (e.g. /home/user/.../UMBRA).
When moving to a new host, only the Gitea variable needs updating.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:38:28 +08:00
c98e47a050 Fix CI/CD deploy: use workspace compose file instead of /opt/umbra
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 13s
The job container doesn't have /opt/umbra. Use the checked-out
repo's docker-compose.yaml (already in the working directory).
Combined pull + deploy into one step. Increased health check wait.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:35:05 +08:00
fac953fcea Fix CI/CD: use catthehacker/ubuntu:act-22.04 for job containers
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 2s
Host mode failed because the act_runner container lacks node/curl/git.
catthehacker/ubuntu:act-22.04 is the standard act_runner job image —
includes node, git, docker CLI, curl, and common CI tools.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:50:11 +08:00
7a9122c235 Fix CI/CD: use host execution mode for runner jobs
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 4s
The node:20-bookworm container doesn't have Docker CLI installed,
causing 'docker: command not found'. Switch runner label from
docker://node:20-bookworm to host mode so jobs run directly on
the runner host where Docker is available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 10:12:00 +08:00
7f38df22db Fix CI/CD: full runner config, shell-only workflow, config mount fix
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 6s
- Replace all GitHub action clones (login-action, build-push-action)
  with plain docker CLI commands — eliminates GitHub dependency
- Expand act_runner_config.yaml to full format (partial config was
  silently falling back to defaults)
- Mount config at /etc/act_runner/ with CONFIG_FILE env var to avoid
  named volume shadowing at /data/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:48:30 +08:00
86c113c412 Fix checkout token: use REGISTRY_TOKEN (GITEA_ prefix reserved)
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 13m32s
Gitea reserves the GITEA_ prefix for secrets. Reuse the existing
REGISTRY_TOKEN PAT which already has repo read access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:19:28 +08:00
1f34da9199 Fix CI/CD checkout failure + enlarge panel action buttons
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 11m16s
CI/CD fixes (from debugger + docker specialist review):
- Add explicit GITEA_TOKEN for checkout auth
- Add act_runner_config.yaml with container.network: host so job
  containers can reach git.sentinelforest.xyz (root cause of 0s
  silent checkout failure)
- Mount config into act_runner container

UI: Enlarge save/close/edit/delete icons in all detail panels
(EventDetailPanel, TodoDetailPanel, ReminderDetailPanel,
TaskDetailPanel, EntityDetailPanel) from h-7/h-3.5 to h-8/h-4
for better visibility and click targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:28:15 +08:00
507c841a92 Fix act_runner: SELinux label:disable, host network, pin image
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Failing after 15m48s
Docker specialist review findings:
- Replace :z with security_opt: label:disable (correct SELinux fix)
- Remove user: 0:0 (unnecessary with SELinux handled)
- Remove redundant DOCKER_HOST env var
- Add network_mode: host (workflow steps need host access)
- Pin image to 0.2.11 (avoid non-deterministic latest tag)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 04:39:59 +08:00
3ad216ab0c Fix act_runner: add :z SELinux label to Docker socket mount
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Has been cancelled
SELinux in enforcing mode blocks container access to the Docker
socket. The :z flag relabels the socket for shared container access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 04:38:16 +08:00
3ca1a9af08 Fix act_runner: run as root for Docker socket access
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Has been cancelled
group_add didn't resolve the permission issue. Running the runner
as root (user 0:0) is the standard approach for CI runners that
need Docker socket access on internal/single-user deployments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 04:36:07 +08:00
d981b9346f Fix act_runner: add docker group (971) for socket access
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Has been cancelled
The runner process runs as non-root but needs access to the Docker
socket owned by root:docker (GID 971). group_add grants it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 04:34:41 +08:00
571268c9b4 Fix act_runner: add explicit DOCKER_HOST env var
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Has been cancelled
The act_runner container couldn't find the Docker socket despite the
volume mount. Adding DOCKER_HOST=unix:///var/run/docker.sock explicitly
tells the runner where to find it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 04:31:42 +08:00
55891eb7b5 Add build: fallback alongside image: for initial bootstrap
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Has been cancelled
When both image: and build: are present, docker compose up --build
builds locally and tags with the image name. This allows the stack
to start before registry images exist, solving the bootstrap problem.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 04:20:33 +08:00
4f8b83ba87 Merge feature/gitea-cicd: Gitea Actions CI/CD pipeline
Some checks failed
Build and Deploy UMBRA / build-and-deploy (push) Has been cancelled
2026-03-18 04:12:55 +08:00
5d64034bb6 Add Gitea Actions CI/CD pipeline for automated builds and deploys
Adds a workflow that triggers on push to main: builds backend/frontend
Docker images, pushes to Gitea container registry, pulls and restarts
on the host, health checks, prunes old images, and sends ntfy notifications.
docker-compose.yaml updated to pull pre-built images from registry and
includes act_runner as a 4th service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 03:36:39 +08:00
0f58edf607 Merge feature/passkey-authentication: WebAuthn passkeys + passwordless login
Major feature: Passkey authentication (WebAuthn/FIDO2) with passwordless
login support, passkey-based lock screen unlock, and full admin controls.

Includes:
- Session consolidation (shared services/session.py)
- Passkey registration, login, management (6 endpoints + py_webauthn)
- Passwordless login toggle (per-account, admin-gated, 2-key minimum)
- Passkey lock screen unlock
- Admin: per-user force-disable, system config toggle
- Pentest: 30+ attack vectors tested, 3 low findings remediated
- QA: 2 critical + 5 warnings + 6 suggestions actioned
- Performance: EXISTS over COUNT, bulk session cap, dynamic imports

19 commits, 35 files changed, 2 migrations (061-062).
2026-03-18 02:34:57 +08:00
ed98924716 Action remaining QA suggestions + performance optimizations
S-02: Extract extract_credential_raw_id() helper in services/passkey.py
  — replaces 2 inline rawId parsing blocks in passkeys.py
S-03: Add PasskeyLoginResponse type, use in useAuth passkeyLoginMutation
S-04: Add Cancel button to disable-passwordless dialog
W-03: Invalidate auth queries on disable ceremony error/cancel

Perf-2: Session cap uses ID-only query + bulk UPDATE instead of loading
  full ORM objects and flipping booleans individually
Perf-3: Remove passkey_count from /auth/status hot path (polled every
  15s). Use EXISTS for has_passkeys boolean. Count derived from passkeys
  list query in PasskeySection (passkeys.length).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:34:00 +08:00
0a8e163e47 Fix QA review findings: 2 critical, 3 warnings, 1 suggestion
C-01: Initialize config=None before conditional in auth/status to
prevent NameError on fresh instance (setup_required=True path)

C-02: Use generic "Authentication failed" on passkey lockout trigger
instead of leaking lockout state (consistent with F-02 remediation)

W-01: Add nginx rate limit for /api/auth/passkeys/passwordless
endpoints (enable accepts password — brute force protection)

W-02: Call record_successful_login in passkey unlock path to reset
failed_login_count (prevents unexpected lockout accumulation)

W-05: Auto-clear must_change_password on passkey login — user can't
provide old password in forced-change form after passkey auth

S-01: Pin webauthn to >=2.1.0,<3 (prevent major version breakage)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:27:16 +08:00
94891d8a70 Fix IAM actions dropdown rendering behind System Settings card
Add relative z-10 to the Users Card so its stacking context sits above
the sibling System Settings Card. Without this, the absolutely-positioned
dropdown menu was painted behind the later sibling in DOM order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:04:41 +08:00
0f6e40a5ba Fix dropdown clipping: remove overflow constraints on parent containers
Revert fixed-positioning approach (caused z-index and placement issues).
Instead fix the root cause: parent containers with overflow that clipped
absolutely-positioned dropdowns.

- IAMPage: Remove overflow-x-auto on table wrapper (columns already
  hide via responsive classes, no horizontal scroll needed)
- AlertBanner: Remove max-h-48 overflow-y-auto on alerts list
  (alerts are naturally bounded, constraint clipped SnoozeDropdown)
- Revert UserActionsMenu and SnoozeDropdown to simple absolute positioning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:20:57 +08:00
a327890b57 Fix dropdown clipping: use fixed positioning to escape overflow
UserActionsMenu and SnoozeDropdown were clipped by parent containers
with overflow-x-auto/overflow-y-auto. Switch from absolute to fixed
positioning — compute viewport-relative coordinates on open via
getBoundingClientRect. Dropdowns now render above all overflow
boundaries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:10:36 +08:00
44e6c8e3e5 Fix 3 pentest findings: lockout status disclosure, timing side-channel, XFF trust scope
F-01 (passkeys.py): Add constant-time DB no-op on login/begin when username not
found. Without it the absent credential-fetch query makes the "no user" path
measurably faster, leaking username existence via timing.

F-02 (session.py, auth.py, passkeys.py, totp.py): Change check_account_lockout
from HTTP 423 to 401 — status-code analysis can no longer distinguish a locked
account from an invalid credential. record_failed_login now returns remaining
attempt count; callers use it for progressive UX warnings (<=3 attempts left,
and on the locking attempt) without changing the 401 status code visible to
attackers. Session-lock 423 path in get_current_user is unaffected.

F-03 (nginx.conf): Replace set_real_ip_from 0.0.0.0/0 with RFC 1918 ranges
(172.16.0.0/12, 10.0.0.0/8) to prevent external clients from spoofing
X-Forwarded-For to bypass rate limiting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:01:19 +08:00
863e9e2c45 feat: improve account lockout UX with severity-aware error styling
Login errors now distinguish between wrong-password (red), progressive
lockout warnings (amber, Lock icon), and temporary lockout (amber, Lock
icon) based on the backend detail string. Removes the dead 423 branch
from handleCredentialSubmit — account lockout is now returned as 401
with a descriptive detail message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:00:42 +08:00
1b868ba503 Fix: hide passwordless toggle when disabled, remove lock auto-trigger
1. Passwordless toggle in Settings is now hidden when admin hasn't
   enabled allow_passwordless in system config (or when user already
   has it enabled — so they can still disable it). Backend exposes
   allow_passwordless in /auth/status response.

2. Remove auto-trigger passkey ceremony on lock screen — previously
   fired immediately when session locked for passwordless users.
   Now waits for user to click "Unlock with passkey" button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 00:32:03 +08:00
42d73526f5 feat(passkeys): implement passwordless login frontend (Phase 2)
- types/index.ts: add passkey_count, passwordless_enabled to AuthStatus; add allow_passwordless to SystemConfig; add passwordless_enabled to AdminUser
- useAuth: expose passwordlessEnabled and passkeyCount from auth query
- useLock: add unlockWithPasskey() — clears lock state without password verification
- LockOverlay: passkey unlock support with three modes: passwordless-primary (passkey only, auto-triggers), hybrid (password + "or use a passkey"), password-only (existing behaviour)
- PasskeySection: passwordless toggle below passkey list — enable via password dialog, disable via WebAuthn ceremony dialog; requires 2+ passkeys
- useAdmin: add useDisablePasswordless mutation (PUT /admin/users/{id}/passwordless)
- IAMPage: add allow_passwordless system config toggle
- UserActionsMenu: add "Disable Passwordless" two-click confirm item (shown when user.passwordless_enabled)
- UserDetailSection: add Passwordless badge in Security & Permissions card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:16:48 +08:00
bcfebbc9ae feat(backend): Phase 1 passwordless login — migration, models, toggle endpoints, unlock, delete guard, admin controls
- Migration 062: adds users.passwordless_enabled and system_config.allow_passwordless (both default false)
- User model: passwordless_enabled field after must_change_password
- SystemConfig model: allow_passwordless field after enforce_mfa_new_users
- auth.py login(): block passwordless-enabled accounts from password login path (403) with audit log
- auth.py auth_status(): change has_passkeys query to full COUNT, add passkey_count + passwordless_enabled to response
- auth.py get_current_user(): add /api/auth/passkeys/login/begin and /login/complete to lock_exempt set
- passkeys.py: add PasswordlessEnableRequest + PasswordlessDisableRequest schemas
- passkeys.py: PUT /passwordless/enable — verify password, check system config, require >= 2 passkeys, set flag
- passkeys.py: POST /passwordless/disable/begin — generate user-bound challenge for passkey auth ceremony
- passkeys.py: PUT /passwordless/disable — verify passkey auth response, clear flag, update sign count
- passkeys.py: PasskeyLoginCompleteRequest.unlock field — passkey re-auth into locked session without new session
- passkeys.py: delete guard — 409 if passwordless user attempts to drop below 2 passkeys
- schemas/admin.py: add passwordless_enabled to UserListItem + UserDetailResponse; add allow_passwordless to SystemConfigResponse + SystemConfigUpdate; add TogglePasswordlessRequest
- admin.py: PUT /users/{user_id}/passwordless — admin-only disable (enabled=False only), revokes all sessions, audit log
- admin.py: update_system_config handles allow_passwordless field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:15:39 +08:00
fc1f8d5514 Fix passkey registration: use correct py_webauthn credential parsers
RegistrationCredential and AuthenticationCredential are plain dataclasses,
not Pydantic models — model_validate_json() does not exist on them.
Replace with parse_registration_credential_json() and
parse_authentication_credential_json() from webauthn.helpers, which
correctly parse the camelCase JSON from @simplewebauthn/browser and
convert base64url fields to bytes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:40:26 +08:00
57d400c6de Update .env.example and README.md for passkey authentication
- .env.example: Add WEBAUTHN_RP_ID, WEBAUTHN_RP_NAME, WEBAUTHN_ORIGIN,
  ENVIRONMENT, and UMBRA_URL with documentation comments
- README.md: Full rewrite — remove outdated PIN/bcrypt references, document
  current auth stack (Argon2id + TOTP + passkeys), all 17 API route groups,
  security features, and Docker deployment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:25:27 +08:00
9234880648 Fix SyntaxError: reorder delete_passkey params
Move `request: Request` (no default) before parameters with defaults
to fix 'parameter without default follows parameter with default'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:05:31 +08:00
53101d1401 Action deferred review items: TOTP lockout consolidation + toast nav
W-04: Replace inline lockout logic in totp.py (3 occurrences of
manual failed_login_count/locked_until manipulation) with shared
session service calls: check_account_lockout, record_failed_login,
record_successful_login. Also fix TOTP replay prevention to use
flush() not commit() for atomicity with session creation.

S-1: Add "Set up" action button to the post-login passkey prompt
toast, navigating to /settings?tab=security (already supported by
SettingsPage search params).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:02:59 +08:00
ab84c7bc53 Fix review findings: transaction atomicity, perf, and UI polish
Backend fixes:
- session.py: record_failed/successful_login use flush() not commit()
  — callers own transaction boundary (BUG-2 atomicity fix)
- auth.py: Add explicit commits after record_failed_login where callers
  raise immediately; add commit before TOTP mfa_token return path
- passkeys.py: JOIN credential+user lookup in login/complete (W-1 perf)
- passkeys.py: Move mfa_enforce_pending clear before main commit (S-2)
- passkeys.py: Add Path(ge=1, le=2147483647) on DELETE endpoint (BUG-3)
- auth.py: Switch has_passkeys from COUNT to EXISTS with LIMIT 1 (W-2)
- passkey.py: Add single-worker nonce cache comment (H-1)

Frontend fixes:
- PasskeySection: emerald→green badge colors (W-3 palette)
- PasskeySection: text-[11px]/text-[10px]→text-xs (W-4 a11y minimum)
- PasskeySection: Scope deleteMutation.isPending to per-item (W-5)
- nginx.conf: Permissions-Policy publickey-credentials use (self) (H-2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:59:59 +08:00
51d98173a6 Phase 3: Post-login passkey prompt toast
Show a one-time toast suggesting passkey setup after login when:
- User has no passkeys registered
- Browser supports WebAuthn
- Prompt hasn't been shown this session (sessionStorage gate)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:51:06 +08:00
cc460df5d4 Phase 2: Add passkey frontend UI
New files:
- PasskeySection.tsx: Passkey management in Settings > Security with
  registration ceremony (password -> browser prompt -> name), credential
  list, two-click delete with password confirmation

Changes:
- types/index.ts: PasskeyCredential type, has_passkeys on AuthStatus
- api.ts: 401 interceptor exclusions for passkey login endpoints
- useAuth.ts: passkeyLoginMutation with dynamic import of
  @simplewebauthn/browser (~45KB saved from initial bundle)
- LockScreen.tsx: "Sign in with a passkey" button (browser feature
  detection, not per-user), Fingerprint icon, error handling
- SecurityTab.tsx: PasskeySection between Auto-lock and TOTP
- package.json: Add @simplewebauthn/browser ^10.0.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:50:06 +08:00
e8e3f62ff8 Phase 1: Add passkey (WebAuthn/FIDO2) backend
New files:
- models/passkey_credential.py: PasskeyCredential model with indexed credential_id
- alembic 061: Create passkey_credentials table
- services/passkey.py: Challenge token management (itsdangerous + nonce replay
  protection) and py_webauthn wrappers for registration/authentication
- routers/passkeys.py: 6 endpoints (register begin/complete, login begin/complete,
  list, delete) with full security hardening

Changes:
- config.py: WEBAUTHN_RP_ID, RP_NAME, ORIGIN, CHALLENGE_TTL settings
- main.py: Mount passkey router, add CSRF exemptions for login endpoints
- auth.py: Add has_passkeys to /auth/status response
- nginx.conf: Rate limiting on all passkey endpoints, Permissions-Policy
  updated for publickey-credentials-get/create
- requirements.txt: Add webauthn>=2.1.0

Security: password re-entry for registration (V-02), single-use nonce
challenges (V-01), constant-time login/begin (V-03), shared lockout
counter, generic 401 errors, audit logging on all events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:46:00 +08:00
eebb34aa77 Phase 0: Consolidate session creation into shared service
Extract _create_db_session, _set_session_cookie, _check_account_lockout,
_record_failed_login, and _record_successful_login from auth.py into
services/session.py. Update totp.py to use shared service instead of
its duplicate _create_full_session (which lacked session cap enforcement).

Also fixes:
- auth/status N+1 query (2 sequential queries -> single JOIN)
- Rename verify_password route to verify_password_endpoint (shadow fix)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:40:46 +08:00
c5a309f4a1 Merge feature/mini-calendar: compact date navigator in sidebar
Adds MiniCalendar component with independent month browsing, click-to-navigate,
today/selected highlights, firstDayOfWeek support, navKey selection clearing,
aria-labels, and mobile sheet auto-close. QA reviewed — 0 critical findings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 21:12:50 +08:00
0ba920f8e1 Fix issues from QA review: stale closure, aria-labels, mobile sheet close
W-01: Use functional updater in handleDayClick to remove displayedMonth
      from dependency array, eliminating stale closure risk
S-02: Add aria-label with full date string to day buttons for screen readers
S-04: Close mobile sidebar sheet when clicking a date in mini calendar,
      matching existing onUseTemplate behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 21:12:16 +08:00
68337b12a0 Fix: clear mini-cal selection on Today click even when month unchanged
datesSet fires but currentDate stays the same value when already on
the current month, so the useEffect didn't re-run. Added navKey counter
that increments on every datesSet call — MiniCalendar watches it in a
separate useEffect to reliably clear selectedDate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:14:00 +08:00
bda02039a6 Clear mini calendar selection on main calendar navigation
Clicking Today/prev/next on the toolbar now clears the selected day
in the mini calendar, so only the today highlight remains visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:08:56 +08:00
2d76ecf869 Fix 1st-of-month highlight bug and restore Calendars header
selectedDate now only set by user clicks in mini calendar, not by
external currentDate prop (which is always 1st of displayed month
from FullCalendar's view.currentStart). Restore h-16 "Calendars"
header above mini calendar for consistent top-of-page alignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:03:22 +08:00
b939843249 Fix review findings: safe date parsing, useCallback discipline, dead class cleanup
W-01: Wrap handlePrev/handleNext/handleDayClick in useCallback
W-02: Use date-fns parse() instead of new Date() for timezone-safe parsing
W-03: Change default firstDayOfWeek from 1 to 0 to match CalendarPage
S-01: Use format(day, 'yyyy-MM-dd') as React key instead of toISOString()
S-02: Remove dead Tailwind color classes overridden by inline styles
Perf: Guard setSelectedDate with comparison to skip no-op re-renders
Perf: Memoize selectedDateObj via useMemo to avoid re-parsing each render

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:48:32 +08:00
a5ac047b0b Add mini monthly calendar to sidebar for quick date navigation
New MiniCalendar component with independent month browsing, today/selected
highlights, firstDayOfWeek support, and month sync with main calendar.
Replaces old "Calendars" header with the mini-cal + "MY CALENDARS" heading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:42:00 +08:00
1daec977ba Merge feature/event-panel-ux: scroll bleed fix, auto-grow description, compact layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:14:52 +08:00
bb39888d2e Fix issues from QA review: invited editor payload, auto-resize perf, resize-y conflict
C-01: Strip is_starred/recurrence_rule from payload for invited editors
      (not in backend allowlist → would 403). Hide Star checkbox from
      invited editor edit mode entirely.

W-01: Wrap auto-resize in requestAnimationFrame to batch with paint
      cycle and avoid forced reflow on every keystroke.

S-01: Add comment documenting belt-and-suspenders scroll prevention.

S-02: Remove resize-y from textarea (conflicts with auto-grow which
      resets height on keystroke, overriding manual resize).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:13:31 +08:00
43322db5ff Disable month-scroll wheel navigation when event panel is open
Prevents accidental month changes (and lost edits) while scrolling
anywhere on the calendar page with the detail panel visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:02:30 +08:00
11f42ef91e Fix description textarea resize: remove max-height cap blocking drag
max-h-[200px] CSS and the 200px JS cap both prevented the resize
handle from expanding the textarea. Removed both constraints so
auto-grow and manual resize work without ceiling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:58:19 +08:00
a78fb495a2 Improve event panel UX: fix scroll bleed, auto-grow description, compact layout
P0 - Scroll bleed: onWheel stopPropagation on panel root prevents
     wheel events from navigating calendar months while editing.

P1 - Description textarea: auto-grows with content (min 80px, max
     200px), manually resizable via resize-y handle. Applied to both
     EventDetailPanel and EventForm.

P2 - Space utilization: moved All Day checkbox inline above date row,
     combined Recurrence + Star into a 2-col row, description now
     fills remaining vertical space with flex-1.

P3 - Removed duplicate footer Save/Cancel buttons from edit mode
     (header icon buttons are sufficient).

P4 - Description field now shows dash placeholder in view mode when
     empty, consistent with other fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:46:40 +08:00
80418172db Rework Nominatim results: name as label, address as full street address
Name now uses the Nominatim place/building label (e.g. "The Quadrant")
when available, falling back to street address. Address field now
contains the full formatted address (house number, road, suburb, city,
state, postcode, country) instead of just the city/state portion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 15:58:20 +08:00
d4117818c7 Preserve house number from user query when Nominatim omits it
Many addresses resolve to just the road in Nominatim (no house_number
in response). Now extracts the leading number from the user's original
search query and prepends it to the road name, so "123 Adelaide Terrace"
stays as "123 Adelaide Terrace" instead of just "Adelaide Terrace".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:52:46 +08:00
90bfd00a82 Fix Nominatim stripping house numbers from location names
Use addressdetails=1 to get structured address components and build
the name as "123 Example St" instead of splitting display_name on
the first comma (which isolated the house number from the road).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:28:02 +08:00
a5118e36dc Add purple ghost favicon for browser tabs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:09:57 +08:00
e95931fa62 Fix read-only banner showing for editor members
The view-only banner checked canEdit (hardcoded to isOwner) instead
of canEditTasks (which includes create_modify members). Editors saw
the banner incorrectly. Removed stale canEdit variable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:03:19 +08:00
03d0742dc4 Fix task/project deletion broken by lazy='raise' on cascade relationships
Adding lazy='raise' to relationships with cascade='all, delete-orphan'
broke db.delete() — SQLAlchemy tried to lazy-load related objects for
Python-side cascade but lazy='raise' blocked it with MissingGreenlet.

Fix: Add passive_deletes=True to subtasks, comments, assignments, tasks,
and members relationships. This tells SQLAlchemy to defer cascade to
PostgreSQL's ondelete=CASCADE FK constraint instead of loading objects
in Python. Both the FK and ORM cascade are now aligned.

Also added onError handler to deleteTaskMutation so failures are visible
via toast instead of failing silently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 07:51:26 +08:00
bb4212d17f Fix TS2345: add missing version to subtask toggle mutation call
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 05:34:05 +08:00
688ce1c132 Merge feature/project-collab-prep: collaborative project sharing, task assignments, delta polling
Adds multi-user project collaboration mirroring the shared calendar pattern:
- ProjectMember model with invite/accept/reject flow, permission levels (read_only/create_modify)
- ProjectTaskAssignment with multi-assign, auto-membership, field allowlist (SEC-P02)
- Optimistic locking via version column with 409 conflict handling
- Delta polling for projects and calendars (5s interval, background tab support)
- Disconnect cascade cleans up memberships + assignments on connection removal
- Frontend: ProjectShareSheet, AssignmentPicker, permission gating, assigned column in task list
- Notification integration: project_invite, project_invite_accepted, task_assigned with action toasts
- Kanban DragOverlay for smooth drag-and-drop
- 4 migrations (057-060), 31 files, ~2500 LOC
- QA: 3 agent reviews (performance, pentest, code), all findings actioned
2026-03-17 05:30:16 +08:00
0a449f166c Polish pass: action all remaining QA suggestions before merge
P-01: Clamp delta poll since param to max 24h in the past (projects +
calendars) to prevent expensive full-table scans from malicious timestamps.

P-02: Validate individual user_id elements in ProjectMemberInvite and
TaskAssignmentCreate with Annotated[int, Field(ge=1, le=2147483647)].

P-04: Only enable delta polling for shared projects (member_count > 0).
Solo projects skip the 5s poll entirely.

P-05: Remove fragile 200ms onBlur timeout in ProjectShareSheet search.
The onMouseDown preventDefault on dropdown items already prevents blur
from firing before click registers.

P-06/S-04: Replace manual dict construction in model_validators with
__table__.columns iteration so new fields are auto-included.

S-01: Replace bare except in ProjectResponse.compute_member_count with
logger.debug to surface errors in development.

S-03: Consolidate cascade_projects_on_disconnect from 2 project ID
queries into 1 using IN clause with both user IDs.

S-05: Send version in toggleTaskMutation, updateTaskStatusMutation,
and toggleSubtaskMutation for full optimistic locking coverage. Handle
409 with refresh toast.

S-07: Replace window.location.href with React Router navigateRef in
task_assigned toast for client-side navigation.

S-08: Already fixed in previous commit (subtask comment selectinload).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 05:28:34 +08:00
dd637bdc84 Fix QA findings from performance, pentest, and code review
Perf-1: Eliminate duplicate permission query on task update.
get_effective_task_permission now returns (effective, project_level)
tuple so the SEC-P02 allowlist check reuses the project-level
permission from the first call instead of querying again.

Perf-2: Memoize member permission lookup in ProjectDetail. Replace
3 inline acceptedMembers.find() calls with useMemo-derived
myPermission and canEditTasks.

S-06: Pass members/currentUserId/ownerId/canAssign to mobile
TaskDetailPanel (was missing — AssignmentPicker never appeared on
mobile).

S-08: Add missing selectinload(TaskComment.user) to subtask comments
chain in _task_load_options. Subtask comment author_name was always
null.

W-01: useDeltaPoll stores queryKeyToInvalidate in a ref to prevent
infinite re-render if caller passes inline array literal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 04:55:47 +08:00
e0a5f4855f Fix Kanban drag jitter: use DragOverlay + ghost placeholder
Root causes of the jitter:
1. No DragOverlay — card transformed in-place via translate(), causing
   parent column layout reflow as siblings shifted around the gap.
2. transition-all on cards fought with drag transforms on slow moves.
3. closestCorners collision bounced rapidly between column boundaries.

Fixes:
- DragOverlay renders a floating copy of the card above everything,
  with a subtle 2deg rotation and shadow for visual feedback.
- Original card becomes a ghost placeholder (accent border, 40% opacity)
  so the column layout stays stable during drag.
- Switched to closestCenter collision detection (less boundary bounce).
- Increased PointerSensor distance from 5px to 8px to reduce accidental
  drag activation.
- Removed transition-all from card styles (no more CSS vs drag fight).
- dropAnimation: null for instant snap on release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 04:37:56 +08:00
7eac213c20 Wire AssignmentPicker into TaskDetailPanel for task assignment
TaskDetailPanel now shows an interactive AssignmentPicker (click to
open dropdown, select members, remove with X) when the user has
create_modify permission or is the owner. Read-only users see static
chips. Owner is included as a synthetic entry in the picker so they
can self-assign. Both assign and unassign mutations invalidate the
project query for immediate UI refresh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 04:31:39 +08:00
957939a165 Remove unused people query and Person import from TaskDetailPanel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 04:26:50 +08:00
fc2068be70 Remove unused assignedPerson variable (TS6133 build error)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 04:25:11 +08:00
d6e4938aa4 Fix task assignment visibility: show column always, wire detail panel
- TaskRow: Show 'unassigned' label (muted) instead of invisible dash
  so the assigned column is always visible in the task list.
- TaskDetailPanel: Replace old person_id dropdown with assignment chips
  showing avatar + name for each assignee. Unassigned shows muted text
  instead of a dash.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 04:23:20 +08:00
990c660fbf Add assigned column to task list with name labels, fix user_name null
- TaskRow: Replace tiny avatar-only display with proper assigned column
  showing avatar + name (single assignee) or avatar + "N people" (multi).
  Hidden on mobile, right-aligned, 96px width matching other columns.
- Load options: Chain selectinload(ProjectTaskAssignment.user) so the
  user relationship is available for serialization.
- TaskAssignmentResponse: Add model_validator to resolve user_name from
  eagerly loaded user relationship (same pattern as TaskCommentResponse).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 04:14:16 +08:00
f42175b3fe Improve sharing visibility: member count on cards, task assignment toast
- Add member_count to ProjectResponse via model_validator (computed from
  eagerly loaded members relationship). Shows on ProjectCard for both
  owners ("2 members") and shared users ("Shared with you").
- Fix share button badge positioning (add relative class).
- Add dedicated showTaskAssignedToast with blue ClipboardList icon,
  "View Project" action button, and 15s duration.
- Wire task_assigned into both initial-load and new-notification toast
  dispatch flows in NotificationToaster.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 04:09:07 +08:00
61e48c3f14 Add project notification types to CHECK constraint (migration 060)
The notifications table CHECK constraint did not include project_invite,
project_invite_accepted, project_invite_rejected, or task_assigned.
This caused 500 errors on invite_members and assign_users_to_task
because create_notification violated ck_notifications_type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 03:54:54 +08:00
05f5b49e26 Fix 500 on POST /api/projects/:id/members — add project_invite types to notification CHECK constraint
The invite_members handler called create_notification with type="project_invite", which
is not in the ck_notifications_type CHECK constraint. The db.flush() inside the handler
flushed both the ProjectMember and Notification INSERTs atomically, causing a
CheckViolationError → 500. Added "project_invite", "project_invite_accepted",
"project_invite_rejected" to the model tuple and migration 060 drops/recreates the
constraint to include them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 03:53:42 +08:00
f0850ad3bf Fix MissingGreenlet in invite_members and assign_users_to_task
Both endpoints accessed ORM object IDs after db.commit(), which
expires all loaded objects in async SQLAlchemy. Added db.flush()
before commit to assign IDs while objects are still live.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 03:49:28 +08:00
a7e93aa2a3 Fix migration 057: use IF NOT EXISTS for indexes that may pre-exist
The ix_project_tasks_parent_task_id index already existed on the
production DB, causing migration 057 to fail with DuplicateTableError.
Switched all CREATE INDEX statements to raw SQL with IF NOT EXISTS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 03:30:19 +08:00
dad5c0e606 Fix QA findings: project invite toast with action buttons, rejected row cleanup
W-04: Add showProjectInviteToast with Accept/Decline buttons in
NotificationToaster, matching the connection/calendar/event invite
toast pattern. Wired into both initial-load and new-notification flows.

W-06: Delete rejected ProjectMember rows on rejection instead of
accumulating them with status='rejected'. Prevents indefinite growth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 03:25:17 +08:00
bef856fd15 Add collaborative project sharing, task assignments, and delta polling
Enables multi-user project collaboration mirroring the shared calendar
pattern. Includes ProjectMember model with permission levels, task
assignment with auto-membership, optimistic locking, field allowlist
for assignees, disconnect cascade, delta polling for projects and
calendars, and full frontend integration with share sheet, assignment
picker, permission gating, and notification handling.

Migrations: 057 (indexes + version + comment user_id), 058
(project_members), 059 (project_task_assignments)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 03:18:35 +08:00
7903e454dc Strip detailed security internals from README
Reduces the security section to a brief summary without exposing
specific middleware names, rate limit thresholds, lockout parameters,
or implementation details that could aid threat actors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:43:39 +08:00
2848739474 Update README to reflect event invitations, shared calendars, and current state
Updates tech stack counts (56 migrations, 20 models, 14 schemas, 16
routers), adds event invitations and shared calendars to features list,
API overview, security section, and project structure. Reflects 5
completed penetration tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:39:54 +08:00
cbb62ea7aa Merge feature/event-invitations: full event invitation system
Adds event invitations with RSVP, per-occurrence overrides for recurring
events, display calendar assignment, can_modify toggle for granting
invitees edit access, active-invitee icon on owner's calendar, and
in-app notification integration. Three QA reviews and two penetration
tests passed. Includes field allowlist for invited editors, connection
validation, 20-invitation cap, and can_modify reset on decline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:33:06 +08:00
925c9caf91 Fix QA and pentest findings for event invitations
C-01: Use func.count() for invitation cap instead of loading all rows
C-02: Remove unused display_calendar_id from EventInvitationResponse
F-01: Add field allowlist for invited editors (blocks is_starred,
      recurrence_rule, calendar_id mutations)
W-02: Memoize existingInviteeIds Set in EventDetailPanel
W-03: Block per-occurrence overrides on declined/pending invitations
S-01: Make can_modify non-optional in EventInvitation TypeScript type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:28:01 +08:00
2f45220c5d Show shared-invitee icon on owner's calendar for events with active guests
Adds has_active_invitees flag to the events GET response. The Users icon
now appears on the owner's calendar view when an event has accepted or
tentative invitees, giving visual feedback that the event is actively
shared. Single batch query with set lookup — no N+1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:14:44 +08:00
c66fd159ea Restore 5s calendar polling for near-real-time shared event sync
Reverts the AW-3 optimization that increased polling from 5s to 30s.
The faster interval is needed for shared calendar edits and invited
editor changes to appear promptly on other users' views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:09:34 +08:00
f35798c757 Add per-invitee can_modify toggle for event edit access
Allows event owners to grant individual invitees edit permission via a
toggle in the invitee list. Invited editors can modify event details
(title, description, time, location) but cannot change calendars, manage
invitees, delete events, or bulk-edit recurring series (scope restricted
to "this" only). The can_modify flag resets on decline to prevent silent
re-grant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 00:59:36 +08:00
8b39c961b6 Remove unused get_accessible_calendar_ids import from dashboard
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:43:35 +08:00
0401a71fce Fix CompoundSelect chaining: use standalone union_all()
SQLAlchemy 2.0's select().union_all() returns a CompoundSelect which
cannot chain another .union_all(). Use the standalone union_all()
function to combine all three queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:39:40 +08:00
8f087ccebf Bump InviteSearch onBlur timeout from 150ms to 200ms
Safer margin for click-through on slower devices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:30:09 +08:00
f54ab5079e Fix QA review findings: C-01, C-02, W-01, W-02, W-04, S-01, S-02, S-03
C-01: Remove nginx rate limit on event invitations endpoint — was
      blocking GET (invitee list) on rapid event switching. Backend
      already caps at 20 invitations per event with connection validation.

C-02: respondingRef uses string prefixes (conn-, cal-, event-) instead
      of fragile numeric offsets (+100000/+200000) to prevent collisions.

W-01: get_accessible_event_scope combined into single UNION ALL query
      (3 DB round-trips → 1) for calendar IDs + invitation IDs.

W-02: Dashboard and upcoming endpoints now include is_invited,
      invitation_status, and display_calendar_id on event items.

W-04: LeaveEventDialog closes on error (.finally) instead of staying
      open when mutation rejects.

S-01: Migration 055 FK constraint gets explicit name for consistency.

S-02: InviteSearch dropdown dismisses on blur (150ms delay for clicks).

S-03: Display calendar picker shows only owned calendars, not shared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:27:01 +08:00
25830bb99e Fix calendar event color not updating after display calendar change
eventDidMount only fires once when FullCalendar first mounts a DOM element.
When event data refetches with a new calendarColor, the existing DOM element
is reused and --event-color CSS variable stays stale.

Fix: renderEventContent now uses a ref callback (syncColor) to walk up to
the parent .umbra-event element and update --event-color on every render,
ensuring background, hover, and dot colors reflect the current calendar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 20:13:34 +08:00
aa1ff50788 Fix display calendar: text cutoff (py-1) and force refetch on update
- Add py-1 to Select to prevent text clipping at h-8 height
- Use refetchQueries instead of invalidateQueries for calendar-events
  after display calendar update to ensure immediate visual refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:14:09 +08:00
d00d6d6d49 Add migration 055: display_calendar_id on event_invitations
Adds nullable FK to calendars, index, and backfills accepted/tentative
invitations with each user's default calendar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:04:50 +08:00
a68ec0e23e Add display calendar support: model, router, service, types, visibility filter
Previously unstaged changes required for the display calendar feature:
- EventInvitation model: display_calendar_id column
- Event invitations router: display-calendar PUT endpoint
- Event invitation service: display calendar update logic
- CalendarPage: respect display_calendar_id in visibility filter
- Types: display_calendar_id on CalendarEvent interface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:03:22 +08:00
29c2cbbec8 Fix post-review findings: stale calendar leak, aria-label, color dot, loading state
- Add access check to display calendar batch query (Security L-01)
- Add aria-label, color dot, disabled-during-mutation, h-8 height (UI W-01/W-02/W-03/S-01)
- Add display_calendar_id to EventInvitationResponse schema (Code W-02)
- Invalidate event-invitations cache on display calendar update (Code S-03)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 19:01:46 +08:00
68a609ee50 Mask calendar name/color for invited events (pen test F-01)
Invitees no longer see the event owner's calendar name/color,
preventing minor information disclosure (CWE-200).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:04:13 +08:00
df857a5719 Fix QA findings: flush before notify, dedup RSVP, sa_false, validation
- C-02: flush invitations before creating notifications so invitation_id
  is available in notification data; eliminates extra pending fetch
- C-03: skip RSVP notification when status hasn't changed
- C-01: add defensive comments on update/delete endpoints
- W-01: add ge=1, le=2147483647 per-element validation on user_ids
- W-04: deduplicate invited_event_ids query via get_invited_event_ids()
- W-06: replace Python False with sa_false() in or_() clauses
- Frontend: extract resolveInvitationId helper, prefer data.invitation_id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 14:01:15 +08:00
496666ec5a Fix 'calendar no longer available' for invited events
The shared-calendar removal guard checks allCalendarIds, which only
contains the user's own + shared calendars. Invited events belong to
the inviter's calendar, triggering a false positive. Skip the check
for invited events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:41:20 +08:00
bafda61958 Fix invited events hidden by calendar visibility filter
Invited events belong to the inviter's calendar, which doesn't exist
in the invitee's calendar list. The visibleCalendarIds filter was
removing them. Now invited events bypass this filter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:36:55 +08:00
0f378ad386 Add event invite actions to notification center + toast on login
- NotificationsPage: Going/Maybe/Decline buttons for event_invite notifications
- NotificationsPage: event_invite icon mapping, eager-refetch, click-to-calendar nav
- NotificationToaster: toast actionable unread notifications on first load (max 3)
  so users see pending invites/requests when they sign in

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:00:27 +08:00
a41b48f016 Fix TS build: remove unused isLoadingInvitees var and Select import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:16:53 +08:00
8652c9f2ce Implement event invitation feature (invite, RSVP, per-occurrence override, leave)
Full-stack implementation of event invitations allowing users to invite connected
contacts to calendar events. Invitees can respond Going/Tentative/Declined, with
per-occurrence overrides for recurring series. Invited events appear on the invitee's
calendar with a Users icon indicator. LeaveEventDialog replaces delete for invited events.

Backend: Migration 054 (2 tables + notification types), EventInvitation model with
lazy="raise", service layer, dual-router (events + event-invitations), cascade on
disconnect, events/dashboard queries extended with OR for invited events.

Frontend: Types, useEventInvitations hook, InviteeSection (view list + RSVP buttons +
invite search), LeaveEventDialog, event invite toast with 3 response buttons, calendar
eventContent render with Users icon for invited events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 02:47:27 +08:00
bdfd8448b1 Remove upper date bound on starred events so future events always show
Starred events should appear in the countdown widget regardless of how
far in the future they are. The _not_parent_template filter still
excludes recurring parent templates while allowing starred children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:59:11 +08:00
348fe8988b Merge feature/calendar-backend-optimisations into main 2026-03-15 01:46:33 +08:00
a2c1058f9c Fix QA findings: single UNION query, weekly validation, nginx docs
W-01: Consolidate get_accessible_calendar_ids to single UNION query
instead of two separate DB round-trips.
W-02: Document that nginx rate limit on /api/events applies to all
methods (30r/m generous enough for GET polling at 2r/m).
W-03: Add weekly rule validation for consistency with other rule types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:46:11 +08:00
be1fdc4551 Calendar backend optimisations: safety caps, shared calendar fix, query consolidation
Phase 1: Recurrence safety — MAX_OCCURRENCES=730 hard cap, adaptive 90-day
horizon for daily events (interval<7), RecurrenceRule cross-field validation,
ID bounds on location_id/calendar_id schemas.

Phase 2: Dashboard correctness — shared calendar events now included in
/dashboard and /upcoming via get_accessible_calendar_ids helper. Project stats
consolidated into single GROUP BY query (saves 1 DB round-trip).

Phase 3: Write performance — bulk db.add_all() for child events, removed
redundant SELECT in this_and_future delete path.

Phase 4: Frontend query efficiency — staleTime: 30_000 on calendar events
query eliminates redundant refetches on mount/view switch. Backend LIMIT 2000
safety guard on events endpoint.

Phase 5: Rate limiting — nginx limit_req zone on /api/events (30r/m) to
prevent DB flooding via recurrence amplification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:31:48 +08:00
99f70f3a41 Merge feature/calendar-visual-overhaul into main
Calendar Visual Overhaul:
- en-AU locale with 12-hour time format
- Translucent event styling via CSS custom properties
- Custom eventContent: dot+title in month, title-first in week/day
- Now-indicator pulse dot with prefers-reduced-motion
- Weekend bg neutralised for cross-browser consistency (10+ attempt RCA)
- Per-view dayHeaderFormat (weekday-only in month view)
- Side-by-side event overlap columns in week/day

Backend Performance:
- Starred events scoped to upcoming_days window
- Dashboard queries use materialized calendar ID list

QA: 1 critical (duplicate migration removed), 3 warnings fixed, reviewed clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:02:21 +08:00
050e0c7141 Fix QA findings: remove duplicate migration, formatting, static classNames
- Remove migration 054 (duplicate of 035 which already has all 3 indexes,
  including a superior partial index for starred events)
- Fix handleEventDidMount indentation and missing semicolons
- Replace eventClassNames arrow function with static UMBRA_EVENT_CLASSES array
- Correct misleading subquery comment in dashboard.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:02:07 +08:00
e12687ca6f Add calendar_events indexes and optimize dashboard queries
Migration 054: three indexes on calendar_events table:
- (calendar_id, start_datetime) for range queries
- (parent_event_id) for recurrence bulk operations
- (calendar_id, is_starred, start_datetime) for starred widget

Dashboard: replaced correlated subquery with single materialized
list fetch for user_calendar_ids in both /dashboard and /upcoming
handlers — eliminates 2 redundant subquery evaluations per request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 00:45:36 +08:00
3e738b18d4 Scope starred events to upcoming_days window
Starred events query had no upper date bound — a starred recurring
event would fill all 5 countdown slots with successive occurrences
beyond the user's configured range. Now capped to upcoming_cutoff_dt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 00:33:23 +08:00
e630832e76 Fix weekend header cells showing different background in Firefox
FC applies its own weekend background to header <th> elements too.
Force weekend header cells to use the same hsl(0 0% 8% / 0.65) as
weekday headers with !important to override FC's built-in styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:14:21 +08:00
a0533ee0a7 Remove weekend background tint — cross-browser compositing unreliable
After 10+ attempts, semi-transparent HSL values on near-black backgrounds
produce visible teal artifacts in Firefox due to compositor divergence.
Weekday/weekend frames now use identical --fc-neutral-bg-color. FC's own
weekend td background is neutralised with transparent !important.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:30:08 +08:00
a0ccaaa4bc Reduce weekend frame tint: 10% was too aggressive, use 9% lightness
hsl(0 0% 10% / 0.65) was visibly too bright vs weekday hsl(0 0% 8% / 0.65)
in Firefox. Reduced to hsl(0 0% 9% / 0.65) — 1% bump, subtle but present.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:18:48 +08:00
f5ed64b7da Fix Firefox weekend tint: use absolute HSL values instead of rgba overlay
Firefox composites rgba(255,255,255,0.05) differently against the
fc-daygrid-day-frame's --fc-neutral-bg-color background, producing a
visible mismatch. Switched to absolute HSL values that match the base
pattern:
- Month frame: hsl(0 0% 10% / 0.65) — same alpha as neutral-bg but
  slightly lighter (10% vs 8% lightness)
- Timegrid cols: hsl(0 0% 5.5%) — slightly above page bg (3.9%)

Cross-browser consistent since no alpha compositing is needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:09:57 +08:00
29c91cd706 Fix weekend header mismatch and wrong date format in day headers
Header mismatch: Removed weekend tint from column headers — the white
overlay replaced the standard header bg (hsl 0 0% 8% / 0.65), creating
a non-flush look. Weekend differentiation now comes from body cells only.

Date format: dayHeaderFormat was applied globally, causing month view
headers to show dates like "Sat 10/1" instead of just "Sat". Moved to
per-view formats: month shows weekday only, week shows weekday + d/m,
day shows full weekday + day + month name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:57:17 +08:00
d959803985 Fix weekend tint not rendering: replace color-mix() with rgba()
autoprefixer was silently stripping color-mix() during the PostCSS
build pipeline, causing the weekend tint background rules to produce
no output in the deployed CSS bundle. Replaced the three weekend
tint color-mix() calls with equivalent rgba(255,255,255,0.05) which
autoprefixer passes through unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:19:20 +08:00
744fe2c224 Fix calendar weekend tint: target fc-daygrid-day-frame not td
FC6 renders an fc-daygrid-day-frame div inside every daygrid td, painted
with --fc-neutral-bg-color (hsl 0 0% 8% / 0.65). This opaque-ish layer sits
on top of the td background, completely hiding any rgba white overlay applied
to the td itself. Previous attempts set the tint on the td — it was never
visible because the frame covered it.

Fix: apply 5% white color-mix overlay directly to fc-daygrid-day-frame for
month view, and !important on fc-timegrid-col for week/day view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:05:31 +08:00
d9b5868343 Fix weekend tint double-stacking: remove fc-daygrid-day-frame rule
Both the <td> and its child fc-daygrid-day-frame had the 3% white overlay,
causing the frame area to compound to ~6% while td edges stayed at 3%.
This created an uneven "not flush" pattern. The td rule alone is sufficient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:56:30 +08:00
3ead9cd25a Fix weekend tint: replace grayscale with 3% white overlay
RCA finding: grayscale tints are imperceptible on near-black (#0a0a0a)
backgrounds. Deltas of 3-5 RGB units fall below human JND threshold
and OLED panels can clip them to identical output via gamma compression.

Changed from hsl(0 0% 5%) to hsl(0 0% 100% / 0.03) — a semi-transparent
white overlay that composites additively for visible contrast.

See .claude/context/RCA/rca-calendarbg.md for full investigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:21:24 +08:00
ebeaefe0c5 Fix calendar weekend tint contrast and dot event margin
- Weekend bg raised from hsl(0 0% 2%) to hsl(0 0% 5%) across all 4 rules
  (day cells, col headers, timegrid cols, daygrid-day-frame) so the tint is
  visually distinct against the #0a0a0a page background
- Reduced .fc-daygrid-event-dot margin from default 4px each side to
  0 2px 0 0 on umbra dot events, tightening the gap between dot and title

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 07:50:01 +08:00
e18c94cd83 Fix weekend tint visibility, dot spacing, and event FOUC
Weekend tint: hsl(0 0% 6%) was lighter than page bg #0a0a0a (imperceptible).
Changed to hsl(0 0% 2%) = #050505 for visible darkening. Added rule for
fc-daygrid-day-frame to paint above FC6 internal layers.

Dot spacing: Reduced padding from 1px 4px to 1px 2px for tighter edge gap.

FOUC fix: Moved umbra-event class from eventDidMount (post-paint) to
eventClassNames (synchronous pre-mount). eventDidMount now only sets
the --event-color CSS custom property.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:43:33 +08:00
d6f5975fb9 Add dot indicator to timed month events in custom eventContent
eventContent replaces FC's default inner markup including the dot span.
Render a manual fc-daygrid-event-dot with border-color: var(--event-color).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:35:40 +08:00
40d0bb336c Merge fix: weekend tint cutoff and missing event dots 2026-03-13 02:34:34 +08:00
2a850ad8fd Fix calendar weekend tint cutoff and missing month-view event dots
- index.css: add explicit .fc-col-header-cell.fc-day-sat/sun rules with
  !important to override the generic header background, and cover
  .fc-timegrid-col weekend cells so the tint reaches all views
- CalendarPage.tsx: render .fc-daygrid-event-dot manually in the timed
  month-view eventContent branch — FC's eventContent hook replaces the
  entire default inner content including the dot span, so the CSS target
  had nothing to paint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 02:33:43 +08:00
0e35d473eb Refine calendar events: dot-only month timed, title-first week, no left border
- Month timed events: dot + title only, hover reveals translucent card
- Month all-day events: keep translucent fill
- Time right-aligned in month view (ml-auto)
- Week/day view: title on top, time underneath for better scanning
- Remove 2px left accent border from all events
- Set color:'transparent' on FC event data to prevent inline style conflicts
- Recurring repeat icon preserved in all views

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:24:20 +08:00
dec2c5d526 Fix event colors: remove inline backgroundColor/borderColor from event data
The previous commit failed to remove inline color props due to CRLF line
endings. FullCalendar was still setting inline styles that override CSS.
calendarColor is now correctly in extendedProps for the eventDidMount callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:09:14 +08:00
c473e7e235 Calendar visual overhaul: translucent events, AU locale, typography hierarchy
- Import en-AU locale (object, not string) for proper date format (day/month)
- Add 12-hour time format (9:00 AM), side-by-side overlap columns
- Replace flat opaque event rectangles with translucent color-mix fills (12% opacity)
- Add 2px left accent border per calendar color via CSS custom property
- Implement eventContent render hook with typography hierarchy (time secondary, title primary)
- Add recurring event indicator (Repeat icon) next to recurring event titles
- Add now-indicator 6px pulse dot with prefers-reduced-motion respect
- Add subtle weekend column tint (hsl 0 0% 6%)
- Mobile: hide time spans in month view custom eventContent
- Update +more popover to inherit translucent event styling
- Document calendar event patterns in stylesheet.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:02:21 +08:00
652be41da4 Merge fix/category-filter-position into main
Fix category chips rendering in wrong position (between search and add button).
Chips now appear inline after the Categories toggle pill.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 01:27:20 +08:00
85a9882d26 Fix category chips appearing in wrong position
Category chips were rendering as a separate flex row that got pushed to the
far right (between search and add button). Flatten the layout so chips appear
inline immediately after the Categories toggle pill, separated by a divider.

Remove redundant wrapper divs from TodosPage, PeoplePage, LocationsPage —
CategoryFilterBar now owns its own flex-1 sizing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 01:22:28 +08:00
bb5cbfa4b3 Merge optimize/docker-and-performance into main
Docker: .dockerignore, entrypoint.sh (PID 1), pinned images, network
segmentation, resource limits, npm ci, frontend healthcheck, DRY proxy config.

Backend: single JOIN auth, async Argon2id, settings cache, batch reorder,
bulk ntfy dedup, permission JOIN, cascade batch, collapsed admin COUNTs,
connection pool tuning, composite indexes (calendar_members, ntfy_sent).

Frontend: lazy-loaded routes, vendor chunk splitting, date-scoped calendar
events (87% payload reduction), 30s poll interval, clock isolation,
conditional shared-calendar polling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 01:15:40 +08:00
e270a2f63d Fix team review findings: reactive shared-calendar gate + ReorderItem hardening
- Convert hasSharingRef from useRef to useState in useCalendars so
  refetchInterval reacts immediately when sharing is detected (P-01)
- Add extra="forbid" to ReorderItem schema to prevent mass-assignment (S-03)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:50:02 +08:00
a94485b138 Address code review findings across all phases
Phase 1 fixes:
- W-01: Add start_period: 30s to backend healthcheck for migration window
- W-03: Narrow .dockerignore *.md to specific files (preserve alembic/README)

Phase 2 fixes:
- C-01: Wrap Argon2id calls in totp.py (disable, regenerate, backup verify,
  backup store) — missed in initial AC-2 pass
- S-01: Extract async wrappers (ahash_password, averify_password,
  averify_password_with_upgrade) into services/auth.py, refactor all
  callers to use them instead of manual run_in_executor boilerplate
- W-01: Fix ntfy dedup regression — commit per category instead of per-user
  to preserve dedup records if a later category fails

Phase 4 fixes:
- C-01: Fix optimistic drag-and-drop cache key to include date range
- C-02: Replace toISOString() with format() to avoid UTC date shift in
  visible range calculation
- W-02: Initialize visibleRange from current month to eliminate unscoped
  first fetch + immediate refetch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:19:33 +08:00
2ab7121e42 Phase 4: Frontend performance optimizations
- AW-2: Scope calendar events fetch to visible date range via start/end
  query params, leveraging existing backend support
- AW-3: Reduce calendar events poll from 5s to 30s (personal organiser
  doesn't need 12 API calls/min)
- AS-4: Gate shared-calendar polling on hasSharedCalendars — saves 12
  wasted API calls/min for personal-only users
- AS-2: Lazy-load all route components with React.lazy() — only
  AdminPortal was previously lazy, now all 10 routes are code-split
- AS-1: Add Vite manualChunks to split FullCalendar (~400KB), React,
  TanStack Query, and UI libs into separate cacheable chunks
- AS-3: Extract clockNow into isolated ClockDisplay memo component —
  prevents all 8 dashboard widgets from re-rendering every minute

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:12:33 +08:00
846019d5c1 Phase 3: Backend queries and indexes optimization
- AW-1: Add composite index on calendar_members(user_id, status) for the
  hot shared-calendar polling query
- AS-6: Add composite index on ntfy_sent(user_id, sent_at) for dedup lookups
- AW-5: Combine get_user_permission into single LEFT JOIN query instead of
  2 sequential queries (called twice per event edit)
- AC-5: Batch cascade_on_disconnect — single GROUP BY + bulk UPDATE instead
  of N per-calendar checks when a connection is severed
- AW-6: Collapse admin dashboard 5 COUNT queries into single conditional
  aggregation using COUNT().filter()
- AC-3: Cache get_current_settings in request.state to avoid redundant
  queries when multiple dependencies need settings in the same request

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:08:45 +08:00
1f2083ee61 Phase 2: Backend critical path optimizations
- AC-1: Merge get_current_user into single JOIN query (session + user in
  one round-trip instead of two sequential queries per request)
- AC-2: Wrap all Argon2id hash/verify calls in run_in_executor to avoid
  blocking the async event loop (~150ms per operation)
- AW-7: Add connection pool config (pool_size=10, pool_pre_ping=True,
  pool_recycle=1800) to prevent connection exhaustion under load
- AC-4: Batch-fetch tasks in reorder_tasks with IN clause instead of
  N sequential queries during Kanban drag operations
- AW-4: Bulk NtfySent inserts with single commit per user instead of
  per-notification commits in the dispatch job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:05:54 +08:00
dbad9c69b3 Phase 1: Docker infrastructure optimization
- Add .dockerignore for backend and frontend (DC-1: eliminates node_modules/
  and .env from build context)
- Delete start.sh with --reload flag (DC-2: superseded by Dockerfile CMD)
- Create entrypoint.sh with exec uvicorn (DW-5: proper PID 1 signal handling)
- Pin base images to patch-level tags (DW-1: reproducible builds)
- Reorder Dockerfile: create appuser before COPY, use --chown (DW-2)
- Switch to npm ci for lockfile-enforced installs (DW-3)
- Add network segmentation: backend_net + frontend_net (DW-4: db unreachable
  from frontend container)
- Add deploy.resources limits to all services (DW-6: OOM protection)
- Refactor proxy-params.conf to include security headers, deduplicate from
  nginx.conf location blocks (DW-7)
- Add image/svg+xml to gzip_types (DS-1)
- Add wget healthcheck for frontend service (DS-2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 00:03:46 +08:00
a73bd17f47 Merge fix/lock-bypass-and-ui-polish into main
Server-persisted lock state, accent color persistence, data prefetching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:14:25 +08:00
aa47ba4136 Address QA findings: prefetch reset on re-lock, settings gate, HSL validation
W-02: Reset hasPrefetched ref when app re-locks so subsequent unlocks
refresh stale cache data.
S-01: Validate localStorage HSL values with regex to prevent CSS injection.
S-05: Defer prefetch until settings are loaded for accurate upcoming_days.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:40:46 +08:00
379cc74387 Add data prefetching to eliminate skeleton flash on tab switch
Prefetches all main page queries (dashboard, upcoming, todos, reminders,
projects, people, locations) in parallel when the app unlocks, so the
TanStack Query cache is warm before the user navigates to each tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:19:08 +08:00
18a2c1314a Remove @layer base cyan defaults to eliminate refresh flash
Browser paints cached Vite CSS (@layer base cyan defaults) before the
inline script populates the static style tag. Remove the competing
cyan defaults — accent vars now come exclusively from the static
<style id="umbra-accent"> tag in index.html, which the inline script
always populates with the correct color before first paint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:15:47 +08:00
4e1b59c0f9 Use static style tag in HTML source for accent color persistence
Vite's initialization strips dynamically created elements from <head>.
Place <style id="umbra-accent"> directly in the HTML source instead of
creating it with createElement. Source-authored elements survive Vite's
head cleanup. The inline script populates it via textContent (XSS-safe).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:47:02 +08:00
f9359bd78a Recreate accent style tag if removed during page init
The <style id="umbra-accent"> tag injected by index.html gets removed
during page initialization. useTheme now defensively recreates the tag
if it's missing, ensuring color changes from the settings page work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:39:37 +08:00
b202ee1a84 Use style tag with !important for accent color persistence
Inline style attribute on <html> gets stripped during page load.
Switch to injecting a <style id="umbra-accent"> tag with !important
CSS custom properties which persists in the DOM and beats @layer base
defaults. useTheme updates the same style tag when settings load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:29:53 +08:00
fce7405b14 Use !important inline styles for accent color to beat all CSS cascade
Both the index.html inline script and useTheme now use setProperty
with 'important' priority flag. This is the highest CSS cascade
priority and cannot be overridden by Vite's stylesheet injection,
@layer rules, or source order. Removes the <style> tag injection
approach which was being overridden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:17:04 +08:00
e7be762198 Fix accent color loss on refresh by using injected style tag
The inline script's style.setProperty values on <html> were being
stripped during Vite's CSS injection. Switch to injecting a <style>
tag with :root vars which persists in the DOM. Restore CSS defaults
as safety fallback. Update useTheme to sync both the style tag and
inline styles when settings load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:10:51 +08:00
988dc37b64 Fix accent color flash on refresh by eliminating CSS/JS race
Guard useTheme effect to skip when settings are undefined, preventing
it from overwriting the inline script's cached color with cyan defaults.
Move CSS accent var defaults from index.css :root into the index.html
inline script so they are always set synchronously before paint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:02:13 +08:00
3d7166740e Fix lock screen flash, theme flicker, and lock state gating
Gate dashboard rendering on isLockResolved to prevent content flash
before lock state is known. Remove animate-fade-in from LockOverlay
so it renders instantly. Always write accent color to localStorage
(even default cyan) to prevent theme flash on reload. Resolve lock
state on auth query error to avoid permanent blank screen. Lift
mobileOpen state above lock gate to survive lock/unlock cycles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:56:05 +08:00
89519a6dd3 Fix lock screen bypass, theme flicker, skeleton flash, and sidebar click target
Critical: Lock state was purely React useState — refreshing the page reset it.
Now persisted server-side via is_locked/locked_at columns on user_sessions.
POST /auth/lock sets the flag, /auth/verify-password clears it, and
GET /auth/status returns is_locked so the frontend initializes correctly.

UI: Cache accent color in localStorage and apply via inline script in
index.html before React hydrates to eliminate the cyan flash on load.

UI: Increase TanStack Query gcTime from 5min to 30min so page data
survives component unmount/remount across tab switches without skeleton.

UI: Move Projects nav onClick from the icon element to the full-width
container div so the entire row is clickable when the sidebar is collapsed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:00:55 +08:00
3dee52b6ad Merge feature/ambient-dashboard-background into main
Global ambient background with drifting gradient orbs, glassmorphism
cards, lightened color palette, live dashboard clock, calendar UI fixes,
and Upcoming widget polish. QA reviewed — 0 critical, all suggestions
addressed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:36:19 +08:00
2770a9e88e Address remaining QA suggestions S-02 through S-06
S-02: Confirmed drift-3 is used by auth/AmbientBackground — not dead code.
S-03: Extracted noise SVG data URI to module-level NOISE_SVG constant.
S-04: Added will-change: transform to drift orbs for GPU layer promotion.
S-05: Documented the 9 AM snooze default in getMinutesUntilTomorrowMorning.
S-06: Made calendar toolbar bg-card/95 with backdrop-blur-md for better
      readability over the transparent FullCalendar grid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:35:44 +08:00
6e0a848c45 Fix QA findings: rename ambient component, add clock tab-resume sync
W-02: Renamed layout/AmbientBackground → AppAmbientBackground to avoid
naming collision with auth/AmbientBackground (IDE auto-import confusion).

S-01: Added visibilitychange listener to re-sync clock after tab
sleep/resume. Previously the interval would drift after laptop sleep
or long tab backgrounding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:28:14 +08:00
b663455c26 Sync clock to minute boundary and stabilize "Updated" text
Clock: Instead of starting a 60s interval from mount time (which drifts
from the system clock), calculate ms until the next :00 second mark,
setTimeout to that point, then setInterval every 60s from there.

Updated text: Replaced formatDistanceToNow (which flickered between
"less than a minute ago" / "a minute ago" / "2 minutes ago" on each
render) with a stable minute-based calculation derived from clockNow:
"just now" / "1 min ago" / "N min ago".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:18:00 +08:00
3afa894e1b Add live clock to dashboard header in 12hr format
Displays current time before the date separated by a vertical bar:
"6:30 PM | Thursday, March 12, 2026". Updates every 60 seconds.
Uses tabular-nums for stable digit widths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:08:05 +08:00
246b54d10c Increase day header separator line visibility
Border was at 30% opacity — nearly invisible against the glassmorphic
card background. Restored to full border-border opacity for clear
section separation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:02:03 +08:00
b2e68d3100 Refine Upcoming day headers: thinner, subtler, aligned
- Removed bg-card from sticky headers (was creating opaque bars against
  glassmorphic card background)
- Reduced padding from pb-1.5 to py-0.5 for slimmer profile
- Added leading-none for proper vertical centering of chevron + text
- Softened border opacity to 30%, text to 70%, chevron to 60%
- Shrunk text from text-xs to text-[10px], chevron from h-3 to h-2.5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:56:23 +08:00
39a42d08ec Fix phantom dropdown arrow next to Today button on desktop
The mobile view Select had md:hidden on the <select> element, but the
Select component wraps it in a <div> with an absolute ChevronDown icon
that remained visible. Moved md:hidden to a wrapper div so the entire
Select (including the chevron) is hidden on desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:31:44 +08:00
91f929c39b Fix outline button background for glassmorphism consistency
The outline variant used bg-background (opaque near-black) which created
a visible dark rectangle against semi-transparent card toolbars. Changed
to bg-transparent so outline buttons blend with their parent container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:27:00 +08:00
8d854b703e Fix calendar view visual inconsistencies with glassmorphism
FullCalendar backgrounds were opaque while the toolbar used semi-transparent
glassmorphism cards, creating a patchy look. Now all FC elements match:
- Page background: transparent (ambient shows through grid)
- Column headers: semi-transparent (0.65 opacity)
- Neutral background: semi-transparent (0.65 opacity)
- More-popover: semi-transparent with backdrop blur

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:59:15 +08:00
01c276fc8d Make ambient background global and lighten card colors
- Moved ambient from DashboardPage to AppLayout so all pages get the
  drifting gradient effect, not just the dashboard
- Lightened card colors: --card 5% → 8%, --card-elevated 7% → 11%,
  popover and FullCalendar backgrounds updated to match
- Renamed DashboardAmbient → AmbientBackground in layout/
- Glassmorphism class renamed dashboard-glass → ambient-glass,
  applied at AppLayout content wrapper level

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:59:12 +08:00
62949c997f Fix ambient edge clipping: extend orb layers with -100px inset
Drift animations translate orbs up to 80px, causing hard cutoff at
container edges. Giving orb layers inset: -100px provides enough
bleed room so the gradient edges are always beyond the overflow-hidden
boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:18:46 +08:00
34ea31421f Boost ambient visibility: stronger orbs, reduced vignette, transparent cards
- Orbs repositioned centrally with larger ellipses (90%/80%) and higher
  opacity (0.45/0.35) so glow is visible through glassmorphism cards
- Vignette reduced from 0.45 to 0.30, transparent zone expanded to 50%
- Card opacity reduced from 0.80 to 0.65 to let more ambient bleed through
- Added overflow-hidden on ambient container to prevent black bar artifacts
  during drift animations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:55:39 +08:00
a4b3a8f7fe Switch ambient background to radial gradients + glassmorphism cards
Blurred circle approach was invisible on near-black backgrounds.
Use radial-gradient orbs at 25%/15% opacity instead, with semi-transparent
cards (backdrop-filter: blur) so the ambient effect shows through.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:44:38 +08:00
11fe3df513 Fix ambient background: use positive z-index layering instead of negative
Negative z-index (-z-10) placed orbs behind the body's opaque background,
making them invisible. Moved all ambient layers (orbs, noise texture,
vignette) into the DashboardAmbient component as absolute-positioned
children at z-0, with content at z-10. Boosted orb opacities to 12%/7%
for perceptible effect. Removed CSS pseudo-element approach in favor of
inline React elements for better stacking control.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:26:02 +08:00
6b02cfa1f8 Add ambient dashboard background: drifting orbs, noise texture, vignette, card breathe
Three layered effects to make the dashboard feel alive:
1. DashboardAmbient: two accent-colored drifting orbs at very low opacity
   (0.04/0.025) with 120px blur — subtle depth and movement
2. Noise texture + radial vignette via CSS pseudo-elements — breaks the
   flat digital surface and draws focus to center content
3. Card breathe animation: data-driven 4s pulsing glow on CalendarWidget
   (when event in progress) and TodoWidget (when overdue todos exist)

All effects respect prefers-reduced-motion, use accent CSS vars (works
with any user-chosen accent color), and are GPU-composited (transform +
opacity only) for negligible performance cost.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:54:00 +08:00
c21d7592ae Merge feature/upcoming-widget-redesign into main
Upcoming Widget redesign + Dashboard Polish (Batch 1+2):
- Upcoming feed: day-grouped, collapsible, focus mode, hover actions,
  optimistic todo completion, staggered row entrance
- Dashboard: plus rotation, card hover glow, DayBriefing container,
  WeekTimeline hover+pulse+tooltips, countdown urgency, CalendarWidget
  progress bars + current highlight + empty state, TodoWidget inline
  complete + empty state, auto-refresh, keyboard quick-add, progress
  rings, content crossfade, prefers-reduced-motion, ARIA compliance
2026-03-12 00:16:20 +08:00
ac3f746ba3 Fix QA findings: combine todo queries, remove dead prop, add aria-labels
- Merge total_todos and total_incomplete_todos into single DB query (W-04)
- Remove unused `days` prop from UpcomingWidget interface (W-03)
- Add aria-label to focus/show-past toggle buttons (S-08)
- Add zero-duration event guard in CalendarWidget progress calc (S-07)
- Combine duplicate date-utils imports (S-01)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:16:00 +08:00
b41b0b6635 Add dashboard polish: micro-animations, visual upgrades, and interactivity
Batch 1+2 implementation (17 items): plus button rotation, card hover
glow consistency, DayBriefing container with Sparkles icon, WeekTimeline
hover scale + pulsing today dot + dot tooltips, countdown urgency scaling,
CalendarWidget time progress bar + current event highlight + empty state,
TodoWidget inline complete + empty state, dashboard auto-refresh (2min),
optimistic todo completion, "Updated Xm ago" with refresh button, keyboard
quick-add (Ctrl+N → e/t/r), progress rings on stat cards, staggered row
entrance in Upcoming, content crossfade, prefers-reduced-motion support,
ARIA attributes on dropdown menu, and hover:bg-card-elevated consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:02:04 +08:00
8b6530c901 Fix hover jitter by overlaying actions instead of swapping content
The type pill, time label, and priority pill were being removed on
hover and replaced with action buttons, causing layout reflow and
visible jitter. Now the labels stay rendered (invisible when hovered
for todos/reminders) to hold their space, and action buttons are
absolutely positioned on top. Events show no actions so their labels
stay visible on hover. Zero layout shift.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:29:37 +08:00
66e230f740 Make right column cards fill height to align with Upcoming card
Wrap TodoWidget in flex-1 container and add h-full to its Card so
the Upcoming Todos card stretches to fill remaining space in the
right column, keeping both columns visually aligned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:21:04 +08:00
b8bc097f6f Fix Upcoming card to match grid row height with internal scroll
Replace fixed maxHeight 520px with h-full + overflow-hidden so the
card stretches to match the right column height in the grid row.
The flex chain (Card flex-col → CardContent flex-1 min-h-0 →
ScrollArea flex-1 min-h-0) ensures content scrolls internally
within the row-determined height instead of capping independently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:47:00 +08:00
847372643b Fix QA findings: bound queries, error handlers, snooze clamp
C-01: Add 30-day lower bound on overdue todo/reminder queries to
prevent fetching entire history.
C-02: Remove dead include_past query param — past-event filtering
is handled client-side.
W-01: Add onError toast handlers to all three inline mutations.
W-02: Snooze dropdown opens upward (bottom-full) to avoid clipping
inside the ScrollArea overflow container.
S-06: Clamp getMinutesUntilTomorrowMorning() to max 1440 to stay
within ReminderSnooze schema bounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:21:10 +08:00
99161f1b47 Fix Upcoming card height constraint with flex column + maxHeight
Root cause: h-full on Card inside a flex-col parent with no explicit
height meant nothing constrained the card — ScrollArea max-h never
triggered overflow. Fix: Card uses maxHeight 520px as the outer cap,
flex-col layout with shrink-0 header, and min-h-0 on CardContent +
ScrollArea so the flex chain allows content to shrink and scroll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:15:53 +08:00
27a5002c74 Fix Upcoming card height — use natural height with scroll cap
The flex-col h-full layout caused the card to stretch to match the
grid row, pushing content beyond the ScrollArea max-height. Switched
to natural card height with max-h-[400px] on ScrollArea so the card
stays compact and scrolls internally without mismatching the right
column cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:07:59 +08:00
076b2fc3c9 Add scroll cap and fix all-day event time display
Restore max-h-[400px] on ScrollArea so the widget caps and scrolls
instead of growing unbounded and making cards uneven. All-day events
now show "All day" instead of the misleading "12:00 AM" time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:37:04 +08:00
28e1673f05 Fix hover glow using arbitrary Tailwind opacity values
/8 is not in Tailwind's default opacity scale so the classes were
purged. Use /[0.08] arbitrary value syntax instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:27:48 +08:00
5af54de44b Replace left border indicators with subtle type-colored hover glow
Removes the always-visible 2px colored left border from each row.
On hover, the row background now glows with the type color at 8%
opacity (blue for todos, purple for events, orange for reminders).
Cleaner at rest, still provides type recognition on interaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:24:08 +08:00
9635401fe8 Redesign Upcoming Widget with day groups, status pills, and inline actions
Backend: Include overdue todos and snoozed reminders in /upcoming response,
add end_datetime/snoozed_until/is_overdue fields, widen snooze schema to
accept 1-1440 minutes for 1h/3h/tomorrow options.

Frontend: Full UpcomingWidget rewrite with sticky day separators (Today
highlighted in accent), collapsible groups, past-event toggle, focus mode
(Today + Tomorrow), color-coded left borders, compact type pills, relative
time for today's items, item count badge, and inline quick actions (complete
todo, snooze/dismiss reminder on hover). Card fills available height with
no dead space. DashboardPage always renders widget (no duplicate empty state).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:07:14 +08:00
1291807847 Merge fix/qa-deferred-items into main
QA deferred items + Settings page redesign:
- Shared overlay, sort dropdown, touch/a11y improvements
- Admin portal tab layout fixes (desktop + mobile scrollbar)
- Settings page: tab-based layout (5 focused components)
- Code review findings actioned across all changes
2026-03-11 19:34:10 +08:00
f2050efe2d Redesign Settings page with tab-based layout
Replace 895-line monolith with 5 focused tab components (Profile,
Appearance, Social, Security, Integrations) mirroring AdminPortal's
tab pattern. URL deep linking via ?tab= search param. Conditional
rendering prevents unmounted tabs from firing API calls.

Reviewed by senior-code-reviewer, senior-ui-designer, and
security-penetration-tester agents — all findings actioned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:58:28 +08:00
6f8054c63d Fix admin portal nav scrollbar by hiding vertical overflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:12:30 +08:00
e935dc08f1 Fix admin portal: restore desktop tab layout, mobile-only changes
- Nav: justify-evenly on mobile, justify-start on desktop
- Title: "Admin Portal" on desktop, "Admin" on mobile
- Restore mr-6 spacing on title group for desktop
- Tab labels: icon-only on mobile, icon+label on sm+

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:06:28 +08:00
4e91944956 Fix code review findings: sort dropdown, overlay ref, CalendarPage
- C-01: Simplify EntityTable sort dropdown to toggle-based (select
  column, re-select to flip direction), add aria-label
- W-01: Convert CalendarPage mobile overlay to MobileDetailOverlay
- W-02: Use ref for onClose in MobileDetailOverlay to prevent
  listener churn from inline arrow functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 03:46:40 +08:00
a737f06e85 Action deferred QA items: shared overlay, sort, touch, a11y
- S-01/W-06/S-02/S-04: Extract MobileDetailOverlay shared component
  with Escape key, body scroll lock, and ARIA dialog attributes.
  Refactored Todos, Reminders, People, Locations, ProjectDetail.
- W-02: Add specificity contract comment to mobile-scale CSS
- W-03: Enforce 10px floor for text-[9px] on mobile
- W-05: Add sort dropdown to EntityTable mobile card view
- S-03: Export MOBILE/DESKTOP breakpoint constants from useMediaQuery,
  updated all 8 consumer files to use constants
- S-06: Bump KanbanBoard TouchSensor tolerance from 5 to 8
- S-07: Hover state audit — no action needed, hoverOnlyWhenSupported
  in Tailwind config already handles touch devices correctly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 03:43:25 +08:00
e51b09f9c5 Merge feature/mobile-responsive into main
Comprehensive mobile-responsive UI across all frontend pages:
- Global font scaling, responsive grids, progressive disclosure
- Mobile card views, touch-optimized inputs, bottom-sheet DatePicker
- Admin portal responsive tables, evenly spaced tab nav
- KanbanBoard touch drag-and-drop, FullCalendar mobile styling
- isDesktop media query guards for detail panels (no dual mount)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 03:14:58 +08:00
89f72895c1 Fix QA findings: dual panel mount, touch-action, font floor, a11y
- Replace CSS-only panel hiding with isDesktop media query guard
  in Todos, Reminders, People, Locations, ProjectDetail (W-01)
- Add touch-action: manipulation for mobile interactive elements (W-04)
- Bump FullCalendar more-link from 0.55rem to 0.625rem (W-07)
- Add aria-label on admin portal tab NavLinks (S-05)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 03:14:38 +08:00
98ad83ae5f Evenly space admin portal tab navigation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:59:23 +08:00
84b3083987 Admin portal mobile responsiveness: tables, grids, and nav
- Tab nav: scroll isolation, icon-only on mobile, accessible titles
- IAM table: hide 6 columns on mobile, responsive padding
- User detail: responsive grid (1→2→3 cols), role select sizing
- Dashboard: responsive stats grid, hide Actor/Target cols on mobile
- Audit log: responsive column hiding and padding
- Actions menu: role submenu repositions below trigger on mobile
- Config: narrower filter select on mobile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:54:23 +08:00
db16a07f68 Fix project title cutoff on mobile in ProjectDetail header
Reduce header gap to gap-2 on mobile, add min-w-0 so title can
shrink properly, hide status badge on small screens, and add
shrink-0 to action buttons to prevent them from compressing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:24:09 +08:00
9b41cb5003 Fix task title truncation on mobile in Projects tab
Hide verbose metadata columns (status badge, priority badge, date,
subtask count) on mobile and replace with compact priority dot +
overdue indicator. Reduce subtask indent and stack project summary
card vertically on small screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:20:53 +08:00
0fc2d05085 Fix calendar view dropdown clipping and title overlap on mobile
Add pr-8 to mobile view Select to prevent text clipping under chevron.
Add min-w-0 flex-shrink to calendar title h2 to prevent nav arrow overlap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:14:35 +08:00
56175aaf86 Fix calendar popover, dropdown clipping, and header spacing across all tabs
Add dark-themed FullCalendar "+more" popover with CSS X close button
(replaces broken font icon). Add pr-8 to all mobile Select dropdowns
to prevent text clipping under chevron. Normalize header gap to
gap-2 md:gap-4 across all page headers for tighter mobile layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 02:13:41 +08:00
023fa86b65 Mobile UI polish: global font scaling, tighter dashboard, cleaner calendar
Scale down all content text on mobile via .mobile-scale CSS class (excludes
navbar/UMBRA title). Hide calendar event times in month view (Google Calendar
style). Restructure CategoryFilterBar so categories display on a separate row
when toggled instead of being hidden behind the search bar. Reduce dashboard
widget density with hidden badges and tighter spacing on small screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 01:56:53 +08:00
ec8f5a9b4e Fix mobile density issues from S24 Ultra testing
- Page titles: text-xl on mobile, text-2xl on desktop (7 pages)
- Stat cards: reduce padding/gap on mobile, hide icons below sm (3 pages)
- TodoItem: two-line layout on mobile (title row + metadata row)
- ReminderItem: same two-line treatment
- FullCalendar: smaller event font/padding on mobile via CSS media query

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:02:42 +08:00
0b84352b09 Fix KanbanBoard: actually wire TouchSensor into useSensors
The import was added but the sensors config replacement failed silently
due to line ending mismatch. TouchSensor now properly registered with
200ms delay / 5px tolerance alongside PointerSensor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:42:27 +08:00
4d5052d731 Action QA findings: fix all critical/warning/suggestion items
Critical fixes:
- C-01: DatePicker isMobile now actually used for bottom sheet positioning
- C-02: Calendar title always visible (text-sm on mobile, text-lg on sm+)
- C-03: Mobile card text-[10px] → text-xs (meets 12px minimum)

Warning fixes:
- W-01: useMediaQuery SSR-safe (typeof window guard)
- W-02: KanbanBoard TouchSensor added (was lost during branch ops)
- W-03: Removed duplicate isMobile query, derived from !isDesktop
- W-04: Search restored on mobile for Calendar/Reminders/Projects (w-32 sm:w-52)
- W-05: SheetClose added to CalendarSidebar mobile Sheet
- W-06: Button icon uses min-h/min-w for touch targets instead of h-11

Suggestion fixes:
- S-01: Removed deprecated WebkitOverflowScrolling from KanbanBoard
- S-02: Added role/tabIndex/onKeyDown to EntityTable mobile card wrappers
- S-03: Added overflow-y-auto to mobile event detail panel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:16:47 +08:00
f7ec04241b Phase 4: mobile polish and touch fallbacks
4a. Touch fallbacks for group-hover actions:
  - 9 occurrences across 5 files changed from opacity-0 group-hover:opacity-100
    to opacity-100 md:opacity-0 md:group-hover:opacity-100
  - CalendarSidebar (3), SharedCalendarSection (2), TaskDetailPanel (2),
    NotificationsPage (1), CopyableField (1)
  - Action buttons now always visible on touch, hover-revealed on desktop

4b. FullCalendar mobile touch:
  - Wheel navigation disabled on touch devices (ontouchstart check)
  - Prevents scroll hijacking on mobile, allows native scroll

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:04:44 +08:00
b05adf7f12 Phase 3: complex component mobile adaptations
3a. CalendarSidebar mobile collapse:
  - Desktop sidebar + resize handle hidden below lg breakpoint
  - Mobile Sheet overlay with PanelLeft toggle in toolbar
  - Template selection closes mobile sidebar automatically

3b. KanbanBoard touch support:
  - TouchSensor added alongside PointerSensor (200ms delay)
  - Column min-width reduced on mobile (160px vs 200px)
  - iOS smooth scroll enabled on horizontal container

3c. EntityTable mobile card view:
  - mobileCardRender optional prop renders cards instead of table on mobile
  - PeoplePage: card with name, category, email, phone
  - LocationsPage: card with name, category, address
  - TodosPage/RemindersPage use custom list components, not EntityTable

3d. DatePicker mobile bottom sheet:
  - Renders as full-width bottom sheet on mobile (< 768px)
  - Safe area inset padding for iOS home indicator
  - Desktop positioned dropdown unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:03:14 +08:00
d8f7f7ac92 Merge mobile card view into feature/mobile-responsive 2026-03-07 17:01:13 +08:00
09c35752c6 Add mobile card view to EntityTable with renderers for People and Locations
- EntityTable: add useMediaQuery hook, mobileCardRender prop, and mobile card path
  that replaces the table on screens <768px when a renderer is provided
- PeoplePage: add mobileCardRender showing name, category, email, phone
- LocationsPage: add mobileCardRender showing name, category, address

Note: TodosPage and RemindersPage use custom list components (TodoList,
ReminderList), not EntityTable directly — no changes needed there.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:59:58 +08:00
d0477b1c13 Phase 2: toolbar responsive patterns
- All page toolbars now flex-wrap on mobile with min-h instead of fixed h-16
- Segmented button filters (priority, status, view) hidden on mobile, replaced
  with compact Select dropdowns
- Search inputs hidden on mobile where CategoryFilterBar already has search
- CategoryFilterBar wraps to full-width row on mobile (order-last)
- Action buttons show icon-only on mobile, full text on md+
- Calendar title hidden on xs screens for space
- Desktop layout completely unchanged (md:flex-nowrap restores original)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:56:08 +08:00
1c16df4db0 Phase 1: mobile responsive foundation
- useMediaQuery hook extracted from CalendarPage inline pattern
- h-screen → h-dvh for mobile address bar viewport fix
- px-6 → px-4 md:px-6 on all page containers/toolbars (14 files)
- Input/Select text-base on mobile to prevent iOS auto-zoom
- Sheet full-width on mobile, max-w-[540px] on sm+
- Button icon size touch-friendly (44px mobile, 40px desktop)
- Tailwind hoverOnlyWhenSupported: true (fixes 157 hover interactions)
- PWA meta tags (apple-mobile-web-app-capable, theme-color)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:51:53 +08:00
36309c2460 Merge feature/birthday-sync: sync DOB to umbral contacts on profile/settings change 2026-03-07 06:19:16 +08:00
66cc1a0457 Action QA findings: refactor sync to accept resolved values
C-01: sync_birthday_to_contacts now accepts (share_birthday, date_of_birth)
      directly — no internal re-query, no stale-read risk with autoflush.
W-01: Eliminated redundant User/Settings SELECTs inside the service.
W-02: Removed scalar_one() on User query (no longer queries internally).
W-03: Settings router only syncs when share_birthday value actually changes.
S-02: Added logger.info with rowcount for observability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:13:21 +08:00
8aec5a5078 Sync birthday to umbral contacts on DOB or share_birthday change
When a user updates their date of birth or toggles share_birthday,
all linked Person records (where linked_user_id matches) are updated.
If share_birthday is off, the birthday is cleared on linked records.
Virtual birthday events auto-reflect the change on next calendar poll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:01:35 +08:00
f0e8f450f3 Merge feature/shared-calendars: full shared calendar system
Shared calendars with invite flow, granular permissions (read_only/create_modify/full_access),
event locking (5-min TTL + permanent), real-time sync (5s polling), drag-drop guards, resizable
sidebar, and polished UI components. QA reviewed, pentested, all findings actioned.

20 commits across 6 phases + QA/pentest fixes + UI polish.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:57:29 +08:00
ff81ef7c14 Fix calendar sidebar resize lag at fast drag speeds
Replace per-mousemove setSidebarWidth() calls (triggering full React re-renders
including FullCalendar) with direct DOM style mutation during drag. React state
is committed only once on mouseup, eliminating all mid-drag re-renders and
localStorage writes that caused the lag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:47:47 +08:00
59c89c904c Resizable calendar sidebar with localStorage persistence
Sidebar width adjustable via click-and-drag (180–400px range, default 224px).
Width persists to localStorage across sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:58:50 +08:00
1bc1e37518 Fix W-06 regression: preferred_name is on Settings, not User model
The _build_member_response helper tried to access member.user.preferred_name
but User model has no preferred_name field (it's on Settings). With lazy="raise"
this caused a 500 on GET /shared-calendars/{id}/members. Reverted to None —
the list_members endpoint already patches preferred_name from Settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:49:49 +08:00
cdbf3175aa Fix remaining QA warnings: lazy=raise on CalendarMember + bidirectional connection check
W-03: invite_member now verifies the target user has a reciprocal
UserConnection row before sending the invite.

W-04: CalendarMember relationships changed from lazy="selectin" to
lazy="raise". All queries that access .user, .calendar, or .inviter
already use explicit selectinload() — verified across all routers
and services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:45:10 +08:00
dd862bfa48 Fix QA review findings: 3 critical, 5 warnings, 1 suggestion
Critical:
- C-01: Populate member_count in GET /calendars for shared calendars
- C-02: Differentiate 423 lock errors in drag-drop onError (show lock-specific toast)
- C-03: Add expired lock purge to APScheduler housekeeping job

Warnings:
- W-01: Replace setattr loop with explicit field assignment in update_member
- W-02: Cap sync `since` param to 7 days to prevent unbounded scans
- W-05: Remove cosmetic isShared toggle (is_shared is auto-managed by invite flow)
- W-06: Populate preferred_name in _build_member_response from user model
- W-07: Add releaseMutation to release callback dependency array

Suggestion:
- S-06: Remove unused ConvertToSharedRequest schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:41:08 +08:00
206144d20d Fix 2 pentest findings: unlock permission check + permanent lock preservation
SC-01: unlock_event now verifies caller has access to the calendar before
revealing lock state. Previously any authenticated user could probe event
existence via 404/204/403 response differences.

SC-02: acquire_lock no longer overwrites permanent locks. If the owner holds
a permanent lock and clicks Edit, the existing lock is returned as-is instead
of being downgraded to a 5-minute temporary lock.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:37:05 +08:00
8f777dd15a Fix lock banner: use viewLockQuery.data directly instead of syncing through state
Root cause: the previous approach synced poll data into lockInfo via a useEffect.
When the user selected an event with cached lock data, both the poll-data effect and
the event-change reset effect ran in the same render cycle. The event-change effect
ran second (effects are ordered by definition) and cleared lockInfo to null. On the
next render, viewLockQuery.data hadn't changed (TanStack Query structural sharing
returns same reference), so the poll-data effect never re-fired. Result: lockInfo
stayed null, banner stayed hidden until the next polling interval returned new data.

Fix: derive activeLockInfo directly from viewLockQuery.data (structural sharing
means it's always the latest authoritative value from TanStack Query) with lockInfo
as a fallback for the 423-error path only. Also add refetchIntervalInBackground:true
and refetchOnMount:'always' to ensure polling doesn't pause on tab switch and always
fires a fresh fetch when the component mounts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:47:26 +08:00
3dcf9d1671 Fix isSharedEvent excluding calendar owners — lock banner never appeared for owners
The selectedEventIsShared check used `permissionMap.get(...) !== 'owner'` which
excluded calendar owners from all shared-event behavior (lock polling, lock
acquisition, lock banner display). Replaced with a sharedCalendarIds set that
includes both owned shared calendars (via cal.is_shared) and memberships.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:41:47 +08:00
e62503424c Fix event lock system: banner persistence, stale isEditing guard, and Edit button gating
- Remove isEditing guard from viewLockQuery effect so lock banner shows for user B
  even after user A transitions into edit mode (fixes banner disappearing)
- Disable Edit button proactively when lockInfo.locked is already known from polling,
  preventing the user from even attempting acquireLock when a lock is active
- Fix acquire callback dep array in useEventLock (missing acquireMutation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:26:22 +08:00
c55af91c60 Fix two shared calendar bugs: lock banner missing and calendar not found on save
Bug 1 (lock banner): Owners bypassed lock acquisition entirely, so no DB lock
was created when an owner edited a shared event. Members polling GET
/shared-calendars/events/{id}/lock correctly saw `locked: false`. Fix: remove
the `myPermission !== 'owner'` guard in handleEditStart so owners also acquire
a temporary 5-min edit lock when editing shared events, making the banner
visible to all other members.

Bug 2 (calendar not found on save): PUT /events/{id} called
_verify_calendar_ownership whenever calendar_id appeared in the payload, even
when it was unchanged. For shared-calendar members this always 404'd because
they don't own the calendar. Fix: add `update_data["calendar_id"] !=
event.calendar_id` to the guard — ownership is only verified when the calendar
is actually being changed (existing M-01 guard handles the move-off-shared case).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:16:35 +08:00
38334b77a3 Shrink color picker swatches (h-8 → h-6)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:02:29 +08:00
a2f4d297a3 Single-line member rows + purple umbral name
- Flatten member row to strict single line: avatar | name | umbral name (violet) | pending badge | permission toggle | controls
- Umbral name shown in text-violet-400 for visual differentiation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:58:51 +08:00
1b36e6b6a7 Widen shared calendar dialogs + single-line member rows
- CalendarForm: max-w-3xl when sharing (was sm:max-w-2xl, overridden by base max-w-xl)
- SharedCalendarSettings: max-w-2xl (was sm:max-w-lg)
- CalendarMemberRow: back to single-line with PermissionToggle inline (less cramped)
- Use unprefixed max-w classes so twMerge properly overrides DialogContent base

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:54:25 +08:00
b401fd9392 Phase 6: Real-time sync, drag-drop guards, security fix, invite bug fix, UI polish
- Event polling (5s refetchInterval) so collaborators see changes without refresh
- Lock status polling in EventDetailPanel view mode — proactive lock banner
- Per-event editable flag blocks drag on read-only shared events
- Read-only permission guard in handleEventDrop/handleEventResize
- M-01 security fix: block non-owners from moving events off shared calendars (403)
- Fix invite response type (backend returns list, not wrapper object)
- Remove is_shared from CalendarCreate/CalendarUpdate input schemas
- New PermissionToggle segmented control (Eye/Pencil/Shield icons)
- CalendarMemberRow restructured into spacious two-line card layout
- CalendarForm dialog widened (sm:max-w-2xl), polished invite card with accent border
- SharedCalendarSettings dialog widened (sm:max-w-lg)
- CalendarMemberList max-height increased (max-h-48 → max-h-72)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:46:15 +08:00
14fc085009 Phase 5: Shared calendar polish — scoped polling, admin stats, dual panel fix, edge case handling
- Scope shared calendar polling to CalendarPage only (other consumers no longer poll)
- Add admin sharing stats card (owned/member/invites sent/received) in UserDetailSection
- Fix dual EventDetailPanel mount via JS media query breakpoint (replaces CSS hidden)
- Auto-close panel + toast when shared calendar is removed while viewing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:38:52 +08:00
f45b7a2115 Fix 4 reported bugs from Phase 4 testing
1. Invite auto-sends at read_only: now stages connection with permission
   selector (Read Only / Create Modify / Full Access) before sending
2. Shared calendars missing from event create dropdown: members with
   create_modify+ permission now see shared calendars in calendar picker
3. Shared calendar category not showing for owner: owner's shared calendars
   now appear under SHARED CALENDARS section with "Owner" badge
4. Event creation not updating calendar: handlePanelClose now invalidates
   calendar-events query to ensure FullCalendar refreshes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:23:45 +08:00
e5690625eb Fix member removal bug + QA fixes + shared calendar sidebar styling
Bug fix:
- CalendarMemberRow: add type="button" to remove button (was submitting parent form)

QA fixes:
- EventDetailPanel: use axios.isAxiosError() instead of duck-typing for lock errors
- EventDetailPanel: only call onSaved on create (edits return to view mode, not close)
- CalendarForm: remove 4 redundant membersQuery.refetch() calls (mutations already invalidate)
- useEventLock: remove unused lockHeld ref from return, fix stale eventId in onSuccess
- EventLockBanner: guard against invalid date parse

UI:
- SharedCalendarSection: add purple Ghost icon next to "SHARED CALENDARS" header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 06:07:55 +08:00
eedfaaf859 Phase 4: Event locking + permission gating for shared calendars
- useEventLock hook with auto-release on unmount/event change
- EventLockBanner component for locked event display
- EventDetailPanel: lock acquire on edit, release on save/cancel, permission-gated edit/delete buttons
- CalendarPage: permission map from owned+shared calendars, per-event editable gating

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 05:24:43 +08:00
4e3fd35040 Phase 3: Frontend core for shared calendars
Types: CalendarPermission, SharedCalendarMembership, CalendarMemberInfo,
CalendarInvite, EventLockInfo. Calendar type extended with is_shared.

Hooks: useCalendars extended with shared calendar polling (5s).
useSharedCalendars for member CRUD, invite responses, color updates.
useConnections cascade invalidation on disconnect.

New components: PermissionBadge, CalendarMemberSearch,
CalendarMemberRow, CalendarMemberList, SharedCalendarSection,
SharedCalendarSettings (non-owner dialog with color, members, leave).

Modified: CalendarForm (sharing toggle, member management for owners),
CalendarSidebar (shared calendars section with localStorage visibility),
CalendarPage (shared calendar ID integration in event filtering),
NotificationToaster (calendar_invite toast with accept/decline),
NotificationsPage (calendar_invite inline actions + type icons).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 04:59:13 +08:00
e6e81c59e7 Phase 2: Shared calendars backend core + QA fixes
Router: invite/accept/reject flow, membership CRUD, event locking
(timed + permanent), sync endpoint, local color override.
Services: permission hierarchy, atomic lock acquisition, disconnect cascade.
Events: shared calendar scoping, permission/lock enforcement, updated_by tracking.
Admin: sharing-stats endpoint. nginx: rate limits for invite + sync.

QA fixes: C-01 (read-only invite gate), C-02 (updated_by in this_and_future),
W-01 (pre-commit response build), W-02 (owned calendar short-circuit),
W-03 (sync calendar_ids cap), W-04 (N+1 owner name batch fetch).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 04:46:17 +08:00
e4b45763b4 Phase 1: Schema and models for shared calendars
Migrations 047-051:
- 047: Add is_shared to calendars
- 048: Create calendar_members table (permissions, status, constraints)
- 049: Create event_locks table (5min TTL, permanent owner locks)
- 050: Expand notification CHECK (calendar_invite types)
- 051: Add updated_by to calendar_events + updated_at index

New models: CalendarMember, EventLock
Updated models: Calendar (is_shared, members), CalendarEvent (updated_by),
  Notification (3 new types)
New schemas: shared_calendar.py (invite, respond, member, lock, sync)
Updated schemas: calendar.py (is_shared, sharing response fields)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 03:22:44 +08:00
b650a94bb8 Fix birthday DatePicker stale closure in Settings profile save
The onBlur handler captured the stale dateOfBirth value from before
onChange updated state, causing the equality guard to silently abort
the save. Fixed by saving inline in onChange with the fresh value
and removing onBlur/onKeyDown from the DatePicker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 02:05:49 +08:00
47645ec115 Merge feature/user-connections into main
User connections system: search by umbral name, send/accept/reject/cancel
requests, bidirectional Person records on accept, per-connection sharing
overrides, in-app notification centre with toast popups, ntfy push
integration. Includes QA fixes, pentest hardening, and contact sync fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:36:02 +08:00
182 changed files with 14940 additions and 2861 deletions

View File

@ -21,6 +21,15 @@ 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
# ──────────────────────────────────────

View File

@ -0,0 +1,86 @@
name: Build and Deploy UMBRA
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
with:
token: ${{ secrets.REGISTRY_TOKEN }}
- name: Login to Gitea Container Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY_HOST }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
- name: Build and push backend
run: |
docker build --pull \
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest \
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }} \
./backend
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }}
- name: Build and push frontend
run: |
docker build --pull \
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest \
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }} \
./frontend
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }}
- name: Deploy
run: |
# Spawn a short-lived container that mounts the host deploy path
# and runs compose commands against the host Docker daemon.
# DEPLOY_PATH is a Gitea variable — update it when moving hosts.
docker run --rm \
--network host \
--security-opt label:disable \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ${{ vars.DEPLOY_PATH }}:/deploy \
-w /deploy \
docker:27-cli sh -c "
docker compose -p umbra --env-file stack.env pull backend frontend &&
docker compose -p umbra --env-file stack.env up -d db backend frontend
"
- name: Health check
run: |
echo "Waiting for services to start..."
sleep 30
curl -f http://localhost:${{ vars.DEPLOY_PORT }}/health || exit 1
- name: Prune old images
if: success()
run: docker image prune -f
- name: Notify success
if: success()
run: |
curl -s \
-H "Title: UMBRA Deploy Success" \
-H "Tags: white_check_mark" \
--data-binary @- https://ntfy.ghost6.xyz/claude <<'NTFY_EOF'
Build ${{ github.sha }} deployed successfully to umbra.ghost6.xyz.
Triggered by push to main.
NTFY_EOF
- name: Notify failure
if: failure()
run: |
curl -s \
-H "Title: UMBRA Deploy FAILED" \
-H "Tags: fire" \
-H "Priority: high" \
--data-binary @- https://ntfy.ghost6.xyz/claude <<'NTFY_EOF'
Deploy failed for commit ${{ github.sha }}.
Check Gitea Actions logs at git.sentinelforest.xyz.
NTFY_EOF

View File

@ -4,18 +4,20 @@ 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
- **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
- **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
## Tech Stack
@ -26,7 +28,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 (37 migrations) |
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic (56 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 |
@ -108,27 +110,7 @@ A self-hosted, multi-user life administration app with a dark-themed UI and role
## Security
### 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
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.
### Production Hardening
@ -137,21 +119,15 @@ 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
@ -163,9 +139,11 @@ 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) |
| `/api/events/*` | Calendar events CRUD (incl. recurring) + event invitations |
| `/api/event-invitations/*` | Invitation responses, per-occurrence overrides, can_modify toggle, leave |
| `/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 |
@ -174,6 +152,8 @@ 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`.
@ -215,15 +195,15 @@ umbra/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── alembic.ini
│ ├── alembic/versions/ # 37 migrations (001037)
│ ├── alembic/versions/ # 56 migrations (001056)
│ └── 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/ # 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
│ ├── 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
│ └── jobs/ # APScheduler notification dispatch
└── frontend/
├── Dockerfile
@ -233,20 +213,22 @@ 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, useCategoryOrder, useTableVisibility
├── hooks/ # useAuth, useAdmin, useSettings, useTheme, useCalendars, useConfirmAction, useConnections, useNotifications, useEventInvitations
├── types/ # TypeScript interfaces
└── components/
├── ui/ # 17 base components (Button, Dialog, Sheet, Card, Input, Select, Switch, etc.)
├── ui/ # 18 base components (Button, Dialog, Sheet, Card, Input, Select, Switch, DatePicker, ...)
├── shared/ # EntityTable, EntityDetailPanel, CategoryFilterBar, CategoryAutocomplete, CopyableField
├── layout/ # AppLayout, Sidebar, LockOverlay
├── auth/ # LockScreen, AmbientBackground
├── admin/ # AdminPortal, IAMPage, ConfigPage, AdminDashboardPage, CreateUserDialog, UserActionsMenu, UserDetailSection
├── admin/ # AdminPortal, IAMPage, ConfigPage, AdminDashboardPage, CreateUserDialog, UserActionsMenu
├── dashboard/ # DashboardPage + 8 widgets
├── calendar/ # CalendarPage, CalendarSidebar, CalendarForm, EventForm, TemplateForm
├── todos/ # TodosPage, TodoList, TodoItem, TodoForm
├── calendar/ # CalendarPage, CalendarSidebar, EventDetailPanel, InviteeSection, LeaveEventDialog, CalendarForm, EventForm, TemplateForm
├── todos/ # TodosPage, TodoList, TodoItem, TodoForm, TodoDetailPanel
├── 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
```

20
act_runner_config.yaml Normal file
View File

@ -0,0 +1,20 @@
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

44
backend/.dockerignore Normal file
View File

@ -0,0 +1,44 @@
# Version control
.git
.gitignore
# Python artifacts
__pycache__
*.pyc
*.pyo
*.egg-info
dist
build
.eggs
# Virtual environments
.venv
venv
env
# IDE
.vscode
.idea
# Environment files — never bake secrets into the image
.env
.env.*
# Tests
tests
pytest.ini
.pytest_cache
.coverage
htmlcov
# Documentation
README.md
CHANGELOG.md
LICENSE
# Dev scripts
start.sh
# Docker files (no need to copy into the image)
Dockerfile
docker-compose*.yaml

View File

@ -1,2 +1,14 @@
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

View File

@ -1,5 +1,5 @@
# ── Build stage: compile C extensions ──────────────────────────────────
FROM python:3.12-slim AS builder
FROM python:3.12.9-slim-bookworm AS builder
WORKDIR /build
@ -11,24 +11,25 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Runtime stage: lean production image ───────────────────────────────
FROM python:3.12-slim
FROM python:3.12.9-slim-bookworm
# Create non-root user first, then copy with correct ownership (DW-2)
RUN useradd -m -u 1000 appuser
WORKDIR /app
# Copy pre-built Python packages from builder
COPY --from=builder /install /usr/local
# Copy application code
COPY . .
# Copy application code with correct ownership — avoids redundant chown layer
COPY --chown=appuser:appuser . .
# Make entrypoint executable
RUN chmod +x entrypoint.sh
# Create non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
# Run migrations and start server
# --no-server-header: suppresses uvicorn version disclosure
# --proxy-headers: reads X-Forwarded-Proto/For from reverse proxy so redirects use correct scheme
# --forwarded-allow-ips '*': trusts proxy headers from any IP (nginx is on Docker bridge network)
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header --proxy-headers --forwarded-allow-ips '*'"]
# Use entrypoint with exec so uvicorn runs as PID 1 and receives signals (DW-5)
ENTRYPOINT ["./entrypoint.sh"]

View File

@ -1,39 +1,37 @@
# UMBRA Backend
A complete FastAPI backend for the UMBRA application with async SQLAlchemy, PostgreSQL, authentication, and comprehensive CRUD operations.
FastAPI backend for the UMBRA life management application with async SQLAlchemy, PostgreSQL, multi-user RBAC, and comprehensive security.
## Features
- **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
- **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
## Project Structure
```
backend/
├── alembic/ # Database migrations
│ ├── versions/ # Migration files
│ ├── env.py # Alembic environment
│ └── script.py.mako # Migration template
├── alembic/versions/ # 61 database migrations
├── app/
│ ├── 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
│ ├── 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
```
## Setup
@ -41,160 +39,87 @@ backend/
### 1. Install Dependencies
```bash
cd backend
pip install -r requirements.txt
```
### 2. Configure Environment
Create a `.env` file:
Copy `.env.example` to `.env` and configure:
```bash
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/umbra
SECRET_KEY=your-secret-key-change-in-production
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
```
### 3. Create Database
```bash
createdb umbra
```
### 4. Run Migrations
### 3. Run Migrations
```bash
alembic upgrade head
```
### 5. Start Server
### 4. Start Server
```bash
# Using the start script
chmod +x start.sh
./start.sh
# Or directly with uvicorn
uvicorn app.main:app --reload
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
The API will be available at `http://localhost:8000`
## API Routes
## API Documentation
All routes require authentication (signed session cookie) except `/api/auth/*` and `/health`.
Interactive API documentation is available at:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
| 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 |
## API Endpoints
## Authentication
### 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
UMBRA supports three authentication methods:
### 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
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
### 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
Passkey login bypasses TOTP (a passkey is inherently two-factor: possession + biometric/PIN).
### 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
## Security
### 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
- 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)
## Docker
Build and run with Docker:
The backend runs as non-root `appuser` in `python:3.12-slim`:
```bash
docker build -t umbra-backend .
docker run -p 8000:8000 -e DATABASE_URL=... -e SECRET_KEY=... umbra-backend
docker run -p 8000:8000 --env-file .env umbra-backend
```
## 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
In production, use Docker Compose (see root `docker-compose.yaml`).

View File

@ -0,0 +1,23 @@
"""Add is_shared to calendars
Revision ID: 047
Revises: 046
"""
from alembic import op
import sqlalchemy as sa
revision = "047"
down_revision = "046"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"calendars",
sa.Column("is_shared", sa.Boolean(), nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("calendars", "is_shared")

View File

@ -0,0 +1,47 @@
"""Create calendar_members table
Revision ID: 048
Revises: 047
"""
from alembic import op
import sqlalchemy as sa
revision = "048"
down_revision = "047"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"calendar_members",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("calendar_id", sa.Integer(), sa.ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("invited_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("permission", sa.String(20), nullable=False),
sa.Column("can_add_others", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("local_color", sa.String(20), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("invited_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("accepted_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("calendar_id", "user_id", name="uq_calendar_members_cal_user"),
sa.CheckConstraint(
"permission IN ('read_only', 'create_modify', 'full_access')",
name="ck_calendar_members_permission",
),
sa.CheckConstraint(
"status IN ('pending', 'accepted', 'rejected')",
name="ck_calendar_members_status",
),
)
op.create_index("ix_calendar_members_user_id", "calendar_members", ["user_id"])
op.create_index("ix_calendar_members_calendar_id", "calendar_members", ["calendar_id"])
op.create_index("ix_calendar_members_status", "calendar_members", ["status"])
def downgrade() -> None:
op.drop_index("ix_calendar_members_status", table_name="calendar_members")
op.drop_index("ix_calendar_members_calendar_id", table_name="calendar_members")
op.drop_index("ix_calendar_members_user_id", table_name="calendar_members")
op.drop_table("calendar_members")

View File

@ -0,0 +1,30 @@
"""Create event_locks table
Revision ID: 049
Revises: 048
"""
from alembic import op
import sqlalchemy as sa
revision = "049"
down_revision = "048"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"event_locks",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("event_id", sa.Integer(), sa.ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False, unique=True),
sa.Column("locked_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("locked_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("expires_at", sa.DateTime(), nullable=True),
sa.Column("is_permanent", sa.Boolean(), nullable=False, server_default="false"),
)
op.create_index("ix_event_locks_expires_at", "event_locks", ["expires_at"])
def downgrade() -> None:
op.drop_index("ix_event_locks_expires_at", table_name="event_locks")
op.drop_table("event_locks")

View File

@ -0,0 +1,33 @@
"""Expand notification type CHECK for calendar invite types
Revision ID: 050
Revises: 049
"""
from alembic import op
revision = "050"
down_revision = "049"
branch_labels = None
depends_on = None
_OLD_TYPES = (
"connection_request", "connection_accepted", "connection_rejected",
"info", "warning", "reminder", "system",
)
_NEW_TYPES = _OLD_TYPES + (
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
)
def _check_sql(types: tuple) -> str:
return f"type IN ({', '.join(repr(t) for t in types)})"
def upgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_NEW_TYPES))
def downgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_OLD_TYPES))

View File

@ -0,0 +1,34 @@
"""Add updated_by to calendar_events and ensure updated_at index
Revision ID: 051
Revises: 050
"""
from alembic import op
import sqlalchemy as sa
revision = "051"
down_revision = "050"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"calendar_events",
sa.Column(
"updated_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(
"ix_calendar_events_updated_at",
"calendar_events",
["updated_at"],
)
def downgrade() -> None:
op.drop_index("ix_calendar_events_updated_at", table_name="calendar_events")
op.drop_column("calendar_events", "updated_by")

View File

@ -0,0 +1,18 @@
"""add is_locked and locked_at to user_sessions
Revision ID: 052
Revises: 051
"""
from alembic import op
import sqlalchemy as sa
revision = "052"
down_revision = "051"
def upgrade():
op.add_column("user_sessions", sa.Column("is_locked", sa.Boolean(), server_default="false", nullable=False))
op.add_column("user_sessions", sa.Column("locked_at", sa.DateTime(), nullable=True))
def downgrade():
op.drop_column("user_sessions", "locked_at")
op.drop_column("user_sessions", "is_locked")

View File

@ -0,0 +1,29 @@
"""Add composite indexes for calendar_members and ntfy_sent
Revision ID: 053
Revises: 052
"""
from alembic import op
revision = "053"
down_revision = "052"
def upgrade():
# AW-1: Hot query polled every 5s uses (user_id, status) together
op.create_index(
"ix_calendar_members_user_id_status",
"calendar_members",
["user_id", "status"],
)
# AS-6: Dedup lookup in notification dispatch uses (user_id, sent_at)
op.create_index(
"ix_ntfy_sent_user_id_sent_at",
"ntfy_sent",
["user_id", "sent_at"],
)
def downgrade():
op.drop_index("ix_ntfy_sent_user_id_sent_at", table_name="ntfy_sent")
op.drop_index("ix_calendar_members_user_id_status", table_name="calendar_members")

View File

@ -0,0 +1,116 @@
"""Event invitations tables and notification types.
Revision ID: 054
Revises: 053
"""
from alembic import op
import sqlalchemy as sa
revision = "054"
down_revision = "053"
branch_labels = None
depends_on = None
def upgrade():
# ── event_invitations table ──
op.create_table(
"event_invitations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"event_id",
sa.Integer(),
sa.ForeignKey("calendar_events.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"invited_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("invited_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("responded_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("event_id", "user_id", name="uq_event_invitations_event_user"),
sa.CheckConstraint(
"status IN ('pending', 'accepted', 'tentative', 'declined')",
name="ck_event_invitations_status",
),
)
op.create_index(
"ix_event_invitations_user_status",
"event_invitations",
["user_id", "status"],
)
op.create_index(
"ix_event_invitations_event_id",
"event_invitations",
["event_id"],
)
# ── event_invitation_overrides table ──
op.create_table(
"event_invitation_overrides",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"invitation_id",
sa.Integer(),
sa.ForeignKey("event_invitations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"occurrence_id",
sa.Integer(),
sa.ForeignKey("calendar_events.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("responded_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("invitation_id", "occurrence_id", name="uq_invitation_override"),
sa.CheckConstraint(
"status IN ('accepted', 'tentative', 'declined')",
name="ck_invitation_override_status",
),
)
op.create_index(
"ix_invitation_overrides_lookup",
"event_invitation_overrides",
["invitation_id", "occurrence_id"],
)
# ── Expand notification type check constraint ──
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint(
"ck_notifications_type",
"notifications",
"type IN ('connection_request', 'connection_accepted', 'connection_rejected', "
"'calendar_invite', 'calendar_invite_accepted', 'calendar_invite_rejected', "
"'event_invite', 'event_invite_response', "
"'info', 'warning', 'reminder', 'system')",
)
def downgrade():
op.drop_index("ix_invitation_overrides_lookup", table_name="event_invitation_overrides")
op.drop_table("event_invitation_overrides")
op.drop_index("ix_event_invitations_event_id", table_name="event_invitations")
op.drop_index("ix_event_invitations_user_status", table_name="event_invitations")
op.drop_table("event_invitations")
# Restore original notification type constraint
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint(
"ck_notifications_type",
"notifications",
"type IN ('connection_request', 'connection_accepted', 'connection_rejected', "
"'calendar_invite', 'calendar_invite_accepted', 'calendar_invite_rejected', "
"'info', 'warning', 'reminder', 'system')",
)

View File

@ -0,0 +1,51 @@
"""Add display_calendar_id to event_invitations.
Allows invitees to assign invited events to their own calendars
for personal organization, color, and visibility control.
Revision ID: 055
Revises: 054
"""
from alembic import op
import sqlalchemy as sa
revision = "055"
down_revision = "054"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"event_invitations",
sa.Column(
"display_calendar_id",
sa.Integer(),
sa.ForeignKey("calendars.id", ondelete="SET NULL", name="fk_event_invitations_display_calendar_id"),
nullable=True,
),
)
op.create_index(
"ix_event_invitations_display_calendar",
"event_invitations",
["display_calendar_id"],
)
# Backfill accepted/tentative invitations with each user's default calendar
op.execute("""
UPDATE event_invitations
SET display_calendar_id = (
SELECT c.id FROM calendars c
WHERE c.user_id = event_invitations.user_id
AND c.is_default = true
LIMIT 1
)
WHERE status IN ('accepted', 'tentative')
AND display_calendar_id IS NULL
""")
def downgrade() -> None:
op.drop_index("ix_event_invitations_display_calendar", table_name="event_invitations")
op.drop_column("event_invitations", "display_calendar_id")

View File

@ -0,0 +1,26 @@
"""add can_modify to event_invitations
Revision ID: 056
Revises: 055
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "056"
down_revision = "055"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"event_invitations",
sa.Column("can_modify", sa.Boolean(), server_default=sa.false(), nullable=False),
)
def downgrade() -> None:
op.drop_column("event_invitations", "can_modify")

View File

@ -0,0 +1,49 @@
"""project collab prep: indexes, task version, comment user_id
Revision ID: 057
Revises: 056
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "057"
down_revision = "056"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1a. Performance indexes for project_tasks
# Use IF NOT EXISTS to handle indexes that may already exist on the DB
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_id ON project_tasks (project_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_parent_task_id ON project_tasks (parent_task_id) WHERE parent_task_id IS NOT NULL")
op.execute("CREATE INDEX IF NOT EXISTS ix_project_tasks_project_updated ON project_tasks (project_id, updated_at DESC)")
op.execute("CREATE INDEX IF NOT EXISTS ix_projects_user_updated ON projects (user_id, updated_at DESC)")
# 1b. Add user_id to task_comments for multi-user attribution
op.add_column(
"task_comments",
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
)
# 1c. Add version column to project_tasks for optimistic locking
op.add_column(
"project_tasks",
sa.Column("version", sa.Integer(), server_default="1", nullable=False),
)
# Calendar delta polling index (Phase 4 prep)
op.execute("CREATE INDEX IF NOT EXISTS ix_events_calendar_updated ON calendar_events (calendar_id, updated_at DESC)")
def downgrade() -> None:
op.drop_index("ix_events_calendar_updated", table_name="calendar_events")
op.drop_column("project_tasks", "version")
op.drop_column("task_comments", "user_id")
op.drop_index("ix_projects_user_updated", table_name="projects")
op.drop_index("ix_project_tasks_project_updated", table_name="project_tasks")
op.drop_index("ix_project_tasks_parent_task_id", table_name="project_tasks")
op.drop_index("ix_project_tasks_project_id", table_name="project_tasks")

View File

@ -0,0 +1,45 @@
"""add project_members table
Revision ID: 058
Revises: 057
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "058"
down_revision = "057"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"project_members",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("project_id", sa.Integer(), sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("invited_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("permission", sa.String(20), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("source", sa.String(20), nullable=False, server_default="invited"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()),
sa.Column("accepted_at", sa.DateTime(), nullable=True),
sa.UniqueConstraint("project_id", "user_id", name="uq_project_members_proj_user"),
sa.CheckConstraint("permission IN ('read_only', 'create_modify')", name="ck_project_members_permission"),
sa.CheckConstraint("status IN ('pending', 'accepted', 'rejected')", name="ck_project_members_status"),
sa.CheckConstraint("source IN ('invited', 'auto_assigned')", name="ck_project_members_source"),
)
op.create_index("ix_project_members_user_id", "project_members", ["user_id"])
op.create_index("ix_project_members_project_id", "project_members", ["project_id"])
op.create_index("ix_project_members_status", "project_members", ["status"])
def downgrade() -> None:
op.drop_index("ix_project_members_status", table_name="project_members")
op.drop_index("ix_project_members_project_id", table_name="project_members")
op.drop_index("ix_project_members_user_id", table_name="project_members")
op.drop_table("project_members")

View File

@ -0,0 +1,35 @@
"""add project_task_assignments table
Revision ID: 059
Revises: 058
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "059"
down_revision = "058"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"project_task_assignments",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("task_id", sa.Integer(), sa.ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("assigned_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
sa.UniqueConstraint("task_id", "user_id", name="uq_task_assignments_task_user"),
)
op.create_index("ix_task_assignments_task_id", "project_task_assignments", ["task_id"])
op.create_index("ix_task_assignments_user_id", "project_task_assignments", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_task_assignments_user_id", table_name="project_task_assignments")
op.drop_index("ix_task_assignments_task_id", table_name="project_task_assignments")
op.drop_table("project_task_assignments")

View File

@ -0,0 +1,36 @@
"""Expand notification type CHECK for project invite types
Revision ID: 060
Revises: 059
"""
from alembic import op
revision = "060"
down_revision = "059"
branch_labels = None
depends_on = None
_OLD_TYPES = (
"connection_request", "connection_accepted", "connection_rejected",
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
"event_invite", "event_invite_response",
"info", "warning", "reminder", "system",
)
_NEW_TYPES = _OLD_TYPES + (
"project_invite", "project_invite_accepted", "project_invite_rejected",
"task_assigned",
)
def _check_sql(types: tuple) -> str:
return f"type IN ({', '.join(repr(t) for t in types)})"
def upgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_NEW_TYPES))
def downgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_OLD_TYPES))

View File

@ -0,0 +1,40 @@
"""Add passkey_credentials table for WebAuthn/FIDO2 authentication
Revision ID: 061
Revises: 060
"""
import sqlalchemy as sa
from alembic import op
revision = "061"
down_revision = "060"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"passkey_credentials",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("credential_id", sa.Text, unique=True, nullable=False),
sa.Column("public_key", sa.Text, nullable=False),
sa.Column("sign_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("transports", sa.Text, nullable=True),
sa.Column("backed_up", sa.Boolean, nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime, server_default=sa.text("now()")),
sa.Column("last_used_at", sa.DateTime, nullable=True),
)
op.create_index(
"ix_passkey_credentials_user_id", "passkey_credentials", ["user_id"]
)
def downgrade():
op.drop_table("passkey_credentials")

View File

@ -0,0 +1,40 @@
"""Passwordless login — add passwordless_enabled to users and allow_passwordless to system_config.
Revision ID: 062
Revises: 061
Create Date: 2026-03-18
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "062"
down_revision = "061"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column(
"passwordless_enabled",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
op.add_column(
"system_config",
sa.Column(
"allow_passwordless",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
def downgrade() -> None:
op.drop_column("users", "passwordless_enabled")
op.drop_column("system_config", "allow_passwordless")

View File

@ -30,6 +30,12 @@ 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",
@ -47,6 +53,9 @@ 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

View File

@ -2,11 +2,15 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess
from sqlalchemy.orm import declarative_base
from app.config import settings
# Create async engine
# Create async engine with tuned pool (AW-7)
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
future=True
future=True,
pool_size=10,
max_overflow=5,
pool_pre_ping=True,
pool_recycle=1800,
)
# Create async session factory

View File

@ -21,6 +21,7 @@ from app.models.notification import Notification as AppNotification
from app.models.reminder import Reminder
from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
from app.models.event_lock import EventLock
from app.models.todo import Todo
from app.models.project import Project
from app.models.ntfy_sent import NtfySent
@ -55,8 +56,8 @@ async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]:
async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
"""Stage a sent record — caller must commit (AW-4: bulk commit per user)."""
db.add(NtfySent(notification_key=key, user_id=user_id))
await db.commit()
# ── Dispatch functions ────────────────────────────────────────────────────────
@ -238,14 +239,20 @@ async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime
# Batch-fetch all sent keys once per user instead of one query per entity
sent_keys = await _get_sent_keys(db, settings.user_id)
# AW-4: Commit after each category to preserve dedup records if a later
# category fails (prevents re-sending already-sent notifications)
if settings.ntfy_reminders_enabled:
await _dispatch_reminders(db, settings, now, sent_keys)
await db.commit()
if settings.ntfy_events_enabled:
await _dispatch_events(db, settings, now, sent_keys)
await db.commit()
if settings.ntfy_todos_enabled:
await _dispatch_todos(db, settings, now.date(), sent_keys)
await db.commit()
if settings.ntfy_projects_enabled:
await _dispatch_projects(db, settings, now.date(), sent_keys)
await db.commit()
async def _purge_old_sent_records(db: AsyncSession) -> None:
@ -300,6 +307,18 @@ async def _purge_resolved_requests(db: AsyncSession) -> None:
await db.commit()
async def _purge_expired_locks(db: AsyncSession) -> None:
"""Remove non-permanent event locks that have expired."""
await db.execute(
delete(EventLock).where(
EventLock.is_permanent == False, # noqa: E712
EventLock.expires_at < datetime.now(),
)
)
await db.commit()
# ── Entry point ───────────────────────────────────────────────────────────────
async def run_notification_dispatch() -> None:
@ -343,6 +362,7 @@ async def run_notification_dispatch() -> None:
await _purge_expired_sessions(db)
await _purge_old_notifications(db)
await _purge_resolved_requests(db)
await _purge_expired_locks(db)
except Exception:
# Broad catch: job failure must never crash the scheduler or the app

View File

@ -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
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.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them
@ -20,6 +20,10 @@ from app.models import audit_log as _audit_log_model # noqa: F401
from app.models import notification as _notification_model # noqa: F401
from app.models import connection_request as _connection_request_model # noqa: F401
from app.models import user_connection as _user_connection_model # noqa: F401
from app.models import calendar_member as _calendar_member_model # noqa: F401
from app.models import event_lock as _event_lock_model # noqa: F401
from app.models import event_invitation as _event_invitation_model # noqa: F401
from app.models import passkey_credential as _passkey_credential_model # noqa: F401
# ---------------------------------------------------------------------------
@ -46,6 +50,8 @@ 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"})
@ -131,9 +137,13 @@ app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
app.include_router(passkeys_router.router, prefix="/api/auth/passkeys", tags=["Passkeys"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
app.include_router(event_invitations_router.events_router, prefix="/api/events", tags=["Event Invitations"])
app.include_router(event_invitations_router.router, prefix="/api/event-invitations", tags=["Event Invitations"])
@app.get("/")

View File

@ -18,6 +18,12 @@ from app.models.audit_log import AuditLog
from app.models.notification import Notification
from app.models.connection_request import ConnectionRequest
from app.models.user_connection import UserConnection
from app.models.calendar_member import CalendarMember
from app.models.event_lock import EventLock
from app.models.event_invitation import EventInvitation, EventInvitationOverride
from app.models.project_member import ProjectMember
from app.models.project_task_assignment import ProjectTaskAssignment
from app.models.passkey_credential import PasskeyCredential
__all__ = [
"Settings",
@ -40,4 +46,11 @@ __all__ = [
"Notification",
"ConnectionRequest",
"UserConnection",
"CalendarMember",
"EventLock",
"EventInvitation",
"EventInvitationOverride",
"ProjectMember",
"ProjectTaskAssignment",
"PasskeyCredential",
]

View File

@ -1,7 +1,10 @@
from sqlalchemy import String, Boolean, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import List
from typing import List, TYPE_CHECKING
if TYPE_CHECKING:
from app.models.calendar_member import CalendarMember
from app.database import Base
@ -17,7 +20,9 @@ class Calendar(Base):
is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
is_visible: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
is_shared: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
events: Mapped[List["CalendarEvent"]] = relationship(back_populates="calendar")
members: Mapped[List["CalendarMember"]] = relationship(back_populates="calendar", cascade="all, delete-orphan")

View File

@ -32,6 +32,11 @@ class CalendarEvent(Base):
# original_start: the originally computed occurrence datetime (children only)
original_start: Mapped[Optional[datetime]] = mapped_column(nullable=True)
updated_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -0,0 +1,53 @@
from sqlalchemy import (
Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index,
String, UniqueConstraint, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional
from app.database import Base
class CalendarMember(Base):
__tablename__ = "calendar_members"
__table_args__ = (
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_members_cal_user"),
CheckConstraint(
"permission IN ('read_only', 'create_modify', 'full_access')",
name="ck_calendar_members_permission",
),
CheckConstraint(
"status IN ('pending', 'accepted', 'rejected')",
name="ck_calendar_members_status",
),
Index("ix_calendar_members_user_id", "user_id"),
Index("ix_calendar_members_calendar_id", "calendar_id"),
Index("ix_calendar_members_status", "status"),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
calendar_id: Mapped[int] = mapped_column(
Integer, ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
invited_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
permission: Mapped[str] = mapped_column(String(20), nullable=False)
can_add_others: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
local_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
invited_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now()
)
accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
calendar: Mapped["Calendar"] = relationship(back_populates="members", lazy="raise")
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
inviter: Mapped[Optional["User"]] = relationship(
foreign_keys=[invited_by], lazy="raise"
)

View File

@ -0,0 +1,77 @@
from sqlalchemy import (
Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index,
String, UniqueConstraint, false as sa_false, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional
from app.database import Base
class EventInvitation(Base):
__tablename__ = "event_invitations"
__table_args__ = (
UniqueConstraint("event_id", "user_id", name="uq_event_invitations_event_user"),
CheckConstraint(
"status IN ('pending', 'accepted', 'tentative', 'declined')",
name="ck_event_invitations_status",
),
Index("ix_event_invitations_user_status", "user_id", "status"),
Index("ix_event_invitations_event_id", "event_id"),
)
id: Mapped[int] = mapped_column(primary_key=True)
event_id: Mapped[int] = mapped_column(
Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
invited_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
status: Mapped[str] = mapped_column(String(20), default="pending")
invited_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now()
)
responded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
display_calendar_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True
)
can_modify: Mapped[bool] = mapped_column(
Boolean, default=False, server_default=sa_false()
)
event: Mapped["CalendarEvent"] = relationship(lazy="raise")
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
inviter: Mapped[Optional["User"]] = relationship(
foreign_keys=[invited_by], lazy="raise"
)
display_calendar: Mapped[Optional["Calendar"]] = relationship(lazy="raise")
overrides: Mapped[list["EventInvitationOverride"]] = relationship(
lazy="raise", cascade="all, delete-orphan"
)
class EventInvitationOverride(Base):
__tablename__ = "event_invitation_overrides"
__table_args__ = (
UniqueConstraint("invitation_id", "occurrence_id", name="uq_invitation_override"),
CheckConstraint(
"status IN ('accepted', 'tentative', 'declined')",
name="ck_invitation_override_status",
),
Index("ix_invitation_overrides_lookup", "invitation_id", "occurrence_id"),
)
id: Mapped[int] = mapped_column(primary_key=True)
invitation_id: Mapped[int] = mapped_column(
Integer, ForeignKey("event_invitations.id", ondelete="CASCADE"), nullable=False
)
occurrence_id: Mapped[int] = mapped_column(
Integer, ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False
)
status: Mapped[str] = mapped_column(String(20), nullable=False)
responded_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now()
)

View File

@ -0,0 +1,31 @@
from sqlalchemy import Boolean, DateTime, Integer, ForeignKey, Index, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional
from app.database import Base
class EventLock(Base):
__tablename__ = "event_locks"
__table_args__ = (Index("ix_event_locks_expires_at", "expires_at"),)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
event_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("calendar_events.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
locked_by: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
locked_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now()
)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
is_permanent: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
event: Mapped["CalendarEvent"] = relationship(lazy="selectin")
holder: Mapped["User"] = relationship(foreign_keys=[locked_by], lazy="selectin")

View File

@ -5,10 +5,12 @@ from datetime import datetime
from typing import Optional
from app.database import Base
# Active: connection_request, connection_accepted
# Reserved: connection_rejected, info, warning, reminder, system
_NOTIFICATION_TYPES = (
"connection_request", "connection_accepted", "connection_rejected",
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
"event_invite", "event_invite_response",
"project_invite", "project_invite_accepted", "project_invite_rejected",
"task_assigned",
"info", "warning", "reminder", "system",
)

View File

@ -0,0 +1,30 @@
from datetime import datetime
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class PasskeyCredential(Base):
__tablename__ = "passkey_credentials"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# base64url-encoded credential ID (spec allows up to 1023 bytes → ~1363 chars)
credential_id: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
# base64url-encoded COSE public key
public_key: Mapped[str] = mapped_column(Text, nullable=False)
# Authenticator sign count for clone detection
sign_count: Mapped[int] = mapped_column(Integer, default=0)
# User-assigned label (e.g. "MacBook Pro — Chrome")
name: Mapped[str] = mapped_column(String(100), nullable=False)
# JSON array of transport hints (e.g. '["usb","hybrid"]')
transports: Mapped[str | None] = mapped_column(Text, nullable=True)
# Whether the credential is backed up / synced across devices
backed_up: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(default=func.now())
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)

View File

@ -22,6 +22,7 @@ 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
tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan")
todos: Mapped[List["Todo"]] = relationship(back_populates="project")
# 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")

View File

@ -0,0 +1,58 @@
from sqlalchemy import (
CheckConstraint, DateTime, Integer, ForeignKey, Index,
String, UniqueConstraint, func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional
from app.database import Base
class ProjectMember(Base):
__tablename__ = "project_members"
__table_args__ = (
UniqueConstraint("project_id", "user_id", name="uq_project_members_proj_user"),
CheckConstraint(
"permission IN ('read_only', 'create_modify')",
name="ck_project_members_permission",
),
CheckConstraint(
"status IN ('pending', 'accepted', 'rejected')",
name="ck_project_members_status",
),
CheckConstraint(
"source IN ('invited', 'auto_assigned')",
name="ck_project_members_source",
),
Index("ix_project_members_user_id", "user_id"),
Index("ix_project_members_project_id", "project_id"),
Index("ix_project_members_status", "status"),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
invited_by: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
permission: Mapped[str] = mapped_column(String(20), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
source: Mapped[str] = mapped_column(String(20), nullable=False, default="invited")
created_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now(), onupdate=func.now()
)
accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# Relationships — lazy="raise" to prevent N+1 (mirrors CalendarMember)
project: Mapped["Project"] = relationship(back_populates="members", lazy="raise")
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
inviter: Mapped[Optional["User"]] = relationship(
foreign_keys=[invited_by], lazy="raise"
)

View File

@ -1,3 +1,4 @@
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
@ -20,21 +21,33 @@ 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
project: Mapped["Project"] = sa_relationship(back_populates="tasks")
person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks")
# 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")
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",
)

View File

@ -0,0 +1,30 @@
from sqlalchemy import DateTime, Integer, ForeignKey, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from app.database import Base
class ProjectTaskAssignment(Base):
__tablename__ = "project_task_assignments"
__table_args__ = (
UniqueConstraint("task_id", "user_id", name="uq_task_assignments_task_user"),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
task_id: Mapped[int] = mapped_column(
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
assigned_by: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), server_default=func.now()
)
# Relationships — lazy="raise" to prevent N+1
task: Mapped["ProjectTask"] = relationship(back_populates="assignments", lazy="raise")
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
assigner: Mapped["User"] = relationship(foreign_keys=[assigned_by], lazy="raise")

View File

@ -18,6 +18,10 @@ class UserSession(Base):
expires_at: Mapped[datetime] = mapped_column(nullable=False)
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
# Session lock — persists across page refresh
is_locked: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
locked_at: Mapped[datetime | None] = mapped_column(nullable=True)
# Audit fields for security logging
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)

View File

@ -21,6 +21,9 @@ 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()

View File

@ -1,6 +1,7 @@
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
@ -11,8 +12,12 @@ 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
task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments")
# 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")

View File

@ -43,6 +43,11 @@ 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())

View File

@ -22,6 +22,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.audit_log import AuditLog
from app.models.calendar import Calendar
from app.models.calendar_member import CalendarMember
from app.models.backup_code import BackupCode
from app.models.session import UserSession
from app.models.settings import Settings
@ -43,13 +45,14 @@ from app.schemas.admin import (
SystemConfigUpdate,
ToggleActiveRequest,
ToggleMfaEnforceRequest,
TogglePasswordlessRequest,
UpdateUserRoleRequest,
UserDetailResponse,
UserListItem,
UserListResponse,
)
from app.services.audit import get_client_ip, log_audit_event
from app.services.auth import hash_password
from app.services.auth import ahash_password
# ---------------------------------------------------------------------------
# Router — all endpoints inherit require_admin
@ -223,7 +226,7 @@ async def create_user(
new_user = User(
username=data.username,
umbral_name=data.username,
password_hash=hash_password(data.password),
password_hash=await ahash_password(data.password),
role=data.role,
email=email,
first_name=data.first_name,
@ -339,7 +342,7 @@ async def reset_user_password(
raise HTTPException(status_code=404, detail="User not found")
temp_password = secrets.token_urlsafe(16)
user.password_hash = hash_password(temp_password)
user.password_hash = await ahash_password(temp_password)
user.must_change_password = True
user.last_password_change_at = datetime.now()
@ -618,6 +621,106 @@ async def list_user_sessions(
}
# ---------------------------------------------------------------------------
# GET /users/{user_id}/sharing-stats
# ---------------------------------------------------------------------------
@router.get("/users/{user_id}/sharing-stats")
async def get_user_sharing_stats(
user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
):
"""Return sharing statistics for a user."""
result = await db.execute(sa.select(User).where(User.id == user_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="User not found")
# Calendars owned that are shared
shared_owned = await db.scalar(
sa.select(sa.func.count())
.select_from(Calendar)
.where(Calendar.user_id == user_id, Calendar.is_shared == True)
) or 0
# Calendars the user is a member of (accepted)
member_of = await db.scalar(
sa.select(sa.func.count())
.select_from(CalendarMember)
.where(CalendarMember.user_id == user_id, CalendarMember.status == "accepted")
) or 0
# Pending invites sent by this user
pending_sent = await db.scalar(
sa.select(sa.func.count())
.select_from(CalendarMember)
.where(CalendarMember.invited_by == user_id, CalendarMember.status == "pending")
) or 0
# Pending invites received by this user
pending_received = await db.scalar(
sa.select(sa.func.count())
.select_from(CalendarMember)
.where(CalendarMember.user_id == user_id, CalendarMember.status == "pending")
) or 0
return {
"shared_calendars_owned": shared_owned,
"calendars_member_of": member_of,
"pending_invites_sent": pending_sent,
"pending_invites_received": pending_received,
}
# ---------------------------------------------------------------------------
# PUT /users/{user_id}/passwordless
# ---------------------------------------------------------------------------
@router.put("/users/{user_id}/passwordless")
async def admin_toggle_passwordless(
request: Request,
data: TogglePasswordlessRequest,
user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Admin-only: disable passwordless login for a user.
Only enabled=False is allowed admin cannot remotely enable passwordless.
Revokes all sessions so the user must re-authenticate.
"""
if data.enabled:
raise HTTPException(
status_code=400,
detail="Admin can only disable passwordless login, not enable it",
)
_guard_self_action(actor, user_id, "toggle passwordless for")
result = await db.execute(sa.select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.passwordless_enabled:
raise HTTPException(status_code=409, detail="Passwordless login is not enabled for this user")
user.passwordless_enabled = False
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.passwordless_disabled",
actor_id=actor.id,
target_id=user_id,
detail={"sessions_revoked": revoked, "username": user.username},
ip=get_client_ip(request),
)
await db.commit()
return {"passwordless_enabled": False, "sessions_revoked": revoked}
# ---------------------------------------------------------------------------
# GET /config
# ---------------------------------------------------------------------------
@ -664,6 +767,9 @@ 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(
@ -688,18 +794,18 @@ async def admin_dashboard(
_actor: User = Depends(get_current_user),
):
"""Aggregate stats for the admin portal dashboard."""
total_users = await db.scalar(
sa.select(sa.func.count()).select_from(User)
)
active_users = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.is_active == True)
)
admin_count = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.role == "admin")
)
totp_count = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.totp_enabled == True)
# AW-6: Single conditional aggregation instead of 5 separate COUNT queries
user_stats = await db.execute(
sa.select(
sa.func.count().label("total"),
sa.func.count().filter(User.is_active == True).label("active"),
sa.func.count().filter(User.role == "admin").label("admins"),
sa.func.count().filter(User.totp_enabled == True).label("totp"),
).select_from(User)
)
row = user_stats.one()
total_users, active_users, admin_count, totp_count = row.tuple()
active_sessions = await db.scalar(
sa.select(sa.func.count()).select_from(UserSession).where(
UserSession.revoked == False,

View File

@ -16,7 +16,6 @@ Security layers:
4. bcryptArgon2id 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
@ -25,10 +24,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.services.connection import sync_birthday_to_contacts
from app.models.user import User
from app.models.session import UserSession
from app.models.settings import Settings
from app.models.system_config import SystemConfig
from app.models.passkey_credential import PasskeyCredential
from app.models.calendar import Calendar
from app.schemas.auth import (
SetupRequest, LoginRequest, RegisterRequest,
@ -36,6 +37,9 @@ from app.schemas.auth import (
ProfileUpdate, ProfileResponse,
)
from app.services.auth import (
ahash_password,
averify_password,
averify_password_with_upgrade,
hash_password,
verify_password,
verify_password_with_upgrade,
@ -45,6 +49,13 @@ 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()
@ -55,22 +66,6 @@ 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
# ---------------------------------------------------------------------------
@ -100,25 +95,22 @@ async def get_current_user(
if user_id is None or session_id is None:
raise HTTPException(status_code=401, detail="Malformed session token")
# Verify session is active in DB (covers revocation + expiry)
session_result = await db.execute(
select(UserSession).where(
# AC-1: Single JOIN query for session + user (was 2 sequential queries)
result = await db.execute(
select(UserSession, User)
.join(User, UserSession.user_id == User.id)
.where(
UserSession.id == session_id,
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
User.is_active == True,
)
)
db_session = session_result.scalar_one_or_none()
if not db_session:
raise HTTPException(status_code=401, detail="Session has been revoked or expired")
user_result = await db.execute(
select(User).where(User.id == user_id, User.is_active == True)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found or inactive")
row = result.one_or_none()
if not row:
raise HTTPException(status_code=401, detail="Session expired or user inactive")
db_session, user = row.tuple()
# L-03: Sliding window renewal — extend session if >1 day has elapsed since
# last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day).
@ -129,25 +121,46 @@ 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
# Defense-in-depth: block API access while session is locked.
# Exempt endpoints needed for unlocking, locking, checking status, and logout.
if db_session.is_locked:
lock_exempt = {
"/api/auth/lock", "/api/auth/verify-password",
"/api/auth/status", "/api/auth/logout",
"/api/auth/passkeys/login/begin", "/api/auth/passkeys/login/complete",
}
if request.url.path not in lock_exempt:
raise HTTPException(status_code=423, detail="Session is locked")
return user
async def get_current_settings(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Settings:
"""
Convenience dependency for routers that need Settings access.
Always chain after get_current_user never use standalone.
AC-3: Cache in request.state so multiple dependencies don't re-query.
"""
cached = getattr(request.state, "settings", None)
if cached is not None:
return cached
result = await db.execute(
select(Settings).where(Settings.user_id == current_user.id)
)
settings_obj = result.scalar_one_or_none()
if not settings_obj:
raise HTTPException(status_code=500, detail="Settings not found for user")
request.state.settings = settings_obj
return settings_obj
@ -169,82 +182,6 @@ 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)
# ---------------------------------------------------------------------------
@ -285,7 +222,7 @@ async def setup(
if user_count.scalar_one() > 0:
raise HTTPException(status_code=400, detail="Setup already completed")
password_hash = hash_password(data.password)
password_hash = await ahash_password(data.password)
new_user = User(
username=data.username,
umbral_name=data.username,
@ -300,8 +237,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,
@ -338,26 +275,45 @@ async def login(
if not user:
# M-02: Run Argon2id against a dummy hash so the response time is
# indistinguishable from a wrong-password attempt (prevents username enumeration).
verify_password("x", _DUMMY_HASH)
await averify_password("x", _DUMMY_HASH)
raise HTTPException(status_code=401, detail="Invalid username or password")
# M-02: Run password verification BEFORE lockout check so Argon2id always
# executes — prevents distinguishing "locked" from "wrong password" via timing.
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
await _check_account_lockout(user)
await check_account_lockout(user)
if not valid:
await _record_failed_login(db, user)
remaining = await record_failed_login(db, user)
await log_audit_event(
db, action="auth.login_failed", actor_id=user.id,
detail={"reason": "invalid_password"}, ip=client_ip,
detail={"reason": "invalid_password", "attempts_remaining": remaining}, ip=client_ip,
)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password")
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.",
)
# 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(
@ -370,7 +326,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:
@ -388,6 +344,7 @@ 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,
@ -398,8 +355,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,
@ -407,8 +364,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,
@ -451,7 +408,7 @@ async def register(
if existing_email.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
password_hash = hash_password(data.password)
password_hash = await ahash_password(data.password)
# SEC-01: Explicit field assignment — never **data.model_dump()
new_user = User(
username=data.username,
@ -490,8 +447,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}
@ -541,32 +498,36 @@ async def auth_status(
authenticated = False
role = None
is_locked = False
u = None
if not setup_required and session_cookie:
payload = verify_session_token(session_cookie)
if payload:
user_id = payload.get("uid")
session_id = payload.get("sid")
if user_id and session_id:
session_result = await db.execute(
select(UserSession).where(
# 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(
UserSession.id == session_id,
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
User.is_active == True,
)
)
if session_result.scalar_one_or_none() is not None:
row = result.one_or_none()
if row is not None:
db_sess, u = row.tuple()
authenticated = True
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:
is_locked = db_sess.is_locked
role = u.role
else:
authenticated = False
# Check registration availability
config = None
registration_open = False
if not setup_required:
config_result = await db.execute(
@ -575,18 +536,50 @@ async def auth_status(
config = config_result.scalar_one_or_none()
registration_open = config.allow_registration if config else False
# Perf-3: Check passkey existence with EXISTS (not COUNT) — this endpoint
# is polled every 15s. Count is derived from GET /auth/passkeys list instead.
has_passkeys = False
passwordless_enabled = False
if authenticated and u:
pk_result = await db.execute(
select(PasskeyCredential.id).where(
PasskeyCredential.user_id == u.id
).limit(1)
)
has_passkeys = pk_result.scalar_one_or_none() is not None
passwordless_enabled = u.passwordless_enabled
return {
"authenticated": authenticated,
"setup_required": setup_required,
"role": role,
"username": u.username if authenticated and u else None,
"registration_open": registration_open,
"is_locked": is_locked,
"has_passkeys": has_passkeys,
"passwordless_enabled": passwordless_enabled,
"allow_passwordless": config.allow_passwordless if config else False,
}
@router.post("/lock")
async def lock_session(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Mark the current session as locked. Frontend must verify password to unlock."""
db_session: UserSession = request.state.db_session
db_session.is_locked = True
db_session.locked_at = datetime.now()
await db.commit()
return {"locked": True}
@router.post("/verify-password")
async def verify_password(
async def verify_password_endpoint(
data: VerifyPasswordRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@ -594,15 +587,21 @@ async def verify_password(
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 = verify_password_with_upgrade(data.password, current_user.password_hash)
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
if not valid:
await _record_failed_login(db, current_user)
await record_failed_login(db, current_user)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid password")
if new_hash:
current_user.password_hash = new_hash
# Clear session lock on successful password verification
db_session: UserSession = request.state.db_session
db_session.is_locked = False
db_session.locked_at = None
await db.commit()
return {"verified": True}
@ -615,17 +614,18 @@ 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, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
if not valid:
await _record_failed_login(db, current_user)
await record_failed_login(db, current_user)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid current password")
if data.new_password == data.old_password:
raise HTTPException(status_code=400, detail="New password must be different from your current password")
current_user.password_hash = hash_password(data.new_password)
current_user.password_hash = await ahash_password(data.new_password)
current_user.last_password_change_at = datetime.now()
# Clear forced password change flag if set (SEC-12)
@ -686,6 +686,12 @@ async def update_profile(
current_user.email = update_data["email"]
if "date_of_birth" in update_data:
current_user.date_of_birth = update_data["date_of_birth"]
settings_result = await db.execute(
select(Settings).where(Settings.user_id == current_user.id)
)
user_settings = settings_result.scalar_one_or_none()
share = user_settings.share_birthday if user_settings else False
await sync_birthday_to_contacts(db, current_user.id, share_birthday=share, date_of_birth=update_data["date_of_birth"])
if "umbral_name" in update_data:
current_user.umbral_name = update_data["umbral_name"]

View File

@ -1,12 +1,17 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from sqlalchemy import func, select, update
from typing import List
from app.database import get_db
from app.models.calendar import Calendar
from app.models.calendar_event import CalendarEvent
from app.models.calendar_member import CalendarMember
from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse
from app.services.calendar_sharing import require_permission
from app.routers.auth import get_current_user
from app.models.user import User
@ -23,7 +28,28 @@ async def get_calendars(
.where(Calendar.user_id == current_user.id)
.order_by(Calendar.is_default.desc(), Calendar.name.asc())
)
return result.scalars().all()
calendars = result.scalars().all()
# Populate member_count for shared calendars
cal_ids = [c.id for c in calendars if c.is_shared]
count_map: dict[int, int] = {}
if cal_ids:
counts = await db.execute(
select(CalendarMember.calendar_id, func.count())
.where(
CalendarMember.calendar_id.in_(cal_ids),
CalendarMember.status == "accepted",
)
.group_by(CalendarMember.calendar_id)
)
count_map = dict(counts.all())
return [
CalendarResponse.model_validate(c, from_attributes=True).model_copy(
update={"member_count": count_map.get(c.id, 0)}
)
for c in calendars
]
@router.post("/", response_model=CalendarResponse, status_code=201)
@ -114,3 +140,62 @@ 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,
)

View File

@ -49,7 +49,9 @@ from app.services.connection import (
resolve_shared_profile,
send_connection_ntfy,
)
from app.services.calendar_sharing import cascade_on_disconnect
from app.services.notification import create_notification
from app.services.project_sharing import cascade_projects_on_disconnect
router = APIRouter()
logger = logging.getLogger(__name__)
@ -823,6 +825,12 @@ async def remove_connection(
if reverse_conn:
await db.delete(reverse_conn)
# Cascade: remove calendar memberships and event locks between these users
await cascade_on_disconnect(db, current_user.id, counterpart_id)
# Cascade: remove project memberships and task assignments between these users
await cascade_projects_on_disconnect(db, current_user.id, counterpart_id)
await log_audit_event(
db,
action="connection.removed",

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from sqlalchemy import false as sa_false, select, func, or_, case
from datetime import datetime, date, timedelta
from typing import Optional, List, Dict, Any
@ -8,11 +8,12 @@ from app.database import get_db
from app.models.settings import Settings
from app.models.todo import Todo
from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
from app.models.reminder import Reminder
from app.models.project import Project
from app.models.user import User
from app.routers.auth import get_current_user, get_current_settings
from app.models.event_invitation import EventInvitation
from app.services.calendar_sharing import get_accessible_event_scope
router = APIRouter()
@ -35,14 +36,18 @@ async def get_dashboard(
today = client_date or date.today()
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
# Subquery: calendar IDs belonging to this user (for event scoping)
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
# Fetch all accessible calendar IDs + invited event IDs
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
# Today's events (exclude parent templates — they are hidden, children are shown)
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
events_query = select(CalendarEvent).where(
or_(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
),
CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= today_end,
_not_parent_template,
@ -50,6 +55,22 @@ async def get_dashboard(
events_result = await db.execute(events_query)
todays_events = events_result.scalars().all()
# Build invitation lookup for today's events
invited_event_id_set = set(invited_event_ids)
today_inv_map: dict[int, tuple[str, int | None]] = {}
today_event_ids = [e.id for e in todays_events]
parent_ids_in_today = [e.parent_event_id for e in todays_events if e.parent_event_id and e.parent_event_id in invited_event_id_set]
inv_lookup_ids = list(set(today_event_ids + parent_ids_in_today) & invited_event_id_set)
if inv_lookup_ids:
inv_result = await db.execute(
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where(
EventInvitation.user_id == current_user.id,
EventInvitation.event_id.in_(inv_lookup_ids),
)
)
for eid, status, disp_cal_id in inv_result.all():
today_inv_map[eid] = (status, disp_cal_id)
# Upcoming todos (not completed, with due date from today through upcoming_days)
todos_query = select(Todo).where(
Todo.user_id == current_user.id,
@ -71,31 +92,35 @@ async def get_dashboard(
reminders_result = await db.execute(reminders_query)
active_reminders = reminders_result.scalars().all()
# Project stats (scoped to user)
total_projects_result = await db.execute(
select(func.count(Project.id)).where(Project.user_id == current_user.id)
)
total_projects = total_projects_result.scalar()
projects_by_status_query = select(
# Project stats — single GROUP BY query, derive total in Python
projects_by_status_result = await db.execute(
select(
Project.status,
func.count(Project.id).label("count")
func.count(Project.id).label("count"),
).where(Project.user_id == current_user.id).group_by(Project.status)
projects_by_status_result = await db.execute(projects_by_status_query)
)
projects_by_status = {row[0]: row[1] for row in projects_by_status_result}
total_projects = sum(projects_by_status.values())
# Total incomplete todos count (scoped to user)
total_incomplete_result = await db.execute(
select(func.count(Todo.id)).where(
Todo.user_id == current_user.id,
Todo.completed == False,
# Todo counts: total and incomplete in a single query
todo_counts_result = await db.execute(
select(
func.count(Todo.id).label("total"),
func.count(case((Todo.completed == False, Todo.id))).label("incomplete"),
).where(Todo.user_id == current_user.id)
)
)
total_incomplete_todos = total_incomplete_result.scalar()
todo_row = todo_counts_result.one()
total_todos = todo_row.total
total_incomplete_todos = todo_row.incomplete
# Starred events (upcoming, ordered by date, scoped to user's calendars)
# Starred events — no upper date bound so future events always appear in countdown.
# _not_parent_template excludes recurring parent templates (children still show).
starred_query = select(CalendarEvent).where(
or_(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
),
CalendarEvent.is_starred == True,
CalendarEvent.start_datetime > today_start,
_not_parent_template,
@ -121,7 +146,10 @@ async def get_dashboard(
"end_datetime": event.end_datetime,
"all_day": event.all_day,
"color": event.color,
"is_starred": event.is_starred
"is_starred": event.is_starred,
"is_invited": (event.parent_event_id or event.id) in invited_event_id_set,
"invitation_status": today_inv_map.get(event.parent_event_id or event.id, (None,))[0],
"display_calendar_id": today_inv_map.get(event.parent_event_id or event.id, (None, None))[1],
}
for event in todays_events
],
@ -148,6 +176,7 @@ async def get_dashboard(
"by_status": projects_by_status
},
"total_incomplete_todos": total_incomplete_todos,
"total_todos": total_todos,
"starred_events": starred_events_data
}
@ -165,42 +194,64 @@ async def get_upcoming(
cutoff_date = today + timedelta(days=days)
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
today_start = datetime.combine(today, datetime.min.time())
overdue_floor = today - timedelta(days=30)
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
# Subquery: calendar IDs belonging to this user
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
# Fetch all accessible calendar IDs + invited event IDs
user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
# Get upcoming todos with due dates (today onward only, scoped to user)
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
todos_query = select(Todo).where(
Todo.user_id == current_user.id,
Todo.completed == False,
Todo.due_date.isnot(None),
Todo.due_date >= today,
Todo.due_date >= overdue_floor,
Todo.due_date <= cutoff_date
)
todos_result = await db.execute(todos_query)
todos = todos_result.scalars().all()
# Get upcoming events (from today onward, exclude parent templates, scoped to user's calendars)
events_query = select(CalendarEvent).where(
or_(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
),
CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= cutoff_datetime,
_not_parent_template,
)
events_result = await db.execute(events_query)
events = events_result.scalars().all()
# Get upcoming reminders (today onward only, scoped to user)
reminders_query = select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.is_active == True,
Reminder.is_dismissed == False,
Reminder.remind_at >= today_start,
Reminder.remind_at >= overdue_floor_dt,
Reminder.remind_at <= cutoff_datetime
)
# Execute queries sequentially (single session cannot run concurrent queries)
todos_result = await db.execute(todos_query)
todos = todos_result.scalars().all()
events_result = await db.execute(events_query)
events = events_result.scalars().all()
reminders_result = await db.execute(reminders_query)
reminders = reminders_result.scalars().all()
# Build invitation lookup for upcoming events
invited_event_id_set_up = set(invited_event_ids)
upcoming_inv_map: dict[int, tuple[str, int | None]] = {}
up_parent_ids = list({e.parent_event_id or e.id for e in events} & invited_event_id_set_up)
if up_parent_ids:
up_inv_result = await db.execute(
select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where(
EventInvitation.user_id == current_user.id,
EventInvitation.event_id.in_(up_parent_ids),
)
)
for eid, status, disp_cal_id in up_inv_result.all():
upcoming_inv_map[eid] = (status, disp_cal_id)
# Combine into unified list
upcoming_items: List[Dict[str, Any]] = []
@ -212,28 +263,39 @@ async def get_upcoming(
"date": todo.due_date.isoformat() if todo.due_date else None,
"datetime": None,
"priority": todo.priority,
"category": todo.category
"category": todo.category,
"is_overdue": todo.due_date < today if todo.due_date else False,
})
for event in events:
end_dt = event.end_datetime
parent_id = event.parent_event_id or event.id
is_inv = parent_id in invited_event_id_set_up
upcoming_items.append({
"type": "event",
"id": event.id,
"title": event.title,
"date": event.start_datetime.date().isoformat(),
"datetime": event.start_datetime.isoformat(),
"end_datetime": end_dt.isoformat() if end_dt else None,
"all_day": event.all_day,
"color": event.color,
"is_starred": event.is_starred
"is_starred": event.is_starred,
"is_invited": is_inv,
"invitation_status": upcoming_inv_map.get(parent_id, (None,))[0] if is_inv else None,
"display_calendar_id": upcoming_inv_map.get(parent_id, (None, None))[1] if is_inv else None,
})
for reminder in reminders:
remind_at_date = reminder.remind_at.date() if reminder.remind_at else None
upcoming_items.append({
"type": "reminder",
"id": reminder.id,
"title": reminder.title,
"date": reminder.remind_at.date().isoformat(),
"datetime": reminder.remind_at.isoformat()
"date": remind_at_date.isoformat() if remind_at_date else None,
"datetime": reminder.remind_at.isoformat() if reminder.remind_at else None,
"snoozed_until": reminder.snoozed_until.isoformat() if reminder.snoozed_until else None,
"is_overdue": remind_at_date < today if remind_at_date else False,
})
# Sort by date/datetime

View File

@ -0,0 +1,307 @@
"""
Event invitation endpoints invite users to events, respond, override per-occurrence, leave.
Two routers:
- events_router: mounted at /api/events for POST/GET /{event_id}/invitations
- router: mounted at /api/event-invitations for respond/override/delete/pending
"""
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.calendar_event import CalendarEvent
from app.models.event_invitation import EventInvitation
from app.models.user import User
from app.routers.auth import get_current_user
from sqlalchemy.orm import selectinload
from app.schemas.event_invitation import (
EventInvitationCreate,
EventInvitationRespond,
EventInvitationOverrideCreate,
UpdateCanModify,
UpdateDisplayCalendar,
)
from app.services.calendar_sharing import get_accessible_calendar_ids, get_user_permission
from app.services.event_invitation import (
send_event_invitations,
respond_to_invitation,
override_occurrence_status,
dismiss_invitation,
dismiss_invitation_by_owner,
get_event_invitations,
get_pending_invitations,
)
# Mounted at /api/events — event-scoped invitation endpoints
events_router = APIRouter()
# Mounted at /api/event-invitations — invitation-scoped endpoints
router = APIRouter()
async def _get_event_with_access_check(
db: AsyncSession, event_id: int, user_id: int
) -> CalendarEvent:
"""Fetch event and verify the user has access (owner, shared member, or invitee)."""
result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Check calendar access
perm = await get_user_permission(db, event.calendar_id, user_id)
if perm is not None:
return event
# Check if invitee (also check parent for recurring children)
event_ids_to_check = [event_id]
if event.parent_event_id:
event_ids_to_check.append(event.parent_event_id)
inv_result = await db.execute(
select(EventInvitation.id).where(
EventInvitation.event_id.in_(event_ids_to_check),
EventInvitation.user_id == user_id,
)
)
if inv_result.first() is not None:
return event
raise HTTPException(status_code=404, detail="Event not found")
# ── Event-scoped endpoints (mounted at /api/events) ──
@events_router.post("/{event_id}/invitations", status_code=201)
async def invite_to_event(
body: EventInvitationCreate,
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Invite connected users to an event. Requires event ownership or create_modify+ permission."""
result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Permission check: owner or create_modify+
perm = await get_user_permission(db, event.calendar_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Event not found")
if perm not in ("owner", "create_modify", "full_access"):
raise HTTPException(status_code=403, detail="Insufficient permission")
# For recurring child events, invite to the parent (series)
target_event_id = event.parent_event_id if event.parent_event_id else event_id
invitations = await send_event_invitations(
db=db,
event_id=target_event_id,
user_ids=body.user_ids,
invited_by=current_user.id,
)
await db.commit()
return {"invited": len(invitations), "event_id": target_event_id}
@events_router.get("/{event_id}/invitations")
async def list_event_invitations(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all invitees and their statuses for an event."""
event = await _get_event_with_access_check(db, event_id, current_user.id)
# For recurring children, also fetch parent's invitations
target_id = event.parent_event_id if event.parent_event_id else event_id
invitations = await get_event_invitations(db, target_id)
return invitations
# ── Invitation-scoped endpoints (mounted at /api/event-invitations) ──
@router.get("/pending")
async def my_pending_invitations(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all pending event invitations for the current user."""
return await get_pending_invitations(db, current_user.id)
@router.put("/{invitation_id}/respond")
async def respond_invitation(
body: EventInvitationRespond,
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accept, tentative, or decline an event invitation."""
invitation = await respond_to_invitation(
db=db,
invitation_id=invitation_id,
user_id=current_user.id,
status=body.status,
)
# Build response before commit (ORM objects expire after commit)
response_data = {
"id": invitation.id,
"event_id": invitation.event_id,
"status": invitation.status,
"responded_at": invitation.responded_at,
}
await db.commit()
return response_data
@router.put("/{invitation_id}/respond/{occurrence_id}")
async def override_occurrence(
body: EventInvitationOverrideCreate,
invitation_id: int = Path(ge=1, le=2147483647),
occurrence_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Override invitation status for a specific occurrence of a recurring event."""
override = await override_occurrence_status(
db=db,
invitation_id=invitation_id,
occurrence_id=occurrence_id,
user_id=current_user.id,
status=body.status,
)
response_data = {
"invitation_id": override.invitation_id,
"occurrence_id": override.occurrence_id,
"status": override.status,
}
await db.commit()
return response_data
@router.put("/{invitation_id}/display-calendar")
async def update_display_calendar(
body: UpdateDisplayCalendar,
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Change the display calendar for an accepted/tentative invitation."""
inv_result = await db.execute(
select(EventInvitation).where(
EventInvitation.id == invitation_id,
EventInvitation.user_id == current_user.id,
)
)
invitation = inv_result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
if invitation.status not in ("accepted", "tentative"):
raise HTTPException(status_code=400, detail="Can only set display calendar for accepted or tentative invitations")
# Verify calendar is accessible to this user
accessible_ids = await get_accessible_calendar_ids(current_user.id, db)
if body.calendar_id not in accessible_ids:
raise HTTPException(status_code=404, detail="Calendar not found")
invitation.display_calendar_id = body.calendar_id
# Extract response before commit (ORM expiry rule)
response_data = {
"id": invitation.id,
"event_id": invitation.event_id,
"display_calendar_id": invitation.display_calendar_id,
}
await db.commit()
return response_data
@router.put("/{invitation_id}/can-modify")
async def update_can_modify(
body: UpdateCanModify,
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Toggle can_modify on an invitation. Owner-only."""
inv_result = await db.execute(
select(EventInvitation)
.options(selectinload(EventInvitation.event))
.where(EventInvitation.id == invitation_id)
)
invitation = inv_result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
# Only the calendar owner can toggle can_modify (W-03)
perm = await get_user_permission(db, invitation.event.calendar_id, current_user.id)
if perm != "owner":
raise HTTPException(status_code=403, detail="Only the calendar owner can grant edit access")
invitation.can_modify = body.can_modify
response_data = {
"id": invitation.id,
"event_id": invitation.event_id,
"can_modify": invitation.can_modify,
}
await db.commit()
return response_data
@router.delete("/{invitation_id}", status_code=204)
async def leave_or_revoke_invitation(
invitation_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Leave an event (invitee) or revoke an invitation (event owner).
Invitees can only delete their own invitations.
Event owners can delete any invitation for their events.
"""
inv_result = await db.execute(
select(EventInvitation).where(EventInvitation.id == invitation_id)
)
invitation = inv_result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
if invitation.user_id == current_user.id:
# Invitee leaving
await dismiss_invitation(db, invitation_id, current_user.id)
else:
# Check if current user is the event owner
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == invitation.event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
perm = await get_user_permission(db, event.calendar_id, current_user.id)
if perm != "owner":
raise HTTPException(status_code=403, detail="Only the event owner can revoke invitations")
await dismiss_invitation_by_owner(db, invitation_id)
await db.commit()
return None

View File

@ -1,7 +1,7 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from sqlalchemy import false as sa_false, select, delete, or_
from sqlalchemy.orm import selectinload
from typing import Optional, List, Any, Literal
@ -19,13 +19,38 @@ from app.schemas.calendar_event import (
from app.routers.auth import get_current_user
from app.models.user import User
from app.services.recurrence import generate_occurrences
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, get_accessible_event_scope, require_permission
from app.services.event_invitation import get_invited_event_ids, get_invitation_overrides_for_user
from app.models.event_invitation import EventInvitation
router = APIRouter()
def _event_to_dict(event: CalendarEvent) -> dict:
def _event_to_dict(
event: CalendarEvent,
is_invited: bool = False,
invitation_status: str | None = None,
invitation_id: int | None = None,
display_calendar_id: int | None = None,
display_calendar_name: str | None = None,
display_calendar_color: str | None = None,
can_modify: bool = False,
has_active_invitees: bool = False,
) -> dict:
"""Serialize a CalendarEvent ORM object to a response dict including calendar info."""
return {
# For invited events: use display calendar if set, otherwise fallback to "Invited"/gray
if is_invited:
if display_calendar_name:
cal_name = display_calendar_name
cal_color = display_calendar_color or "#6B7280"
else:
cal_name = "Invited"
cal_color = "#6B7280"
else:
cal_name = event.calendar.name if event.calendar else ""
cal_color = event.calendar.color if event.calendar else ""
d = {
"id": event.id,
"title": event.title,
"description": event.description,
@ -37,15 +62,22 @@ def _event_to_dict(event: CalendarEvent) -> dict:
"recurrence_rule": event.recurrence_rule,
"is_starred": event.is_starred,
"calendar_id": event.calendar_id,
"calendar_name": event.calendar.name if event.calendar else "",
"calendar_color": event.calendar.color if event.calendar else "",
"calendar_name": cal_name,
"calendar_color": cal_color,
"is_virtual": False,
"parent_event_id": event.parent_event_id,
"is_recurring": event.is_recurring,
"original_start": event.original_start,
"created_at": event.created_at,
"updated_at": event.updated_at,
"is_invited": is_invited,
"invitation_status": invitation_status,
"invitation_id": invitation_id,
"display_calendar_id": display_calendar_id,
"can_modify": can_modify,
"has_active_invitees": has_active_invitees,
}
return d
def _birthday_events_for_range(
@ -142,13 +174,20 @@ async def get_events(
recurrence_rule IS NOT NULL) are excluded their materialised children
are what get displayed on the calendar.
"""
# Scope events through calendar ownership
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
# Scope events through calendar ownership + shared memberships + invitations
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
query = (
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.calendar_id.in_(user_calendar_ids))
.where(
or_(
CalendarEvent.calendar_id.in_(all_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
)
)
)
# Exclude parent template rows — they are not directly rendered
@ -165,12 +204,93 @@ async def get_events(
if end:
query = query.where(CalendarEvent.start_datetime <= end)
query = query.order_by(CalendarEvent.start_datetime.asc())
query = query.order_by(CalendarEvent.start_datetime.asc()).limit(2000)
result = await db.execute(query)
events = result.scalars().all()
response: List[dict] = [_event_to_dict(e) for e in events]
# Build invitation lookup for the current user
invited_event_id_set = set(invited_event_ids)
invitation_map: dict[int, tuple[str, int, int | None, bool]] = {} # event_id -> (status, invitation_id, display_calendar_id, can_modify)
if invited_event_ids:
inv_result = await db.execute(
select(
EventInvitation.event_id,
EventInvitation.status,
EventInvitation.id,
EventInvitation.display_calendar_id,
EventInvitation.can_modify,
).where(
EventInvitation.user_id == current_user.id,
EventInvitation.event_id.in_(invited_event_ids),
)
)
for eid, status, inv_id, disp_cal_id, cm in inv_result.all():
invitation_map[eid] = (status, inv_id, disp_cal_id, cm)
# Batch-fetch display calendars for invited events
display_cal_ids = {t[2] for t in invitation_map.values() if t[2] is not None}
display_cal_map: dict[int, dict] = {} # cal_id -> {name, color}
if display_cal_ids:
cal_result = await db.execute(
select(Calendar.id, Calendar.name, Calendar.color).where(
Calendar.id.in_(display_cal_ids),
Calendar.id.in_(all_calendar_ids),
)
)
for cal_id, cal_name, cal_color in cal_result.all():
display_cal_map[cal_id] = {"name": cal_name, "color": cal_color}
# Get per-occurrence overrides for invited events
all_event_ids = [e.id for e in events]
override_map = await get_invitation_overrides_for_user(db, current_user.id, all_event_ids)
# Batch-fetch event IDs that have accepted/tentative invitees (for owner's shared icon)
active_invitee_set: set[int] = set()
if all_event_ids:
active_inv_result = await db.execute(
select(EventInvitation.event_id).where(
EventInvitation.event_id.in_(all_event_ids),
EventInvitation.status.in_(["accepted", "tentative"]),
).distinct()
)
active_invitee_set = {r[0] for r in active_inv_result.all()}
# Also mark parent events: if a parent has active invitees, all its children should show the icon
parent_ids = {e.parent_event_id for e in events if e.parent_event_id and e.parent_event_id in active_invitee_set}
if parent_ids:
active_invitee_set.update(e.id for e in events if e.parent_event_id in active_invitee_set)
response: List[dict] = []
for e in events:
# Determine if this event is from an invitation
parent_id = e.parent_event_id or e.id
is_invited = parent_id in invited_event_id_set
inv_status = None
inv_id = None
disp_cal_id = None
disp_cal_name = None
disp_cal_color = None
inv_can_modify = False
if is_invited and parent_id in invitation_map:
inv_status, inv_id, disp_cal_id, inv_can_modify = invitation_map[parent_id]
# Check for per-occurrence override
if e.id in override_map:
inv_status = override_map[e.id]
# Resolve display calendar info
if disp_cal_id and disp_cal_id in display_cal_map:
disp_cal_name = display_cal_map[disp_cal_id]["name"]
disp_cal_color = display_cal_map[disp_cal_id]["color"]
response.append(_event_to_dict(
e,
is_invited=is_invited,
invitation_status=inv_status,
invitation_id=inv_id,
display_calendar_id=disp_cal_id,
display_calendar_name=disp_cal_name,
display_calendar_color=disp_cal_color,
can_modify=inv_can_modify,
has_active_invitees=(parent_id in active_invitee_set or e.id in active_invitee_set),
))
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
bday_result = await db.execute(
@ -219,8 +339,13 @@ async def create_event(
if not data.get("calendar_id"):
data["calendar_id"] = await _get_default_calendar_id(db, current_user.id)
else:
# SEC-04: verify the target calendar belongs to the requesting user
await _verify_calendar_ownership(db, data["calendar_id"], current_user.id)
# SEC-04: verify ownership OR shared calendar permission
cal_ownership_result = await db.execute(
select(Calendar).where(Calendar.id == data["calendar_id"], Calendar.user_id == current_user.id)
)
if not cal_ownership_result.scalar_one_or_none():
# Not owned — check shared calendar permission
await require_permission(db, data["calendar_id"], current_user.id, "create_modify")
# Serialize RecurrenceRule object to JSON string for DB storage
# Exclude None values so defaults in recurrence service work correctly
@ -229,13 +354,12 @@ async def create_event(
if rule_json:
# Parent template: is_recurring=True, no parent_event_id
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True)
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True, updated_by=current_user.id)
db.add(parent)
await db.flush() # assign parent.id before generating children
children = generate_occurrences(parent)
for child in children:
db.add(child)
db.add_all(children)
await db.commit()
@ -258,7 +382,7 @@ async def create_event(
return result.scalar_one()
else:
new_event = CalendarEvent(**data, recurrence_rule=None)
new_event = CalendarEvent(**data, recurrence_rule=None, updated_by=current_user.id)
db.add(new_event)
await db.commit()
@ -276,14 +400,20 @@ async def get_event(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
all_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db)
invited_set = set(invited_event_ids)
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
or_(
CalendarEvent.calendar_id.in_(all_calendar_ids),
CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(),
CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(),
),
)
)
event = result.scalar_one_or_none()
@ -301,23 +431,79 @@ async def update_event(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
# Event invitees can VIEW events but must NOT be able to edit them
# UNLESS they have can_modify=True (checked in fallback path below).
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
is_invited_editor = False
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.calendar_id.in_(all_calendar_ids),
)
)
event = result.scalar_one_or_none()
if not event:
# Fallback: check if user has can_modify invitation for this event
# Must check both event_id (direct) and parent_event_id (recurring child)
# because invitations are stored against the parent event
target_event_result = await db.execute(
select(CalendarEvent.parent_event_id).where(CalendarEvent.id == event_id)
)
target_row = target_event_result.one_or_none()
if not target_row:
raise HTTPException(status_code=404, detail="Calendar event not found")
candidate_ids = [event_id]
if target_row[0] is not None:
candidate_ids.append(target_row[0])
inv_result = await db.execute(
select(EventInvitation).where(
EventInvitation.event_id.in_(candidate_ids),
EventInvitation.user_id == current_user.id,
EventInvitation.can_modify == True,
EventInvitation.status.in_(["accepted", "tentative"]),
)
)
inv = inv_result.scalar_one_or_none()
if not inv:
raise HTTPException(status_code=404, detail="Calendar event not found")
# Load the event directly (bypassing calendar filter)
event_result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
is_invited_editor = True
update_data = event_update.model_dump(exclude_unset=True)
if is_invited_editor:
# Invited editor restrictions — enforce BEFORE any data mutation
# Field allowlist: invited editors can only modify event content, not structure
INVITED_EDITOR_ALLOWED = {"title", "description", "start_datetime", "end_datetime", "all_day", "color", "edit_scope", "location_id"}
disallowed = set(update_data.keys()) - INVITED_EDITOR_ALLOWED
if disallowed:
raise HTTPException(status_code=403, detail="Invited editors cannot modify: " + ", ".join(sorted(disallowed)))
scope_peek = update_data.get("edit_scope")
# Block all bulk-scope edits on recurring events (C-01/F-01)
if event.is_recurring and scope_peek != "this":
raise HTTPException(status_code=403, detail="Invited editors can only edit individual occurrences")
else:
# Standard calendar-access path: require create_modify+ permission
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
# Lock check applies to both paths (uses owner's calendar_id)
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
# Extract scope before applying fields to the model
scope: Optional[str] = update_data.pop("edit_scope", None)
@ -326,10 +512,25 @@ async def update_event(
if rule_obj is not None:
update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None
if not is_invited_editor:
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
if "calendar_id" in update_data and update_data["calendar_id"] is not None:
# Only verify ownership when the calendar is actually changing — members submitting
# an unchanged calendar_id must not be rejected just because they aren't the owner.
if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id:
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id)
# M-01: Block non-owners from moving events off shared calendars
if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id:
source_cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id)
)
source_cal = source_cal_result.scalar_one_or_none()
if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Only the calendar owner can move events between calendars",
)
start = update_data.get("start_datetime", event.start_datetime)
end_dt = update_data.get("end_datetime", event.end_datetime)
if end_dt is not None and end_dt < start:
@ -342,6 +543,7 @@ async def update_event(
# Detach from parent so it's an independent event going forward
event.parent_event_id = None
event.is_recurring = False
event.updated_by = current_user.id
await db.commit()
elif scope == "this_and_future":
@ -371,6 +573,7 @@ async def update_event(
event.parent_event_id = None
event.is_recurring = True
event.original_start = None
event.updated_by = current_user.id
# Inherit parent's recurrence_rule if none was provided in update
if not event.recurrence_rule and parent_rule:
@ -380,12 +583,12 @@ async def update_event(
if event.recurrence_rule:
await db.flush()
children = generate_occurrences(event)
for child in children:
db.add(child)
db.add_all(children)
else:
# This IS a parent — update it and regenerate all children
for key, value in update_data.items():
setattr(event, key, value)
event.updated_by = current_user.id
# Delete all existing children and regenerate
if event.recurrence_rule:
@ -396,8 +599,7 @@ async def update_event(
)
await db.flush()
children = generate_occurrences(event)
for child in children:
db.add(child)
db.add_all(children)
await db.commit()
@ -405,6 +607,7 @@ async def update_event(
# No scope — plain update (non-recurring events or full-series metadata)
for key, value in update_data.items():
setattr(event, key, value)
event.updated_by = current_user.id
await db.commit()
result = await db.execute(
@ -426,12 +629,15 @@ async def delete_event(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
# Event invitees can VIEW events but must NOT be able to delete them.
# Invitees use DELETE /api/event-invitations/{id} to leave instead.
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
result = await db.execute(
select(CalendarEvent).where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.calendar_id.in_(all_calendar_ids),
)
)
event = result.scalar_one_or_none()
@ -439,6 +645,10 @@ async def delete_event(
if not event:
raise HTTPException(status_code=404, detail="Calendar event not found")
# Shared calendar: require full_access+ and check lock
await require_permission(db, event.calendar_id, current_user.id, "full_access")
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
if scope == "this":
# Delete just this one occurrence
await db.delete(event)
@ -448,20 +658,13 @@ async def delete_event(
this_original_start = event.original_start or event.start_datetime
if parent_id is not None:
# Delete this + all future siblings
# Delete this + all future siblings (original_start is always set on children)
await db.execute(
delete(CalendarEvent).where(
CalendarEvent.parent_event_id == parent_id,
CalendarEvent.original_start >= this_original_start,
)
)
# Ensure the target event itself is deleted (edge case: original_start fallback mismatch)
existing = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
target = existing.scalar_one_or_none()
if target:
await db.delete(target)
else:
# This event IS the parent — delete it and all children (CASCADE handles children)
await db.delete(event)

View File

@ -8,6 +8,7 @@ import json
import urllib.request
import urllib.parse
import logging
import re
from app.database import get_db
from app.models.location import Location
@ -57,7 +58,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&limit=5"
url = f"https://nominatim.openstreetmap.org/search?q={encoded_q}&format=json&addressdetails=1&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())
@ -67,9 +68,37 @@ 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", "")
name_parts = display_name.split(",", 1)
name = name_parts[0].strip()
address = name_parts[1].strip() if len(name_parts) > 1 else 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
results.append(
LocationSearchResult(
source="nominatim",

View File

@ -0,0 +1,675 @@
"""
Passkey (WebAuthn/FIDO2) router.
Endpoints (all under /api/auth/passkeys registered in main.py):
POST /register/begin Start passkey registration (auth + password required)
POST /register/complete Complete registration ceremony (auth required)
POST /login/begin Start passkey authentication (public, CSRF-exempt)
POST /login/complete Complete authentication ceremony (public, CSRF-exempt)
GET / List registered passkeys (auth required)
DELETE /{id} Remove a passkey (auth + password required)
Security:
- Challenge tokens signed with itsdangerous (60s TTL, single-use nonce)
- Registration binds challenge to user_id, validated on complete (S-01)
- Registration requires password re-entry (V-02)
- Generic 401 on all auth failures (no credential enumeration)
- Constant-time response on login/begin (V-03)
- Failed passkey logins increment shared lockout counter
- Passkey login bypasses TOTP (passkey IS 2FA)
"""
import asyncio
import json
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.models.passkey_credential import PasskeyCredential
from app.models.system_config import SystemConfig
from app.models.user import User
from app.routers.auth import get_current_user
from app.services.audit import get_client_ip, log_audit_event
from app.services.auth import averify_password_with_upgrade, verify_session_token
from app.services.session import (
create_db_session,
set_session_cookie,
check_account_lockout,
record_failed_login,
record_successful_login,
)
from app.services.passkey import (
create_challenge_token,
verify_challenge_token,
build_registration_options,
verify_registration as verify_registration_response_svc,
build_authentication_options,
verify_authentication as verify_authentication_response_svc,
extract_credential_raw_id,
)
from app.models.session import UserSession
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Request/Response schemas
# ---------------------------------------------------------------------------
class PasskeyRegisterBeginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str = Field(max_length=128)
class PasskeyRegisterCompleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
credential: str = Field(max_length=8192)
challenge_token: str = Field(max_length=2048)
name: str = Field(min_length=1, max_length=100)
class PasskeyLoginBeginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str | None = Field(None, max_length=50)
class PasskeyLoginCompleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
credential: str = Field(max_length=8192)
challenge_token: str = Field(max_length=2048)
unlock: bool = False
class PasskeyDeleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str = Field(max_length=128)
class PasswordlessEnableRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str = Field(max_length=128)
class PasswordlessDisableRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
credential: str = Field(max_length=8192)
challenge_token: str = Field(max_length=2048)
# ---------------------------------------------------------------------------
# Registration endpoints (authenticated)
# ---------------------------------------------------------------------------
@router.post("/register/begin")
async def passkey_register_begin(
data: PasskeyRegisterBeginRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Start passkey registration. Requires password re-entry (V-02)."""
# V-02: Verify password before allowing registration
valid, new_hash = await averify_password_with_upgrade(
data.password, current_user.password_hash
)
if not valid:
raise HTTPException(status_code=401, detail="Invalid password")
if new_hash:
current_user.password_hash = new_hash
await db.commit()
# Load existing credential IDs for exclusion
result = await db.execute(
select(PasskeyCredential.credential_id).where(
PasskeyCredential.user_id == current_user.id
)
)
existing_ids = [
base64url_to_bytes(row[0]) for row in result.all()
]
options_json, challenge = build_registration_options(
user_id=current_user.id,
username=current_user.username,
existing_credential_ids=existing_ids,
)
token = create_challenge_token(challenge, user_id=current_user.id)
return {
"options": json.loads(options_json),
"challenge_token": token,
}
@router.post("/register/complete")
async def passkey_register_complete(
data: PasskeyRegisterCompleteRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Complete passkey registration ceremony."""
# Verify challenge token — cross-check user binding (S-01) + single-use nonce (V-01)
challenge = verify_challenge_token(
data.challenge_token, expected_user_id=current_user.id
)
if challenge is None:
raise HTTPException(status_code=401, detail="Invalid or expired challenge")
try:
verified = verify_registration_response_svc(
credential_json=data.credential,
challenge=challenge,
)
except Exception as e:
logger.warning("Passkey registration verification failed: %s", e)
raise HTTPException(status_code=400, detail="Registration verification failed")
# Store credential
credential_id_b64 = bytes_to_base64url(verified.credential_id)
# Check for duplicate (race condition safety)
existing = await db.execute(
select(PasskeyCredential).where(
PasskeyCredential.credential_id == credential_id_b64
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Credential already registered")
# Extract transport hints if available
transports_json = None
if hasattr(verified, 'credential_device_type'):
pass # py_webauthn doesn't expose transports on VerifiedRegistration
# Transports come from the browser response — parse from credential JSON
try:
cred_data = json.loads(data.credential)
if "response" in cred_data and "transports" in cred_data["response"]:
transports_json = json.dumps(cred_data["response"]["transports"])
except (json.JSONDecodeError, KeyError):
pass
# Determine backup state from py_webauthn flags
backed_up = getattr(verified, 'credential_backed_up', False)
new_credential = PasskeyCredential(
user_id=current_user.id,
credential_id=credential_id_b64,
public_key=bytes_to_base64url(verified.credential_public_key),
sign_count=verified.sign_count,
name=data.name,
transports=transports_json,
backed_up=backed_up,
)
db.add(new_credential)
# B-02: If user has mfa_enforce_pending, clear it (passkey = MFA)
if current_user.mfa_enforce_pending:
current_user.mfa_enforce_pending = False
# Extract response data BEFORE commit (ORM expiry rule)
response_data = {
"id": None, # will be set after flush
"name": new_credential.name,
"created_at": None,
"backed_up": backed_up,
}
await db.flush()
response_data["id"] = new_credential.id
response_data["created_at"] = str(new_credential.created_at) if new_credential.created_at else None
await log_audit_event(
db, action="passkey.registered", actor_id=current_user.id,
detail={"credential_name": data.name},
ip=get_client_ip(request),
)
await db.commit()
return response_data
# ---------------------------------------------------------------------------
# Authentication endpoints (unauthenticated — CSRF-exempt)
# ---------------------------------------------------------------------------
@router.post("/login/begin")
async def passkey_login_begin(
data: PasskeyLoginBeginRequest,
db: AsyncSession = Depends(get_db),
):
"""Start passkey authentication. CSRF-exempt, public endpoint."""
credential_data = None
if data.username:
# Look up user's credentials for allowCredentials
result = await db.execute(
select(User).where(User.username == data.username.lower().strip())
)
user = result.scalar_one_or_none()
if user:
cred_result = await db.execute(
select(
PasskeyCredential.credential_id,
PasskeyCredential.transports,
).where(PasskeyCredential.user_id == user.id)
)
rows = cred_result.all()
if rows:
credential_data = []
for row in rows:
cid_bytes = base64url_to_bytes(row[0])
transports = json.loads(row[1]) if row[1] else None
credential_data.append((cid_bytes, transports))
else:
# F-01: User not found — run a no-op DB query to equalize timing with
# the credential fetch that executes for existing users. Without this,
# the absence of the second query makes the "no user" path measurably
# faster, leaking whether the username exists.
await db.execute(
select(PasskeyCredential.credential_id).where(
PasskeyCredential.user_id == 0
).limit(1)
)
# V-03: Generate options regardless of whether user exists or has passkeys.
# Identical response shape prevents timing enumeration.
options_json, challenge = build_authentication_options(
credential_ids_and_transports=credential_data,
)
token = create_challenge_token(challenge)
return {
"options": json.loads(options_json),
"challenge_token": token,
}
@router.post("/login/complete")
async def passkey_login_complete(
data: PasskeyLoginCompleteRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""Complete passkey authentication. CSRF-exempt, public endpoint."""
# Verify challenge token (60s TTL, single-use nonce V-01)
challenge = verify_challenge_token(data.challenge_token)
if challenge is None:
raise HTTPException(status_code=401, detail="Authentication failed")
# Parse credential_id from browser response (S-02: shared helper)
raw_id_b64 = extract_credential_raw_id(data.credential)
if not raw_id_b64:
raise HTTPException(status_code=401, detail="Authentication failed")
# Look up credential + user in a single JOIN query (W-1 perf fix)
result = await db.execute(
select(PasskeyCredential, User)
.join(User, User.id == PasskeyCredential.user_id)
.where(PasskeyCredential.credential_id == raw_id_b64)
)
row = result.one_or_none()
if not row:
raise HTTPException(status_code=401, detail="Authentication failed")
credential, user = row.tuple()
# Check account lockout (C-03)
await check_account_lockout(user)
# Check active status (C-03)
if not user.is_active:
raise HTTPException(status_code=401, detail="Authentication failed")
# Verify the authentication response
try:
verified = verify_authentication_response_svc(
credential_json=data.credential,
challenge=challenge,
credential_public_key=base64url_to_bytes(credential.public_key),
credential_current_sign_count=credential.sign_count,
)
except Exception as e:
logger.warning("Passkey authentication verification failed for user %s: %s", user.id, e)
# Increment failed login counter (shared with password auth)
remaining = await record_failed_login(db, user)
await log_audit_event(
db, action="passkey.login_failed", actor_id=user.id,
detail={"reason": "verification_failed", "attempts_remaining": remaining},
ip=get_client_ip(request),
)
await db.commit()
# Generic message for all failures — don't leak lockout state (C-02/F-02)
raise HTTPException(status_code=401, detail="Authentication failed")
# Update sign count (log anomaly but don't fail — S-05)
new_sign_count = verified.new_sign_count
if new_sign_count < credential.sign_count and credential.sign_count > 0:
logger.warning(
"Sign count anomaly for user %s credential %s: expected >= %d, got %d",
user.id, credential.id, credential.sign_count, new_sign_count,
)
await log_audit_event(
db, action="passkey.sign_count_anomaly", actor_id=user.id,
detail={
"credential_id": credential.id,
"expected": credential.sign_count,
"received": new_sign_count,
},
ip=get_client_ip(request),
)
credential.sign_count = new_sign_count
credential.last_used_at = datetime.now()
# Passkey unlock — re-authenticate into a locked session instead of creating a new one
if data.unlock:
session_cookie = request.cookies.get("session")
payload = verify_session_token(session_cookie) if session_cookie else None
if not payload or payload.get("uid") != user.id:
raise HTTPException(status_code=401, detail="Authentication failed")
sess_result = await db.execute(
select(UserSession).where(
UserSession.id == payload["sid"],
UserSession.user_id == user.id,
UserSession.revoked == False,
)
)
db_sess = sess_result.scalar_one_or_none()
if not db_sess:
raise HTTPException(status_code=401, detail="Authentication failed")
db_sess.is_locked = False
db_sess.locked_at = None
# Reset failed login counter on successful passkey unlock (W-02)
await record_successful_login(db, user)
await log_audit_event(
db, action="passkey.unlock_success", actor_id=user.id,
ip=get_client_ip(request),
)
await db.commit()
return {"unlocked": True}
# Record successful login
await record_successful_login(db, user)
# Create session (shared service — enforces session cap)
client_ip = get_client_ip(request)
user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, user, client_ip, user_agent)
set_session_cookie(response, token)
# Handle special flags for passkey login
result_data: dict = {"authenticated": True}
# W-05: Passkey login auto-clears must_change_password — user can't provide
# old password in the forced-change form since they authenticated via passkey.
if user.must_change_password:
user.must_change_password = False
# Passkey satisfies MFA — if mfa_enforce_pending, clear it (before commit)
if user.mfa_enforce_pending:
user.mfa_enforce_pending = False
await log_audit_event(
db, action="passkey.login_success", actor_id=user.id,
detail={"credential_name": credential.name},
ip=client_ip,
)
await db.commit()
return result_data
# ---------------------------------------------------------------------------
# Passwordless toggle endpoints (authenticated)
# ---------------------------------------------------------------------------
@router.put("/passwordless/enable")
async def passwordless_enable(
data: PasswordlessEnableRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Enable passwordless login for the current user.
Requirements:
- System config must have allow_passwordless = True
- User must have >= 2 registered passkeys
- Password confirmation required
"""
# Verify password first
valid, new_hash = await averify_password_with_upgrade(
data.password, current_user.password_hash
)
if not valid:
raise HTTPException(status_code=401, detail="Invalid password")
if new_hash:
current_user.password_hash = new_hash
# Check system config
config_result = await db.execute(
select(SystemConfig).where(SystemConfig.id == 1)
)
config = config_result.scalar_one_or_none()
if not config or not config.allow_passwordless:
raise HTTPException(
status_code=403,
detail="Passwordless login is not enabled on this system",
)
# Require >= 2 passkeys as safety net (can't get locked out)
pk_count_result = await db.execute(
select(func.count()).select_from(PasskeyCredential).where(
PasskeyCredential.user_id == current_user.id
)
)
pk_count = pk_count_result.scalar_one()
if pk_count < 2:
raise HTTPException(
status_code=400,
detail="At least 2 passkeys must be registered before enabling passwordless login",
)
current_user.passwordless_enabled = True
await log_audit_event(
db, action="passkey.passwordless_enabled", actor_id=current_user.id,
ip=get_client_ip(request),
)
await db.commit()
return {"passwordless_enabled": True}
@router.post("/passwordless/disable/begin")
async def passwordless_disable_begin(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Begin the passkey authentication ceremony to disable passwordless login.
Returns challenge options for the browser to present to the authenticator.
"""
# Load user's credentials for allowCredentials
cred_result = await db.execute(
select(
PasskeyCredential.credential_id,
PasskeyCredential.transports,
).where(PasskeyCredential.user_id == current_user.id)
)
rows = cred_result.all()
credential_data = None
if rows:
credential_data = []
for row in rows:
cid_bytes = base64url_to_bytes(row[0])
transports = json.loads(row[1]) if row[1] else None
credential_data.append((cid_bytes, transports))
options_json, challenge = build_authentication_options(
credential_ids_and_transports=credential_data,
)
# Bind challenge to this user so complete endpoint can cross-check
token = create_challenge_token(challenge, user_id=current_user.id)
return {
"options": json.loads(options_json),
"challenge_token": token,
}
@router.put("/passwordless/disable")
async def passwordless_disable(
data: PasswordlessDisableRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Complete passkey authentication to disable passwordless login.
Verifies the credential belongs to the current user.
"""
# Verify challenge token — user-bound (single-use nonce V-01, cross-user binding S-01)
challenge = verify_challenge_token(
data.challenge_token, expected_user_id=current_user.id
)
if challenge is None:
raise HTTPException(status_code=401, detail="Invalid or expired challenge")
# Parse rawId from credential (S-02: shared helper)
raw_id_b64 = extract_credential_raw_id(data.credential)
if not raw_id_b64:
raise HTTPException(status_code=401, detail="Authentication failed")
# Look up credential — verify ownership (IDOR prevention)
cred_result = await db.execute(
select(PasskeyCredential).where(
PasskeyCredential.credential_id == raw_id_b64,
PasskeyCredential.user_id == current_user.id,
)
)
credential = cred_result.scalar_one_or_none()
if not credential:
raise HTTPException(status_code=401, detail="Authentication failed")
# Verify the authentication response
try:
verified = verify_authentication_response_svc(
credential_json=data.credential,
challenge=challenge,
credential_public_key=base64url_to_bytes(credential.public_key),
credential_current_sign_count=credential.sign_count,
)
except Exception as e:
logger.warning(
"Passwordless disable: auth verification failed for user %s: %s",
current_user.id, e,
)
raise HTTPException(status_code=401, detail="Authentication failed")
# Update sign count
credential.sign_count = verified.new_sign_count
credential.last_used_at = datetime.now()
current_user.passwordless_enabled = False
await log_audit_event(
db, action="passkey.passwordless_disabled", actor_id=current_user.id,
ip=get_client_ip(request),
)
await db.commit()
return {"passwordless_enabled": False}
# ---------------------------------------------------------------------------
# Management endpoints (authenticated)
# ---------------------------------------------------------------------------
@router.get("/")
async def list_passkeys(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all passkeys for the current user."""
result = await db.execute(
select(PasskeyCredential)
.where(PasskeyCredential.user_id == current_user.id)
.order_by(PasskeyCredential.created_at.desc())
)
credentials = result.scalars().all()
return [
{
"id": c.id,
"name": c.name,
"created_at": str(c.created_at) if c.created_at else None,
"last_used_at": str(c.last_used_at) if c.last_used_at else None,
"backed_up": c.backed_up,
}
for c in credentials
]
@router.delete("/{credential_id}")
async def delete_passkey(
request: Request,
credential_id: int = Path(ge=1, le=2147483647),
data: PasskeyDeleteRequest = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a passkey. Requires password confirmation (S-06)."""
# Verify password
valid, new_hash = await averify_password_with_upgrade(
data.password, current_user.password_hash
)
if not valid:
raise HTTPException(status_code=401, detail="Invalid password")
if new_hash:
current_user.password_hash = new_hash
# Look up credential — verify ownership (IDOR prevention)
result = await db.execute(
select(PasskeyCredential).where(
PasskeyCredential.id == credential_id,
PasskeyCredential.user_id == current_user.id,
)
)
credential = result.scalar_one_or_none()
if not credential:
raise HTTPException(status_code=404, detail="Passkey not found")
# Guard: passwordless users must retain at least 2 passkeys
if current_user.passwordless_enabled:
pk_count_result = await db.execute(
select(func.count()).select_from(PasskeyCredential).where(
PasskeyCredential.user_id == current_user.id
)
)
pk_count = pk_count_result.scalar_one()
if pk_count <= 2:
raise HTTPException(
status_code=409,
detail="Cannot delete: passwordless requires at least 2 passkeys",
)
cred_name = credential.name
await db.delete(credential)
await log_audit_event(
db, action="passkey.deleted", actor_id=current_user.id,
detail={"credential_name": cred_name, "credential_db_id": credential_id},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Passkey removed"}

View File

@ -1,18 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import delete as sa_delete, select, update
from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import date, timedelta
from pydantic import BaseModel
from datetime import date, datetime, 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
@ -20,39 +33,67 @@ router = APIRouter()
class ReorderItem(BaseModel):
model_config = ConfigDict(extra="forbid")
id: int
sort_order: int
def _project_load_options():
"""All load options needed for project responses (tasks + subtasks + comments at each level)."""
"""All load options needed for project responses (tasks + subtasks + comments + assignments)."""
return [
selectinload(Project.tasks).selectinload(ProjectTask.comments),
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
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.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(ProjectTask.subtasks).selectinload(ProjectTask.comments),
selectinload(ProjectTask.comments).selectinload(TaskComment.user),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
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 with their tasks. Optionally filter by tracked status."""
"""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 []
query = (
select(Project)
.options(*_project_load_options())
.where(Project.user_id == current_user.id)
.where(Project.id.in_(accessible_ids))
.order_by(Project.created_at.desc())
)
if tracked is not None:
@ -71,6 +112,10 @@ 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)
@ -82,7 +127,7 @@ async def get_tracked_tasks(
selectinload(ProjectTask.parent_task),
)
.where(
Project.user_id == current_user.id,
Project.id.in_(accessible_ids),
Project.is_tracked == True,
ProjectTask.due_date.isnot(None),
ProjectTask.due_date >= today,
@ -109,6 +154,31 @@ 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,
@ -120,7 +190,6 @@ 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()
@ -133,10 +202,12 @@ 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, Project.user_id == current_user.id)
.where(Project.id == project_id)
)
result = await db.execute(query)
project = result.scalar_one_or_none()
@ -154,10 +225,10 @@ async def update_project(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a project."""
result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
)
"""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))
project = result.scalar_one_or_none()
if not project:
@ -170,7 +241,6 @@ 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()
@ -182,10 +252,10 @@ async def delete_project(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a project and all its tasks."""
result = await db.execute(
select(Project).where(Project.id == project_id, Project.user_id == current_user.id)
)
"""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))
project = result.scalar_one_or_none()
if not project:
@ -197,6 +267,10 @@ 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),
@ -204,14 +278,7 @@ async def get_project_tasks(
current_user: User = Depends(get_current_user)
):
"""Get top-level tasks for a specific project (subtasks are nested)."""
# 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")
await require_project_permission(db, project_id, current_user.id, "read_only")
query = (
select(ProjectTask)
@ -235,15 +302,8 @@ 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."""
# 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")
"""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")
# Validate parent_task_id if creating a subtask
if task.parent_task_id is not None:
@ -267,7 +327,6 @@ async def create_project_task(
db.add(new_task)
await db.commit()
# Re-fetch with subtasks loaded
query = (
select(ProjectTask)
.options(*_task_load_options())
@ -284,26 +343,23 @@ async def reorder_tasks(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""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()
"""Bulk update sort_order for tasks. Requires create_modify permission."""
await require_project_permission(db, project_id, current_user.id, "create_modify")
if not project:
raise HTTPException(status_code=404, detail="Project not found")
for item in items:
# AC-4: Batch-fetch all tasks in one query instead of N sequential queries
task_ids = [item.id for item in items]
task_result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == item.id,
ProjectTask.project_id == project_id
ProjectTask.id.in_(task_ids),
ProjectTask.project_id == project_id,
)
)
task = task_result.scalar_one_or_none()
if task:
task.sort_order = item.sort_order
tasks_by_id = {t.id: t for t in task_result.scalars().all()}
order_map = {item.id: item.sort_order for item in items}
for task_id, task in tasks_by_id.items():
if task_id in order_map:
task.sort_order = order_map[task_id]
await db.commit()
@ -318,13 +374,12 @@ async def update_project_task(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""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():
"""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:
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(
@ -339,12 +394,28 @@ 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())
@ -361,13 +432,8 @@ async def delete_project_task(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""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")
"""Delete a project task (cascades to subtasks). Requires create_modify permission."""
await require_project_permission(db, project_id, current_user.id, "create_modify")
result = await db.execute(
select(ProjectTask).where(
@ -386,6 +452,10 @@ 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),
@ -394,13 +464,8 @@ async def create_task_comment(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""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")
"""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")
result = await db.execute(
select(ProjectTask).where(
@ -413,12 +478,23 @@ async def create_task_comment(
if not task:
raise HTTPException(status_code=404, detail="Task not found")
new_comment = TaskComment(task_id=task_id, content=comment.content)
new_comment = TaskComment(task_id=task_id, user_id=current_user.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 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,
)
@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
@ -429,12 +505,9 @@ async def delete_task_comment(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""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():
"""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:
raise HTTPException(status_code=404, detail="Project not found")
result = await db.execute(
@ -448,7 +521,484 @@ 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,
)

View File

@ -7,6 +7,7 @@ from app.models.settings import Settings
from app.models.user import User
from app.schemas.settings import SettingsUpdate, SettingsResponse
from app.routers.auth import get_current_user, get_current_settings
from app.services.connection import sync_birthday_to_contacts
router = APIRouter()
@ -78,6 +79,7 @@ async def get_settings(
async def update_settings(
settings_update: SettingsUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings)
):
"""Update settings."""
@ -91,9 +93,18 @@ async def update_settings(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
old_share_birthday = current_settings.share_birthday
for key, value in update_data.items():
setattr(current_settings, key, value)
if "share_birthday" in update_data and update_data["share_birthday"] != old_share_birthday:
await sync_birthday_to_contacts(
db, current_user.id,
share_birthday=update_data["share_birthday"],
date_of_birth=current_user.date_of_birth,
)
await db.commit()
await db.refresh(current_settings)

View File

@ -0,0 +1,871 @@
"""
Shared calendars router invites, membership, locks, sync.
All endpoints live under /api/shared-calendars.
"""
import logging
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from sqlalchemy import delete, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.calendar import Calendar
from app.models.calendar_event import CalendarEvent
from app.models.calendar_member import CalendarMember
from app.models.event_lock import EventLock
from app.models.settings import Settings
from app.models.user import User
from app.models.user_connection import UserConnection
from app.routers.auth import get_current_user
from app.schemas.shared_calendar import (
CalendarInviteResponse,
CalendarMemberResponse,
InviteMemberRequest,
LockStatusResponse,
RespondInviteRequest,
SyncResponse,
UpdateLocalColorRequest,
UpdateMemberRequest,
)
from app.services.audit import get_client_ip, log_audit_event
from app.services.calendar_sharing import (
PERMISSION_RANK,
acquire_lock,
get_user_permission,
release_lock,
require_permission,
)
from app.services.notification import create_notification
router = APIRouter()
logger = logging.getLogger(__name__)
PENDING_INVITE_CAP = 10
# -- Helpers ---------------------------------------------------------------
async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None:
result = await db.execute(select(Settings).where(Settings.user_id == user_id))
return result.scalar_one_or_none()
def _build_member_response(member: CalendarMember) -> dict:
return {
"id": member.id,
"calendar_id": member.calendar_id,
"user_id": member.user_id,
"umbral_name": member.user.umbral_name if member.user else "",
"preferred_name": None,
"permission": member.permission,
"can_add_others": member.can_add_others,
"local_color": member.local_color,
"status": member.status,
"invited_at": member.invited_at,
"accepted_at": member.accepted_at,
}
# -- GET / — List accepted memberships ------------------------------------
@router.get("/")
async def list_shared_calendars(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List calendars the current user has accepted membership in."""
result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
.options(selectinload(CalendarMember.calendar))
.order_by(CalendarMember.accepted_at.desc())
)
members = result.scalars().all()
return [
{
"id": m.id,
"calendar_id": m.calendar_id,
"calendar_name": m.calendar.name if m.calendar else "",
"calendar_color": m.calendar.color if m.calendar else "",
"local_color": m.local_color,
"permission": m.permission,
"can_add_others": m.can_add_others,
"is_owner": False,
}
for m in members
]
# -- POST /{cal_id}/invite — Invite via connection_id ---------------------
@router.post("/{cal_id}/invite", status_code=201)
async def invite_member(
body: InviteMemberRequest,
request: Request,
cal_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Invite a connected user to a shared calendar."""
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id)
)
calendar = cal_result.scalar_one_or_none()
if not calendar:
raise HTTPException(status_code=404, detail="Calendar not found")
is_owner = calendar.user_id == current_user.id
inviter_perm = "owner" if is_owner else None
if not is_owner:
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.calendar_id == cal_id,
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Calendar not found")
if not member.can_add_others:
raise HTTPException(status_code=403, detail="You do not have permission to invite others")
if PERMISSION_RANK.get(member.permission, 0) < PERMISSION_RANK.get("create_modify", 0):
raise HTTPException(status_code=403, detail="Read-only members cannot invite others")
inviter_perm = member.permission
# Permission ceiling
if inviter_perm != "owner":
if PERMISSION_RANK.get(body.permission, 0) > PERMISSION_RANK.get(inviter_perm, 0):
raise HTTPException(
status_code=403,
detail="Cannot grant a permission level higher than your own",
)
# Resolve connection_id -> connected user
conn_result = await db.execute(
select(UserConnection).where(
UserConnection.id == body.connection_id,
UserConnection.user_id == current_user.id,
)
)
connection = conn_result.scalar_one_or_none()
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
target_user_id = connection.connected_user_id
# W-03: Verify bidirectional connection still active
reverse_conn = await db.execute(
select(UserConnection.id).where(
UserConnection.user_id == target_user_id,
UserConnection.connected_user_id == current_user.id,
)
)
if not reverse_conn.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Connection is no longer active")
if target_user_id == calendar.user_id:
raise HTTPException(status_code=400, detail="Cannot invite the calendar owner")
target_result = await db.execute(
select(User).where(User.id == target_user_id)
)
target = target_result.scalar_one_or_none()
if not target or not target.is_active:
raise HTTPException(status_code=404, detail="Target user not found or inactive")
existing = await db.execute(
select(CalendarMember).where(
CalendarMember.calendar_id == cal_id,
CalendarMember.user_id == target_user_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="User already invited or is a member")
pending_count = await db.scalar(
select(func.count())
.select_from(CalendarMember)
.where(
CalendarMember.calendar_id == cal_id,
CalendarMember.status == "pending",
)
) or 0
if pending_count >= PENDING_INVITE_CAP:
raise HTTPException(status_code=429, detail="Too many pending invites for this calendar")
if not calendar.is_shared:
calendar.is_shared = True
new_member = CalendarMember(
calendar_id=cal_id,
user_id=target_user_id,
invited_by=current_user.id,
permission=body.permission,
can_add_others=body.can_add_others,
status="pending",
)
db.add(new_member)
await db.flush()
inviter_settings = await _get_settings_for_user(db, current_user.id)
inviter_display = (inviter_settings.preferred_name if inviter_settings else None) or current_user.umbral_name
cal_name = calendar.name
await create_notification(
db,
user_id=target_user_id,
type="calendar_invite",
title="Calendar Invite",
message=f"{inviter_display} invited you to '{cal_name}'",
data={"calendar_id": cal_id, "calendar_name": cal_name},
source_type="calendar_invite",
source_id=new_member.id,
)
await log_audit_event(
db,
action="calendar.invite_sent",
actor_id=current_user.id,
target_id=target_user_id,
detail={
"calendar_id": cal_id,
"calendar_name": cal_name,
"permission": body.permission,
},
ip=get_client_ip(request),
)
response = {
"message": "Invite sent",
"member_id": new_member.id,
"calendar_id": cal_id,
}
await db.commit()
return response
# -- PUT /invites/{id}/respond — Accept or reject -------------------------
@router.put("/invites/{invite_id}/respond")
async def respond_to_invite(
body: RespondInviteRequest,
request: Request,
invite_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accept or reject a calendar invite."""
result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.id == invite_id,
CalendarMember.user_id == current_user.id,
CalendarMember.status == "pending",
)
.options(selectinload(CalendarMember.calendar))
)
invite = result.scalar_one_or_none()
if not invite:
raise HTTPException(status_code=404, detail="Invite not found or already resolved")
calendar_name = invite.calendar.name if invite.calendar else "Unknown"
calendar_owner_id = invite.calendar.user_id if invite.calendar else None
inviter_id = invite.invited_by
if body.action == "accept":
invite.status = "accepted"
invite.accepted_at = datetime.now()
notify_user_id = inviter_id or calendar_owner_id
if notify_user_id:
user_settings = await _get_settings_for_user(db, current_user.id)
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
await create_notification(
db,
user_id=notify_user_id,
type="calendar_invite_accepted",
title="Invite Accepted",
message=f"{display} accepted your invite to '{calendar_name}'",
data={"calendar_id": invite.calendar_id},
source_type="calendar_invite",
source_id=invite.id,
)
await log_audit_event(
db,
action="calendar.invite_accepted",
actor_id=current_user.id,
detail={"calendar_id": invite.calendar_id, "calendar_name": calendar_name},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Invite accepted"}
else:
member_id = invite.id
calendar_id = invite.calendar_id
notify_user_id = inviter_id or calendar_owner_id
if notify_user_id:
user_settings = await _get_settings_for_user(db, current_user.id)
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
await create_notification(
db,
user_id=notify_user_id,
type="calendar_invite_rejected",
title="Invite Rejected",
message=f"{display} declined your invite to '{calendar_name}'",
data={"calendar_id": calendar_id},
source_type="calendar_invite",
source_id=member_id,
)
await log_audit_event(
db,
action="calendar.invite_rejected",
actor_id=current_user.id,
detail={"calendar_id": calendar_id, "calendar_name": calendar_name},
ip=get_client_ip(request),
)
await db.delete(invite)
await db.commit()
return {"message": "Invite rejected"}
# -- GET /invites/incoming — Pending invites -------------------------------
@router.get("/invites/incoming", response_model=list[CalendarInviteResponse])
async def get_incoming_invites(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List pending calendar invites for the current user."""
offset = (page - 1) * per_page
result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "pending",
)
.options(
selectinload(CalendarMember.calendar),
selectinload(CalendarMember.inviter),
)
.order_by(CalendarMember.invited_at.desc())
.offset(offset)
.limit(per_page)
)
invites = result.scalars().all()
# Batch-fetch owner names to avoid N+1
owner_ids = list({inv.calendar.user_id for inv in invites if inv.calendar})
if owner_ids:
owner_result = await db.execute(
select(User.id, User.umbral_name).where(User.id.in_(owner_ids))
)
owner_names = {row.id: row.umbral_name for row in owner_result.all()}
else:
owner_names = {}
responses = []
for inv in invites:
owner_name = owner_names.get(inv.calendar.user_id, "") if inv.calendar else ""
responses.append(CalendarInviteResponse(
id=inv.id,
calendar_id=inv.calendar_id,
calendar_name=inv.calendar.name if inv.calendar else "",
calendar_color=inv.calendar.color if inv.calendar else "",
owner_umbral_name=owner_name,
inviter_umbral_name=inv.inviter.umbral_name if inv.inviter else "",
permission=inv.permission,
invited_at=inv.invited_at,
))
return responses
# -- GET /{cal_id}/members — Member list -----------------------------------
@router.get("/{cal_id}/members", response_model=list[CalendarMemberResponse])
async def list_members(
cal_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all members of a shared calendar. Requires membership or ownership."""
perm = await get_user_permission(db, cal_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Calendar not found")
result = await db.execute(
select(CalendarMember)
.where(CalendarMember.calendar_id == cal_id)
.options(selectinload(CalendarMember.user))
.order_by(CalendarMember.invited_at.asc())
)
members = result.scalars().all()
user_ids = [m.user_id for m in members]
if user_ids:
settings_result = await db.execute(
select(Settings.user_id, Settings.preferred_name)
.where(Settings.user_id.in_(user_ids))
)
pref_names = {row.user_id: row.preferred_name for row in settings_result.all()}
else:
pref_names = {}
responses = []
for m in members:
resp = _build_member_response(m)
resp["preferred_name"] = pref_names.get(m.user_id)
responses.append(resp)
return responses
# -- PUT /{cal_id}/members/{mid} — Update permission (owner only) ----------
@router.put("/{cal_id}/members/{member_id}")
async def update_member(
body: UpdateMemberRequest,
request: Request,
cal_id: int = Path(ge=1, le=2147483647),
member_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a member permission or can_add_others. Owner only."""
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id, Calendar.user_id == current_user.id)
)
if not cal_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Only the calendar owner can update members")
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.id == member_id,
CalendarMember.calendar_id == cal_id,
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(status_code=400, detail="No fields to update")
if "permission" in update_data:
member.permission = update_data["permission"]
if "can_add_others" in update_data:
member.can_add_others = update_data["can_add_others"]
await log_audit_event(
db,
action="calendar.member_updated",
actor_id=current_user.id,
target_id=member.user_id,
detail={"calendar_id": cal_id, "member_id": member_id, "changes": update_data},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Member updated"}
# -- DELETE /{cal_id}/members/{mid} — Remove member or leave ---------------
@router.delete("/{cal_id}/members/{member_id}", status_code=204)
async def remove_member(
request: Request,
cal_id: int = Path(ge=1, le=2147483647),
member_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remove a member or leave a shared calendar."""
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.id == member_id,
CalendarMember.calendar_id == cal_id,
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
cal_result = await db.execute(
select(Calendar).where(Calendar.id == cal_id)
)
calendar = cal_result.scalar_one_or_none()
is_self = member.user_id == current_user.id
is_owner = calendar and calendar.user_id == current_user.id
if not is_self and not is_owner:
raise HTTPException(status_code=403, detail="Only the calendar owner can remove other members")
target_user_id = member.user_id
await db.execute(
delete(EventLock).where(
EventLock.locked_by == target_user_id,
EventLock.event_id.in_(
select(CalendarEvent.id).where(CalendarEvent.calendar_id == cal_id)
),
)
)
await db.delete(member)
remaining = await db.execute(
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
)
if not remaining.scalar_one_or_none() and calendar:
calendar.is_shared = False
action = "calendar.member_left" if is_self else "calendar.member_removed"
await log_audit_event(
db,
action=action,
actor_id=current_user.id,
target_id=target_user_id,
detail={"calendar_id": cal_id, "member_id": member_id},
ip=get_client_ip(request),
)
await db.commit()
return None
# -- PUT /{cal_id}/members/me/color — Update local color -------------------
@router.put("/{cal_id}/members/me/color")
async def update_local_color(
body: UpdateLocalColorRequest,
cal_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update the current user local color for a shared calendar."""
member_result = await db.execute(
select(CalendarMember).where(
CalendarMember.calendar_id == cal_id,
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Membership not found")
member.local_color = body.local_color
await db.commit()
return {"message": "Color updated"}
# -- GET /sync — Sync endpoint ---------------------------------------------
@router.get("/sync", response_model=SyncResponse)
async def sync_shared_calendars(
since: datetime = Query(...),
calendar_ids: Optional[str] = Query(None, description="Comma-separated calendar IDs"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Sync events and member changes since a given timestamp. Cap 500 events."""
MAX_EVENTS = 500
# Cap since to 7 days ago to prevent unbounded scans
floor = datetime.now() - timedelta(days=7)
if since < floor:
since = floor
cal_id_list: list[int] = []
if calendar_ids:
for part in calendar_ids.split(","):
part = part.strip()
if part.isdigit():
cal_id_list.append(int(part))
cal_id_list = cal_id_list[:50] # Cap to prevent unbounded IN clause
owned_ids_result = await db.execute(
select(Calendar.id).where(Calendar.user_id == current_user.id)
)
owned_ids = {row[0] for row in owned_ids_result.all()}
member_ids_result = await db.execute(
select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
)
member_ids = {row[0] for row in member_ids_result.all()}
accessible = owned_ids | member_ids
if cal_id_list:
accessible = accessible & set(cal_id_list)
if not accessible:
return SyncResponse(events=[], member_changes=[], server_time=datetime.now())
accessible_list = list(accessible)
events_result = await db.execute(
select(CalendarEvent)
.where(
CalendarEvent.calendar_id.in_(accessible_list),
CalendarEvent.updated_at >= since,
)
.options(selectinload(CalendarEvent.calendar))
.order_by(CalendarEvent.updated_at.desc())
.limit(MAX_EVENTS + 1)
)
events = events_result.scalars().all()
truncated = len(events) > MAX_EVENTS
events = events[:MAX_EVENTS]
event_dicts = []
for e in events:
event_dicts.append({
"id": e.id,
"title": e.title,
"start_datetime": e.start_datetime.isoformat() if e.start_datetime else None,
"end_datetime": e.end_datetime.isoformat() if e.end_datetime else None,
"all_day": e.all_day,
"calendar_id": e.calendar_id,
"calendar_name": e.calendar.name if e.calendar else "",
"updated_at": e.updated_at.isoformat() if e.updated_at else None,
"updated_by": e.updated_by,
})
members_result = await db.execute(
select(CalendarMember)
.where(
CalendarMember.calendar_id.in_(accessible_list),
(CalendarMember.invited_at >= since) | (CalendarMember.accepted_at >= since),
)
.options(selectinload(CalendarMember.user))
)
member_changes = members_result.scalars().all()
member_dicts = []
for m in member_changes:
member_dicts.append({
"id": m.id,
"calendar_id": m.calendar_id,
"user_id": m.user_id,
"umbral_name": m.user.umbral_name if m.user else "",
"permission": m.permission,
"status": m.status,
"invited_at": m.invited_at.isoformat() if m.invited_at else None,
"accepted_at": m.accepted_at.isoformat() if m.accepted_at else None,
})
return SyncResponse(
events=event_dicts,
member_changes=member_dicts,
server_time=datetime.now(),
truncated=truncated,
)
# -- Event Lock Endpoints --------------------------------------------------
@router.post("/events/{event_id}/lock")
async def lock_event(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Acquire a 5-minute editing lock on an event."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
lock = await acquire_lock(db, event_id, current_user.id)
# Build response BEFORE commit — ORM objects expire after commit
response = {
"locked": True,
"locked_by_name": current_user.umbral_name,
"expires_at": lock.expires_at,
"is_permanent": lock.is_permanent,
}
await db.commit()
return response
@router.delete("/events/{event_id}/lock", status_code=204)
async def unlock_event(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Release a lock. Only the holder or calendar owner can release."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# SC-01: Verify caller has access to this calendar before revealing lock state
perm = await get_user_permission(db, event.calendar_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Event not found")
lock_result = await db.execute(
select(EventLock).where(EventLock.event_id == event_id)
)
lock = lock_result.scalar_one_or_none()
if not lock:
return None
cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id)
)
calendar = cal_result.scalar_one_or_none()
is_owner = calendar and calendar.user_id == current_user.id
if lock.locked_by != current_user.id and not is_owner:
raise HTTPException(status_code=403, detail="Only the lock holder or calendar owner can release")
await db.delete(lock)
await db.commit()
return None
@router.get("/events/{event_id}/lock", response_model=LockStatusResponse)
async def get_lock_status(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get the lock status of an event."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
perm = await get_user_permission(db, event.calendar_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Event not found")
lock_result = await db.execute(
select(EventLock)
.where(EventLock.event_id == event_id)
.options(selectinload(EventLock.holder))
)
lock = lock_result.scalar_one_or_none()
if not lock:
return LockStatusResponse(locked=False)
now = datetime.now()
if not lock.is_permanent and lock.expires_at and lock.expires_at < now:
await db.delete(lock)
await db.commit()
return LockStatusResponse(locked=False)
return LockStatusResponse(
locked=True,
locked_by_name=lock.holder.umbral_name if lock.holder else None,
expires_at=lock.expires_at,
is_permanent=lock.is_permanent,
)
@router.post("/events/{event_id}/owner-lock")
async def set_permanent_lock(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Set a permanent lock on an event. Calendar owner only."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
)
if not cal_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Only the calendar owner can set permanent locks")
now = datetime.now()
await db.execute(
text("""
INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent)
VALUES (:event_id, :user_id, :now, NULL, true)
ON CONFLICT (event_id)
DO UPDATE SET
locked_by = :user_id,
locked_at = :now,
expires_at = NULL,
is_permanent = true
"""),
{"event_id": event_id, "user_id": current_user.id, "now": now},
)
await db.commit()
return {"message": "Permanent lock set", "is_permanent": True}
@router.delete("/events/{event_id}/owner-lock", status_code=204)
async def remove_permanent_lock(
event_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Remove a permanent lock. Calendar owner only."""
event_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
event = event_result.scalar_one_or_none()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
)
if not cal_result.scalar_one_or_none():
raise HTTPException(status_code=403, detail="Only the calendar owner can remove permanent locks")
await db.execute(
delete(EventLock).where(
EventLock.event_id == event_id,
EventLock.is_permanent == True,
)
)
await db.commit()
return None

View File

@ -17,10 +17,10 @@ Security:
- Failed TOTP attempts increment user.failed_login_count (shared lockout counter)
- totp-verify uses mfa_token (not session cookie) user is not yet authenticated
"""
import uuid
import asyncio
import secrets
import logging
from datetime import datetime, timedelta
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@ -31,17 +31,21 @@ 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, _set_session_cookie
from app.routers.auth import get_current_user
from app.services.audit import get_client_ip
from app.services.auth import (
verify_password_with_upgrade,
hash_password,
averify_password_with_upgrade,
verify_mfa_token,
verify_mfa_enforce_token,
create_session_token,
)
from app.services.session import (
create_db_session,
set_session_cookie,
check_account_lockout,
record_failed_login,
record_successful_login,
)
from app.services.totp import (
generate_totp_secret,
@ -52,7 +56,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
@ -117,8 +121,10 @@ class EnforceConfirmRequest(BaseModel):
async def _store_backup_codes(db: AsyncSession, user_id: int, plaintext_codes: list[str]) -> None:
"""Hash and insert backup codes for the given user."""
# AC-2: Run Argon2id hashing in executor to avoid blocking event loop
loop = asyncio.get_running_loop()
for code in plaintext_codes:
code_hash = _ph.hash(code)
code_hash = await loop.run_in_executor(None, _ph.hash, code)
db.add(BackupCode(user_id=user_id, code_hash=code_hash))
await db.commit()
@ -145,9 +151,12 @@ async def _verify_backup_code(
)
unused_codes = result.scalars().all()
# AC-2: Run Argon2id verification in executor to avoid blocking event loop
loop = asyncio.get_running_loop()
for record in unused_codes:
try:
if _ph.verify(record.code_hash, submitted_code):
matched = await loop.run_in_executor(None, _ph.verify, record.code_hash, submitted_code)
if matched:
record.used_at = datetime.now()
await db.commit()
return True
@ -157,29 +166,6 @@ 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
# ---------------------------------------------------------------------------
@ -283,60 +269,55 @@ async def totp_verify(
raise HTTPException(status_code=400, detail="TOTP not configured for this account")
# Check account lockout (shared counter with password failures)
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.",
)
await check_account_lockout(user)
# --- 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:
user.failed_login_count += 1
if user.failed_login_count >= 10:
user.locked_until = datetime.now() + timedelta(minutes=30)
remaining = await record_failed_login(db, user)
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
user.failed_login_count = 0
user.locked_until = None
user.last_login_at = datetime.now()
await db.commit()
await record_successful_login(db, user)
token = await _create_full_session(db, user, request)
_set_session_cookie(response, token)
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)
await db.commit()
return {"authenticated": True}
# --- TOTP code path ---
matched_window = verify_totp_code(user.totp_secret, data.code)
if matched_window is None:
user.failed_login_count += 1
if user.failed_login_count >= 10:
user.locked_until = datetime.now() + timedelta(minutes=30)
remaining = await record_failed_login(db, user)
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.commit()
await db.flush()
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
user.failed_login_count = 0
user.locked_until = None
user.last_login_at = datetime.now()
await db.commit()
await record_successful_login(db, user)
token = await _create_full_session(db, user, request)
_set_session_cookie(response, token)
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)
await db.commit()
return {"authenticated": True}
@ -355,7 +336,8 @@ async def totp_disable(
raise HTTPException(status_code=400, detail="TOTP is not enabled")
# Verify password (handles bcrypt→Argon2id upgrade transparently)
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
# AC-2: async wrapper to avoid blocking event loop
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")
@ -391,7 +373,8 @@ async def regenerate_backup_codes(
if not current_user.totp_enabled:
raise HTTPException(status_code=400, detail="TOTP is not enabled")
valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
# AC-2: async wrapper to avoid blocking event loop
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")
@ -506,9 +489,11 @@ async def enforce_confirm_totp(
user.last_login_at = datetime.now()
await db.commit()
# Issue a full session
token = await _create_full_session(db, user, request)
_set_session_cookie(response, token)
# 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)
return {"authenticated": True}

View File

@ -30,6 +30,7 @@ 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
@ -107,6 +108,7 @@ class ToggleMfaEnforceRequest(BaseModel):
class SystemConfigResponse(BaseModel):
allow_registration: bool
enforce_mfa_new_users: bool
allow_passwordless: bool = False
model_config = ConfigDict(from_attributes=True)
@ -115,6 +117,12 @@ 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
# ---------------------------------------------------------------------------

View File

@ -25,7 +25,13 @@ class CalendarResponse(BaseModel):
is_default: bool
is_system: bool
is_visible: bool
is_shared: bool = False
created_at: datetime
updated_at: datetime
owner_umbral_name: Optional[str] = None
my_permission: Optional[str] = None
my_can_add_others: bool = False
my_local_color: Optional[str] = None
member_count: int = 0
model_config = ConfigDict(from_attributes=True)

View File

@ -1,6 +1,6 @@
import json as _json
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from datetime import datetime
from typing import Literal, Optional
@ -17,6 +17,20 @@ class RecurrenceRule(BaseModel):
# monthly_date
day: Optional[int] = Field(None, ge=1, le=31)
@model_validator(mode="after")
def validate_required_fields(self):
"""Enforce required fields per rule type."""
if self.type == "every_n_days" and self.interval is None:
raise ValueError("every_n_days rule requires 'interval'")
if self.type == "weekly" and self.weekday is None:
raise ValueError("weekly rule requires 'weekday'")
if self.type == "monthly_nth_weekday":
if self.week is None or self.weekday is None:
raise ValueError("monthly_nth_weekday rule requires both 'week' and 'weekday'")
if self.type == "monthly_date" and self.day is None:
raise ValueError("monthly_date rule requires 'day'")
return self
def _coerce_recurrence_rule(v):
"""Accept None, dict, RecurrenceRule, or JSON/legacy strings gracefully."""
@ -47,10 +61,10 @@ class CalendarEventCreate(BaseModel):
end_datetime: datetime
all_day: bool = False
color: Optional[str] = Field(None, max_length=20)
location_id: Optional[int] = None
location_id: Optional[int] = Field(None, ge=1, le=2147483647)
recurrence_rule: Optional[RecurrenceRule] = None
is_starred: bool = False
calendar_id: Optional[int] = None # If None, server assigns default calendar
calendar_id: Optional[int] = Field(None, ge=1, le=2147483647)
@field_validator("recurrence_rule", mode="before")
@classmethod
@ -67,10 +81,10 @@ class CalendarEventUpdate(BaseModel):
end_datetime: Optional[datetime] = None
all_day: Optional[bool] = None
color: Optional[str] = Field(None, max_length=20)
location_id: Optional[int] = None
location_id: Optional[int] = Field(None, ge=1, le=2147483647)
recurrence_rule: Optional[RecurrenceRule] = None
is_starred: Optional[bool] = None
calendar_id: Optional[int] = None
calendar_id: Optional[int] = Field(None, ge=1, le=2147483647)
# Controls which occurrences an edit applies to; absent = non-recurring or whole-series
edit_scope: Optional[Literal["this", "this_and_future"]] = None

View File

@ -0,0 +1,43 @@
from typing import Annotated, Literal, Optional
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class EventInvitationCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(..., min_length=1, max_length=20)
class EventInvitationRespond(BaseModel):
model_config = ConfigDict(extra="forbid")
status: Literal["accepted", "tentative", "declined"]
class EventInvitationOverrideCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
status: Literal["accepted", "tentative", "declined"]
class UpdateDisplayCalendar(BaseModel):
model_config = ConfigDict(extra="forbid")
calendar_id: Annotated[int, Field(ge=1, le=2147483647)]
class UpdateCanModify(BaseModel):
model_config = ConfigDict(extra="forbid")
can_modify: bool
class EventInvitationResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
event_id: int
user_id: int
invited_by: Optional[int]
status: str
invited_at: datetime
responded_at: Optional[datetime]
invitee_name: Optional[str] = None
invitee_umbral_name: Optional[str] = None
can_modify: bool = False

View File

@ -1,8 +1,11 @@
from pydantic import BaseModel, ConfigDict, Field
import logging
from pydantic import BaseModel, ConfigDict, Field, model_validator
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"]
@ -30,18 +33,44 @@ 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

View File

@ -0,0 +1,43 @@
from typing import Annotated, Optional, Literal
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
MemberPermission = Literal["read_only", "create_modify"]
MemberStatus = Literal["pending", "accepted", "rejected"]
InviteResponse = Literal["accepted", "rejected"]
class ProjectMemberInvite(BaseModel):
model_config = ConfigDict(extra="forbid")
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=10)
permission: MemberPermission = "create_modify"
class ProjectMemberUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
permission: MemberPermission
class ProjectMemberRespond(BaseModel):
model_config = ConfigDict(extra="forbid")
response: InviteResponse
class ProjectMemberResponse(BaseModel):
id: int
project_id: int
user_id: int
invited_by: int
permission: str
status: str
source: str
user_name: str | None = None
inviter_name: str | None = None
created_at: datetime
updated_at: datetime
accepted_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)

View File

@ -2,6 +2,7 @@ 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"]
@ -30,6 +31,7 @@ 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):
@ -43,10 +45,12 @@ 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)

View File

@ -0,0 +1,31 @@
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, model_validator
from datetime import datetime
class TaskAssignmentCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
user_ids: list[Annotated[int, Field(ge=1, le=2147483647)]] = Field(min_length=1, max_length=20)
class TaskAssignmentResponse(BaseModel):
id: int
task_id: int
user_id: int
assigned_by: int
user_name: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
@model_validator(mode="before")
@classmethod
def resolve_user_name(cls, data): # type: ignore[override]
"""Populate user_name from eagerly loaded user relationship."""
if hasattr(data, "user") and data.user is not None and not getattr(data, "user_name", None):
# Build dict from ORM columns so new fields are auto-included
cols = {c.key: getattr(data, c.key) for c in data.__table__.columns}
cols["user_name"] = data.user.username
return cols
return data

View File

@ -27,7 +27,7 @@ class ReminderUpdate(BaseModel):
class ReminderSnooze(BaseModel):
model_config = ConfigDict(extra="forbid")
minutes: Literal[5, 10, 15]
minutes: int = Field(ge=1, le=1440)
client_now: Optional[datetime] = None

View File

@ -0,0 +1,76 @@
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing import Optional, Literal
from datetime import datetime
class InviteMemberRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
connection_id: int = Field(ge=1, le=2147483647)
permission: Literal["read_only", "create_modify", "full_access"]
can_add_others: bool = False
class RespondInviteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["accept", "reject"]
class UpdateMemberRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
permission: Optional[Literal["read_only", "create_modify", "full_access"]] = None
can_add_others: Optional[bool] = None
class UpdateLocalColorRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
local_color: Optional[str] = Field(None, max_length=20)
@field_validator("local_color")
@classmethod
def validate_color(cls, v: Optional[str]) -> Optional[str]:
if v is not None and not re.match(r"^#[0-9a-fA-F]{6}$", v):
raise ValueError("Color must be a hex color code (#RRGGBB)")
return v
class CalendarMemberResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
calendar_id: int
user_id: int
umbral_name: str
preferred_name: Optional[str] = None
permission: str
can_add_others: bool
local_color: Optional[str] = None
status: str
invited_at: datetime
accepted_at: Optional[datetime] = None
class CalendarInviteResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
calendar_id: int
calendar_name: str
calendar_color: str
owner_umbral_name: str
inviter_umbral_name: str
permission: str
invited_at: datetime
class LockStatusResponse(BaseModel):
locked: bool
locked_by_name: Optional[str] = None
expires_at: Optional[datetime] = None
is_permanent: bool = False
class SyncResponse(BaseModel):
events: list[dict]
member_changes: list[dict]
server_time: datetime
truncated: bool = False

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
from datetime import datetime
@ -11,7 +11,19 @@ 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

View File

@ -6,6 +6,8 @@ Password strategy:
- Legacy bcrypt hashes (migrated from PIN auth): accepted on login, immediately
rehashed to Argon2id on first successful use.
"""
import asyncio
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
@ -76,6 +78,28 @@ def verify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str
return valid, new_hash
# ---------------------------------------------------------------------------
# Async wrappers — run CPU-bound Argon2id ops in a thread pool (AC-2/S-01)
# ---------------------------------------------------------------------------
async def ahash_password(password: str) -> str:
"""Async wrapper for hash_password — runs Argon2id in executor."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, hash_password, password)
async def averify_password(password: str, hashed: str) -> bool:
"""Async wrapper for verify_password — runs Argon2id in executor."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, verify_password, password, hashed)
async def averify_password_with_upgrade(password: str, hashed: str) -> tuple[bool, str | None]:
"""Async wrapper for verify_password_with_upgrade — runs Argon2id in executor."""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, verify_password_with_upgrade, password, hashed)
# ---------------------------------------------------------------------------
# Session tokens
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,281 @@
"""
Calendar sharing service permission checks, lock management, disconnect cascade.
All functions accept an AsyncSession and do NOT commit callers manage transactions.
"""
import logging
from datetime import datetime, timedelta
from fastapi import HTTPException
from sqlalchemy import delete, literal_column, select, text, union_all, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.calendar import Calendar
from app.models.calendar_member import CalendarMember
from app.models.event_lock import EventLock
logger = logging.getLogger(__name__)
PERMISSION_RANK = {"read_only": 1, "create_modify": 2, "full_access": 3}
LOCK_DURATION_MINUTES = 5
async def get_accessible_calendar_ids(user_id: int, db: AsyncSession) -> list[int]:
"""Return all calendar IDs the user can access (owned + accepted shared memberships)."""
result = await db.execute(
select(Calendar.id).where(Calendar.user_id == user_id)
.union(
select(CalendarMember.calendar_id).where(
CalendarMember.user_id == user_id,
CalendarMember.status == "accepted",
)
)
)
return [r[0] for r in result.all()]
async def get_accessible_event_scope(
user_id: int, db: AsyncSession
) -> tuple[list[int], list[int]]:
"""
Returns (calendar_ids, invited_parent_event_ids) in a single DB round-trip.
calendar_ids: all calendars the user can access (owned + accepted shared).
invited_parent_event_ids: event IDs where the user has a non-declined invitation.
"""
from app.models.event_invitation import EventInvitation
result = await db.execute(
union_all(
select(literal_column("'c'").label("kind"), Calendar.id.label("val"))
.where(Calendar.user_id == user_id),
select(literal_column("'c'"), CalendarMember.calendar_id)
.where(
CalendarMember.user_id == user_id,
CalendarMember.status == "accepted",
),
select(literal_column("'i'"), EventInvitation.event_id)
.where(
EventInvitation.user_id == user_id,
EventInvitation.status != "declined",
),
)
)
cal_ids: list[int] = []
inv_ids: list[int] = []
for kind, val in result.all():
if kind == "c":
cal_ids.append(val)
else:
inv_ids.append(val)
return cal_ids, inv_ids
async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None:
"""
Returns "owner" if the user owns the calendar, the permission string
if they are an accepted member, or None if they have no access.
AW-5: Single query with LEFT JOIN instead of 2 sequential queries.
"""
result = await db.execute(
select(
Calendar.user_id,
CalendarMember.permission,
)
.outerjoin(
CalendarMember,
(CalendarMember.calendar_id == Calendar.id)
& (CalendarMember.user_id == user_id)
& (CalendarMember.status == "accepted"),
)
.where(Calendar.id == calendar_id)
)
row = result.one_or_none()
if not row:
return None
owner_id, member_permission = row.tuple()
if owner_id == user_id:
return "owner"
return member_permission
async def require_permission(
db: AsyncSession, calendar_id: int, user_id: int, min_level: str
) -> str:
"""
Raises 403 if the user lacks at least min_level permission.
Returns the actual permission string (or "owner").
"""
perm = await get_user_permission(db, calendar_id, user_id)
if perm is None:
raise HTTPException(status_code=404, detail="Calendar not found")
if perm == "owner":
return "owner"
if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0):
raise HTTPException(status_code=403, detail="Insufficient permission on this calendar")
return perm
async def acquire_lock(db: AsyncSession, event_id: int, user_id: int) -> EventLock:
"""
Atomic INSERT ON CONFLICT acquires a 5-minute lock on the event.
Only succeeds if no unexpired lock exists or the existing lock is held by the same user.
Permanent locks are never overwritten if the same user holds one, it is returned as-is.
Returns the lock or raises 423 Locked.
"""
# Check for existing permanent lock first
existing = await db.execute(
select(EventLock).where(EventLock.event_id == event_id)
)
existing_lock = existing.scalar_one_or_none()
if existing_lock and existing_lock.is_permanent:
if existing_lock.locked_by == user_id:
# Owner holds permanent lock — return it without downgrading
return existing_lock
raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner")
now = datetime.now()
expires = now + timedelta(minutes=LOCK_DURATION_MINUTES)
result = await db.execute(
text("""
INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent)
VALUES (:event_id, :user_id, :now, :expires, false)
ON CONFLICT (event_id)
DO UPDATE SET
locked_by = :user_id,
locked_at = :now,
expires_at = :expires,
is_permanent = false
WHERE event_locks.expires_at < :now
OR event_locks.locked_by = :user_id
RETURNING id, event_id, locked_by, locked_at, expires_at, is_permanent
"""),
{"event_id": event_id, "user_id": user_id, "now": now, "expires": expires},
)
row = result.first()
if not row:
raise HTTPException(status_code=423, detail="Event is locked by another user")
lock_result = await db.execute(
select(EventLock).where(EventLock.id == row.id)
)
return lock_result.scalar_one()
async def release_lock(db: AsyncSession, event_id: int, user_id: int) -> None:
"""Delete the lock only if held by this user."""
await db.execute(
delete(EventLock).where(
EventLock.event_id == event_id,
EventLock.locked_by == user_id,
)
)
async def check_lock_for_edit(
db: AsyncSession, event_id: int, user_id: int, calendar_id: int
) -> None:
"""
For shared calendars: verify no active lock by another user blocks this edit.
For personal (non-shared) calendars: no-op.
"""
cal_result = await db.execute(
select(Calendar.is_shared).where(Calendar.id == calendar_id)
)
is_shared = cal_result.scalar_one_or_none()
if not is_shared:
return
lock_result = await db.execute(
select(EventLock).where(EventLock.event_id == event_id)
)
lock = lock_result.scalar_one_or_none()
if not lock:
return
now = datetime.now()
if lock.is_permanent and lock.locked_by != user_id:
raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner")
if lock.locked_by != user_id and (lock.expires_at is None or lock.expires_at > now):
raise HTTPException(status_code=423, detail="Event is locked by another user")
async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int) -> None:
"""
When a connection is severed:
1. Delete CalendarMember rows where one user is a member of the other's calendars
2. Delete EventLock rows held by the disconnected user on affected calendars
3. Reset is_shared=False on calendars with no remaining members
"""
# Find calendars owned by each user
a_cal_ids_result = await db.execute(
select(Calendar.id).where(Calendar.user_id == user_a_id)
)
a_cal_ids = [row[0] for row in a_cal_ids_result.all()]
b_cal_ids_result = await db.execute(
select(Calendar.id).where(Calendar.user_id == user_b_id)
)
b_cal_ids = [row[0] for row in b_cal_ids_result.all()]
# Delete user_b's memberships on user_a's calendars + locks
if a_cal_ids:
await db.execute(
delete(CalendarMember).where(
CalendarMember.calendar_id.in_(a_cal_ids),
CalendarMember.user_id == user_b_id,
)
)
await db.execute(
text("""
DELETE FROM event_locks
WHERE locked_by = :user_id
AND event_id IN (
SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids)
)
"""),
{"user_id": user_b_id, "cal_ids": a_cal_ids},
)
# Delete user_a's memberships on user_b's calendars + locks
if b_cal_ids:
await db.execute(
delete(CalendarMember).where(
CalendarMember.calendar_id.in_(b_cal_ids),
CalendarMember.user_id == user_a_id,
)
)
await db.execute(
text("""
DELETE FROM event_locks
WHERE locked_by = :user_id
AND event_id IN (
SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids)
)
"""),
{"user_id": user_a_id, "cal_ids": b_cal_ids},
)
# Clean up event invitations between the two users
from app.services.event_invitation import cascade_event_invitations_on_disconnect
await cascade_event_invitations_on_disconnect(db, user_a_id, user_b_id)
# AC-5: Single aggregation query instead of N per-calendar checks
all_cal_ids = a_cal_ids + b_cal_ids
if all_cal_ids:
# Find which calendars still have members
has_members_result = await db.execute(
select(CalendarMember.calendar_id)
.where(CalendarMember.calendar_id.in_(all_cal_ids))
.group_by(CalendarMember.calendar_id)
)
cals_with_members = {row[0] for row in has_members_result.all()}
# Reset is_shared on calendars with no remaining members
empty_cal_ids = [cid for cid in all_cal_ids if cid not in cals_with_members]
if empty_cal_ids:
await db.execute(
update(Calendar)
.where(Calendar.id.in_(empty_cal_ids))
.values(is_shared=False)
)

View File

@ -9,6 +9,7 @@ from datetime import date as date_type
from types import SimpleNamespace
from typing import Optional
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.person import Person
@ -134,6 +135,25 @@ def create_person_from_connection(
)
async def sync_birthday_to_contacts(
db: AsyncSession,
user_id: int,
share_birthday: bool,
date_of_birth: Optional[date_type],
) -> None:
"""Sync user's DOB to all Person records where linked_user_id == user_id.
Caller passes resolved values no internal re-query."""
new_birthday = date_of_birth if share_birthday else None
result = await db.execute(
update(Person)
.where(Person.linked_user_id == user_id)
.values(birthday=new_birthday)
)
logger.info("sync_birthday_to_contacts user_id=%s updated %s person(s)", user_id, result.rowcount)
async def detach_umbral_contact(person: Person) -> None:
"""Convert an umbral contact back to a standard contact. Does NOT commit.

View File

@ -0,0 +1,421 @@
"""
Event invitation service send, respond, override, dismiss invitations.
All functions accept an AsyncSession and do NOT commit callers manage transactions.
"""
import logging
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.calendar import Calendar
from app.models.calendar_event import CalendarEvent
from app.models.event_invitation import EventInvitation, EventInvitationOverride
from app.models.user_connection import UserConnection
from app.models.settings import Settings
from app.models.user import User
from app.services.notification import create_notification
logger = logging.getLogger(__name__)
async def validate_connections(
db: AsyncSession, inviter_id: int, user_ids: list[int]
) -> None:
"""Verify bidirectional connections exist for all invitees. Raises 404 on failure."""
if not user_ids:
return
result = await db.execute(
select(UserConnection.connected_user_id).where(
UserConnection.user_id == inviter_id,
UserConnection.connected_user_id.in_(user_ids),
)
)
connected_ids = {r[0] for r in result.all()}
missing = set(user_ids) - connected_ids
if missing:
raise HTTPException(status_code=404, detail="One or more users not found in your connections")
async def send_event_invitations(
db: AsyncSession,
event_id: int,
user_ids: list[int],
invited_by: int,
) -> list[EventInvitation]:
"""
Bulk-insert invitations for an event. Skips self-invites and existing invitations.
Creates in-app notifications for each invitee.
"""
# Remove self from list
user_ids = [uid for uid in user_ids if uid != invited_by]
if not user_ids:
raise HTTPException(status_code=400, detail="Cannot invite yourself")
# Validate connections
await validate_connections(db, invited_by, user_ids)
# Check existing invitations to skip duplicates
existing_result = await db.execute(
select(EventInvitation.user_id).where(
EventInvitation.event_id == event_id,
EventInvitation.user_id.in_(user_ids),
)
)
existing_ids = {r[0] for r in existing_result.all()}
# Cap: max 20 invitations per event
count_result = await db.execute(
select(func.count(EventInvitation.id)).where(EventInvitation.event_id == event_id)
)
current_count = count_result.scalar_one()
new_ids = [uid for uid in user_ids if uid not in existing_ids]
if current_count + len(new_ids) > 20:
raise HTTPException(status_code=400, detail="Maximum 20 invitations per event")
if not new_ids:
return []
# Fetch event title for notifications
event_result = await db.execute(
select(CalendarEvent.title, CalendarEvent.start_datetime).where(
CalendarEvent.id == event_id
)
)
event_row = event_result.one_or_none()
event_title = event_row[0] if event_row else "an event"
event_start = event_row[1] if event_row else None
# Fetch inviter's name
inviter_settings = await db.execute(
select(Settings.preferred_name).where(Settings.user_id == invited_by)
)
inviter_name_row = inviter_settings.one_or_none()
inviter_name = inviter_name_row[0] if inviter_name_row and inviter_name_row[0] else "Someone"
invitations = []
for uid in new_ids:
inv = EventInvitation(
event_id=event_id,
user_id=uid,
invited_by=invited_by,
status="pending",
)
db.add(inv)
invitations.append(inv)
# Flush to populate invitation IDs before creating notifications
await db.flush()
for inv in invitations:
start_str = event_start.strftime("%b %d, %I:%M %p") if event_start else ""
await create_notification(
db=db,
user_id=inv.user_id,
type="event_invite",
title="Event Invitation",
message=f"{inviter_name} invited you to {event_title}" + (f" · {start_str}" if start_str else ""),
data={"event_id": event_id, "event_title": event_title, "invitation_id": inv.id},
source_type="event_invitation",
source_id=event_id,
)
return invitations
async def respond_to_invitation(
db: AsyncSession,
invitation_id: int,
user_id: int,
status: str,
) -> EventInvitation:
"""Update invitation status. Returns the updated invitation."""
result = await db.execute(
select(EventInvitation)
.options(selectinload(EventInvitation.event))
.where(
EventInvitation.id == invitation_id,
EventInvitation.user_id == user_id,
)
)
invitation = result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
# Build response data before modifying
event_title = invitation.event.title
old_status = invitation.status
invitation.status = status
invitation.responded_at = datetime.now()
# Clear can_modify on decline (F-02: prevent silent re-grant)
if status == "declined":
invitation.can_modify = False
# Auto-assign display calendar on accept/tentative (atomic: only if not already set)
if status in ("accepted", "tentative"):
default_cal = await db.execute(
select(Calendar.id).where(
Calendar.user_id == user_id,
Calendar.is_default == True,
).limit(1)
)
default_cal_id = default_cal.scalar_one_or_none()
if default_cal_id and invitation.display_calendar_id is None:
# Atomic: only set if still NULL (race-safe)
await db.execute(
update(EventInvitation)
.where(
EventInvitation.id == invitation_id,
EventInvitation.display_calendar_id == None,
)
.values(display_calendar_id=default_cal_id)
)
invitation.display_calendar_id = default_cal_id
# Notify the inviter only if status actually changed (prevents duplicate notifications)
if invitation.invited_by and old_status != status:
status_label = {"accepted": "Going", "tentative": "Tentative", "declined": "Declined"}
# Fetch responder name
responder_settings = await db.execute(
select(Settings.preferred_name).where(Settings.user_id == user_id)
)
responder_row = responder_settings.one_or_none()
responder_name = responder_row[0] if responder_row and responder_row[0] else "Someone"
await create_notification(
db=db,
user_id=invitation.invited_by,
type="event_invite_response",
title="Event RSVP",
message=f"{responder_name} is {status_label.get(status, status)} for {event_title}",
data={"event_id": invitation.event_id, "status": status},
source_type="event_invitation",
source_id=invitation.event_id,
)
return invitation
async def override_occurrence_status(
db: AsyncSession,
invitation_id: int,
occurrence_id: int,
user_id: int,
status: str,
) -> EventInvitationOverride:
"""Create or update a per-occurrence status override."""
# Verify invitation belongs to user
inv_result = await db.execute(
select(EventInvitation).where(
EventInvitation.id == invitation_id,
EventInvitation.user_id == user_id,
)
)
invitation = inv_result.scalar_one_or_none()
if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found")
if invitation.status not in ("accepted", "tentative"):
raise HTTPException(status_code=400, detail="Must accept or tentatively accept the invitation first")
# Verify occurrence belongs to the invited event's series
occ_result = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == occurrence_id)
)
occurrence = occ_result.scalar_one_or_none()
if not occurrence:
raise HTTPException(status_code=404, detail="Occurrence not found")
# Occurrence must be the event itself OR a child of the invited event
if occurrence.id != invitation.event_id and occurrence.parent_event_id != invitation.event_id:
raise HTTPException(status_code=400, detail="Occurrence does not belong to this event series")
# Upsert override
existing = await db.execute(
select(EventInvitationOverride).where(
EventInvitationOverride.invitation_id == invitation_id,
EventInvitationOverride.occurrence_id == occurrence_id,
)
)
override = existing.scalar_one_or_none()
if override:
override.status = status
override.responded_at = datetime.now()
else:
override = EventInvitationOverride(
invitation_id=invitation_id,
occurrence_id=occurrence_id,
status=status,
responded_at=datetime.now(),
)
db.add(override)
return override
async def dismiss_invitation(
db: AsyncSession,
invitation_id: int,
user_id: int,
) -> None:
"""Delete an invitation (invitee leaving or owner revoking)."""
result = await db.execute(
delete(EventInvitation).where(
EventInvitation.id == invitation_id,
EventInvitation.user_id == user_id,
)
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Invitation not found")
async def dismiss_invitation_by_owner(
db: AsyncSession,
invitation_id: int,
) -> None:
"""Delete an invitation by the event owner (revoking)."""
result = await db.execute(
delete(EventInvitation).where(EventInvitation.id == invitation_id)
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Invitation not found")
async def get_event_invitations(
db: AsyncSession,
event_id: int,
) -> list[dict]:
"""Get all invitations for an event with invitee names."""
result = await db.execute(
select(
EventInvitation,
Settings.preferred_name,
User.umbral_name,
)
.join(User, EventInvitation.user_id == User.id)
.outerjoin(Settings, Settings.user_id == User.id)
.where(EventInvitation.event_id == event_id)
.order_by(EventInvitation.invited_at.asc())
)
rows = result.all()
return [
{
"id": inv.id,
"event_id": inv.event_id,
"user_id": inv.user_id,
"invited_by": inv.invited_by,
"status": inv.status,
"invited_at": inv.invited_at,
"responded_at": inv.responded_at,
"invitee_name": preferred_name or umbral_name or "Unknown",
"invitee_umbral_name": umbral_name or "Unknown",
"can_modify": inv.can_modify,
}
for inv, preferred_name, umbral_name in rows
]
async def get_invited_event_ids(
db: AsyncSession,
user_id: int,
) -> list[int]:
"""Return event IDs where user has a non-declined invitation."""
result = await db.execute(
select(EventInvitation.event_id).where(
EventInvitation.user_id == user_id,
EventInvitation.status != "declined",
)
)
return [r[0] for r in result.all()]
async def get_pending_invitations(
db: AsyncSession,
user_id: int,
) -> list[dict]:
"""Return pending invitations for the current user."""
result = await db.execute(
select(
EventInvitation,
CalendarEvent.title,
CalendarEvent.start_datetime,
Settings.preferred_name,
)
.join(CalendarEvent, EventInvitation.event_id == CalendarEvent.id)
.outerjoin(
User, EventInvitation.invited_by == User.id
)
.outerjoin(
Settings, Settings.user_id == User.id
)
.where(
EventInvitation.user_id == user_id,
EventInvitation.status == "pending",
)
.order_by(EventInvitation.invited_at.desc())
)
rows = result.all()
return [
{
"id": inv.id,
"event_id": inv.event_id,
"event_title": title,
"event_start": start_dt,
"invited_by_name": inviter_name or "Someone",
"invited_at": inv.invited_at,
"status": inv.status,
}
for inv, title, start_dt, inviter_name in rows
]
async def get_invitation_overrides_for_user(
db: AsyncSession,
user_id: int,
event_ids: list[int],
) -> dict[int, str]:
"""
For a list of occurrence event IDs, return a map of occurrence_id -> override status.
Used to annotate event listings with per-occurrence invitation status.
"""
if not event_ids:
return {}
result = await db.execute(
select(
EventInvitationOverride.occurrence_id,
EventInvitationOverride.status,
)
.join(EventInvitation, EventInvitationOverride.invitation_id == EventInvitation.id)
.where(
EventInvitation.user_id == user_id,
EventInvitationOverride.occurrence_id.in_(event_ids),
)
)
return {r[0]: r[1] for r in result.all()}
async def cascade_event_invitations_on_disconnect(
db: AsyncSession,
user_a_id: int,
user_b_id: int,
) -> None:
"""Delete event invitations between two users when connection is severed."""
# Delete invitations where A invited B
await db.execute(
delete(EventInvitation).where(
EventInvitation.invited_by == user_a_id,
EventInvitation.user_id == user_b_id,
)
)
# Delete invitations where B invited A
await db.execute(
delete(EventInvitation).where(
EventInvitation.invited_by == user_b_id,
EventInvitation.user_id == user_a_id,
)
)

View File

@ -0,0 +1,240 @@
"""
Passkey (WebAuthn/FIDO2) service.
Handles challenge token creation/verification (itsdangerous + nonce replay protection)
and wraps py_webauthn library calls for registration and authentication ceremonies.
"""
import base64
import json
import logging
import secrets
import time
import threading
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
options_to_json,
)
from webauthn.helpers.structs import (
PublicKeyCredentialDescriptor,
AuthenticatorSelectionCriteria,
AuthenticatorTransport,
ResidentKeyRequirement,
UserVerificationRequirement,
AttestationConveyancePreference,
)
from webauthn.helpers import (
bytes_to_base64url,
base64url_to_bytes,
parse_registration_credential_json,
parse_authentication_credential_json,
)
from app.config import settings as app_settings
# ---------------------------------------------------------------------------
# Credential JSON helpers
# ---------------------------------------------------------------------------
def extract_credential_raw_id(credential_json: str) -> str | None:
"""Extract the base64url-encoded rawId from a WebAuthn credential JSON string.
Returns None if parsing fails.
"""
try:
cred_data = json.loads(credential_json)
return cred_data.get("rawId") or cred_data.get("id") or None
except (json.JSONDecodeError, KeyError, TypeError):
return None
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Challenge token management (itsdangerous + nonce replay protection V-01)
# ---------------------------------------------------------------------------
_challenge_serializer = URLSafeTimedSerializer(
secret_key=app_settings.SECRET_KEY,
salt="webauthn-challenge-v1",
)
# Thread-safe nonce cache for single-use enforcement.
# Keys: nonce string, Values: expiry timestamp.
# NOTE: This is process-local. If scaling to multiple uvicorn workers,
# move nonce tracking to Redis or a DB table with unique constraint.
# Current deployment: single worker (Dockerfile --workers 1).
_used_nonces: dict[str, float] = {}
_nonce_lock = threading.Lock()
def create_challenge_token(challenge: bytes, user_id: int | None = None) -> str:
"""Sign challenge + nonce + optional user_id. Returns opaque token string."""
nonce = secrets.token_urlsafe(16)
payload = {
"ch": base64.b64encode(challenge).decode(),
"n": nonce,
}
if user_id is not None:
payload["uid"] = user_id
return _challenge_serializer.dumps(payload)
def verify_challenge_token(token: str, expected_user_id: int | None = None) -> bytes | None:
"""Verify token (TTL from config), enforce single-use via nonce.
If expected_user_id provided, cross-check user binding (for registration).
Returns challenge bytes or None on failure.
"""
try:
data = _challenge_serializer.loads(
token, max_age=app_settings.WEBAUTHN_CHALLENGE_TTL
)
except (BadSignature, SignatureExpired):
return None
nonce = data.get("n")
if not nonce:
return None
now = time.time()
with _nonce_lock:
# Lazy cleanup of expired nonces
expired = [k for k, v in _used_nonces.items() if v <= now]
for k in expired:
del _used_nonces[k]
# Check for replay
if nonce in _used_nonces:
return None
# Mark nonce as used
_used_nonces[nonce] = now + app_settings.WEBAUTHN_CHALLENGE_TTL
# Cross-check user binding for registration tokens
if expected_user_id is not None:
if data.get("uid") != expected_user_id:
return None
return base64.b64decode(data["ch"])
# ---------------------------------------------------------------------------
# py_webauthn wrappers
# All synchronous — ECDSA P-256 verification is ~0.1ms, faster than executor overhead.
# ---------------------------------------------------------------------------
def build_registration_options(
user_id: int,
username: str,
existing_credential_ids: list[bytes],
) -> tuple[str, bytes]:
"""Generate WebAuthn registration options.
Returns (options_json_str, challenge_bytes).
"""
exclude_credentials = [
PublicKeyCredentialDescriptor(id=cid)
for cid in existing_credential_ids
]
options = generate_registration_options(
rp_id=app_settings.WEBAUTHN_RP_ID,
rp_name=app_settings.WEBAUTHN_RP_NAME,
user_id=str(user_id).encode(),
user_name=username,
attestation=AttestationConveyancePreference.NONE,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.PREFERRED,
),
exclude_credentials=exclude_credentials,
timeout=60000,
)
options_json = options_to_json(options)
return options_json, options.challenge
def verify_registration(
credential_json: str,
challenge: bytes,
) -> "VerifiedRegistration":
"""Verify a registration response from the browser.
Returns VerifiedRegistration on success, raises on failure.
"""
credential = parse_registration_credential_json(credential_json)
return verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=app_settings.WEBAUTHN_RP_ID,
expected_origin=app_settings.WEBAUTHN_ORIGIN,
require_user_verification=False,
)
def build_authentication_options(
credential_ids_and_transports: list[tuple[bytes, list[str] | None]] | None = None,
) -> tuple[str, bytes]:
"""Generate WebAuthn authentication options.
If credential_ids_and_transports provided, includes allowCredentials.
Otherwise, allows discoverable credential flow.
Returns (options_json_str, challenge_bytes).
"""
allow_credentials = None
if credential_ids_and_transports:
allow_credentials = []
for cid, transports in credential_ids_and_transports:
transport_list = None
if transports:
transport_list = [
AuthenticatorTransport(t)
for t in transports
if t in [e.value for e in AuthenticatorTransport]
]
allow_credentials.append(
PublicKeyCredentialDescriptor(
id=cid,
transports=transport_list or None,
)
)
options = generate_authentication_options(
rp_id=app_settings.WEBAUTHN_RP_ID,
allow_credentials=allow_credentials,
user_verification=UserVerificationRequirement.PREFERRED,
timeout=60000,
)
options_json = options_to_json(options)
return options_json, options.challenge
def verify_authentication(
credential_json: str,
challenge: bytes,
credential_public_key: bytes,
credential_current_sign_count: int,
) -> "VerifiedAuthentication":
"""Verify an authentication response from the browser.
Returns VerifiedAuthentication on success, raises on failure.
Sign count anomalies are NOT hard-failed caller should log and continue.
"""
credential = parse_authentication_credential_json(credential_json)
return verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=app_settings.WEBAUTHN_RP_ID,
expected_origin=app_settings.WEBAUTHN_ORIGIN,
credential_public_key=credential_public_key,
credential_current_sign_count=credential_current_sign_count,
require_user_verification=False,
)

View File

@ -0,0 +1,269 @@
"""
Project sharing service permission checks, auto-membership, disconnect cascade.
All functions accept an AsyncSession and do NOT commit callers manage transactions.
"""
import logging
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.project import Project
from app.models.project_member import ProjectMember
from app.models.project_task import ProjectTask
from app.models.project_task_assignment import ProjectTaskAssignment
from app.models.user_connection import UserConnection
logger = logging.getLogger(__name__)
PERMISSION_RANK = {"read_only": 1, "create_modify": 2}
# Fields task assignees (from assignment, not project membership) may edit
ASSIGNEE_ALLOWED_FIELDS = {"title", "description", "status", "priority", "due_date"}
async def get_project_permission(
db: AsyncSession, project_id: int, user_id: int
) -> str | None:
"""
Returns 'owner', 'create_modify', 'read_only', or None.
Single query with LEFT JOIN (mirrors calendar_sharing pattern).
"""
result = await db.execute(
select(
Project.user_id,
ProjectMember.permission,
)
.outerjoin(
ProjectMember,
(ProjectMember.project_id == Project.id)
& (ProjectMember.user_id == user_id)
& (ProjectMember.status == "accepted"),
)
.where(Project.id == project_id)
)
row = result.one_or_none()
if not row:
return None
owner_id, member_permission = row.tuple()
if owner_id == user_id:
return "owner"
return member_permission
async def require_project_permission(
db: AsyncSession, project_id: int, user_id: int, min_level: str
) -> str:
"""
Raises 404 if project doesn't exist or user has no access.
Raises 403 if user has insufficient permission.
Returns the actual permission string (or 'owner').
"""
perm = await get_project_permission(db, project_id, user_id)
if perm is None:
raise HTTPException(status_code=404, detail="Project not found")
if perm == "owner":
return "owner"
if min_level == "owner":
raise HTTPException(status_code=403, detail="Only the project owner can perform this action")
if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0):
raise HTTPException(status_code=403, detail="Insufficient permission on this project")
return perm
async def get_accessible_project_ids(db: AsyncSession, user_id: int) -> set[int]:
"""Returns owned + accepted membership project IDs."""
result = await db.execute(
select(Project.id).where(Project.user_id == user_id)
.union(
select(ProjectMember.project_id).where(
ProjectMember.user_id == user_id,
ProjectMember.status == "accepted",
)
)
)
return {r[0] for r in result.all()}
async def validate_project_connections(
db: AsyncSession, owner_id: int, user_ids: list[int]
) -> None:
"""Validates all target users are active connections of the owner. Raises 400 on failure."""
if not user_ids:
return
result = await db.execute(
select(UserConnection.connected_user_id).where(
UserConnection.user_id == owner_id,
UserConnection.connected_user_id.in_(user_ids),
)
)
connected = {r[0] for r in result.all()}
missing = set(user_ids) - connected
if missing:
raise HTTPException(
status_code=400,
detail=f"Users {sorted(missing)} are not your connections",
)
async def get_effective_task_permission(
db: AsyncSession, user_id: int, task_id: int, project_id: int
) -> tuple[str | None, str | None]:
"""
Returns (effective_permission, project_level_permission) for a specific task.
1. Get project-level permission (owner/create_modify/read_only)
2. If user is assigned to THIS task max(project_perm, create_modify)
3. If task has parent and user assigned to PARENT same as above
4. Return (effective, project_level)
"""
project_perm = await get_project_permission(db, project_id, user_id)
if project_perm is None:
return None, None
if project_perm == "owner":
return "owner", "owner"
# Check direct assignment on this task
task_result = await db.execute(
select(ProjectTask.parent_task_id).where(ProjectTask.id == task_id)
)
task_row = task_result.one_or_none()
if not task_row:
return project_perm, project_perm
parent_task_id = task_row[0]
# Check assignment on this task or its parent
check_task_ids = [task_id]
if parent_task_id is not None:
check_task_ids.append(parent_task_id)
assignment_result = await db.execute(
select(ProjectTaskAssignment.id).where(
ProjectTaskAssignment.task_id.in_(check_task_ids),
ProjectTaskAssignment.user_id == user_id,
).limit(1)
)
if assignment_result.scalar_one_or_none() is not None:
# Assignment grants at least create_modify
if PERMISSION_RANK.get(project_perm, 0) >= PERMISSION_RANK["create_modify"]:
return project_perm, project_perm
return "create_modify", project_perm
return project_perm, project_perm
async def ensure_auto_membership(
db: AsyncSession, project_id: int, user_id: int, invited_by: int
) -> None:
"""
When assigning a user to a task, ensure they have a ProjectMember row.
If none exists, create one with read_only + auto_assigned + accepted (no invite flow).
"""
existing = await db.execute(
select(ProjectMember.id).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
)
)
if existing.scalar_one_or_none() is not None:
return
member = ProjectMember(
project_id=project_id,
user_id=user_id,
invited_by=invited_by,
permission="read_only",
status="accepted",
source="auto_assigned",
accepted_at=datetime.now(),
)
db.add(member)
async def cleanup_auto_membership(
db: AsyncSession, project_id: int, user_id: int
) -> None:
"""
After removing a task assignment, check if user has any remaining assignments
in this project. If not and membership is auto_assigned, remove it.
"""
remaining = await db.execute(
select(ProjectTaskAssignment.id)
.join(ProjectTask, ProjectTaskAssignment.task_id == ProjectTask.id)
.where(
ProjectTask.project_id == project_id,
ProjectTaskAssignment.user_id == user_id,
)
.limit(1)
)
if remaining.scalar_one_or_none() is not None:
return # Still has assignments
# Remove auto_assigned membership only
await db.execute(
delete(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
ProjectMember.source == "auto_assigned",
)
)
async def cascade_projects_on_disconnect(
db: AsyncSession, user_a_id: int, user_b_id: int
) -> None:
"""
When a connection is severed:
1. Find all ProjectMember rows where one user is a member of the other's projects
2. Find all ProjectTaskAssignment rows for those memberships
3. Remove assignments, then remove memberships
"""
# Single query: find projects owned by each user
result = await db.execute(
select(Project.id, Project.user_id).where(
Project.user_id.in_([user_a_id, user_b_id])
)
)
a_proj_ids: list[int] = []
b_proj_ids: list[int] = []
for proj_id, owner_id in result.all():
if owner_id == user_a_id:
a_proj_ids.append(proj_id)
else:
b_proj_ids.append(proj_id)
# Remove user_b's assignments + memberships on user_a's projects
if a_proj_ids:
await db.execute(
delete(ProjectTaskAssignment).where(
ProjectTaskAssignment.user_id == user_b_id,
ProjectTaskAssignment.task_id.in_(
select(ProjectTask.id).where(ProjectTask.project_id.in_(a_proj_ids))
),
)
)
await db.execute(
delete(ProjectMember).where(
ProjectMember.project_id.in_(a_proj_ids),
ProjectMember.user_id == user_b_id,
)
)
# Remove user_a's assignments + memberships on user_b's projects
if b_proj_ids:
await db.execute(
delete(ProjectTaskAssignment).where(
ProjectTaskAssignment.user_id == user_a_id,
ProjectTaskAssignment.task_id.in_(
select(ProjectTask.id).where(ProjectTask.project_id.in_(b_proj_ids))
),
)
)
await db.execute(
delete(ProjectMember).where(
ProjectMember.project_id.in_(b_proj_ids),
ProjectMember.user_id == user_a_id,
)
)

View File

@ -10,6 +10,9 @@ from typing import Optional
from app.models.calendar_event import CalendarEvent
# Hard cap: never generate more than 730 child events regardless of horizon_days.
MAX_OCCURRENCES = 730
def _nth_weekday_of_month(year: int, month: int, weekday: int, week: int) -> Optional[datetime]:
"""
@ -99,8 +102,12 @@ def generate_occurrences(
interval: int = _rule_int(rule, "interval", 1)
if interval < 1:
interval = 1
# Adaptive horizon: cap daily-ish events (interval < 7) to 90 days
effective_horizon = horizon if interval >= 7 else min(horizon, parent_start + timedelta(days=90))
current = parent_start + timedelta(days=interval)
while current < horizon:
while current < effective_horizon:
if len(occurrences) >= MAX_OCCURRENCES:
break
occurrences.append(_make_child(current))
current += timedelta(days=interval)
@ -112,6 +119,8 @@ def generate_occurrences(
days_ahead = 7
current = parent_start + timedelta(days=days_ahead)
while current < horizon:
if len(occurrences) >= MAX_OCCURRENCES:
break
occurrences.append(_make_child(current))
current += timedelta(weeks=1)
@ -126,6 +135,8 @@ def generate_occurrences(
month = 1
year += 1
while True:
if len(occurrences) >= MAX_OCCURRENCES:
break
target = _nth_weekday_of_month(year, month, weekday, week)
if target is None:
# Skip months where Nth weekday doesn't exist
@ -157,6 +168,8 @@ def generate_occurrences(
month = 1
year += 1
while True:
if len(occurrences) >= MAX_OCCURRENCES:
break
# Some months don't have day 29-31
try:
occ_start = datetime(

View File

@ -0,0 +1,121 @@
"""
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

13
backend/entrypoint.sh Normal file
View File

@ -0,0 +1,13 @@
#!/bin/sh
set -e
echo "Running database migrations..."
alembic upgrade head
echo "Starting uvicorn..."
exec uvicorn app.main:app \
--host 0.0.0.0 \
--port 8000 \
--no-server-header \
--proxy-headers \
--forwarded-allow-ips '*'

View File

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

View File

@ -1,9 +0,0 @@
#!/bin/bash
# Run database migrations
echo "Running database migrations..."
alembic upgrade head
# Start the FastAPI application
echo "Starting FastAPI application..."
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

View File

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

25
frontend/.dockerignore Normal file
View File

@ -0,0 +1,25 @@
# Dependencies — rebuilt inside the container from lockfile
node_modules
# Build output — rebuilt inside the container
dist
# Version control
.git
.gitignore
# Environment files
.env
.env.*
# IDE
.vscode
.idea
# Documentation
*.md
LICENSE
# Docker files
Dockerfile
docker-compose*.yaml

View File

@ -1,13 +1,13 @@
# Build stage
FROM node:20-alpine AS build
FROM node:20.18-alpine AS build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Install dependencies from lockfile (DW-3)
RUN npm ci
# Copy source files
COPY . .
@ -16,7 +16,7 @@ COPY . .
RUN npm run build
# Production stage — unprivileged nginx (runs as non-root, listens on 8080)
FROM nginxinc/nginx-unprivileged:alpine
FROM nginxinc/nginx-unprivileged:1.27-alpine
# Copy built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html

View File

@ -3,7 +3,33 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#09090b" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>UMBRA</title>
<!-- Static style tag — survives Vite's head cleanup (unlike dynamically created elements).
The inline script below populates it with accent color from localStorage cache. -->
<style id="umbra-accent"></style>
<script>
// Populate the static style tag with cached accent color before first paint.
// Uses textContent (safe from XSS) and !important (beats @layer base defaults).
(function() {
var h = '187', s = '85.7%', l = '53.3%';
try {
var c = localStorage.getItem('umbra-accent-color');
if (c) {
var p = JSON.parse(c);
if (p.h && /^\d+$/.test(p.h)) h = p.h;
if (p.s && /^\d+\.?\d*%$/.test(p.s)) s = p.s;
if (p.l && /^\d+\.?\d*%$/.test(p.l)) l = p.l;
}
} catch(e) {}
document.getElementById('umbra-accent').textContent =
':root{--accent-h:' + h + ' !important;--accent-s:' + s + ' !important;--accent-l:' + l + ' !important}';
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet" />

View File

@ -4,9 +4,16 @@ limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m;
# Admin API generous for legitimate use but still guards against scraping/brute-force
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m;
# Calendar sharing endpoints
limit_req_zone $binary_remote_addr zone=cal_invite_limit:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=cal_sync_limit:10m rate=15r/m;
# Connection endpoints prevent search enumeration and request spam
limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
# Event creation recurrence amplification means 1 POST = up to 90-365 child rows
limit_req_zone $binary_remote_addr zone=event_create_limit:10m rate=30r/m;
# Health endpoint lightweight but rate-limited for resilience
limit_req_zone $binary_remote_addr zone=health_limit:1m rate=30r/m;
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
map $http_x_forwarded_proto $forwarded_proto {
@ -24,13 +31,14 @@ server {
# Suppress nginx version in Server header
server_tokens off;
# ── Real client IP restoration (PT-01) ────────────────────────────
# ── Real client IP restoration (PT-01 / F-03) ─────────────────────
# 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.
# 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;
# 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;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
@ -38,7 +46,7 @@ server {
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json image/svg+xml;
# Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04)
location ~ /\.(?!well-known) {
@ -78,6 +86,36 @@ 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;
@ -99,6 +137,19 @@ server {
include /etc/nginx/proxy-params.conf;
}
# Calendar invite rate-limited to prevent invite spam
location ~ /api/shared-calendars/\d+/invite {
limit_req zone=cal_invite_limit burst=3 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Calendar sync rate-limited to prevent excessive polling
location /api/shared-calendars/sync {
limit_req zone=cal_sync_limit burst=5 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Admin API rate-limited separately from general /api traffic
location /api/admin/ {
limit_req zone=admin_limit burst=10 nodelay;
@ -106,28 +157,28 @@ server {
include /etc/nginx/proxy-params.conf;
}
# API proxy
# Event creation rate-limited to prevent DB flooding via recurrence amplification.
# Note: exact match applies to GET+POST; 30r/m with burst=10 is generous enough
# for polling (2r/m) and won't affect reads even with multiple tabs.
location = /api/events {
limit_req zone=event_create_limit burst=10 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Health endpoint proxied to backend for external uptime monitoring
location = /health {
limit_req zone=health_limit burst=5 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# API proxy (catch-all for non-rate-limited endpoints)
location /api {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_cache_bypass $http_upgrade;
# PT-L01: Prevent browser caching of authenticated API responses
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
# Security headers (must be repeated nginx add_header in a location block
# overrides server-level add_header directives, so all headers must be explicit)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
include /etc/nginx/proxy-params.conf;
}
# SPA fallback - serve index.html for all routes
@ -142,7 +193,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';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
@ -150,8 +201,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';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# PT-I03: Restrict unnecessary browser APIs
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;
}

View File

@ -16,6 +16,7 @@
"@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",
@ -1348,6 +1349,22 @@
"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",

View File

@ -17,6 +17,7 @@
"@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",

View File

@ -4,3 +4,13 @@ proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
# Security headers (repeated per location — nginx add_header in a location block
# overrides server-level directives, so all headers must be explicit)
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;

View File

@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 324 B

View File

@ -3,28 +3,29 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import LockScreen from '@/components/auth/LockScreen';
import AppLayout from '@/components/layout/AppLayout';
import DashboardPage from '@/components/dashboard/DashboardPage';
import TodosPage from '@/components/todos/TodosPage';
import CalendarPage from '@/components/calendar/CalendarPage';
import RemindersPage from '@/components/reminders/RemindersPage';
import ProjectsPage from '@/components/projects/ProjectsPage';
import ProjectDetail from '@/components/projects/ProjectDetail';
import PeoplePage from '@/components/people/PeoplePage';
import LocationsPage from '@/components/locations/LocationsPage';
import SettingsPage from '@/components/settings/SettingsPage';
import NotificationsPage from '@/components/notifications/NotificationsPage';
// AS-2: Lazy-load all route components to reduce initial bundle parse time
const DashboardPage = lazy(() => import('@/components/dashboard/DashboardPage'));
const TodosPage = lazy(() => import('@/components/todos/TodosPage'));
const CalendarPage = lazy(() => import('@/components/calendar/CalendarPage'));
const RemindersPage = lazy(() => import('@/components/reminders/RemindersPage'));
const ProjectsPage = lazy(() => import('@/components/projects/ProjectsPage'));
const ProjectDetail = lazy(() => import('@/components/projects/ProjectDetail'));
const PeoplePage = lazy(() => import('@/components/people/PeoplePage'));
const LocationsPage = lazy(() => import('@/components/locations/LocationsPage'));
const SettingsPage = lazy(() => import('@/components/settings/SettingsPage'));
const NotificationsPage = lazy(() => import('@/components/notifications/NotificationsPage'));
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
const RouteFallback = () => (
<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>
);
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
return <div className="h-dvh bg-background" />;
}
if (!authStatus?.authenticated) {
@ -38,11 +39,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
return <div className="h-dvh bg-background" />;
}
if (!authStatus?.authenticated || authStatus?.role !== 'admin') {
@ -65,21 +62,21 @@ function App() {
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="todos" element={<TodosPage />} />
<Route path="calendar" element={<CalendarPage />} />
<Route path="reminders" element={<RemindersPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="people" element={<PeoplePage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="dashboard" element={<Suspense fallback={<RouteFallback />}><DashboardPage /></Suspense>} />
<Route path="todos" element={<Suspense fallback={<RouteFallback />}><TodosPage /></Suspense>} />
<Route path="calendar" element={<Suspense fallback={<RouteFallback />}><CalendarPage /></Suspense>} />
<Route path="reminders" element={<Suspense fallback={<RouteFallback />}><RemindersPage /></Suspense>} />
<Route path="projects" element={<Suspense fallback={<RouteFallback />}><ProjectsPage /></Suspense>} />
<Route path="projects/:id" element={<Suspense fallback={<RouteFallback />}><ProjectDetail /></Suspense>} />
<Route path="people" element={<Suspense fallback={<RouteFallback />}><PeoplePage /></Suspense>} />
<Route path="locations" element={<Suspense fallback={<RouteFallback />}><LocationsPage /></Suspense>} />
<Route path="notifications" element={<Suspense fallback={<RouteFallback />}><NotificationsPage /></Suspense>} />
<Route path="settings" element={<Suspense fallback={<RouteFallback />}><SettingsPage /></Suspense>} />
<Route
path="admin/*"
element={
<AdminRoute>
<Suspense fallback={<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>}>
<Suspense fallback={<RouteFallback />}>
<AdminPortal />
</Suspense>
</AdminRoute>

View File

@ -23,9 +23,9 @@ export default function AdminDashboardPage() {
dashboard ? dashboard.total_users - dashboard.active_users : null;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
{/* Stats grid */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-5">
<div className="grid gap-2.5 grid-cols-2 md:grid-cols-3 lg:grid-cols-5">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
@ -94,10 +94,10 @@ export default function AdminDashboardPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Username
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
When
</th>
</tr>
@ -111,8 +111,8 @@ export default function AdminDashboardPage() {
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)}
>
<td className="px-5 py-2.5 font-medium">{entry.username}</td>
<td className="px-5 py-2.5 text-xs text-muted-foreground">
<td className="px-3 lg:px-5 py-2.5 font-medium">{entry.username}</td>
<td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground">
{getRelativeTime(entry.last_login_at)}
</td>
</tr>
@ -142,16 +142,16 @@ export default function AdminDashboardPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Action
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
Actor
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
Target
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
When
</th>
</tr>
@ -165,7 +165,7 @@ export default function AdminDashboardPage() {
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)}
>
<td className="px-5 py-2.5">
<td className="px-3 lg:px-5 py-2.5">
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
@ -175,15 +175,15 @@ export default function AdminDashboardPage() {
{entry.action}
</span>
</td>
<td className="px-5 py-2.5 text-xs font-medium">
<td className="px-3 lg:px-5 py-2.5 text-xs font-medium hidden sm:table-cell">
{entry.actor_username ?? (
<span className="text-muted-foreground italic">system</span>
)}
</td>
<td className="px-5 py-2.5 text-xs text-muted-foreground">
<td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground hidden sm:table-cell">
{entry.target_username ?? '—'}
</td>
<td className="px-5 py-2.5 text-xs text-muted-foreground whitespace-nowrap">
<td className="px-3 lg:px-5 py-2.5 text-xs text-muted-foreground whitespace-nowrap">
{getRelativeTime(entry.created_at)}
</td>
</tr>

View File

@ -17,32 +17,37 @@ export default function AdminPortal() {
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Portal header with tab navigation */}
<div className="shrink-0 border-b bg-card">
<div className="px-6 h-16 flex items-center gap-4">
<div className="flex items-center gap-2 mr-6">
<div className="shrink-0 border-b bg-card overflow-hidden">
<div className="px-3 md:px-6 h-14 md:h-16 flex items-center gap-2 md:gap-4">
<div className="flex items-center gap-2 shrink-0 md:mr-6">
<div className="p-1.5 rounded-md bg-red-500/10">
<ShieldCheck className="h-5 w-5 text-red-400" />
</div>
<h1 className="font-heading text-2xl font-bold tracking-tight">Admin Portal</h1>
<h1 className="font-heading text-base md:text-2xl font-bold tracking-tight">
<span className="hidden md:inline">Admin Portal</span>
<span className="md:hidden">Admin</span>
</h1>
</div>
{/* Horizontal tab navigation */}
<nav className="flex items-center gap-1 h-full">
{/* Horizontal tab navigation — evenly spaced on mobile, left-aligned on desktop */}
<nav className="flex items-center justify-evenly md:justify-start flex-1 md:flex-none md:gap-1 h-full min-w-0 overflow-hidden">
{tabs.map(({ label, path, icon: Icon }) => {
const isActive = location.pathname.startsWith(path);
return (
<NavLink
key={path}
to={path}
title={label}
aria-label={label}
className={cn(
'flex items-center gap-2 px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px',
'flex items-center justify-center md:justify-start gap-1.5 px-2.5 md:px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px whitespace-nowrap',
isActive
? 'text-accent border-accent'
: 'text-muted-foreground hover:text-foreground border-transparent'
)}
>
<Icon className="h-4 w-4" />
{label}
<Icon className="h-4 w-4 shrink-0" />
<span className="hidden sm:inline">{label}</span>
</NavLink>
);
})}

View File

@ -54,7 +54,7 @@ export default function ConfigPage() {
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
<Card>
<CardHeader className="flex-row items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
@ -75,7 +75,7 @@ export default function ConfigPage() {
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Filter:</span>
</div>
<div className="w-52">
<div className="w-36 sm:w-52">
<Select
value={filterAction}
onChange={(e) => {
@ -129,22 +129,22 @@ export default function ConfigPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Time
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
Actor
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Action
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden sm:table-cell">
Target
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
IP
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Detail
</th>
</tr>
@ -158,15 +158,15 @@ export default function ConfigPage() {
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)}
>
<td className="px-5 py-3 text-xs text-muted-foreground whitespace-nowrap">
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground whitespace-nowrap">
{getRelativeTime(entry.created_at)}
</td>
<td className="px-5 py-3 text-xs font-medium">
<td className="px-3 lg:px-5 py-3 text-xs font-medium hidden sm:table-cell">
{entry.actor_username ?? (
<span className="text-muted-foreground italic">system</span>
)}
</td>
<td className="px-5 py-3">
<td className="px-3 lg:px-5 py-3">
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
@ -176,13 +176,13 @@ export default function ConfigPage() {
{entry.action}
</span>
</td>
<td className="px-5 py-3 text-xs text-muted-foreground">
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground hidden sm:table-cell">
{entry.target_username ?? '—'}
</td>
<td className="px-5 py-3 text-xs text-muted-foreground font-mono">
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground font-mono hidden lg:table-cell">
{entry.ip_address ?? '—'}
</td>
<td className="px-5 py-3 text-xs text-muted-foreground max-w-xs truncate">
<td className="px-3 lg:px-5 py-3 text-xs text-muted-foreground max-w-xs truncate hidden lg:table-cell">
{entry.detail ?? '—'}
</td>
</tr>

View File

@ -81,7 +81,7 @@ export default function IAMPage() {
);
}, [users, searchQuery]);
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => {
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users' | 'allow_passwordless', value: boolean) => {
try {
await updateConfig.mutateAsync({ [key]: value });
toast.success('System settings updated');
@ -95,7 +95,7 @@ export default function IAMPage() {
: null;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
<div className="px-4 md:px-6 py-6 space-y-6 animate-fade-in">
{/* Stats row */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<StatCard
@ -123,9 +123,9 @@ export default function IAMPage() {
/>
</div>
{/* User table */}
<Card>
<CardHeader className="flex-row items-center justify-between gap-3">
{/* User table — relative z-10 so action dropdowns render above sibling cards */}
<Card className="relative z-10">
<CardHeader className="flex-row items-center justify-between flex-wrap gap-2 md:gap-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<Users className="h-4 w-4 text-accent" />
@ -139,12 +139,12 @@ export default function IAMPage() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search users..."
className="pl-8 h-8 w-48 text-xs"
className="pl-8 h-8 w-32 sm:w-48 text-xs"
/>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Create User
<span className="hidden sm:inline">Create User</span>
</Button>
</div>
</CardHeader>
@ -164,34 +164,34 @@ export default function IAMPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-card-elevated/50">
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Username
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Umbral Name
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Email
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Role
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Status
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Last Login
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
MFA
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Sessions
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium hidden lg:table-cell">
Created
</th>
<th className="px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
<th className="px-3 lg:px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Actions
</th>
</tr>
@ -211,17 +211,17 @@ export default function IAMPage() {
)
)}
>
<td className="px-5 py-3 font-medium">{user.username}</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
<td className="px-3 lg:px-5 py-3 font-medium">{user.username}</td>
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
{user.umbral_name || user.username}
</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
{user.email || '—'}
</td>
<td className="px-5 py-3">
<td className="px-3 lg:px-5 py-3">
<RoleBadge role={user.role} />
</td>
<td className="px-5 py-3">
<td className="px-3 lg:px-5 py-3">
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
@ -233,10 +233,10 @@ export default function IAMPage() {
{user.is_active ? 'Active' : 'Disabled'}
</span>
</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
{user.last_login_at ? getRelativeTime(user.last_login_at) : '—'}
</td>
<td className="px-5 py-3">
<td className="px-3 lg:px-5 py-3 hidden lg:table-cell">
{user.totp_enabled ? (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
On
@ -249,13 +249,13 @@ export default function IAMPage() {
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="px-5 py-3 text-muted-foreground text-xs tabular-nums">
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs tabular-nums hidden lg:table-cell">
{user.active_sessions}
</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
<td className="px-3 lg:px-5 py-3 text-muted-foreground text-xs hidden lg:table-cell">
{getRelativeTime(user.created_at)}
</td>
<td className="px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<td className="px-3 lg:px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<UserActionsMenu user={user} currentUsername={authStatus?.username ?? null} />
</td>
</tr>
@ -320,6 +320,20 @@ 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>

View File

@ -11,6 +11,7 @@ import {
ChevronRight,
Loader2,
Trash2,
ShieldOff,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
@ -23,6 +24,7 @@ import {
useToggleUserActive,
useRevokeSessions,
useDeleteUser,
useDisablePasswordless,
getErrorMessage,
} from '@/hooks/useAdmin';
import type { AdminUserDetail, UserRole } from '@/types';
@ -53,6 +55,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
const toggleActive = useToggleUserActive();
const revokeSessions = useRevokeSessions();
const deleteUser = useDeleteUser();
const disablePasswordless = useDisablePasswordless();
// Close on outside click
useEffect(() => {
@ -102,6 +105,10 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
}
});
const disablePasswordlessConfirm = useConfirmAction(() => {
handleAction(() => disablePasswordless.mutateAsync(user.id), 'Passwordless login disabled');
});
const isLoading =
updateRole.isPending ||
resetPassword.isPending ||
@ -110,7 +117,8 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
removeMfaEnforcement.isPending ||
toggleActive.isPending ||
revokeSessions.isPending ||
deleteUser.isPending;
deleteUser.isPending ||
disablePasswordless.isPending;
return (
<div ref={menuRef} className="relative">
@ -147,7 +155,7 @@ export default function UserActionsMenu({ user, currentUsername }: UserActionsMe
{roleSubmenuOpen && (
<div
className="absolute right-full top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1"
className="absolute left-0 top-full sm:left-auto sm:right-full sm:top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1"
onMouseEnter={() => setRoleSubmenuOpen(true)}
onMouseLeave={() => setRoleSubmenuOpen(false)}
>
@ -258,6 +266,21 @@ 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 */}

View File

@ -1,10 +1,10 @@
import { X, User, ShieldCheck, Loader2 } from 'lucide-react';
import { X, User, ShieldCheck, Share2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useAdminUserDetail, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
import { useAdminUserDetail, useAdminSharingStats, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
import { getRelativeTime } from '@/lib/date-utils';
import { cn } from '@/lib/utils';
import type { UserRole } from '@/types';
@ -57,6 +57,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean })
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
const { data: user, isLoading, error } = useAdminUserDetail(userId);
const updateRole = useUpdateRole();
const { data: sharingStats } = useAdminSharingStats(userId);
const handleRoleChange = async (newRole: UserRole) => {
if (!user || newRole === user.role) return;
@ -70,15 +71,15 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
if (isLoading) {
return (
<div className="grid grid-cols-4 gap-4">
<Card className="col-span-1">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
<Card>
<CardContent className="p-5 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-1">
<Card>
<CardContent className="p-5 space-y-3">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
@ -108,9 +109,9 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
if (!user) return null;
return (
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
{/* User Information (read-only) */}
<Card className="col-span-1">
<Card>
<CardHeader className="flex-row items-center justify-between pb-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
@ -151,7 +152,7 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
</Card>
{/* Security & Permissions */}
<Card className="col-span-1">
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
@ -167,7 +168,7 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
<Select
value={user.role}
onChange={(e) => handleRoleChange(e.target.value as UserRole)}
className="h-6 text-xs py-0 px-1.5 w-auto min-w-[120px]"
className="h-6 text-xs py-0 px-1.5 w-auto min-w-[100px] sm:min-w-[120px]"
disabled={updateRole.isPending}
>
<option value="admin">Admin</option>
@ -192,6 +193,18 @@ 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'}
@ -218,6 +231,24 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
/>
</CardContent>
</Card>
{/* Sharing Stats */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<Share2 className="h-3.5 w-3.5 text-accent" />
</div>
<CardTitle className="text-sm">Sharing</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-0.5">
<DetailRow label="Calendars Shared" value={String(sharingStats?.shared_calendars_owned ?? 0)} />
<DetailRow label="Member Of" value={String(sharingStats?.calendars_member_of ?? 0)} />
<DetailRow label="Invites Sent" value={String(sharingStats?.pending_invites_sent ?? 0)} />
<DetailRow label="Invites Received" value={String(sharingStats?.pending_invites_received ?? 0)} />
</CardContent>
</Card>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { useState, FormEvent } from 'react';
import { Navigate } from 'react-router-dom';
import { toast } from 'sonner';
import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
import { AlertTriangle, Copy, Fingerprint, 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,6 +10,7 @@ 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';
@ -47,6 +48,8 @@ export default function LockScreen() {
isRegisterPending,
isSetupPending,
isTotpPending,
passkeyLogin,
isPasskeyLoginPending,
} = useAuth();
// ── Shared credential fields ──
@ -83,6 +86,31 @@ 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 />;
@ -127,11 +155,10 @@ export default function LockScreen() {
// mfaSetupRequired / mfaRequired handled by hook state → activeMode switches automatically
} catch (error: any) {
const status = error?.response?.status;
if (status === 423) {
setLoginError(error.response.data?.detail || 'Account locked. Try again later.');
} else if (status === 403) {
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'));
}
}
@ -491,18 +518,28 @@ export default function LockScreen() {
</div>
</CardHeader>
<CardContent>
{loginError && (
{loginError && (() => {
const isLockWarning =
loginError.includes('remaining') || loginError.includes('temporarily locked');
return (
<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'
'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'
)}
>
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
<p className="text-xs text-red-400">{loginError}</p>
{isLockWarning
? <Lock className="h-4 w-4 text-amber-400 shrink-0" aria-hidden="true" />
: <AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />}
<p className={cn('text-xs', isLockWarning ? 'text-amber-400' : 'text-red-400')}>
{loginError}
</p>
</div>
)}
);
})()}
<form onSubmit={handleCredentialSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" required>Username</Label>
@ -561,6 +598,30 @@ 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">

View File

@ -1,8 +1,8 @@
import { useState, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, FormEvent, useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Calendar } from '@/types';
import type { Calendar, CalendarMemberInfo, CalendarPermission, Connection } from '@/types';
import {
Dialog,
DialogContent,
@ -14,6 +14,11 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import PermissionToggle from './PermissionToggle';
import { useConnections } from '@/hooks/useConnections';
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
import CalendarMemberSearch from './CalendarMemberSearch';
import CalendarMemberList from './CalendarMemberList';
interface CalendarFormProps {
calendar: Calendar | null;
@ -21,14 +26,8 @@ interface CalendarFormProps {
}
const colorSwatches = [
'#3b82f6', // blue
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#8b5cf6', // purple
'#ec4899', // pink
'#06b6d4', // cyan
'#3b82f6', '#ef4444', '#f97316', '#eab308',
'#22c55e', '#8b5cf6', '#ec4899', '#06b6d4',
];
export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
@ -36,6 +35,23 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
const [name, setName] = useState(calendar?.name || '');
const [color, setColor] = useState(calendar?.color || '#3b82f6');
const [pendingInvite, setPendingInvite] = useState<{ conn: Connection; permission: CalendarPermission } | null>(null);
const { connections } = useConnections();
const { invite, isInviting, updateMember, removeMember } = useSharedCalendars();
const membersQuery = useQuery({
queryKey: ['calendar-members', calendar?.id],
queryFn: async () => {
const { data } = await api.get<CalendarMemberInfo[]>(
`/shared-calendars/${calendar!.id}/members`
);
return data;
},
enabled: !!calendar?.is_shared,
});
const members = membersQuery.data ?? [];
const mutation = useMutation({
mutationFn: async () => {
if (calendar) {
@ -78,11 +94,42 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
mutation.mutate();
};
const handleSelectConnection = useCallback((conn: Connection) => {
setPendingInvite({ conn, permission: 'read_only' });
}, []);
const handleSendInvite = async () => {
if (!calendar || !pendingInvite) return;
await invite({
calendarId: calendar.id,
connectionId: pendingInvite.conn.id,
permission: pendingInvite.permission,
canAddOthers: false,
});
setPendingInvite(null);
};
const handleUpdatePermission = async (memberId: number, permission: CalendarPermission) => {
if (!calendar) return;
await updateMember({ calendarId: calendar.id, memberId, permission });
};
const handleUpdateCanAddOthers = async (memberId: number, canAddOthers: boolean) => {
if (!calendar) return;
await updateMember({ calendarId: calendar.id, memberId, canAddOthers });
};
const handleRemoveMember = async (memberId: number) => {
if (!calendar) return;
await removeMember({ calendarId: calendar.id, memberId });
};
const canDelete = calendar && !calendar.is_default && !calendar.is_system;
const showSharing = calendar && !calendar.is_system;
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogContent className={calendar?.is_shared && showSharing ? 'max-w-3xl' : undefined}>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
@ -108,7 +155,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
key={c}
type="button"
onClick={() => setColor(c)}
className="h-8 w-8 rounded-full border-2 transition-all duration-150 hover:scale-110"
className="h-6 w-6 rounded-full border-2 transition-all duration-150 hover:scale-110"
style={{
backgroundColor: c,
borderColor: color === c ? 'hsl(0 0% 98%)' : 'transparent',
@ -119,6 +166,72 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
</div>
</div>
{showSharing && (
<>
{calendar?.is_shared && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="mb-0">Members</Label>
<span className="text-[11px] text-muted-foreground">
You (Owner)
</span>
</div>
{pendingInvite ? (
<div
className="rounded-lg border border-border bg-card-elevated p-4 space-y-3 animate-fade-in"
style={{ borderLeftWidth: '3px', borderLeftColor: 'hsl(var(--accent-color) / 0.5)' }}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name}
</span>
<button
type="button"
onClick={() => setPendingInvite(null)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
<div className="flex items-center gap-3">
<PermissionToggle
value={pendingInvite.permission}
onChange={(p) => setPendingInvite((prev) => prev ? { ...prev, permission: p } : null)}
/>
<div className="flex-1" />
<Button
type="button"
size="sm"
onClick={handleSendInvite}
disabled={isInviting}
>
{isInviting ? 'Sending...' : 'Send Invite'}
</Button>
</div>
</div>
) : (
<CalendarMemberSearch
connections={connections}
existingMembers={members}
onSelect={handleSelectConnection}
isLoading={isInviting}
/>
)}
<CalendarMemberList
members={members}
isLoading={membersQuery.isLoading}
isOwner={true}
onUpdatePermission={handleUpdatePermission}
onUpdateCanAddOthers={handleUpdateCanAddOthers}
onRemove={handleRemoveMember}
/>
</div>
)}
</>
)}
<DialogFooter>
{canDelete && (
<Button

View File

@ -0,0 +1,55 @@
import { Loader2 } from 'lucide-react';
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
import CalendarMemberRow from './CalendarMemberRow';
interface CalendarMemberListProps {
members: CalendarMemberInfo[];
isLoading?: boolean;
isOwner: boolean;
readOnly?: boolean;
onUpdatePermission?: (memberId: number, permission: CalendarPermission) => void;
onUpdateCanAddOthers?: (memberId: number, canAddOthers: boolean) => void;
onRemove?: (memberId: number) => void;
}
export default function CalendarMemberList({
members,
isLoading = false,
isOwner,
readOnly = false,
onUpdatePermission,
onUpdateCanAddOthers,
onRemove,
}: CalendarMemberListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (members.length === 0) {
return (
<p className="text-xs text-muted-foreground py-2">
Search your connections to add members
</p>
);
}
return (
<div className="space-y-2 max-h-72 overflow-y-auto">
{members.map((member) => (
<CalendarMemberRow
key={member.id}
member={member}
isOwner={isOwner}
readOnly={readOnly}
onUpdatePermission={onUpdatePermission}
onUpdateCanAddOthers={onUpdateCanAddOthers}
onRemove={onRemove}
/>
))}
</div>
);
}

View File

@ -0,0 +1,86 @@
import { X, UserPlus } from 'lucide-react';
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
import { Checkbox } from '@/components/ui/checkbox';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import PermissionBadge from './PermissionBadge';
import PermissionToggle from './PermissionToggle';
interface CalendarMemberRowProps {
member: CalendarMemberInfo;
isOwner: boolean;
readOnly?: boolean;
onUpdatePermission?: (memberId: number, permission: CalendarPermission) => void;
onUpdateCanAddOthers?: (memberId: number, canAddOthers: boolean) => void;
onRemove?: (memberId: number) => void;
}
export default function CalendarMemberRow({
member,
isOwner,
readOnly = false,
onUpdatePermission,
onUpdateCanAddOthers,
onRemove,
}: CalendarMemberRowProps) {
const { confirming, handleClick: handleRemoveClick } = useConfirmAction(
() => onRemove?.(member.id)
);
const displayName = member.preferred_name || member.umbral_name;
const initial = displayName.charAt(0).toUpperCase();
return (
<div className="flex items-center gap-3 rounded-lg border border-border p-3 transition-all duration-200 hover:border-border/80">
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-sm text-violet-400 font-medium">{initial}</span>
</div>
<div className="flex items-center gap-2 min-w-0 flex-1 truncate">
<span className="text-sm font-medium truncate">{displayName}</span>
{member.preferred_name && (
<span className="text-xs text-violet-400 truncate shrink-0">{member.umbral_name}</span>
)}
{member.status === 'pending' && (
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-400 font-medium shrink-0">
Pending
</span>
)}
</div>
{readOnly ? (
<PermissionBadge permission={member.permission} />
) : isOwner ? (
<div className="flex items-center gap-2.5 shrink-0">
<PermissionToggle
value={member.permission}
onChange={(p) => onUpdatePermission?.(member.id, p)}
/>
{(member.permission === 'create_modify' || member.permission === 'full_access') && (
<label className="flex items-center gap-1.5 cursor-pointer shrink-0" title="Can add others">
<Checkbox
checked={member.can_add_others}
onChange={() => onUpdateCanAddOthers?.(member.id, !member.can_add_others)}
className="h-3.5 w-3.5"
/>
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
</label>
)}
<button
type="button"
onClick={handleRemoveClick}
className="text-muted-foreground hover:text-destructive transition-colors"
title={confirming ? 'Click again to confirm' : 'Remove member'}
>
{confirming ? (
<span className="text-[10px] text-destructive font-medium px-1">Sure?</span>
) : (
<X className="h-4 w-4" />
)}
</button>
</div>
) : (
<PermissionBadge permission={member.permission} />
)}
</div>
);
}

View File

@ -0,0 +1,103 @@
import { useState, useRef, useEffect } from 'react';
import { Search, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import type { Connection, CalendarMemberInfo } from '@/types';
interface CalendarMemberSearchProps {
connections: Connection[];
existingMembers: CalendarMemberInfo[];
onSelect: (connection: Connection) => void;
isLoading?: boolean;
}
export default function CalendarMemberSearch({
connections,
existingMembers,
onSelect,
isLoading = false,
}: CalendarMemberSearchProps) {
const [query, setQuery] = useState('');
const [focused, setFocused] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setFocused(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const existingUserIds = new Set(existingMembers.map((m) => m.user_id));
const filtered = connections.filter((c) => {
if (existingUserIds.has(c.connected_user_id)) return false;
if (!query.trim()) return true;
const q = query.toLowerCase();
return (
c.connected_umbral_name.toLowerCase().includes(q) ||
(c.connected_preferred_name?.toLowerCase().includes(q) ?? false)
);
});
const handleSelect = (connection: Connection) => {
onSelect(connection);
setQuery('');
setFocused(false);
};
return (
<div ref={containerRef} className="relative">
<div className="relative">
{isLoading ? (
<Loader2 className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground animate-spin" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder="Search connections to invite..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
className="pl-8 h-9 text-sm"
/>
</div>
{focused && filtered.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden max-h-40 overflow-y-auto">
{filtered.map((conn) => {
const displayName = conn.connected_preferred_name || conn.connected_umbral_name;
const initial = displayName.charAt(0).toUpperCase();
return (
<button
key={conn.id}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(conn)}
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
>
<div className="h-6 w-6 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-xs text-violet-400 font-medium">{initial}</span>
</div>
<div className="min-w-0 flex-1">
<span className="font-medium truncate block">{displayName}</span>
{conn.connected_preferred_name && (
<span className="text-xs text-muted-foreground">{conn.connected_umbral_name}</span>
)}
</div>
</button>
);
})}
</div>
)}
{focused && query.trim() && filtered.length === 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg p-3">
<p className="text-xs text-muted-foreground text-center">No matching connections</p>
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More