diff --git a/.env.example b/.env.example index 5f6c1a89..5d18ea43 100644 --- a/.env.example +++ b/.env.example @@ -2,20 +2,47 @@ SM_DATABASE_URL=sqlite+aiosqlite:///./app.db # SM_DATABASE_URL=postgresql+asyncpg://sm:sm@localhost:5432/simple_module -# Keycloak (auth module settings — prefix must match AuthSettings.env_prefix) -SM_AUTH_KEYCLOAK_URL=http://localhost:8080 -SM_AUTH_KEYCLOAK_REALM=simple-module -SM_AUTH_KEYCLOAK_CLIENT_ID=simple-module-app -SM_AUTH_KEYCLOAK_CLIENT_SECRET=change-me-in-production - # App SM_ENVIRONMENT=development SM_SECRET_KEY=change-me-in-production SM_VITE_DEV_URL=http://localhost:5050 -# Multi-tenancy (default off). Turn on only for deployments that actually -# partition data by tenant. SM_TENANT_HEADER enables a header source for -# tenant resolution when there's no authenticated user — leave empty in -# production to force tenant resolution through the auth token. +# Users module — local email+password auth (replaces Keycloak) +# +# Signup mode: false = admin-invite only (default), true = public signup +SM_USERS_ALLOW_SIGNUP=false + +# Token secrets — MUST change in production. Dev placeholders are visible in +# log lines so they're obvious to rotate. +SM_USERS_RESET_PASSWORD_TOKEN_SECRET=dev-reset-token-secret-change-me +SM_USERS_VERIFICATION_TOKEN_SECRET=dev-verify-token-secret-change-me + +# Mailer: console (logs tokenized links at INFO) or smtp +SM_USERS_MAILER=console +SM_USERS_BASE_URL=http://localhost:8000 +# SM_USERS_SMTP_HOST= +# SM_USERS_SMTP_PORT=587 +# SM_USERS_SMTP_USERNAME= +# SM_USERS_SMTP_PASSWORD= +# SM_USERS_SMTP_FROM=no-reply@localhost +# SM_USERS_SMTP_TLS=true + +# First-admin bootstrap (auto-create an admin on first boot iff users table +# is empty AND both are set). Leave blank to disable — use `sm-users +# create-admin` instead. +# SM_USERS_BOOTSTRAP_EMAIL=admin@example.com +# SM_USERS_BOOTSTRAP_PASSWORD=changeme + +# Optional second seeded user with the "user" role. Seeded alongside the +# admin on first boot (same empty-table guard). When both env vars are set +# AND SM_ENVIRONMENT=development, the login page renders quick-login buttons +# for both accounts to speed up manual testing. Leave blank in production. +# SM_USERS_BOOTSTRAP_USER_EMAIL=user@example.com +# SM_USERS_BOOTSTRAP_USER_PASSWORD=changeme + +# Cookie: set cookie_secure=false in dev (HTTPS-only otherwise) +SM_USERS_COOKIE_SECURE=false + +# Multi-tenancy (default off). # SM_MULTI_TENANT=false # SM_TENANT_HEADER= diff --git a/README.md b/README.md index 03baa523..5b30007b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A modular-monolith framework for Python. Each feature lives in its own self-cont - **Backend:** Python 3.12, FastAPI, SQLAlchemy async, Alembic - **Frontend:** Inertia.js + React + Tailwind CSS 4, Vite HMR -- **Auth:** Keycloak (OIDC, cookie-based sessions) +- **Auth:** Local user management (email+password, cookie-based sessions) via fastapi-users - **Tooling:** uv workspaces, Ruff, ty, Biome, pytest ## Quickstart @@ -18,7 +18,7 @@ make install # 2. Copy env template (defaults work for local SQLite dev) cp .env.example .env -# 3. Start Keycloak + Postgres (skip if sticking with SQLite) +# 3. Start Postgres (skip if using SQLite — the default .env uses SQLite) make docker-up # 4. Run migrations @@ -28,7 +28,7 @@ make migrate make dev ``` -Hit `http://localhost:8000` — you land on the public page. `/auth/login` takes you through Keycloak, `/dashboard` is the authenticated home, `/products` is a fully-working example module. +Hit `http://localhost:8000` — you land on the public page. `/users/login` is the email+password login, `/dashboard` is the authenticated home, `/products` is a fully-working example module. ## Create a new module @@ -85,7 +85,7 @@ docs/ | `make migration msg="..."` | Autogenerate a new migration | | `make new-module name=` | Scaffold a new module | | `make kill` | Stop any running dev servers (ports 8000, 5173) | -| `make docker-up` / `docker-down` | Manage Keycloak + Postgres containers | +| `make docker-up` / `docker-down` | Manage the Postgres container (SQLite needs no Docker) | ## Configuration @@ -96,12 +96,60 @@ All settings are `SM_`-prefixed env vars. Defaults in `.env.example` cover local | `SM_DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | Async URL. Postgres: `postgresql+asyncpg://...` | | `SM_ENVIRONMENT` | `development` | Anything else triggers strict module discovery | | `SM_SECRET_KEY` | _(placeholder)_ | **Must** change in production — signs session cookies | -| `SM_AUTH_KEYCLOAK_URL` | `http://localhost:8080` | Auth module settings (note the `SM_AUTH_` prefix) | +| `SM_USERS_ALLOW_SIGNUP` | `false` | Enable public signup (else admin-invite only) | +| `SM_USERS_MAILER` | `console` | `console` logs links; `smtp` uses SMTP config (see `.env.example`) | +| `SM_USERS_RESET_PASSWORD_TOKEN_SECRET` | _(dev placeholder)_ | **Must** change in production | +| `SM_USERS_VERIFICATION_TOKEN_SECRET` | _(dev placeholder)_ | **Must** change in production | +| `SM_USERS_BOOTSTRAP_EMAIL` | `` | First-admin email; combined with `SM_USERS_BOOTSTRAP_PASSWORD`, creates admin on first boot iff users table is empty | +| `SM_USERS_BOOTSTRAP_PASSWORD` | `` | Paired with above | | `SM_MULTI_TENANT` | `false` | Set `true` to enable `TenantMiddleware` | | `SM_TENANT_HEADER` | `` | Empty = token-only; set e.g. `X-Tenant-ID` to enable header fallback | See `framework-conventions.md` for the settings-per-module convention. +## User management + +### Creating the first admin + +Either use the CLI: + +```bash +uv run sm-users create-admin --email admin@example.com --password changeme +``` + +Or let the app bootstrap it automatically on first boot by setting env vars **before** running `make migrate && make dev`: + +``` +SM_USERS_BOOTSTRAP_EMAIL=admin@example.com +SM_USERS_BOOTSTRAP_PASSWORD=changeme +``` + +The auto-bootstrap is idempotent — it only creates the user if the `users_user` table is empty. + +### Inviting users + +1. Log in as admin and navigate to `/users/admin/invite`. +2. Fill in the invitee's email and optionally a full name and role(s). Click **Send invite**. +3. With the default `console` mailer, the invite link is logged to stdout (`tail -f` the server log). Copy the link and send it to the user. With `smtp`, the email is delivered automatically. +4. The invitee opens the link (`/users/invite/accept?token=…`), sets a password, and is immediately logged in. + +### Enabling public signup + +Set `SM_USERS_ALLOW_SIGNUP=true` and restart the server. The `/users/register` page becomes accessible. + +### Switching to SMTP + +``` +SM_USERS_MAILER=smtp +SM_USERS_BASE_URL=https://your-domain.com +SM_USERS_SMTP_HOST=smtp.example.com +SM_USERS_SMTP_PORT=587 +SM_USERS_SMTP_USERNAME=no-reply@example.com +SM_USERS_SMTP_PASSWORD=secret +SM_USERS_SMTP_FROM=no-reply@example.com +SM_USERS_SMTP_TLS=true +``` + ## Architecture - **Modules**: discovered via Python entry points at boot. Each module subclasses `ModuleBase` and opts into the lifecycle hooks it needs (`register_routes`, `register_menu_items`, `register_permissions`, `register_middleware`, `on_startup`, ...). diff --git a/conftest.py b/conftest.py index 2ed6be8e..03e6a651 100644 --- a/conftest.py +++ b/conftest.py @@ -145,22 +145,24 @@ async def client(app) -> AsyncGenerator[httpx.AsyncClient, None]: @pytest.fixture async def authenticated_client(app) -> AsyncGenerator[httpx.AsyncClient, None]: - """Authenticated async HTTP client (admin user via signed session cookie).""" + """HTTPX client with a signed session cookie carrying a seeded admin user's id.""" import json from base64 import b64encode from itsdangerous import TimestampSigner - - userinfo = { - "sub": "test-user-id", - "email": "test@example.com", - "name": "Test User", - "preferred_username": "testuser", - "realm_access": {"roles": ["admin"]}, - } - session_data = {"userinfo": userinfo} + from users.bootstrap import create_admin + + async with app.state.db.session_factory() as session: + result = await create_admin( + session, + email="admin@test", + password="test-password", + full_name="Test Admin", + ) + user_id = str(result.user.id) + + session_data = {"user_id": user_id} data = b64encode(json.dumps(session_data).encode()) - signer = TimestampSigner(str(app.state.settings.secret_key)) signed = signer.sign(data).decode("utf-8") diff --git a/docker-compose.yml b/docker-compose.yml index 1086732f..2c62e492 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,4 @@ services: - keycloak: - image: quay.io/keycloak/keycloak:latest - command: start-dev --import-realm - environment: - KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: admin - volumes: - - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm.json - ports: - - "8080:8080" - postgres: image: postgres:16 environment: diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index cba5b951..f17bdb55 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -1,13 +1,22 @@ # End-to-End Testing The repo ships Playwright-driven smoke tests at -[tests/e2e/test_smoke.py](../tests/e2e/test_smoke.py). Two tests drive a real +[tests/e2e/test_smoke.py](../tests/e2e/test_smoke.py). Four tests drive a real Chromium browser through the core flows: -* **`test_login_and_browse_smoke`** — landing → Keycloak login → dashboard → - products browse → logout. Minimal regression guard. +* **`test_login_and_browse_smoke`** — landing → local email+password login → + dashboard → products browse → logout. Minimal regression guard. * **`test_products_crud_smoke`** — same login + a full create / edit / delete round-trip against the products module. +* **`test_password_reset_smoke`** — **skipped** (see inline comment in the + test file). `fastapi-users` `reset_password()` validates a password + fingerprint (`password_fgpt`) that is only available server-side. The + full HTTP-layer flow is covered by unit tests in + `modules/users/tests/test_api_auth.py`. +* **`test_admin_invite_smoke`** — admin invites a new user via the UI; the + invitee accepts the invite in a fresh browser context and lands on the + dashboard. Token is minted locally using the dev-default verify secret + (equivalent to what the ConsoleMailer logs). End-to-end tests are gated behind the `e2e` pytest marker (declared in [pyproject.toml](../pyproject.toml)) and are **excluded from the default @@ -25,11 +34,20 @@ uv run playwright install chromium Then bring up the full stack (in a separate terminal, leave it running): ```bash -make docker-up # Keycloak + Postgres +make docker-up # Postgres (skip if using the default SQLite config) make migrate # apply Alembic migrations make dev # FastAPI on :8000 + Vite on :5173 ``` +Create the first admin user (needed for e2e auth): + +```bash +uv run sm-users create-admin --email admin@example.com --password admin +``` + +Or set `SM_USERS_BOOTSTRAP_EMAIL` / `SM_USERS_BOOTSTRAP_PASSWORD` in `.env` +before the first `make dev` run. + ## Running ```bash @@ -44,23 +62,21 @@ uv run pytest -m e2e tests/e2e ## Configuration -The test reads three environment variables (all optional): - -| Variable | Default | Notes | -| -------------- | ------------------------- | ------------------------------------------------------------------ | -| `E2E_BASE_URL` | `http://localhost:8000` | Where the FastAPI host is listening. | -| `E2E_USERNAME` | `admin` | Keycloak username. The seeded `admin` user has role `admin`. | -| `E2E_PASSWORD` | `admin` | Keycloak password for the above user. | +The tests read these environment variables (all optional): -Seeded Keycloak users live in [keycloak/realm-export.json](../keycloak/realm-export.json). -The defaults match the `admin`/`admin` user out of the box. +| Variable | Default | Notes | +| -------------- | ------------------------- | ------------------------------------------------------------ | +| `E2E_BASE_URL` | `http://localhost:8000` | Where the FastAPI host is listening. | +| `E2E_USERNAME` | `admin@example.com` | Email of the admin user created via `sm-users create-admin`. | +| `E2E_PASSWORD` | `admin` | Password of the above admin user. | +| `SM_USERS_VERIFICATION_TOKEN_SECRET` | `dev-verify-token-secret-change-me` | Must match the running server's value so locally-minted invite tokens are accepted. | ## What the smoke tests cover **`test_login_and_browse_smoke`** 1. Landing page renders (`/`) with the "Get Started" CTA. -2. Keycloak OIDC login round-trip. +2. Local email+password login via `/users/login`. 3. Dashboard (`/dashboard/`) renders — proves session cookie + AuthMiddleware + Inertia resolver + AuthenticatedLayout. 4. Products browse (`/products/`) renders — proves module pages resolve. @@ -73,10 +89,16 @@ The defaults match the `admin`/`admin` user out of the box. 3. Edit its name and verify the new name appears in the list. 4. Delete it through the confirm dialog and verify the row disappears. -The CRUD test relies on the Keycloak `simple-module-app` client shipping a -`realm roles` protocol mapper that emits `realm_access.roles` into userinfo -(see [keycloak/realm-export.json](../keycloak/realm-export.json)); without -that, `RequiresPermission("products.create")` returns 403. +The CRUD test relies on the admin user having the `admin` role (created +automatically by `sm-users create-admin` or the bootstrap env vars). + +**`test_admin_invite_smoke`** + +1. Admin logs in and submits the invite form at `/users/admin/invite`. +2. The test looks up the new user's UUID via the admin API. +3. A verify token is minted locally (same secret the server uses). +4. A fresh browser context navigates to `/users/invite/accept?token=…`, sets + a password, and verifies a redirect to `/dashboard`. These are **not** pixel-perfect regression tests — the goal is to catch broad breakage in the auth + render + CRUD spine. diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index 2cfb9cf9..721320d5 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -93,7 +93,7 @@ SM_DEBUG, SM_LOG_LEVEL, SM_LOG_FORMAT, SM_MULTI_TENANT, SM_TENANT_HEADER ``` Module settings should: -- Use a per-module prefix: `SM__*` (e.g. `SM_AUTH_KEYCLOAK_URL`). +- Use a per-module prefix: `SM__*` (e.g. `SM_USERS_ALLOW_SIGNUP`). - Be stored on `app.state._settings` during `register_settings(app)`. - `SM012` diagnostic fires if `register_settings` is overridden but no `app.state._settings` is added. diff --git a/docs/plans/2026-04-13-alembic-migrations-design.md b/docs/plans/2026-04-13-alembic-migrations-design.md index 91a9c728..6f8c8c84 100644 --- a/docs/plans/2026-04-13-alembic-migrations-design.md +++ b/docs/plans/2026-04-13-alembic-migrations-design.md @@ -1,5 +1,9 @@ # Alembic Database Migrations — Design Document +> **Note (2026-04-15):** Keycloak integration was removed; see plan +> `cryptic-juggling-lightning`. References to "Keycloak" below are +> historical context from the original design — the patterns still apply. + **Goal:** Replace the `create_all` approach in module `on_startup` hooks with Alembic-managed schema versioning, adding migration diagnostics to the existing framework. **Tech Stack:** Python 3.12, SQLAlchemy async, Alembic, FastAPI, UV workspace diff --git a/docs/superpowers/plans/2026-04-13-module-lifecycle-hooks.md b/docs/superpowers/plans/2026-04-13-module-lifecycle-hooks.md index c63458f7..d43575fc 100644 --- a/docs/superpowers/plans/2026-04-13-module-lifecycle-hooks.md +++ b/docs/superpowers/plans/2026-04-13-module-lifecycle-hooks.md @@ -1,5 +1,9 @@ # Module Lifecycle Hooks Implementation Plan +> **Note (2026-04-15):** Keycloak integration was removed; see plan +> `cryptic-juggling-lightning`. References to "Keycloak" below are +> historical context from the original design — the patterns still apply. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add three new ModuleBase lifecycle hooks (register_exception_handlers, register_health_checks, register_settings), restructure the app boot sequence, add SM010 diagnostic, and migrate auth settings out of the framework. diff --git a/docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md b/docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md index c5e142e7..716f830d 100644 --- a/docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md +++ b/docs/superpowers/specs/2026-04-13-module-lifecycle-hooks-design.md @@ -1,5 +1,9 @@ # Module Lifecycle Hooks: Exception Handlers, Health Checks, Module Settings +> **Note (2026-04-15):** Keycloak integration was removed; see plan +> `cryptic-juggling-lightning`. References to "Keycloak" below are +> historical context from the original design — the patterns still apply. + **Date**: 2026-04-13 **Status**: Draft **Scope**: Add three new lifecycle hooks to `ModuleBase` and restructure `create_app()` sequencing diff --git a/framework/core/simple_module_core/permissions.py b/framework/core/simple_module_core/permissions.py index 10066feb..8cb7335c 100644 --- a/framework/core/simple_module_core/permissions.py +++ b/framework/core/simple_module_core/permissions.py @@ -4,6 +4,14 @@ from dataclasses import dataclass, field +WILDCARD = "*" + +# Default role→permission mapping. Admin gets all permissions via the wildcard. +# Additional mappings are added at registration time via PermissionRegistry.map_role. +DEFAULT_ROLE_PERMISSIONS: dict[str, list[str]] = { + "admin": [WILDCARD], +} + @dataclass class PermissionGroup: @@ -14,10 +22,23 @@ class PermissionGroup: class PermissionRegistry: - """Central registry of all permissions across all modules.""" + """Central registry of all permissions across all modules. + + Effectively immutable after module-registration (boot phase). The computed + views ``all_permissions`` and ``role_map`` are read on every authenticated + request by ``InertiaLayoutDataMiddleware`` — cache them and invalidate on + every mutation. + """ def __init__(self) -> None: self._groups: dict[str, PermissionGroup] = {} + self._role_map: dict[str, set[str]] = {} + self._all_permissions_cache: list[str] | None = None + self._role_map_cache: dict[str, list[str]] | None = None + + def _invalidate(self) -> None: + self._all_permissions_cache = None + self._role_map_cache = None def add_group(self, name: str, permissions: list[str]) -> None: """Register a group of related permissions.""" @@ -25,6 +46,7 @@ def add_group(self, name: str, permissions: list[str]) -> None: self._groups[name].permissions.extend(permissions) else: self._groups[name] = PermissionGroup(name=name, permissions=list(permissions)) + self._invalidate() def add(self, permission: str) -> None: """Register a single permission (auto-grouped by prefix before '.').""" @@ -33,14 +55,17 @@ def add(self, permission: str) -> None: self._groups[group_name] = PermissionGroup(name=group_name) if permission not in self._groups[group_name].permissions: self._groups[group_name].permissions.append(permission) + self._invalidate() @property def all_permissions(self) -> list[str]: """All registered permission strings, sorted.""" - perms: list[str] = [] - for group in self._groups.values(): - perms.extend(group.permissions) - return sorted(set(perms)) + if self._all_permissions_cache is None: + perms: set[str] = set() + for group in self._groups.values(): + perms.update(group.permissions) + self._all_permissions_cache = sorted(perms) + return self._all_permissions_cache @property def groups(self) -> list[PermissionGroup]: @@ -49,6 +74,32 @@ def groups(self) -> list[PermissionGroup]: def has(self, permission: str) -> bool: return any(permission in g.permissions for g in self._groups.values()) + def map_role(self, role: str, permissions: list[str]) -> None: + """Register a role→permission mapping. + + Merges *permissions* into the existing set for *role* so that multiple + calls from different modules accumulate rather than overwrite. + """ + if role not in self._role_map: + self._role_map[role] = set() + self._role_map[role].update(permissions) + self._invalidate() + + @property + def role_map(self) -> dict[str, list[str]]: + """Merged role→permission mapping (``DEFAULT_ROLE_PERMISSIONS`` + module maps).""" + if self._role_map_cache is None: + merged: dict[str, list[str]] = { + role: list(perms) for role, perms in DEFAULT_ROLE_PERMISSIONS.items() + } + for role, perms in self._role_map.items(): + if role in merged: + merged[role] = list(set(merged[role]) | perms) + else: + merged[role] = list(perms) + self._role_map_cache = merged + return self._role_map_cache + def get_permissions_for_roles( self, roles: list[str], diff --git a/framework/core/tests/test_permissions.py b/framework/core/tests/test_permissions.py index a53602b1..ddc8232b 100644 --- a/framework/core/tests/test_permissions.py +++ b/framework/core/tests/test_permissions.py @@ -2,7 +2,7 @@ from __future__ import annotations -from simple_module_core.permissions import PermissionRegistry +from simple_module_core.permissions import WILDCARD, PermissionRegistry class TestPermissionRegistry: @@ -86,3 +86,30 @@ async def test_permissions_sorted(self): reg.add("z.last") reg.add("a.first") assert reg.all_permissions == ["a.first", "z.last"] + + +class TestPermissionRegistryMapRole: + async def test_map_role_adds_entries(self): + reg = PermissionRegistry() + reg.map_role("user", ["users.self.profile"]) + assert "users.self.profile" in reg.role_map["user"] + + async def test_map_role_merges_into_existing_role(self): + reg = PermissionRegistry() + reg.map_role("user", ["users.self.profile"]) + reg.map_role("user", ["users.self.settings"]) + assert "users.self.profile" in reg.role_map["user"] + assert "users.self.settings" in reg.role_map["user"] + + async def test_role_map_includes_default_admin_wildcard(self): + reg = PermissionRegistry() + assert WILDCARD in reg.role_map["admin"] + + async def test_role_map_returns_plain_dict_of_lists(self): + reg = PermissionRegistry() + reg.map_role("editor", ["products.edit"]) + result = reg.role_map + assert isinstance(result, dict) + for key, val in result.items(): + assert isinstance(key, str) + assert isinstance(val, list) diff --git a/framework/db/tests/_models.py b/framework/db/tests/_models.py new file mode 100644 index 00000000..41ed8a98 --- /dev/null +++ b/framework/db/tests/_models.py @@ -0,0 +1,31 @@ +"""Test-only SQLAlchemy models used across the database test suite. + +Kept in a dedicated module so that ``from conftest import`` is not required at +module level in individual test files (which is fragile when multiple test +directories with their own ``conftest.py`` files are collected in the same +pytest run). +""" + +from __future__ import annotations + +from simple_module_db.base import create_module_base +from simple_module_db.mixins import MultiTenantMixin, SoftDeleteMixin +from simple_module_db.provider import DatabaseProvider +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +_TenantBase = create_module_base("mt_test", provider=DatabaseProvider.SQLITE) + + +class _TenantItem(_TenantBase, MultiTenantMixin): # ty: ignore[unsupported-base] + __tablename__ = "mt_test_item" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100)) + + +class _TenantSoftItem(_TenantBase, MultiTenantMixin, SoftDeleteMixin): # ty: ignore[unsupported-base] + """Combines multi-tenant and soft-delete mixins to test filter composition.""" + + __tablename__ = "mt_test_soft_item" + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100)) diff --git a/framework/db/tests/conftest.py b/framework/db/tests/conftest.py index 7feb5cde..6130d96e 100644 --- a/framework/db/tests/conftest.py +++ b/framework/db/tests/conftest.py @@ -5,30 +5,10 @@ from collections.abc import AsyncGenerator import pytest -from simple_module_db.base import create_module_base +from _models import _TenantBase from simple_module_db.listeners import register_listeners -from simple_module_db.mixins import MultiTenantMixin, SoftDeleteMixin -from simple_module_db.provider import DatabaseProvider from simple_module_db.session import init_db -from sqlalchemy import String from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Mapped, mapped_column - -_TenantBase = create_module_base("mt_test", provider=DatabaseProvider.SQLITE) - - -class _TenantItem(_TenantBase, MultiTenantMixin): # ty: ignore[unsupported-base] - __tablename__ = "mt_test_item" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(100)) - - -class _TenantSoftItem(_TenantBase, MultiTenantMixin, SoftDeleteMixin): # ty: ignore[unsupported-base] - """Combines multi-tenant and soft-delete mixins to test filter composition.""" - - __tablename__ = "mt_test_soft_item" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(100)) @pytest.fixture diff --git a/framework/db/tests/test_db_logging.py b/framework/db/tests/test_db_logging.py index f22b678e..dbc387cc 100644 --- a/framework/db/tests/test_db_logging.py +++ b/framework/db/tests/test_db_logging.py @@ -6,11 +6,10 @@ import logging from unittest.mock import MagicMock +from _models import _TenantBase, _TenantItem from simple_module_db.deps import get_db from sqlalchemy.ext.asyncio import AsyncSession -from conftest import _TenantBase, _TenantItem # ty: ignore[unresolved-import] - async def _drive_get_db(db_state, populate=None): """Yield the session, let ``populate`` touch it, then let the dependency diff --git a/framework/db/tests/test_multi_tenancy.py b/framework/db/tests/test_multi_tenancy.py index d487058a..c5d5cacc 100644 --- a/framework/db/tests/test_multi_tenancy.py +++ b/framework/db/tests/test_multi_tenancy.py @@ -5,13 +5,12 @@ import asyncio import pytest +from _models import _TenantItem, _TenantSoftItem from simple_module_db.listeners import TenantIsolationError, current_tenant_id from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from conftest import _TenantItem, _TenantSoftItem # ty: ignore[unresolved-import] - class TestMultiTenancy: """Automatic tenant isolation: auto-populate, query filtering, enforcement.""" diff --git a/framework/hosting/simple_module_hosting/manifest.py b/framework/hosting/simple_module_hosting/manifest.py index 64eb8bbc..c4fb8f6d 100644 --- a/framework/hosting/simple_module_hosting/manifest.py +++ b/framework/hosting/simple_module_hosting/manifest.py @@ -194,11 +194,11 @@ def _glob_pattern_for(pages_dir: Path, output_dir: Path) -> str: rel = Path(os.path.relpath(pages_dir, output_dir.resolve())) except ValueError: # Different drive on Windows — fall back to absolute (rare). - return pages_dir.as_posix() + "/*.tsx" + return pages_dir.as_posix() + "/**/*.tsx" rel_str = rel.as_posix() if not rel_str.startswith(("./", "../")): rel_str = "./" + rel_str - return rel_str + "/*.tsx" + return rel_str + "/**/*.tsx" def read_module_package_json(mod: ModuleBase) -> dict | None: diff --git a/framework/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py index 409419bb..79348ec9 100644 --- a/framework/hosting/simple_module_hosting/middleware.py +++ b/framework/hosting/simple_module_hosting/middleware.py @@ -223,7 +223,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: roles = getattr(user, "roles", []) if user else [] # Resolve permissions once and cache on request.state for RequiresPermission - resolved = resolve_permissions(roles) if is_authenticated else set() + resolved = ( + resolve_permissions(roles, role_map=self.permission_registry.role_map) + if is_authenticated + else set() + ) request.state.resolved_permissions = resolved # Expand wildcard to full list for frontend (no "*" leak) diff --git a/framework/hosting/simple_module_hosting/permissions.py b/framework/hosting/simple_module_hosting/permissions.py index 44ce104a..acd6a65b 100644 --- a/framework/hosting/simple_module_hosting/permissions.py +++ b/framework/hosting/simple_module_hosting/permissions.py @@ -3,16 +3,15 @@ from __future__ import annotations from fastapi import HTTPException, Request +from simple_module_core.permissions import DEFAULT_ROLE_PERMISSIONS, WILDCARD -WILDCARD = "*" - -# "*" grants all permissions (superuser). The framework only ships the -# ``admin`` wildcard mapping — additional role→permission mappings belong -# in the host (or in a host-owned module) so the framework doesn't need to -# know the names of plugin permissions. -DEFAULT_ROLE_PERMISSIONS: dict[str, list[str]] = { - "admin": [WILDCARD], -} +__all__ = [ + "DEFAULT_ROLE_PERMISSIONS", + "WILDCARD", + "RequiresPermission", + "expand_permissions", + "resolve_permissions", +] def resolve_permissions( @@ -59,7 +58,10 @@ def __call__(self, request: Request) -> None: # Use cached permissions from middleware if available permissions: set[str] | None = getattr(request.state, "resolved_permissions", None) if permissions is None: - permissions = resolve_permissions(user.roles) + # Fallback: middleware did not run — consult registry role_map if available + perm_registry = getattr(getattr(request.app, "state", None), "perm_registry", None) + role_map = perm_registry.role_map if perm_registry is not None else None + permissions = resolve_permissions(user.roles, role_map=role_map) request.state.resolved_permissions = permissions if WILDCARD in permissions: diff --git a/framework/hosting/tests/test_app.py b/framework/hosting/tests/test_app.py index 9d19c070..30f033ae 100644 --- a/framework/hosting/tests/test_app.py +++ b/framework/hosting/tests/test_app.py @@ -30,7 +30,7 @@ async def test_modules_enabled_limits_loaded_modules(self, settings: Settings): restricted = settings.model_copy(update={"modules_enabled": ["Auth"]}) app = create_app(restricted) paths: set[str] = {str(r.path) for r in app.routes if hasattr(r, "path")} - assert "/auth/login" in paths + # Auth is now contracts-only, so it has no routes — only health remains. assert not any(p.startswith("/api/products") for p in paths) assert "/dashboard" not in paths @@ -90,10 +90,8 @@ async def test_expected_routes_registered(self, app: FastAPI): assert "/api/products/" in route_paths assert "/api/products/{product_id}" in route_paths - assert "/auth/login" in route_paths - assert "/auth/callback" in route_paths - assert "/auth/logout" in route_paths - assert "/auth/me" in route_paths + # Users module owns login, register, etc. Auth module is contracts-only. + assert "/users/login" in route_paths # Dashboard — mounted at the /dashboard view prefix; the public # landing page at "/" is owned by the host and added in host/main.py, @@ -118,12 +116,12 @@ class TestProtectedPages: async def test_dashboard_redirects_unauthenticated(self, client: httpx.AsyncClient): resp = await client.get("/dashboard", follow_redirects=False) assert resp.status_code == 302 - assert "/auth/login" in resp.headers["location"] + assert "/users/login" in resp.headers["location"] async def test_products_page_redirects_unauthenticated(self, client: httpx.AsyncClient): resp = await client.get("/products/", follow_redirects=False) assert resp.status_code == 302 - assert "/auth/login" in resp.headers["location"] + assert "/users/login" in resp.headers["location"] class TestSecurityHeaders: diff --git a/framework/hosting/tests/test_hosting_permissions.py b/framework/hosting/tests/test_hosting_permissions.py new file mode 100644 index 00000000..077cebe8 --- /dev/null +++ b/framework/hosting/tests/test_hosting_permissions.py @@ -0,0 +1,185 @@ +"""Tests for InertiaLayoutDataMiddleware and RequiresPermission with registry role_map.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from fastapi import Depends, FastAPI +from httpx import ASGITransport, AsyncClient +from simple_module_core.menu import MenuRegistry +from simple_module_core.permissions import PermissionRegistry +from simple_module_hosting.middleware import InertiaLayoutDataMiddleware +from simple_module_hosting.permissions import RequiresPermission + + +def _http_scope( + roles: list[str] | None = None, + headers: list[tuple[bytes, bytes]] | None = None, +) -> dict: + scope: dict = { + "type": "http", + "method": "GET", + "path": "/", + "headers": headers or [], + "state": {}, + # InertiaLayoutDataMiddleware reads request.app.state.i18n_registry; a + # stub app with an empty state is enough for the lookup to return None. + "app": SimpleNamespace(state=SimpleNamespace()), + } + if roles is not None: + scope["state"]["user"] = SimpleNamespace( + id="u1", + name="Test User", + email="test@example.com", + roles=roles, + ) + return scope + + +async def _noop_receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + +async def _noop_send(message): + return None + + +class TestInertiaLayoutDataMiddlewareRoleMap: + """InertiaLayoutDataMiddleware should consult registry.role_map.""" + + async def test_user_role_resolves_via_registry(self): + """A user with role 'user' gets the permission mapped via map_role.""" + reg = PermissionRegistry() + reg.add_group("users", ["users.self.profile"]) + reg.map_role("user", ["users.self.profile"]) + + menu_reg = MenuRegistry() + captured: dict = {} + + async def inner_app(scope, receive, send): + from starlette.requests import Request + + req = Request(scope) + captured["resolved"] = req.state.resolved_permissions + captured["shared"] = req.state.inertia_shared + + mw = InertiaLayoutDataMiddleware(inner_app, menu_registry=menu_reg, permission_registry=reg) + scope = _http_scope(roles=["user"]) + await mw(scope, _noop_receive, _noop_send) + + assert "users.self.profile" in captured["resolved"] + assert "users.self.profile" in captured["shared"]["auth"]["permissions"] + + async def test_unauthenticated_gets_empty_permissions(self): + """Unauthenticated requests get no permissions.""" + reg = PermissionRegistry() + reg.map_role("user", ["users.self.profile"]) + menu_reg = MenuRegistry() + captured: dict = {} + + async def inner_app(scope, receive, send): + from starlette.requests import Request + + req = Request(scope) + captured["resolved"] = req.state.resolved_permissions + captured["shared"] = req.state.inertia_shared + + mw = InertiaLayoutDataMiddleware(inner_app, menu_registry=menu_reg, permission_registry=reg) + scope = _http_scope(roles=None) # no user + await mw(scope, _noop_receive, _noop_send) + + assert captured["resolved"] == set() + assert captured["shared"]["auth"]["permissions"] == [] + + async def test_admin_role_still_gets_all_permissions(self): + """Admin users still get all permissions via wildcard expansion.""" + reg = PermissionRegistry() + reg.add_group("products", ["products.view", "products.create"]) + menu_reg = MenuRegistry() + captured: dict = {} + + async def inner_app(scope, receive, send): + from starlette.requests import Request + + req = Request(scope) + captured["shared"] = req.state.inertia_shared + + mw = InertiaLayoutDataMiddleware(inner_app, menu_registry=menu_reg, permission_registry=reg) + scope = _http_scope(roles=["admin"]) + await mw(scope, _noop_receive, _noop_send) + + perms = captured["shared"]["auth"]["permissions"] + assert "products.view" in perms + assert "products.create" in perms + + +class TestRequiresPermissionWithRoleMap: + """RequiresPermission uses registry role_map in middleware and fallback.""" + + def _build_app(self, reg: PermissionRegistry, permission: str) -> FastAPI: + """Build a minimal FastAPI app with the middleware and a protected route.""" + from simple_module_core.menu import MenuRegistry + + app = FastAPI() + app.state.perm_registry = reg + + menu_reg = MenuRegistry() + app.add_middleware( + InertiaLayoutDataMiddleware, + menu_registry=menu_reg, + permission_registry=reg, + ) + + @app.get("/protected", dependencies=[Depends(RequiresPermission(permission))]) + async def protected(): + return {"ok": True} + + return app + + def _make_client(self, app: FastAPI, roles: list[str]) -> AsyncClient: + """Return an async client with a fake authenticated user.""" + + async def _set_user(scope, receive, send): + if scope["type"] == "http": + scope.setdefault("state", {})["user"] = SimpleNamespace( + id="u1", + name="Test", + email="test@example.com", + roles=roles, + ) + await app(scope, receive, send) + + transport = ASGITransport(app=_set_user) # type: ignore[arg-type] + return AsyncClient(transport=transport, base_url="http://testserver") + + async def test_role_with_permission_gets_200(self): + reg = PermissionRegistry() + reg.add_group("products", ["products.edit"]) + reg.map_role("editor", ["products.edit"]) + + app = self._build_app(reg, "products.edit") + async with self._make_client(app, ["editor"]) as client: + resp = await client.get("/protected") + assert resp.status_code == 200 + + async def test_role_without_permission_gets_403(self): + reg = PermissionRegistry() + reg.add_group("products", ["products.edit"]) + reg.map_role("editor", ["products.edit"]) + + app = self._build_app(reg, "products.edit") + async with self._make_client(app, ["viewer"]) as client: + resp = await client.get("/protected") + assert resp.status_code == 403 + + async def test_unauthenticated_gets_401(self): + reg = PermissionRegistry() + reg.add_group("products", ["products.edit"]) + reg.map_role("editor", ["products.edit"]) + + app = self._build_app(reg, "products.edit") + # No user in state — use the raw app transport + transport = ASGITransport(app=app) # type: ignore[arg-type] + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.get("/protected") + assert resp.status_code == 401 diff --git a/host/client_app/pages.ts b/host/client_app/pages.ts index 9779b8bf..1c193710 100644 --- a/host/client_app/pages.ts +++ b/host/client_app/pages.ts @@ -24,7 +24,7 @@ const pages: Record = {}; for (const [moduleName, globEntries] of Object.entries(moduleGlobs)) { for (const [filePath, loader] of Object.entries(globEntries)) { // e.g., "/.../products/pages/Browse.tsx" -> pageName = "Browse" - const match = filePath.match(/\/pages\/(\w+)\.tsx$/); + const match = filePath.match(/\/pages\/(.+)\.tsx$/); if (match) { pages[`${moduleName}/${match[1]}`] = loader; } diff --git a/host/client_app/pages/Landing.tsx b/host/client_app/pages/Landing.tsx index f25b67b7..bf22fa3b 100644 --- a/host/client_app/pages/Landing.tsx +++ b/host/client_app/pages/Landing.tsx @@ -141,20 +141,15 @@ function Landing() { <> {/* Hero */}
-
- - - {t(keys.host.landing.badge)} - -
- -

