Compare commits
33 Commits
b2ecedbc94
...
f8c2df9328
| Author | SHA1 | Date | |
|---|---|---|---|
| f8c2df9328 | |||
| a313ce8b32 | |||
| d269742aa2 | |||
| 1aeb725410 | |||
| c4c06be148 | |||
| 5426657b2e | |||
| b2d81f7015 | |||
| c68fd69cdf | |||
| 8582b41b03 | |||
| c3654dc069 | |||
| 48e15fa677 | |||
| e7cb6de7d5 | |||
| c56830ddb0 | |||
| 1ebc41b9d7 | |||
| 8e27f2920b | |||
| 2f58282c31 | |||
| 581efa183a | |||
| 9f7bbbfcbb | |||
| a128005ae5 | |||
| f07ce02576 | |||
| 0fc3f1a14b | |||
| e860723a2a | |||
| 4fc85684ea | |||
| 2438cdcf25 | |||
| 619e220622 | |||
| 72e00f3a69 | |||
| 72ac1d53fb | |||
| cbf4663e8d | |||
| e57a5b00c9 | |||
| e5a7ce13e0 | |||
| d8bdae8ec3 | |||
| 2ec70d9344 | |||
| 464b8b911f |
51
README.md
51
README.md
@ -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 (001–025)
|
||||
│ ├── alembic/versions/ # 37 migrations (001–037)
|
||||
│ └── 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
|
||||
|
||||
101
backend/alembic/versions/026_add_user_role_and_system_config.py
Normal file
101
backend/alembic/versions/026_add_user_role_and_system_config.py
Normal 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")
|
||||
38
backend/alembic/versions/027_add_user_id_to_todos.py
Normal file
38
backend/alembic/versions/027_add_user_id_to_todos.py
Normal 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")
|
||||
36
backend/alembic/versions/028_add_user_id_to_reminders.py
Normal file
36
backend/alembic/versions/028_add_user_id_to_reminders.py
Normal 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")
|
||||
36
backend/alembic/versions/029_add_user_id_to_projects.py
Normal file
36
backend/alembic/versions/029_add_user_id_to_projects.py
Normal 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")
|
||||
40
backend/alembic/versions/030_add_user_id_to_calendars.py
Normal file
40
backend/alembic/versions/030_add_user_id_to_calendars.py
Normal 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")
|
||||
36
backend/alembic/versions/031_add_user_id_to_people.py
Normal file
36
backend/alembic/versions/031_add_user_id_to_people.py
Normal 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")
|
||||
34
backend/alembic/versions/032_add_user_id_to_locations.py
Normal file
34
backend/alembic/versions/032_add_user_id_to_locations.py
Normal 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")
|
||||
@ -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")
|
||||
50
backend/alembic/versions/034_add_user_id_to_ntfy_sent.py
Normal file
50
backend/alembic/versions/034_add_user_id_to_ntfy_sent.py
Normal 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")
|
||||
66
backend/alembic/versions/035_add_performance_indexes.py
Normal file
66
backend/alembic/versions/035_add_performance_indexes.py
Normal 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")
|
||||
104
backend/alembic/versions/036_add_cascade_to_transitive_fks.py
Normal file
104
backend/alembic/versions/036_add_cascade_to_transitive_fks.py
Normal 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"])
|
||||
29
backend/alembic/versions/037_add_user_profile_fields.py
Normal file
29
backend/alembic/versions/037_add_user_profile_fields.py
Normal 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")
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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("/")
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
27
backend/app/models/audit_log.py
Normal file
27
backend/app/models/audit_log.py
Normal 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
|
||||
)
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
27
backend/app/models/system_config.py
Normal file
27
backend/app/models/system_config.py
Normal 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()
|
||||
)
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
802
backend/app/routers/admin.py
Normal file
802
backend/app/routers/admin.py
Normal 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)
|
||||
@ -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. bcrypt→Argon2id transparent upgrade on first login with migrated hash
|
||||
4. bcrypt→Argon2id 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 bcrypt→Argon2id 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"}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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:
|
||||
|
||||
194
backend/app/schemas/admin.py
Normal file
194
backend/app/schemas/admin.py
Normal 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
|
||||
@ -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 3–50 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 3–50 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")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
22
backend/app/services/audit.py
Normal file
22
backend/app/services/audit.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
200
frontend/src/components/admin/AdminDashboardPage.tsx
Normal file
200
frontend/src/components/admin/AdminDashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/admin/AdminPortal.tsx
Normal file
64
frontend/src/components/admin/AdminPortal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
frontend/src/components/admin/ConfigPage.tsx
Normal file
218
frontend/src/components/admin/ConfigPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
frontend/src/components/admin/CreateUserDialog.tsx
Normal file
184
frontend/src/components/admin/CreateUserDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
325
frontend/src/components/admin/IAMPage.tsx
Normal file
325
frontend/src/components/admin/IAMPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
323
frontend/src/components/admin/UserActionsMenu.tsx
Normal file
323
frontend/src/components/admin/UserActionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
frontend/src/components/admin/UserDetailSection.tsx
Normal file
197
frontend/src/components/admin/UserDetailSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/admin/shared.tsx
Normal file
42
frontend/src/components/admin/shared.tsx
Normal 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';
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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) */}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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 && (
|
||||
|
||||
189
frontend/src/hooks/useAdmin.ts
Normal file
189
frontend/src/hooks/useAdmin.ts
Normal 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 };
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user