Compare commits

...

33 Commits

Author SHA1 Message Date
f8c2df9328 Merge multi-user RBAC with login flow fixes, QA + pentest remediations
Multi-user RBAC (8 phases):
- user_id FKs on all 12 domain models (migrations 026-037)
- Per-user query scoping on all 14 routers
- Admin portal: IAM, system config, audit logs
- Registration flow with optional MFA enforcement
- Role-based authorization (admin/standard)

Login flow fixes:
- Block inactive user login (HTTP 403) before session creation
- Optimistic setQueryData eliminates login flicker
- Unified inline error alerts (401/403/423)
- Axios 401 interceptor excludes auth endpoints

Security hardening:
- Global CSRF middleware (X-Requested-With header check)
- extra="forbid" on all Pydantic input schemas
- max_length on all string fields, ge/le on path IDs
- Timing-safe login (M-02 dummy hash)
- Password reuse prevention on change-password
- Setup endpoint 500 fix (func.count vs scalar_one_or_none)

Verified: QA review (0 critical) + pentest (51+ test cases, 0 exploitable)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 03:02:28 +08:00
a313ce8b32 Update README for multi-user RBAC release
- Add multi-user RBAC, admin portal, and registration to features
- Update tech stack (37 migrations, CSRF middleware, RBAC)
- Expand security section with IDOR protection, CSRF, input validation,
  timing safety, inactive user blocking, password reuse prevention
- Update project structure (18 models, 13 schema modules, admin components)
- Add admin endpoints to API overview
- Note pentest verification (51+ test cases, 0 exploitable findings)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 02:58:52 +08:00
d269742aa2 Fix pentest findings: setup 500 error + password reuse prevention
- L-01: Setup endpoint used scalar_one_or_none() on unbounded User
  query, throwing 500 MultipleResultsFound when >1 user exists.
  Replaced with select(func.count()) for a reliable check.
- L-02: Change-password allowed reusing the same password, defeating
  must_change_password enforcement. Added equality check before
  accepting the new password.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 02:42:00 +08:00
1aeb725410 Fix issues from QA review: hash upgrade ordering, interceptor scope, guard
- W-01: Move is_active check before hash upgrade so disabled accounts
  don't get their password hash silently mutated on rejected login