+ + {t(keys.host.landing.badge)} + + +

{t(keys.host.landing.hero_title_line1)}
@@ -162,19 +157,13 @@ function Landing() {

-

+

{t(keys.host.landing.hero_subtitle)}

-
+
+ + + + + ); +} + +export default AcceptInvite; diff --git a/modules/users/users/pages/ForgotPassword.tsx b/modules/users/users/pages/ForgotPassword.tsx new file mode 100644 index 00000000..2d90fbdc --- /dev/null +++ b/modules/users/users/pages/ForgotPassword.tsx @@ -0,0 +1,90 @@ +import { Button } from '@simple-module/ui/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@simple-module/ui/components/ui/card'; +import { Input } from '@simple-module/ui/components/ui/input'; +import { Label } from '@simple-module/ui/components/ui/label'; +import { AuthCardShell } from '@simple-module/ui/layouts/AuthCardShell'; +import { useState } from 'react'; + +function ForgotPassword() { + const [email, setEmail] = useState(''); + const [submitted, setSubmitted] = useState(false); + const [loading, setLoading] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + fetch('/api/users/auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }).finally(() => { + // Always show the same message regardless of whether the email exists + // (anti-enumeration: fastapi-users returns 202 regardless) + setLoading(false); + setSubmitted(true); + }); + }; + + if (submitted) { + return ( + + + + Check your email + + If an account with that email exists, we've sent a password reset link. + + + + + Back to sign in + + + + + ); + } + + return ( + + + + Forgot password + Enter your email to receive a reset link + + +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + autoComplete="email" + /> +
+ +
+

+ + Back to sign in + +

+
+
+
+ ); +} + +export default ForgotPassword; diff --git a/modules/users/users/pages/Login.tsx b/modules/users/users/pages/Login.tsx new file mode 100644 index 00000000..171f94e0 --- /dev/null +++ b/modules/users/users/pages/Login.tsx @@ -0,0 +1,184 @@ +import { router, usePage } from '@inertiajs/react'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@simple-module/ui/components/ui/card'; +import { Input } from '@simple-module/ui/components/ui/input'; +import { Label } from '@simple-module/ui/components/ui/label'; +import { AuthCardShell } from '@simple-module/ui/layouts/AuthCardShell'; +import { useState } from 'react'; + +interface DevAccount { + label: string; + email: string; + password: string; +} + +interface Props { + allow_signup: boolean; + dev_accounts: DevAccount[]; +} + +function Login() { + const { allow_signup, dev_accounts } = usePage<{ props: Props }>().props as unknown as Props; + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [needsVerification, setNeedsVerification] = useState(false); + const [loading, setLoading] = useState(false); + + const nextUrl = + typeof window !== 'undefined' + ? new URLSearchParams(window.location.search).get('next') || '/dashboard' + : '/dashboard'; + + const submitLogin = (username: string, pwd: string) => { + setError(null); + setNeedsVerification(false); + setLoading(true); + const body = new URLSearchParams({ username, password: pwd }); + fetch('/api/users/auth/login', { + method: 'POST', + body, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + .then(async (res) => { + if (res.status === 204) { + router.visit(nextUrl); + } else if (res.status === 429) { + setError('Too many attempts. Please try again in a few minutes.'); + } else { + const data = await res.json().catch(() => ({})); + const detail = typeof data?.detail === 'string' ? data.detail : ''; + if (detail === 'LOGIN_USER_NOT_VERIFIED') { + setNeedsVerification(true); + } else { + setError('Invalid email or password.'); + } + } + }) + .catch(() => setError('An error occurred. Please try again.')) + .finally(() => setLoading(false)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + submitLogin(email, password); + }; + + const handleDevLogin = (account: DevAccount) => { + setEmail(account.email); + setPassword(account.password); + submitLogin(account.email, account.password); + }; + + const handleResendVerification = () => { + fetch('/api/users/auth/request-verify-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }).then(() => { + setError('Verification email resent. Please check your inbox.'); + setNeedsVerification(false); + }); + }; + + return ( + + + + Sign in + Enter your email and password to continue + + +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + autoComplete="email" + /> +
+
+
+ + + Forgot password? + +
+ setPassword(e.target.value)} + required + autoComplete="current-password" + /> +
+ + {error &&

{error}

} + + {needsVerification && ( +
+

Please verify your email before signing in.

+ +
+ )} + + +
+ + {dev_accounts && dev_accounts.length > 0 && ( +
+

Dev: log in as seeded user

+
+ {dev_accounts.map((account) => ( + + ))} +
+
+ )} + + {allow_signup && ( +

+ Don't have an account?{' '} + + Create account + +

+ )} +
+
+
+ ); +} + +export default Login; diff --git a/modules/users/users/pages/Profile.tsx b/modules/users/users/pages/Profile.tsx new file mode 100644 index 00000000..0737d7b1 --- /dev/null +++ b/modules/users/users/pages/Profile.tsx @@ -0,0 +1,112 @@ +import { usePage } from '@inertiajs/react'; +import { PageShell } from '@simple-module/ui/components/PageShell'; +import { Badge } from '@simple-module/ui/components/ui/badge'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { Card, CardContent } from '@simple-module/ui/components/ui/card'; +import { Input } from '@simple-module/ui/components/ui/input'; +import { Label } from '@simple-module/ui/components/ui/label'; +import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface AuthUser { + id: string; + email: string; + full_name: string | null; + is_verified: boolean; + roles: string[]; +} + +interface SharedProps { + auth: { + user: AuthUser | null; + }; +} + +function Profile() { + const { auth } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const user = auth?.user; + + const [fullName, setFullName] = useState(user?.full_name ?? ''); + const [saving, setSaving] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + fetch('/api/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ full_name: fullName }), + }) + .then(async (res) => { + if (res.ok) { + toast.success('Profile updated'); + } else { + const data = await res.json().catch(() => ({})); + toast.error(typeof data?.detail === 'string' ? data.detail : 'Failed to update profile'); + } + }) + .catch(() => toast.error('An error occurred')) + .finally(() => setSaving(false)); + }; + + if (!user) { + return null; + } + + return ( + + + +
+
+ + +
+ {user.is_verified ? ( + Verified + ) : ( + Unverified + )} +
+
+ +
+ + setFullName(e.target.value)} + placeholder="Your name" + maxLength={200} + /> +
+ + {user.roles.length > 0 && ( +
+ +
+ {user.roles.map((role) => ( + + {role} + + ))} +
+
+ )} + +
+ +
+
+
+
+
+ ); +} + +Profile.layout = (page: React.ReactNode) => {page}; +export default Profile; diff --git a/modules/users/users/pages/Register.tsx b/modules/users/users/pages/Register.tsx new file mode 100644 index 00000000..156e9971 --- /dev/null +++ b/modules/users/users/pages/Register.tsx @@ -0,0 +1,152 @@ +import { Button } from '@simple-module/ui/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@simple-module/ui/components/ui/card'; +import { Input } from '@simple-module/ui/components/ui/input'; +import { Label } from '@simple-module/ui/components/ui/label'; +import { AuthCardShell } from '@simple-module/ui/layouts/AuthCardShell'; +import { useState } from 'react'; + +function Register() { + const [email, setEmail] = useState(''); + const [fullName, setFullName] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (password !== confirm) { + setError('Passwords do not match.'); + return; + } + setLoading(true); + fetch('/api/users/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, full_name: fullName }), + }) + .then(async (res) => { + if (res.status === 201) { + setSuccess(true); + } else { + const data = await res.json().catch(() => ({})); + const detail = data?.detail; + if (detail === 'REGISTER_USER_ALREADY_EXISTS') { + setError('An account with this email already exists.'); + } else if (typeof detail === 'object' && detail?.code === 'REGISTER_INVALID_PASSWORD') { + setError(`Password not accepted: ${detail.reason ?? 'too weak'}`); + } else if (typeof detail === 'string') { + setError(detail); + } else { + setError('Registration failed. Please try again.'); + } + } + }) + .catch(() => setError('An error occurred. Please try again.')) + .finally(() => setLoading(false)); + }; + + if (success) { + return ( + + + + Check your email + + We've sent a verification link to {email}. Please verify your account + before signing in. + + + + + Back to sign in + + + + + ); + } + + return ( + + + + Create account + Fill in your details to get started + + +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + autoComplete="email" + /> +
+
+ + setFullName(e.target.value)} + placeholder="Your name" + autoComplete="name" + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="new-password" + /> +
+
+ + setConfirm(e.target.value)} + required + autoComplete="new-password" + /> +
+ + {error &&

{error}

} + + +
+ +

+ Already have an account?{' '} + + Sign in + +

+
+
+
+ ); +} + +export default Register; diff --git a/modules/users/users/pages/ResetPassword.tsx b/modules/users/users/pages/ResetPassword.tsx new file mode 100644 index 00000000..99fe85d5 --- /dev/null +++ b/modules/users/users/pages/ResetPassword.tsx @@ -0,0 +1,112 @@ +import { router, usePage } from '@inertiajs/react'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@simple-module/ui/components/ui/card'; +import { Input } from '@simple-module/ui/components/ui/input'; +import { Label } from '@simple-module/ui/components/ui/label'; +import { AuthCardShell } from '@simple-module/ui/layouts/AuthCardShell'; +import { useState } from 'react'; + +interface Props { + token: string; +} + +function ResetPassword() { + const { token: initialToken } = usePage<{ props: Props }>().props as unknown as Props; + + // Prefer the token from the URL query string (deeplink); fall back to Inertia prop. + const urlToken = + typeof window !== 'undefined' + ? (new URLSearchParams(window.location.search).get('token') ?? '') + : ''; + const token = urlToken || initialToken; + + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (password !== confirm) { + setError('Passwords do not match.'); + return; + } + setLoading(true); + fetch('/api/users/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password }), + }) + .then(async (res) => { + if (res.status === 200 || res.status === 204) { + router.visit('/users/login'); + } else { + const data = await res.json().catch(() => ({})); + const detail = + typeof data?.detail === 'string' + ? data.detail + : 'Reset failed. The link may have expired.'; + setError(detail); + } + }) + .catch(() => setError('An error occurred. Please try again.')) + .finally(() => setLoading(false)); + }; + + return ( + + + + Reset password + Choose a new password for your account + + +
+
+ + setPassword(e.target.value)} + required + autoComplete="new-password" + /> +
+
+ + setConfirm(e.target.value)} + required + autoComplete="new-password" + /> +
+ + {error &&

{error}

} + + +
+ {!token && ( +

+ No reset token found. Please use the link from your email. +

+ )} +
+
+
+ ); +} + +export default ResetPassword; diff --git a/modules/users/users/pages/Users/Edit.tsx b/modules/users/users/pages/Users/Edit.tsx new file mode 100644 index 00000000..8bddce44 --- /dev/null +++ b/modules/users/users/pages/Users/Edit.tsx @@ -0,0 +1,184 @@ +import { Link, router, usePage } from '@inertiajs/react'; +import { PageShell } from '@simple-module/ui/components/PageShell'; +import { Badge } from '@simple-module/ui/components/ui/badge'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@simple-module/ui/components/ui/card'; +import { Checkbox } from '@simple-module/ui/components/ui/checkbox'; +import { Label } from '@simple-module/ui/components/ui/label'; +import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface UserListItem { + id: string; + email: string; + full_name: string | null; + is_active: boolean; + is_verified: boolean; + last_login_at: string | null; + roles: string[]; +} + +interface Role { + id: string; + name: string; +} + +interface Props { + user: UserListItem; + roles: Role[]; +} + +function Edit() { + const { user, roles } = usePage<{ props: Props }>().props as unknown as Props; + + const [isActive, setIsActive] = useState(user.is_active); + const [selectedRoles, setSelectedRoles] = useState(user.roles ?? []); + const [savingStatus, setSavingStatus] = useState(false); + const [savingRoles, setSavingRoles] = useState(false); + + const toggleRole = (roleName: string) => { + setSelectedRoles((prev) => + prev.includes(roleName) ? prev.filter((r) => r !== roleName) : [...prev, roleName], + ); + }; + + const handleToggleActive = () => { + setSavingStatus(true); + const endpoint = isActive + ? `/api/users/admin/${user.id}/disable` + : `/api/users/admin/${user.id}/enable`; + fetch(endpoint, { method: 'PATCH' }) + .then(async (res) => { + if (res.ok) { + const newActive = !isActive; + setIsActive(newActive); + toast.success(newActive ? 'User enabled' : 'User disabled'); + } else { + const data = await res.json().catch(() => ({})); + toast.error(typeof data?.detail === 'string' ? data.detail : 'Failed to update status'); + } + }) + .catch(() => toast.error('An error occurred')) + .finally(() => setSavingStatus(false)); + }; + + const handleSaveRoles = () => { + setSavingRoles(true); + fetch(`/api/users/admin/${user.id}/roles`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role_names: selectedRoles }), + }) + .then(async (res) => { + if (res.ok) { + toast.success('Roles updated'); + } else { + const data = await res.json().catch(() => ({})); + toast.error(typeof data?.detail === 'string' ? data.detail : 'Failed to update roles'); + } + }) + .catch(() => toast.error('An error occurred')) + .finally(() => setSavingRoles(false)); + }; + + const handleCopyResetLink = () => { + fetch(`/api/users/admin/${user.id}/reset-password-link`, { method: 'POST' }) + .then(async (res) => { + if (res.ok) { + const data = await res.json(); + await navigator.clipboard.writeText(data.link ?? data.url ?? ''); + toast.success('Reset link copied to clipboard'); + } else { + toast.error('Failed to generate reset link'); + } + }) + .catch(() => toast.error('An error occurred')); + }; + + const handleReload = () => { + router.reload(); + }; + + return ( + + Back to Users + + } + > +
+ {/* Status card */} + + + Account status + + +
+ + {isActive ? 'Active' : 'Disabled'} + + {user.is_verified ? ( + Verified + ) : ( + + Unverified + + )} +
+
+ + +
+
+
+ + {/* Roles card */} + + + Roles + + +
+ {roles.map((role) => ( +
+ toggleRole(role.name)} + /> + +
+ ))} +
+
+ + +
+
+
+
+
+ ); +} + +Edit.layout = (page: React.ReactNode) => {page}; +export default Edit; diff --git a/modules/users/users/pages/Users/Index.tsx b/modules/users/users/pages/Users/Index.tsx new file mode 100644 index 00000000..8950a345 --- /dev/null +++ b/modules/users/users/pages/Users/Index.tsx @@ -0,0 +1,200 @@ +import { Link, router, usePage } from '@inertiajs/react'; +import { PageShell } from '@simple-module/ui/components/PageShell'; +import { Badge } from '@simple-module/ui/components/ui/badge'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { Card } from '@simple-module/ui/components/ui/card'; +import { Input } from '@simple-module/ui/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@simple-module/ui/components/ui/table'; +import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; +import { Pencil, Plus, Search, Users } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +interface UserListItem { + id: string; + email: string; + full_name: string | null; + is_active: boolean; + is_verified: boolean; + last_login_at: string | null; + roles: string[]; +} + +interface Pagination { + page: number; + per_page: number; + total: number; +} + +interface Props { + users: UserListItem[]; + pagination: Pagination; + query: string; + roles: { id: string; name: string }[]; +} + +function Index() { + const { + users, + pagination, + query: initialQuery, + } = usePage<{ props: Props }>().props as unknown as Props; + + const [search, setSearch] = useState(initialQuery ?? ''); + + const navigate = useCallback( + (page: number, q?: string) => { + const params: Record = {}; + const query = q ?? search; + if (query) params.q = query; + if (page > 1) params.page = String(page); + router.get('/users/admin', params, { preserveState: true, preserveScroll: true }); + }, + [search], + ); + + useEffect(() => { + if (search === (initialQuery ?? '')) return; + const timeout = setTimeout(() => navigate(1, search), 300); + return () => clearTimeout(timeout); + }, [search, initialQuery, navigate]); + + const totalPages = Math.ceil(pagination.total / pagination.per_page); + + return ( + + + + Invite user + + + } + > +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ {pagination.total > 0 && ( +

+ {pagination.total} user{pagination.total !== 1 ? 's' : ''} +

+ )} +
+ + + + + + Email + Name + Roles + Status + Last login + Actions + + + + {users.map((user) => ( + + +
+ {user.email} + {!user.is_verified && ( + + unverified + + )} +
+
+ + {user.full_name || '—'} + + +
+ {user.roles.length > 0 ? ( + user.roles.map((r) => ( + + {r} + + )) + ) : ( + — + )} +
+
+ + + {user.is_active ? 'Active' : 'Disabled'} + + + + {user.last_login_at ? new Date(user.last_login_at).toLocaleDateString() : '—'} + + + + +
+ ))} + {users.length === 0 && ( + + +
+ +

{search ? `No users match "${search}"` : 'No users yet'}

+
+
+
+ )} +
+
+
+ + {totalPages > 1 && ( +
+ + + Page {pagination.page} of {totalPages} + + +
+ )} +
+ ); +} + +Index.layout = (page: React.ReactNode) => {page}; +export default Index; diff --git a/modules/users/users/pages/Users/Invite.tsx b/modules/users/users/pages/Users/Invite.tsx new file mode 100644 index 00000000..6e878f3b --- /dev/null +++ b/modules/users/users/pages/Users/Invite.tsx @@ -0,0 +1,135 @@ +import { Link, router, usePage } from '@inertiajs/react'; +import { PageShell } from '@simple-module/ui/components/PageShell'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { Card, CardContent } from '@simple-module/ui/components/ui/card'; +import { Checkbox } from '@simple-module/ui/components/ui/checkbox'; +import { Input } from '@simple-module/ui/components/ui/input'; +import { Label } from '@simple-module/ui/components/ui/label'; +import { AuthenticatedLayout } from '@simple-module/ui/layouts/AuthenticatedLayout'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface Role { + id: string; + name: string; +} + +interface Props { + roles: Role[]; +} + +function Invite() { + const { roles } = usePage<{ props: Props }>().props as unknown as Props; + + const [email, setEmail] = useState(''); + const [fullName, setFullName] = useState(''); + const [selectedRoles, setSelectedRoles] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const toggleRole = (roleName: string) => { + setSelectedRoles((prev) => + prev.includes(roleName) ? prev.filter((r) => r !== roleName) : [...prev, roleName], + ); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + fetch('/api/users/admin/invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, full_name: fullName || null, role_names: selectedRoles }), + }) + .then(async (res) => { + if (res.ok) { + toast.success('Invite sent'); + router.visit('/users/admin'); + } else { + const data = await res.json().catch(() => ({})); + setError(typeof data?.detail === 'string' ? data.detail : 'Failed to send invite'); + } + }) + .catch(() => setError('An error occurred. Please try again.')) + .finally(() => setLoading(false)); + }; + + return ( + + Back to Users + + } + > + + +
+
+ + setEmail(e.target.value)} + placeholder="user@example.com" + required + autoComplete="off" + /> +
+ +
+ + setFullName(e.target.value)} + placeholder="Optional" + /> +
+ + {roles.length > 0 && ( +
+ +
+ {roles.map((role) => ( +
+ toggleRole(role.name)} + /> + +
+ ))} +
+
+ )} + + {error &&

{error}

} + +
+ + +
+
+
+
+
+ ); +} + +Invite.layout = (page: React.ReactNode) => {page}; +export default Invite; diff --git a/modules/users/users/pages/VerifyEmail.tsx b/modules/users/users/pages/VerifyEmail.tsx new file mode 100644 index 00000000..e94722b8 --- /dev/null +++ b/modules/users/users/pages/VerifyEmail.tsx @@ -0,0 +1,110 @@ +import { usePage } from '@inertiajs/react'; +import { Button } from '@simple-module/ui/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@simple-module/ui/components/ui/card'; +import { AuthCardShell } from '@simple-module/ui/layouts/AuthCardShell'; +import { useEffect, useState } from 'react'; + +interface Props { + token: string; +} + +type VerifyStatus = 'pending' | 'success' | 'already_verified' | 'error'; + +function VerifyEmail() { + const { token: initialToken } = usePage<{ props: Props }>().props as unknown as Props; + const urlToken = + typeof window !== 'undefined' + ? (new URLSearchParams(window.location.search).get('token') ?? '') + : ''; + const token = urlToken || initialToken; + + const [status, setStatus] = useState('pending'); + const [errorMsg, setErrorMsg] = useState(''); + + useEffect(() => { + if (!token) { + setStatus('error'); + setErrorMsg('No verification token found in this link.'); + return; + } + + fetch('/api/users/auth/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) + .then(async (res) => { + if (res.status === 200 || res.status === 204) { + setStatus('success'); + } else { + const data = await res.json().catch(() => ({})); + const detail = typeof data?.detail === 'string' ? data.detail : ''; + if (detail === 'VERIFY_USER_ALREADY_VERIFIED') { + setStatus('already_verified'); + } else { + setStatus('error'); + setErrorMsg('Verification link expired or invalid. Please request a new one.'); + } + } + }) + .catch(() => { + setStatus('error'); + setErrorMsg('An error occurred. Please try again.'); + }); + }, [token]); + + const content = { + pending: { + title: 'Verifying your email…', + description: 'Please wait while we verify your email address.', + body: null, + }, + success: { + title: 'Email verified!', + description: 'Your account is now active. You can sign in.', + body: ( + + + + ), + }, + already_verified: { + title: 'Already verified', + description: 'This account is already verified — you can log in.', + body: ( + + + + ), + }, + error: { + title: 'Verification failed', + description: errorMsg || 'Verification link expired or invalid.', + body: ( + + Back to sign in + + ), + }, + }[status]; + + return ( + + + + {content.title} + {content.description} + + {content.body && {content.body}} + + + ); +} + +export default VerifyEmail; diff --git a/modules/users/users/py.typed b/modules/users/users/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/modules/users/users/rate_limit.py b/modules/users/users/rate_limit.py new file mode 100644 index 00000000..8f71a147 --- /dev/null +++ b/modules/users/users/rate_limit.py @@ -0,0 +1,33 @@ +"""In-process login rate limiter — TTL caches, no Redis.""" + +from __future__ import annotations + +from cachetools import TTLCache + + +class LoginRateLimiter: + """Per-key failure counter with a cooldown window after N failures.""" + + def __init__( + self, + max_failures: int = 5, + window_seconds: int = 300, + cooldown_seconds: int = 900, + ) -> None: + self._fails: TTLCache = TTLCache(maxsize=10_000, ttl=window_seconds) + self._locks: TTLCache = TTLCache(maxsize=10_000, ttl=cooldown_seconds) + self._max = max_failures + + def is_locked(self, key: str) -> bool: + return key in self._locks + + def record_failure(self, key: str) -> None: + count = self._fails.get(key, 0) + 1 + self._fails[key] = count + if count >= self._max: + self._locks[key] = True + self._fails.pop(key, None) + + def reset(self, key: str) -> None: + self._fails.pop(key, None) + self._locks.pop(key, None) diff --git a/modules/users/users/service.py b/modules/users/users/service.py new file mode 100644 index 00000000..6cb6d5e0 --- /dev/null +++ b/modules/users/users/service.py @@ -0,0 +1,206 @@ +"""UserService — admin operations delegating to the DB and UserManager.""" + +from __future__ import annotations + +import secrets +import uuid +from datetime import UTC, datetime + +from sqlalchemy import delete, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from users.contracts.schemas import UserCreate, UserListItem +from users.manager import UserManager +from users.models import Role, User, UserRole + + +class UserService: + def __init__( + self, + db: AsyncSession, + user_manager: UserManager, + ) -> None: + self._db = db + self._manager = user_manager + + # ── Helpers ───────────────────────────────────────────────── + + async def _resolve_roles(self, role_names: list[str]) -> list[Role]: + """Return Role ORM objects matching the given names.""" + if not role_names: + return [] + result = await self._db.execute(select(Role).where(Role.name.in_(role_names))) + return list(result.scalars().all()) + + async def to_list_item(self, user: User) -> UserListItem: + """Build the DTO from a User with roles already eager-loaded.""" + return UserListItem( + id=user.id, + email=user.email, + full_name=user.full_name, + is_active=user.is_active, + is_verified=user.is_verified, + disabled_at=user.disabled_at, + last_login_at=user.last_login_at, + roles=[r.name for r in user.roles], + ) + + async def _get_user_with_roles(self, user_id: uuid.UUID) -> User | None: + result = await self._db.execute( + select(User).where(User.id == user_id).options(selectinload(User.roles)) + ) + return result.scalar_one_or_none() + + # ── Public API ─────────────────────────────────────────────── + + async def list_users( + self, + *, + page: int = 1, + per_page: int = 20, + search: str | None = None, + ) -> tuple[list[UserListItem], int]: + """Returns (items, total_count). Filters on email/full_name LIKE search.""" + stmt = select(User).options(selectinload(User.roles)) + count_stmt = select(func.count()).select_from(User) + + if search: + pattern = f"%{search}%" + condition = or_( + User.email.ilike(pattern), + User.full_name.ilike(pattern), + ) + stmt = stmt.where(condition) + count_stmt = count_stmt.where(condition) + + total = (await self._db.execute(count_stmt)).scalar_one() + + stmt = stmt.order_by(User.email).offset((page - 1) * per_page).limit(per_page) + rows = (await self._db.execute(stmt)).scalars().all() + + items = [await self.to_list_item(u) for u in rows] + return items, total + + async def invite( + self, + email: str, + full_name: str | None, + role_names: list[str], + *, + invited_by: User | None = None, + ) -> tuple[User, str]: + """Creates unverified user + random unusable password, assigns roles, + mints a verification token. Returns (user, token).""" + password = secrets.token_urlsafe(32) + user_create = UserCreate( + email=email, + password=password, + full_name=full_name, + is_active=True, + is_verified=False, + ) + user = await self._manager.create(user_create, safe=False) + + # Assign roles + roles = await self._resolve_roles(role_names) + invited_by_str = str(invited_by.id) if invited_by else None + for role in roles: + self._db.add( + UserRole( + user_id=user.id, + role_id=role.id, + assigned_by=invited_by_str, + ) + ) + if roles: + await self._db.commit() + + token = await self._manager.generate_verification_token(user) + return user, token + + async def disable(self, user_id: uuid.UUID) -> User: + user = await self._get_user_with_roles(user_id) + if user is None: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="User not found") + user.disabled_at = datetime.now(UTC) + user.is_active = False + await self._db.commit() + self._db.expire_all() + refreshed = await self._get_user_with_roles(user_id) + assert refreshed is not None # we just committed a change to this user + return refreshed + + async def enable(self, user_id: uuid.UUID) -> User: + user = await self._get_user_with_roles(user_id) + if user is None: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="User not found") + user.disabled_at = None + user.is_active = True + await self._db.commit() + self._db.expire_all() + refreshed = await self._get_user_with_roles(user_id) + assert refreshed is not None # we just committed a change to this user + return refreshed + + async def set_roles( + self, + user_id: uuid.UUID, + role_names: list[str], + *, + assigned_by: str | None = None, + ) -> User: + user = await self._get_user_with_roles(user_id) + if user is None: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="User not found") + + # Delete all existing role assignments for this user + await self._db.execute(delete(UserRole).where(UserRole.user_id == user_id)) + + # Insert new role assignments + roles = await self._resolve_roles(role_names) + for role in roles: + self._db.add( + UserRole( + user_id=user_id, + role_id=role.id, + assigned_by=assigned_by, + ) + ) + + await self._db.commit() + # Expire the session so the next query sees DB-committed data. + self._db.expire_all() + + # Re-fetch with roles loaded + refreshed = await self._get_user_with_roles(user_id) + assert refreshed is not None # we just committed role changes to this user + return refreshed + + async def generate_reset_link(self, user_id: uuid.UUID, base_url: str) -> str: + """Build an admin-copyable password-reset URL. No email side-effect.""" + user = await self._get_user_with_roles(user_id) + if user is None: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="User not found") + + token = self._manager.generate_reset_password_token(user) + return f"{base_url.rstrip('/')}/users/reset-password?token={token}" + + async def get_with_roles(self, user_id: uuid.UUID) -> User | None: + return await self._get_user_with_roles(user_id) + + async def get_list_item(self, user_id: uuid.UUID) -> UserListItem: + user = await self._get_user_with_roles(user_id) + if user is None: + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="User not found") + return await self.to_list_item(user) diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py new file mode 100644 index 00000000..42ffec63 --- /dev/null +++ b/modules/users/users/settings.py @@ -0,0 +1,52 @@ +"""Users module settings loaded from SM_USERS_* environment variables.""" + +from __future__ import annotations + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class UsersSettings(BaseSettings): + """Local user management configuration.""" + + model_config = SettingsConfigDict(env_prefix="SM_USERS_", env_file=".env", extra="ignore") + + # Self-service signup + allow_signup: bool = False + require_verification: bool = True + + # Token secrets — MUST be set in production. Dev default is a deterministic + # placeholder that's obvious in logs so it can't be mistaken for a real key. + reset_password_token_secret: str = "dev-reset-token-secret-change-me" + verification_token_secret: str = "dev-verify-token-secret-change-me" + reset_password_token_lifetime_seconds: int = 60 * 60 # 1 hour + verification_token_lifetime_seconds: int = 60 * 60 * 24 * 7 # 7 days + + # Cookie (fastapi-users AuthenticationBackend) + cookie_name: str = "sm_auth" + cookie_max_age_seconds: int = 60 * 60 * 24 * 14 # 14 days + cookie_secure: bool = True # flipped False in dev by the module at startup + cookie_samesite: str = "lax" + + # Mailer + mailer: str = Field(default="console", pattern="^(console|smtp)$") + base_url: str = "http://localhost:8000" + smtp_host: str = "" + smtp_port: int = 587 + smtp_username: str = "" + smtp_password: str = "" + smtp_from: str = "no-reply@localhost" + smtp_tls: bool = True + + # Rate limit (login) + login_rate_limit_failures: int = 5 + login_rate_limit_window_seconds: int = 300 + login_rate_limit_cooldown_seconds: int = 900 + + # Bootstrap (env-var auto-create users on first boot) + bootstrap_email: str = "" + bootstrap_password: str = "" + # Optional second seed user with the "user" role — handy in dev for + # testing non-admin flows without logging out/in repeatedly. + bootstrap_user_email: str = "" + bootstrap_user_password: str = "" diff --git a/package-lock.json b/package-lock.json index 0703c660..d17e42eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -105,6 +105,19 @@ "react-dom": "^19.0.0" } }, + "modules/users": { + "name": "@simple-module/users", + "version": "0.1.0", + "devDependencies": { + "@simple-module/tsconfig": "*" + }, + "peerDependencies": { + "@inertiajs/react": "^2.0.0", + "@simple-module/ui": "*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -4113,6 +4126,10 @@ "resolved": "packages/ui", "link": true }, + "node_modules/@simple-module/users": { + "resolved": "modules/users", + "link": true + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/packages/ui/src/components/LocaleSwitcher.tsx b/packages/ui/src/components/LocaleSwitcher.tsx index cc8d4270..84bd0e74 100644 --- a/packages/ui/src/components/LocaleSwitcher.tsx +++ b/packages/ui/src/components/LocaleSwitcher.tsx @@ -53,7 +53,7 @@ export function LocaleSwitcher() { }; return ( - <> +
@@ -76,6 +76,6 @@ export function LocaleSwitcher() { ))} - +
); } diff --git a/packages/ui/src/components/NavIcon.tsx b/packages/ui/src/components/NavIcon.tsx index 29a1d897..295c308c 100644 --- a/packages/ui/src/components/NavIcon.tsx +++ b/packages/ui/src/components/NavIcon.tsx @@ -49,6 +49,22 @@ const ICONS: Record = { /> ), + user: ( + + ), settings: (
+

{title} @@ -21,9 +19,7 @@ export function PageShell({ title, description, children, actions }: PageShellPr

{actions &&
{actions}
}
-
- {children} -
+
{children}
); } diff --git a/packages/ui/src/layouts/AuthCardShell.tsx b/packages/ui/src/layouts/AuthCardShell.tsx new file mode 100644 index 00000000..cb695bc5 --- /dev/null +++ b/packages/ui/src/layouts/AuthCardShell.tsx @@ -0,0 +1,15 @@ +import type React from 'react'; + +/** + * Full-viewport centered-card shell for unauthenticated flows + * (login, register, password reset, email verify, invite accept). + * + * Distinct from PublicLayout (branded landing nav + footer) and + * AuthenticatedLayout (sidebar shell): auth-form pages want a minimal + * undecorated centering wrapper with nothing else on the page. + */ +export function AuthCardShell({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} diff --git a/packages/ui/src/layouts/AuthenticatedLayout.tsx b/packages/ui/src/layouts/AuthenticatedLayout.tsx index fe8df689..37072122 100644 --- a/packages/ui/src/layouts/AuthenticatedLayout.tsx +++ b/packages/ui/src/layouts/AuthenticatedLayout.tsx @@ -17,15 +17,7 @@ const THEME = { export function AuthenticatedLayout({ children }: { children: React.ReactNode }) { return ( - - -
- } - > + }> {children} diff --git a/packages/ui/src/layouts/SidebarLayout.tsx b/packages/ui/src/layouts/SidebarLayout.tsx index f39acef9..3e11026f 100644 --- a/packages/ui/src/layouts/SidebarLayout.tsx +++ b/packages/ui/src/layouts/SidebarLayout.tsx @@ -1,12 +1,21 @@ import { Link, usePage } from '@inertiajs/react'; import { Avatar, AvatarFallback } from '@simple-module/ui/components/ui/avatar'; import { Button } from '@simple-module/ui/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@simple-module/ui/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@simple-module/ui/components/ui/tooltip'; +import { ChevronsUpDown } from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; import { NavIcon } from '../components/NavIcon'; @@ -161,33 +170,76 @@ export function SidebarLayout({ {footerNavSlot} - {/* User section */} - {auth?.user && ( -
-
- - - {auth.user.name?.charAt(0)?.toUpperCase() || 'U'} - - -
-

{auth.user.name}

-

{auth.user.email}

+ {/* User section — avatar row opens a dropdown with Profile / Logout / etc. */} + {auth?.user && + (() => { + // UserContext.from_user defaults ``name`` to ``email`` when no + // full_name is set, so guard against rendering the email twice. + const hasDistinctName = auth.user.name && auth.user.name !== auth.user.email; + return ( +
+ + + + + + + {hasDistinctName ? ( + <> +

{auth.user.name}

+

+ {auth.user.email} +

+ + ) : ( +

{auth.user.email}

+ )} +
+ {menus?.userDropdown && menus.userDropdown.length > 0 && ( + + )} + {menus?.userDropdown?.map((item) => ( + + + + {item.label} + + + ))} +
+
-
- {menus?.userDropdown?.map((item) => ( - - - {item.label} - - ))} -
- )} + ); + })()} {/* Main content */} diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 82890d40..2b84dc07 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -224,47 +224,6 @@ --radius-xl: calc(var(--radius) + 4px); } -/* ── Animations ────────────────────────────────────────────── */ -@keyframes fade-in-up { - from { - opacity: 0; - transform: translateY(12px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes slide-in-left { - from { - opacity: 0; - transform: translateX(-8px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -@keyframes shimmer { - 0% { - background-position: -200% 0; - } - 100% { - background-position: 200% 0; - } -} - /* ── Base Styles ────────────────────────────────────────────── */ @layer base { html { @@ -287,17 +246,3 @@ font-family: var(--font-display); } } - -/* ── Custom Utilities (Tailwind v4) ─────────────────────────── */ - -@utility animate-fade-in-up { - animation: fade-in-up 0.5s ease-out both; -} - -@utility animate-fade-in { - animation: fade-in 0.4s ease-out both; -} - -@utility animate-slide-in-left { - animation: slide-in-left 0.4s ease-out both; -} diff --git a/pyproject.toml b/pyproject.toml index 2a30a961..ddf98d74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ extra-paths = [ "modules/auth", "modules/dashboard", "modules/products", + "modules/users", "host", ] @@ -70,6 +71,6 @@ exclude = ["framework/hosting/simple_module_hosting/templates/**"] [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "tests/integration", "tests/e2e"] -markers = ["e2e: end-to-end tests requiring live services (Keycloak, browser)"] +testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "modules/users/tests", "tests/integration", "tests/e2e"] +markers = ["e2e: end-to-end tests requiring a live browser"] addopts = "-m 'not e2e'" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index e1eac65b..81047402 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -6,8 +6,17 @@ Env vars: E2E_BASE_URL — where the FastAPI host is listening (default: http://localhost:8000) - E2E_USERNAME — Keycloak username (default: admin — seeded in keycloak/realm-export.json) - E2E_PASSWORD — Keycloak password (default: admin) + E2E_USERNAME — admin email address (default: admin@example.com) + E2E_PASSWORD — admin password (default: admin) + E2E_USER_ID — UUID of the E2E admin user (optional; enables password-reset test) + +Token-minting helpers +--------------------- +:func:`mint_verify_token` builds a fastapi-users verify/invite token signed +with the dev-default secret (or whatever ``SM_USERS_VERIFICATION_TOKEN_SECRET`` +is set to). The server verifies tokens with the same secret, so minting +locally is equivalent to what the ConsoleMailer would have logged — without +needing to scrape server stdout. """ from __future__ import annotations @@ -16,6 +25,10 @@ import pytest +# --------------------------------------------------------------------------- +# Base fixtures +# --------------------------------------------------------------------------- + @pytest.fixture(scope="session") def base_url() -> str: @@ -24,7 +37,7 @@ def base_url() -> str: @pytest.fixture(scope="session") def e2e_username() -> str: - return os.environ.get("E2E_USERNAME", "admin") + return os.environ.get("E2E_USERNAME", "admin@example.com") @pytest.fixture(scope="session") @@ -39,3 +52,50 @@ def browser_context_args(browser_context_args, base_url): With this in place, ``page.goto("/")`` resolves relative to E2E_BASE_URL. """ return {**browser_context_args, "base_url": base_url} + + +# --------------------------------------------------------------------------- +# Token-secret fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def reset_token_secret() -> str: + """The same secret the server uses to sign password-reset JWTs.""" + return os.environ.get( + "SM_USERS_RESET_PASSWORD_TOKEN_SECRET", + "dev-reset-token-secret-change-me", + ) + + +@pytest.fixture(scope="session") +def verify_token_secret() -> str: + """The same secret the server uses to sign verify/invite JWTs.""" + return os.environ.get( + "SM_USERS_VERIFICATION_TOKEN_SECRET", + "dev-verify-token-secret-change-me", + ) + + +# --------------------------------------------------------------------------- +# Token-minting helpers +# --------------------------------------------------------------------------- + + +def mint_verify_token(user_id: str, email: str, secret: str) -> str: + """Mint a fastapi-users verification/invite token locally. + + The token shape mirrors what ``UserManager.generate_verification_token`` + produces, so the server's ``/api/users/auth/accept-invite`` endpoint + accepts it without modification. + + Audience: ``"fastapi-users:verify"`` — same as the invite flow. + Lifetime: 3600 seconds (sufficient for a test run). + """ + from fastapi_users.jwt import generate_jwt + + return generate_jwt( + {"sub": user_id, "email": email, "aud": "fastapi-users:verify"}, + secret, + 3600, + ) diff --git a/tests/e2e/test_smoke.py b/tests/e2e/test_smoke.py index 8805753d..faf47030 100644 --- a/tests/e2e/test_smoke.py +++ b/tests/e2e/test_smoke.py @@ -1,15 +1,25 @@ """End-to-end UI smoke tests. -Two browser-driven happy-path tests, both gated by the ``e2e`` marker: +Four browser-driven happy-path tests, all gated by the ``e2e`` marker: -* :func:`test_login_and_browse_smoke` — landing → Keycloak login → +* :func:`test_login_and_browse_smoke` — landing → local login form → dashboard → products browse → logout. Minimal regression guard that proves auth and page rendering work end to end. * :func:`test_products_crud_smoke` — builds on the browse smoke with - the full create → edit → delete loop. Requires the Keycloak client - to emit ``realm_access.roles`` in userinfo so ``RequiresPermission`` - lets the admin user through. + the full create → edit → delete loop. Requires the admin user to have + the ``admin`` role so ``RequiresPermission`` lets the admin user through. + +* :func:`test_password_reset_smoke` — SKIPPED (see inline comment). + Requires the hashed-password fingerprint (``password_fgpt``) that only + the server holds; the HTTP-level flow is covered by unit tests in + ``modules/users/tests/test_api_auth.py``. + +* :func:`test_admin_invite_smoke` — admin invites a new user via the UI, + then the invitee accepts the invite in a fresh browser context and is + redirected to the dashboard. The invite token is minted locally using + the dev-default verify secret — equivalent to what the ConsoleMailer logs, + without scraping server stdout. Requires a live stack. See docs/e2e-testing.md for setup. """ @@ -20,7 +30,9 @@ import time import pytest -from playwright.sync_api import Page, expect +from playwright.sync_api import Browser, Page, expect + +from tests.e2e.conftest import mint_verify_token _PRODUCTS_URL = re.compile(r"/products/?$") @@ -28,12 +40,12 @@ def _login(page: Page, username: str, password: str) -> None: - """From landing, click Get Started and complete the Keycloak form.""" + """From landing, click Get Started and complete the local login form.""" # Landing renders a nav "Get Started" and a hero "Get Started"; either works. page.get_by_role("link", name="Get Started").first.click() - page.locator("#username").fill(username) + page.locator("#email").fill(username) page.locator("#password").fill(password) - page.locator("#kc-login").click() + page.get_by_role("button", name="Login").click() def _login_and_land_on_dashboard(page: Page, username: str, password: str) -> None: @@ -58,7 +70,7 @@ def test_login_and_browse_smoke( page.goto("/products") expect(page.get_by_role("heading", name="Products")).to_be_visible() - page.goto("/auth/logout") + page.goto("/users/logout") page.goto("/") expect(page.get_by_role("heading", name="Modular Monolith")).to_be_visible() @@ -70,8 +82,7 @@ def test_products_crud_smoke( ) -> None: """Full create → edit → delete loop against the live Products module. - Requires the admin user's session to carry ``realm_access.roles`` - (set via the realm-export.json protocol mapper) so the + Requires the admin user's session to carry the ``admin`` role so the ``products.create`` / ``.edit`` / ``.delete`` permission gates allow the calls. """ @@ -108,3 +119,104 @@ def test_products_crud_smoke( expect(dialog).to_be_visible() dialog.get_by_role("button", name="Delete").click() expect(edited_row).not_to_be_visible(timeout=10_000) + + +@pytest.mark.skip( + reason=( + "fastapi-users reset_password() validates a password fingerprint " + "(password_fgpt = PBKDF2/Argon2 hash of hashed_password) that is " + "only available server-side. Minting a valid token outside the " + "server process is not feasible without the stored hashed_password. " + "The full HTTP-layer flow is covered by unit tests in " + "modules/users/tests/test_api_auth.py." + ) +) +def test_password_reset_smoke( + page: Page, + e2e_username: str, +) -> None: # pragma: no cover + """Skipped — see decorator reason above.""" + + +def test_admin_invite_smoke( + page: Page, + browser: Browser, + base_url: str, + e2e_username: str, + e2e_password: str, + verify_token_secret: str, +) -> None: + """Admin invites a new user; invitee accepts the invite in a fresh context. + + Token-minting approach (plan option b): we POST the invite through the + real UI, which creates the user server-side (``is_verified=False``). We + then mint a verification token locally using the same secret the server + uses — identical to what the ConsoleMailer would have logged — and navigate + to ``/users/invite/accept?token=…`` in a fresh browser context to complete + the flow without scraping server logs. + """ + # 1. Log in as admin and navigate to the invite page. + _login_and_land_on_dashboard(page, e2e_username, e2e_password) + page.goto("/users/admin/invite") + expect(page.get_by_role("heading", name="Invite user")).to_be_visible(timeout=10_000) + + # 2. Fill in the invite form with a timestamped email. + invitee_email = f"invitee+{int(time.time() * 1000)}@test.invalid" + invitee_name = "E2E Invitee" + + page.locator("#email").fill(invitee_email) + page.locator("#full_name").fill(invitee_name) + + # Check the "user" role checkbox (label text matches the role name). + user_role_checkbox = page.get_by_label("user", exact=True) + if user_role_checkbox.count() > 0: + user_role_checkbox.check() + + page.get_by_role("button", name="Send invite").click() + + # 3. Expect redirect back to /users/admin after a successful invite. + page.wait_for_url("**/users/admin**", timeout=15_000) + + # 4. Retrieve the newly created user's id via the admin API so we can + # mint the token. The invite endpoint also returns the user in the + # response, but since we went through the browser we use the list API. + import json + import urllib.request + + list_url = f"{base_url}/api/users/admin/users?query={invitee_email}&page=1&per_page=10" + # Re-use the admin session cookie that Playwright set on the page's context. + cookies = page.context.cookies() + cookie_header = "; ".join(f"{c['name']}={c['value']}" for c in cookies) + req = urllib.request.Request(list_url, headers={"Cookie": cookie_header}) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + + users = data.get("users", []) + if not users: + pytest.skip(f"Invited user {invitee_email!r} not found via admin API — cannot mint token") + + invitee_id = users[0]["id"] + + # 5. Mint the verify token locally (same secret the server uses). + token = mint_verify_token(invitee_id, invitee_email, verify_token_secret) + + # 6. Open a fresh browser context (no admin session cookies). + new_context = browser.new_context(base_url=base_url) + new_page = new_context.new_page() + try: + new_page.goto(f"/users/invite/accept?token={token}") + expect(new_page.get_by_role("heading", name="Accept invitation")).to_be_visible( + timeout=10_000 + ) + + # 7. Set a password and submit. + invitee_password = "InviteePass1!" + new_page.locator("#password").fill(invitee_password) + new_page.locator("#confirm").fill(invitee_password) + new_page.get_by_role("button", name="Activate account").click() + + # 8. Expect redirect to dashboard and invitee session established. + new_page.wait_for_url("**/dashboard/**", timeout=15_000) + expect(new_page.get_by_role("heading", name="Dashboard")).to_be_visible() + finally: + new_context.close() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f62eb502..70b8ec93 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -13,25 +13,76 @@ from __future__ import annotations import json +import uuid as _uuid from base64 import b64encode from collections.abc import AsyncGenerator -from typing import Any import httpx import pytest +from fastapi_users.password import PasswordHelper from itsdangerous import TimestampSigner +from sqlalchemy import select -def _sign_session(secret: str, userinfo: dict[str, Any]) -> str: +def _sign_session(session_data: dict, secret: str) -> str: """Build a signed ``session`` cookie value matching SessionMiddleware.""" - data = b64encode(json.dumps({"userinfo": userinfo}).encode()) + data = b64encode(json.dumps(session_data).encode()) return TimestampSigner(secret).sign(data).decode("utf-8") +async def _seed_user_with_roles(app, email: str, role_names: list[str]): + """Seed a User + roles into app's DB and return the User. + + If a named role doesn't exist yet, creates it with a deterministic UUID. + """ + from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID + from users.models import Role, User, UserRole + + _role_ids: dict[str, object] = { + "admin": ADMIN_ROLE_ID, + "user": USER_ROLE_ID, + } + + async with app.state.db.session_factory() as session: + # Ensure requested roles exist. + for name in role_names: + existing = ( + await session.execute(select(Role).where(Role.name == name)) + ).scalar_one_or_none() + if existing is None: + role_id = _role_ids.get(name, _uuid.uuid4()) + session.add(Role(id=role_id, name=name, description=name.title())) + await session.flush() + + user = User( + id=_uuid.uuid4(), + email=email, + hashed_password=PasswordHelper().hash("TestPass1!"), + is_active=True, + is_superuser=False, + is_verified=True, + full_name=email.split("@")[0].title(), + ) + session.add(user) + await session.flush() + + if role_names: + roles = ( + (await session.execute(select(Role).where(Role.name.in_(role_names)))) + .scalars() + .all() + ) + for role in roles: + session.add(UserRole(user_id=user.id, role_id=role.id)) + + await session.commit() + return user + + def _make_client( - app, userinfo: dict[str, Any], *, extra_headers: dict[str, str] | None = None + app, user_id: str, *, extra_headers: dict[str, str] | None = None ) -> httpx.AsyncClient: - cookie = _sign_session(str(app.state.settings.secret_key), userinfo) + cookie = _sign_session({"user_id": user_id}, str(app.state.settings.secret_key)) return httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://testserver", @@ -43,28 +94,21 @@ def _make_client( @pytest.fixture async def viewer_client(app) -> AsyncGenerator[httpx.AsyncClient, None]: """Authenticated client with only the ``viewer`` role (no admin, no products.*).""" - userinfo = { - "sub": "viewer-user-id", - "email": "viewer@example.com", - "name": "Viewer User", - "preferred_username": "vieweruser", - "realm_access": {"roles": ["viewer"]}, - } - async with _make_client(app, userinfo) as c: + user = await _seed_user_with_roles(app, "viewer@example.com", ["viewer"]) + async with _make_client(app, str(user.id)) as c: yield c @pytest.fixture async def inertia_client(app) -> AsyncGenerator[httpx.AsyncClient, None]: """Admin client that sends the ``X-Inertia`` header on every request.""" - userinfo = { - "sub": "test-user-id", - "email": "test@example.com", - "name": "Test User", - "preferred_username": "testuser", - "realm_access": {"roles": ["admin"]}, - } - async with _make_client(app, userinfo, extra_headers={"X-Inertia": "true"}) as c: + async with _make_client( + app, + # Use the same admin user seeded by authenticated_client (fixture ordering + # means it may or may not exist). Simpler: seed a fresh admin-role user. + str((await _seed_user_with_roles(app, "inertia-admin@example.com", ["admin"])).id), + extra_headers={"X-Inertia": "true"}, + ) as c: yield c diff --git a/tests/integration/test_permission_boundaries.py b/tests/integration/test_permission_boundaries.py index 7689b475..dd1378ca 100644 --- a/tests/integration/test_permission_boundaries.py +++ b/tests/integration/test_permission_boundaries.py @@ -58,7 +58,7 @@ class TestUnauthenticatedAccess: async def test_unauthenticated_api_redirects_to_login(self, client: httpx.AsyncClient): resp = await client.get("/api/products/", follow_redirects=False) assert resp.status_code == 302 - assert "/auth/login" in resp.headers["location"] + assert "/users/login" in resp.headers["location"] async def test_unauthenticated_write_redirects_to_login(self, client: httpx.AsyncClient): resp = await client.post( @@ -67,4 +67,4 @@ async def test_unauthenticated_write_redirects_to_login(self, client: httpx.Asyn follow_redirects=False, ) assert resp.status_code == 302 - assert "/auth/login" in resp.headers["location"] + assert "/users/login" in resp.headers["location"] diff --git a/tests/integration/test_products_journey.py b/tests/integration/test_products_journey.py index a14fd4d4..7c00d921 100644 --- a/tests/integration/test_products_journey.py +++ b/tests/integration/test_products_journey.py @@ -64,28 +64,20 @@ async def test_multiple_products_listed_in_order(self, authenticated_client: htt class TestSessionDuringJourney: - async def test_session_user_visible_before_and_after_crud( + async def test_session_persists_before_and_after_crud( self, authenticated_client: httpx.AsyncClient ): - before = await authenticated_client.get("/auth/me") + """Session cookie remains valid before and after a CRUD operation.""" + # Confirm session is active — a protected endpoint returns 200. + before = await authenticated_client.get("/api/products/") assert before.status_code == 200 - assert before.json() == { - "authenticated": True, - "user": { - "sub": "test-user-id", - "email": "test@example.com", - "name": "Test User", - "preferred_username": "testuser", - "realm_access": {"roles": ["admin"]}, - }, - } create = await authenticated_client.post( "/api/products/", json={"name": "Journey", "price": "5.00"} ) assert create.status_code == 201 - after = await authenticated_client.get("/auth/me") + # Session must still be valid after the write. + after = await authenticated_client.get("/api/products/") assert after.status_code == 200 - assert after.json()["authenticated"] is True - assert after.json()["user"]["sub"] == "test-user-id" + assert any(p["name"] == "Journey" for p in after.json())