- W-02: Narrow interceptor exclusion to specific auth endpoints instead
  of blanket /auth/* prefix (future-proofs against new auth routes)
- W-03: Add null guard on optimistic setQueryData to handle undefined
  cache gracefully instead of spreading undefined
- S-01: Clear loginError when switching from register back to login mode
- S-03: Add detail dict to auth.login_blocked_inactive audit event

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 02:13:48 +08:00
c4c06be148 Fix login error vanishing: exclude auth endpoints from 401 interceptor
The global axios 401 interceptor was firing window.location.href =
'/login' on every 401 response, including POST /auth/login with wrong
credentials. This caused a full page reload to /login, which remounted
the entire React tree and reset all LockScreen state (loginError,
username, password) before the user could see the error alert.

Fix: skip the redirect for /auth/* endpoints, which legitimately
return 401 for invalid credentials. The interceptor still redirects
to /login for expired sessions on protected API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:43:18 +08:00
5426657b2e Fix login error alert disappearing due to browser autofill
After a failed login, the browser's password manager fires onChange
events on the username/password inputs (clearing or resetting them).
The onChange handlers were calling setLoginError(null), which wiped
the error alert immediately after it appeared.

Fix: remove setLoginError(null) from input onChange handlers. The
error now clears at the start of the next submit attempt via the
existing setLoginError(null) in handleCredentialSubmit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:37:57 +08:00
b2d81f7015 Block inactive user login + fix login flicker + inline error alerts
- Backend: reject is_active=False users with HTTP 403 after password
  verification but before session creation (prevents last_login_at
  update, lockout reset, and MFA token issuance for disabled accounts)
- Frontend: optimistic setQueryData on successful login eliminates the
  form flash between mutation success and auth query refetch
- LockScreen: replace lockoutMessage + toast.error with unified
  loginError inline alert for 401/403/423 responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 01:21:06 +08:00
c68fd69cdf Make temp password click-to-copy in reset password flow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:35:18 +08:00
8582b41b03 Add user profile fields + IAM search, email column, detail panel
Backend:
- Migration 037: add email, first_name, last_name to users table
- User model: add 3 profile columns
- Admin schemas: extend UserListItem/UserDetailResponse/CreateUserRequest
  with profile fields, email validator, name field sanitization
- _create_user_defaults: accept optional preferred_name kwarg
- POST /users: set profile fields, email uniqueness check, IntegrityError guard
- GET /users/{id}: join Settings for preferred_name, include must_change_password/locked_until

Frontend:
- AdminUser/AdminUserDetail types: add profile + detail fields
- useAdmin: add CreateUserPayload profile fields + useAdminUserDetail query
- CreateUserDialog: optional profile section (first/last name, email, preferred name)
- IAMPage: search bar filtering on username/email/name, email column in table,
  row click to select user with accent highlight
- UserDetailSection: two-column detail panel (User Info + Security & Permissions)
  with inline role editing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:40:20 +08:00
c3654dc069 Fix audit log target for deleted users + create user 500 error
1. Audit log: COALESCE target_username with detail JSON fallback so
   deleted users still show their username in the target column
   (target_user_id is SET NULL by FK cascade, but detail JSON
   preserves the username).

2. Create/get user: add exclude={"active_sessions"} to model_dump()
   calls — UserListItem defaults active_sessions=0, so model_dump()
   includes it, then the explicit active_sessions=N keyword argument
   causes a duplicate keyword TypeError. DB commit already happened,
   so the user exists but the response was a 500.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:44:10 +08:00
48e15fa677 UX polish for delete-user: username toast, hide self-delete
S-03: Delete toast now shows the deleted username from the API response
S-04: Delete button hidden for the current admin's own row (backend
still guards with 403, but no reason to show a dead button)

Adds username to auth status response so the frontend can identify
the current user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:30:43 +08:00
e7cb6de7d5 Add admin delete-user with full cascade cleanup
Migration 036 adds ondelete rules to 5 transitive FKs that would
otherwise block user deletion (calendar_events via calendars,
project_tasks via projects, todos via projects, etc.).

DELETE /api/admin/users/{user_id} with self-action guard, last-admin
guard, session revocation, and audit logging. Frontend gets a red
two-click confirm button in the IAM actions menu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:20:47 +08:00
c56830ddb0 Fix SyntaxError in admin.py: add default to Request params
Three functions (update_user_role, toggle_mfa_enforce, toggle_user_active)
had `request: Request` without a default after Path params with defaults,
causing Python SyntaxError on import.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:31:25 +08:00
1ebc41b9d7 L-03: Session 7-day sliding window with 30-day hard ceiling
Reduce session expiry from 30 days to 7 days of inactivity while
preserving a 30-day absolute token lifetime for itsdangerous:

- SESSION_MAX_AGE_DAYS=7: sliding window for DB expires_at + cookie
- SESSION_TOKEN_HARD_CEILING_DAYS=30: itsdangerous max_age (prevents
  rejecting renewed tokens whose creation timestamp exceeds 7 days)
- get_current_user: silently extends expires_at and re-issues cookie
  when >1 day has elapsed since last renewal
- Active users never notice; 7 days of inactivity forces re-login;
  30-day absolute ceiling forces re-login regardless of activity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:45:15 +08:00
8e27f2920b M-02: Timing-safe login prevents username enumeration
When a login targets a non-existent username, run Argon2id against a
pre-computed dummy hash so response time (~60ms) matches wrong-password
attempts. Also reorder the login flow to run verify_password_with_upgrade
BEFORE the lockout check, preventing timing side-channels that could
distinguish locked accounts from wrong passwords.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:44:27 +08:00
2f58282c31 M-01+M-03: Add input validation and extra=forbid to all request schemas
- Add max_length constraints to all string fields in request schemas,
  matching DB column limits (title:255, description:5000, etc.)
- Add min_length=1 to required name/title fields
- Add ConfigDict(extra="forbid") to all request schemas to reject
  unknown fields (prevents silent field injection)
- Add Path(ge=1, le=2147483647) to all integer path parameters across
  all routers to prevent integer overflow → 500 errors
- Add max_length to TOTP inline schemas (code:6, mfa_token:256, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:43:55 +08:00
581efa183a H-01: Add global CSRF middleware for all mutating endpoints
Replace the admin-only verify_xhr dependency with a pure ASGI
CSRFHeaderMiddleware that validates X-Requested-With: XMLHttpRequest
on all POST/PUT/PATCH/DELETE requests globally. Pre-auth endpoints
(login, setup, register, totp-verify, enforce-setup/confirm) are
exempt. This closes the CSRF gap where non-admin routes accepted
requests without origin validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:39:42 +08:00
9f7bbbfcbb Add per-user active session counts to IAM user list
Move active_sessions field from UserDetailResponse into UserListItem
so GET /admin/users returns session counts. Uses a correlated subquery
to count non-revoked, non-expired sessions per user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:26:32 +08:00
a128005ae5 Fix create-mode crash in detail panels (null entity access)
The desktop detail panels are pre-mounted (always in DOM, hidden with w-0).
useState(isCreating) only captures the initial value on mount (false), so
when isCreating later becomes true via props, isEditing stays false. The
view-mode branch then runs with a null entity, crashing on property access.

Fix: use (isEditing || isCreating) for all conditionals that gate between
edit/create form and view mode, ensuring the form always renders when
isCreating is true regardless of isEditing state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:57:19 +08:00
f07ce02576 Fix crash when creating new todo/reminder/event (null.priority)
All three DetailPanel components initialized isEditing=false even
when isCreating=true. The useEffect that flips it to true runs AFTER
the first render, so the view-mode branch executes with todo=null,
crashing on null.priority. Initialize isEditing from isCreating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 08:35:31 +08:00
0fc3f1a14b Allow dots in usernames (e.g. user.test)
Added . to the username character whitelist regex. No security
reason to exclude it — dots are standard in usernames.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 08:03:25 +08:00
e860723a2a Fix Edit Role submenu overflowing right edge of viewport
Submenu was positioned left-full (opening rightward) but the parent
dropdown is already at the right edge. Changed to right-full so it
opens leftward into available space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 06:35:03 +08:00
4fc85684ea Fix IAM user actions dropdown clipped by overflow-x-auto
The table wrapper's overflow-x-auto forced overflow-y to also clip,
hiding the 3-dot actions dropdown below the container boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 06:23:08 +08:00
2438cdcf25 Fix migration 034 failing on fresh DB: drop index not constraint
Migration 022 created a unique INDEX (ix_ntfy_sent_notification_key),
not a named unique CONSTRAINT. Migration 034 was trying to drop a
constraint name that only existed on upgraded DBs. Fixed to drop the
index instead, which works on both fresh and upgrade paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 06:06:13 +08:00
619e220622 Fix QA review #2: W-03/W-04, S-01 through S-04
W-03: Unify split transactions — _create_db_session() now uses flush()
      instead of commit(), callers own the final commit.
W-04: Time-bound dedup key fetch to 7-day purge window.
S-01: Type admin dashboard response with RecentLoginItem/RecentAuditItem.
S-02: Convert starred events index to partial index WHERE is_starred = true.
S-03: EventTemplate.created_at default changed to func.now() for consistency.
S-04: Add single-worker scaling note to weather cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 05:41:16 +08:00
72e00f3a69 Fix QA review #2: backup code flow, audit filters, schema hardening
C-01: verifyTotp now sends backup_code field when in backup mode
C-02: Backup code input filter allows alphanumeric chars (not digits only)
W-01: Audit log ACTION_TYPES aligned with actual backend action strings
W-02: Added extra="forbid" to SetupRequest and LoginRequest schemas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 04:59:29 +08:00
72ac1d53fb Fix migration 030 failing on fresh DB with no admin user
Migration 006 seeds default calendar rows. On a fresh install, no users
exist when migration 030 runs, so the backfill SELECT returns NULL and
SET NOT NULL fails. Now deletes orphan calendars before enforcing the
constraint — account setup will recreate defaults for new users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 04:49:57 +08:00
cbf4663e8d Fix TS build errors and apply remaining QA fixes
Remove unused imports (UserCheck, Loader2, ShieldOff) and replace
non-existent SmartphoneOff icon with Smartphone in admin components.
Includes backend query fixes, performance indexes migration, and
admin page shared utilities extraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 04:42:23 +08:00
e57a5b00c9 Fix QA review findings: C-01 through C-04, W-01 through W-07, S-01/S-04/S-05/S-06
Critical fixes:
- C-01: Pass user_id to _mark_sent/_already_sent (ntfy crash)
- C-02: Align frontend HTTP methods with backend routes (PATCH->PUT,
  DELETE->POST, fix reset-password/enforce-mfa/disable-mfa paths)
- C-03: Add X-Requested-With to CORS allow_headers
- C-04: Replace scalar_one_or_none with func.count for auth/status

Warning fixes:
- W-01: Batch audit log into same transaction in create_user, setup, register
- W-02: Extract users array from UserListResponse wrapper in useAdminUsers
- W-03: Update password hint from "8 chars" to "12 chars" in CreateUserDialog
- W-04: Remove password input from reset flow, show returned temp password
- W-06: Remove unused actor_alias variable in admin_dashboard
- W-07: Resolve usernames in dashboard audit entries via JOIN, remove
  ip_address column from recent_logins (not tracked on User model)

Suggestions applied:
- S-01/S-06: Add extra="forbid" to all admin mutation schemas
- S-04: Add ondelete="SET NULL" to audit_log.actor_user_id FK
- S-05: Improve registration error message for better UX

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:19:04 +08:00
e5a7ce13e0 Merge multi-user RBAC implementation (Phases 1-8)
Complete multi-user integration for UMBRA:
- Database: role enum, system_config, audit_log, user_id FKs on all tables
- Auth: RBAC with require_role(), registration, MFA enforcement
- Routing: All 12 routers scoped by user_id
- Admin API: 14 endpoints for IAM, config, audit, dashboard
- Admin Portal: IAM page, config page, dashboard with stats
- Registration flow: LockScreen with register/MFA-enforce/password-change
- Security: SEC-01 through SEC-15 mitigations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:06:43 +08:00
d8bdae8ec3 Implement multi-user RBAC: database, auth, routing, admin API (Phases 1-6)
Phase 1: Add role, mfa_enforce_pending, must_change_password to users table.
Create system_config (singleton) and audit_log tables. Migration 026.

Phase 2: Add user_id FK to all 8 data tables (todos, reminders, projects,
calendars, people, locations, event_templates, ntfy_sent) with 4-step
nullable→backfill→FK→NOT NULL pattern. Migrations 027-034.

Phase 3: Harden auth schemas (extra="forbid" on RegisterRequest), add
MFA enforcement token serializer with distinct salt, rewrite auth router
with require_role() factory and registration endpoint.

Phase 4: Scope all 12 routers by user_id, fix dependency type bugs,
bound weather cache (SEC-15), multi-user ntfy dispatch.

Phase 5: Create admin router (14 endpoints), admin schemas, audit
service, rate limiting in nginx. SEC-08 CSRF via X-Requested-With.

Phase 6: Update frontend types, useAuth hook (role/isAdmin/register),
App.tsx (AdminRoute guard), Sidebar (admin link), api.ts (XHR header).

Security findings addressed: SEC-01, SEC-02, SEC-03, SEC-04, SEC-05,
SEC-06, SEC-07, SEC-08, SEC-12, SEC-13, SEC-15.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 19:06:25 +08:00
2ec70d9344 Add Phase 7 admin portal frontend (IAM, Config, Dashboard)
Creates 7 files: useAdmin hook with TanStack Query v5, AdminPortal
layout with horizontal tab nav, IAMPage with user table + stat cards
+ system settings, UserActionsMenu with two-click confirms, CreateUserDialog,
ConfigPage with paginated audit log + action filter, AdminDashboardPage
with stats + recent logins/actions tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:40:16 +08:00
464b8b911f Phase 8: Registration flow & MFA enforcement UI
- backend: add POST /auth/totp/enforce-setup and /auth/totp/enforce-confirm
  endpoints that operate on mfa_enforce_token (not session cookie), generate
  TOTP secret/QR/backup codes, verify confirmation code, enable TOTP, clear
  mfa_enforce_pending flag, and issue a full session cookie
- frontend: expand LockScreen to five modes — login, first-run setup,
  open registration, TOTP challenge, MFA enforcement setup (QR -> verify ->
  backup codes), and forced password change; all modes share AmbientBackground
  and the existing card layout; registration visible only when
  authStatus.registration_open is true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:39:18 +08:00
77 changed files with 5509 additions and 607 deletions

View File

@ -1,9 +1,10 @@
# UMBRA
A self-hosted personal life administration app with a dark-themed UI. Manage your todos, calendar events, projects, reminders, contacts, and locations from a single dashboard.
A self-hosted, multi-user life administration app with a dark-themed UI and role-based access control. Manage your todos, calendar events, projects, reminders, contacts, and locations from a single dashboard.
## 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
@ -12,8 +13,9 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you
- **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 (5 presets), first day of week, weather city, ntfy push notifications, TOTP two-factor auth, auto-lock, password management
- **Settings** - Accent color picker (8 presets), first day of week, weather city, ntfy push notifications, TOTP two-factor auth, auto-lock, password management
- **Notifications** - ntfy push notifications for reminders (configurable per-user)
- **Admin Portal** - User management (create, delete, activate/deactivate, role assignment, password reset), system configuration (open registration, MFA enforcement), audit log viewer
## Tech Stack
@ -24,8 +26,8 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you
| 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 (25 migrations) |
| Auth | Username/password with Argon2id hashing, DB-backed sessions (signed cookies), optional TOTP MFA |
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic (37 migrations) |
| Auth | Argon2id hashing, DB-backed sessions (signed httpOnly cookies), TOTP MFA, CSRF middleware, role-based access control |
| Scheduler | APScheduler (async) for ntfy notification dispatch |
| Deployment | Docker Compose (3 services), Nginx reverse proxy |
@ -66,7 +68,7 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you
4. **Open the app**
Navigate to `http://localhost` in your browser. On first launch you'll be prompted to create a username and password.
Navigate to `http://localhost` in your browser. On first launch you'll be prompted to create an admin account.
## Architecture
@ -108,18 +110,25 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you
### 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 cookies
- **Optional TOTP MFA** — authenticator app support with backup codes
- **DB-backed sessions** — revocable, with signed itsdangerous httpOnly cookies, 7-day sliding window with 30-day hard ceiling
- **Optional TOTP MFA** — authenticator app support with backup codes, admin-enforced MFA for new users
### Production Hardening
@ -150,12 +159,13 @@ Additionally for production:
## API Overview
All endpoints require authentication (signed session cookie) except auth routes and the health check.
All endpoints require authentication (signed session cookie) except auth routes and the health check. Admin endpoints require the `admin` role.
| Endpoint | Description |
|-----------------------|-------------|
| `GET /health` | Health check |
| `/api/auth/*` | Login, logout, setup, status, password change, TOTP MFA |
| `/api/auth/*` | Login, logout, setup, register, status, password change, TOTP MFA |
| `/api/admin/*` | User management, system config, audit logs (admin only) |
| `/api/todos/*` | Todos CRUD + toggle completion |
| `/api/events/*` | Calendar events CRUD (incl. recurring) |
| `/api/event-templates/*` | Event templates CRUD |
@ -164,7 +174,7 @@ All endpoints require authentication (signed session cookie) except auth routes
| `/api/projects/*` | Projects + nested tasks + comments CRUD |
| `/api/people/*` | People CRUD |
| `/api/locations/*` | Locations CRUD + search |
| `/api/settings/*` | App settings + password change + ntfy config |
| `/api/settings/*` | App settings + ntfy config |
| `/api/dashboard` | Dashboard aggregation |
| `/api/upcoming` | Unified upcoming items feed |
| `/api/weather/*` | Weather data proxy |
@ -209,15 +219,15 @@ umbra/
│ ├── Dockerfile
│ ├── requirements.txt
│ ├── alembic.ini
│ ├── alembic/versions/ # 25 migrations (001025)
│ ├── alembic/versions/ # 37 migrations (001037)
│ └── app/
│ ├── main.py # FastAPI app, router registration, health endpoint
│ ├── 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/ # 17 SQLAlchemy ORM models
│ ├── schemas/ # Pydantic v2 request/response schemas
│ ├── routers/ # 14 API route handlers
│ ├── services/ # Auth (Argon2id), recurrence, TOTP, ntfy
│ ├── models/ # 18 SQLAlchemy ORM models (incl. User, UserSession, SystemConfig, AuditLog)
│ ├── schemas/ # 13 Pydantic v2 request/response schema modules (incl. admin)
│ ├── routers/ # 14 API route handlers (incl. auth, admin, totp)
│ ├── services/ # Auth (Argon2id), recurrence, TOTP, ntfy, audit
│ └── jobs/ # APScheduler notification dispatch
└── frontend/
├── Dockerfile
@ -225,15 +235,16 @@ umbra/
├── proxy-params.conf # Shared proxy settings (DRY include)
├── package.json
└── src/
├── App.tsx # Routes and auth guard
├── lib/ # api.ts, date-utils.ts, utils.ts
├── hooks/ # useAuth, useSettings, useTheme, useCalendars, useConfirmAction, useCategoryOrder, useTableVisibility
├── 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
├── types/ # TypeScript interfaces
└── components/
├── ui/ # 18 base components (Button, Dialog, Sheet, Card, Input, Select, Switch, etc.)
├── ui/ # 17 base components (Button, Dialog, Sheet, Card, Input, Select, Switch, etc.)
├── shared/ # EntityTable, EntityDetailPanel, CategoryFilterBar, CategoryAutocomplete, CopyableField
├── layout/ # AppLayout, Sidebar, LockOverlay
├── auth/ # LockScreen, AmbientBackground
├── admin/ # AdminPortal, IAMPage, ConfigPage, AdminDashboardPage, CreateUserDialog, UserActionsMenu, UserDetailSection
├── dashboard/ # DashboardPage + 8 widgets
├── calendar/ # CalendarPage, CalendarSidebar, CalendarForm, EventForm, TemplateForm
├── todos/ # TodosPage, TodoList, TodoItem, TodoForm

View File

@ -0,0 +1,101 @@
"""Add role, mfa_enforce_pending, must_change_password to users; create system_config table.
Revision ID: 026
Revises: 025
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "026"
down_revision = "025"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. Add role column with server_default for existing rows
op.add_column("users", sa.Column(
"role", sa.String(30), nullable=False, server_default="standard"
))
# 2. Add MFA enforcement pending flag
op.add_column("users", sa.Column(
"mfa_enforce_pending", sa.Boolean(), nullable=False, server_default="false"
))
# 3. Add forced password change flag (SEC-12)
op.add_column("users", sa.Column(
"must_change_password", sa.Boolean(), nullable=False, server_default="false"
))
# 4. Add last_password_change_at audit column
op.add_column("users", sa.Column(
"last_password_change_at", sa.DateTime(), nullable=True
))
# 5. Add CHECK constraint on role values (SEC-16)
op.create_check_constraint(
"ck_users_role",
"users",
"role IN ('admin', 'standard', 'public_event_manager')"
)
# 6. Promote the first (existing) user to admin
op.execute(
"UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)"
)
# 7. Create system_config table (singleton pattern -- always id=1)
op.create_table(
"system_config",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("allow_registration", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("enforce_mfa_new_users", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")),
sa.PrimaryKeyConstraint("id"),
# SEC-09: Enforce singleton row
sa.CheckConstraint("id = 1", name="ck_system_config_singleton"),
)
# 8. Seed the singleton row
op.execute(
"INSERT INTO system_config (id, allow_registration, enforce_mfa_new_users) "
"VALUES (1, false, false)"
)
# 9. Create audit_log table
op.create_table(
"audit_log",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("target_user_id", sa.Integer(), nullable=True),
sa.Column("action", sa.String(100), nullable=False),
sa.Column("detail", sa.Text(), nullable=True),
sa.Column("ip_address", sa.String(45), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(
["target_user_id"], ["users.id"], ondelete="SET NULL"
),
)
op.create_index("ix_audit_log_actor_user_id", "audit_log", ["actor_user_id"])
op.create_index("ix_audit_log_target_user_id", "audit_log", ["target_user_id"])
op.create_index("ix_audit_log_action", "audit_log", ["action"])
op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_audit_log_created_at", table_name="audit_log")
op.drop_index("ix_audit_log_action", table_name="audit_log")
op.drop_index("ix_audit_log_target_user_id", table_name="audit_log")
op.drop_index("ix_audit_log_actor_user_id", table_name="audit_log")
op.drop_table("audit_log")
op.drop_table("system_config")
op.drop_constraint("ck_users_role", "users", type_="check")
op.drop_column("users", "last_password_change_at")
op.drop_column("users", "must_change_password")
op.drop_column("users", "mfa_enforce_pending")
op.drop_column("users", "role")

View File

@ -0,0 +1,38 @@
"""Add user_id FK to todos table.
Revision ID: 027
Revises: 026
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "027"
down_revision = "026"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("todos", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE todos SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_todos_user_id", "todos", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("todos", "user_id", nullable=False)
op.create_index("ix_todos_user_id", "todos", ["user_id"])
op.create_index("ix_todos_user_completed", "todos", ["user_id", "completed"])
op.create_index("ix_todos_user_due_date", "todos", ["user_id", "due_date"])
def downgrade() -> None:
op.drop_index("ix_todos_user_due_date", table_name="todos")
op.drop_index("ix_todos_user_completed", table_name="todos")
op.drop_index("ix_todos_user_id", table_name="todos")
op.drop_constraint("fk_todos_user_id", "todos", type_="foreignkey")
op.drop_column("todos", "user_id")

View File

@ -0,0 +1,36 @@
"""Add user_id FK to reminders table.
Revision ID: 028
Revises: 027
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "028"
down_revision = "027"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("reminders", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE reminders SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_reminders_user_id", "reminders", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("reminders", "user_id", nullable=False)
op.create_index("ix_reminders_user_id", "reminders", ["user_id"])
op.create_index("ix_reminders_user_remind_at", "reminders", ["user_id", "remind_at"])
def downgrade() -> None:
op.drop_index("ix_reminders_user_remind_at", table_name="reminders")
op.drop_index("ix_reminders_user_id", table_name="reminders")
op.drop_constraint("fk_reminders_user_id", "reminders", type_="foreignkey")
op.drop_column("reminders", "user_id")

View File

@ -0,0 +1,36 @@
"""Add user_id FK to projects table.
Revision ID: 029
Revises: 028
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "029"
down_revision = "028"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("projects", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE projects SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_projects_user_id", "projects", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("projects", "user_id", nullable=False)
op.create_index("ix_projects_user_id", "projects", ["user_id"])
op.create_index("ix_projects_user_status", "projects", ["user_id", "status"])
def downgrade() -> None:
op.drop_index("ix_projects_user_status", table_name="projects")
op.drop_index("ix_projects_user_id", table_name="projects")
op.drop_constraint("fk_projects_user_id", "projects", type_="foreignkey")
op.drop_column("projects", "user_id")

View File

@ -0,0 +1,40 @@
"""Add user_id FK to calendars table.
Revision ID: 030
Revises: 029
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "030"
down_revision = "029"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("calendars", sa.Column("user_id", sa.Integer(), nullable=True))
# Backfill existing calendars to first admin user
op.execute(
"UPDATE calendars SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
# On fresh installs no users exist yet, so seeded calendars still have
# NULL user_id. Remove them — account setup will recreate defaults.
op.execute("DELETE FROM calendars WHERE user_id IS NULL")
op.create_foreign_key(
"fk_calendars_user_id", "calendars", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("calendars", "user_id", nullable=False)
op.create_index("ix_calendars_user_id", "calendars", ["user_id"])
op.create_index("ix_calendars_user_default", "calendars", ["user_id", "is_default"])
def downgrade() -> None:
op.drop_index("ix_calendars_user_default", table_name="calendars")
op.drop_index("ix_calendars_user_id", table_name="calendars")
op.drop_constraint("fk_calendars_user_id", "calendars", type_="foreignkey")
op.drop_column("calendars", "user_id")

View File

@ -0,0 +1,36 @@
"""Add user_id FK to people table.
Revision ID: 031
Revises: 030
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "031"
down_revision = "030"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("people", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE people SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_people_user_id", "people", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("people", "user_id", nullable=False)
op.create_index("ix_people_user_id", "people", ["user_id"])
op.create_index("ix_people_user_name", "people", ["user_id", "name"])
def downgrade() -> None:
op.drop_index("ix_people_user_name", table_name="people")
op.drop_index("ix_people_user_id", table_name="people")
op.drop_constraint("fk_people_user_id", "people", type_="foreignkey")
op.drop_column("people", "user_id")

View File

@ -0,0 +1,34 @@
"""Add user_id FK to locations table.
Revision ID: 032
Revises: 031
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "032"
down_revision = "031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("locations", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE locations SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_locations_user_id", "locations", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("locations", "user_id", nullable=False)
op.create_index("ix_locations_user_id", "locations", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_locations_user_id", table_name="locations")
op.drop_constraint("fk_locations_user_id", "locations", type_="foreignkey")
op.drop_column("locations", "user_id")

View File

@ -0,0 +1,34 @@
"""Add user_id FK to event_templates table.
Revision ID: 033
Revises: 032
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "033"
down_revision = "032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("event_templates", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE event_templates SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_event_templates_user_id", "event_templates", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
op.alter_column("event_templates", "user_id", nullable=False)
op.create_index("ix_event_templates_user_id", "event_templates", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_event_templates_user_id", table_name="event_templates")
op.drop_constraint("fk_event_templates_user_id", "event_templates", type_="foreignkey")
op.drop_column("event_templates", "user_id")

View File

@ -0,0 +1,50 @@
"""Add user_id FK to ntfy_sent table, rebuild unique constraint as composite.
Revision ID: 034
Revises: 033
Create Date: 2026-02-26
"""
from alembic import op
import sqlalchemy as sa
revision = "034"
down_revision = "033"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("ntfy_sent", sa.Column("user_id", sa.Integer(), nullable=True))
op.execute(
"UPDATE ntfy_sent SET user_id = ("
" SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1"
")"
)
op.create_foreign_key(
"fk_ntfy_sent_user_id", "ntfy_sent", "users",
["user_id"], ["id"], ondelete="CASCADE"
)
# On fresh DB ntfy_sent may be empty — clean up NULLs just in case
op.execute("DELETE FROM ntfy_sent WHERE user_id IS NULL")
op.alter_column("ntfy_sent", "user_id", nullable=False)
# Migration 022 created a unique INDEX (ix_ntfy_sent_notification_key), not a
# named unique CONSTRAINT. Drop the index; the new composite unique constraint
# below replaces it.
op.drop_index("ix_ntfy_sent_notification_key", table_name="ntfy_sent")
# Create composite unique constraint (per-user dedup)
op.create_unique_constraint(
"uq_ntfy_sent_user_key", "ntfy_sent", ["user_id", "notification_key"]
)
op.create_index("ix_ntfy_sent_user_id", "ntfy_sent", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_ntfy_sent_user_id", table_name="ntfy_sent")
op.drop_constraint("uq_ntfy_sent_user_key", "ntfy_sent", type_="unique")
op.create_index(
"ix_ntfy_sent_notification_key", "ntfy_sent", ["notification_key"], unique=True
)
op.drop_constraint("fk_ntfy_sent_user_id", "ntfy_sent", type_="foreignkey")
op.drop_column("ntfy_sent", "user_id")

View File

@ -0,0 +1,66 @@
"""Add performance indexes for hot query paths.
Covers:
- calendar_events range queries scoped by calendar (dashboard, notifications)
- calendar_events starred query (dashboard widget)
- calendar_events parent_event_id (recurring series DELETE)
- user_sessions lookup (auth middleware, every request)
- ntfy_sent purge query (background job, every 60s)
Revision ID: 035
Revises: 034
Create Date: 2026-02-27
"""
from alembic import op
revision = "035"
down_revision = "034"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Composite index for event range queries scoped by calendar
op.create_index(
"ix_calendar_events_calendar_start_end",
"calendar_events",
["calendar_id", "start_datetime", "end_datetime"],
)
# Partial index for starred events dashboard query — only rows where
# is_starred = true are ever queried, so a partial index is smaller and faster.
op.create_index(
"ix_calendar_events_calendar_starred",
"calendar_events",
["calendar_id", "start_datetime"],
postgresql_where="is_starred = true",
)
# FK lookup index for recurring children DELETE
op.create_index(
"ix_calendar_events_parent_id",
"calendar_events",
["parent_event_id"],
)
# Composite index for session validation (runs on every authenticated request)
op.create_index(
"ix_user_sessions_lookup",
"user_sessions",
["user_id", "revoked", "expires_at"],
)
# Index for ntfy_sent purge query (DELETE WHERE sent_at < cutoff)
op.create_index(
"ix_ntfy_sent_sent_at",
"ntfy_sent",
["sent_at"],
)
def downgrade() -> None:
op.drop_index("ix_ntfy_sent_sent_at", table_name="ntfy_sent")
op.drop_index("ix_user_sessions_lookup", table_name="user_sessions")
op.drop_index("ix_calendar_events_parent_id", table_name="calendar_events")
op.drop_index("ix_calendar_events_calendar_starred", table_name="calendar_events")
op.drop_index("ix_calendar_events_calendar_start_end", table_name="calendar_events")

View File

@ -0,0 +1,104 @@
"""Add ondelete to transitive FK constraints.
Without these, deleting a user would fail because the DB-level CASCADE
only reaches first-level children (calendars, projects, people, locations).
Second-level children (calendar_events via calendar_id, project_tasks via
project_id, etc.) need their own ondelete rules to allow the full cascade.
FK changes:
calendar_events.calendar_id CASCADE (events die with calendar)
calendar_events.location_id SET NULL (optional ref, just unlink)
project_tasks.project_id CASCADE (tasks die with project)
project_tasks.person_id SET NULL (optional assignee, just unlink)
todos.project_id SET NULL (optional ref, just unlink)
Revision ID: 036
Revises: 035
Create Date: 2026-02-27
"""
from alembic import op
revision = "036"
down_revision = "035"
branch_labels = None
depends_on = None
def upgrade() -> None:
# calendar_events.calendar_id → CASCADE
op.drop_constraint(
"fk_calendar_events_calendar_id", "calendar_events", type_="foreignkey"
)
op.create_foreign_key(
"fk_calendar_events_calendar_id",
"calendar_events",
"calendars",
["calendar_id"],
["id"],
ondelete="CASCADE",
)
# calendar_events.location_id → SET NULL
op.drop_constraint(
"calendar_events_location_id_fkey", "calendar_events", type_="foreignkey"
)
op.create_foreign_key(
"calendar_events_location_id_fkey",
"calendar_events",
"locations",
["location_id"],
["id"],
ondelete="SET NULL",
)
# project_tasks.project_id → CASCADE
op.drop_constraint(
"project_tasks_project_id_fkey", "project_tasks", type_="foreignkey"
)
op.create_foreign_key(
"project_tasks_project_id_fkey",
"project_tasks",
"projects",
["project_id"],
["id"],
ondelete="CASCADE",
)
# project_tasks.person_id → SET NULL
op.drop_constraint(
"project_tasks_person_id_fkey", "project_tasks", type_="foreignkey"
)
op.create_foreign_key(
"project_tasks_person_id_fkey",
"project_tasks",
"people",
["person_id"],
["id"],
ondelete="SET NULL",
)
# todos.project_id → SET NULL
op.drop_constraint(
"todos_project_id_fkey", "todos", type_="foreignkey"
)
op.create_foreign_key(
"todos_project_id_fkey",
"todos",
"projects",
["project_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
# Reverse: remove ondelete by re-creating without it
for table, col, ref_table, constraint in [
("todos", "project_id", "projects", "todos_project_id_fkey"),
("project_tasks", "person_id", "people", "project_tasks_person_id_fkey"),
("project_tasks", "project_id", "projects", "project_tasks_project_id_fkey"),
("calendar_events", "location_id", "locations", "calendar_events_location_id_fkey"),
("calendar_events", "calendar_id", "calendars", "fk_calendar_events_calendar_id"),
]:
op.drop_constraint(constraint, table, type_="foreignkey")
op.create_foreign_key(constraint, table, ref_table, [col], ["id"])

View File

@ -0,0 +1,29 @@
"""Add user profile fields (email, first_name, last_name).
Revision ID: 037
Revises: 036
"""
from alembic import op
import sqlalchemy as sa
revision = "037"
down_revision = "036"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("email", sa.String(255), nullable=True))
op.add_column("users", sa.Column("first_name", sa.String(100), nullable=True))
op.add_column("users", sa.Column("last_name", sa.String(100), nullable=True))
op.create_unique_constraint("uq_users_email", "users", ["email"])
op.create_index("ix_users_email", "users", ["email"])
def downgrade() -> None:
op.drop_index("ix_users_email", table_name="users")
op.drop_constraint("uq_users_email", "users", type_="unique")
op.drop_column("users", "last_name")
op.drop_column("users", "first_name")
op.drop_column("users", "email")

View File

@ -9,8 +9,9 @@ class Settings(BaseSettings):
COOKIE_SECURE: bool = False
OPENWEATHERMAP_API_KEY: str = ""
# Session config
SESSION_MAX_AGE_DAYS: int = 30
# Session config — sliding window
SESSION_MAX_AGE_DAYS: int = 7 # Sliding window: inactive sessions expire after 7 days
SESSION_TOKEN_HARD_CEILING_DAYS: int = 30 # Absolute token lifetime for itsdangerous max_age
# MFA token config (short-lived token bridging password OK → TOTP verification)
MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes

View File

@ -19,6 +19,7 @@ from app.database import AsyncSessionLocal
from app.models.settings import Settings
from app.models.reminder import Reminder
from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
from app.models.todo import Todo
from app.models.project import Project
from app.models.ntfy_sent import NtfySent
@ -39,26 +40,32 @@ UMBRA_URL = "http://10.0.69.35"
# ── Dedup helpers ─────────────────────────────────────────────────────────────
async def _already_sent(db: AsyncSession, key: str) -> bool:
async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]:
"""Batch-fetch recent notification keys for a user (within the 7-day purge window)."""
cutoff = datetime.now() - timedelta(days=7)
result = await db.execute(
select(NtfySent).where(NtfySent.notification_key == key)
select(NtfySent.notification_key).where(
NtfySent.user_id == user_id,
NtfySent.sent_at >= cutoff,
)
)
return result.scalar_one_or_none() is not None
return set(result.scalars().all())
async def _mark_sent(db: AsyncSession, key: str) -> None:
db.add(NtfySent(notification_key=key))
async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
db.add(NtfySent(notification_key=key, user_id=user_id))
await db.commit()
# ── Dispatch functions ────────────────────────────────────────────────────────
async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime) -> None:
async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime, sent_keys: set[str]) -> None:
"""Send notifications for reminders that are currently due and not dismissed/snoozed."""
# Mirror the filter from /api/reminders/due
# Mirror the filter from /api/reminders/due, scoped to this user
result = await db.execute(
select(Reminder).where(
and_(
Reminder.user_id == settings.user_id,
Reminder.remind_at <= now,
Reminder.is_dismissed == False, # noqa: E712
Reminder.is_active == True, # noqa: E712
@ -72,9 +79,9 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim
if reminder.snoozed_until and reminder.snoozed_until > now:
continue # respect snooze
# Key ties notification to the specific day to handle re-fires after midnight
key = f"reminder:{reminder.id}:{reminder.remind_at.date()}"
if await _already_sent(db, key):
# Key includes user_id to prevent cross-user dedup collisions
key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}"
if key in sent_keys:
continue
payload = build_reminder_notification(
@ -89,18 +96,23 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim
**payload,
)
if sent:
await _mark_sent(db, key)
await _mark_sent(db, key, settings.user_id)
sent_keys.add(key)
async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) -> None:
async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime, sent_keys: set[str]) -> None:
"""Send notifications for calendar events within the configured lead time window."""
lead_minutes = settings.ntfy_event_lead_minutes
# Window: events starting between now and (now + lead_minutes)
window_end = now + timedelta(minutes=lead_minutes)
# Scope events through calendar ownership
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == settings.user_id)
result = await db.execute(
select(CalendarEvent).where(
and_(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.start_datetime >= now,
CalendarEvent.start_datetime <= window_end,
# Exclude recurring parent templates — they duplicate the child instance rows.
@ -116,9 +128,9 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime)
today = now.date()
for event in events:
# Key includes the minute-precision start to avoid re-firing during the window
key = f"event:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}"
if await _already_sent(db, key):
# Key includes user_id to prevent cross-user dedup collisions
key = f"event:{settings.user_id}:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}"
if key in sent_keys:
continue
payload = build_event_notification(
@ -136,18 +148,19 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime)
**payload,
)
if sent:
await _mark_sent(db, key)
await _mark_sent(db, key, settings.user_id)
sent_keys.add(key)
async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
async def _dispatch_todos(db: AsyncSession, settings: Settings, today, sent_keys: set[str]) -> None:
"""Send notifications for incomplete todos due within the configured lead days."""
from datetime import date as date_type
lead_days = settings.ntfy_todo_lead_days
cutoff = today + timedelta(days=lead_days)
result = await db.execute(
select(Todo).where(
and_(
Todo.user_id == settings.user_id,
Todo.completed == False, # noqa: E712
Todo.due_date != None, # noqa: E711
Todo.due_date <= cutoff,
@ -157,8 +170,9 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
todos = result.scalars().all()
for todo in todos:
key = f"todo:{todo.id}:{today}"
if await _already_sent(db, key):
# Key includes user_id to prevent cross-user dedup collisions
key = f"todo:{settings.user_id}:{todo.id}:{today}"
if key in sent_keys:
continue
payload = build_todo_notification(
@ -174,10 +188,11 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
**payload,
)
if sent:
await _mark_sent(db, key)
await _mark_sent(db, key, settings.user_id)
sent_keys.add(key)
async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> None:
async def _dispatch_projects(db: AsyncSession, settings: Settings, today, sent_keys: set[str]) -> None:
"""Send notifications for projects with deadlines within the configured lead days."""
lead_days = settings.ntfy_project_lead_days
cutoff = today + timedelta(days=lead_days)
@ -185,6 +200,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non
result = await db.execute(
select(Project).where(
and_(
Project.user_id == settings.user_id,
Project.due_date != None, # noqa: E711
Project.due_date <= cutoff,
Project.status != "completed",
@ -194,8 +210,9 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non
projects = result.scalars().all()
for project in projects:
key = f"project:{project.id}:{today}"
if await _already_sent(db, key):
# Key includes user_id to prevent cross-user dedup collisions
key = f"project:{settings.user_id}:{project.id}:{today}"
if key in sent_keys:
continue
payload = build_project_notification(
@ -210,7 +227,23 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non
**payload,
)
if sent:
await _mark_sent(db, key)
await _mark_sent(db, key, settings.user_id)
sent_keys.add(key)
async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime) -> None:
"""Run all notification dispatches for a single user's settings."""
# Batch-fetch all sent keys once per user instead of one query per entity
sent_keys = await _get_sent_keys(db, settings.user_id)
if settings.ntfy_reminders_enabled:
await _dispatch_reminders(db, settings, now, sent_keys)
if settings.ntfy_events_enabled:
await _dispatch_events(db, settings, now, sent_keys)
if settings.ntfy_todos_enabled:
await _dispatch_todos(db, settings, now.date(), sent_keys)
if settings.ntfy_projects_enabled:
await _dispatch_projects(db, settings, now.date(), sent_keys)
async def _purge_old_sent_records(db: AsyncSession) -> None:
@ -240,29 +273,35 @@ async def run_notification_dispatch() -> None:
"""
Main dispatch function called by APScheduler every 60 seconds.
Uses AsyncSessionLocal directly not the get_db() request-scoped dependency.
Iterates over ALL users with ntfy enabled. Per-user errors are caught and
logged individually so one user's failure does not prevent others from
receiving notifications.
"""
try:
async with AsyncSessionLocal() as db:
result = await db.execute(select(Settings))
settings = result.scalar_one_or_none()
# Fetch all Settings rows that have ntfy enabled
result = await db.execute(
select(Settings).where(Settings.ntfy_enabled == True) # noqa: E712
)
all_settings = result.scalars().all()
if not settings or not settings.ntfy_enabled:
if not all_settings:
return
# See DATETIME NOTE at top of file re: naive datetime usage
now = datetime.now()
today = now.date()
if settings.ntfy_reminders_enabled:
await _dispatch_reminders(db, settings, now)
if settings.ntfy_events_enabled:
await _dispatch_events(db, settings, now)
if settings.ntfy_todos_enabled:
await _dispatch_todos(db, settings, today)
if settings.ntfy_projects_enabled:
await _dispatch_projects(db, settings, today)
for user_settings in all_settings:
try:
await _dispatch_for_user(db, user_settings, now)
except Exception:
# Isolate per-user failures — log and continue to next user
logger.exception(
"ntfy dispatch failed for user_id=%s", user_settings.user_id
)
# Daily housekeeping: purge stale dedup records
# Daily housekeeping: purge stale dedup records (shared across all users)
await _purge_old_sent_records(db)
# Security housekeeping runs every cycle regardless of ntfy_enabled

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
from app.routers import totp, admin
from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them
@ -15,6 +15,61 @@ from app.models import user as _user_model # noqa: F401
from app.models import session as _session_model # noqa: F401
from app.models import totp_usage as _totp_usage_model # noqa: F401
from app.models import backup_code as _backup_code_model # noqa: F401
from app.models import system_config as _system_config_model # noqa: F401
from app.models import audit_log as _audit_log_model # noqa: F401
# ---------------------------------------------------------------------------
# Pure ASGI CSRF middleware — SEC-08 (global)
# ---------------------------------------------------------------------------
class CSRFHeaderMiddleware:
"""
Require X-Requested-With: XMLHttpRequest on all state-mutating requests.
Browsers never send this header cross-origin without a CORS preflight,
which our CORS policy blocks. This prevents CSRF attacks from simple
form submissions and cross-origin fetches.
Uses pure ASGI (not BaseHTTPMiddleware) to avoid streaming/memory overhead.
"""
_EXEMPT_PATHS = frozenset({
"/health",
"/",
"/api/auth/login",
"/api/auth/setup",
"/api/auth/register",
"/api/auth/totp-verify",
"/api/auth/totp/enforce-setup",
"/api/auth/totp/enforce-confirm",
})
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
method = scope.get("method", "")
path = scope.get("path", "")
if method in self._MUTATING_METHODS and path not in self._EXEMPT_PATHS:
headers = dict(scope.get("headers", []))
if headers.get(b"x-requested-with") != b"XMLHttpRequest":
body = b'{"detail":"Invalid request origin"}'
await send({
"type": "http.response.start",
"status": 403,
"headers": [
[b"content-type", b"application/json"],
[b"content-length", str(len(body)).encode()],
],
})
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
@asynccontextmanager
@ -45,13 +100,18 @@ app = FastAPI(
openapi_url="/openapi.json" if _is_dev else None,
)
# CORS configuration
# Middleware stack — added in reverse order (last added = outermost).
# CSRF is added first (innermost), then CORS wraps it (outermost).
# This ensures CORS headers appear on CSRF 403 responses.
app.add_middleware(CSRFHeaderMiddleware)
# CORS configuration — outermost layer
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "Cookie"],
allow_headers=["Content-Type", "Authorization", "Cookie", "X-Requested-With"],
)
# Include routers with /api prefix
@ -68,6 +128,7 @@ 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(admin.router, prefix="/api/admin", tags=["Admin"])
@app.get("/")

View File

@ -13,6 +13,8 @@ from app.models.session import UserSession
from app.models.ntfy_sent import NtfySent
from app.models.totp_usage import TOTPUsage
from app.models.backup_code import BackupCode
from app.models.system_config import SystemConfig
from app.models.audit_log import AuditLog
__all__ = [
"Settings",
@ -30,4 +32,6 @@ __all__ = [
"NtfySent",
"TOTPUsage",
"BackupCode",
"SystemConfig",
"AuditLog",
]

View File

@ -0,0 +1,27 @@
from sqlalchemy import String, Text, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
from app.database import Base
class AuditLog(Base):
"""
Append-only audit trail for admin actions and auth events.
No DELETE endpoint this table is immutable once written.
"""
__tablename__ = "audit_log"
id: Mapped[int] = mapped_column(primary_key=True)
actor_user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
target_user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
detail: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(
default=func.now(), server_default=func.now(), index=True
)

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Boolean, func
from sqlalchemy import String, Boolean, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import List
@ -9,6 +9,9 @@ class Calendar(Base):
__tablename__ = "calendars"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str] = mapped_column(String(20), nullable=False, default="#3b82f6")
is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")

View File

@ -15,10 +15,10 @@ class CalendarEvent(Base):
end_datetime: Mapped[datetime] = mapped_column(nullable=False)
all_day: Mapped[bool] = mapped_column(Boolean, default=False)
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
location_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("locations.id"), nullable=True)
location_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("locations.id", ondelete="SET NULL"), nullable=True)
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
is_starred: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
calendar_id: Mapped[int] = mapped_column(Integer, ForeignKey("calendars.id"), nullable=False)
calendar_id: Mapped[int] = mapped_column(Integer, ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False)
# Recurrence fields
# parent_event_id: set on child events; NULL on the parent template row

View File

@ -9,6 +9,9 @@ class EventTemplate(Base):
__tablename__ = "event_templates"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
@ -21,4 +24,4 @@ class EventTemplate(Base):
Integer, ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
is_starred: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(default=datetime.now, server_default=func.now())
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Text, Boolean, func, text
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional, List
@ -9,6 +9,9 @@ class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
address: Mapped[str] = mapped_column(Text, nullable=False)
category: Mapped[str] = mapped_column(String(100), default="other")

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, func
from sqlalchemy import String, Integer, ForeignKey, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
@ -8,7 +8,7 @@ class NtfySent(Base):
"""
Deduplication table for ntfy notifications.
Prevents the background job from re-sending the same notification
within a given time window.
within a given time window. Scoped per-user.
Key format: "{type}:{entity_id}:{date_window}"
Examples:
@ -18,7 +18,13 @@ class NtfySent(Base):
"project:3:2026-02-25"
"""
__tablename__ = "ntfy_sent"
__table_args__ = (
UniqueConstraint("user_id", "notification_key", name="uq_ntfy_sent_user_key"),
)
id: Mapped[int] = mapped_column(primary_key=True)
notification_key: Mapped[str] = mapped_column(String(255), unique=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
notification_key: Mapped[str] = mapped_column(String(255), index=True)
sent_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Text, Date, Boolean, func, text
from sqlalchemy import String, Text, Date, Boolean, Integer, ForeignKey, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, date
from typing import Optional, List
@ -9,6 +9,9 @@ class Person(Base):
__tablename__ = "people"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)

View File

@ -1,5 +1,5 @@
import sqlalchemy as sa
from sqlalchemy import Boolean, String, Text, Date, func
from sqlalchemy import Boolean, String, Text, Date, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, date
from typing import Optional, List
@ -10,6 +10,9 @@ class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="not_started")

View File

@ -9,7 +9,7 @@ class ProjectTask(Base):
__tablename__ = "project_tasks"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
parent_task_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=True
)
@ -18,7 +18,7 @@ class ProjectTask(Base):
status: Mapped[str] = mapped_column(String(20), default="pending")
priority: Mapped[str] = mapped_column(String(20), default="medium")
due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
person_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("people.id"), 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)
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Text, Boolean, func
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
@ -9,6 +9,9 @@ class Reminder(Base):
__tablename__ = "reminders"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
remind_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)

View File

@ -0,0 +1,27 @@
from sqlalchemy import Boolean, CheckConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class SystemConfig(Base):
"""
Singleton system configuration table (always id=1).
Stores global toggles for registration, MFA enforcement, etc.
"""
__tablename__ = "system_config"
__table_args__ = (
CheckConstraint("id = 1", name="ck_system_config_singleton"),
)
id: Mapped[int] = mapped_column(primary_key=True)
allow_registration: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
enforce_mfa_new_users: 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

@ -9,6 +9,9 @@ class Todo(Base):
__tablename__ = "todos"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
priority: Mapped[str] = mapped_column(String(20), default="medium")
@ -20,7 +23,7 @@ class Todo(Base):
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
reset_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, index=True)
next_due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id"), nullable=True)
project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
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

@ -9,6 +9,9 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True, index=True)
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
# MFA — populated in Track B
@ -23,7 +26,23 @@ class User(Base):
# Account state
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# RBAC
role: Mapped[str] = mapped_column(
String(30), nullable=False, default="standard", server_default="standard"
)
# MFA enforcement (admin can toggle; checked at login)
mfa_enforce_pending: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
# Forced password change (set after admin reset)
must_change_password: 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())
last_login_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None)
last_password_change_at: Mapped[datetime | None] = mapped_column(nullable=True, default=None)

View File

@ -0,0 +1,802 @@
"""
Admin router full user management, system config, and audit log.
Security measures implemented:
SEC-02: Session revocation on role change
SEC-05: Block admin self-actions (own role/password/MFA/active status)
SEC-08: X-Requested-With validation (now handled globally by CSRFHeaderMiddleware)
SEC-13: Session revocation + ntfy alert on MFA disable
All routes require the `require_admin` dependency (which chains through
get_current_user, so the session cookie is always validated).
"""
import secrets
from datetime import datetime
from typing import Optional
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.audit_log import AuditLog
from app.models.backup_code import BackupCode
from app.models.session import UserSession
from app.models.settings import Settings
from app.models.system_config import SystemConfig
from app.models.user import User
from app.routers.auth import (
_create_user_defaults,
get_current_user,
require_admin,
)
from app.schemas.admin import (
AdminDashboardResponse,
AuditLogEntry,
AuditLogResponse,
CreateUserRequest,
DeleteUserResponse,
ResetPasswordResponse,
SystemConfigResponse,
SystemConfigUpdate,
ToggleActiveRequest,
ToggleMfaEnforceRequest,
UpdateUserRoleRequest,
UserDetailResponse,
UserListItem,
UserListResponse,
)
from app.services.audit import log_audit_event
from app.services.auth import hash_password
# ---------------------------------------------------------------------------
# Router — all endpoints inherit require_admin
# (SEC-08 CSRF validation is now handled globally by CSRFHeaderMiddleware)
# ---------------------------------------------------------------------------
router = APIRouter(
dependencies=[Depends(require_admin)],
)
# ---------------------------------------------------------------------------
# Audit log helper — resolve target username even for deleted users
# ---------------------------------------------------------------------------
def _target_username_col(target_alias, audit_model):
"""
COALESCE: prefer the live username from the users table,
fall back to the username stored in the audit detail JSON
(survives user deletion since audit_log.target_user_id SET NULL).
"""
return sa.func.coalesce(
target_alias.username,
sa.cast(audit_model.detail, JSONB)["username"].as_string(),
).label("target_username")
# ---------------------------------------------------------------------------
# Session revocation helper (used in multiple endpoints)
# ---------------------------------------------------------------------------
async def _revoke_all_sessions(db: AsyncSession, user_id: int) -> int:
"""Mark every active session for user_id as revoked. Returns count revoked."""
result = await db.execute(
sa.update(UserSession)
.where(UserSession.user_id == user_id, UserSession.revoked == False)
.values(revoked=True)
.returning(UserSession.id)
)
return len(result.fetchall())
# ---------------------------------------------------------------------------
# Self-action guard — SEC-05
# ---------------------------------------------------------------------------
def _guard_self_action(actor: User, target_id: int, action: str) -> None:
"""Raise 403 if an admin attempts a privileged action against their own account."""
if actor.id == target_id:
raise HTTPException(
status_code=403,
detail=f"Admins cannot {action} their own account",
)
# ---------------------------------------------------------------------------
# GET /users
# ---------------------------------------------------------------------------
@router.get("/users", response_model=UserListResponse)
async def list_users(
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
):
"""Return all users with basic stats including active session counts."""
active_sub = (
sa.select(sa.func.count())
.select_from(UserSession)
.where(
UserSession.user_id == User.id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
)
.correlate(User)
.scalar_subquery()
)
result = await db.execute(
sa.select(User, active_sub.label("active_sessions"))
.order_by(User.created_at)
)
rows = result.all()
return UserListResponse(
users=[
UserListItem(
**UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}),
active_sessions=count,
)
for user, count in rows
],
total=len(rows),
)
# ---------------------------------------------------------------------------
# GET /users/{user_id}
# ---------------------------------------------------------------------------
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
):
"""Return a single user with their active session count and preferred_name."""
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")
session_result = await db.execute(
sa.select(sa.func.count()).select_from(UserSession).where(
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
)
)
active_sessions = session_result.scalar_one()
# Fetch preferred_name from Settings
settings_result = await db.execute(
sa.select(Settings.preferred_name).where(Settings.user_id == user_id)
)
preferred_name = settings_result.scalar_one_or_none()
return UserDetailResponse(
**UserListItem.model_validate(user).model_dump(exclude={"active_sessions"}),
active_sessions=active_sessions,
preferred_name=preferred_name,
)
# ---------------------------------------------------------------------------
# POST /users
# ---------------------------------------------------------------------------
@router.post("/users", response_model=UserDetailResponse, status_code=201)
async def create_user(
data: CreateUserRequest,
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""Admin-create a user with Settings and default calendars."""
existing = await db.execute(sa.select(User).where(User.username == data.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Username already taken")
# Check email uniqueness if provided
email = data.email.strip().lower() if data.email else None
if email:
email_exists = await db.execute(sa.select(User).where(User.email == email))
if email_exists.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Email already in use")
new_user = User(
username=data.username,
password_hash=hash_password(data.password),
role=data.role,
email=email,
first_name=data.first_name,
last_name=data.last_name,
last_password_change_at=datetime.now(),
# Force password change so the user sets their own credential
must_change_password=True,
)
db.add(new_user)
await db.flush() # populate new_user.id
await _create_user_defaults(db, new_user.id, preferred_name=data.preferred_name)
await log_audit_event(
db,
action="admin.user_created",
actor_id=actor.id,
target_id=new_user.id,
detail={"username": new_user.username, "role": new_user.role},
ip=request.client.host if request.client else None,
)
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="Username or email already in use")
return UserDetailResponse(
**UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}),
active_sessions=0,
)
# ---------------------------------------------------------------------------
# PUT /users/{user_id}/role — SEC-02, SEC-05
# ---------------------------------------------------------------------------
@router.put("/users/{user_id}/role")
async def update_user_role(
user_id: int = Path(ge=1, le=2147483647),
data: UpdateUserRoleRequest = ...,
request: Request = ...,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Change a user's role.
Blocks demotion of the last admin (SEC-05 variant).
Revokes all sessions after role change (SEC-02).
"""
_guard_self_action(actor, user_id, "change role of")
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")
# Prevent demoting the last admin
if user.role == "admin" and data.role != "admin":
admin_count = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.role == "admin")
)
if admin_count <= 1:
raise HTTPException(
status_code=409,
detail="Cannot demote the last admin account",
)
old_role = user.role
user.role = data.role
# SEC-02: revoke sessions so the new role takes effect immediately
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.role_changed",
actor_id=actor.id,
target_id=user_id,
detail={"old_role": old_role, "new_role": data.role, "sessions_revoked": revoked},
ip=request.client.host if request.client else None,
)
await db.commit()
return {"message": f"Role updated to '{data.role}'. {revoked} session(s) revoked."}
# ---------------------------------------------------------------------------
# POST /users/{user_id}/reset-password — SEC-05
# ---------------------------------------------------------------------------
@router.post("/users/{user_id}/reset-password", response_model=ResetPasswordResponse)
async def reset_user_password(
user_id: int = Path(ge=1, le=2147483647),
request: Request = ...,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Generate a temporary password, revoke all sessions, and mark must_change_password.
The admin is shown the plaintext temp password once it is not stored.
"""
_guard_self_action(actor, user_id, "reset the password of")
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")
temp_password = secrets.token_urlsafe(16)
user.password_hash = hash_password(temp_password)
user.must_change_password = True
user.last_password_change_at = datetime.now()
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.password_reset",
actor_id=actor.id,
target_id=user_id,
detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None,
)
await db.commit()
return ResetPasswordResponse(
message=f"Password reset. {revoked} session(s) revoked. User must change password on next login.",
temporary_password=temp_password,
)
# ---------------------------------------------------------------------------
# POST /users/{user_id}/disable-mfa — SEC-05, SEC-13
# ---------------------------------------------------------------------------
@router.post("/users/{user_id}/disable-mfa")
async def disable_user_mfa(
user_id: int = Path(ge=1, le=2147483647),
request: Request = ...,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Clear TOTP secret + backup codes and revoke all sessions (SEC-13).
"""
_guard_self_action(actor, user_id, "disable MFA 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.totp_enabled:
raise HTTPException(status_code=409, detail="MFA is not enabled for this user")
# Clear TOTP data
user.totp_secret = None
user.totp_enabled = False
user.mfa_enforce_pending = False
# Remove all backup codes
await db.execute(
sa.delete(BackupCode).where(BackupCode.user_id == user_id)
)
# SEC-13: revoke sessions so the MFA downgrade takes effect immediately
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.mfa_disabled",
actor_id=actor.id,
target_id=user_id,
detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None,
)
await db.commit()
return {"message": f"MFA disabled. {revoked} session(s) revoked."}
# ---------------------------------------------------------------------------
# PUT /users/{user_id}/enforce-mfa — SEC-05
# ---------------------------------------------------------------------------
@router.put("/users/{user_id}/enforce-mfa")
async def toggle_mfa_enforce(
user_id: int = Path(ge=1, le=2147483647),
data: ToggleMfaEnforceRequest = ...,
request: Request = ...,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""Toggle the mfa_enforce_pending flag. Next login will prompt MFA setup."""
_guard_self_action(actor, user_id, "toggle MFA enforcement 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")
user.mfa_enforce_pending = data.enforce
await log_audit_event(
db,
action="admin.mfa_enforce_toggled",
actor_id=actor.id,
target_id=user_id,
detail={"enforce": data.enforce},
ip=request.client.host if request.client else None,
)
await db.commit()
return {"message": f"MFA enforcement {'enabled' if data.enforce else 'disabled'} for user."}
# ---------------------------------------------------------------------------
# PUT /users/{user_id}/active — SEC-05
# ---------------------------------------------------------------------------
@router.put("/users/{user_id}/active")
async def toggle_user_active(
user_id: int = Path(ge=1, le=2147483647),
data: ToggleActiveRequest = ...,
request: Request = ...,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Enable or disable a user account.
Revoking an account also revokes all active sessions immediately.
"""
_guard_self_action(actor, user_id, "change active status of")
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")
user.is_active = data.is_active
revoked = 0
if not data.is_active:
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.user_deactivated" if not data.is_active else "admin.user_activated",
actor_id=actor.id,
target_id=user_id,
detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None,
)
await db.commit()
state = "activated" if data.is_active else f"deactivated ({revoked} session(s) revoked)"
return {"message": f"User {state}."}
# ---------------------------------------------------------------------------
# DELETE /users/{user_id}/sessions
# ---------------------------------------------------------------------------
@router.delete("/users/{user_id}/sessions")
async def revoke_user_sessions(
user_id: int = Path(ge=1, le=2147483647),
request: Request = ...,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""Forcibly revoke all active sessions 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")
revoked = await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.sessions_revoked",
actor_id=actor.id,
target_id=user_id,
detail={"sessions_revoked": revoked},
ip=request.client.host if request.client else None,
)
await db.commit()
return {"message": f"{revoked} session(s) revoked."}
# ---------------------------------------------------------------------------
# DELETE /users/{user_id} — hard delete user + all data
# ---------------------------------------------------------------------------
@router.delete("/users/{user_id}", response_model=DeleteUserResponse)
async def delete_user(
user_id: int = Path(ge=1, le=2147483647),
request: Request = ...,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""
Permanently delete a user and all their data.
DB CASCADE rules handle child row cleanup.
"""
_guard_self_action(actor, user_id, "delete")
result = await db.execute(sa.select(User).where(User.id == user_id))
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="User not found")
# Prevent deleting the last admin
if target.role == "admin":
admin_count = await db.scalar(
sa.select(sa.func.count()).select_from(User).where(User.role == "admin")
)
if admin_count <= 1:
raise HTTPException(
status_code=409,
detail="Cannot delete the last admin account",
)
deleted_username = target.username
# Belt-and-suspenders: explicitly revoke sessions before delete
await _revoke_all_sessions(db, user_id)
await log_audit_event(
db,
action="admin.user_deleted",
actor_id=actor.id,
target_id=user_id,
detail={"user_id": user_id, "username": deleted_username},
ip=request.client.host if request.client else None,
)
# Flush audit + session revocation within the same transaction
await db.flush()
# DB CASCADE handles all child rows; SET NULL fires on audit_log.target_user_id
await db.delete(target)
await db.commit()
return DeleteUserResponse(
message=f"User '{deleted_username}' permanently deleted.",
deleted_username=deleted_username,
)
# ---------------------------------------------------------------------------
# GET /users/{user_id}/sessions
# ---------------------------------------------------------------------------
@router.get("/users/{user_id}/sessions")
async def list_user_sessions(
user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
):
"""List all active (non-revoked, non-expired) sessions 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")
sessions_result = await db.execute(
sa.select(UserSession).where(
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
).order_by(UserSession.created_at.desc())
)
sessions = sessions_result.scalars().all()
return {
"sessions": [
{
"id": s.id,
"created_at": s.created_at,
"expires_at": s.expires_at,
"ip_address": s.ip_address,
"user_agent": s.user_agent,
}
for s in sessions
],
"total": len(sessions),
}
# ---------------------------------------------------------------------------
# GET /config
# ---------------------------------------------------------------------------
@router.get("/config", response_model=SystemConfigResponse)
async def get_system_config(
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
):
"""Fetch the singleton system configuration row."""
result = await db.execute(sa.select(SystemConfig).where(SystemConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
# Bootstrap the singleton if it doesn't exist yet
config = SystemConfig(id=1)
db.add(config)
await db.commit()
return config
# ---------------------------------------------------------------------------
# PUT /config
# ---------------------------------------------------------------------------
@router.put("/config", response_model=SystemConfigResponse)
async def update_system_config(
data: SystemConfigUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
actor: User = Depends(get_current_user),
):
"""Update one or more system config fields (partial update)."""
result = await db.execute(sa.select(SystemConfig).where(SystemConfig.id == 1))
config = result.scalar_one_or_none()
if not config:
config = SystemConfig(id=1)
db.add(config)
await db.flush()
changes: dict = {}
if data.allow_registration is not None:
changes["allow_registration"] = data.allow_registration
config.allow_registration = data.allow_registration
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 changes:
await log_audit_event(
db,
action="admin.config_updated",
actor_id=actor.id,
detail=changes,
ip=request.client.host if request.client else None,
)
await db.commit()
return config
# ---------------------------------------------------------------------------
# GET /dashboard
# ---------------------------------------------------------------------------
@router.get("/dashboard", response_model=AdminDashboardResponse)
async def admin_dashboard(
db: AsyncSession = Depends(get_db),
_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)
)
active_sessions = await db.scalar(
sa.select(sa.func.count()).select_from(UserSession).where(
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
)
)
mfa_adoption = (totp_count / total_users) if total_users else 0.0
# 10 most recent logins
recent_logins_result = await db.execute(
sa.select(User.username, User.last_login_at)
.where(User.last_login_at != None)
.order_by(User.last_login_at.desc())
.limit(10)
)
recent_logins = [
{"username": row.username, "last_login_at": row.last_login_at}
for row in recent_logins_result
]
# 10 most recent audit entries — resolve usernames via JOINs
actor_user = sa.orm.aliased(User, name="actor_user")
target_user = sa.orm.aliased(User, name="target_user")
recent_audit_result = await db.execute(
sa.select(
AuditLog,
actor_user.username.label("actor_username"),
_target_username_col(target_user, AuditLog),
)
.outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id)
.outerjoin(target_user, AuditLog.target_user_id == target_user.id)
.order_by(AuditLog.created_at.desc())
.limit(10)
)
recent_audit_entries = [
{
"action": row.AuditLog.action,
"actor_username": row.actor_username,
"target_username": row.target_username,
"created_at": row.AuditLog.created_at,
}
for row in recent_audit_result
]
return AdminDashboardResponse(
total_users=total_users or 0,
active_users=active_users or 0,
admin_count=admin_count or 0,
active_sessions=active_sessions or 0,
mfa_adoption_rate=round(mfa_adoption, 4),
recent_logins=recent_logins,
recent_audit_entries=recent_audit_entries,
)
# ---------------------------------------------------------------------------
# GET /audit-log
# ---------------------------------------------------------------------------
@router.get("/audit-log", response_model=AuditLogResponse)
async def get_audit_log(
db: AsyncSession = Depends(get_db),
_actor: User = Depends(get_current_user),
action: Optional[str] = Query(None, description="Filter by action string (prefix match)"),
target_user_id: Optional[int] = Query(None, description="Filter by target user ID"),
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
per_page: int = Query(50, ge=1, le=200, description="Results per page"),
):
"""
Paginated audit log with optional filters.
Resolves actor and target user IDs to usernames via a JOIN.
"""
# Aliases for the two user joins
actor_user = sa.orm.aliased(User, name="actor_user")
target_user = sa.orm.aliased(User, name="target_user")
# Base query — left outer join so entries with NULL actor/target still appear
base_q = (
sa.select(
AuditLog,
actor_user.username.label("actor_username"),
_target_username_col(target_user, AuditLog),
)
.outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id)
.outerjoin(target_user, AuditLog.target_user_id == target_user.id)
)
if action:
base_q = base_q.where(AuditLog.action.like(f"{action}%"))
if target_user_id is not None:
base_q = base_q.where(AuditLog.target_user_id == target_user_id)
# Count before pagination
count_q = sa.select(sa.func.count()).select_from(
base_q.subquery()
)
total = await db.scalar(count_q) or 0
# Paginate
offset = (page - 1) * per_page
rows_result = await db.execute(
base_q.order_by(AuditLog.created_at.desc()).offset(offset).limit(per_page)
)
entries = [
AuditLogEntry(
id=row.AuditLog.id,
actor_username=row.actor_username,
target_username=row.target_username,
action=row.AuditLog.action,
detail=row.AuditLog.detail,
ip_address=row.AuditLog.ip_address,
created_at=row.AuditLog.created_at,
)
for row in rows_result
]
return AuditLogResponse(entries=entries, total=total)

View File

@ -1,18 +1,20 @@
"""
Authentication router username/password with DB-backed sessions and account lockout.
Authentication router username/password with DB-backed sessions, account lockout,
role-based access control, and multi-user registration.
Session flow:
POST /setup create User + Settings row issue session cookie
POST /login verify credentials check lockout insert UserSession issue cookie
if TOTP enabled: return mfa_token instead of full session
POST /setup create admin User + Settings + calendars issue session cookie
POST /login verify credentials check lockout MFA/enforce checks issue session
POST /register create standard user (when registration enabled)
POST /logout mark session revoked in DB delete cookie
GET /status verify user exists + session valid
GET /status verify user exists + session valid + role + registration_open
Security layers:
1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) outer guard on all auth endpoints
2. DB-backed account lockout (10 failures 30-min lock, HTTP 423) per-user guard
1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) outer guard on auth endpoints
2. DB-backed account lockout (10 failures 30-min lock, HTTP 423)
3. Session revocation stored in DB (survives container restarts)
4. bcryptArgon2id transparent upgrade on first login with migrated hash
4. bcryptArgon2id transparent upgrade on first login
5. Role-based authorization via require_role() dependency factory
"""
import uuid
from datetime import datetime, timedelta
@ -20,24 +22,38 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Cookie
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, func
from app.database import get_db
from app.models.user import User
from app.models.session import UserSession
from app.models.settings import Settings
from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest, VerifyPasswordRequest
from app.models.system_config import SystemConfig
from app.models.calendar import Calendar
from app.schemas.auth import (
SetupRequest, LoginRequest, RegisterRequest,
ChangePasswordRequest, VerifyPasswordRequest,
)
from app.services.auth import (
hash_password,
verify_password,
verify_password_with_upgrade,
create_session_token,
verify_session_token,
create_mfa_token,
create_mfa_enforce_token,
)
from app.services.audit import log_audit_event
from app.config import settings as app_settings
router = APIRouter()
# Pre-computed dummy hash for timing equalization (M-02).
# When a login attempt targets a non-existent username, we still run
# Argon2id verification against this dummy hash so the response time
# is indistinguishable from a wrong-password attempt.
_DUMMY_HASH = hash_password("timing-equalization-dummy")
# ---------------------------------------------------------------------------
# Cookie helper
# ---------------------------------------------------------------------------
@ -59,13 +75,16 @@ def _set_session_cookie(response: Response, token: str) -> None:
async def get_current_user(
request: Request,
response: Response,
session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db),
) -> User:
"""
Dependency that verifies the session cookie and returns the authenticated User.
Replaces the old get_current_session (which returned Settings).
Any router that hasn't been updated will get a compile-time type error.
L-03 sliding window: if the session has less than
(SESSION_MAX_AGE_DAYS - 1) days remaining, silently extend expires_at
and re-issue the cookie so active users never hit expiration.
"""
if not session_cookie:
raise HTTPException(status_code=401, detail="Not authenticated")
@ -99,6 +118,17 @@ async def get_current_user(
if not user:
raise HTTPException(status_code=401, detail="User not found or inactive")
# L-03: Sliding window renewal — extend session if >1 day has elapsed since
# last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day).
now = datetime.now()
renewal_threshold = timedelta(days=app_settings.SESSION_MAX_AGE_DAYS - 1)
if db_session.expires_at - now < renewal_threshold:
db_session.expires_at = now + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
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)
return user
@ -119,6 +149,24 @@ async def get_current_settings(
return settings_obj
# ---------------------------------------------------------------------------
# Role-based authorization dependencies
# ---------------------------------------------------------------------------
def require_role(*allowed_roles: str):
"""Factory: returns a dependency that enforces role membership."""
async def _check(
current_user: User = Depends(get_current_user),
) -> User:
if current_user.role not in allowed_roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return current_user
return _check
# Convenience aliases
require_admin = require_role("admin")
# ---------------------------------------------------------------------------
# Account lockout helpers
# ---------------------------------------------------------------------------
@ -166,15 +214,36 @@ async def _create_db_session(
id=session_id,
user_id=user.id,
expires_at=expires_at,
ip_address=ip[:45] if ip else None, # clamp to column width
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()
await db.flush()
token = create_session_token(user.id, session_id)
return session_id, token
# ---------------------------------------------------------------------------
# User bootstrapping helper (Settings + default calendars)
# ---------------------------------------------------------------------------
async def _create_user_defaults(
db: AsyncSession, user_id: int, *, preferred_name: str | None = None,
) -> None:
"""Create Settings row and default calendars for a new user."""
db.add(Settings(user_id=user_id, preferred_name=preferred_name))
db.add(Calendar(
name="Personal", color="#3b82f6",
is_default=True, is_system=False, is_visible=True,
user_id=user_id,
))
db.add(Calendar(
name="Birthdays", color="#f59e0b",
is_default=False, is_system=True, is_visible=True,
user_id=user_id,
))
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@ -187,28 +256,35 @@ async def setup(
db: AsyncSession = Depends(get_db),
):
"""
First-time setup: create the User record and a linked Settings row.
First-time setup: create the admin User + Settings + default calendars.
Only works when no users exist (i.e., fresh install).
"""
existing = await db.execute(select(User))
if existing.scalar_one_or_none():
user_count = await db.execute(select(func.count()).select_from(User))
if user_count.scalar_one() > 0:
raise HTTPException(status_code=400, detail="Setup already completed")
password_hash = hash_password(data.password)
new_user = User(username=data.username, password_hash=password_hash)
new_user = User(
username=data.username,
password_hash=password_hash,
role="admin",
last_password_change_at=datetime.now(),
)
db.add(new_user)
await db.flush() # assign new_user.id before creating Settings
await db.flush()
# Create Settings row linked to this user with all defaults
new_settings = Settings(user_id=new_user.id)
db.add(new_settings)
await db.commit()
await _create_user_defaults(db, new_user.id)
ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent")
_, 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,
)
await db.commit()
return {"message": "Setup completed successfully", "authenticated": True}
@ -223,37 +299,70 @@ async def login(
Authenticate with username + password.
Returns:
{ authenticated: true } on success (no TOTP)
{ authenticated: true } on success (no TOTP, no enforcement)
{ authenticated: false, totp_required: true, mfa_token: "..." } TOTP pending
HTTP 401 wrong credentials (generic; never reveals which field is wrong)
{ authenticated: false, mfa_setup_required: true, mfa_token: "..." } MFA enforcement
{ authenticated: false, must_change_password: true } forced password change after admin reset
HTTP 401 wrong credentials
HTTP 403 account disabled (is_active=False)
HTTP 423 account locked
HTTP 429 IP rate limited
"""
client_ip = request.client.host if request.client else "unknown"
# Lookup user — do NOT differentiate "user not found" from "wrong password"
result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none()
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)
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)
await _check_account_lockout(user)
# Transparent bcrypt→Argon2id upgrade
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
if not valid:
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,
)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password")
# Persist upgraded hash if migration happened
# Block disabled accounts — checked AFTER password verification to avoid
# 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(
db, action="auth.login_blocked_inactive", actor_id=user.id,
detail={"reason": "account_disabled"}, ip=client_ip,
)
await db.commit()
raise HTTPException(status_code=403, detail="Account is disabled. Contact an administrator.")
if new_hash:
user.password_hash = new_hash
await _record_successful_login(db, user)
# If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session
# SEC-03: MFA enforcement — block login entirely until MFA setup completes
if user.mfa_enforce_pending and not user.totp_enabled:
enforce_token = create_mfa_enforce_token(user.id)
await log_audit_event(
db, action="auth.mfa_enforce_prompted", actor_id=user.id, ip=client_ip,
)
await db.commit()
return {
"authenticated": False,
"mfa_setup_required": True,
"mfa_token": enforce_token,
}
# If TOTP is enabled, issue a short-lived MFA challenge token
if user.totp_enabled:
mfa_token = create_mfa_token(user.id)
return {
@ -262,13 +371,98 @@ async def login(
"mfa_token": mfa_token,
}
# SEC-12: Forced password change after admin reset
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)
await db.commit()
return {
"authenticated": True,
"must_change_password": True,
}
user_agent = request.headers.get("user-agent")
_, 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,
)
await db.commit()
return {"authenticated": True}
@router.post("/register")
async def register(
data: RegisterRequest,
response: Response,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""
Create a new standard user account.
Only available when system_config.allow_registration is True.
"""
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_registration:
raise HTTPException(status_code=403, detail="Registration is not available")
# Check username availability (generic error to prevent enumeration)
existing = await db.execute(
select(User).where(User.username == data.username)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.")
password_hash = hash_password(data.password)
# SEC-01: Explicit field assignment — never **data.model_dump()
new_user = User(
username=data.username,
password_hash=password_hash,
role="standard",
last_password_change_at=datetime.now(),
)
# Check if MFA enforcement is enabled for new users
if config.enforce_mfa_new_users:
new_user.mfa_enforce_pending = True
db.add(new_user)
await db.flush()
await _create_user_defaults(db, new_user.id)
ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent")
await log_audit_event(
db, action="auth.registration", actor_id=new_user.id, ip=ip,
)
await db.commit()
# If MFA enforcement is pending, don't issue a session — require MFA setup first
if new_user.mfa_enforce_pending:
enforce_token = create_mfa_enforce_token(new_user.id)
return {
"message": "Registration successful",
"authenticated": False,
"mfa_setup_required": True,
"mfa_token": enforce_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}
@router.post("/logout")
async def logout(
response: Response,
@ -304,13 +498,14 @@ async def auth_status(
db: AsyncSession = Depends(get_db),
):
"""
Check authentication status and whether initial setup has been performed.
Used by the frontend to decide whether to show login vs setup screen.
Check authentication status, role, and whether initial setup/registration is available.
"""
user_result = await db.execute(select(User))
existing_user = user_result.scalar_one_or_none()
setup_required = existing_user is None
user_count_result = await db.execute(
select(func.count()).select_from(User)
)
setup_required = user_count_result.scalar_one() == 0
authenticated = False
role = None
if not setup_required and session_cookie:
payload = verify_session_token(session_cookie)
@ -326,9 +521,33 @@ async def auth_status(
UserSession.expires_at > datetime.now(),
)
)
authenticated = session_result.scalar_one_or_none() is not None
if session_result.scalar_one_or_none() is not None:
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:
role = u.role
else:
authenticated = False
return {"authenticated": authenticated, "setup_required": setup_required}
# Check registration availability
registration_open = False
if not setup_required:
config_result = await db.execute(
select(SystemConfig).where(SystemConfig.id == 1)
)
config = config_result.scalar_one_or_none()
registration_open = config.allow_registration if config else False
return {
"authenticated": authenticated,
"setup_required": setup_required,
"role": role,
"username": u.username if authenticated and u else None,
"registration_open": registration_open,
}
@router.post("/verify-password")
@ -340,8 +559,6 @@ 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.
Also handles transparent bcryptArgon2id upgrade.
Shares the same lockout guards as /login. Nginx limit_req_zone handles IP rate limiting.
"""
await _check_account_lockout(current_user)
@ -350,7 +567,6 @@ async def verify_password(
await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid password")
# Persist upgraded hash if migration happened
if new_hash:
current_user.password_hash = new_hash
await db.commit()
@ -372,7 +588,16 @@ async def change_password(
await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid current password")
if data.new_password == data.old_password:
raise HTTPException(status_code=400, detail="New password must be different from your current password")
current_user.password_hash = hash_password(data.new_password)
current_user.last_password_change_at = datetime.now()
# Clear forced password change flag if set (SEC-12)
if current_user.must_change_password:
current_user.must_change_password = False
await db.commit()
return {"message": "Password changed successfully"}

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import List
@ -18,7 +18,11 @@ async def get_calendars(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
result = await db.execute(select(Calendar).order_by(Calendar.is_default.desc(), Calendar.name.asc()))
result = await db.execute(
select(Calendar)
.where(Calendar.user_id == current_user.id)
.order_by(Calendar.is_default.desc(), Calendar.name.asc())
)
return result.scalars().all()
@ -34,6 +38,7 @@ async def create_calendar(
is_default=False,
is_system=False,
is_visible=True,
user_id=current_user.id,
)
db.add(new_calendar)
await db.commit()
@ -43,12 +48,14 @@ async def create_calendar(
@router.put("/{calendar_id}", response_model=CalendarResponse)
async def update_calendar(
calendar_id: int,
calendar_update: CalendarUpdate,
calendar_id: int = Path(ge=1, le=2147483647),
calendar_update: CalendarUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
result = await db.execute(select(Calendar).where(Calendar.id == calendar_id))
result = await db.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id)
)
calendar = result.scalar_one_or_none()
if not calendar:
@ -70,11 +77,13 @@ async def update_calendar(
@router.delete("/{calendar_id}", status_code=204)
async def delete_calendar(
calendar_id: int,
calendar_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
result = await db.execute(select(Calendar).where(Calendar.id == calendar_id))
result = await db.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == current_user.id)
)
calendar = result.scalar_one_or_none()
if not calendar:
@ -86,8 +95,13 @@ async def delete_calendar(
if calendar.is_default:
raise HTTPException(status_code=400, detail="Cannot delete the default calendar")
# Reassign all events on this calendar to the default calendar
default_result = await db.execute(select(Calendar).where(Calendar.is_default == True))
# Reassign all events on this calendar to the user's default calendar
default_result = await db.execute(
select(Calendar).where(
Calendar.user_id == current_user.id,
Calendar.is_default == True,
)
)
default_calendar = default_result.scalar_one_or_none()
if default_calendar:

View File

@ -8,9 +8,11 @@ from app.database import get_db
from app.models.settings import Settings
from app.models.todo import Todo
from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
from app.models.reminder import Reminder
from app.models.project import Project
from app.routers.auth import get_current_settings
from app.models.user import User
from app.routers.auth import get_current_user, get_current_settings
router = APIRouter()
@ -24,18 +26,23 @@ _not_parent_template = or_(
@router.get("/dashboard")
async def get_dashboard(
client_date: Optional[date] = Query(None),
client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
db: AsyncSession = Depends(get_db),
current_settings: Settings = Depends(get_current_settings)
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
):
"""Get aggregated dashboard data."""
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)
# 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(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= today_end,
_not_parent_template,
@ -45,6 +52,7 @@ async def get_dashboard(
# Upcoming todos (not completed, with due date from today through upcoming_days)
todos_query = select(Todo).where(
Todo.user_id == current_user.id,
Todo.completed == False,
Todo.due_date.isnot(None),
Todo.due_date >= today,
@ -55,6 +63,7 @@ async def get_dashboard(
# Active reminders (not dismissed, is_active = true, from today onward)
reminders_query = select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.is_active == True,
Reminder.is_dismissed == False,
Reminder.remind_at >= today_start
@ -62,28 +71,33 @@ async def get_dashboard(
reminders_result = await db.execute(reminders_query)
active_reminders = reminders_result.scalars().all()
# Project stats
total_projects_result = await db.execute(select(func.count(Project.id)))
# Project stats (scoped to user)
total_projects_result = await db.execute(
select(func.count(Project.id)).where(Project.user_id == current_user.id)
)
total_projects = total_projects_result.scalar()
projects_by_status_query = select(
Project.status,
func.count(Project.id).label("count")
).group_by(Project.status)
).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 incomplete todos count
# Total incomplete todos count (scoped to user)
total_incomplete_result = await db.execute(
select(func.count(Todo.id)).where(Todo.completed == False)
select(func.count(Todo.id)).where(
Todo.user_id == current_user.id,
Todo.completed == False,
)
)
total_incomplete_todos = total_incomplete_result.scalar()
# Starred events (upcoming, ordered by date)
now = datetime.now()
# Starred events (upcoming, ordered by date, scoped to user's calendars)
starred_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.is_starred == True,
CalendarEvent.start_datetime > now,
CalendarEvent.start_datetime > today_start,
_not_parent_template,
).order_by(CalendarEvent.start_datetime.asc()).limit(5)
starred_result = await db.execute(starred_query)
@ -141,9 +155,10 @@ async def get_dashboard(
@router.get("/upcoming")
async def get_upcoming(
days: int = Query(default=7, ge=1, le=90),
client_date: Optional[date] = Query(None),
client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
db: AsyncSession = Depends(get_db),
current_settings: Settings = Depends(get_current_settings)
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
):
"""Get unified list of upcoming items (todos, events, reminders) sorted by date."""
today = client_date or date.today()
@ -151,8 +166,12 @@ async def get_upcoming(
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
today_start = datetime.combine(today, datetime.min.time())
# Get upcoming todos with due dates (today onward only)
# Subquery: calendar IDs belonging to this user
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
# Get upcoming todos with due dates (today onward only, scoped to user)
todos_query = select(Todo).where(
Todo.user_id == current_user.id,
Todo.completed == False,
Todo.due_date.isnot(None),
Todo.due_date >= today,
@ -161,8 +180,9 @@ async def get_upcoming(
todos_result = await db.execute(todos_query)
todos = todos_result.scalars().all()
# Get upcoming events (from today onward, exclude parent templates)
# Get upcoming events (from today onward, exclude parent templates, scoped to user's calendars)
events_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= cutoff_datetime,
_not_parent_template,
@ -170,8 +190,9 @@ async def get_upcoming(
events_result = await db.execute(events_query)
events = events_result.scalars().all()
# Get upcoming reminders (today onward only)
# 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,

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Path, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@ -20,7 +20,11 @@ async def list_templates(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(EventTemplate).order_by(EventTemplate.name))
result = await db.execute(
select(EventTemplate)
.where(EventTemplate.user_id == current_user.id)
.order_by(EventTemplate.name)
)
return result.scalars().all()
@ -30,7 +34,7 @@ async def create_template(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
template = EventTemplate(**payload.model_dump())
template = EventTemplate(**payload.model_dump(), user_id=current_user.id)
db.add(template)
await db.commit()
await db.refresh(template)
@ -39,13 +43,16 @@ async def create_template(
@router.put("/{template_id}", response_model=EventTemplateResponse)
async def update_template(
template_id: int,
payload: EventTemplateUpdate,
template_id: int = Path(ge=1, le=2147483647),
payload: EventTemplateUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(EventTemplate).where(EventTemplate.id == template_id)
select(EventTemplate).where(
EventTemplate.id == template_id,
EventTemplate.user_id == current_user.id,
)
)
template = result.scalar_one_or_none()
if template is None:
@ -61,12 +68,15 @@ async def update_template(
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template(
template_id: int,
template_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(EventTemplate).where(EventTemplate.id == template_id)
select(EventTemplate).where(
EventTemplate.id == template_id,
EventTemplate.user_id == current_user.id,
)
)
template = result.scalar_one_or_none()
if template is None:

View File

@ -1,5 +1,5 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from sqlalchemy.orm import selectinload
@ -105,19 +105,33 @@ def _birthday_events_for_range(
return virtual_events
async def _get_default_calendar_id(db: AsyncSession) -> int:
"""Return the id of the default calendar, raising 500 if not found."""
result = await db.execute(select(Calendar).where(Calendar.is_default == True))
async def _get_default_calendar_id(db: AsyncSession, user_id: int) -> int:
"""Return the id of the user's default calendar, raising 500 if not found."""
result = await db.execute(
select(Calendar).where(
Calendar.user_id == user_id,
Calendar.is_default == True,
)
)
default = result.scalar_one_or_none()
if not default:
raise HTTPException(status_code=500, detail="No default calendar configured")
return default.id
async def _verify_calendar_ownership(db: AsyncSession, calendar_id: int, user_id: int) -> None:
"""Raise 404 if calendar_id does not belong to user_id (SEC-04)."""
result = await db.execute(
select(Calendar).where(Calendar.id == calendar_id, Calendar.user_id == user_id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Calendar not found")
@router.get("/", response_model=None)
async def get_events(
start: Optional[date] = Query(None),
end: Optional[date] = Query(None),
start: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
end: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> List[Any]:
@ -128,9 +142,13 @@ async def get_events(
recurrence_rule IS NOT NULL) are excluded their materialised children
are what get displayed on the calendar.
"""
# Scope events through calendar ownership
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
query = (
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.calendar_id.in_(user_calendar_ids))
)
# Exclude parent template rows — they are not directly rendered
@ -154,14 +172,24 @@ async def get_events(
response: List[dict] = [_event_to_dict(e) for e in events]
# Fetch Birthdays calendar; only generate virtual events if visible
# Fetch the user's Birthdays system calendar; only generate virtual events if visible
bday_result = await db.execute(
select(Calendar).where(Calendar.name == "Birthdays", Calendar.is_system == True)
select(Calendar).where(
Calendar.user_id == current_user.id,
Calendar.name == "Birthdays",
Calendar.is_system == True,
)
)
bday_calendar = bday_result.scalar_one_or_none()
if bday_calendar and bday_calendar.is_visible:
people_result = await db.execute(select(Person).where(Person.birthday.isnot(None)))
# Scope birthday people to this user
people_result = await db.execute(
select(Person).where(
Person.user_id == current_user.id,
Person.birthday.isnot(None),
)
)
people = people_result.scalars().all()
virtual = _birthday_events_for_range(
@ -187,9 +215,12 @@ async def create_event(
data = event.model_dump()
# Resolve calendar_id to default if not provided
# Resolve calendar_id to user's default if not provided
if not data.get("calendar_id"):
data["calendar_id"] = await _get_default_calendar_id(db)
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)
# Serialize RecurrenceRule object to JSON string for DB storage
# Exclude None values so defaults in recurrence service work correctly
@ -241,14 +272,19 @@ async def create_event(
@router.get("/{event_id}", response_model=CalendarEventResponse)
async def get_event(
event_id: int,
event_id: int = Path(ge=1, le=2147483647),
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)
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
.where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
)
)
event = result.scalar_one_or_none()
@ -260,15 +296,20 @@ async def get_event(
@router.put("/{event_id}", response_model=CalendarEventResponse)
async def update_event(
event_id: int,
event_update: CalendarEventUpdate,
event_id: int = Path(ge=1, le=2147483647),
event_update: CalendarEventUpdate = ...,
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)
result = await db.execute(
select(CalendarEvent)
.options(selectinload(CalendarEvent.calendar))
.where(CalendarEvent.id == event_id)
.where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
)
)
event = result.scalar_one_or_none()
@ -285,6 +326,10 @@ 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
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
if "calendar_id" in update_data and update_data["calendar_id"] is not None:
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id)
start = update_data.get("start_datetime", event.start_datetime)
end_dt = update_data.get("end_datetime", event.end_datetime)
if end_dt is not None and end_dt < start:
@ -376,12 +421,19 @@ async def update_event(
@router.delete("/{event_id}", status_code=204)
async def delete_event(
event_id: int,
event_id: int = Path(ge=1, le=2147483647),
scope: Optional[Literal["this", "this_and_future"]] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id))
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
result = await db.execute(
select(CalendarEvent).where(
CalendarEvent.id == event_id,
CalendarEvent.calendar_id.in_(user_calendar_ids),
)
)
event = result.scalar_one_or_none()
if not event:

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from datetime import datetime, timezone
@ -29,14 +29,15 @@ async def search_locations(
"""Search locations from local DB and Nominatim OSM."""
results: List[LocationSearchResult] = []
# Local DB search
# Local DB search — scoped to user's locations
local_query = (
select(Location)
.where(
Location.user_id == current_user.id,
or_(
Location.name.ilike(f"%{q}%"),
Location.address.ilike(f"%{q}%"),
)
),
)
.limit(5)
)
@ -89,7 +90,7 @@ async def get_locations(
current_user: User = Depends(get_current_user)
):
"""Get all locations with optional category filter."""
query = select(Location)
query = select(Location).where(Location.user_id == current_user.id)
if category:
query = query.where(Location.category == category)
@ -109,7 +110,7 @@ async def create_location(
current_user: User = Depends(get_current_user)
):
"""Create a new location."""
new_location = Location(**location.model_dump())
new_location = Location(**location.model_dump(), user_id=current_user.id)
db.add(new_location)
await db.commit()
await db.refresh(new_location)
@ -119,12 +120,14 @@ async def create_location(
@router.get("/{location_id}", response_model=LocationResponse)
async def get_location(
location_id: int,
location_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific location by ID."""
result = await db.execute(select(Location).where(Location.id == location_id))
result = await db.execute(
select(Location).where(Location.id == location_id, Location.user_id == current_user.id)
)
location = result.scalar_one_or_none()
if not location:
@ -135,13 +138,15 @@ async def get_location(
@router.put("/{location_id}", response_model=LocationResponse)
async def update_location(
location_id: int,
location_update: LocationUpdate,
location_id: int = Path(ge=1, le=2147483647),
location_update: LocationUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a location."""
result = await db.execute(select(Location).where(Location.id == location_id))
result = await db.execute(
select(Location).where(Location.id == location_id, Location.user_id == current_user.id)
)
location = result.scalar_one_or_none()
if not location:
@ -163,12 +168,14 @@ async def update_location(
@router.delete("/{location_id}", status_code=204)
async def delete_location(
location_id: int,
location_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a location."""
result = await db.execute(select(Location).where(Location.id == location_id))
result = await db.execute(
select(Location).where(Location.id == location_id, Location.user_id == current_user.id)
)
location = result.scalar_one_or_none()
if not location:

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from datetime import datetime, timezone
@ -37,7 +37,7 @@ async def get_people(
current_user: User = Depends(get_current_user)
):
"""Get all people with optional search and category filter."""
query = select(Person)
query = select(Person).where(Person.user_id == current_user.id)
if search:
term = f"%{search}%"
@ -75,7 +75,7 @@ async def create_person(
parts = data['name'].split(' ', 1)
data['first_name'] = parts[0]
data['last_name'] = parts[1] if len(parts) > 1 else None
new_person = Person(**data)
new_person = Person(**data, user_id=current_user.id)
new_person.name = _compute_display_name(
new_person.first_name,
new_person.last_name,
@ -91,12 +91,14 @@ async def create_person(
@router.get("/{person_id}", response_model=PersonResponse)
async def get_person(
person_id: int,
person_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific person by ID."""
result = await db.execute(select(Person).where(Person.id == person_id))
result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
)
person = result.scalar_one_or_none()
if not person:
@ -107,13 +109,15 @@ async def get_person(
@router.put("/{person_id}", response_model=PersonResponse)
async def update_person(
person_id: int,
person_update: PersonUpdate,
person_id: int = Path(ge=1, le=2147483647),
person_update: PersonUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a person and refresh the denormalised display name."""
result = await db.execute(select(Person).where(Person.id == person_id))
result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
)
person = result.scalar_one_or_none()
if not person:
@ -142,12 +146,14 @@ async def update_person(
@router.delete("/{person_id}", status_code=204)
async def delete_person(
person_id: int,
person_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a person."""
result = await db.execute(select(Person).where(Person.id == person_id))
result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
)
person = result.scalar_one_or_none()
if not person:

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
@ -49,7 +49,12 @@ async def get_projects(
current_user: User = Depends(get_current_user)
):
"""Get all projects with their tasks. Optionally filter by tracked status."""
query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc())
query = (
select(Project)
.options(*_project_load_options())
.where(Project.user_id == current_user.id)
.order_by(Project.created_at.desc())
)
if tracked is not None:
query = query.where(Project.is_tracked == tracked)
result = await db.execute(query)
@ -77,6 +82,7 @@ async def get_tracked_tasks(
selectinload(ProjectTask.parent_task),
)
.where(
Project.user_id == current_user.id,
Project.is_tracked == True,
ProjectTask.due_date.isnot(None),
ProjectTask.due_date >= today,
@ -110,7 +116,7 @@ async def create_project(
current_user: User = Depends(get_current_user)
):
"""Create a new project."""
new_project = Project(**project.model_dump())
new_project = Project(**project.model_dump(), user_id=current_user.id)
db.add(new_project)
await db.commit()
@ -122,12 +128,16 @@ async def create_project(
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: int,
project_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific project by ID with its tasks."""
query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
query = (
select(Project)
.options(*_project_load_options())
.where(Project.id == project_id, Project.user_id == current_user.id)
)
result = await db.execute(query)
project = result.scalar_one_or_none()
@ -139,13 +149,15 @@ async def get_project(
@router.put("/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: int,
project_update: ProjectUpdate,
project_id: int = Path(ge=1, le=2147483647),
project_update: ProjectUpdate = ...,
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))
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:
@ -166,12 +178,14 @@ async def update_project(
@router.delete("/{project_id}", status_code=204)
async def delete_project(
project_id: int,
project_id: int = Path(ge=1, le=2147483647),
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))
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:
@ -185,12 +199,15 @@ async def delete_project(
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
async def get_project_tasks(
project_id: int,
project_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get top-level tasks for a specific project (subtasks are nested)."""
result = await db.execute(select(Project).where(Project.id == project_id))
# 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:
@ -213,13 +230,16 @@ async def get_project_tasks(
@router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201)
async def create_project_task(
project_id: int,
task: ProjectTaskCreate,
project_id: int = Path(ge=1, le=2147483647),
task: ProjectTaskCreate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new task or subtask for a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
# 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:
@ -259,13 +279,16 @@ async def create_project_task(
@router.put("/{project_id}/tasks/reorder", status_code=200)
async def reorder_tasks(
project_id: int,
items: List[ReorderItem],
project_id: int = Path(ge=1, le=2147483647),
items: List[ReorderItem] = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Bulk update sort_order for tasks."""
result = await db.execute(select(Project).where(Project.id == project_id))
# 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:
@ -289,13 +312,20 @@ async def reorder_tasks(
@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
async def update_project_task(
project_id: int,
task_id: int,
task_update: ProjectTaskUpdate,
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
task_update: ProjectTaskUpdate = ...,
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():
raise HTTPException(status_code=404, detail="Project not found")
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
@ -326,12 +356,19 @@ async def update_project_task(
@router.delete("/{project_id}/tasks/{task_id}", status_code=204)
async def delete_project_task(
project_id: int,
task_id: int,
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
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")
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
@ -351,13 +388,20 @@ async def delete_project_task(
@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201)
async def create_task_comment(
project_id: int,
task_id: int,
comment: TaskCommentCreate,
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
comment: TaskCommentCreate = ...,
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")
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
@ -379,13 +423,20 @@ async def create_task_comment(
@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
async def delete_task_comment(
project_id: int,
task_id: int,
comment_id: int,
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
comment_id: int = Path(ge=1, le=2147483647),
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():
raise HTTPException(status_code=404, detail="Project not found")
result = await db.execute(
select(TaskComment).where(
TaskComment.id == comment_id,

View File

@ -1,6 +1,6 @@
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_
from typing import Optional, List
@ -22,7 +22,7 @@ async def get_reminders(
current_user: User = Depends(get_current_user)
):
"""Get all reminders with optional filters."""
query = select(Reminder)
query = select(Reminder).where(Reminder.user_id == current_user.id)
if active is not None:
query = query.where(Reminder.is_active == active)
@ -48,6 +48,7 @@ async def get_due_reminders(
now = client_now or datetime.now()
query = select(Reminder).where(
and_(
Reminder.user_id == current_user.id,
Reminder.remind_at <= now,
Reminder.is_dismissed == False,
Reminder.is_active == True,
@ -68,13 +69,18 @@ async def get_due_reminders(
@router.patch("/{reminder_id}/snooze", response_model=ReminderResponse)
async def snooze_reminder(
reminder_id: int,
body: ReminderSnooze,
reminder_id: int = Path(ge=1, le=2147483647),
body: ReminderSnooze = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Snooze a reminder for N minutes from now."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
result = await db.execute(
select(Reminder).where(
Reminder.id == reminder_id,
Reminder.user_id == current_user.id,
)
)
reminder = result.scalar_one_or_none()
if not reminder:
@ -99,7 +105,7 @@ async def create_reminder(
current_user: User = Depends(get_current_user)
):
"""Create a new reminder."""
new_reminder = Reminder(**reminder.model_dump())
new_reminder = Reminder(**reminder.model_dump(), user_id=current_user.id)
db.add(new_reminder)
await db.commit()
await db.refresh(new_reminder)
@ -109,12 +115,17 @@ async def create_reminder(
@router.get("/{reminder_id}", response_model=ReminderResponse)
async def get_reminder(
reminder_id: int,
reminder_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific reminder by ID."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
result = await db.execute(
select(Reminder).where(
Reminder.id == reminder_id,
Reminder.user_id == current_user.id,
)
)
reminder = result.scalar_one_or_none()
if not reminder:
@ -125,13 +136,18 @@ async def get_reminder(
@router.put("/{reminder_id}", response_model=ReminderResponse)
async def update_reminder(
reminder_id: int,
reminder_update: ReminderUpdate,
reminder_id: int = Path(ge=1, le=2147483647),
reminder_update: ReminderUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
result = await db.execute(
select(Reminder).where(
Reminder.id == reminder_id,
Reminder.user_id == current_user.id,
)
)
reminder = result.scalar_one_or_none()
if not reminder:
@ -159,12 +175,17 @@ async def update_reminder(
@router.delete("/{reminder_id}", status_code=204)
async def delete_reminder(
reminder_id: int,
reminder_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
result = await db.execute(
select(Reminder).where(
Reminder.id == reminder_id,
Reminder.user_id == current_user.id,
)
)
reminder = result.scalar_one_or_none()
if not reminder:
@ -178,12 +199,17 @@ async def delete_reminder(
@router.patch("/{reminder_id}/dismiss", response_model=ReminderResponse)
async def dismiss_reminder(
reminder_id: int,
reminder_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Dismiss a reminder."""
result = await db.execute(select(Reminder).where(Reminder.id == reminder_id))
result = await db.execute(
select(Reminder).where(
Reminder.id == reminder_id,
Reminder.user_id == current_user.id,
)
)
reminder = result.scalar_one_or_none()
if not reminder:

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy import select, and_, func
from typing import Optional, List
from datetime import datetime, date, timedelta
import calendar
@ -73,15 +73,31 @@ def _calculate_recurrence(
return reset_at, next_due
async def _reactivate_recurring_todos(db: AsyncSession) -> None:
async def _reactivate_recurring_todos(db: AsyncSession, user_id: int) -> None:
"""Auto-reactivate recurring todos whose reset_at has passed.
Uses flush (not commit) so changes are visible to the subsequent query
within the same transaction. The caller's commit handles persistence.
Scoped to a single user to avoid cross-user reactivation.
"""
now = datetime.now()
# Fast-path: skip the FOR UPDATE lock when nothing needs reactivation (common case)
count = await db.scalar(
select(func.count()).select_from(Todo).where(
Todo.user_id == user_id,
Todo.completed == True, # noqa: E712
Todo.recurrence_rule.isnot(None),
Todo.reset_at.isnot(None),
Todo.reset_at <= now,
)
)
if count == 0:
return
query = select(Todo).where(
and_(
Todo.user_id == user_id,
Todo.completed == True,
Todo.recurrence_rule.isnot(None),
Todo.reset_at.isnot(None),
@ -110,13 +126,14 @@ async def get_todos(
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings)
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
):
"""Get all todos with optional filters."""
# Reactivate any recurring todos whose reset time has passed
await _reactivate_recurring_todos(db)
await _reactivate_recurring_todos(db, current_user.id)
query = select(Todo)
query = select(Todo).where(Todo.user_id == current_user.id)
if completed is not None:
query = query.where(Todo.completed == completed)
@ -144,10 +161,10 @@ async def get_todos(
async def create_todo(
todo: TodoCreate,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings)
current_user: User = Depends(get_current_user),
):
"""Create a new todo."""
new_todo = Todo(**todo.model_dump())
new_todo = Todo(**todo.model_dump(), user_id=current_user.id)
db.add(new_todo)
await db.commit()
await db.refresh(new_todo)
@ -157,12 +174,14 @@ async def create_todo(
@router.get("/{todo_id}", response_model=TodoResponse)
async def get_todo(
todo_id: int,
todo_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings)
current_user: User = Depends(get_current_user),
):
"""Get a specific todo by ID."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none()
if not todo:
@ -173,13 +192,16 @@ async def get_todo(
@router.put("/{todo_id}", response_model=TodoResponse)
async def update_todo(
todo_id: int,
todo_update: TodoUpdate,
todo_id: int = Path(ge=1, le=2147483647),
todo_update: TodoUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings)
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
):
"""Update a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none()
if not todo:
@ -210,7 +232,7 @@ async def update_todo(
reset_at, next_due = _calculate_recurrence(
todo.recurrence_rule,
todo.due_date,
current_user.first_day_of_week,
current_settings.first_day_of_week,
)
todo.reset_at = reset_at
todo.next_due_date = next_due
@ -227,12 +249,14 @@ async def update_todo(
@router.delete("/{todo_id}", status_code=204)
async def delete_todo(
todo_id: int,
todo_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings)
current_user: User = Depends(get_current_user),
):
"""Delete a todo."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none()
if not todo:
@ -246,12 +270,15 @@ async def delete_todo(
@router.patch("/{todo_id}/toggle", response_model=TodoResponse)
async def toggle_todo(
todo_id: int,
todo_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings)
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
):
"""Toggle todo completion status. For recurring todos, calculates reset schedule."""
result = await db.execute(select(Todo).where(Todo.id == todo_id))
result = await db.execute(
select(Todo).where(Todo.id == todo_id, Todo.user_id == current_user.id)
)
todo = result.scalar_one_or_none()
if not todo:
@ -267,7 +294,7 @@ async def toggle_todo(
reset_at, next_due = _calculate_recurrence(
todo.recurrence_rule,
todo.due_date,
current_user.first_day_of_week,
current_settings.first_day_of_week,
)
todo.reset_at = reset_at
todo.next_due_date = next_due

View File

@ -24,7 +24,7 @@ from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from sqlalchemy.exc import IntegrityError
@ -39,6 +39,7 @@ from app.services.auth import (
verify_password_with_upgrade,
hash_password,
verify_mfa_token,
verify_mfa_enforce_token,
create_session_token,
)
from app.services.totp import (
@ -75,23 +76,38 @@ _ph = PasswordHasher(
# ---------------------------------------------------------------------------
class TOTPConfirmRequest(BaseModel):
code: str
model_config = ConfigDict(extra="forbid")
code: str = Field(min_length=6, max_length=6)
class TOTPVerifyRequest(BaseModel):
mfa_token: str
code: Optional[str] = None # 6-digit TOTP code
backup_code: Optional[str] = None # Alternative: XXXX-XXXX backup code
model_config = ConfigDict(extra="forbid")
mfa_token: str = Field(max_length=256)
code: Optional[str] = Field(None, min_length=6, max_length=6) # 6-digit TOTP code
backup_code: Optional[str] = Field(None, max_length=9) # XXXX-XXXX backup code
class TOTPDisableRequest(BaseModel):
password: str
code: str # Current TOTP code required to disable
model_config = ConfigDict(extra="forbid")
password: str = Field(max_length=128)
code: str = Field(min_length=6, max_length=6) # Current TOTP code required to disable
class BackupCodesRegenerateRequest(BaseModel):
password: str
code: str # Current TOTP code required to regenerate
model_config = ConfigDict(extra="forbid")
password: str = Field(max_length=128)
code: str = Field(min_length=6, max_length=6) # Current TOTP code required to regenerate
class EnforceSetupRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
mfa_token: str = Field(max_length=256)
class EnforceConfirmRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
mfa_token: str = Field(max_length=256)
code: str = Field(min_length=6, max_length=6) # 6-digit TOTP code from authenticator app
# ---------------------------------------------------------------------------
@ -394,6 +410,108 @@ async def regenerate_backup_codes(
return {"backup_codes": plaintext_codes}
@router.post("/totp/enforce-setup")
async def enforce_setup_totp(
data: EnforceSetupRequest,
db: AsyncSession = Depends(get_db),
):
"""
Generate TOTP secret + QR code + backup codes during MFA enforcement.
Called after login returns mfa_setup_required=True. Uses the mfa_enforce_token
(not a session cookie) because the user is not yet fully authenticated.
Idempotent: regenerates secret if called again before confirm.
Returns { secret, qr_code_base64, backup_codes }.
"""
user_id = verify_mfa_enforce_token(data.mfa_token)
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid or expired enforcement token — please log in again")
result = await db.execute(select(User).where(User.id == user_id, User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found or inactive")
if not user.mfa_enforce_pending:
raise HTTPException(status_code=400, detail="MFA enforcement is not pending for this account")
if user.totp_enabled:
raise HTTPException(status_code=400, detail="TOTP is already enabled for this account")
# Generate new secret (idempotent — overwrite any unconfirmed secret)
raw_secret = generate_totp_secret()
encrypted_secret = encrypt_totp_secret(raw_secret)
user.totp_secret = encrypted_secret
user.totp_enabled = False # Not enabled until enforce-confirm called
# Generate backup codes — hash before storage, return plaintext once
plaintext_codes = generate_backup_codes(10)
await _delete_backup_codes(db, user.id)
await _store_backup_codes(db, user.id, plaintext_codes)
await db.commit()
uri = get_totp_uri(encrypted_secret, user.username)
qr_base64 = generate_qr_base64(uri)
return {
"secret": raw_secret,
"qr_code_base64": qr_base64,
"backup_codes": plaintext_codes,
}
@router.post("/totp/enforce-confirm")
async def enforce_confirm_totp(
data: EnforceConfirmRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""
Confirm TOTP setup during enforcement, clear the pending flag, issue a full session.
Must be called after /totp/enforce-setup while totp_enabled is still False.
On success: enables TOTP, clears mfa_enforce_pending, sets session cookie,
returns { authenticated: true }.
"""
user_id = verify_mfa_enforce_token(data.mfa_token)
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid or expired enforcement token — please log in again")
result = await db.execute(select(User).where(User.id == user_id, User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found or inactive")
if not user.mfa_enforce_pending:
raise HTTPException(status_code=400, detail="MFA enforcement is not pending for this account")
if not user.totp_secret:
raise HTTPException(status_code=400, detail="TOTP setup not started — call /totp/enforce-setup first")
if user.totp_enabled:
raise HTTPException(status_code=400, detail="TOTP is already enabled")
# Verify the confirmation code
matched_window = verify_totp_code(user.totp_secret, data.code)
if matched_window is None:
raise HTTPException(status_code=400, detail="Invalid code — check your authenticator app time sync")
# Enable TOTP and clear the enforcement flag
user.totp_enabled = True
user.mfa_enforce_pending = False
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)
return {"authenticated": True}
@router.get("/totp/status")
async def totp_status(
db: AsyncSession = Depends(get_db),

View File

@ -3,6 +3,7 @@ from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime, timedelta
from collections import OrderedDict
import asyncio
import urllib.request
import urllib.parse
@ -11,13 +12,39 @@ import json
from app.database import get_db
from app.models.settings import Settings
from app.models.user import User
from app.config import settings as app_settings
from app.routers.auth import get_current_user, get_current_settings
from app.models.user import User
router = APIRouter()
_cache: dict = {}
# SEC-15: Bounded LRU cache keyed by (user_id, location) — max 100 entries.
# OrderedDict preserves insertion order; move_to_end on hit, popitem(last=False)
# to evict the oldest when capacity is exceeded.
# NOTE: This cache is process-local. With multiple workers each process would
# maintain its own copy, wasting API quota. Currently safe — single Uvicorn worker.
_CACHE_MAX = 100
_cache: OrderedDict = OrderedDict()
def _cache_get(key: tuple) -> dict | None:
"""Return cached entry if it exists and hasn't expired."""
entry = _cache.get(key)
if entry and datetime.now() < entry["expires_at"]:
_cache.move_to_end(key) # LRU: promote to most-recently-used
return entry["data"]
if entry:
del _cache[key] # expired — evict immediately
return None
def _cache_set(key: tuple, data: dict) -> None:
"""Store an entry; evict the oldest if over capacity."""
if key in _cache:
_cache.move_to_end(key)
_cache[key] = {"data": data, "expires_at": datetime.now() + timedelta(hours=1)}
while len(_cache) > _CACHE_MAX:
_cache.popitem(last=False) # evict LRU (oldest)
class GeoSearchResult(BaseModel):
@ -66,23 +93,24 @@ async def search_locations(
@router.get("/")
async def get_weather(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_settings)
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
):
city = current_user.weather_city
lat = current_user.weather_lat
lon = current_user.weather_lon
city = current_settings.weather_city
lat = current_settings.weather_lat
lon = current_settings.weather_lon
if not city and (lat is None or lon is None):
raise HTTPException(status_code=400, detail="No weather location configured")
# Build cache key from coordinates or city
# Cache key includes user_id so each user gets isolated cache entries
use_coords = lat is not None and lon is not None
cache_key = f"{lat},{lon}" if use_coords else city
location_key = f"{lat},{lon}" if use_coords else city
cache_key = (current_user.id, location_key)
# Check cache
now = datetime.now()
if _cache.get("expires_at") and now < _cache["expires_at"] and _cache.get("cache_key") == cache_key:
return _cache["data"]
cached = _cache_get(cache_key)
if cached is not None:
return cached
api_key = app_settings.OPENWEATHERMAP_API_KEY
if not api_key:
@ -122,11 +150,7 @@ async def get_weather(
"city": current_data["name"],
}
# Cache for 1 hour
_cache["data"] = weather_result
_cache["expires_at"] = now + timedelta(hours=1)
_cache["cache_key"] = cache_key
_cache_set(cache_key, weather_result)
return weather_result
except urllib.error.URLError:

View File

@ -0,0 +1,194 @@
"""
Admin API schemas Pydantic v2.
All admin-facing request/response shapes live here to keep the admin router
clean and testable in isolation.
"""
import re
from datetime import datetime
from typing import Optional, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.auth import _validate_username, _validate_password_strength
# ---------------------------------------------------------------------------
# User list / detail
# ---------------------------------------------------------------------------
class UserListItem(BaseModel):
id: int
username: str
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
role: str
is_active: bool
last_login_at: Optional[datetime] = None
last_password_change_at: Optional[datetime] = None
totp_enabled: bool
mfa_enforce_pending: bool
created_at: datetime
active_sessions: int = 0
model_config = ConfigDict(from_attributes=True)
class UserListResponse(BaseModel):
users: list[UserListItem]
total: int
class UserDetailResponse(UserListItem):
preferred_name: Optional[str] = None
must_change_password: bool = False
locked_until: Optional[datetime] = None
# ---------------------------------------------------------------------------
# Mutating user requests
# ---------------------------------------------------------------------------
class CreateUserRequest(BaseModel):
"""Admin-created user — allows role selection (unlike public RegisterRequest)."""
model_config = ConfigDict(extra="forbid")
username: str
password: str
role: Literal["admin", "standard", "public_event_manager"] = "standard"
email: Optional[str] = Field(None, max_length=254)
first_name: Optional[str] = Field(None, max_length=100)
last_name: Optional[str] = Field(None, max_length=100)
preferred_name: Optional[str] = Field(None, max_length=100)
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
return _validate_username(v)
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
return _validate_password_strength(v)
@field_validator("email")
@classmethod
def validate_email(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip().lower()
if not v:
return None
# Basic format check: must have exactly one @, with non-empty local and domain parts
if not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", v):
raise ValueError("Invalid email format")
return v
@field_validator("first_name", "last_name", "preferred_name")
@classmethod
def validate_name_fields(cls, v: str | None) -> str | None:
if v is None:
return None
v = v.strip()
if not v:
return None
# Reject ASCII control characters
if re.search(r"[\x00-\x1f]", v):
raise ValueError("Name must not contain control characters")
return v
class UpdateUserRoleRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
role: Literal["admin", "standard", "public_event_manager"]
class ToggleActiveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
is_active: bool
class ToggleMfaEnforceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enforce: bool
# ---------------------------------------------------------------------------
# System config
# ---------------------------------------------------------------------------
class SystemConfigResponse(BaseModel):
allow_registration: bool
enforce_mfa_new_users: bool
model_config = ConfigDict(from_attributes=True)
class SystemConfigUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
allow_registration: Optional[bool] = None
enforce_mfa_new_users: Optional[bool] = None
# ---------------------------------------------------------------------------
# Admin dashboard
# ---------------------------------------------------------------------------
class RecentLoginItem(BaseModel):
username: str
last_login_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class RecentAuditItem(BaseModel):
action: str
actor_username: Optional[str] = None
target_username: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class AdminDashboardResponse(BaseModel):
total_users: int
active_users: int
admin_count: int
active_sessions: int
mfa_adoption_rate: float
recent_logins: list[RecentLoginItem]
recent_audit_entries: list[RecentAuditItem]
# ---------------------------------------------------------------------------
# Password reset
# ---------------------------------------------------------------------------
class ResetPasswordResponse(BaseModel):
message: str
temporary_password: str
class DeleteUserResponse(BaseModel):
message: str
deleted_username: str
# ---------------------------------------------------------------------------
# Audit log
# ---------------------------------------------------------------------------
class AuditLogEntry(BaseModel):
id: int
actor_username: Optional[str] = None
target_username: Optional[str] = None
action: str
detail: Optional[str] = None
ip_address: Optional[str] = None
created_at: datetime
class AuditLogResponse(BaseModel):
entries: list[AuditLogEntry]
total: int

View File

@ -1,5 +1,5 @@
import re
from pydantic import BaseModel, field_validator
from pydantic import BaseModel, ConfigDict, field_validator
def _validate_password_strength(v: str) -> str:
@ -21,19 +21,48 @@ def _validate_password_strength(v: str) -> str:
return v
def _validate_username(v: str) -> str:
"""Shared username validation."""
v = v.strip().lower()
if not 3 <= len(v) <= 50:
raise ValueError("Username must be 350 characters")
if not re.fullmatch(r"[a-z0-9_.\-]+", v):
raise ValueError("Username may only contain letters, numbers, _ . and -")
return v
class SetupRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str
password: str
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
v = v.strip().lower()
if not 3 <= len(v) <= 50:
raise ValueError("Username must be 350 characters")
if not re.fullmatch(r"[a-z0-9_\-]+", v):
raise ValueError("Username may only contain letters, numbers, _ and -")
return v
return _validate_username(v)
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
return _validate_password_strength(v)
class RegisterRequest(BaseModel):
"""
Public registration schema SEC-01: extra="forbid" prevents role injection.
An attacker sending {"username": "...", "password": "...", "role": "admin"}
will get a 422 Validation Error instead of silent acceptance.
"""
model_config = ConfigDict(extra="forbid")
username: str
password: str
@field_validator("username")
@classmethod
def validate_username(cls, v: str) -> str:
return _validate_username(v)
@field_validator("password")
@classmethod
@ -42,6 +71,8 @@ class SetupRequest(BaseModel):
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str
password: str
@ -53,6 +84,8 @@ class LoginRequest(BaseModel):
class ChangePasswordRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
old_password: str
new_password: str
@ -63,6 +96,8 @@ class ChangePasswordRequest(BaseModel):
class VerifyPasswordRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str
@field_validator("password")

View File

@ -1,16 +1,20 @@
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
from typing import Optional
class CalendarCreate(BaseModel):
name: str
color: str = "#3b82f6"
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=100)
color: str = Field("#3b82f6", max_length=20)
class CalendarUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
model_config = ConfigDict(extra="forbid")
name: Optional[str] = Field(None, min_length=1, max_length=100)
color: Optional[str] = Field(None, max_length=20)
is_visible: Optional[bool] = None

View File

@ -39,12 +39,14 @@ def _coerce_recurrence_rule(v):
class CalendarEventCreate(BaseModel):
title: str
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
start_datetime: datetime
end_datetime: datetime
all_day: bool = False
color: Optional[str] = None
color: Optional[str] = Field(None, max_length=20)
location_id: Optional[int] = None
recurrence_rule: Optional[RecurrenceRule] = None
is_starred: bool = False
@ -57,12 +59,14 @@ class CalendarEventCreate(BaseModel):
class CalendarEventUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
start_datetime: Optional[datetime] = None
end_datetime: Optional[datetime] = None
all_day: Optional[bool] = None
color: Optional[str] = None
color: Optional[str] = Field(None, max_length=20)
location_id: Optional[int] = None
recurrence_rule: Optional[RecurrenceRule] = None
is_starred: Optional[bool] = None

View File

@ -1,25 +1,29 @@
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
from typing import Optional
class EventTemplateCreate(BaseModel):
name: str
title: str
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
calendar_id: Optional[int] = None
recurrence_rule: Optional[str] = None
recurrence_rule: Optional[str] = Field(None, max_length=5000)
all_day: bool = False
location_id: Optional[int] = None
is_starred: bool = False
class EventTemplateUpdate(BaseModel):
name: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
name: Optional[str] = Field(None, min_length=1, max_length=255)
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
calendar_id: Optional[int] = None
recurrence_rule: Optional[str] = None
recurrence_rule: Optional[str] = Field(None, max_length=5000)
all_day: Optional[bool] = None
location_id: Optional[int] = None
is_starred: Optional[bool] = None

View File

@ -1,5 +1,5 @@
import re
from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
from datetime import datetime
from typing import Optional, Literal
@ -14,13 +14,15 @@ class LocationSearchResult(BaseModel):
class LocationCreate(BaseModel):
name: str
address: str
category: str = "other"
notes: Optional[str] = None
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
address: str = Field(min_length=1, max_length=2000)
category: str = Field("other", max_length=100)
notes: Optional[str] = Field(None, max_length=5000)
is_frequent: bool = False
contact_number: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = Field(None, max_length=50)
email: Optional[str] = Field(None, max_length=255)
@field_validator('email')
@classmethod
@ -31,13 +33,15 @@ class LocationCreate(BaseModel):
class LocationUpdate(BaseModel):
name: Optional[str] = None
address: Optional[str] = None
category: Optional[str] = None
notes: Optional[str] = None
model_config = ConfigDict(extra="forbid")
name: Optional[str] = Field(None, min_length=1, max_length=255)
address: Optional[str] = Field(None, min_length=1, max_length=2000)
category: Optional[str] = Field(None, max_length=100)
notes: Optional[str] = Field(None, max_length=5000)
is_frequent: Optional[bool] = None
contact_number: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = Field(None, max_length=50)
email: Optional[str] = Field(None, max_length=255)
@field_validator('email')
@classmethod

View File

@ -1,5 +1,5 @@
import re
from pydantic import BaseModel, ConfigDict, model_validator, field_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator, field_validator
from datetime import datetime, date
from typing import Optional
@ -7,20 +7,22 @@ _EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
class PersonCreate(BaseModel):
name: Optional[str] = None # legacy fallback — auto-split into first/last if provided alone
first_name: Optional[str] = None
last_name: Optional[str] = None
nickname: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
mobile: Optional[str] = None
address: Optional[str] = None
model_config = ConfigDict(extra="forbid")
name: Optional[str] = Field(None, max_length=255) # legacy fallback — auto-split into first/last if provided alone
first_name: Optional[str] = Field(None, max_length=100)
last_name: Optional[str] = Field(None, max_length=100)
nickname: Optional[str] = Field(None, max_length=100)
email: Optional[str] = Field(None, max_length=255)
phone: Optional[str] = Field(None, max_length=50)
mobile: Optional[str] = Field(None, max_length=50)
address: Optional[str] = Field(None, max_length=2000)
birthday: Optional[date] = None
category: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
is_favourite: bool = False
company: Optional[str] = None
job_title: Optional[str] = None
notes: Optional[str] = None
company: Optional[str] = Field(None, max_length=255)
job_title: Optional[str] = Field(None, max_length=255)
notes: Optional[str] = Field(None, max_length=5000)
@model_validator(mode='after')
def require_some_name(self) -> 'PersonCreate':
@ -42,20 +44,22 @@ class PersonCreate(BaseModel):
class PersonUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
# name is intentionally omitted — always computed from first/last/nickname
first_name: Optional[str] = None
last_name: Optional[str] = None
nickname: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
mobile: Optional[str] = None
address: Optional[str] = None
first_name: Optional[str] = Field(None, max_length=100)
last_name: Optional[str] = Field(None, max_length=100)
nickname: Optional[str] = Field(None, max_length=100)
email: Optional[str] = Field(None, max_length=255)
phone: Optional[str] = Field(None, max_length=50)
mobile: Optional[str] = Field(None, max_length=50)
address: Optional[str] = Field(None, max_length=2000)
birthday: Optional[date] = None
category: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
is_favourite: Optional[bool] = None
company: Optional[str] = None
job_title: Optional[str] = None
notes: Optional[str] = None
company: Optional[str] = Field(None, max_length=255)
job_title: Optional[str] = Field(None, max_length=255)
notes: Optional[str] = Field(None, max_length=5000)
@field_validator('email')
@classmethod

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime, date
from typing import Optional, List, Literal
from app.schemas.project_task import ProjectTaskResponse
@ -7,19 +7,23 @@ ProjectStatus = Literal["not_started", "in_progress", "completed", "blocked", "r
class ProjectCreate(BaseModel):
name: str
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
status: ProjectStatus = "not_started"
color: Optional[str] = None
color: Optional[str] = Field(None, max_length=20)
due_date: Optional[date] = None
is_tracked: bool = False
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
status: Optional[ProjectStatus] = None
color: Optional[str] = None
color: Optional[str] = Field(None, max_length=20)
due_date: Optional[date] = None
is_tracked: Optional[bool] = None

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime, date
from typing import Optional, List, Literal
from app.schemas.task_comment import TaskCommentResponse
@ -8,8 +8,10 @@ TaskPriority = Literal["none", "low", "medium", "high"]
class ProjectTaskCreate(BaseModel):
title: str
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
status: TaskStatus = "pending"
priority: TaskPriority = "medium"
due_date: Optional[date] = None
@ -19,8 +21,10 @@ class ProjectTaskCreate(BaseModel):
class ProjectTaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
status: Optional[TaskStatus] = None
priority: Optional[TaskPriority] = None
due_date: Optional[date] = None

View File

@ -1,19 +1,23 @@
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
from typing import Literal, Optional
class ReminderCreate(BaseModel):
title: str
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
remind_at: Optional[datetime] = None
is_active: bool = True
recurrence_rule: Optional[Literal['daily', 'weekly', 'monthly']] = None
class ReminderUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
remind_at: Optional[datetime] = None
is_active: Optional[bool] = None
is_dismissed: Optional[bool] = None
@ -21,6 +25,8 @@ class ReminderUpdate(BaseModel):
class ReminderSnooze(BaseModel):
model_config = ConfigDict(extra="forbid")
minutes: Literal[5, 10, 15]
client_now: Optional[datetime] = None

View File

@ -1,5 +1,5 @@
import re
from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
from datetime import datetime
from typing import Literal, Optional
@ -9,17 +9,19 @@ _NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
class SettingsUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
accent_color: Optional[AccentColor] = None
upcoming_days: int | None = None
preferred_name: str | None = None
weather_city: str | None = None
preferred_name: str | None = Field(None, max_length=100)
weather_city: str | None = Field(None, max_length=100)
weather_lat: float | None = None
weather_lon: float | None = None
first_day_of_week: int | None = None
# ntfy configuration fields
ntfy_server_url: Optional[str] = None
ntfy_topic: Optional[str] = None
ntfy_server_url: Optional[str] = Field(None, max_length=500)
ntfy_topic: Optional[str] = Field(None, max_length=100)
# Empty string means "clear the token"; None means "leave unchanged"
ntfy_auth_token: Optional[str] = None
ntfy_enabled: Optional[bool] = None

View File

@ -1,9 +1,11 @@
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime
class TaskCommentCreate(BaseModel):
content: str
model_config = ConfigDict(extra="forbid")
content: str = Field(min_length=1, max_length=10000)
class TaskCommentResponse(BaseModel):

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime, date, time
from typing import Optional, Literal
@ -7,24 +7,28 @@ RecurrenceRule = Literal["daily", "weekly", "monthly"]
class TodoCreate(BaseModel):
title: str
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
priority: TodoPriority = "medium"
due_date: Optional[date] = None
due_time: Optional[time] = None
category: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
recurrence_rule: Optional[RecurrenceRule] = None
project_id: Optional[int] = None
class TodoUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=5000)
priority: Optional[TodoPriority] = None
due_date: Optional[date] = None
due_time: Optional[time] = None
completed: Optional[bool] = None
category: Optional[str] = None
category: Optional[str] = Field(None, max_length=100)
recurrence_rule: Optional[RecurrenceRule] = None
project_id: Optional[int] = None

View File

@ -0,0 +1,22 @@
import json
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
async def log_audit_event(
db: AsyncSession,
action: str,
actor_id: int | None = None,
target_id: int | None = None,
detail: dict | None = None,
ip: str | None = None,
) -> None:
"""Record an action in the audit log. Does NOT commit — caller handles transaction."""
entry = AuditLog(
actor_user_id=actor_id,
target_user_id=target_id,
action=action,
detail=json.dumps(detail) if detail else None,
ip_address=ip[:45] if ip else None,
)
db.add(entry)

View File

@ -88,10 +88,14 @@ def create_session_token(user_id: int, session_id: str) -> str:
def verify_session_token(token: str, max_age: int | None = None) -> dict | None:
"""
Verify a session cookie and return its payload dict, or None if invalid/expired.
max_age defaults to SESSION_MAX_AGE_DAYS from config.
max_age defaults to SESSION_TOKEN_HARD_CEILING_DAYS (absolute token lifetime).
The sliding window (SESSION_MAX_AGE_DAYS) is enforced via DB expires_at checks,
not by itsdangerous this decoupling prevents the serializer from rejecting
renewed tokens that were created more than SESSION_MAX_AGE_DAYS ago.
"""
if max_age is None:
max_age = app_settings.SESSION_MAX_AGE_DAYS * 86400
max_age = app_settings.SESSION_TOKEN_HARD_CEILING_DAYS * 86400
try:
return _serializer.loads(token, max_age=max_age)
except (BadSignature, SignatureExpired):
@ -126,3 +130,32 @@ def verify_mfa_token(token: str) -> int | None:
return data["uid"]
except Exception:
return None
# ---------------------------------------------------------------------------
# MFA enforcement tokens (SEC-03: distinct salt from challenge tokens)
# ---------------------------------------------------------------------------
_mfa_enforce_serializer = URLSafeTimedSerializer(
secret_key=app_settings.SECRET_KEY,
salt="mfa-enforce-setup-v1",
)
def create_mfa_enforce_token(user_id: int) -> str:
"""Create a short-lived token for MFA enforcement setup (not a session)."""
return _mfa_enforce_serializer.dumps({"uid": user_id})
def verify_mfa_enforce_token(token: str) -> int | None:
"""
Verify an MFA enforcement setup token.
Returns user_id on success, None if invalid or expired (5-minute TTL).
"""
try:
data = _mfa_enforce_serializer.loads(
token, max_age=app_settings.MFA_TOKEN_MAX_AGE_SECONDS
)
return data["uid"]
except Exception:
return None

View File

@ -1,5 +1,9 @@
# Rate limiting zones (before server block)
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
# SEC-14: Registration endpoint slightly more permissive than strict auth endpoints
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;
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
map $http_x_forwarded_proto $forwarded_proto {
@ -60,6 +64,20 @@ server {
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;
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;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# API proxy
location /api {
proxy_pass http://backend:8000;

View File

@ -1,3 +1,4 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import LockScreen from '@/components/auth/LockScreen';
@ -12,6 +13,8 @@ import PeoplePage from '@/components/people/PeoplePage';
import LocationsPage from '@/components/locations/LocationsPage';
import SettingsPage from '@/components/settings/SettingsPage';
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth();
@ -30,6 +33,24 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
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>
);
}
if (!authStatus?.authenticated || authStatus?.role !== 'admin') {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
}
function App() {
return (
<Routes>
@ -52,6 +73,16 @@ function App() {
<Route path="people" element={<PeoplePage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route
path="admin/*"
element={
<AdminRoute>
<Suspense fallback={<div className="flex h-full items-center justify-center text-muted-foreground">Loading...</div>}>
<AdminPortal />
</Suspense>
</AdminRoute>
}
/>
</Route>
</Routes>
);

View File

@ -0,0 +1,200 @@
import {
Users,
UserCheck,
UserX,
Activity,
Smartphone,
LogIn,
ShieldAlert,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useAdminDashboard, useAuditLog } from '@/hooks/useAdmin';
import { getRelativeTime } from '@/lib/date-utils';
import { cn } from '@/lib/utils';
import { StatCard, actionColor } from './shared';
export default function AdminDashboardPage() {
const { data: dashboard, isLoading } = useAdminDashboard();
const { data: auditData } = useAuditLog(1, 10);
const mfaPct = dashboard ? Math.round(dashboard.mfa_adoption_rate * 100) : null;
const disabledUsers =
dashboard ? dashboard.total_users - dashboard.active_users : null;
return (
<div className="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">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-5">
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
))
) : (
<>
<StatCard
icon={<Users className="h-5 w-5 text-accent" />}
label="Total Users"
value={dashboard?.total_users ?? '—'}
/>
<StatCard
icon={<UserCheck className="h-5 w-5 text-green-400" />}
label="Active Users"
value={dashboard?.active_users ?? '—'}
iconBg="bg-green-500/10"
/>
<StatCard
icon={<UserX className="h-5 w-5 text-red-400" />}
label="Disabled Users"
value={disabledUsers ?? '—'}
iconBg="bg-red-500/10"
/>
<StatCard
icon={<Activity className="h-5 w-5 text-blue-400" />}
label="Active Sessions"
value={dashboard?.active_sessions ?? '—'}
iconBg="bg-blue-500/10"
/>
<StatCard
icon={<Smartphone className="h-5 w-5 text-purple-400" />}
label="MFA Adoption"
value={mfaPct !== null ? `${mfaPct}%` : '—'}
iconBg="bg-purple-500/10"
/>
</>
)}
</div>
<div className="grid gap-5 lg:grid-cols-2">
{/* Recent logins */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-green-500/10">
<LogIn className="h-4 w-4 text-green-400" />
</div>
<CardTitle>Recent Logins</CardTitle>
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="px-5 pb-5 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : !dashboard?.recent_logins?.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">No recent logins.</p>
) : (
<div className="overflow-x-auto">
<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">
Username
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
When
</th>
</tr>
</thead>
<tbody>
{dashboard.recent_logins.map((entry, idx) => (
<tr
key={idx}
className={cn(
'border-b border-border hover:bg-card-elevated/50 transition-colors',
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">
{getRelativeTime(entry.last_login_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Recent admin actions */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-orange-500/10">
<ShieldAlert className="h-4 w-4 text-orange-400" />
</div>
<CardTitle>Recent Admin Actions</CardTitle>
</div>
</CardHeader>
<CardContent className="p-0">
{!auditData?.entries?.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">No recent actions.</p>
) : (
<div className="overflow-x-auto">
<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">
Action
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Actor
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Target
</th>
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
When
</th>
</tr>
</thead>
<tbody>
{auditData.entries.slice(0, 10).map((entry, idx) => (
<tr
key={entry.id}
className={cn(
'border-b border-border hover:bg-card-elevated/50 transition-colors',
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)}
>
<td className="px-5 py-2.5">
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
actionColor(entry.action)
)}
>
{entry.action}
</span>
</td>
<td className="px-5 py-2.5 text-xs font-medium">
{entry.actor_username ?? (
<span className="text-muted-foreground italic">system</span>
)}
</td>
<td className="px-5 py-2.5 text-xs text-muted-foreground">
{entry.target_username ?? '—'}
</td>
<td className="px-5 py-2.5 text-xs text-muted-foreground whitespace-nowrap">
{getRelativeTime(entry.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
import { NavLink, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Users, Settings2, LayoutDashboard, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import IAMPage from './IAMPage';
import ConfigPage from './ConfigPage';
import AdminDashboardPage from './AdminDashboardPage';
const tabs = [
{ label: 'IAM Management', path: '/admin/iam', icon: Users },
{ label: 'Configuration', path: '/admin/config', icon: Settings2 },
{ label: 'Management Dashboard', path: '/admin/dashboard', icon: LayoutDashboard },
];
export default function AdminPortal() {
const location = useLocation();
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="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>
</div>
{/* Horizontal tab navigation */}
<nav className="flex items-center gap-1 h-full">
{tabs.map(({ label, path, icon: Icon }) => {
const isActive = location.pathname.startsWith(path);
return (
<NavLink
key={path}
to={path}
className={cn(
'flex items-center gap-2 px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px',
isActive
? 'text-accent border-accent'
: 'text-muted-foreground hover:text-foreground border-transparent'
)}
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
);
})}
</nav>
</div>
</div>
{/* Page content */}
<div className="flex-1 overflow-y-auto">
<Routes>
<Route index element={<Navigate to="iam" replace />} />
<Route path="iam" element={<IAMPage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="dashboard" element={<AdminDashboardPage />} />
</Routes>
</div>
</div>
);
}

View File

@ -0,0 +1,218 @@
import { useState } from 'react';
import {
FileText,
ChevronLeft,
ChevronRight,
Filter,
X,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuditLog } from '@/hooks/useAdmin';
import { getRelativeTime } from '@/lib/date-utils';
import { cn } from '@/lib/utils';
import { actionColor } from './shared';
const ACTION_TYPES = [
'admin.user_created',
'admin.role_changed',
'admin.password_reset',
'admin.mfa_disabled',
'admin.mfa_enforce_toggled',
'admin.user_deactivated',
'admin.user_activated',
'admin.sessions_revoked',
'admin.config_updated',
'auth.login_success',
'auth.login_failed',
'auth.setup_complete',
'auth.registration',
'auth.mfa_enforce_prompted',
];
function actionLabel(action: string): string {
return action
.split('.')
.map((p) => p.replace(/_/g, ' '))
.join(' — ');
}
export default function ConfigPage() {
const [page, setPage] = useState(1);
const [filterAction, setFilterAction] = useState<string>('');
const PER_PAGE = 25;
const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined);
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
<Card>
<CardHeader className="flex-row items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<FileText className="h-4 w-4 text-accent" />
</div>
<CardTitle>Audit Log</CardTitle>
{data && (
<span className="text-xs text-muted-foreground">
{data.total} entries
</span>
)}
</div>
{/* Filter controls */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<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">
<Select
value={filterAction}
onChange={(e) => {
setFilterAction(e.target.value);
setPage(1);
}}
className="h-8 text-xs"
>
<option value="">All actions</option>
{ACTION_TYPES.map((a) => (
<option key={a} value={a}>
{actionLabel(a)}
</option>
))}
</Select>
</div>
{filterAction && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
setFilterAction('');
setPage(1);
}}
aria-label="Clear filter"
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="px-5 pb-5 space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : !data?.entries?.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">No audit entries found.</p>
) : (
<>
<div className="overflow-x-auto">
<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">
Time
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Actor
</th>
<th className="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">
Target
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
IP
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Detail
</th>
</tr>
</thead>
<tbody>
{data.entries.map((entry, idx) => (
<tr
key={entry.id}
className={cn(
'border-b border-border transition-colors hover:bg-card-elevated/50',
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)}
>
<td className="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">
{entry.actor_username ?? (
<span className="text-muted-foreground italic">system</span>
)}
</td>
<td className="px-5 py-3">
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide whitespace-nowrap',
actionColor(entry.action)
)}
>
{entry.action}
</span>
</td>
<td className="px-5 py-3 text-xs text-muted-foreground">
{entry.target_username ?? '—'}
</td>
<td className="px-5 py-3 text-xs text-muted-foreground font-mono">
{entry.ip_address ?? '—'}
</td>
<td className="px-5 py-3 text-xs text-muted-foreground max-w-xs truncate">
{entry.detail ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-5 py-3 border-t border-border">
<span className="text-xs text-muted-foreground">
Page {page} of {totalPages}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
Prev
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,184 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { UserPlus, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { useCreateUser, getErrorMessage } from '@/hooks/useAdmin';
import type { UserRole } from '@/types';
interface CreateUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialogProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<UserRole>('standard');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [preferredName, setPreferredName] = useState('');
const createUser = useCreateUser();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !password.trim()) return;
const payload: Parameters<typeof createUser.mutateAsync>[0] = {
username: username.trim(),
password,
role,
};
if (email.trim()) payload.email = email.trim();
if (firstName.trim()) payload.first_name = firstName.trim();
if (lastName.trim()) payload.last_name = lastName.trim();
if (preferredName.trim()) payload.preferred_name = preferredName.trim();
try {
await createUser.mutateAsync(payload);
toast.success(`User "${username.trim()}" created successfully`);
setUsername('');
setPassword('');
setRole('standard');
setFirstName('');
setLastName('');
setEmail('');
setPreferredName('');
onOpenChange(false);
} catch (err) {
toast.error(getErrorMessage(err, 'Failed to create user'));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-accent" />
Create User
</DialogTitle>
</DialogHeader>
<DialogClose onClick={() => onOpenChange(false)} />
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="new-username">Username</Label>
<Input
id="new-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password">Password</Label>
<Input
id="new-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 12 characters"
required
/>
<p className="text-[11px] text-muted-foreground">
Min. 12 characters with at least one letter and one non-letter. User must change on first login.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-role">Role</Label>
<Select
id="new-role"
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
>
<option value="standard">Standard</option>
<option value="admin">Admin</option>
<option value="public_event_manager">Public Event Manager</option>
</Select>
</div>
<div className="pt-2 space-y-3">
<p className="text-[11px] text-muted-foreground uppercase tracking-wider font-medium">
Optional Profile
</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="new-first-name">First Name</Label>
<Input
id="new-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="First name"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-last-name">Last Name</Label>
<Input
id="new-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Last name"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-email">Email</Label>
<Input
id="new-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-preferred-name">Preferred Name</Label>
<Input
id="new-preferred-name"
value={preferredName}
onChange={(e) => setPreferredName(e.target.value)}
placeholder="Display name"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
size="sm"
disabled={createUser.isPending || !username.trim() || !password.trim()}
>
{createUser.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Create User
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,325 @@
import { useState, useMemo } from 'react';
import { toast } from 'sonner';
import {
Users,
ShieldCheck,
Smartphone,
Plus,
Activity,
Search,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { StatCard } from './shared';
import UserDetailSection from './UserDetailSection';
import {
useAdminUsers,
useAdminDashboard,
useAdminConfig,
useUpdateConfig,
getErrorMessage,
} from '@/hooks/useAdmin';
import { useAuth } from '@/hooks/useAuth';
import { getRelativeTime } from '@/lib/date-utils';
import type { AdminUserDetail, UserRole } from '@/types';
import { cn } from '@/lib/utils';
import UserActionsMenu from './UserActionsMenu';
import CreateUserDialog from './CreateUserDialog';
// ── Role badge ────────────────────────────────────────────────────────────────
function RoleBadge({ role }: { role: UserRole }) {
const styles: Record<UserRole, string> = {
admin: 'bg-red-500/15 text-red-400',
standard: 'bg-blue-500/15 text-blue-400',
public_event_manager: 'bg-purple-500/15 text-purple-400',
};
const labels: Record<UserRole, string> = {
admin: 'Admin',
standard: 'Standard',
public_event_manager: 'Pub. Events',
};
return (
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
styles[role]
)}
>
{labels[role]}
</span>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function IAMPage() {
const [createOpen, setCreateOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const { authStatus } = useAuth();
const { data: users, isLoading: usersLoading } = useAdminUsers();
const { data: dashboard } = useAdminDashboard();
const { data: config, isLoading: configLoading } = useAdminConfig();
const updateConfig = useUpdateConfig();
const filteredUsers = useMemo(() => {
if (!users) return [];
if (!searchQuery.trim()) return users;
const q = searchQuery.toLowerCase();
return users.filter(
(u) =>
u.username.toLowerCase().includes(q) ||
(u.email && u.email.toLowerCase().includes(q)) ||
(u.first_name && u.first_name.toLowerCase().includes(q)) ||
(u.last_name && u.last_name.toLowerCase().includes(q))
);
}, [users, searchQuery]);
const handleConfigToggle = async (key: 'allow_registration' | 'enforce_mfa_new_users', value: boolean) => {
try {
await updateConfig.mutateAsync({ [key]: value });
toast.success('System settings updated');
} catch (err) {
toast.error(getErrorMessage(err, 'Failed to update settings'));
}
};
const mfaPct = dashboard
? Math.round(dashboard.mfa_adoption_rate * 100)
: null;
return (
<div className="px-6 py-6 space-y-6 animate-fade-in">
{/* Stats row */}
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Users className="h-5 w-5 text-accent" />}
label="Total Users"
value={dashboard?.total_users ?? '—'}
/>
<StatCard
icon={<Activity className="h-5 w-5 text-green-400" />}
label="Active Sessions"
value={dashboard?.active_sessions ?? '—'}
iconBg="bg-green-500/10"
/>
<StatCard
icon={<ShieldCheck className="h-5 w-5 text-red-400" />}
label="Admins"
value={dashboard?.admin_count ?? '—'}
iconBg="bg-red-500/10"
/>
<StatCard
icon={<Smartphone className="h-5 w-5 text-purple-400" />}
label="MFA Adoption"
value={mfaPct !== null ? `${mfaPct}%` : '—'}
iconBg="bg-purple-500/10"
/>
</div>
{/* User table */}
<Card>
<CardHeader className="flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<Users className="h-4 w-4 text-accent" />
</div>
<CardTitle>Users</CardTitle>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search users..."
className="pl-8 h-8 w-48 text-xs"
/>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Create User
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{usersLoading ? (
<div className="px-5 pb-5 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : !filteredUsers.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">
{searchQuery ? 'No users match your search.' : 'No users found.'}
</p>
) : (
<div>
<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">
Username
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Email
</th>
<th className="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">
Status
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Last Login
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
MFA
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Sessions
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Created
</th>
<th className="px-5 py-3 text-right text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Actions
</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user: AdminUserDetail, idx) => (
<tr
key={user.id}
onClick={() => setSelectedUserId(selectedUserId === user.id ? null : user.id)}
className={cn(
'border-b border-border transition-colors cursor-pointer',
selectedUserId === user.id
? 'bg-accent/5 border-l-2 border-l-accent'
: cn(
'hover:bg-card-elevated/50',
idx % 2 === 0 ? '' : 'bg-card-elevated/25'
)
)}
>
<td className="px-5 py-3 font-medium">{user.username}</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{user.email || '—'}
</td>
<td className="px-5 py-3">
<RoleBadge role={user.role} />
</td>
<td className="px-5 py-3">
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
user.is_active
? 'bg-green-500/15 text-green-400'
: 'bg-red-500/15 text-red-400'
)}
>
{user.is_active ? 'Active' : 'Disabled'}
</span>
</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{user.last_login_at ? getRelativeTime(user.last_login_at) : '—'}
</td>
<td className="px-5 py-3">
{user.totp_enabled ? (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-green-500/15 text-green-400">
On
</span>
) : user.mfa_enforce_pending ? (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-orange-500/15 text-orange-400">
Pending
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="px-5 py-3 text-muted-foreground text-xs tabular-nums">
{user.active_sessions}
</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{getRelativeTime(user.created_at)}
</td>
<td className="px-5 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<UserActionsMenu user={user} currentUsername={authStatus?.username ?? null} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* User detail section */}
{selectedUserId !== null && (
<UserDetailSection
userId={selectedUserId}
onClose={() => setSelectedUserId(null)}
/>
)}
{/* System settings */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<ShieldCheck className="h-4 w-4 text-accent" />
</div>
<CardTitle>System Settings</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-5">
{configLoading ? (
<div className="space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : (
<>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label className="text-sm font-medium">Allow New Account Registration</Label>
<p className="text-xs text-muted-foreground">
When enabled, the /register page accepts new sign-ups.
</p>
</div>
<Switch
checked={config?.allow_registration ?? false}
onCheckedChange={(v) => handleConfigToggle('allow_registration', v)}
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">Enforce MFA on New Users</Label>
<p className="text-xs text-muted-foreground">
Newly registered users will be required to set up TOTP before accessing the app.
</p>
</div>
<Switch
checked={config?.enforce_mfa_new_users ?? false}
onCheckedChange={(v) => handleConfigToggle('enforce_mfa_new_users', v)}
disabled={updateConfig.isPending}
/>
</div>
</>
)}
</CardContent>
</Card>
<CreateUserDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@ -0,0 +1,323 @@
import { useState, useRef, useEffect } from 'react';
import { toast } from 'sonner';
import {
MoreHorizontal,
ShieldCheck,
KeyRound,
UserX,
UserCheck,
LogOut,
Smartphone,
ChevronRight,
Loader2,
Trash2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import {
useUpdateRole,
useResetPassword,
useDisableMfa,
useEnforceMfa,
useRemoveMfaEnforcement,
useToggleUserActive,
useRevokeSessions,
useDeleteUser,
getErrorMessage,
} from '@/hooks/useAdmin';
import type { AdminUserDetail, UserRole } from '@/types';
import { cn } from '@/lib/utils';
interface UserActionsMenuProps {
user: AdminUserDetail;
currentUsername: string | null;
}
const ROLES: { value: UserRole; label: string }[] = [
{ value: 'admin', label: 'Admin' },
{ value: 'standard', label: 'Standard' },
{ value: 'public_event_manager', label: 'Public Event Manager' },
];
export default function UserActionsMenu({ user, currentUsername }: UserActionsMenuProps) {
const [open, setOpen] = useState(false);
const [roleSubmenuOpen, setRoleSubmenuOpen] = useState(false);
const [tempPassword, setTempPassword] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const updateRole = useUpdateRole();
const resetPassword = useResetPassword();
const disableMfa = useDisableMfa();
const enforceMfa = useEnforceMfa();
const removeMfaEnforcement = useRemoveMfaEnforcement();
const toggleActive = useToggleUserActive();
const revokeSessions = useRevokeSessions();
const deleteUser = useDeleteUser();
// Close on outside click
useEffect(() => {
const handleOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
setRoleSubmenuOpen(false);
}
};
if (open) document.addEventListener('mousedown', handleOutside);
return () => document.removeEventListener('mousedown', handleOutside);
}, [open]);
const handleAction = async (fn: () => Promise<unknown>, successMsg: string) => {
try {
await fn();
toast.success(successMsg);
setOpen(false);
} catch (err) {
toast.error(getErrorMessage(err, 'Action failed'));
}
};
// Two-click confirms
const disableMfaConfirm = useConfirmAction(() => {
handleAction(() => disableMfa.mutateAsync(user.id), 'MFA disabled');
});
const toggleActiveConfirm = useConfirmAction(() => {
handleAction(
() => toggleActive.mutateAsync({ userId: user.id, active: !user.is_active }),
user.is_active ? 'Account disabled' : 'Account enabled'
);
});
const revokeSessionsConfirm = useConfirmAction(() => {
handleAction(() => revokeSessions.mutateAsync(user.id), 'Sessions revoked');
});
const deleteUserConfirm = useConfirmAction(async () => {
try {
const result = await deleteUser.mutateAsync(user.id);
toast.success(`User '${(result as { deleted_username: string }).deleted_username}' permanently deleted`);
setOpen(false);
} catch (err) {
toast.error(getErrorMessage(err, 'Delete failed'));
}
});
const isLoading =
updateRole.isPending ||
resetPassword.isPending ||
disableMfa.isPending ||
enforceMfa.isPending ||
removeMfaEnforcement.isPending ||
toggleActive.isPending ||
revokeSessions.isPending ||
deleteUser.isPending;
return (
<div ref={menuRef} className="relative">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setOpen((v) => !v)}
aria-label="User actions"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
{open && (
<div className="absolute right-0 top-8 z-50 min-w-[200px] rounded-lg border bg-card shadow-lg py-1">
{/* Edit Role */}
<div className="relative">
<button
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onMouseEnter={() => setRoleSubmenuOpen(true)}
onMouseLeave={() => setRoleSubmenuOpen(false)}
onClick={() => setRoleSubmenuOpen((v) => !v)}
>
<span className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
Edit Role
</span>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
</button>
{roleSubmenuOpen && (
<div
className="absolute right-full top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1"
onMouseEnter={() => setRoleSubmenuOpen(true)}
onMouseLeave={() => setRoleSubmenuOpen(false)}
>
{ROLES.map(({ value, label }) => (
<button
key={value}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors',
user.role === value && 'text-accent'
)}
onClick={() =>
handleAction(
() => updateRole.mutateAsync({ userId: user.id, role: value }),
`Role updated to ${label}`
)
}
>
{user.role === value && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
{label}
</button>
))}
</div>
)}
</div>
{/* Reset Password */}
{tempPassword ? (
<div className="px-3 py-2 space-y-1.5">
<p className="text-[11px] text-muted-foreground">Temporary password:</p>
<code
className="block px-2 py-1.5 bg-card-elevated rounded text-xs font-mono text-accent select-all break-all cursor-pointer hover:bg-card-elevated/80 transition-colors"
title="Click to copy"
onClick={() => {
navigator.clipboard.writeText(tempPassword);
toast.success('Password copied to clipboard');
}}
>
{tempPassword}
</code>
<button
className="w-full rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setTempPassword(null);
setOpen(false);
}}
>
Done
</button>
</div>
) : (
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={async () => {
try {
const result = await resetPassword.mutateAsync(user.id);
setTempPassword((result as { temporary_password: string }).temporary_password);
toast.success('Password reset — user must change on next login');
} catch (err) {
toast.error(getErrorMessage(err, 'Password reset failed'));
}
}}
>
<KeyRound className="h-4 w-4 text-muted-foreground" />
Reset Password
</button>
)}
<div className="my-1 border-t border-border" />
{/* MFA actions */}
{user.mfa_enforce_pending ? (
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={() =>
handleAction(
() => removeMfaEnforcement.mutateAsync(user.id),
'MFA enforcement removed'
)
}
>
<Smartphone className="h-4 w-4 text-muted-foreground" />
Remove MFA Enforcement
</button>
) : (
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={() =>
handleAction(() => enforceMfa.mutateAsync(user.id), 'MFA enforcement set')
}
>
<Smartphone className="h-4 w-4 text-muted-foreground" />
Enforce MFA
</button>
)}
{user.totp_enabled && (
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
disableMfaConfirm.confirming
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
: 'hover:bg-card-elevated'
)}
onClick={disableMfaConfirm.handleClick}
>
<Smartphone className="h-4 w-4" />
{disableMfaConfirm.confirming ? 'Sure? Click to confirm' : 'Disable MFA'}
</button>
)}
<div className="my-1 border-t border-border" />
{/* Disable / Enable Account */}
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
toggleActiveConfirm.confirming
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
: 'hover:bg-card-elevated'
)}
onClick={toggleActiveConfirm.handleClick}
>
{user.is_active ? (
<>
<UserX className="h-4 w-4" />
{toggleActiveConfirm.confirming ? 'Sure? Click to confirm' : 'Disable Account'}
</>
) : (
<>
<UserCheck className="h-4 w-4 text-green-400" />
{toggleActiveConfirm.confirming ? 'Sure? Click to confirm' : 'Enable Account'}
</>
)}
</button>
{/* Revoke Sessions */}
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
revokeSessionsConfirm.confirming
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
: 'hover:bg-card-elevated'
)}
onClick={revokeSessionsConfirm.handleClick}
>
<LogOut className="h-4 w-4" />
{revokeSessionsConfirm.confirming ? 'Sure? Click to confirm' : 'Revoke All Sessions'}
</button>
{/* Delete User — hidden for own account */}
{currentUsername !== user.username && (
<>
<div className="my-1 border-t border-border" />
<button
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
deleteUserConfirm.confirming
? 'text-red-400 bg-red-500/10 hover:bg-red-500/15'
: 'text-red-400 hover:bg-card-elevated'
)}
onClick={deleteUserConfirm.handleClick}
>
<Trash2 className="h-4 w-4" />
{deleteUserConfirm.confirming ? 'Sure? This is permanent' : 'Delete User'}
</button>
</>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,197 @@
import { X, User, ShieldCheck, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useAdminUserDetail, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
import { getRelativeTime } from '@/lib/date-utils';
import { cn } from '@/lib/utils';
import type { UserRole } from '@/types';
interface UserDetailSectionProps {
userId: number;
onClose: () => void;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-3 py-1.5">
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
<span className="text-xs text-foreground text-right">{value || '—'}</span>
</div>
);
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span
className={cn(
'text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide',
active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'
)}
>
{active ? 'Active' : 'Disabled'}
</span>
);
}
function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean }) {
if (enabled) {
return (
<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>
);
}
if (pending) {
return (
<span className="text-[9px] px-1.5 py-0.5 rounded font-medium uppercase tracking-wide bg-orange-500/15 text-orange-400">
Pending
</span>
);
}
return <span className="text-xs text-muted-foreground">Off</span>;
}
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
const { data: user, isLoading } = useAdminUserDetail(userId);
const updateRole = useUpdateRole();
const handleRoleChange = async (newRole: UserRole) => {
if (!user || newRole === user.role) return;
try {
await updateRole.mutateAsync({ userId: user.id, role: newRole });
toast.success(`Role updated to "${newRole}"`);
} catch (err) {
toast.error(getErrorMessage(err, 'Failed to update role'));
}
};
if (isLoading) {
return (
<div className="grid grid-cols-4 gap-4">
<Card className="col-span-1">
<CardContent className="p-5 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-1">
<CardContent className="p-5 space-y-3">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</CardContent>
</Card>
</div>
);
}
if (!user) return null;
return (
<div className="grid grid-cols-4 gap-4">
{/* User Information (read-only) */}
<Card className="col-span-1">
<CardHeader className="flex-row items-center justify-between pb-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<User className="h-3.5 w-3.5 text-accent" />
</div>
<CardTitle className="text-sm">User Information</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={onClose}
>
<X className="h-3.5 w-3.5" />
</Button>
</CardHeader>
<CardContent className="pt-0 space-y-0.5">
<DetailRow label="Username" value={user.username} />
<DetailRow label="First Name" value={user.first_name} />
<DetailRow label="Last Name" value={user.last_name} />
<DetailRow label="Email" value={user.email} />
<DetailRow label="Preferred Name" value={user.preferred_name} />
<DetailRow
label="Created"
value={getRelativeTime(user.created_at)}
/>
</CardContent>
</Card>
{/* Security & Permissions */}
<Card className="col-span-1">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<ShieldCheck className="h-3.5 w-3.5 text-accent" />
</div>
<CardTitle className="text-sm">Security & Permissions</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-0.5">
<div className="flex items-center justify-between gap-3 py-1.5">
<span className="text-xs text-muted-foreground shrink-0">Role</span>
<div className="flex items-center gap-1.5">
<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]"
disabled={updateRole.isPending}
>
<option value="admin">Admin</option>
<option value="standard">Standard</option>
<option value="public_event_manager">Pub. Events</option>
</Select>
{updateRole.isPending && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</div>
</div>
<DetailRow
label="Account Status"
value={<StatusBadge active={user.is_active} />}
/>
<DetailRow
label="MFA Status"
value={
<MfaBadge
enabled={user.totp_enabled}
pending={user.mfa_enforce_pending}
/>
}
/>
<DetailRow
label="Must Change Pwd"
value={user.must_change_password ? 'Yes' : 'No'}
/>
<DetailRow
label="Active Sessions"
value={String(user.active_sessions)}
/>
<DetailRow
label="Last Login"
value={user.last_login_at ? getRelativeTime(user.last_login_at) : null}
/>
<DetailRow
label="Last Pwd Change"
value={
user.last_password_change_at
? getRelativeTime(user.last_password_change_at)
: null
}
/>
<DetailRow
label="Locked Until"
value={user.locked_until ?? null}
/>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,42 @@
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
// ── StatCard ─────────────────────────────────────────────────────────────────
interface StatCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
iconBg?: string;
}
export function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) {
return (
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-5">
<div className="flex items-center gap-3">
<div className={cn('p-1.5 rounded-md', iconBg)}>{icon}</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">{label}</p>
<p className="font-heading text-xl font-bold tabular-nums">{value}</p>
</div>
</div>
</CardContent>
</Card>
);
}
// ── actionColor ──────────────────────────────────────────────────────────────
export function actionColor(action: string): string {
if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) {
return 'bg-red-500/15 text-red-400';
}
if (action.includes('login') || action.includes('create') || action.includes('enabled')) {
return 'bg-green-500/15 text-green-400';
}
if (action.includes('config') || action.includes('role') || action.includes('password')) {
return 'bg-orange-500/15 text-orange-400';
}
return 'bg-blue-500/15 text-blue-400';
}

View File

@ -1,15 +1,16 @@
import { useState, FormEvent } from 'react';
import { Navigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Lock, Loader2 } from 'lucide-react';
import { AlertTriangle, Copy, Lock, Loader2, ShieldCheck, UserPlus } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { getErrorMessage } from '@/lib/api';
import api, { getErrorMessage } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import AmbientBackground from './AmbientBackground';
import type { TotpSetupResponse } from '@/types';
/** Validates password against backend rules: 12-128 chars, at least one letter + one non-letter. */
function validatePassword(password: string): string | null {
@ -20,254 +21,709 @@ function validatePassword(password: string): string | null {
return null;
}
export default function LockScreen() {
const { authStatus, isLoading, login, setup, verifyTotp, mfaRequired, isLoginPending, isSetupPending, isTotpPending } = useAuth();
type ScreenMode =
| 'login'
| 'setup' // first-run admin account creation
| 'register' // open registration
| 'totp' // TOTP challenge after login
| 'mfa_enforce' // forced MFA setup after login/register
| 'force_pw'; // admin-forced password change
// Credentials state (shared across login/setup states)
type MfaEnforceStep = 'qr' | 'verify' | 'backup_codes';
export default function LockScreen() {
const {
authStatus,
isLoading,
login,
register,
setup,
verifyTotp,
mfaRequired,
mfaSetupRequired,
mfaToken,
isLoginPending,
isRegisterPending,
isSetupPending,
isTotpPending,
} = useAuth();
// ── Shared credential fields ──
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// TOTP challenge state
// ── TOTP challenge ──
const [totpCode, setTotpCode] = useState('');
const [useBackupCode, setUseBackupCode] = useState(false);
// Lockout handling (HTTP 423)
const [lockoutMessage, setLockoutMessage] = useState<string | null>(null);
// ── Registration mode ──
const [mode, setMode] = useState<ScreenMode>('login');
// Redirect authenticated users immediately
if (!isLoading && authStatus?.authenticated) {
// ── Inline error (423 lockout, 403 disabled, 401 bad creds) ──
const [loginError, setLoginError] = useState<string | null>(null);
// ── MFA enforcement setup flow ──
const [mfaEnforceStep, setMfaEnforceStep] = useState<MfaEnforceStep>('qr');
const [mfaEnforceQr, setMfaEnforceQr] = useState('');
const [mfaEnforceSecret, setMfaEnforceSecret] = useState('');
const [mfaEnforceBackupCodes, setMfaEnforceBackupCodes] = useState<string[]>([]);
const [mfaEnforceCode, setMfaEnforceCode] = useState('');
const [isMfaEnforceSetupPending, setIsMfaEnforceSetupPending] = useState(false);
const [isMfaEnforceConfirmPending, setIsMfaEnforceConfirmPending] = useState(false);
// ── Forced password change ──
const [forcedNewPassword, setForcedNewPassword] = useState('');
const [forcedConfirmPassword, setForcedConfirmPassword] = useState('');
const [isForcePwPending, setIsForcePwPending] = useState(false);
// Redirect authenticated users (no pending MFA flows)
if (!isLoading && authStatus?.authenticated && !mfaSetupRequired && mode !== 'force_pw') {
return <Navigate to="/dashboard" replace />;
}
const isSetup = authStatus?.setup_required === true;
const registrationOpen = authStatus?.registration_open === true;
// Derive active screen — hook-driven states override local mode
const activeMode: ScreenMode = mfaRequired
? 'totp'
: mfaSetupRequired
? 'mfa_enforce'
: isSetup
? 'setup'
: mode;
// ── Handlers ──
const handleCredentialSubmit = async (e: FormEvent) => {
e.preventDefault();
setLockoutMessage(null);
setLoginError(null);
if (isSetup) {
// Setup mode: validate password then create account
const validationError = validatePassword(password);
if (validationError) {
toast.error(validationError);
return;
}
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
const err = validatePassword(password);
if (err) { toast.error(err); return; }
if (password !== confirmPassword) { toast.error('Passwords do not match'); return; }
try {
await setup({ username, password });
// useAuth invalidates auth query → Navigate above handles redirect
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to create account'));
}
} else {
// Login mode
try {
await login({ username, password });
// If mfaRequired becomes true, the TOTP state renders automatically
// If not required, useAuth invalidates auth query → Navigate above handles redirect
} catch (error: any) {
if (error?.response?.status === 423) {
const msg = error.response.data?.detail || 'Account locked. Try again later.';
setLockoutMessage(msg);
} else {
toast.error(getErrorMessage(error, 'Invalid username or password'));
}
return;
}
try {
const result = await login({ username, password });
// must_change_password: backend issued session but UI must gate the app
if ('must_change_password' in result && result.must_change_password) {
setMode('force_pw');
}
// 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) {
setLoginError(error.response.data?.detail || 'Account is disabled. Contact an administrator.');
} else {
setLoginError(getErrorMessage(error, 'Invalid username or password'));
}
}
};
const handleRegisterSubmit = async (e: FormEvent) => {
e.preventDefault();
const err = validatePassword(password);
if (err) { toast.error(err); return; }
if (password !== confirmPassword) { toast.error('Passwords do not match'); return; }
try {
await register({ username, password });
// On success useAuth invalidates query → Navigate handles redirect
// If mfa_setup_required the hook sets mfaSetupRequired → activeMode switches
} catch (error) {
toast.error(getErrorMessage(error, 'Registration failed'));
}
};
const handleTotpSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
await verifyTotp(totpCode);
// useAuth invalidates auth query → Navigate above handles redirect
await verifyTotp({ code: totpCode, isBackup: useBackupCode });
} catch (error) {
toast.error(getErrorMessage(error, 'Invalid verification code'));
setTotpCode('');
}
};
const handleMfaEnforceStart = async () => {
if (!mfaToken) return;
setIsMfaEnforceSetupPending(true);
try {
const { data } = await api.post<TotpSetupResponse>('/auth/totp/enforce-setup', {
mfa_token: mfaToken,
});
setMfaEnforceQr(data.qr_code_base64);
setMfaEnforceSecret(data.secret);
setMfaEnforceBackupCodes(data.backup_codes);
setMfaEnforceStep('qr');
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to begin MFA setup'));
} finally {
setIsMfaEnforceSetupPending(false);
}
};
const handleMfaEnforceConfirm = async () => {
if (!mfaToken || !mfaEnforceCode || mfaEnforceCode.length !== 6) {
toast.error('Enter a 6-digit code from your authenticator app');
return;
}
setIsMfaEnforceConfirmPending(true);
try {
await api.post('/auth/totp/enforce-confirm', {
mfa_token: mfaToken,
code: mfaEnforceCode,
});
// Backend issued session — show backup codes then redirect
setMfaEnforceStep('backup_codes');
} catch (error) {
toast.error(getErrorMessage(error, 'Invalid code — try again'));
setMfaEnforceCode('');
} finally {
setIsMfaEnforceConfirmPending(false);
}
};
const handleCopyBackupCodes = async () => {
try {
await navigator.clipboard.writeText(mfaEnforceBackupCodes.join('\n'));
toast.success('Backup codes copied');
} catch {
toast.error('Failed to copy — please select and copy manually');
}
};
const handleForcePwSubmit = async (e: FormEvent) => {
e.preventDefault();
const err = validatePassword(forcedNewPassword);
if (err) { toast.error(err); return; }
if (forcedNewPassword !== forcedConfirmPassword) {
toast.error('Passwords do not match');
return;
}
setIsForcePwPending(true);
try {
await api.post('/auth/change-password', {
old_password: password, // retained from original login submission
new_password: forcedNewPassword,
});
toast.success('Password updated — welcome to UMBRA');
// Auth query still has authenticated:true → Navigate will fire after re-render
setMode('login');
} catch (error) {
toast.error(getErrorMessage(error, 'Failed to change password'));
} finally {
setIsForcePwPending(false);
}
};
// ── Render helpers ──
const renderTotpChallenge = () => (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-accent/10">
<Lock className="h-4 w-4 text-accent" aria-hidden="true" />
</div>
<div>
<CardTitle>Two-Factor Authentication</CardTitle>
<CardDescription>
{useBackupCode ? 'Enter one of your backup codes' : 'Enter the code from your authenticator app'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleTotpSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="totp-code">
{useBackupCode ? 'Backup Code' : 'Authenticator Code'}
</Label>
<Input
id="totp-code"
type="text"
inputMode={useBackupCode ? 'text' : 'numeric'}
pattern={useBackupCode ? undefined : '[0-9]*'}
maxLength={useBackupCode ? 9 : 6}
value={totpCode}
onChange={(e) =>
setTotpCode(
useBackupCode
? e.target.value.replace(/[^A-Za-z0-9-]/g, '').toUpperCase()
: e.target.value.replace(/\D/g, '')
)
}
placeholder={useBackupCode ? 'XXXX-XXXX' : '000000'}
autoFocus
autoComplete="one-time-code"
className="text-center text-lg tracking-widest"
/>
</div>
<Button type="submit" className="w-full" disabled={isTotpPending}>
{isTotpPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Verifying</>
) : (
'Verify'
)}
</Button>
<button
type="button"
onClick={() => { setUseBackupCode(!useBackupCode); setTotpCode(''); }}
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{useBackupCode ? 'Use authenticator app instead' : 'Use a backup code instead'}
</button>
</form>
</CardContent>
</>
);
const renderMfaEnforce = () => {
// Show a loading/start state if QR hasn't been fetched yet
if (!mfaEnforceQr && mfaEnforceStep !== 'backup_codes') {
return (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<ShieldCheck className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Set Up Two-Factor Authentication</CardTitle>
<CardDescription>Your account requires MFA before you can continue</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
An administrator has required that your account be protected with an authenticator app.
You'll need an app like Google Authenticator, Authy, or 1Password to continue.
</p>
<Button className="w-full" onClick={handleMfaEnforceStart} disabled={isMfaEnforceSetupPending}>
{isMfaEnforceSetupPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Generating QR Code</>
) : (
'Begin Setup'
)}
</Button>
</CardContent>
</>
);
}
if (mfaEnforceStep === 'backup_codes') {
return (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<AlertTriangle className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Save Your Backup Codes</CardTitle>
<CardDescription>Store these somewhere safe they won't be shown again</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-xs text-muted-foreground">
These {mfaEnforceBackupCodes.length} codes can each be used once if you lose access to
your authenticator app. MFA is now active on your account.
</p>
<div className="grid grid-cols-2 gap-2 bg-secondary rounded-md p-3">
{mfaEnforceBackupCodes.map((code, i) => (
<code key={i} className="text-xs font-mono text-foreground text-center py-0.5">
{code}
</code>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={handleCopyBackupCodes}
className="w-full gap-2"
>
<Copy className="h-4 w-4" />
Copy All Codes
</Button>
<Button
className="w-full"
onClick={() => {
// Session is already issued — redirect to app
window.location.href = '/dashboard';
}}
>
I've saved my backup codes Enter UMBRA
</Button>
</CardContent>
</>
);
}
if (mfaEnforceStep === 'qr') {
return (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<ShieldCheck className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Scan QR Code</CardTitle>
<CardDescription>Add UMBRA to your authenticator app</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<img
src={`data:image/png;base64,${mfaEnforceQr}`}
alt="TOTP QR code — scan with your authenticator app"
className="h-44 w-44 rounded-md border border-border"
/>
</div>
<p className="text-xs text-muted-foreground text-center">
Can't scan? Enter this code manually in your app:
</p>
<code className="block text-center text-xs font-mono bg-secondary px-3 py-2 rounded-md tracking-widest break-all">
{mfaEnforceSecret}
</code>
<Button className="w-full" onClick={() => setMfaEnforceStep('verify')}>
Next: Verify Code
</Button>
</CardContent>
</>
);
}
// verify step
return (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<ShieldCheck className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Verify Your Authenticator</CardTitle>
<CardDescription>Enter the 6-digit code shown in your app</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="enforce-code">Verification Code</Label>
<Input
id="enforce-code"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="000000"
value={mfaEnforceCode}
onChange={(e) => setMfaEnforceCode(e.target.value.replace(/\D/g, ''))}
className="text-center tracking-widest text-lg"
autoFocus
autoComplete="one-time-code"
onKeyDown={(e) => { if (e.key === 'Enter') handleMfaEnforceConfirm(); }}
/>
</div>
<Button
className="w-full"
onClick={handleMfaEnforceConfirm}
disabled={isMfaEnforceConfirmPending}
>
{isMfaEnforceConfirmPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Verifying</>
) : (
'Verify & Enable MFA'
)}
</Button>
<button
type="button"
onClick={() => setMfaEnforceStep('qr')}
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Back to QR code
</button>
</CardContent>
</>
);
};
const renderLoginOrSetup = () => (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-accent/10">
<Lock className="h-4 w-4 text-accent" aria-hidden="true" />
</div>
<div>
<CardTitle>{isSetup ? 'Welcome to UMBRA' : 'Sign in'}</CardTitle>
<CardDescription>
{isSetup ? 'Create your account to get started' : 'Enter your credentials to continue'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{loginError && (
<div
role="alert"
className={cn(
'flex items-center gap-2 rounded-md border border-red-500/30',
'bg-red-500/10 px-3 py-2 mb-4'
)}
>
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
<p className="text-xs text-red-400">{loginError}</p>
</div>
)}
<form onSubmit={handleCredentialSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" required>Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
required
autoFocus
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" required>Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isSetup ? 'Create a password' : 'Enter password'}
required
autoComplete={isSetup ? 'new-password' : 'current-password'}
/>
</div>
{isSetup && (
<div className="space-y-2">
<Label htmlFor="confirm-password" required>Confirm Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
required
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">
Must be 12-128 characters with at least one letter and one non-letter.
</p>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isLoginPending || isSetupPending}
>
{isLoginPending || isSetupPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Please wait</>
) : isSetup ? (
'Create Account'
) : (
'Sign in'
)}
</Button>
</form>
{/* Open registration link — only shown on login screen when enabled */}
{!isSetup && registrationOpen && (
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setMode('register');
setUsername('');
setPassword('');
setConfirmPassword('');
setLoginError(null);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Don't have an account?{' '}
<span className="text-accent hover:underline">Create one</span>
</button>
</div>
)}
</CardContent>
</>
);
const renderRegister = () => (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-accent/10">
<UserPlus className="h-4 w-4 text-accent" aria-hidden="true" />
</div>
<div>
<CardTitle>Create Account</CardTitle>
<CardDescription>Register for access to UMBRA</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleRegisterSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="reg-username" required>Username</Label>
<Input
id="reg-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose a username"
required
autoFocus
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reg-password" required>Password</Label>
<Input
id="reg-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Create a password"
required
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reg-confirm-password" required>Confirm Password</Label>
<Input
id="reg-confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
required
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">
Must be 12-128 characters with at least one letter and one non-letter.
</p>
</div>
<Button type="submit" className="w-full" disabled={isRegisterPending}>
{isRegisterPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Creating account</>
) : (
'Create Account'
)}
</Button>
</form>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setMode('login');
setUsername('');
setPassword('');
setConfirmPassword('');
setLoginError(null);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Already have an account?{' '}
<span className="text-accent hover:underline">Sign in</span>
</button>
</div>
</CardContent>
</>
);
const renderForcedPasswordChange = () => (
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-amber-500/10">
<Lock className="h-4 w-4 text-amber-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Password Change Required</CardTitle>
<CardDescription>An administrator has reset your password. Please set a new one.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleForcePwSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="force-new-pw" required>New Password</Label>
<Input
id="force-new-pw"
type="password"
value={forcedNewPassword}
onChange={(e) => setForcedNewPassword(e.target.value)}
placeholder="Create a new password"
required
autoFocus
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="force-confirm-pw" required>Confirm New Password</Label>
<Input
id="force-confirm-pw"
type="password"
value={forcedConfirmPassword}
onChange={(e) => setForcedConfirmPassword(e.target.value)}
placeholder="Confirm your new password"
required
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">
Must be 12-128 characters with at least one letter and one non-letter.
</p>
</div>
<Button type="submit" className="w-full" disabled={isForcePwPending}>
{isForcePwPending ? (
<><Loader2 className="h-4 w-4 animate-spin" />Saving</>
) : (
'Set New Password'
)}
</Button>
</form>
</CardContent>
</>
);
return (
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background p-4 overflow-hidden">
<AmbientBackground />
{/* Wordmark — in flex flow above card */}
{/* Wordmark */}
<span className="font-heading text-5xl sm:text-6xl font-bold tracking-tight text-accent mb-10 relative z-10 animate-slide-up">
UMBRA
</span>
{/* Auth card */}
<Card className="w-full max-w-sm relative z-10 border-border/80 animate-slide-up">
{mfaRequired ? (
// State C: TOTP challenge
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-accent/10">
<Lock className="h-4 w-4 text-accent" aria-hidden="true" />
</div>
<div>
<CardTitle>Two-Factor Authentication</CardTitle>
<CardDescription>
{useBackupCode
? 'Enter one of your backup codes'
: 'Enter the code from your authenticator app'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleTotpSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="totp-code">
{useBackupCode ? 'Backup Code' : 'Authenticator Code'}
</Label>
<Input
id="totp-code"
type="text"
inputMode={useBackupCode ? 'text' : 'numeric'}
pattern={useBackupCode ? undefined : '[0-9]*'}
maxLength={useBackupCode ? 9 : 6}
value={totpCode}
onChange={(e) =>
setTotpCode(
useBackupCode
? e.target.value.replace(/[^0-9-]/g, '')
: e.target.value.replace(/\D/g, '')
)
}
placeholder={useBackupCode ? 'XXXX-XXXX' : '000000'}
autoFocus
autoComplete="one-time-code"
className="text-center text-lg tracking-widest"
/>
</div>
<Button type="submit" className="w-full" disabled={isTotpPending}>
{isTotpPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verifying
</>
) : (
'Verify'
)}
</Button>
<button
type="button"
onClick={() => {
setUseBackupCode(!useBackupCode);
setTotpCode('');
}}
className="w-full text-center text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{useBackupCode ? 'Use authenticator app instead' : 'Use a backup code instead'}
</button>
</form>
</CardContent>
</>
) : (
// State A (setup) or State B (login)
<>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-accent/10">
<Lock className="h-4 w-4 text-accent" aria-hidden="true" />
</div>
<div>
<CardTitle>{isSetup ? 'Welcome to UMBRA' : 'Sign in'}</CardTitle>
<CardDescription>
{isSetup
? 'Create your account to get started'
: 'Enter your credentials to continue'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{/* Lockout warning banner */}
{lockoutMessage && (
<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'
)}
>
<Lock className="h-4 w-4 text-red-400 shrink-0" aria-hidden="true" />
<p className="text-xs text-red-400">{lockoutMessage}</p>
</div>
)}
<form onSubmit={handleCredentialSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" required>Username</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => { setUsername(e.target.value); setLockoutMessage(null); }}
placeholder="Enter username"
required
autoFocus
autoComplete="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" required>Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => { setPassword(e.target.value); setLockoutMessage(null); }}
placeholder={isSetup ? 'Create a password' : 'Enter password'}
required
autoComplete={isSetup ? 'new-password' : 'current-password'}
/>
</div>
{isSetup && (
<div className="space-y-2">
<Label htmlFor="confirm-password" required>Confirm Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your password"
required
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">
Must be 12-128 characters with at least one letter and one non-letter.
</p>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={isLoginPending || isSetupPending || !!lockoutMessage}
>
{isLoginPending || isSetupPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Please wait
</>
) : isSetup ? (
'Create Account'
) : (
'Sign in'
)}
</Button>
</form>
</CardContent>
</>
)}
{activeMode === 'totp' && renderTotpChallenge()}
{activeMode === 'mfa_enforce' && renderMfaEnforce()}
{activeMode === 'force_pw' && renderForcedPasswordChange()}
{activeMode === 'register' && renderRegister()}
{(activeMode === 'login' || activeMode === 'setup') && renderLoginOrSetup()}
</Card>
</div>
);

View File

@ -232,7 +232,7 @@ export default function EventDetailPanel({
staleTime: 5 * 60 * 1000,
});
const [isEditing, setIsEditing] = useState(false);
const [isEditing, setIsEditing] = useState(isCreating);
const [editState, setEditState] = useState<EditState>(() =>
isCreating
? buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '')
@ -480,7 +480,7 @@ export default function EventDetailPanel({
>
<X className="h-3.5 w-3.5" />
</Button>
) : isEditing ? (
) : (isEditing || isCreating) ? (
<>
<Button
variant="ghost"
@ -586,7 +586,7 @@ export default function EventDetailPanel({
</Button>
</div>
</div>
) : isEditing ? (
) : (isEditing || isCreating) ? (
/* Edit / Create mode */
<div className="space-y-4">
{/* Title (only shown in body for create mode; edit mode has it in header) */}

View File

@ -16,6 +16,7 @@ import {
X,
LogOut,
Lock,
Shield,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
@ -44,7 +45,7 @@ interface SidebarProps {
export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const { logout } = useAuth();
const { logout, isAdmin } = useAuth();
const { lock } = useLock();
const [projectsExpanded, setProjectsExpanded] = useState(false);
@ -193,6 +194,16 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
<Lock className="h-5 w-5 shrink-0" />
{showExpanded && <span>Lock</span>}
</button>
{isAdmin && (
<NavLink
to="/admin"
onClick={mobileOpen ? onMobileClose : undefined}
className={navLinkClass}
>
<Shield className="h-5 w-5 shrink-0" />
{showExpanded && <span>Admin</span>}
</NavLink>
)}
<NavLink
to="/settings"
onClick={mobileOpen ? onMobileClose : undefined}

View File

@ -70,7 +70,7 @@ export default function ReminderDetailPanel({
}: ReminderDetailPanelProps) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [isEditing, setIsEditing] = useState(isCreating);
const [editState, setEditState] = useState<EditState>(() =>
isCreating ? buildCreateState() : reminder ? buildEditState(reminder) : buildCreateState()
);
@ -224,7 +224,7 @@ export default function ReminderDetailPanel({
)}
<div className="flex items-center gap-1 shrink-0">
{isEditing ? (
{(isEditing || isCreating) ? (
<>
<Button
variant="ghost"
@ -308,7 +308,7 @@ export default function ReminderDetailPanel({
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{isEditing ? (
{(isEditing || isCreating) ? (
/* Edit / Create mode */
<div className="space-y-4">
{isCreating && (

View File

@ -95,7 +95,7 @@ export default function TodoDetailPanel({
}: TodoDetailPanelProps) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [isEditing, setIsEditing] = useState(isCreating);
const [editState, setEditState] = useState<EditState>(() =>
isCreating ? buildCreateState(createDefaults) : todo ? buildEditState(todo) : buildCreateState()
);
@ -254,7 +254,7 @@ export default function TodoDetailPanel({
)}
<div className="flex items-center gap-1 shrink-0">
{isEditing ? (
{(isEditing || isCreating) ? (
<>
<Button
variant="ghost"
@ -326,7 +326,7 @@ export default function TodoDetailPanel({
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{isEditing ? (
{(isEditing || isCreating) ? (
/* Edit / Create mode */
<div className="space-y-4">
{isCreating && (

View File

@ -0,0 +1,189 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api, { getErrorMessage } from '@/lib/api';
import type {
AdminUserDetail,
AdminDashboardData,
SystemConfig,
AuditLogEntry,
UserRole,
} from '@/types';
interface UserListResponse {
users: AdminUserDetail[];
total: number;
}
interface AuditLogResponse {
entries: AuditLogEntry[];
total: number;
}
interface CreateUserPayload {
username: string;
password: string;
role: UserRole;
email?: string;
first_name?: string;
last_name?: string;
preferred_name?: string;
}
interface UpdateRolePayload {
userId: number;
role: UserRole;
}
interface ResetPasswordResult {
message: string;
temporary_password: string;
}
// ── Queries ──────────────────────────────────────────────────────────────────
export function useAdminUsers() {
return useQuery<AdminUserDetail[]>({
queryKey: ['admin', 'users'],
queryFn: async () => {
const { data } = await api.get<UserListResponse>('/admin/users');
return data.users;
},
});
}
export function useAdminUserDetail(userId: number | null) {
return useQuery<AdminUserDetail>({
queryKey: ['admin', 'users', userId],
queryFn: async () => {
const { data } = await api.get<AdminUserDetail>(`/admin/users/${userId}`);
return data;
},
enabled: userId !== null,
});
}
export function useAdminDashboard() {
return useQuery<AdminDashboardData>({
queryKey: ['admin', 'dashboard'],
queryFn: async () => {
const { data } = await api.get<AdminDashboardData>('/admin/dashboard');
return data;
},
});
}
export function useAdminConfig() {
return useQuery<SystemConfig>({
queryKey: ['admin', 'config'],
queryFn: async () => {
const { data } = await api.get<SystemConfig>('/admin/config');
return data;
},
});
}
export function useAuditLog(
page: number,
perPage: number,
action?: string,
targetUserId?: number
) {
return useQuery<AuditLogResponse>({
queryKey: ['admin', 'audit-log', page, perPage, action, targetUserId],
queryFn: async () => {
const params: Record<string, unknown> = { page, per_page: perPage };
if (action) params.action = action;
if (targetUserId) params.target_user_id = targetUserId;
const { data } = await api.get<AuditLogResponse>('/admin/audit-log', { params });
return data;
},
});
}
// ── Mutations ─────────────────────────────────────────────────────────────────
function useAdminMutation<TVariables, TData = unknown>(
mutationFn: (vars: TVariables) => Promise<TData>,
onSuccess?: () => void
) {
const queryClient = useQueryClient();
return useMutation<TData, Error, TVariables>({
mutationFn,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin'] });
onSuccess?.();
},
});
}
export function useCreateUser() {
return useAdminMutation(async (payload: CreateUserPayload) => {
const { data } = await api.post('/admin/users', payload);
return data;
});
}
export function useUpdateRole() {
return useAdminMutation(async ({ userId, role }: UpdateRolePayload) => {
const { data } = await api.put(`/admin/users/${userId}/role`, { role });
return data;
});
}
export function useResetPassword() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.post<ResetPasswordResult>(`/admin/users/${userId}/reset-password`);
return data;
});
}
export function useDisableMfa() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.post(`/admin/users/${userId}/disable-mfa`);
return data;
});
}
export function useEnforceMfa() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.put(`/admin/users/${userId}/enforce-mfa`, { enforce: true });
return data;
});
}
export function useRemoveMfaEnforcement() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.put(`/admin/users/${userId}/enforce-mfa`, { enforce: false });
return data;
});
}
export function useToggleUserActive() {
return useAdminMutation(async ({ userId, active }: { userId: number; active: boolean }) => {
const { data } = await api.put(`/admin/users/${userId}/active`, { is_active: active });
return data;
});
}
export function useRevokeSessions() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.delete(`/admin/users/${userId}/sessions`);
return data;
});
}
export function useDeleteUser() {
return useAdminMutation(async (userId: number) => {
const { data } = await api.delete(`/admin/users/${userId}`);
return data;
});
}
export function useUpdateConfig() {
return useAdminMutation(async (config: Partial<SystemConfig>) => {
const { data } = await api.put('/admin/config', config);
return data;
});
}
// Re-export getErrorMessage for convenience in admin components
export { getErrorMessage };

View File

@ -5,8 +5,8 @@ import type { AuthStatus, LoginResponse } from '@/types';
export function useAuth() {
const queryClient = useQueryClient();
// Ephemeral MFA token — not in TanStack cache, lives only during the TOTP challenge step
const [mfaToken, setMfaToken] = useState<string | null>(null);
const [mfaSetupRequired, setMfaSetupRequired] = useState(false);
const authQuery = useQuery({
queryKey: ['auth'],
@ -23,26 +23,60 @@ export function useAuth() {
return data;
},
onSuccess: (data) => {
if ('mfa_token' in data && data.totp_required) {
// MFA required — store token locally, do NOT mark as authenticated yet
if ('mfa_setup_required' in data && data.mfa_setup_required) {
// MFA enforcement — user must set up TOTP before accessing app
setMfaSetupRequired(true);
setMfaToken(data.mfa_token);
} else if ('mfa_token' in data && 'totp_required' in data && data.totp_required) {
// Regular TOTP challenge
setMfaToken(data.mfa_token);
setMfaSetupRequired(false);
} else {
setMfaToken(null);
setMfaSetupRequired(false);
// Optimistically mark authenticated to prevent form flash during refetch
if ('authenticated' in data && data.authenticated && !('must_change_password' in data && data.must_change_password)) {
queryClient.setQueryData(['auth'], (old: AuthStatus | undefined) => {
if (!old) return old; // let invalidateQueries handle it
return { ...old, authenticated: true };
});
}
queryClient.invalidateQueries({ queryKey: ['auth'] });
}
},
});
const registerMutation = useMutation({
mutationFn: async ({ username, password }: { username: string; password: string }) => {
const { data } = await api.post<LoginResponse & { message?: string }>('/auth/register', { username, password });
return data;
},
onSuccess: (data) => {
if ('mfa_setup_required' in data && data.mfa_setup_required) {
setMfaSetupRequired(true);
setMfaToken(data.mfa_token);
} else {
setMfaToken(null);
setMfaSetupRequired(false);
queryClient.invalidateQueries({ queryKey: ['auth'] });
}
},
});
const totpVerifyMutation = useMutation({
mutationFn: async (code: string) => {
const { data } = await api.post('/auth/totp-verify', {
mfa_token: mfaToken,
code,
});
mutationFn: async ({ code, isBackup }: { code: string; isBackup: boolean }) => {
const payload: Record<string, string> = { mfa_token: mfaToken! };
if (isBackup) {
payload.backup_code = code;
} else {
payload.code = code;
}
const { data } = await api.post('/auth/totp-verify', payload);
return data;
},
onSuccess: () => {
setMfaToken(null);
setMfaSetupRequired(false);
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
});
@ -64,6 +98,7 @@ export function useAuth() {
},
onSuccess: () => {
setMfaToken(null);
setMfaSetupRequired(false);
queryClient.invalidateQueries({ queryKey: ['auth'] });
},
});
@ -71,12 +106,18 @@ export function useAuth() {
return {
authStatus: authQuery.data,
isLoading: authQuery.isLoading,
mfaRequired: mfaToken !== null,
role: authQuery.data?.role ?? null,
isAdmin: authQuery.data?.role === 'admin',
mfaRequired: mfaToken !== null && !mfaSetupRequired,
mfaSetupRequired,
mfaToken,
login: loginMutation.mutateAsync,
register: registerMutation.mutateAsync,
verifyTotp: totpVerifyMutation.mutateAsync,
setup: setupMutation.mutateAsync,
logout: logoutMutation.mutateAsync,
isLoginPending: loginMutation.isPending,
isRegisterPending: registerMutation.isPending,
isTotpPending: totpVerifyMutation.isPending,
isSetupPending: setupMutation.isPending,
};

View File

@ -4,6 +4,7 @@ const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
withCredentials: true,
});
@ -12,7 +13,12 @@ api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
window.location.href = '/login';
const url = error.config?.url || '';
// Don't redirect on auth endpoints — they legitimately return 401
const authEndpoints = ['/auth/login', '/auth/register', '/auth/setup', '/auth/verify-password', '/auth/change-password'];
if (!authEndpoints.some(ep => url.startsWith(ep))) {
window.location.href = '/login';
}
}
return Promise.reject(error);
}

View File

@ -188,14 +188,20 @@ export interface Location {
updated_at: string;
}
export type UserRole = 'admin' | 'standard' | 'public_event_manager';
export interface AuthStatus {
authenticated: boolean;
setup_required: boolean;
role: UserRole | null;
username: string | null;
registration_open: boolean;
}
// Login response discriminated union
export interface LoginSuccessResponse {
authenticated: true;
must_change_password?: boolean;
}
export interface LoginMfaRequiredResponse {
@ -204,7 +210,69 @@ export interface LoginMfaRequiredResponse {
mfa_token: string;
}
export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse;
export interface LoginMfaSetupRequiredResponse {
authenticated: false;
mfa_setup_required: true;
mfa_token: string;
}
export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | LoginMfaSetupRequiredResponse;
// Admin types
export interface AdminUser {
id: number;
username: string;
email: string | null;
first_name: string | null;
last_name: string | null;
role: UserRole;
is_active: boolean;
last_login_at: string | null;
last_password_change_at: string | null;
totp_enabled: boolean;
mfa_enforce_pending: boolean;
created_at: string;
}
export interface AdminUserDetail extends AdminUser {
active_sessions: number;
preferred_name?: string | null;
must_change_password?: boolean;
locked_until?: string | null;
}
export interface SystemConfig {
allow_registration: boolean;
enforce_mfa_new_users: boolean;
}
export interface AuditLogEntry {
id: number;
actor_username: string | null;
target_username: string | null;
action: string;
detail: string | null;
ip_address: string | null;
created_at: string;
}
export interface AdminDashboardData {
total_users: number;
active_users: number;
admin_count: number;
active_sessions: number;
mfa_adoption_rate: number;
recent_logins: Array<{
username: string;
last_login_at: string;
}>;
recent_audit_entries: Array<{
action: string;
actor_username: string | null;
target_username: string | null;
created_at: string;
}>;
}
// TOTP setup response (from POST /api/auth/totp/setup)
export interface TotpSetupResponse {