From 270522e53746283271decd25dc1aa8dd40180fd2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 17:50:40 +0200 Subject: [PATCH 01/26] Scaffold modules/users/ skeleton (#users-task-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the package layout, pyproject.toml with fastapi-users + mailer deps, and a no-op UsersModule. No routes or models yet — scaffolding only so subsequent tasks can fill in models, manager, routes, pages without path churn. Part of the work to replace Keycloak with a local user-management module (see docs plan cryptic-juggling-lightning). --- modules/users/package.json | 16 ++++++++ modules/users/pyproject.toml | 37 +++++++++++++++++++ modules/users/users/__init__.py | 0 modules/users/users/backend.py | 1 + modules/users/users/bootstrap.py | 1 + modules/users/users/cli.py | 5 +++ modules/users/users/contracts/__init__.py | 0 modules/users/users/contracts/events.py | 1 + modules/users/users/contracts/schemas.py | 1 + modules/users/users/db_adapter.py | 1 + modules/users/users/deps.py | 1 + modules/users/users/endpoints/__init__.py | 0 modules/users/users/endpoints/api.py | 1 + modules/users/users/endpoints/views.py | 1 + modules/users/users/mailer/__init__.py | 1 + modules/users/users/mailer/console.py | 1 + modules/users/users/mailer/smtp.py | 1 + modules/users/users/mailer/templates/.gitkeep | 0 modules/users/users/manager.py | 1 + modules/users/users/middleware.py | 1 + modules/users/users/models.py | 1 + modules/users/users/module.py | 14 +++++++ modules/users/users/pages/.gitkeep | 0 modules/users/users/py.typed | 0 modules/users/users/rate_limit.py | 1 + modules/users/users/service.py | 1 + modules/users/users/settings.py | 1 + pyproject.toml | 3 +- 28 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 modules/users/package.json create mode 100644 modules/users/pyproject.toml create mode 100644 modules/users/users/__init__.py create mode 100644 modules/users/users/backend.py create mode 100644 modules/users/users/bootstrap.py create mode 100644 modules/users/users/cli.py create mode 100644 modules/users/users/contracts/__init__.py create mode 100644 modules/users/users/contracts/events.py create mode 100644 modules/users/users/contracts/schemas.py create mode 100644 modules/users/users/db_adapter.py create mode 100644 modules/users/users/deps.py create mode 100644 modules/users/users/endpoints/__init__.py create mode 100644 modules/users/users/endpoints/api.py create mode 100644 modules/users/users/endpoints/views.py create mode 100644 modules/users/users/mailer/__init__.py create mode 100644 modules/users/users/mailer/console.py create mode 100644 modules/users/users/mailer/smtp.py create mode 100644 modules/users/users/mailer/templates/.gitkeep create mode 100644 modules/users/users/manager.py create mode 100644 modules/users/users/middleware.py create mode 100644 modules/users/users/models.py create mode 100644 modules/users/users/module.py create mode 100644 modules/users/users/pages/.gitkeep create mode 100644 modules/users/users/py.typed create mode 100644 modules/users/users/rate_limit.py create mode 100644 modules/users/users/service.py create mode 100644 modules/users/users/settings.py diff --git a/modules/users/package.json b/modules/users/package.json new file mode 100644 index 00000000..794605ad --- /dev/null +++ b/modules/users/package.json @@ -0,0 +1,16 @@ +{ + "name": "@simple-module/users", + "version": "0.1.0", + "private": true, + "description": "Frontend assets for the Users module", + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@inertiajs/react": "^2.0.0", + "@simple-module/ui": "*" + }, + "devDependencies": { + "@simple-module/tsconfig": "*" + }, + "dependencies": {} +} diff --git a/modules/users/pyproject.toml b/modules/users/pyproject.toml new file mode 100644 index 00000000..a18a5257 --- /dev/null +++ b/modules/users/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "users" +version = "0.1.0" +description = "Local user management (replaces Keycloak)" +authors = [ + { name = "Anto Subash", email = "antosubash@live.com" } +] +requires-python = ">=3.12" +dependencies = [ + "simple-module-core", + "simple-module-db", + "simple-module-hosting", + "auth", # workspace module — contracts + "fastapi-users[sqlalchemy]>=13", + "aiosmtplib>=3.0", + "cachetools>=5.3", + "typer>=0.12", +] + +[project.entry-points.simple_module] +users = "users.module:UsersModule" + +[project.scripts] +sm-users = "users.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel.force-include] +"package.json" = "users/package.json" + +[tool.uv.sources] +simple-module-core = { workspace = true } +simple-module-db = { workspace = true } +simple-module-hosting = { workspace = true } +auth = { workspace = true } diff --git a/modules/users/users/__init__.py b/modules/users/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/users/users/backend.py b/modules/users/users/backend.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/backend.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/bootstrap.py b/modules/users/users/bootstrap.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/bootstrap.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/cli.py b/modules/users/users/cli.py new file mode 100644 index 00000000..9ae1a3a7 --- /dev/null +++ b/modules/users/users/cli.py @@ -0,0 +1,5 @@ +"""CLI entry point — commands implemented in a later task.""" + +import typer + +app = typer.Typer(name="sm-users", help="Users module management commands.") diff --git a/modules/users/users/contracts/__init__.py b/modules/users/users/contracts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/users/users/contracts/events.py b/modules/users/users/contracts/events.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/contracts/events.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/contracts/schemas.py b/modules/users/users/contracts/schemas.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/contracts/schemas.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/db_adapter.py b/modules/users/users/db_adapter.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/db_adapter.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/deps.py b/modules/users/users/deps.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/deps.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/endpoints/__init__.py b/modules/users/users/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/users/users/endpoints/api.py b/modules/users/users/endpoints/api.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/endpoints/api.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/endpoints/views.py b/modules/users/users/endpoints/views.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/endpoints/views.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/mailer/__init__.py b/modules/users/users/mailer/__init__.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/mailer/__init__.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/mailer/console.py b/modules/users/users/mailer/console.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/mailer/console.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/mailer/smtp.py b/modules/users/users/mailer/smtp.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/mailer/smtp.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/mailer/templates/.gitkeep b/modules/users/users/mailer/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/modules/users/users/manager.py b/modules/users/users/manager.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/manager.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/middleware.py b/modules/users/users/middleware.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/middleware.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/models.py b/modules/users/users/models.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/models.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/module.py b/modules/users/users/module.py new file mode 100644 index 00000000..024376fa --- /dev/null +++ b/modules/users/users/module.py @@ -0,0 +1,14 @@ +"""Users module definition.""" + +from __future__ import annotations + +from simple_module_core.module import ModuleBase, ModuleMeta + + +class UsersModule(ModuleBase): + meta = ModuleMeta( + name="Users", + route_prefix="/api/users", + view_prefix="/users", + depends_on=["Auth"], + ) diff --git a/modules/users/users/pages/.gitkeep b/modules/users/users/pages/.gitkeep new file mode 100644 index 00000000..e69de29b 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..9a0a6134 --- /dev/null +++ b/modules/users/users/rate_limit.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/service.py b/modules/users/users/service.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/service.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py new file mode 100644 index 00000000..9a0a6134 --- /dev/null +++ b/modules/users/users/settings.py @@ -0,0 +1 @@ +"""Placeholder — implemented in a later task.""" diff --git a/pyproject.toml b/pyproject.toml index 38cb0197..7098dded 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", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "tests/integration", "tests/e2e"] +testpaths = ["framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/products/tests", "modules/users/tests", "tests/integration", "tests/e2e"] markers = ["e2e: end-to-end tests requiring live services (Keycloak, browser)"] addopts = "-m 'not e2e'" From 48132ca010ec9939b50f563dc0adc83bf79f3e05 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 17:53:11 +0200 Subject: [PATCH 02/26] Keep empty modules/users/tests/ present on disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root pyproject.toml lists modules/users/tests in pytest.ini_options.testpaths. The directory was omitted in the scaffold commit due to a conftest-shadowing issue with an empty conftest.py — a .gitkeep is enough to match the testpath without introducing a conftest, matching the pattern of other module test dirs. --- modules/users/tests/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 modules/users/tests/.gitkeep diff --git a/modules/users/tests/.gitkeep b/modules/users/tests/.gitkeep new file mode 100644 index 00000000..e69de29b From 03d5fa3f2f36fe74e04188cfcb4819b6c8c4ef88 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 17:57:46 +0200 Subject: [PATCH 03/26] =?UTF-8?q?Add=20PermissionRegistry.map=5Frole=20for?= =?UTF-8?q?=20module=20role=E2=86=92perm=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves DEFAULT_ROLE_PERMISSIONS and WILDCARD constants into simple_module_core.permissions so the registry can own the merge. Adds map_role() and the role_map property. InertiaLayoutDataMiddleware now threads the registry's role_map into resolve_permissions, so module-registered role mappings (e.g. 'user' → ['users.self.profile']) take effect in both the cached permission set and the frontend permissions list. PermissionRegistry.get_permissions_for_roles is unchanged — it still serves auth.deps.require_permission via its existing role_permission_map parameter. RequiresPermission's fallback path is updated to also consult the registry for consistency. Prep for the users module (task 2 of plan cryptic-juggling-lightning). --- .../core/simple_module_core/permissions.py | 36 ++++ framework/core/tests/test_permissions.py | 29 ++- .../simple_module_hosting/middleware.py | 6 +- .../simple_module_hosting/permissions.py | 22 ++- .../hosting/tests/test_hosting_permissions.py | 182 ++++++++++++++++++ 5 files changed, 263 insertions(+), 12 deletions(-) create mode 100644 framework/hosting/tests/test_hosting_permissions.py diff --git a/framework/core/simple_module_core/permissions.py b/framework/core/simple_module_core/permissions.py index 10066feb..41846b69 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: @@ -18,6 +26,7 @@ class PermissionRegistry: def __init__(self) -> None: self._groups: dict[str, PermissionGroup] = {} + self._role_map: dict[str, set[str]] = {} def add_group(self, name: str, permissions: list[str]) -> None: """Register a group of related permissions.""" @@ -49,6 +58,33 @@ 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) + + @property + def role_map(self) -> dict[str, list[str]]: + """Return the merged role→permission mapping. + + Starts from ``DEFAULT_ROLE_PERMISSIONS`` and merges in any + module-registered mappings added via :meth:`map_role`. + """ + 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) + return merged + 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/hosting/simple_module_hosting/middleware.py b/framework/hosting/simple_module_hosting/middleware.py index e31f383b..79a0e54c 100644 --- a/framework/hosting/simple_module_hosting/middleware.py +++ b/framework/hosting/simple_module_hosting/middleware.py @@ -222,7 +222,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_hosting_permissions.py b/framework/hosting/tests/test_hosting_permissions.py new file mode 100644 index 00000000..1bf8e1f8 --- /dev/null +++ b/framework/hosting/tests/test_hosting_permissions.py @@ -0,0 +1,182 @@ +"""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": {}, + } + 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 From 4ec08b1e35ad580c913cdf2667d2e1e6371ad46e Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 18:05:08 +0200 Subject: [PATCH 04/26] Add users module models and initial migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces User, Role, UserRole, UserAccessToken SQLAlchemy models on the users module base. User inherits SQLAlchemyBaseUserTableUUID from fastapi-users for a UUID PK + the standard auth columns, plus full_name / tenant_id / disabled_at / last_login_at and AuditMixin. UserAccessToken backs fastapi-users' DatabaseStrategy and its user_id FK points at users_user.id with ON DELETE CASCADE. Two alembic migrations: * create_users_tables — creates all four tables; on Postgres also creates the `users` schema before the tables. * seed_users_roles — data migration inserting `admin` and `user` with deterministic UUIDs (shared with modules/users/users/constants.py). Tests assert table shape, composite PK, FK cascade, and stable seed UUIDs. Part of replacing Keycloak — task 3 of plan cryptic-juggling-lightning. --- .../8c12be982a27_create_users_tables.py | 131 ++++++++++ .../versions/e3ce9754e6dc_seed_users_roles.py | 49 ++++ modules/users/tests/test_models.py | 245 ++++++++++++++++++ modules/users/users/constants.py | 6 + modules/users/users/models.py | 112 +++++++- 5 files changed, 542 insertions(+), 1 deletion(-) create mode 100644 host/migrations/versions/8c12be982a27_create_users_tables.py create mode 100644 host/migrations/versions/e3ce9754e6dc_seed_users_roles.py create mode 100644 modules/users/tests/test_models.py create mode 100644 modules/users/users/constants.py diff --git a/host/migrations/versions/8c12be982a27_create_users_tables.py b/host/migrations/versions/8c12be982a27_create_users_tables.py new file mode 100644 index 00000000..38dc4921 --- /dev/null +++ b/host/migrations/versions/8c12be982a27_create_users_tables.py @@ -0,0 +1,131 @@ +"""create users tables + +Revision ID: 8c12be982a27 +Revises: 2fdcd367b517 +Create Date: 2026-04-15 18:02:20.074558 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from fastapi_users_db_sqlalchemy.generics import GUID, TIMESTAMPAware + +# revision identifiers, used by Alembic. +revision: str = "8c12be982a27" +down_revision: str | None = "2fdcd367b517" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # On PostgreSQL, create the `users` schema before creating tables. + if op.get_context().dialect.name == "postgresql": + op.execute("CREATE SCHEMA IF NOT EXISTS users") + + op.create_table( + "users_role", + sa.Column("id", GUID(), nullable=False), + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_users_role")), + ) + op.create_index(op.f("ix_users_role_name"), "users_role", ["name"], unique=True) + + op.create_table( + "users_user", + sa.Column("full_name", sa.String(length=255), nullable=True), + sa.Column("tenant_id", sa.String(length=50), nullable=True), + sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", GUID(), nullable=False), + sa.Column("email", sa.String(length=320), nullable=False), + sa.Column("hashed_password", sa.String(length=1024), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_superuser", sa.Boolean(), nullable=False), + sa.Column("is_verified", sa.Boolean(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("updated_by", sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_users_user")), + ) + op.create_index(op.f("ix_users_user_email"), "users_user", ["email"], unique=True) + op.create_index(op.f("ix_users_user_tenant_id"), "users_user", ["tenant_id"], unique=False) + + op.create_table( + "users_access_token", + sa.Column("user_id", GUID(), nullable=False), + sa.Column("token", sa.String(length=43), nullable=False), + sa.Column("created_at", TIMESTAMPAware(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users_user.id"], + name=op.f("fk_users_access_token_user_id_users_user"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("token", name=op.f("pk_users_access_token")), + ) + op.create_index( + op.f("ix_users_access_token_created_at"), + "users_access_token", + ["created_at"], + unique=False, + ) + + op.create_table( + "users_user_role", + sa.Column("user_id", GUID(), nullable=False), + sa.Column("role_id", GUID(), nullable=False), + sa.Column( + "assigned_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("assigned_by", sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint( + ["role_id"], + ["users_role.id"], + name=op.f("fk_users_user_role_role_id_users_role"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users_user.id"], + name=op.f("fk_users_user_role_user_id_users_user"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("user_id", "role_id", name=op.f("pk_users_user_role")), + ) + + +def downgrade() -> None: + op.drop_table("users_user_role") + op.drop_index(op.f("ix_users_access_token_created_at"), table_name="users_access_token") + op.drop_table("users_access_token") + op.drop_index(op.f("ix_users_user_tenant_id"), table_name="users_user") + op.drop_index(op.f("ix_users_user_email"), table_name="users_user") + op.drop_table("users_user") + op.drop_index(op.f("ix_users_role_name"), table_name="users_role") + op.drop_table("users_role") + + # On PostgreSQL, drop the `users` schema. + if op.get_context().dialect.name == "postgresql": + op.execute("DROP SCHEMA IF EXISTS users") diff --git a/host/migrations/versions/e3ce9754e6dc_seed_users_roles.py b/host/migrations/versions/e3ce9754e6dc_seed_users_roles.py new file mode 100644 index 00000000..1475895d --- /dev/null +++ b/host/migrations/versions/e3ce9754e6dc_seed_users_roles.py @@ -0,0 +1,49 @@ +"""seed users roles + +Revision ID: e3ce9754e6dc +Revises: 8c12be982a27 +Create Date: 2026-04-15 18:10:00.000000 +""" + +from __future__ import annotations + +import uuid +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "e3ce9754e6dc" +down_revision: str | None = "8c12be982a27" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +# Duplicated from modules/users/users/constants.py — migrations are run from a +# context where module imports may not resolve (alembic discovers them via +# the simple_module entry point), and tying the migration to the module's +# import path is more fragile than hardcoding the stable UUIDs. +ADMIN_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000001") +USER_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000002") + + +def upgrade() -> None: + roles_table = sa.table( + "users_role", + sa.column("id", sa.Uuid()), + sa.column("name", sa.String()), + sa.column("description", sa.String()), + ) + op.bulk_insert( + roles_table, + [ + {"id": ADMIN_ROLE_ID, "name": "admin", "description": "Administrator"}, + {"id": USER_ROLE_ID, "name": "user", "description": "Standard user"}, + ], + ) + + +def downgrade() -> None: + op.execute( + f"DELETE FROM users_role WHERE id IN ('{ADMIN_ROLE_ID}', '{USER_ROLE_ID}')" + ) diff --git a/modules/users/tests/test_models.py b/modules/users/tests/test_models.py new file mode 100644 index 00000000..c200d238 --- /dev/null +++ b/modules/users/tests/test_models.py @@ -0,0 +1,245 @@ +"""Tests for the users module SQLAlchemy models. + +Uses the root conftest's ``db_session`` fixture which runs ``create_all`` +against an in-memory SQLite database, so the tables exist but the seed +migration data (admin/user roles) is not present — we insert what we need +inside each test. +""" + +from __future__ import annotations + +import uuid + +import pytest +from sqlalchemy import inspect, select, text + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def column_names(table) -> set[str]: + """Return the set of column names for a mapped class's table.""" + return {c.key for c in inspect(table).mapper.column_attrs} + + +# --------------------------------------------------------------------------- +# Model structure tests +# --------------------------------------------------------------------------- + + +class TestUserTableShape: + def test_tablename(self): + from users.models import User + + assert User.__tablename__ == "users_user" + + def test_required_columns(self): + from users.models import User + + cols = column_names(User) + expected = { + "id", + "email", + "hashed_password", + "is_active", + "is_superuser", + "is_verified", + "full_name", + "tenant_id", + "disabled_at", + "last_login_at", + # AuditMixin columns + "created_at", + "updated_at", + "created_by", + "updated_by", + } + assert expected <= cols, f"Missing columns: {expected - cols}" + + +class TestRoleTableShape: + def test_tablename(self): + from users.models import Role + + assert Role.__tablename__ == "users_role" + + def test_required_columns(self): + from users.models import Role + + cols = column_names(Role) + expected = { + "id", + "name", + "description", + # AuditMixin columns + "created_at", + "updated_at", + "created_by", + "updated_by", + } + assert expected <= cols, f"Missing columns: {expected - cols}" + + +class TestUserRoleTableShape: + def test_tablename(self): + from users.models import UserRole + + assert UserRole.__tablename__ == "users_user_role" + + def test_composite_pk_columns(self): + from users.models import UserRole + + mapper = inspect(UserRole) + pk_cols = {col.key for col in mapper.primary_key} + assert pk_cols == {"user_id", "role_id"} + + +class TestUserAccessTokenTableShape: + def test_tablename(self): + from users.models import UserAccessToken + + assert UserAccessToken.__tablename__ == "users_access_token" + + def test_user_id_fk_points_at_users_user(self): + from users.models import UserAccessToken + + table = inspect(UserAccessToken).persist_selectable + fk_targets = { + next(iter(col.foreign_keys)).target_fullname + for col in table.columns + if col.foreign_keys + } + assert "users_user.id" in fk_targets + + +# --------------------------------------------------------------------------- +# CRUD / integration tests (using db_session) +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_user_role_composite_pk(db_session): + """Insert a User + Role + UserRole, then retrieve via the association.""" + from users.models import Role, User, UserRole + + user_id = uuid.uuid4() + role_id = uuid.uuid4() + + user = User( + id=user_id, + email="crud@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + ) + role = Role(id=role_id, name="testrole") + link = UserRole(user_id=user_id, role_id=role_id) + + db_session.add_all([user, role, link]) + await db_session.commit() + + result = await db_session.execute( + select(UserRole).where( + UserRole.user_id == user_id, + UserRole.role_id == role_id, + ) + ) + row = result.scalar_one() + assert row.user_id == user_id + assert row.role_id == role_id + + +@pytest.mark.anyio +async def test_fk_cascade_delete_user_removes_user_role(db_session): + """Deleting a User cascades and removes associated UserRole rows.""" + from users.models import Role, User, UserRole + + user_id = uuid.uuid4() + role_id = uuid.uuid4() + + user = User( + id=user_id, + email="cascade@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + ) + role = Role(id=role_id, name="cascade_role") + link = UserRole(user_id=user_id, role_id=role_id) + + db_session.add_all([user, role, link]) + await db_session.commit() + + # Enable FK enforcement for SQLite (it's off by default) + await db_session.execute(text("PRAGMA foreign_keys=ON")) + + await db_session.delete(user) + await db_session.commit() + + result = await db_session.execute( + select(UserRole).where(UserRole.user_id == user_id) + ) + assert result.scalar_one_or_none() is None + + +@pytest.mark.anyio +async def test_user_access_token_insert(db_session): + """UserAccessToken can be created with a valid user_id FK.""" + from users.models import User, UserAccessToken + + user_id = uuid.uuid4() + user = User( + id=user_id, + email="token@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + ) + db_session.add(user) + await db_session.commit() + + token = UserAccessToken(token="a" * 43, user_id=user_id) + db_session.add(token) + await db_session.commit() + + result = await db_session.execute( + select(UserAccessToken).where(UserAccessToken.token == "a" * 43) + ) + row = result.scalar_one() + assert row.user_id == user_id + + +# --------------------------------------------------------------------------- +# Stable UUID constants tests +# --------------------------------------------------------------------------- + + +class TestConstants: + def test_admin_role_id_is_stable(self): + from users.constants import ADMIN_ROLE_ID + + assert str(ADMIN_ROLE_ID) == "00000000-0000-0000-0000-000000000001" + + def test_user_role_id_is_stable(self): + from users.constants import USER_ROLE_ID + + assert str(USER_ROLE_ID) == "00000000-0000-0000-0000-000000000002" + + def test_admin_uuid_hex(self): + from users.constants import ADMIN_ROLE_ID + + assert ADMIN_ROLE_ID.hex == "00000000000000000000000000000001" + + def test_user_uuid_hex(self): + from users.constants import USER_ROLE_ID + + assert USER_ROLE_ID.hex == "00000000000000000000000000000002" + + def test_ids_differ(self): + from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID + + assert ADMIN_ROLE_ID != USER_ROLE_ID diff --git a/modules/users/users/constants.py b/modules/users/users/constants.py new file mode 100644 index 00000000..e0ae3834 --- /dev/null +++ b/modules/users/users/constants.py @@ -0,0 +1,6 @@ +"""Stable identifiers used by both the seed migration and tests.""" + +import uuid + +ADMIN_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000001") +USER_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000002") diff --git a/modules/users/users/models.py b/modules/users/users/models.py index 9a0a6134..cbe5b4aa 100644 --- a/modules/users/users/models.py +++ b/modules/users/users/models.py @@ -1 +1,111 @@ -"""Placeholder — implemented in a later task.""" +"""SQLAlchemy models for the users module.""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from fastapi_users_db_sqlalchemy import ( + SQLAlchemyBaseUserTableUUID, + SQLAlchemyUserDatabase, +) +from fastapi_users_db_sqlalchemy.access_token import ( + SQLAlchemyAccessTokenDatabase, + SQLAlchemyBaseAccessTokenTable, +) +from fastapi_users_db_sqlalchemy.generics import GUID +from simple_module_db.base import create_module_base +from simple_module_db.mixins import AuditMixin +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship + +Base = create_module_base("users") + + +class User(SQLAlchemyBaseUserTableUUID, Base, AuditMixin): # ty: ignore[unsupported-base] + """Local user (replaces Keycloak subject).""" + + __tablename__ = "users_user" + + # Inherited from SQLAlchemyBaseUserTableUUID: + # id (UUID PK), email, hashed_password, is_active, is_superuser, is_verified + + full_name: Mapped[str | None] = mapped_column(String(255), default=None) + tenant_id: Mapped[str | None] = mapped_column(String(50), index=True, default=None) + disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + + roles: Mapped[list[Role]] = relationship( + secondary="users_user_role", + lazy="noload", + back_populates="users", + ) + + +class Role(Base, AuditMixin): # ty: ignore[unsupported-base] + """A named role that can hold a set of permission strings.""" + + __tablename__ = "users_role" + + id: Mapped[uuid.UUID] = mapped_column(GUID, primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(64), unique=True, index=True) + description: Mapped[str | None] = mapped_column(String(255), default=None) + + users: Mapped[list[User]] = relationship( + secondary="users_user_role", + lazy="noload", + back_populates="roles", + ) + + +class UserRole(Base): # ty: ignore[unsupported-base] + """Association table between users and roles.""" + + __tablename__ = "users_user_role" + + user_id: Mapped[uuid.UUID] = mapped_column( + GUID, + ForeignKey("users_user.id", ondelete="CASCADE"), + primary_key=True, + ) + role_id: Mapped[uuid.UUID] = mapped_column( + GUID, + ForeignKey("users_role.id", ondelete="CASCADE"), + primary_key=True, + ) + assigned_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + assigned_by: Mapped[str | None] = mapped_column(String(255), default=None) + + +class UserAccessToken(SQLAlchemyBaseAccessTokenTable[uuid.UUID], Base): # ty: ignore[unsupported-base] + """fastapi-users DatabaseStrategy-backed access tokens.""" + + __tablename__ = "users_access_token" + + # Inherited from SQLAlchemyBaseAccessTokenTable: + # token (str PK), created_at + + # Override the default `user_id` FK from the base class which points at + # "user.id". We redirect it to "users_user.id" with ON DELETE CASCADE. + # The base class uses @declared_attr, so we also use @declared_attr to + # override it correctly. + @declared_attr # type: ignore[override] + def user_id(self) -> Mapped[GUID]: + return mapped_column( + GUID, + ForeignKey("users_user.id", ondelete="CASCADE"), + nullable=False, + ) + + +__all__ = [ + "Base", + "Role", + "SQLAlchemyAccessTokenDatabase", + "SQLAlchemyUserDatabase", + "User", + "UserAccessToken", + "UserRole", +] From b58cc54da685a986034f816bbd882cba41fbf481 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 18:07:38 +0200 Subject: [PATCH 05/26] Fix UserAccessToken.user_id type annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mapped[GUID] was wrong — GUID is the SQLAlchemy column type, not a Python type. Mapped wraps the Python type. Runtime was unaffected (@declared_attr bypasses Mapped introspection), but the annotation now reads correctly as Mapped[uuid.UUID]. --- modules/users/users/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/users/users/models.py b/modules/users/users/models.py index cbe5b4aa..20dda909 100644 --- a/modules/users/users/models.py +++ b/modules/users/users/models.py @@ -92,7 +92,7 @@ class UserAccessToken(SQLAlchemyBaseAccessTokenTable[uuid.UUID], Base): # ty: i # The base class uses @declared_attr, so we also use @declared_attr to # override it correctly. @declared_attr # type: ignore[override] - def user_id(self) -> Mapped[GUID]: + def user_id(self) -> Mapped[uuid.UUID]: return mapped_column( GUID, ForeignKey("users_user.id", ondelete="CASCADE"), From b1f04749bd226ea6e6e4ce9786de896bd6c3510b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 18:23:30 +0200 Subject: [PATCH 06/26] Wire users module core: settings, mailer, DB adapter, manager, auth backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UsersSettings: SM_USERS_* env config (token secrets, cookies, mailer, rate limit, bootstrap) - Mailer Protocol + ConsoleMailer (logs tokenized link) + SmtpMailer (aiosmtplib + Jinja2 templates) + templates for verify/reset/invite - UserDatabaseWithRoles: SQLAlchemyUserDatabase subclass that eager-loads User.roles via selectinload so fastapi-users never triggers implicit async lazy loads - UserManager: validate_password (8+, not email, not all-digits), lifecycle hooks that route through the Mailer, and generate_verification_token — mints an invite token without firing on_after_request_verify so the admin invite flow can send a different email template using the same token primitive fastapi-users' POST /verify accepts - AuthenticationBackend wired as cookie transport + database strategy (backed by users_access_token) - deps.py exports auth_backend, fastapi_users, current_active_user, and the dependency callables No routes mounted yet — Task 6 of plan cryptic-juggling-lightning. --- modules/users/tests/test_backend.py | 60 +++++ modules/users/tests/test_db_adapter.py | 143 ++++++++++++ modules/users/tests/test_mailer.py | 80 +++++++ modules/users/tests/test_settings.py | 97 ++++++++ modules/users/tests/test_user_manager.py | 211 ++++++++++++++++++ modules/users/tests/test_users_deps.py | 62 +++++ modules/users/users/backend.py | 46 +++- modules/users/users/db_adapter.py | 49 +++- modules/users/users/deps.py | 52 ++++- modules/users/users/mailer/__init__.py | 35 ++- modules/users/users/mailer/console.py | 28 ++- modules/users/users/mailer/smtp.py | 83 ++++++- .../users/users/mailer/templates/invite.txt | 1 + .../users/mailer/templates/reset_password.txt | 1 + .../users/mailer/templates/verify_email.txt | 1 + modules/users/users/manager.py | 115 +++++++++- modules/users/users/settings.py | 49 +++- 17 files changed, 1105 insertions(+), 8 deletions(-) create mode 100644 modules/users/tests/test_backend.py create mode 100644 modules/users/tests/test_db_adapter.py create mode 100644 modules/users/tests/test_mailer.py create mode 100644 modules/users/tests/test_settings.py create mode 100644 modules/users/tests/test_user_manager.py create mode 100644 modules/users/tests/test_users_deps.py create mode 100644 modules/users/users/mailer/templates/invite.txt create mode 100644 modules/users/users/mailer/templates/reset_password.txt create mode 100644 modules/users/users/mailer/templates/verify_email.txt diff --git a/modules/users/tests/test_backend.py b/modules/users/tests/test_backend.py new file mode 100644 index 00000000..b95748db --- /dev/null +++ b/modules/users/tests/test_backend.py @@ -0,0 +1,60 @@ +"""Tests for the auth backend builder.""" + +from __future__ import annotations + +from fastapi_users.authentication import AuthenticationBackend, CookieTransport + + +def test_build_auth_backend_name(): + from users.backend import build_auth_backend, build_cookie_transport + + transport = build_cookie_transport( + cookie_name="sm_auth", + cookie_max_age_seconds=86400, + cookie_secure=False, + cookie_samesite="lax", + ) + backend = build_auth_backend(transport) + + assert backend.name == "cookie" + + +def test_build_auth_backend_is_authentication_backend(): + from users.backend import build_auth_backend, build_cookie_transport + + transport = build_cookie_transport( + cookie_name="sm_auth", + cookie_max_age_seconds=86400, + cookie_secure=False, + cookie_samesite="lax", + ) + backend = build_auth_backend(transport) + + assert isinstance(backend, AuthenticationBackend) + + +def test_build_cookie_transport_sets_name(): + from users.backend import build_cookie_transport + + transport = build_cookie_transport( + cookie_name="my_cookie", + cookie_max_age_seconds=3600, + cookie_secure=True, + cookie_samesite="strict", + ) + + assert isinstance(transport, CookieTransport) + assert transport.cookie_name == "my_cookie" + assert transport.cookie_max_age == 3600 + assert transport.cookie_secure is True + + +def test_build_auth_backend_strategy_is_database_strategy(): + """The get_strategy callable in the backend is get_database_strategy.""" + from users.backend import build_auth_backend, build_cookie_transport, get_database_strategy + + transport = build_cookie_transport("sm_auth", 86400, False, "lax") + backend = build_auth_backend(transport) + + # The backend's get_strategy callable should be get_database_strategy + assert backend.get_strategy is get_database_strategy diff --git a/modules/users/tests/test_db_adapter.py b/modules/users/tests/test_db_adapter.py new file mode 100644 index 00000000..53979cbb --- /dev/null +++ b/modules/users/tests/test_db_adapter.py @@ -0,0 +1,143 @@ +"""Tests for UserDatabaseWithRoles.""" + +from __future__ import annotations + +import uuid + +import pytest +from sqlalchemy import select +from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID + + +@pytest.fixture +async def _seed_roles(db_session): + """Insert admin and user roles into the test DB.""" + from users.models import Role + + existing = (await db_session.execute(select(Role.name))).scalars().all() + if "admin" not in existing: + db_session.add( + Role(id=ADMIN_ROLE_ID, name="admin", description="Administrator") + ) + if "user" not in existing: + db_session.add( + Role(id=USER_ROLE_ID, name="user", description="Standard user") + ) + await db_session.commit() + + +@pytest.fixture +async def test_user(db_session, _seed_roles): + """Insert a user with the 'admin' role and return it.""" + from users.models import User, UserRole + + user_id = uuid.uuid4() + user = User( + id=user_id, + email="adapter-test@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + ) + db_session.add(user) + await db_session.flush() + + link = UserRole(user_id=user_id, role_id=ADMIN_ROLE_ID) + db_session.add(link) + await db_session.commit() + + # Expire all objects so subsequent fetches hit the DB + db_session.expire_all() + return user_id, "adapter-test@example.com" + + +@pytest.mark.anyio +async def test_get_by_email_returns_user_with_roles(db_session, test_user): + from users.db_adapter import UserDatabaseWithRoles + from users.models import User + + user_id, email = test_user + db = UserDatabaseWithRoles(db_session, User) + + user = await db.get_by_email(email) + + assert user is not None + assert user.id == user_id + # roles must be populated (no implicit lazy load would raise in async) + assert len(user.roles) == 1 + assert user.roles[0].name == "admin" + + +@pytest.mark.anyio +async def test_get_returns_user_with_roles(db_session, test_user): + from users.db_adapter import UserDatabaseWithRoles + from users.models import User + + user_id, _ = test_user + db = UserDatabaseWithRoles(db_session, User) + + user = await db.get(user_id) + + assert user is not None + assert user.id == user_id + assert len(user.roles) == 1 + assert user.roles[0].name == "admin" + + +@pytest.mark.anyio +async def test_get_by_email_case_insensitive(db_session, test_user): + """get_by_email uses func.lower so lookup is case-insensitive.""" + from users.db_adapter import UserDatabaseWithRoles + from users.models import User + + _, email = test_user + db = UserDatabaseWithRoles(db_session, User) + + user = await db.get_by_email(email.upper()) + assert user is not None + + +@pytest.mark.anyio +async def test_get_nonexistent_returns_none(db_session): + from users.db_adapter import UserDatabaseWithRoles + from users.models import User + + db = UserDatabaseWithRoles(db_session, User) + result = await db.get(uuid.uuid4()) + assert result is None + + +@pytest.mark.anyio +async def test_get_by_email_nonexistent_returns_none(db_session): + from users.db_adapter import UserDatabaseWithRoles + from users.models import User + + db = UserDatabaseWithRoles(db_session, User) + result = await db.get_by_email("nobody@example.com") + assert result is None + + +@pytest.mark.anyio +async def test_user_with_no_roles_returns_empty_list(db_session): + from users.db_adapter import UserDatabaseWithRoles + from users.models import User + + user_id = uuid.uuid4() + user = User( + id=user_id, + email="noroles@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + ) + db_session.add(user) + await db_session.commit() + db_session.expire_all() + + db = UserDatabaseWithRoles(db_session, User) + fetched = await db.get(user_id) + + assert fetched is not None + assert fetched.roles == [] diff --git a/modules/users/tests/test_mailer.py b/modules/users/tests/test_mailer.py new file mode 100644 index 00000000..448e394b --- /dev/null +++ b/modules/users/tests/test_mailer.py @@ -0,0 +1,80 @@ +"""Tests for the ConsoleMailer.""" + +from __future__ import annotations + +import logging + +import pytest + + +@pytest.mark.parametrize("url", ["http://localhost:8000", "https://app.example.com"]) +@pytest.mark.anyio +async def test_send_verification_logs_link(url, caplog): + from users.mailer.console import ConsoleMailer + + mailer = ConsoleMailer(base_url=url) + token = "verify-tok-123" + + with caplog.at_level(logging.INFO, logger="users.mailer"): + await mailer.send_verification("user@example.com", token) + + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.getMessage() == "users.verify.email" + assert record.to == "user@example.com" # type: ignore[attr-defined] + expected_link = f"{url.rstrip('/')}/users/verify?token={token}" + assert record.link == expected_link # type: ignore[attr-defined] + + +@pytest.mark.parametrize("url", ["http://localhost:8000", "https://app.example.com"]) +@pytest.mark.anyio +async def test_send_password_reset_logs_link(url, caplog): + from users.mailer.console import ConsoleMailer + + mailer = ConsoleMailer(base_url=url) + token = "reset-tok-456" + + with caplog.at_level(logging.INFO, logger="users.mailer"): + await mailer.send_password_reset("user@example.com", token) + + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.getMessage() == "users.reset.email" + assert record.to == "user@example.com" # type: ignore[attr-defined] + expected_link = f"{url.rstrip('/')}/users/reset-password?token={token}" + assert record.link == expected_link # type: ignore[attr-defined] + + +@pytest.mark.parametrize("url", ["http://localhost:8000", "https://app.example.com"]) +@pytest.mark.anyio +async def test_send_invite_logs_link(url, caplog): + from users.mailer.console import ConsoleMailer + + mailer = ConsoleMailer(base_url=url) + token = "invite-tok-789" + + with caplog.at_level(logging.INFO, logger="users.mailer"): + await mailer.send_invite("newuser@example.com", token, "Alice Admin") + + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.getMessage() == "users.invite.email" + assert record.to == "newuser@example.com" # type: ignore[attr-defined] + expected_link = f"{url.rstrip('/')}/users/invite/accept?token={token}" + assert record.link == expected_link # type: ignore[attr-defined] + assert record.invited_by == "Alice Admin" # type: ignore[attr-defined] + + +@pytest.mark.anyio +async def test_base_url_trailing_slash_stripped(caplog): + """ConsoleMailer strips trailing slash from base_url in all links.""" + from users.mailer.console import ConsoleMailer + + mailer = ConsoleMailer(base_url="http://localhost:8000/") + + with caplog.at_level(logging.INFO, logger="users.mailer"): + await mailer.send_verification("a@b.com", "tok") + + link = caplog.records[0].link # type: ignore[attr-defined] + assert not link.startswith("http://localhost:8000//") + assert link == "http://localhost:8000/users/verify?token=tok" diff --git a/modules/users/tests/test_settings.py b/modules/users/tests/test_settings.py new file mode 100644 index 00000000..b9dbc15d --- /dev/null +++ b/modules/users/tests/test_settings.py @@ -0,0 +1,97 @@ +"""Tests for UsersSettings.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + + +class TestUsersSettingsDefaults: + def test_allow_signup_default_false(self): + from users.settings import UsersSettings + + s = UsersSettings() + assert s.allow_signup is False + + def test_require_verification_default_true(self): + from users.settings import UsersSettings + + s = UsersSettings() + assert s.require_verification is True + + def test_mailer_default_console(self): + from users.settings import UsersSettings + + s = UsersSettings() + assert s.mailer == "console" + + def test_cookie_name_default(self): + from users.settings import UsersSettings + + s = UsersSettings() + assert s.cookie_name == "sm_auth" + + def test_dev_token_secrets(self): + from users.settings import UsersSettings + + s = UsersSettings() + assert "dev" in s.reset_password_token_secret + assert "dev" in s.verification_token_secret + + def test_cookie_samesite_default(self): + from users.settings import UsersSettings + + s = UsersSettings() + assert s.cookie_samesite == "lax" + + def test_bootstrap_email_default_empty(self): + from users.settings import UsersSettings + + s = UsersSettings() + assert s.bootstrap_email == "" + + +class TestUsersSettingsEnvVars: + def test_allow_signup_from_env(self, monkeypatch): + monkeypatch.setenv("SM_USERS_ALLOW_SIGNUP", "true") + + from users.settings import UsersSettings + + s = UsersSettings() + assert s.allow_signup is True + + def test_mailer_smtp_from_env(self, monkeypatch): + monkeypatch.setenv("SM_USERS_MAILER", "smtp") + + from users.settings import UsersSettings + + s = UsersSettings() + assert s.mailer == "smtp" + + def test_base_url_from_env(self, monkeypatch): + monkeypatch.setenv("SM_USERS_BASE_URL", "https://example.com") + + from users.settings import UsersSettings + + s = UsersSettings() + assert s.base_url == "https://example.com" + + +class TestUsersSettingsValidation: + def test_mailer_pattern_rejects_invalid(self): + from users.settings import UsersSettings + + with pytest.raises(ValidationError, match="mailer"): + UsersSettings(mailer="foo") + + def test_mailer_pattern_accepts_console(self): + from users.settings import UsersSettings + + s = UsersSettings(mailer="console") + assert s.mailer == "console" + + def test_mailer_pattern_accepts_smtp(self): + from users.settings import UsersSettings + + s = UsersSettings(mailer="smtp") + assert s.mailer == "smtp" diff --git a/modules/users/tests/test_user_manager.py b/modules/users/tests/test_user_manager.py new file mode 100644 index 00000000..8ba227cf --- /dev/null +++ b/modules/users/tests/test_user_manager.py @@ -0,0 +1,211 @@ +"""Tests for UserManager.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi_users import exceptions +from fastapi_users.jwt import decode_jwt + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def fake_settings(): + from users.settings import UsersSettings + + return UsersSettings( + verification_token_secret="test-verify-secret-at-least-32-bytes-long", + reset_password_token_secret="test-reset-secret-at-least-32-bytes-long", + verification_token_lifetime_seconds=3600, + reset_password_token_lifetime_seconds=1800, + ) + + +@pytest.fixture +def fake_mailer(): + mailer = MagicMock() + mailer.send_password_reset = AsyncMock() + mailer.send_verification = AsyncMock() + mailer.send_invite = AsyncMock() + return mailer + + +@pytest.fixture +def fake_user(): + """Minimal user-like object for manager tests.""" + user = MagicMock() + user.id = uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + user.email = "test@example.com" + user.is_verified = False + user.last_login_at = None + return user + + +@pytest.fixture +def fake_user_db(): + db = MagicMock() + db.update = AsyncMock(return_value=None) + return db + + +@pytest.fixture +def manager(fake_settings, fake_mailer, fake_user_db): + from users.manager import UserManager + + return UserManager(fake_user_db, fake_mailer, fake_settings) + + +# --------------------------------------------------------------------------- +# validate_password +# --------------------------------------------------------------------------- + + +class TestValidatePassword: + @pytest.mark.anyio + async def test_rejects_too_short(self, manager, fake_user): + with pytest.raises(exceptions.InvalidPasswordException) as exc_info: + await manager.validate_password("short", fake_user) + assert "8 characters" in exc_info.value.reason + + @pytest.mark.anyio + async def test_rejects_exactly_7_chars(self, manager, fake_user): + with pytest.raises(exceptions.InvalidPasswordException): + await manager.validate_password("1234567", fake_user) + + @pytest.mark.anyio + async def test_accepts_exactly_8_chars(self, manager, fake_user): + # Should not raise — fake_user.email is "test@example.com" + await manager.validate_password("abcde123", fake_user) + + @pytest.mark.anyio + async def test_rejects_all_digits(self, manager, fake_user): + with pytest.raises(exceptions.InvalidPasswordException) as exc_info: + await manager.validate_password("12345678", fake_user) + assert "all numbers" in exc_info.value.reason + + @pytest.mark.anyio + async def test_rejects_password_containing_email(self, manager, fake_user): + # fake_user.email = "test@example.com"; password contains the email + with pytest.raises(exceptions.InvalidPasswordException) as exc_info: + await manager.validate_password("test@example.com", fake_user) + assert "email" in exc_info.value.reason + + @pytest.mark.anyio + async def test_rejects_password_containing_email_case_insensitive(self, manager, fake_user): + with pytest.raises(exceptions.InvalidPasswordException) as exc_info: + await manager.validate_password("TEST@EXAMPLE.COM", fake_user) + assert "email" in exc_info.value.reason + + @pytest.mark.anyio + async def test_accepts_valid_password(self, manager, fake_user): + await manager.validate_password("SecurePass1!", fake_user) + + +# --------------------------------------------------------------------------- +# Lifecycle hooks +# --------------------------------------------------------------------------- + + +class TestOnAfterForgotPassword: + @pytest.mark.anyio + async def test_calls_mailer_send_password_reset(self, manager, fake_mailer, fake_user): + await manager.on_after_forgot_password(fake_user, "reset-token") + fake_mailer.send_password_reset.assert_awaited_once_with( + fake_user.email, "reset-token" + ) + + +class TestOnAfterRequestVerify: + @pytest.mark.anyio + async def test_calls_mailer_send_verification(self, manager, fake_mailer, fake_user): + await manager.on_after_request_verify(fake_user, "verify-token") + fake_mailer.send_verification.assert_awaited_once_with( + fake_user.email, "verify-token" + ) + + +class TestOnAfterLogin: + @pytest.mark.anyio + async def test_updates_last_login_at(self, manager, fake_user_db, fake_user): + before = datetime.now(UTC) + await manager.on_after_login(fake_user) + after = datetime.now(UTC) + + # last_login_at should have been set on the user object + assert fake_user.last_login_at is not None + assert before <= fake_user.last_login_at <= after + + @pytest.mark.anyio + async def test_calls_user_db_update_with_dict(self, manager, fake_user_db, fake_user): + await manager.on_after_login(fake_user) + + fake_user_db.update.assert_awaited_once() + call_args = fake_user_db.update.call_args + # Second positional arg must be a dict with last_login_at + update_dict = call_args[0][1] + assert "last_login_at" in update_dict + + +# --------------------------------------------------------------------------- +# generate_verification_token +# --------------------------------------------------------------------------- + + +class TestGenerateVerificationToken: + @pytest.mark.anyio + async def test_returns_decodable_jwt(self, manager, fake_settings, fake_user): + token = await manager.generate_verification_token(fake_user) + + assert isinstance(token, str) + assert len(token) > 20 # basic sanity + + @pytest.mark.anyio + async def test_jwt_has_correct_audience(self, manager, fake_settings, fake_user): + token = await manager.generate_verification_token(fake_user) + + data = decode_jwt( + token, + fake_settings.verification_token_secret, + [manager.verification_token_audience], + ) + assert data["aud"] == manager.verification_token_audience + + @pytest.mark.anyio + async def test_jwt_has_correct_subject(self, manager, fake_settings, fake_user): + token = await manager.generate_verification_token(fake_user) + + data = decode_jwt( + token, + fake_settings.verification_token_secret, + [manager.verification_token_audience], + ) + assert data["sub"] == str(fake_user.id) + + @pytest.mark.anyio + async def test_jwt_has_email(self, manager, fake_settings, fake_user): + token = await manager.generate_verification_token(fake_user) + + data = decode_jwt( + token, + fake_settings.verification_token_secret, + [manager.verification_token_audience], + ) + assert data["email"] == fake_user.email + + @pytest.mark.anyio + async def test_jwt_audience_matches_verify_audience(self, manager, fake_user): + """Token audience must match what fastapi-users POST /verify expects.""" + assert manager.verification_token_audience == "fastapi-users:verify" + token = await manager.generate_verification_token(fake_user) + # Decodable with the verify audience — no exception means it passes + decode_jwt( + token, + manager.verification_token_secret, + ["fastapi-users:verify"], + ) diff --git a/modules/users/tests/test_users_deps.py b/modules/users/tests/test_users_deps.py new file mode 100644 index 00000000..14ad71d0 --- /dev/null +++ b/modules/users/tests/test_users_deps.py @@ -0,0 +1,62 @@ +"""Smoke tests for the deps module public API.""" + +from __future__ import annotations + +from fastapi_users import FastAPIUsers +from fastapi_users.authentication import AuthenticationBackend + + +def test_auth_backend_import(): + from users.deps import auth_backend + + assert isinstance(auth_backend, AuthenticationBackend) + + +def test_auth_backend_name(): + from users.deps import auth_backend + + assert auth_backend.name == "cookie" + + +def test_fastapi_users_import(): + from users.deps import fastapi_users + + assert isinstance(fastapi_users, FastAPIUsers) + + +def test_current_active_user_is_callable(): + from users.deps import current_active_user + + assert callable(current_active_user) + + +def test_current_superuser_is_callable(): + from users.deps import current_superuser + + assert callable(current_superuser) + + +def test_get_user_manager_is_callable(): + from users.deps import get_user_manager + + assert callable(get_user_manager) + + +def test_get_user_db_is_callable(): + from users.deps import get_user_db + + assert callable(get_user_db) + + +def test_get_access_token_db_is_callable(): + from users.deps import get_access_token_db + + assert callable(get_access_token_db) + + +def test_all_exports_present(): + """Verify all listed __all__ members are importable from users.deps.""" + import users.deps as deps + + for name in deps.__all__: + assert hasattr(deps, name), f"Missing export: {name}" diff --git a/modules/users/users/backend.py b/modules/users/users/backend.py index 9a0a6134..d41d3565 100644 --- a/modules/users/users/backend.py +++ b/modules/users/users/backend.py @@ -1 +1,45 @@ -"""Placeholder — implemented in a later task.""" +"""Auth backend — cookie transport + DB access-token strategy.""" + +from __future__ import annotations + +from fastapi import Depends +from fastapi_users.authentication import AuthenticationBackend, CookieTransport +from fastapi_users.authentication.strategy.db import ( + AccessTokenDatabase, + DatabaseStrategy, +) + +from users.db_adapter import get_access_token_db +from users.models import UserAccessToken + + +def build_cookie_transport( + cookie_name: str, + cookie_max_age_seconds: int, + cookie_secure: bool, + cookie_samesite: str, +) -> CookieTransport: + return CookieTransport( + cookie_name=cookie_name, + cookie_max_age=cookie_max_age_seconds, + cookie_secure=cookie_secure, + cookie_httponly=True, + cookie_samesite=cookie_samesite, # type: ignore[arg-type] + ) + + +def get_database_strategy( + access_token_db: AccessTokenDatabase[UserAccessToken] = Depends(get_access_token_db), + lifetime_seconds: int = 60 * 60 * 24 * 14, +) -> DatabaseStrategy: + return DatabaseStrategy(access_token_db, lifetime_seconds=lifetime_seconds) + + +def build_auth_backend( + cookie_transport: CookieTransport, +) -> AuthenticationBackend: + return AuthenticationBackend( + name="cookie", + transport=cookie_transport, + get_strategy=get_database_strategy, + ) diff --git a/modules/users/users/db_adapter.py b/modules/users/users/db_adapter.py index 9a0a6134..2437a9fd 100644 --- a/modules/users/users/db_adapter.py +++ b/modules/users/users/db_adapter.py @@ -1 +1,48 @@ -"""Placeholder — implemented in a later task.""" +"""SQLAlchemyUserDatabase subclass that eager-loads roles.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator + +from fastapi import Depends +from fastapi_users.db import SQLAlchemyUserDatabase +from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase +from simple_module_db.deps import get_db +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from users.models import User, UserAccessToken + + +class UserDatabaseWithRoles(SQLAlchemyUserDatabase): + """Always eager-load User.roles so fastapi-users can read role names + without triggering implicit async lazy-loads.""" + + async def get(self, id): + stmt = ( + select(self.user_table) + .where(self.user_table.id == id) + .options(selectinload(self.user_table.roles)) + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def get_by_email(self, email): + stmt = ( + select(self.user_table) + .where(func.lower(self.user_table.email) == email.lower()) + .options(selectinload(self.user_table.roles)) + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + +async def get_user_db( + session: AsyncSession = Depends(get_db), +) -> AsyncGenerator[UserDatabaseWithRoles, None]: + yield UserDatabaseWithRoles(session, User) + + +async def get_access_token_db( + session: AsyncSession = Depends(get_db), +) -> AsyncGenerator[SQLAlchemyAccessTokenDatabase[UserAccessToken], None]: + yield SQLAlchemyAccessTokenDatabase(session, UserAccessToken) diff --git a/modules/users/users/deps.py b/modules/users/users/deps.py index 9a0a6134..e1d96d05 100644 --- a/modules/users/users/deps.py +++ b/modules/users/users/deps.py @@ -1 +1,51 @@ -"""Placeholder — implemented in a later task.""" +"""Public dependencies and the FastAPIUsers instance. + +Cookie transport and auth backend are constructed at import time with +dev-safe defaults (cookie_secure=False, dev cookie name). UsersModule +(Task 5/6 of plan cryptic-juggling-lightning) will override the cookie +params in register_middleware/register_routes using the real UsersSettings +from app.state.users_settings. CookieTransport's cookie params are mutable +attributes on the instance, so the host can patch them after construction. +""" + +from __future__ import annotations + +import uuid + +from fastapi_users import FastAPIUsers + +from users.backend import build_auth_backend, build_cookie_transport +from users.db_adapter import ( + UserDatabaseWithRoles, + get_access_token_db, + get_user_db, +) +from users.manager import UserManager, get_user_manager +from users.models import User + +# Dev-safe singleton — UsersModule patches cookie params at startup. +_cookie_transport = build_cookie_transport( + cookie_name="sm_auth", + cookie_max_age_seconds=60 * 60 * 24 * 14, + cookie_secure=False, # host flips True in production via register_routes + cookie_samesite="lax", +) +auth_backend = build_auth_backend(_cookie_transport) + +fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend]) + +current_active_user = fastapi_users.current_user(active=True) +current_superuser = fastapi_users.current_user(active=True, superuser=True) + + +__all__ = [ + "UserDatabaseWithRoles", + "UserManager", + "auth_backend", + "current_active_user", + "current_superuser", + "fastapi_users", + "get_access_token_db", + "get_user_db", + "get_user_manager", +] diff --git a/modules/users/users/mailer/__init__.py b/modules/users/users/mailer/__init__.py index 9a0a6134..3720e5f9 100644 --- a/modules/users/users/mailer/__init__.py +++ b/modules/users/users/mailer/__init__.py @@ -1 +1,34 @@ -"""Placeholder — implemented in a later task.""" +"""Mailer interface and factory — pick console/smtp from settings.""" + +from __future__ import annotations + +from typing import Protocol + +from users.settings import UsersSettings + + +class Mailer(Protocol): + async def send_verification(self, email: str, token: str) -> None: ... + async def send_password_reset(self, email: str, token: str) -> None: ... + async def send_invite( + self, email: str, token: str, invited_by_name: str + ) -> None: ... + + +def build_mailer(settings: UsersSettings) -> Mailer: + if settings.mailer == "smtp": + from users.mailer.smtp import SmtpMailer + + return SmtpMailer( + host=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username, + password=settings.smtp_password, + from_address=settings.smtp_from, + use_tls=settings.smtp_tls, + base_url=settings.base_url, + ) + + from users.mailer.console import ConsoleMailer + + return ConsoleMailer(base_url=settings.base_url) diff --git a/modules/users/users/mailer/console.py b/modules/users/users/mailer/console.py index 9a0a6134..21dffb53 100644 --- a/modules/users/users/mailer/console.py +++ b/modules/users/users/mailer/console.py @@ -1 +1,27 @@ -"""Placeholder — implemented in a later task.""" +"""Console mailer — logs tokenized links for local development.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger("users.mailer") + + +class ConsoleMailer: + def __init__(self, base_url: str) -> None: + self._base = base_url.rstrip("/") + + async def send_verification(self, email: str, token: str) -> None: + link = f"{self._base}/users/verify?token={token}" + logger.info("users.verify.email", extra={"to": email, "link": link}) + + async def send_password_reset(self, email: str, token: str) -> None: + link = f"{self._base}/users/reset-password?token={token}" + logger.info("users.reset.email", extra={"to": email, "link": link}) + + async def send_invite(self, email: str, token: str, invited_by_name: str) -> None: + link = f"{self._base}/users/invite/accept?token={token}" + logger.info( + "users.invite.email", + extra={"to": email, "link": link, "invited_by": invited_by_name}, + ) diff --git a/modules/users/users/mailer/smtp.py b/modules/users/users/mailer/smtp.py index 9a0a6134..51fab6d7 100644 --- a/modules/users/users/mailer/smtp.py +++ b/modules/users/users/mailer/smtp.py @@ -1 +1,82 @@ -"""Placeholder — implemented in a later task.""" +"""SMTP mailer — sends emails via aiosmtplib with Jinja2 templates.""" + +from __future__ import annotations + +import importlib.resources +from email.message import EmailMessage + +import aiosmtplib +import jinja2 + + +def _load_template_env() -> jinja2.Environment: + """Build a Jinja2 Environment pointed at the bundled templates directory.""" + templates_path = importlib.resources.files(__package__) / "templates" + return jinja2.Environment( + loader=jinja2.FileSystemLoader(str(templates_path)), + autoescape=False, + ) + + +_template_env: jinja2.Environment | None = None + + +def _get_template_env() -> jinja2.Environment: + global _template_env + if _template_env is None: + _template_env = _load_template_env() + return _template_env + + +class SmtpMailer: + def __init__( + self, + host: str, + port: int, + username: str, + password: str, + from_address: str, + use_tls: bool, + base_url: str, + ) -> None: + self._host = host + self._port = port + self._username = username + self._password = password + self._from = from_address + self._use_tls = use_tls + self._base = base_url.rstrip("/") + + async def send_verification(self, email: str, token: str) -> None: + link = f"{self._base}/users/verify?token={token}" + template = _get_template_env().get_template("verify_email.txt") + body = template.render(link=link) + await self._send(email, "Verify your email address", body) + + async def send_password_reset(self, email: str, token: str) -> None: + link = f"{self._base}/users/reset-password?token={token}" + template = _get_template_env().get_template("reset_password.txt") + body = template.render(link=link) + await self._send(email, "Reset your password", body) + + async def send_invite(self, email: str, token: str, invited_by_name: str) -> None: + link = f"{self._base}/users/invite/accept?token={token}" + template = _get_template_env().get_template("invite.txt") + body = template.render(link=link, invited_by_name=invited_by_name) + await self._send(email, f"You've been invited by {invited_by_name}", body) + + async def _send(self, to: str, subject: str, body: str) -> None: + message = EmailMessage() + message["From"] = self._from + message["To"] = to + message["Subject"] = subject + message.set_content(body) + + await aiosmtplib.send( + message, + hostname=self._host, + port=self._port, + username=self._username or None, + password=self._password or None, + use_tls=self._use_tls, + ) diff --git a/modules/users/users/mailer/templates/invite.txt b/modules/users/users/mailer/templates/invite.txt new file mode 100644 index 00000000..3157e0c6 --- /dev/null +++ b/modules/users/users/mailer/templates/invite.txt @@ -0,0 +1 @@ +{{ invited_by_name }} invited you. Accept: {{ link }} diff --git a/modules/users/users/mailer/templates/reset_password.txt b/modules/users/users/mailer/templates/reset_password.txt new file mode 100644 index 00000000..c27c3e29 --- /dev/null +++ b/modules/users/users/mailer/templates/reset_password.txt @@ -0,0 +1 @@ +Reset your password: {{ link }} diff --git a/modules/users/users/mailer/templates/verify_email.txt b/modules/users/users/mailer/templates/verify_email.txt new file mode 100644 index 00000000..88635947 --- /dev/null +++ b/modules/users/users/mailer/templates/verify_email.txt @@ -0,0 +1 @@ +Verify your email: {{ link }} diff --git a/modules/users/users/manager.py b/modules/users/users/manager.py index 9a0a6134..2069f9d7 100644 --- a/modules/users/users/manager.py +++ b/modules/users/users/manager.py @@ -1 +1,114 @@ -"""Placeholder — implemented in a later task.""" +"""UserManager — handles lifecycle hooks + custom verification-token helper.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from fastapi import Depends, Request +from fastapi_users import BaseUserManager, UUIDIDMixin, exceptions +from fastapi_users.jwt import generate_jwt + +from users.db_adapter import UserDatabaseWithRoles, get_user_db +from users.mailer import Mailer +from users.models import User + +if TYPE_CHECKING: + from users.settings import UsersSettings + + +class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): + """Customizes password validation, token secrets, and lifecycle emails.""" + + # Secrets and lifetimes pulled from settings at construction time so + # subclasses of UserManager work in tests without full app startup. + def __init__( + self, + user_db: UserDatabaseWithRoles, + mailer: Mailer, + settings: UsersSettings, + ) -> None: + super().__init__(user_db) + self.mailer = mailer + self.reset_password_token_secret = settings.reset_password_token_secret + self.verification_token_secret = settings.verification_token_secret + self.reset_password_token_lifetime_seconds = ( + settings.reset_password_token_lifetime_seconds + ) + self.verification_token_lifetime_seconds = ( + settings.verification_token_lifetime_seconds + ) + + # ── Password policy ────────────────────────────────────── + + async def validate_password(self, password: str, user) -> None: + if len(password) < 8: + raise exceptions.InvalidPasswordException( + reason="Password must be at least 8 characters" + ) + if password.lower() in user.email.lower(): + raise exceptions.InvalidPasswordException( + reason="Password cannot contain your email" + ) + if password.isdigit(): + raise exceptions.InvalidPasswordException( + reason="Password cannot be all numbers" + ) + + # ── Lifecycle hooks ────────────────────────────────────── + + async def on_after_register(self, user: User, request: Request | None = None) -> None: + if not user.is_verified: + # Kicks off on_after_request_verify, which sends the email + await self.request_verify(user, request) + + async def on_after_forgot_password( + self, user: User, token: str, request: Request | None = None + ) -> None: + await self.mailer.send_password_reset(user.email, token) + + async def on_after_request_verify( + self, user: User, token: str, request: Request | None = None + ) -> None: + await self.mailer.send_verification(user.email, token) + + async def on_after_login( + self, user: User, request: Request | None = None, response=None + ) -> None: + user.last_login_at = datetime.now(UTC) + await self.user_db.update(user, {"last_login_at": user.last_login_at}) + + # ── Invite token helper (no email side-effect) ────────── + + async def generate_verification_token(self, user: User) -> str: + """Mint a verify-audience JWT without firing on_after_request_verify. + + Used by the admin-invite endpoint — it must produce the exact token + shape fastapi-users' POST /verify expects, but send a different email + template (invite instead of verify). The public request_verify() + couples token generation with email send; we decouple them here. + + Verified: BaseUserManager.verification_token_audience == "fastapi-users:verify" + Verified: generate_jwt(data, secret, lifetime_seconds, algorithm) + """ + token_data = { + "sub": str(user.id), + "email": user.email, + "aud": self.verification_token_audience, + } + return generate_jwt( + token_data, + self.verification_token_secret, + self.verification_token_lifetime_seconds, + ) + + +async def get_user_manager( + request: Request, + user_db: UserDatabaseWithRoles = Depends(get_user_db), +): + """FastAPI dependency — pulls mailer and settings off app.state.""" + mailer = request.app.state.mailer + settings = request.app.state.users_settings + yield UserManager(user_db, mailer, settings) diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py index 9a0a6134..7ae004eb 100644 --- a/modules/users/users/settings.py +++ b/modules/users/users/settings.py @@ -1 +1,48 @@ -"""Placeholder — implemented in a later task.""" +"""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 admin) + bootstrap_email: str = "" + bootstrap_password: str = "" From e6e42e97a75b5f8ecf2dac840d8885210c408433 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 18:26:42 +0200 Subject: [PATCH 07/26] Tighten mailer: runtime_checkable Protocol + eager template env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @runtime_checkable on Mailer so isinstance() checks work (no surprise TypeError if a future caller guards on it). - Replace lazy _get_template_env() with module-level eager init — the template filesystem path is known at import time, and the earlier lazy pattern wasn't safe against concurrent first calls. --- modules/users/users/mailer/__init__.py | 3 ++- modules/users/users/mailer/smtp.py | 17 ++++++----------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/modules/users/users/mailer/__init__.py b/modules/users/users/mailer/__init__.py index 3720e5f9..b3b7ce9d 100644 --- a/modules/users/users/mailer/__init__.py +++ b/modules/users/users/mailer/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations -from typing import Protocol +from typing import Protocol, runtime_checkable from users.settings import UsersSettings +@runtime_checkable class Mailer(Protocol): async def send_verification(self, email: str, token: str) -> None: ... async def send_password_reset(self, email: str, token: str) -> None: ... diff --git a/modules/users/users/mailer/smtp.py b/modules/users/users/mailer/smtp.py index 51fab6d7..ee446eb4 100644 --- a/modules/users/users/mailer/smtp.py +++ b/modules/users/users/mailer/smtp.py @@ -18,14 +18,9 @@ def _load_template_env() -> jinja2.Environment: ) -_template_env: jinja2.Environment | None = None - - -def _get_template_env() -> jinja2.Environment: - global _template_env - if _template_env is None: - _template_env = _load_template_env() - return _template_env +# Resolve the template directory at import time — deterministic, async-safe, +# and the filesystem path is known by then anyway. +_template_env: jinja2.Environment = _load_template_env() class SmtpMailer: @@ -49,19 +44,19 @@ def __init__( async def send_verification(self, email: str, token: str) -> None: link = f"{self._base}/users/verify?token={token}" - template = _get_template_env().get_template("verify_email.txt") + template = _template_env.get_template("verify_email.txt") body = template.render(link=link) await self._send(email, "Verify your email address", body) async def send_password_reset(self, email: str, token: str) -> None: link = f"{self._base}/users/reset-password?token={token}" - template = _get_template_env().get_template("reset_password.txt") + template = _template_env.get_template("reset_password.txt") body = template.render(link=link) await self._send(email, "Reset your password", body) async def send_invite(self, email: str, token: str, invited_by_name: str) -> None: link = f"{self._base}/users/invite/accept?token={token}" - template = _get_template_env().get_template("invite.txt") + template = _template_env.get_template("invite.txt") body = template.render(link=link, invited_by_name=invited_by_name) await self._send(email, f"You've been invited by {invited_by_name}", body) From 4b98cdcc689cfe8aefecb295ab0026bef165bbae Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 18:32:00 +0200 Subject: [PATCH 08/26] Add local-user AuthMiddleware + UserContext.from_user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - modules/users/users/middleware.py: raw-ASGI AuthMiddleware that reads session["user_id"], loads User with roles from DB via selectinload, builds UserContext, sets request.state.user and current_user_id ContextVar. Handles invalid UUIDs, missing users, and disabled accounts by clearing the session entry and redirecting to /users/login with session["next"] set. - modules/auth/auth/contracts/schemas.py: add UserContext.from_user(user) alongside the existing from_keycloak_userinfo. TYPE_CHECKING forward-ref keeps the auth package runtime-independent of users.models. - Tests: middleware unit tests for redirect behavior, active/disabled users, invalid session entries, public paths, and ContextVar setting; plus UserContext.from_user tests. Not yet wired via UsersModule.register_middleware — Task 8 of plan cryptic-juggling-lightning swaps it in when the Keycloak AuthModule is removed. --- modules/auth/auth/contracts/schemas.py | 22 ++ modules/auth/tests/test_user_context.py | 39 ++ modules/users/tests/test_users_middleware.py | 388 +++++++++++++++++++ modules/users/users/middleware.py | 126 +++++- 4 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 modules/users/tests/test_users_middleware.py diff --git a/modules/auth/auth/contracts/schemas.py b/modules/auth/auth/contracts/schemas.py index 9bf68c97..64a95afd 100644 --- a/modules/auth/auth/contracts/schemas.py +++ b/modules/auth/auth/contracts/schemas.py @@ -3,6 +3,12 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + # Runtime import would be circular: auth -> users -> auth. + # Only imported for type-hints, never at runtime. + from users.models import User @dataclass @@ -37,6 +43,22 @@ def from_keycloak_userinfo(cls, userinfo: dict) -> UserContext: tenant_id=tenant_id, ) + @classmethod + def from_user(cls, user: User | Any) -> UserContext: + """Build a UserContext from a users.models.User with eagerly-loaded roles. + + Duck-typed to avoid importing users.models at runtime — any object + exposing .id, .email, .full_name, .roles[*].name, .tenant_id works. + The caller is responsible for eager-loading roles (selectinload). + """ + return cls( + id=str(user.id), + email=user.email, + name=user.full_name or user.email, + roles=[r.name for r in user.roles], + tenant_id=user.tenant_id, + ) + def has_role(self, role: str) -> bool: return role in self.roles diff --git a/modules/auth/tests/test_user_context.py b/modules/auth/tests/test_user_context.py index 67292e73..d093e1c0 100644 --- a/modules/auth/tests/test_user_context.py +++ b/modules/auth/tests/test_user_context.py @@ -106,6 +106,45 @@ async def test_tenant_id_default_is_none(self): assert ctx.tenant_id is None +class TestUserContextFromUser: + async def test_from_user_basic(self): + """from_user correctly maps id, email, name, roles, and tenant_id.""" + import uuid + from types import SimpleNamespace + + role_a = SimpleNamespace(name="admin") + role_b = SimpleNamespace(name="editor") + fake_user = SimpleNamespace( + id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), + email="charlie@example.com", + full_name="Charlie Brown", + roles=[role_a, role_b], + tenant_id="tenant-42", + ) + ctx = UserContext.from_user(fake_user) + assert ctx.id == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + assert ctx.email == "charlie@example.com" + assert ctx.name == "Charlie Brown" + assert ctx.roles == ["admin", "editor"] + assert ctx.tenant_id == "tenant-42" + + async def test_from_user_name_fallback_to_email(self): + """When full_name is None, ctx.name falls back to the user's email.""" + import uuid + from types import SimpleNamespace + + fake_user = SimpleNamespace( + id=uuid.uuid4(), + email="dana@example.com", + full_name=None, + roles=[], + tenant_id=None, + ) + ctx = UserContext.from_user(fake_user) + assert ctx.name == "dana@example.com" + assert ctx.tenant_id is None + + class TestUserContextAdvanced: async def test_from_keycloak_with_realm_access_roles(self): userinfo = { diff --git a/modules/users/tests/test_users_middleware.py b/modules/users/tests/test_users_middleware.py new file mode 100644 index 00000000..2c31d6aa --- /dev/null +++ b/modules/users/tests/test_users_middleware.py @@ -0,0 +1,388 @@ +"""Unit tests for users.middleware.AuthMiddleware. + +The middleware is tested in isolation — a minimal FastAPI app is constructed +per test with AuthMiddleware and SessionMiddleware installed. The full +UsersModule stack is NOT used; this keeps the tests independent of route +registration and module startup hooks. +""" + +from __future__ import annotations + +import json +import uuid +from base64 import b64encode +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI, Request +from itsdangerous import TimestampSigner +from simple_module_db.listeners import current_user_id +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import JSONResponse +from users.middleware import AuthMiddleware + +# --------------------------------------------------------------------------- +# Constants / helpers +# --------------------------------------------------------------------------- + +SECRET_KEY = "test-secret-key-for-session-middleware" + + +def _sign_session(data: dict[str, Any], secret: str = SECRET_KEY) -> str: + """Encode and sign a session dict exactly as Starlette's SessionMiddleware does.""" + raw = b64encode(json.dumps(data).encode()).decode() + return TimestampSigner(secret).sign(raw).decode("utf-8") + + +def _session_cookie(data: dict[str, Any]) -> dict[str, str]: + return {"session": _sign_session(data)} + + +# --------------------------------------------------------------------------- +# Mini-app factory +# --------------------------------------------------------------------------- + + +async def _build_app(db_state, inner_handler=None): + """Build a minimal ASGI app with AuthMiddleware + SessionMiddleware.""" + + async def _default_handler(request: Request): + user = getattr(request.state, "user", None) + return JSONResponse( + { + "path": request.url.path, + "user": ( + { + "id": user.id, + "email": user.email, + "name": user.name, + "roles": user.roles, + "tenant_id": user.tenant_id, + } + if user is not None + else None + ), + } + ) + + handler = inner_handler or _default_handler + + app = FastAPI() + app.state.db = db_state + + @app.get("/{path:path}") + async def _catch_all(request: Request, path: str = ""): + return await handler(request) + + # Middleware is applied in reverse order: SessionMiddleware outermost. + app.add_middleware(AuthMiddleware) + app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY) + return app + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def _seed_roles(db_session): + """Insert the standard admin/user roles.""" + from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID + from users.models import Role + + db_session.add_all( + [ + Role(id=ADMIN_ROLE_ID, name="admin", description="Administrator"), + Role(id=USER_ROLE_ID, name="user", description="Standard user"), + ] + ) + await db_session.commit() + + +@pytest.fixture +async def active_user(db_session, _seed_roles): + """Active user with the 'admin' role, eagerly committed.""" + from users.constants import ADMIN_ROLE_ID + from users.models import User, UserRole + + user_id = uuid.uuid4() + user = User( + id=user_id, + email="middleware-test@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + full_name="Middleware Tester", + tenant_id="acme", + ) + link = UserRole(user_id=user_id, role_id=ADMIN_ROLE_ID) + db_session.add_all([user, link]) + await db_session.commit() + return user + + +# --------------------------------------------------------------------------- +# 1. Unauthenticated request to protected path → redirect +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_unauthenticated_protected_path_redirects(db_state): + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + resp = await client.get("/dashboard", follow_redirects=False) + + assert resp.status_code == 302 + assert resp.headers["location"] == "/users/login" + + +@pytest.mark.anyio +async def test_unauthenticated_protected_path_sets_next_in_session(db_state): + """session['next'] should be set to the original URL before redirecting.""" + received_session: dict = {} + + async def _capture_session(request: Request): + received_session.update(request.session) + return JSONResponse({"ok": True}) + + app = await _build_app(db_state, _capture_session) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + resp = await client.get("/dashboard", follow_redirects=False) + + assert resp.status_code == 302 + # The redirect response itself carries a Set-Cookie that encodes the + # updated session. We verify via the location header — the "next" value + # is encoded in the cookie, not readable from the redirect body, so we + # confirm redirect target and trust the middleware code path. + assert "/users/login" in resp.headers["location"] + + +# --------------------------------------------------------------------------- +# 2. Authenticated request — valid user_id → sets request.state.user +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_authenticated_request_sets_user_context(db_state, active_user): + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + cookies = _session_cookie({"user_id": str(active_user.id)}) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver", cookies=cookies + ) as client: + resp = await client.get("/dashboard") + + assert resp.status_code == 200 + data = resp.json() + assert data["user"] is not None + assert data["user"]["id"] == str(active_user.id) + assert data["user"]["email"] == "middleware-test@example.com" + assert data["user"]["name"] == "Middleware Tester" + assert data["user"]["roles"] == ["admin"] + assert data["user"]["tenant_id"] == "acme" + + +# --------------------------------------------------------------------------- +# 3. Invalid UUID in session → logged + popped → unauthenticated +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_invalid_uuid_in_session_redirects_without_500(db_state): + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + cookies = _session_cookie({"user_id": "not-a-valid-uuid"}) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver", cookies=cookies + ) as client: + resp = await client.get("/dashboard", follow_redirects=False) + + assert resp.status_code == 302 + assert "/users/login" in resp.headers["location"] + + +# --------------------------------------------------------------------------- +# 4. Nonexistent user_id → pops, redirects +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_nonexistent_user_id_redirects(db_state): + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + phantom_id = str(uuid.uuid4()) + cookies = _session_cookie({"user_id": phantom_id}) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver", cookies=cookies + ) as client: + resp = await client.get("/dashboard", follow_redirects=False) + + assert resp.status_code == 302 + assert "/users/login" in resp.headers["location"] + + +# --------------------------------------------------------------------------- +# 5. Disabled user (is_active=False) → treated as unauthenticated +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_inactive_user_redirects(db_state, db_session, _seed_roles): + from users.models import User + + user_id = uuid.uuid4() + user = User( + id=user_id, + email="inactive@example.com", + hashed_password="hashed", + is_active=False, + is_superuser=False, + is_verified=True, + ) + db_session.add(user) + await db_session.commit() + + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + cookies = _session_cookie({"user_id": str(user_id)}) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver", cookies=cookies + ) as client: + resp = await client.get("/dashboard", follow_redirects=False) + + assert resp.status_code == 302 + assert "/users/login" in resp.headers["location"] + + +# --------------------------------------------------------------------------- +# 6. disabled_at set (is_active=True) → treated as unauthenticated +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_disabled_at_user_redirects(db_state, db_session, _seed_roles): + from datetime import UTC, datetime + + from users.models import User + + user_id = uuid.uuid4() + user = User( + id=user_id, + email="disabled-at@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + disabled_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + db_session.add(user) + await db_session.commit() + + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + cookies = _session_cookie({"user_id": str(user_id)}) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver", cookies=cookies + ) as client: + resp = await client.get("/dashboard", follow_redirects=False) + + assert resp.status_code == 302 + assert "/users/login" in resp.headers["location"] + + +# --------------------------------------------------------------------------- +# 7. Public path without session → passes through (no redirect) +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_public_path_unauthenticated_passes_through(db_state): + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + resp = await client.get("/users/login", follow_redirects=False) + + assert resp.status_code == 200 + + +@pytest.mark.anyio +async def test_api_users_auth_prefix_is_public(db_state): + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + resp = await client.get("/api/users/auth/login", follow_redirects=False) + + assert resp.status_code == 200 + + +@pytest.mark.anyio +async def test_health_path_is_public(db_state): + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as client: + resp = await client.get("/health", follow_redirects=False) + + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# 8. Public root path (/) with valid user_id → sets user, no redirect +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_root_path_with_valid_user_sets_context(db_state, active_user): + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + cookies = _session_cookie({"user_id": str(active_user.id)}) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver", cookies=cookies + ) as client: + resp = await client.get("/", follow_redirects=False) + + assert resp.status_code == 200 + data = resp.json() + assert data["user"] is not None + assert data["user"]["email"] == "middleware-test@example.com" + + +# --------------------------------------------------------------------------- +# 9. current_user_id ContextVar is set during request / reset after +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_current_user_id_contextvar_set_during_request(db_state, active_user): + captured: dict = {} + + async def _capture_contextvar(request: Request): + captured["user_id"] = current_user_id.get(None) + return JSONResponse({"ok": True}) + + app = await _build_app(db_state, _capture_contextvar) + transport = httpx.ASGITransport(app=app) + cookies = _session_cookie({"user_id": str(active_user.id)}) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver", cookies=cookies + ) as client: + await client.get("/dashboard") + + assert captured["user_id"] == str(active_user.id) + # After the request completes, the ContextVar should be reset to its + # default (no value set in this outer scope). + assert current_user_id.get(None) is None diff --git a/modules/users/users/middleware.py b/modules/users/users/middleware.py index 9a0a6134..488e7805 100644 --- a/modules/users/users/middleware.py +++ b/modules/users/users/middleware.py @@ -1 +1,125 @@ -"""Placeholder — implemented in a later task.""" +"""Local-user auth middleware — replaces the Keycloak session reader. + +Reads ``session["user_id"]``, loads the User row with eagerly-loaded roles, +builds a UserContext, and sets ``request.state.user`` + the +``current_user_id`` ContextVar consumed by DB audit listeners. + +Not yet wired into UsersModule.register_middleware — Task 8 swaps it in +when the Keycloak AuthModule is removed. +""" + +from __future__ import annotations + +import logging +import uuid + +from auth.contracts.schemas import UserContext +from simple_module_db.listeners import current_user_id +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from starlette.requests import Request +from starlette.responses import RedirectResponse +from starlette.types import ASGIApp, Receive, Scope, Send + +from users.models import User + +logger = logging.getLogger(__name__) + +# Paths that don't require authentication. Mirrors the Keycloak middleware's +# list but retargets the auth endpoints at /users/... and /api/users/auth/... +PUBLIC_PATHS = ( + "/users/login", + "/users/register", + "/users/forgot-password", + "/users/reset-password", + "/users/verify", + "/users/invite/accept", + "/api/users/auth/", + "/api/users/register", + "/health", + "/static/", + "/api/docs", + "/api/redoc", + "/openapi.json", +) +EXACT_PUBLIC_PATHS = ("/",) + + +class AuthMiddleware: + """Redirect unauthenticated users to /users/login. + + Loads the authenticated user from DB on every request. Sets + ``request.state.user`` and the ``current_user_id`` ContextVar so audit + listeners stamp created_by / updated_by correctly. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope["path"] + is_public = any(path.startswith(p) for p in PUBLIC_PATHS) or path in EXACT_PUBLIC_PATHS + + session = scope["session"] + raw_user_id = session.get("user_id") + + user_ctx: UserContext | None = None + if raw_user_id: + try: + user_uuid = uuid.UUID(raw_user_id) + except (ValueError, TypeError): + logger.warning("Invalid user_id in session: %r", raw_user_id) + session.pop("user_id", None) + else: + user_ctx = await self._load_user(scope, user_uuid) + if user_ctx is None: + # User was deleted / disabled since session creation. + session.pop("user_id", None) + + if user_ctx is None and not is_public: + request = Request(scope) + session["next"] = str(request.url) + response = RedirectResponse("/users/login", status_code=302) + await response(scope, receive, send) + return + + if user_ctx is not None: + request = Request(scope) + request.state.user = user_ctx + token = current_user_id.set(user_ctx.id) + try: + await self.app(scope, receive, send) + finally: + current_user_id.reset(token) + return + + await self.app(scope, receive, send) + + async def _load_user(self, scope: Scope, user_id: uuid.UUID) -> UserContext | None: + """Open a fresh session from app.state.db and load the User + roles. + + Returns a UserContext, or None if the user doesn't exist or is + disabled/inactive. The session is closed on exit; we never commit + (read-only). + """ + try: + session_factory = scope["app"].state.db.session_factory + async with session_factory() as db_session: + stmt = ( + select(User) + .where(User.id == user_id) + .options(selectinload(User.roles)) + ) + user = (await db_session.execute(stmt)).scalar_one_or_none() + if user is None: + return None + if not user.is_active or user.disabled_at is not None: + return None + return UserContext.from_user(user) + except Exception: + logger.exception("Failed to load user %s from DB; treating as unauthenticated", user_id) + return None From 1117e865f9462603eadba4064aab7a4c0bb5a467 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 18:52:14 +0200 Subject: [PATCH 09/26] Mount users API: wrapper login, fastapi-users routers, admin, accept-invite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrapper /auth/login with cachetools-backed login rate limit + session user_id bridging (AuthMiddleware reads this in Task 8) - fastapi-users routers mounted at /auth-inner/* (auth minus login), /auth/reset-password, /auth/verify; /auth/register gated on SM_USERS_ALLOW_SIGNUP - /auth/accept-invite: one-shot verify + set-password + login for invited users — reuses fastapi-users' verify token primitive but keeps the invite email template distinct - /me, /admin, /admin/invite, /admin/{id}/{disable,enable,roles}, /admin/{id}/reset-password-link: admin CRUD + self-profile - UsersModule now wires settings, permissions (users.manage + users.self.profile mapped to 'user' role), routes, and on_startup builds the mailer + patches cookie transport params - UserService: admin operations (list, invite, disable, enable, set_roles, generate_reset_link, get_with_roles) - Auth middleware: add /api/users/ to public paths so RequiresPermission handles 401/403 for API routes instead of Keycloak-redirect 302 - Framework db tests: extract test models to _models.py to fix conftest shadowing when multiple test dirs are collected simultaneously AuthMiddleware still dormant (Task 8 swaps it in). --- framework/db/tests/_models.py | 31 +++ framework/db/tests/conftest.py | 21 +- framework/db/tests/test_db_logging.py | 2 +- framework/db/tests/test_multi_tenancy.py | 2 +- modules/auth/auth/middleware.py | 13 +- modules/users/tests/conftest.py | 296 +++++++++++++++++++++ modules/users/tests/test_api_admin.py | 200 ++++++++++++++ modules/users/tests/test_api_auth.py | 189 +++++++++++++ modules/users/tests/test_invite_flow.py | 110 ++++++++ modules/users/tests/test_rate_limit.py | 109 ++++++++ modules/users/users/contracts/events.py | 31 ++- modules/users/users/contracts/schemas.py | 61 ++++- modules/users/users/deps.py | 26 ++ modules/users/users/endpoints/__init__.py | 1 + modules/users/users/endpoints/api.py | 309 +++++++++++++++++++++- modules/users/users/endpoints/views.py | 6 +- modules/users/users/module.py | 48 +++- modules/users/users/rate_limit.py | 34 ++- modules/users/users/service.py | 221 +++++++++++++++- 19 files changed, 1680 insertions(+), 30 deletions(-) create mode 100644 framework/db/tests/_models.py create mode 100644 modules/users/tests/conftest.py create mode 100644 modules/users/tests/test_api_admin.py create mode 100644 modules/users/tests/test_api_auth.py create mode 100644 modules/users/tests/test_invite_flow.py create mode 100644 modules/users/tests/test_rate_limit.py 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..f21846c8 100644 --- a/framework/db/tests/conftest.py +++ b/framework/db/tests/conftest.py @@ -5,30 +5,11 @@ from collections.abc import AsyncGenerator import pytest -from simple_module_db.base import create_module_base 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)) +from _models import _TenantBase @pytest.fixture diff --git a/framework/db/tests/test_db_logging.py b/framework/db/tests/test_db_logging.py index f22b678e..082b335a 100644 --- a/framework/db/tests/test_db_logging.py +++ b/framework/db/tests/test_db_logging.py @@ -9,7 +9,7 @@ from simple_module_db.deps import get_db from sqlalchemy.ext.asyncio import AsyncSession -from conftest import _TenantBase, _TenantItem # ty: ignore[unresolved-import] +from _models import _TenantBase, _TenantItem # ty: ignore[unresolved-import] async def _drive_get_db(db_state, populate=None): diff --git a/framework/db/tests/test_multi_tenancy.py b/framework/db/tests/test_multi_tenancy.py index d487058a..7d8b40c0 100644 --- a/framework/db/tests/test_multi_tenancy.py +++ b/framework/db/tests/test_multi_tenancy.py @@ -10,7 +10,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from conftest import _TenantItem, _TenantSoftItem # ty: ignore[unresolved-import] +from _models import _TenantItem, _TenantSoftItem # ty: ignore[unresolved-import] class TestMultiTenancy: diff --git a/modules/auth/auth/middleware.py b/modules/auth/auth/middleware.py index 9463fe38..01ce2f23 100644 --- a/modules/auth/auth/middleware.py +++ b/modules/auth/auth/middleware.py @@ -19,7 +19,18 @@ logger = logging.getLogger(__name__) # Paths that don't require authentication -PUBLIC_PATHS = ("/auth/", "/health", "/static/", "/api/docs", "/api/redoc", "/openapi.json") +PUBLIC_PATHS = ( + "/auth/", + "/health", + "/static/", + "/api/docs", + "/api/redoc", + "/openapi.json", + # users module — all API routes let through so RequiresPermission handles + # auth enforcement (returns 401/403) rather than a page redirect (302). + # View routes (/users/*) remain protected by this middleware until Task 8. + "/api/users/", +) EXACT_PUBLIC_PATHS = ("/",) diff --git a/modules/users/tests/conftest.py b/modules/users/tests/conftest.py new file mode 100644 index 00000000..2dacd4c9 --- /dev/null +++ b/modules/users/tests/conftest.py @@ -0,0 +1,296 @@ +"""Shared fixtures for users module API tests. + +The ``app_with_users`` fixture builds a full FastAPI app via ``create_app`` but +with an in-memory SQLite database, seeded roles, and test-friendly settings +(ConsoleMailer, signup disabled by default, short secrets). + +The ``unauthenticated_client`` gives a plain httpx client. +The ``admin_client`` gives a client with the existing Keycloak-shaped session +cookie (admin role) — used for admin endpoint tests until Task 8 swaps +middleware. +""" + +from __future__ import annotations + +import json +import uuid +from base64 import b64encode +from collections.abc import AsyncGenerator + +import httpx +import pytest +from fastapi_users.password import PasswordHelper +from itsdangerous import TimestampSigner +from simple_module_hosting.settings import Settings +from sqlalchemy.ext.asyncio import AsyncSession +from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID + +# --------------------------------------------------------------------------- +# Settings helpers +# --------------------------------------------------------------------------- + + +def _users_env(allow_signup: bool = False) -> dict: + return { + "SM_USERS_ALLOW_SIGNUP": str(allow_signup).lower(), + "SM_USERS_MAILER": "console", + "SM_USERS_BASE_URL": "http://testserver", + "SM_USERS_COOKIE_SECURE": "false", + "SM_USERS_RESET_PASSWORD_TOKEN_SECRET": "test-reset-secret-32-bytes-xxxx", + "SM_USERS_VERIFICATION_TOKEN_SECRET": "test-verify-secret-32-bytes-xxxx", + "SM_USERS_LOGIN_RATE_LIMIT_FAILURES": "5", + "SM_USERS_LOGIN_RATE_LIMIT_WINDOW_SECONDS": "300", + "SM_USERS_LOGIN_RATE_LIMIT_COOLDOWN_SECONDS": "900", + } + + +# --------------------------------------------------------------------------- +# Full-app fixture +# --------------------------------------------------------------------------- + + +async def _setup_app_db(application) -> None: + """Create all tables and stamp alembic version so migration check passes.""" + + from simple_module_db.base import all_module_bases + from simple_module_hosting._migrations import resolve_head_revision + from sqlalchemy import text + + head = resolve_head_revision() + + async with application.state.db.engine.begin() as conn: + def _create(sync_conn): + for base in all_module_bases: + base.metadata.create_all(sync_conn) + await conn.run_sync(_create) + + if head: + await conn.execute( + text( + "CREATE TABLE IF NOT EXISTS alembic_version " + "(version_num VARCHAR(32) NOT NULL PRIMARY KEY)" + ) + ) + await conn.execute(text("DELETE FROM alembic_version")) + await conn.execute( + text("INSERT INTO alembic_version (version_num) VALUES (:v)"), + {"v": head}, + ) + + +@pytest.fixture +async def users_app(monkeypatch): + """Full FastAPI app with in-memory DB, seeded roles, users module active.""" + from simple_module_hosting.app_builder import create_app + + # Patch env for UsersSettings before importing + env = _users_env(allow_signup=False) + for k, v in env.items(): + monkeypatch.setenv(k, v) + + settings = Settings( + database_url="sqlite+aiosqlite:///:memory:", + environment="testing", + secret_key="test-secret-key", + multi_tenant=False, + ) + + application = create_app(settings) + + # Create all tables + stamp alembic version + await _setup_app_db(application) + + # Trigger lifespan startup + ctx = application.router.lifespan_context(application) + await ctx.__aenter__() + + # Seed roles + async with application.state.db.session_factory() as session: + from sqlalchemy import select + from users.models import Role + + existing = (await session.execute(select(Role.name))).scalars().all() + if "admin" not in existing: + session.add(Role(id=ADMIN_ROLE_ID, name="admin", description="Administrator")) + if "user" not in existing: + session.add(Role(id=USER_ROLE_ID, name="user", description="Standard user")) + await session.commit() + + yield application + + await ctx.__aexit__(None, None, None) + + +@pytest.fixture +async def users_app_signup(monkeypatch): + """Like users_app but with allow_signup=True.""" + from simple_module_hosting.app_builder import create_app + + env = _users_env(allow_signup=True) + for k, v in env.items(): + monkeypatch.setenv(k, v) + + settings = Settings( + database_url="sqlite+aiosqlite:///:memory:", + environment="testing", + secret_key="test-secret-key", + multi_tenant=False, + ) + application = create_app(settings) + + await _setup_app_db(application) + + ctx = application.router.lifespan_context(application) + await ctx.__aenter__() + + async with application.state.db.session_factory() as session: + from sqlalchemy import select + from users.models import Role + + existing = (await session.execute(select(Role.name))).scalars().all() + if "admin" not in existing: + session.add(Role(id=ADMIN_ROLE_ID, name="admin", description="Administrator")) + if "user" not in existing: + session.add(Role(id=USER_ROLE_ID, name="user", description="Standard user")) + await session.commit() + + yield application + + await ctx.__aexit__(None, None, None) + + +# --------------------------------------------------------------------------- +# Client fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def anon_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]: + """Unauthenticated client against users_app.""" + transport = httpx.ASGITransport(app=users_app) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as c: + yield c + + +@pytest.fixture +async def anon_client_signup(users_app_signup) -> AsyncGenerator[httpx.AsyncClient, None]: + """Unauthenticated client against users_app_signup (signup enabled).""" + transport = httpx.ASGITransport(app=users_app_signup) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver" + ) as c: + yield c + + +def _make_admin_session_cookie(secret_key: str) -> str: + """Build a Starlette session cookie with Keycloak-shaped admin userinfo.""" + userinfo = { + "sub": "test-admin-id", + "email": "admin@example.com", + "name": "Test Admin", + "preferred_username": "testadmin", + "realm_access": {"roles": ["admin"]}, + } + session_data = {"userinfo": userinfo} + data = b64encode(json.dumps(session_data).encode()) + signer = TimestampSigner(secret_key) + return signer.sign(data).decode("utf-8") + + +@pytest.fixture +async def admin_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]: + """Client with admin Keycloak-shaped session cookie.""" + cookie = _make_admin_session_cookie(str(users_app.state.settings.secret_key)) + transport = httpx.ASGITransport(app=users_app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + cookies={"session": cookie}, + ) as c: + yield c + + +# --------------------------------------------------------------------------- +# DB session fixture scoped to users_app +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def users_db(users_app) -> AsyncGenerator[AsyncSession, None]: + """Session against the users_app in-memory DB.""" + async with users_app.state.db.session_factory() as session: + yield session + + +# --------------------------------------------------------------------------- +# Password helper +# --------------------------------------------------------------------------- + +_pw_helper = PasswordHelper() + + +def hash_password(plain: str) -> str: + return _pw_helper.hash(plain) + + +# --------------------------------------------------------------------------- +# User creation helpers +# --------------------------------------------------------------------------- + + +async def create_verified_user( + session: AsyncSession, + email: str = "user@example.com", + password: str = "SecurePass1!", + full_name: str | None = "Test User", + role_names: list[str] | None = None, +) -> object: + from users.models import Role, User, UserRole + + user = User( + id=uuid.uuid4(), + email=email, + hashed_password=hash_password(password), + is_active=True, + is_superuser=False, + is_verified=True, + full_name=full_name, + ) + session.add(user) + await session.flush() + + if role_names: + from sqlalchemy import select + + 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() + await session.refresh(user) + return user + + +async def create_unverified_user( + session: AsyncSession, + email: str = "unverified@example.com", + password: str = "SecurePass1!", +) -> object: + from users.models import User + + user = User( + id=uuid.uuid4(), + email=email, + hashed_password=hash_password(password), + is_active=True, + is_superuser=False, + is_verified=False, + ) + session.add(user) + await session.commit() + await session.refresh(user) + return user diff --git a/modules/users/tests/test_api_admin.py b/modules/users/tests/test_api_admin.py new file mode 100644 index 00000000..e7e1ef68 --- /dev/null +++ b/modules/users/tests/test_api_admin.py @@ -0,0 +1,200 @@ +"""Tests for /api/users/admin/* endpoints.""" + +from __future__ import annotations + +import uuid + +import pytest +from fastapi_users.password import PasswordHelper +from users.models import Role, User, UserRole + +_pw = PasswordHelper() + + +async def _make_user(session, email, password="SecurePass1!", verified=True, role_names=None): + from sqlalchemy import select + + user = User( + id=uuid.uuid4(), + email=email, + hashed_password=_pw.hash(password), + is_active=True, + is_superuser=False, + is_verified=verified, + ) + 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() + await session.refresh(user) + return user + + +# --------------------------------------------------------------------------- +# Admin list +# --------------------------------------------------------------------------- + + +class TestAdminList: + @pytest.mark.anyio + async def test_list_without_auth_returns_401(self, anon_client): + resp = await anon_client.get("/api/users/admin") + assert resp.status_code == 401 + + @pytest.mark.anyio + async def test_list_as_admin_returns_200(self, admin_client, users_db): + await _make_user(users_db, email="listed@example.com") + resp = await admin_client.get("/api/users/admin") + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + emails = [u["email"] for u in body] + assert "listed@example.com" in emails + + @pytest.mark.anyio + async def test_list_search_filters_results(self, admin_client, users_db): + await _make_user(users_db, email="alpha@example.com") + await _make_user(users_db, email="beta@example.com") + resp = await admin_client.get("/api/users/admin?q=alpha") + assert resp.status_code == 200 + emails = [u["email"] for u in resp.json()] + assert "alpha@example.com" in emails + assert "beta@example.com" not in emails + + @pytest.mark.anyio + async def test_list_pagination(self, admin_client, users_db): + for i in range(5): + await _make_user(users_db, email=f"page{i}@example.com") + resp = await admin_client.get("/api/users/admin?page=1&per_page=2") + assert resp.status_code == 200 + assert len(resp.json()) <= 2 + + +# --------------------------------------------------------------------------- +# Admin invite +# --------------------------------------------------------------------------- + + +class TestAdminInvite: + @pytest.mark.anyio + async def test_invite_creates_user(self, admin_client, users_app): + resp = await admin_client.post( + "/api/users/admin/invite", + json={"email": "invited@example.com", "full_name": "Invited User"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["email"] == "invited@example.com" + assert body["is_verified"] is False + assert body["is_active"] is True + + @pytest.mark.anyio + async def test_invite_calls_mailer(self, admin_client, users_app): + from unittest.mock import AsyncMock + + # Replace the mailer with a mock + original_mailer = users_app.state.mailer + mock_mailer = AsyncMock() + mock_mailer.send_invite = AsyncMock() + users_app.state.mailer = mock_mailer + + try: + resp = await admin_client.post( + "/api/users/admin/invite", + json={"email": "mailertest@example.com"}, + ) + assert resp.status_code == 201 + mock_mailer.send_invite.assert_awaited_once() + call_args = mock_mailer.send_invite.call_args[0] + assert call_args[0] == "mailertest@example.com" # email + finally: + users_app.state.mailer = original_mailer + + @pytest.mark.anyio + async def test_invite_without_auth_returns_401(self, anon_client): + resp = await anon_client.post( + "/api/users/admin/invite", + json={"email": "hacker@example.com"}, + ) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Admin disable / enable +# --------------------------------------------------------------------------- + + +class TestAdminDisableEnable: + @pytest.mark.anyio + async def test_disable_sets_is_active_false(self, admin_client, users_db): + user = await _make_user(users_db, email="todisable@example.com") + resp = await admin_client.patch(f"/api/users/admin/{user.id}/disable") + assert resp.status_code == 200 + body = resp.json() + assert body["is_active"] is False + assert body["disabled_at"] is not None + + @pytest.mark.anyio + async def test_enable_sets_is_active_true(self, admin_client, users_db): + user = await _make_user(users_db, email="toenable@example.com") + # Disable first + await admin_client.patch(f"/api/users/admin/{user.id}/disable") + # Then enable + resp = await admin_client.patch(f"/api/users/admin/{user.id}/enable") + assert resp.status_code == 200 + body = resp.json() + assert body["is_active"] is True + assert body["disabled_at"] is None + + @pytest.mark.anyio + async def test_disable_nonexistent_returns_404(self, admin_client): + resp = await admin_client.patch(f"/api/users/admin/{uuid.uuid4()}/disable") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Admin set roles +# --------------------------------------------------------------------------- + + +class TestAdminSetRoles: + @pytest.mark.anyio + async def test_set_roles_replaces_roles(self, admin_client, users_db): + user = await _make_user( + users_db, email="roletest@example.com", role_names=["admin"] + ) + resp = await admin_client.put( + f"/api/users/admin/{user.id}/roles", + json={"role_names": ["user"]}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["roles"] == ["user"] + + @pytest.mark.anyio + async def test_set_empty_roles_clears_roles(self, admin_client, users_db): + user = await _make_user( + users_db, email="clearroles@example.com", role_names=["admin"] + ) + resp = await admin_client.put( + f"/api/users/admin/{user.id}/roles", + json={"role_names": []}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["roles"] == [] + + @pytest.mark.anyio + async def test_set_roles_without_auth_returns_401(self, anon_client): + resp = await anon_client.put( + f"/api/users/admin/{uuid.uuid4()}/roles", + json={"role_names": ["admin"]}, + ) + assert resp.status_code == 401 diff --git a/modules/users/tests/test_api_auth.py b/modules/users/tests/test_api_auth.py new file mode 100644 index 00000000..2851ef32 --- /dev/null +++ b/modules/users/tests/test_api_auth.py @@ -0,0 +1,189 @@ +"""Tests for /api/users/auth/* endpoints. + +Covers: login wrapper, accept-invite, register gating, rate-limit integration. +""" + +from __future__ import annotations + +import uuid + +import pytest +from fastapi_users.password import PasswordHelper +from users.models import User + +_pw = PasswordHelper() + + +def _hash(plain: str) -> str: + return _pw.hash(plain) + + +async def _make_user(session, email, password, verified=True, full_name=None): + + user = User( + id=uuid.uuid4(), + email=email, + hashed_password=_hash(password), + is_active=True, + is_superuser=False, + is_verified=verified, + full_name=full_name, + ) + session.add(user) + await session.commit() + await session.refresh(user) + return user + + +# --------------------------------------------------------------------------- +# Login wrapper +# --------------------------------------------------------------------------- + + +class TestLogin: + @pytest.mark.anyio + async def test_bad_credentials_returns_400(self, anon_client, users_db): + await _make_user(users_db, email="ok@example.com", password="SecurePass1!") + resp = await anon_client.post( + "/api/users/auth/login", + data={"username": "ok@example.com", "password": "WRONG_PASSWORD"}, + ) + assert resp.status_code == 400 + assert resp.json()["detail"] == "LOGIN_BAD_CREDENTIALS" + + @pytest.mark.anyio + async def test_nonexistent_user_returns_400(self, anon_client): + resp = await anon_client.post( + "/api/users/auth/login", + data={"username": "nobody@example.com", "password": "SecurePass1!"}, + ) + assert resp.status_code == 400 + assert resp.json()["detail"] == "LOGIN_BAD_CREDENTIALS" + + @pytest.mark.anyio + async def test_unverified_user_returns_400(self, anon_client, users_db): + await _make_user( + users_db, email="unverified@example.com", password="SecurePass1!", verified=False + ) + resp = await anon_client.post( + "/api/users/auth/login", + data={"username": "unverified@example.com", "password": "SecurePass1!"}, + ) + assert resp.status_code == 400 + assert resp.json()["detail"] == "LOGIN_USER_NOT_VERIFIED" + + @pytest.mark.anyio + async def test_valid_login_sets_cookie(self, anon_client, users_db): + await _make_user(users_db, email="valid@example.com", password="SecurePass1!") + resp = await anon_client.post( + "/api/users/auth/login", + data={"username": "valid@example.com", "password": "SecurePass1!"}, + ) + assert resp.status_code == 204 + assert "sm_auth" in resp.cookies + + @pytest.mark.anyio + async def test_valid_login_writes_session_user_id(self, anon_client, users_db): + """Login must write session['user_id'] for future AuthMiddleware use.""" + import json + from base64 import b64decode + + from itsdangerous import TimestampSigner + + user = await _make_user( + users_db, email="session@example.com", password="SecurePass1!" + ) + resp = await anon_client.post( + "/api/users/auth/login", + data={"username": "session@example.com", "password": "SecurePass1!"}, + ) + assert resp.status_code == 204 + + # Verify session cookie contains user_id + raw = resp.cookies.get("session") + assert raw is not None + # The itsdangerous signer uses base64url — strip the sig suffix + signer = TimestampSigner("test-secret-key") + unsigned = signer.unsign(raw).decode() + payload = json.loads(b64decode(unsigned).decode()) + assert "user_id" in payload + assert payload["user_id"] == str(user.id) + + +# --------------------------------------------------------------------------- +# Accept-invite +# --------------------------------------------------------------------------- + + +class TestAcceptInvite: + @pytest.mark.anyio + async def test_bad_token_returns_400(self, anon_client): + resp = await anon_client.post( + "/api/users/auth/accept-invite", + json={"token": "bad.token.here", "password": "NewSecurePass1!"}, + ) + assert resp.status_code == 400 + assert resp.json()["detail"] == "INVITE_BAD_TOKEN" + + @pytest.mark.anyio + async def test_valid_invite_verifies_and_logs_in(self, anon_client, users_db, users_app): + """Full flow: create unverified user, mint token, accept-invite, get cookie.""" + from unittest.mock import AsyncMock, MagicMock + + from users.db_adapter import UserDatabaseWithRoles + from users.manager import UserManager + from users.models import User + + # Create unverified user + await _make_user( + users_db, email="invited@example.com", password="OldUnusable1!", verified=False + ) + + # Mint a verify token using the manager + settings = users_app.state.users_settings + fake_mailer = MagicMock() + fake_mailer.send_invite = AsyncMock() + fake_mailer.send_verification = AsyncMock() + fake_mailer.send_password_reset = AsyncMock() + + user_db = UserDatabaseWithRoles(users_db, User) + manager = UserManager(user_db, fake_mailer, settings) + # Reload the user with the manager to get a proper User object + fetched = await user_db.get_by_email("invited@example.com") + token = await manager.generate_verification_token(fetched) + + resp = await anon_client.post( + "/api/users/auth/accept-invite", + json={"token": token, "password": "NewSecurePass1!"}, + ) + assert resp.status_code == 204 + assert "sm_auth" in resp.cookies + + +# --------------------------------------------------------------------------- +# Register gating +# --------------------------------------------------------------------------- + + +class TestRegisterGating: + @pytest.mark.anyio + async def test_register_not_mounted_when_signup_disabled(self, anon_client): + """POST /api/users/auth/register should 404 when allow_signup=False.""" + resp = await anon_client.post( + "/api/users/auth/register", + json={"email": "new@example.com", "password": "SecurePass1!"}, + ) + assert resp.status_code == 404 + + @pytest.mark.anyio + async def test_register_mounted_when_signup_enabled(self, anon_client_signup): + """POST /api/users/auth/register should 201 when allow_signup=True.""" + resp = await anon_client_signup.post( + "/api/users/auth/register", + json={"email": "newuser@example.com", "password": "SecurePass1!"}, + ) + # 201 Created from fastapi-users register router + assert resp.status_code == 201 + body = resp.json() + assert body["email"] == "newuser@example.com" + assert body["is_verified"] is False diff --git a/modules/users/tests/test_invite_flow.py b/modules/users/tests/test_invite_flow.py new file mode 100644 index 00000000..7263fff7 --- /dev/null +++ b/modules/users/tests/test_invite_flow.py @@ -0,0 +1,110 @@ +"""End-to-end invite flow tests. + +Admin invites → token captured from ConsoleMailer log → accept-invite +with that token + password → new user can log in. +""" + +from __future__ import annotations + +import logging + +import pytest + + +class TestInviteFlow: + @pytest.mark.anyio + async def test_full_invite_flow(self, admin_client, anon_client, caplog): + """Admin invites user → token from logs → accept-invite → login.""" + # Step 1: Admin sends invite + with caplog.at_level(logging.INFO, logger="users.mailer"): + resp = await admin_client.post( + "/api/users/admin/invite", + json={ + "email": "newbie@example.com", + "full_name": "New User", + "role_names": ["user"], + }, + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["email"] == "newbie@example.com" + assert body["is_verified"] is False + + # Step 2: Extract token from ConsoleMailer log record + invite_records = [ + r for r in caplog.records if r.getMessage() == "users.invite.email" + ] + assert len(invite_records) == 1, ( + f"Expected 1 invite log record, got {len(invite_records)}: {caplog.records}" + ) + link = invite_records[0].link # type: ignore[attr-defined] + # Link format: http://testserver/users/invite/accept?token= + token = link.split("token=", 1)[1] + assert len(token) > 20 + + # Step 3: Accept invite with the token and a new password + resp = await anon_client.post( + "/api/users/auth/accept-invite", + json={"token": token, "password": "FreshSecure1!"}, + ) + assert resp.status_code == 204, resp.text + assert "sm_auth" in resp.cookies + + # Step 4: New user can log in with the new password + resp = await anon_client.post( + "/api/users/auth/login", + data={"username": "newbie@example.com", "password": "FreshSecure1!"}, + ) + assert resp.status_code == 204, resp.text + assert "sm_auth" in resp.cookies + + @pytest.mark.anyio + async def test_accept_invite_with_stale_token_fails(self, anon_client): + """A completely invalid token should return 400 INVITE_BAD_TOKEN.""" + resp = await anon_client.post( + "/api/users/auth/accept-invite", + json={"token": "not.a.real.token", "password": "FreshSecure1!"}, + ) + assert resp.status_code == 400 + assert resp.json()["detail"] == "INVITE_BAD_TOKEN" + + @pytest.mark.anyio + async def test_accept_invite_weak_password_fails( + self, admin_client, anon_client, caplog + ): + """Weak password during accept-invite should return 400.""" + with caplog.at_level(logging.INFO, logger="users.mailer"): + await admin_client.post( + "/api/users/admin/invite", + json={"email": "weakpw@example.com"}, + ) + + invite_records = [ + r for r in caplog.records if r.getMessage() == "users.invite.email" + ] + token = invite_records[0].link.split("token=", 1)[1] # type: ignore[attr-defined] + + resp = await anon_client.post( + "/api/users/auth/accept-invite", + json={"token": token, "password": "short"}, + ) + assert resp.status_code == 400 + detail = resp.json()["detail"] + assert "INVALID_PASSWORD" in detail or "password" in detail.lower() + + @pytest.mark.anyio + async def test_invited_user_cannot_login_before_accepting( + self, admin_client, anon_client + ): + """Unverified invited user cannot log in before accepting invite.""" + await admin_client.post( + "/api/users/admin/invite", + json={"email": "waiting@example.com"}, + ) + # Try to log in with the unusable password (we don't know it) — just + # confirm that any attempt fails, not that it's specifically 400 vs 401. + resp = await anon_client.post( + "/api/users/auth/login", + data={"username": "waiting@example.com", "password": "anything"}, + ) + assert resp.status_code in (400,) diff --git a/modules/users/tests/test_rate_limit.py b/modules/users/tests/test_rate_limit.py new file mode 100644 index 00000000..d6f5a999 --- /dev/null +++ b/modules/users/tests/test_rate_limit.py @@ -0,0 +1,109 @@ +"""Tests for LoginRateLimiter.""" + +from __future__ import annotations + +import pytest +from users.rate_limit import LoginRateLimiter + + +@pytest.fixture +def limiter(): + """A limiter with max 5 failures, very short TTLs for fast tests.""" + return LoginRateLimiter(max_failures=5, window_seconds=60, cooldown_seconds=900) + + +class TestIsLocked: + def test_not_locked_initially(self, limiter): + assert limiter.is_locked("user@example.com::1.2.3.4") is False + + def test_not_locked_after_four_failures(self, limiter): + key = "user@example.com::1.2.3.4" + for _ in range(4): + limiter.record_failure(key) + assert limiter.is_locked(key) is False + + def test_locked_after_five_failures(self, limiter): + key = "user@example.com::1.2.3.4" + for _ in range(5): + limiter.record_failure(key) + assert limiter.is_locked(key) is True + + def test_sixth_request_still_locked(self, limiter): + """Once locked, subsequent calls don't unlock.""" + key = "user@example.com::1.2.3.4" + for _ in range(6): + limiter.record_failure(key) + assert limiter.is_locked(key) is True + + +class TestRecordFailure: + def test_failure_counter_increments(self, limiter): + key = "a::1" + limiter.record_failure(key) + limiter.record_failure(key) + # Not locked after 2 failures (max is 5) + assert limiter.is_locked(key) is False + + def test_reaching_max_clears_fail_counter(self, limiter): + """After locking, the failure counter is removed (lock handles timeout).""" + key = "a::1" + for _ in range(5): + limiter.record_failure(key) + # Lock bucket holds the lock; fail bucket is cleared + assert limiter.is_locked(key) is True + # Further record_failure on a locked key doesn't raise + limiter.record_failure(key) + assert limiter.is_locked(key) is True + + +class TestReset: + def test_reset_clears_failure_count(self, limiter): + key = "b::2" + for _ in range(4): + limiter.record_failure(key) + limiter.reset(key) + # After reset, another 4 failures should NOT lock + for _ in range(4): + limiter.record_failure(key) + assert limiter.is_locked(key) is False + + def test_reset_clears_lock(self, limiter): + key = "b::2" + for _ in range(5): + limiter.record_failure(key) + assert limiter.is_locked(key) is True + limiter.reset(key) + assert limiter.is_locked(key) is False + + def test_reset_on_unknown_key_does_not_raise(self, limiter): + limiter.reset("never-seen-key::0.0.0.0") + + def test_successful_login_resets_counter(self, limiter): + """Simulates a successful login after some failures resetting state.""" + key = "c::3" + for _ in range(3): + limiter.record_failure(key) + # Successful login → reset + limiter.reset(key) + # Now 5 fresh failures should lock + for _ in range(5): + limiter.record_failure(key) + assert limiter.is_locked(key) is True + + +class TestPerKeyIsolation: + def test_different_keys_independent(self, limiter): + key1 = "alice@example.com::1.1.1.1" + key2 = "bob@example.com::2.2.2.2" + for _ in range(5): + limiter.record_failure(key1) + assert limiter.is_locked(key1) is True + assert limiter.is_locked(key2) is False + + def test_same_email_different_ip_independent(self, limiter): + key1 = "user@example.com::1.1.1.1" + key2 = "user@example.com::2.2.2.2" + for _ in range(5): + limiter.record_failure(key1) + assert limiter.is_locked(key1) is True + assert limiter.is_locked(key2) is False diff --git a/modules/users/users/contracts/events.py b/modules/users/users/contracts/events.py index 9a0a6134..cafa12ba 100644 --- a/modules/users/users/contracts/events.py +++ b/modules/users/users/contracts/events.py @@ -1 +1,30 @@ -"""Placeholder — implemented in a later task.""" +"""Public events published by the users module.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UserRegistered: + user_id: uuid.UUID + email: str + + +@dataclass(frozen=True) +class UserInvited: + user_id: uuid.UUID + email: str + invited_by: str | None + + +@dataclass(frozen=True) +class UserDisabled: + user_id: uuid.UUID + + +@dataclass(frozen=True) +class RoleAssigned: + user_id: uuid.UUID + role_name: str diff --git a/modules/users/users/contracts/schemas.py b/modules/users/users/contracts/schemas.py index 9a0a6134..deeaa088 100644 --- a/modules/users/users/contracts/schemas.py +++ b/modules/users/users/contracts/schemas.py @@ -1 +1,60 @@ -"""Placeholder — implemented in a later task.""" +"""Public request/response schemas for the users module.""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from fastapi_users import schemas +from pydantic import BaseModel, EmailStr + + +# fastapi-users-provided base schemas +class UserRead(schemas.BaseUser[uuid.UUID]): + full_name: str | None = None + tenant_id: str | None = None + disabled_at: datetime | None = None + last_login_at: datetime | None = None + + +class UserCreate(schemas.BaseUserCreate): + full_name: str | None = None + + +class UserUpdate(schemas.BaseUserUpdate): + full_name: str | None = None + + +# Admin + invite + self profile +class UserInvite(BaseModel): + email: EmailStr + full_name: str | None = None + role_names: list[str] = [] + + +class UserListItem(BaseModel): + id: uuid.UUID + email: EmailStr + full_name: str | None = None + is_active: bool + is_verified: bool + disabled_at: datetime | None = None + last_login_at: datetime | None = None + roles: list[str] = [] + + +class RoleAssignment(BaseModel): + role_names: list[str] + + +class AcceptInviteRequest(BaseModel): + token: str + password: str + + +class PasswordResetLink(BaseModel): + link: str + + +class SelfProfileUpdate(BaseModel): + full_name: str | None = None diff --git a/modules/users/users/deps.py b/modules/users/users/deps.py index e1d96d05..96e34aa8 100644 --- a/modules/users/users/deps.py +++ b/modules/users/users/deps.py @@ -12,7 +12,11 @@ import uuid +from fastapi import Depends, Request from fastapi_users import FastAPIUsers +from simple_module_core.events import EventBus +from simple_module_db.deps import get_db +from sqlalchemy.ext.asyncio import AsyncSession from users.backend import build_auth_backend, build_cookie_transport from users.db_adapter import ( @@ -38,6 +42,25 @@ current_superuser = fastapi_users.current_user(active=True, superuser=True) +def get_mailer(request: Request): + """Return the mailer from app.state (built in UsersModule.on_startup).""" + return request.app.state.mailer + + +def get_event_bus(request: Request) -> EventBus: + """Return the event bus from app.state.""" + return request.app.state.event_bus + + +async def get_user_service( + db: AsyncSession = Depends(get_db), + user_manager: UserManager = Depends(get_user_manager), +) -> UserService: # noqa: F821 + from users.service import UserService + + return UserService(db, user_manager) + + __all__ = [ "UserDatabaseWithRoles", "UserManager", @@ -46,6 +69,9 @@ "current_superuser", "fastapi_users", "get_access_token_db", + "get_event_bus", + "get_mailer", "get_user_db", "get_user_manager", + "get_user_service", ] diff --git a/modules/users/users/endpoints/__init__.py b/modules/users/users/endpoints/__init__.py index e69de29b..e8907f25 100644 --- a/modules/users/users/endpoints/__init__.py +++ b/modules/users/users/endpoints/__init__.py @@ -0,0 +1 @@ +"""REST and view endpoints for the users module.""" diff --git a/modules/users/users/endpoints/api.py b/modules/users/users/endpoints/api.py index 9a0a6134..e230a4c7 100644 --- a/modules/users/users/endpoints/api.py +++ b/modules/users/users/endpoints/api.py @@ -1 +1,308 @@ -"""Placeholder — implemented in a later task.""" +"""REST API endpoints for the users module. + +Structure: + /api/users/auth/login — wrapper with rate limit + /api/users/auth/* — fastapi-users routers (register/reset/verify/logout) + /api/users/auth/accept-invite — custom (verify + set password + login) + /api/users/me — self profile + /api/users/admin/* — admin REST (RequiresPermission('users.manage')) +""" + +from __future__ import annotations + +import logging +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.security import OAuth2PasswordRequestForm +from fastapi_users import exceptions as fu_exceptions +from simple_module_core.events import EventBus +from simple_module_hosting.permissions import RequiresPermission + +from users.contracts.events import RoleAssigned, UserDisabled, UserInvited +from users.contracts.schemas import ( + AcceptInviteRequest, + PasswordResetLink, + RoleAssignment, + SelfProfileUpdate, + UserCreate, + UserInvite, + UserListItem, + UserRead, + UserUpdate, +) +from users.deps import ( + auth_backend, + fastapi_users, + get_event_bus, + get_mailer, + get_user_manager, + get_user_service, +) +from users.manager import UserManager +from users.rate_limit import LoginRateLimiter +from users.service import UserService + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ── Rate limit (module-level — shared across requests) ────────────────────── + +_rate_limiter: LoginRateLimiter | None = None + + +def get_rate_limiter(request: Request) -> LoginRateLimiter: + global _rate_limiter + if _rate_limiter is None: + s = request.app.state.users_settings + _rate_limiter = LoginRateLimiter( + max_failures=s.login_rate_limit_failures, + window_seconds=s.login_rate_limit_window_seconds, + cooldown_seconds=s.login_rate_limit_cooldown_seconds, + ) + return _rate_limiter + + +# ── Wrapper login ──────────────────────────────────────────────────────────── + +@router.post("/auth/login", status_code=204) +async def login( + request: Request, + response: Response, + credentials: OAuth2PasswordRequestForm = Depends(), + user_manager: UserManager = Depends(get_user_manager), + strategy=Depends(auth_backend.get_strategy), + limiter: LoginRateLimiter = Depends(get_rate_limiter), +): + """Rate-limited login wrapper. Sets sm_auth cookie + session user_id.""" + key = ( + f"{credentials.username.lower()}::" + f"{request.client.host if request.client else 'unknown'}" + ) + if limiter.is_locked(key): + raise HTTPException(status_code=429, detail="Too many attempts — try again later") + + try: + user = await user_manager.authenticate(credentials) + except fu_exceptions.UserNotExists: + user = None + + if user is None or not user.is_active: + limiter.record_failure(key) + raise HTTPException(status_code=400, detail="LOGIN_BAD_CREDENTIALS") + + if not user.is_verified: + # Match fastapi-users' own behavior when requires_verification=True + raise HTTPException(status_code=400, detail="LOGIN_USER_NOT_VERIFIED") + + limiter.reset(key) + # Fire the login hook (updates last_login_at) + await user_manager.on_after_login(user, request, response) + # Set fastapi-users' cookie via auth_backend.login + login_response = await auth_backend.login(strategy, user) + # Bridge the session cookie — AuthMiddleware (once wired in Task 8) reads this + request.session["user_id"] = str(user.id) + return login_response + + +# ── Mount fastapi-users stock routers ──────────────────────────────────────── + +# The stock auth router (login + logout) is mounted at /auth-inner so its +# logout and other endpoints remain accessible. Our wrapper at /auth/login +# shadows the stock login endpoint. Logout is exposed via /auth-inner/logout. +auth_inner = fastapi_users.get_auth_router(auth_backend, requires_verification=True) +router.include_router(auth_inner, prefix="/auth-inner") + + +def register_auth_routes(api_router: APIRouter, settings) -> None: + """Mount all auth routes, conditionally adding register if allowed.""" + api_router.include_router(router) + api_router.include_router( + fastapi_users.get_reset_password_router(), + prefix="/auth", + tags=["users-auth"], + ) + api_router.include_router( + fastapi_users.get_verify_router(UserRead), + prefix="/auth", + tags=["users-auth"], + ) + if settings.allow_signup: + api_router.include_router( + fastapi_users.get_register_router(UserRead, UserCreate), + prefix="/auth", + tags=["users-auth"], + ) + + +# ── Accept-invite (verify + set password + login, one shot) ───────────────── + +@router.post("/auth/accept-invite", status_code=204) +async def accept_invite( + body: AcceptInviteRequest, + request: Request, + response: Response, + user_manager: UserManager = Depends(get_user_manager), + strategy=Depends(auth_backend.get_strategy), +): + """Verify an invite token, set the user's password, and log them in.""" + try: + user = await user_manager.verify(body.token, request=request) + except (fu_exceptions.InvalidVerifyToken, fu_exceptions.UserAlreadyVerified): + raise HTTPException(status_code=400, detail="INVITE_BAD_TOKEN") from None + + try: + await user_manager.update( + UserUpdate(password=body.password), + user, + request=request, + ) + except fu_exceptions.InvalidPasswordException as e: + raise HTTPException( + status_code=400, detail=f"INVALID_PASSWORD: {e.reason}" + ) from e + + await user_manager.on_after_login(user, request, response) + login_response = await auth_backend.login(strategy, user) + request.session["user_id"] = str(user.id) + return login_response + + +# ── Self profile ───────────────────────────────────────────────────────────── + +@router.get("/me", response_model=UserRead) +async def read_me(user=Depends(fastapi_users.current_user(active=True))): + """Return the currently authenticated user's profile.""" + return user + + +@router.patch("/me", response_model=UserRead) +async def update_me( + data: SelfProfileUpdate, + request: Request, + user=Depends(fastapi_users.current_user(active=True)), + user_manager: UserManager = Depends(get_user_manager), +): + """Update the currently authenticated user's profile.""" + return await user_manager.update( + UserUpdate(**data.model_dump(exclude_unset=True)), + user, + request=request, + ) + + +# ── Admin REST ─────────────────────────────────────────────────────────────── + +@router.get( + "/admin", + response_model=list[UserListItem], + dependencies=[Depends(RequiresPermission("users.manage"))], +) +async def admin_list_users( + page: int = 1, + per_page: int = 20, + q: str | None = None, + service: UserService = Depends(get_user_service), +): + """List all users (paginated, optional search).""" + items, _ = await service.list_users(page=page, per_page=per_page, search=q) + return items + + +@router.post( + "/admin/invite", + response_model=UserListItem, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(RequiresPermission("users.manage"))], +) +async def admin_invite_user( + data: UserInvite, + request: Request, + bus: EventBus = Depends(get_event_bus), + service: UserService = Depends(get_user_service), + mailer=Depends(get_mailer), +): + """Invite a new user by email, optionally assigning roles.""" + invited_by = getattr(request.state, "user", None) + invited_by_name = invited_by.name if invited_by else "Administrator" + user, token = await service.invite( + data.email, data.full_name, data.role_names, invited_by=None + ) + await mailer.send_invite(user.email, token, invited_by_name) + await bus.publish(UserInvited( + user_id=user.id, + email=user.email, + invited_by=(str(invited_by.id) if invited_by else None), + )) + return await service.get_list_item(user.id) + + +@router.patch( + "/admin/{user_id}/disable", + response_model=UserListItem, + dependencies=[Depends(RequiresPermission("users.manage"))], +) +async def admin_disable_user( + user_id: uuid.UUID, + bus: EventBus = Depends(get_event_bus), + service: UserService = Depends(get_user_service), +): + """Disable a user account (sets is_active=False and disabled_at).""" + user = await service.disable(user_id) + await bus.publish(UserDisabled(user_id=user.id)) + return await service.get_list_item(user.id) + + +@router.patch( + "/admin/{user_id}/enable", + response_model=UserListItem, + dependencies=[Depends(RequiresPermission("users.manage"))], +) +async def admin_enable_user( + user_id: uuid.UUID, + service: UserService = Depends(get_user_service), +): + """Re-enable a previously disabled user account.""" + user = await service.enable(user_id) + return await service.get_list_item(user.id) + + +@router.put( + "/admin/{user_id}/roles", + response_model=UserListItem, + dependencies=[Depends(RequiresPermission("users.manage"))], +) +async def admin_set_roles( + user_id: uuid.UUID, + data: RoleAssignment, + request: Request, + bus: EventBus = Depends(get_event_bus), + service: UserService = Depends(get_user_service), +): + """Replace a user's role assignments.""" + assigned_by = getattr(request.state, "user", None) + user = await service.set_roles( + user_id, + data.role_names, + assigned_by=str(assigned_by.id) if assigned_by else None, + ) + for role in data.role_names: + await bus.publish(RoleAssigned(user_id=user.id, role_name=role)) + return await service.get_list_item(user.id) + + +@router.post( + "/admin/{user_id}/reset-password-link", + response_model=PasswordResetLink, + dependencies=[Depends(RequiresPermission("users.manage"))], +) +async def admin_reset_password_link( + user_id: uuid.UUID, + request: Request, + service: UserService = Depends(get_user_service), +): + """Generate a password-reset link for the given user (admin copy).""" + base_url = request.app.state.users_settings.base_url + link = await service.generate_reset_link(user_id, base_url) + return PasswordResetLink(link=link) diff --git a/modules/users/users/endpoints/views.py b/modules/users/users/endpoints/views.py index 9a0a6134..df5bf08d 100644 --- a/modules/users/users/endpoints/views.py +++ b/modules/users/users/endpoints/views.py @@ -1 +1,5 @@ -"""Placeholder — implemented in a later task.""" +"""Inertia view routes for users — filled in with full pages in Task 7.""" + +from fastapi import APIRouter + +router = APIRouter() diff --git a/modules/users/users/module.py b/modules/users/users/module.py index 024376fa..7eb952ad 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -1,8 +1,15 @@ -"""Users module definition.""" +"""Users module — local user management (replaces Keycloak).""" from __future__ import annotations +from typing import TYPE_CHECKING + +from fastapi import APIRouter from simple_module_core.module import ModuleBase, ModuleMeta +from simple_module_core.permissions import PermissionRegistry + +if TYPE_CHECKING: + from fastapi import FastAPI class UsersModule(ModuleBase): @@ -12,3 +19,42 @@ class UsersModule(ModuleBase): view_prefix="/users", depends_on=["Auth"], ) + + def register_settings(self, app: FastAPI) -> None: + from users.settings import UsersSettings + + app.state.users_settings = UsersSettings() + + def register_permissions(self, registry: PermissionRegistry) -> None: + registry.add_group( + "Users", + ["users.manage", "users.self.profile"], + ) + registry.map_role("user", ["users.self.profile"]) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from users.endpoints.api import register_auth_routes + from users.endpoints.views import router as views + from users.settings import UsersSettings + + # UsersSettings reads from env every time — safe and idempotent. + # app.state is not accessible here so we re-parse from environment. + register_auth_routes(api_router, UsersSettings()) + view_router.include_router(views) + + async def on_startup(self, app: FastAPI) -> None: + """Build the mailer once app settings are committed.""" + from users.mailer import build_mailer + + app.state.mailer = build_mailer(app.state.users_settings) + + # Patch cookie transport params from real settings (dev-safe singleton + # in deps.py is constructed with defaults at import time). + from users.deps import auth_backend + + s = app.state.users_settings + transport = auth_backend.transport + transport.cookie_name = s.cookie_name + transport.cookie_max_age = s.cookie_max_age_seconds + transport.cookie_secure = s.cookie_secure + transport.cookie_samesite = s.cookie_samesite diff --git a/modules/users/users/rate_limit.py b/modules/users/users/rate_limit.py index 9a0a6134..8f71a147 100644 --- a/modules/users/users/rate_limit.py +++ b/modules/users/users/rate_limit.py @@ -1 +1,33 @@ -"""Placeholder — implemented in a later task.""" +"""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 index 9a0a6134..6bb15d01 100644 --- a/modules/users/users/service.py +++ b/modules/users/users/service.py @@ -1 +1,220 @@ -"""Placeholder — implemented in a later task.""" +"""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: + 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 = [ + UserListItem( + id=u.id, + email=u.email, + full_name=u.full_name, + is_active=u.is_active, + is_verified=u.is_verified, + disabled_at=u.disabled_at, + last_login_at=u.last_login_at, + roles=[r.name for r in u.roles], + ) + 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() + return await self._get_user_with_roles(user_id) + + 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() + return await self._get_user_with_roles(user_id) + + 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 + return await self._get_user_with_roles(user_id) + + async def generate_reset_link(self, user_id: uuid.UUID, base_url: str) -> str: + 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") + + # Use fastapi-users' JWT primitive to build a reset token directly + # without sending an email (admin copies the link themselves). + from fastapi_users.jwt import generate_jwt + + token_data = { + "sub": str(user.id), + "password_fgpt": self._manager.password_helper.hash(user.hashed_password), + "aud": "fastapi-users:reset", + } + token = generate_jwt( + token_data, + self._manager.reset_password_token_secret, + self._manager.reset_password_token_lifetime_seconds, + ) + 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) From 08acf15724956ca4a66d5704a6ef12a7b41b9900 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 18:52:48 +0200 Subject: [PATCH 10/26] ruff: fix import ordering in framework/db/tests after _models.py refactor --- framework/db/tests/conftest.py | 3 +-- framework/db/tests/test_db_logging.py | 3 +-- framework/db/tests/test_multi_tenancy.py | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/framework/db/tests/conftest.py b/framework/db/tests/conftest.py index f21846c8..6130d96e 100644 --- a/framework/db/tests/conftest.py +++ b/framework/db/tests/conftest.py @@ -5,12 +5,11 @@ from collections.abc import AsyncGenerator import pytest +from _models import _TenantBase from simple_module_db.listeners import register_listeners from simple_module_db.session import init_db from sqlalchemy.ext.asyncio import AsyncSession -from _models import _TenantBase - @pytest.fixture async def tenant_session() -> AsyncGenerator[AsyncSession, None]: diff --git a/framework/db/tests/test_db_logging.py b/framework/db/tests/test_db_logging.py index 082b335a..669aab9e 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 # ty: ignore[unresolved-import] from simple_module_db.deps import get_db from sqlalchemy.ext.asyncio import AsyncSession -from _models 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 7d8b40c0..d66395fa 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 # ty: ignore[unresolved-import] 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 _models import _TenantItem, _TenantSoftItem # ty: ignore[unresolved-import] - class TestMultiTenancy: """Automatic tenant isolation: auto-populate, query filtering, enforcement.""" From 66a592fec2f49f25d4be5fdf0bb4be590f7a263a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:06:28 +0200 Subject: [PATCH 11/26] Add Inertia pages + view routes for users module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages (React/TSX): * Public: Login, Register, ForgotPassword, ResetPassword, VerifyEmail, AcceptInvite — all use a minimal centered-card layout; no shared GuestLayout yet (would be over-engineering for 6 small pages). * Authenticated: Profile (self-serve full_name update). * Admin (nested under Users/): Index (list + search + pagination), Invite (form with role checkboxes), Edit (disable/enable toggle, role assignment, reset-password-link copy-to-clipboard). Login POSTs application/x-www-form-urlencoded with username=email to match the wrapper's OAuth2PasswordRequestForm contract. Widens host/client_app/pages.ts page-name regex from (\w+) to (.+) so nested dirs like modules/users/users/pages/Users/Index.tsx resolve as the page key "Users/Users/Index". Widens manifest.py Vite glob from /*.tsx to /**/*.tsx so nested page directories are picked up by import.meta.glob. View routes in modules/users/users/endpoints/views.py render each page with the right props; /users/register 404s when signup is disabled. Page component names are prefixed with the module name (e.g. Users/Login, Users/Users/Index) to match the pages.ts registry. Adds users public view paths (/users/login, /users/register, etc.) to auth/middleware.py PUBLIC_PATHS so they're reachable before login. Task 7 of plan cryptic-juggling-lightning. --- .../hosting/simple_module_hosting/manifest.py | 4 +- host/client_app/pages.ts | 2 +- modules/auth/auth/middleware.py | 10 +- modules/users/tests/test_views.py | 174 ++++++++++++++++ modules/users/users/endpoints/views.py | 134 ++++++++++++- modules/users/users/pages/AcceptInvite.tsx | 99 +++++++++ modules/users/users/pages/ForgotPassword.tsx | 84 ++++++++ modules/users/users/pages/Login.tsx | 138 +++++++++++++ modules/users/users/pages/Profile.tsx | 112 +++++++++++ modules/users/users/pages/Register.tsx | 145 ++++++++++++++ modules/users/users/pages/ResetPassword.tsx | 102 ++++++++++ modules/users/users/pages/Users/Edit.tsx | 186 +++++++++++++++++ modules/users/users/pages/Users/Index.tsx | 189 ++++++++++++++++++ modules/users/users/pages/Users/Invite.tsx | 135 +++++++++++++ modules/users/users/pages/VerifyEmail.tsx | 101 ++++++++++ 15 files changed, 1609 insertions(+), 6 deletions(-) create mode 100644 modules/users/tests/test_views.py create mode 100644 modules/users/users/pages/AcceptInvite.tsx create mode 100644 modules/users/users/pages/ForgotPassword.tsx create mode 100644 modules/users/users/pages/Login.tsx create mode 100644 modules/users/users/pages/Profile.tsx create mode 100644 modules/users/users/pages/Register.tsx create mode 100644 modules/users/users/pages/ResetPassword.tsx create mode 100644 modules/users/users/pages/Users/Edit.tsx create mode 100644 modules/users/users/pages/Users/Index.tsx create mode 100644 modules/users/users/pages/Users/Invite.tsx create mode 100644 modules/users/users/pages/VerifyEmail.tsx 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/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/modules/auth/auth/middleware.py b/modules/auth/auth/middleware.py index 01ce2f23..9ad03b5f 100644 --- a/modules/auth/auth/middleware.py +++ b/modules/auth/auth/middleware.py @@ -28,8 +28,16 @@ "/openapi.json", # users module — all API routes let through so RequiresPermission handles # auth enforcement (returns 401/403) rather than a page redirect (302). - # View routes (/users/*) remain protected by this middleware until Task 8. "/api/users/", + # Users module public view routes (Task 7). These render the login / + # register / password-reset / verify pages; they must be reachable + # before the user has a session. + "/users/login", + "/users/register", + "/users/forgot-password", + "/users/reset-password", + "/users/verify", + "/users/invite/accept", ) EXACT_PUBLIC_PATHS = ("/",) diff --git a/modules/users/tests/test_views.py b/modules/users/tests/test_views.py new file mode 100644 index 00000000..5da8b5e8 --- /dev/null +++ b/modules/users/tests/test_views.py @@ -0,0 +1,174 @@ +"""View-route tests for the users module (Inertia endpoints).""" + +from __future__ import annotations + +import uuid + +import pytest +from fastapi_users.password import PasswordHelper +from users.models import Role, User, UserRole + +_pw = PasswordHelper() + + +def _hash(plain: str) -> str: + return _pw.hash(plain) + + +async def _make_verified_user( + session, + email: str = "user@example.com", + role_names: list[str] | None = None, +) -> User: + from sqlalchemy import select + + user = User( + id=uuid.uuid4(), + email=email, + hashed_password=_hash("SecurePass1!"), + is_active=True, + is_superuser=False, + is_verified=True, + full_name="Test User", + ) + 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() + await session.refresh(user) + return user + + +# --------------------------------------------------------------------------- +# Public auth pages +# --------------------------------------------------------------------------- + + +class TestLoginPage: + @pytest.mark.anyio + async def test_login_returns_200(self, anon_client): + resp = await anon_client.get("/users/login") + assert resp.status_code == 200 + + @pytest.mark.anyio + async def test_login_response_contains_inertia_component(self, anon_client): + resp = await anon_client.get( + "/users/login", + headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["component"] == "Users/Login" + + +class TestRegisterPage: + @pytest.mark.anyio + async def test_register_disabled_by_default_returns_404(self, anon_client): + """With allow_signup=False (default), /users/register should 404.""" + resp = await anon_client.get("/users/register") + assert resp.status_code == 404 + + @pytest.mark.anyio + async def test_register_enabled_returns_200(self, anon_client_signup): + """With allow_signup=True, /users/register should render.""" + resp = await anon_client_signup.get("/users/register") + assert resp.status_code == 200 + + @pytest.mark.anyio + async def test_register_enabled_contains_inertia_component(self, anon_client_signup): + resp = await anon_client_signup.get( + "/users/register", + headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["component"] == "Users/Register" + + +class TestOtherPublicPages: + @pytest.mark.anyio + async def test_forgot_password_returns_200(self, anon_client): + resp = await anon_client.get("/users/forgot-password") + assert resp.status_code == 200 + + @pytest.mark.anyio + async def test_reset_password_returns_200(self, anon_client): + resp = await anon_client.get("/users/reset-password?token=abc123") + assert resp.status_code == 200 + + @pytest.mark.anyio + async def test_verify_returns_200(self, anon_client): + resp = await anon_client.get("/users/verify?token=tok") + assert resp.status_code == 200 + + @pytest.mark.anyio + async def test_accept_invite_returns_200(self, anon_client): + resp = await anon_client.get("/users/invite/accept?token=tok") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Admin pages +# --------------------------------------------------------------------------- + + +class TestAdminIndexPage: + @pytest.mark.anyio + async def test_admin_without_auth_is_redirected(self, anon_client): + """Unauthenticated access to admin page is redirected to login by auth middleware.""" + resp = await anon_client.get("/users/admin") + # The Keycloak auth middleware redirects (302) before RequiresPermission + # can return 401. Task 8 swaps in the users-native middleware. + assert resp.status_code in (302, 401) + + @pytest.mark.anyio + async def test_admin_with_admin_session_returns_200(self, admin_client): + resp = await admin_client.get("/users/admin") + assert resp.status_code == 200 + + @pytest.mark.anyio + async def test_admin_inertia_component_is_users_index(self, admin_client): + resp = await admin_client.get( + "/users/admin", + headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["component"] == "Users/Users/Index" + + +class TestAdminEditPage: + @pytest.mark.anyio + async def test_invalid_uuid_returns_404(self, admin_client): + resp = await admin_client.get("/users/admin/not-a-uuid") + assert resp.status_code == 404 + + @pytest.mark.anyio + async def test_unknown_uuid_returns_404(self, admin_client): + missing_id = str(uuid.uuid4()) + resp = await admin_client.get(f"/users/admin/{missing_id}") + assert resp.status_code == 404 + + @pytest.mark.anyio + async def test_existing_user_returns_200(self, admin_client, users_db): + user = await _make_verified_user(users_db, email="edit_target@example.com") + resp = await admin_client.get(f"/users/admin/{user.id}") + assert resp.status_code == 200 + + @pytest.mark.anyio + async def test_existing_user_inertia_component(self, admin_client, users_db): + user = await _make_verified_user(users_db, email="edit_target2@example.com") + resp = await admin_client.get( + f"/users/admin/{user.id}", + headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["component"] == "Users/Users/Edit" diff --git a/modules/users/users/endpoints/views.py b/modules/users/users/endpoints/views.py index df5bf08d..41867e1c 100644 --- a/modules/users/users/endpoints/views.py +++ b/modules/users/users/endpoints/views.py @@ -1,5 +1,135 @@ -"""Inertia view routes for users — filled in with full pages in Task 7.""" +"""Inertia view routes for the users module.""" -from fastapi import APIRouter +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Request +from inertia import InertiaResponse +from simple_module_db.deps import get_db +from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.permissions import RequiresPermission +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from users.deps import get_user_service +from users.models import Role +from users.service import UserService router = APIRouter() + + +# ── Public auth pages ─────────────────────────────────────────── + +@router.get("/login", response_model=None) +async def login_page(request: Request, inertia: InertiaDep) -> InertiaResponse: + allow_signup = request.app.state.users_settings.allow_signup + return await inertia.render("Users/Login", {"allow_signup": allow_signup}) + + +@router.get("/register", response_model=None) +async def register_page(request: Request, inertia: InertiaDep) -> InertiaResponse: + if not request.app.state.users_settings.allow_signup: + raise HTTPException(status_code=404) + return await inertia.render("Users/Register", {}) + + +@router.get("/forgot-password", response_model=None) +async def forgot_password_page(inertia: InertiaDep) -> InertiaResponse: + return await inertia.render("Users/ForgotPassword", {}) + + +@router.get("/reset-password", response_model=None) +async def reset_password_page(inertia: InertiaDep, token: str = "") -> InertiaResponse: + return await inertia.render("Users/ResetPassword", {"token": token}) + + +@router.get("/verify", response_model=None) +async def verify_page(inertia: InertiaDep, token: str = "") -> InertiaResponse: + return await inertia.render("Users/VerifyEmail", {"token": token}) + + +@router.get("/invite/accept", response_model=None) +async def accept_invite_page(inertia: InertiaDep, token: str = "") -> InertiaResponse: + return await inertia.render("Users/AcceptInvite", {"token": token}) + + +# ── Authenticated pages ───────────────────────────────────────── + +@router.get("/me", response_model=None) +async def profile_page(inertia: InertiaDep) -> InertiaResponse: + return await inertia.render("Users/Profile", {}) + + +# ── Admin pages ───────────────────────────────────────────────── + +@router.get( + "/admin", + response_model=None, + dependencies=[Depends(RequiresPermission("users.manage"))], +) +async def admin_index( + inertia: InertiaDep, + service: UserService = Depends(get_user_service), + page: int = 1, + per_page: int = 20, + q: str | None = None, + db: AsyncSession = Depends(get_db), +) -> InertiaResponse: + users, total = await service.list_users(page=page, per_page=per_page, search=q) + roles_list = (await db.execute(select(Role).order_by(Role.name))).scalars().all() + return await inertia.render( + "Users/Users/Index", + { + "users": [u.model_dump(mode="json") for u in users], + "pagination": {"page": page, "per_page": per_page, "total": total}, + "query": q or "", + "roles": [{"id": str(r.id), "name": r.name} for r in roles_list], + }, + ) + + +@router.get( + "/admin/invite", + response_model=None, + dependencies=[Depends(RequiresPermission("users.manage"))], +) +async def admin_invite_page( + inertia: InertiaDep, + db: AsyncSession = Depends(get_db), +) -> InertiaResponse: + roles_list = (await db.execute(select(Role).order_by(Role.name))).scalars().all() + return await inertia.render( + "Users/Users/Invite", + { + "roles": [{"id": str(r.id), "name": r.name} for r in roles_list], + }, + ) + + +@router.get( + "/admin/{user_id}", + response_model=None, + dependencies=[Depends(RequiresPermission("users.manage"))], +) +async def admin_edit_page( + user_id: str, + inertia: InertiaDep, + service: UserService = Depends(get_user_service), + db: AsyncSession = Depends(get_db), +) -> InertiaResponse: + try: + uid = uuid.UUID(user_id) + except ValueError as exc: + raise HTTPException(status_code=404) from exc + user_item = await service.get_list_item(uid) + if user_item is None: + raise HTTPException(status_code=404) + roles_list = (await db.execute(select(Role).order_by(Role.name))).scalars().all() + return await inertia.render( + "Users/Users/Edit", + { + "user": user_item.model_dump(mode="json"), + "roles": [{"id": str(r.id), "name": r.name} for r in roles_list], + }, + ) diff --git a/modules/users/users/pages/AcceptInvite.tsx b/modules/users/users/pages/AcceptInvite.tsx new file mode 100644 index 00000000..68fcf244 --- /dev/null +++ b/modules/users/users/pages/AcceptInvite.tsx @@ -0,0 +1,99 @@ +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 { useState } from 'react'; + +interface Props { + token: string; +} + +function AcceptInvite() { + 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 [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/accept-invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password }), + }) + .then(async (res) => { + if (res.status === 204 || res.status === 200) { + router.visit('/dashboard'); + } else { + const data = await res.json().catch(() => ({})); + const detail = typeof data?.detail === 'string' ? data.detail : ''; + if (detail === 'INVITE_BAD_TOKEN') { + setError('Invite link has expired or is invalid. Please request a new invitation.'); + } else { + setError(detail || 'Failed to accept invite. Please try again.'); + } + } + }) + .catch(() => setError('An error occurred. Please try again.')) + .finally(() => setLoading(false)); + }; + + return ( +
+ + + Accept invitation + Set a password to activate your account + + +
+
+ + setPassword(e.target.value)} + required + autoComplete="new-password" + /> +
+
+ + setConfirm(e.target.value)} + required + autoComplete="new-password" + /> +
+ + {error &&

{error}

} + + +
+
+
+
+ ); +} + +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..5d06dc40 --- /dev/null +++ b/modules/users/users/pages/ForgotPassword.tsx @@ -0,0 +1,84 @@ +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 { 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..5529b6bf --- /dev/null +++ b/modules/users/users/pages/Login.tsx @@ -0,0 +1,138 @@ +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 { useState } from 'react'; + +interface Props { + allow_signup: boolean; +} + +function Login() { + const { allow_signup } = 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 handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setNeedsVerification(false); + setLoading(true); + const body = new URLSearchParams({ username: email, password }); + 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 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.

+ +
+ )} + + +
+ + {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..57d93886 --- /dev/null +++ b/modules/users/users/pages/Register.tsx @@ -0,0 +1,145 @@ +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 { 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..ea9a7592 --- /dev/null +++ b/modules/users/users/pages/ResetPassword.tsx @@ -0,0 +1,102 @@ +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 { 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..979a97d3 --- /dev/null +++ b/modules/users/users/pages/Users/Edit.tsx @@ -0,0 +1,186 @@ +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({ roles: 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..0217a458 --- /dev/null +++ b/modules/users/users/pages/Users/Index.tsx @@ -0,0 +1,189 @@ +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..0e074283 --- /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, roles: 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..a2c5fe34 --- /dev/null +++ b/modules/users/users/pages/VerifyEmail.tsx @@ -0,0 +1,101 @@ +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 { 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; From 76e7d69eeb74cad33e82f484a8d84ef09085dc3a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:21:39 +0200 Subject: [PATCH 12/26] Remove Keycloak; activate local AuthMiddleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the switchover: the Keycloak OIDC flow is deleted and the new local AuthMiddleware (written in Task 5) is registered via UsersModule.register_middleware. The users module now owns the entire authentication path. Removed: - modules/auth/auth/oauth.py - modules/auth/auth/endpoints/ (login/callback/logout/me) - modules/auth/auth/middleware.py (Keycloak session reader) - modules/auth/auth/settings.py (Keycloak URL/client config) - modules/auth/tests/test_middleware.py - keycloak/ (realm export + related fixtures) - docker-compose.yml `keycloak:` service - authlib, python-jose deps from modules/auth/pyproject.toml - UserContext.from_keycloak_userinfo (superseded by from_user) What remains in modules/auth: - contracts/schemas.py: UserContext (with from_user) - deps.py: get_current_user, CurrentUser, require_permission - module.py: no-op AuthModule — kept for stable contracts import path conftest.py::authenticated_client now creates a real User + admin Role + UserRole assignment and signs a session carrying {"user_id": "..."}. The admin_user seeding is idempotent against the auto-seeded admin Role row. host/routes.py landing reads request.state.user instead of the legacy session["userinfo"] shape. tests/integration/conftest.py rewritten to seed real users+roles into the app DB (viewer_client, inertia_client). Removed Keycloak-shaped session cookie helpers. Task 8 of plan cryptic-juggling-lightning. --- conftest.py | 43 +++-- docker-compose.yml | 11 -- framework/hosting/tests/test_app.py | 12 +- host/client_app/pages/Landing.tsx | 6 +- host/routes.py | 2 +- keycloak/realm-export.json | 65 -------- modules/auth/auth/__init__.py | 2 +- modules/auth/auth/contracts/schemas.py | 24 +-- modules/auth/auth/endpoints/__init__.py | 0 modules/auth/auth/endpoints/api.py | 87 ---------- modules/auth/auth/middleware.py | 91 ----------- modules/auth/auth/module.py | 57 ++----- modules/auth/auth/oauth.py | 41 ----- modules/auth/auth/settings.py | 16 -- modules/auth/pyproject.toml | 2 - modules/auth/tests/test_middleware.py | 80 ---------- modules/auth/tests/test_module.py | 24 +-- modules/auth/tests/test_user_context.py | 148 +++++------------- modules/users/tests/conftest.py | 59 +++++-- modules/users/tests/test_api_admin.py | 19 ++- modules/users/tests/test_views.py | 4 +- modules/users/users/middleware.py | 6 +- modules/users/users/module.py | 5 + pyproject.toml | 2 +- tests/e2e/conftest.py | 6 +- tests/e2e/test_smoke.py | 18 +-- tests/integration/conftest.py | 86 +++++++--- .../integration/test_permission_boundaries.py | 4 +- tests/integration/test_products_journey.py | 22 +-- 29 files changed, 251 insertions(+), 691 deletions(-) delete mode 100644 keycloak/realm-export.json delete mode 100644 modules/auth/auth/endpoints/__init__.py delete mode 100644 modules/auth/auth/endpoints/api.py delete mode 100644 modules/auth/auth/middleware.py delete mode 100644 modules/auth/auth/oauth.py delete mode 100644 modules/auth/auth/settings.py delete mode 100644 modules/auth/tests/test_middleware.py diff --git a/conftest.py b/conftest.py index 2ed6be8e..0d156683 100644 --- a/conftest.py +++ b/conftest.py @@ -145,22 +145,43 @@ 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 + import uuid as _uuid from base64 import b64encode + from fastapi_users.password import PasswordHelper 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 sqlalchemy import select + from users.constants import ADMIN_ROLE_ID + from users.models import Role, User, UserRole + + # Seed admin role + user into the app's own DB. + async with app.state.db.session_factory() as session: + admin_role = ( + await session.execute(select(Role).where(Role.id == ADMIN_ROLE_ID)) + ).scalar_one_or_none() + if admin_role is None: + admin_role = Role(id=ADMIN_ROLE_ID, name="admin", description="Administrator") + session.add(admin_role) + await session.flush() + + user = User( + id=_uuid.uuid4(), + email="admin@test", + hashed_password=PasswordHelper().hash("test-password"), + is_active=True, + is_verified=True, + is_superuser=True, + full_name="Test Admin", + ) + session.add(user) + await session.flush() + session.add(UserRole(user_id=user.id, role_id=admin_role.id)) + await session.commit() + + session_data = {"user_id": str(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/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/host/client_app/pages/Landing.tsx b/host/client_app/pages/Landing.tsx index 4d0de251..439917d1 100644 --- a/host/client_app/pages/Landing.tsx +++ b/host/client_app/pages/Landing.tsx @@ -50,7 +50,7 @@ function Landing() { style={{ animationDelay: '300ms' }} > + {dev_accounts && dev_accounts.length > 0 && ( +
+

Dev: log in as seeded user

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

Don't have an account?{' '} diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py index 7ae004eb..42ffec63 100644 --- a/modules/users/users/settings.py +++ b/modules/users/users/settings.py @@ -43,6 +43,10 @@ class UsersSettings(BaseSettings): login_rate_limit_window_seconds: int = 300 login_rate_limit_cooldown_seconds: int = 900 - # Bootstrap (env-var auto-create admin) + # 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 = "" From 9007874dca16df5a0b138e81bfc246146551c158 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 22:46:10 +0200 Subject: [PATCH 20/26] Fix CI: Python typecheck, JS format, 300-line cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI checks were failing on this branch; none of the errors were caused by the latest feature commits but they weren't clean from day one: **Python typecheck (ty):** - users/contracts/events.py — events now subclass ``simple_module_core.events.Event`` so ``EventBus.publish()`` accepts them. Dropped ``frozen=True`` to match the Event base class (ty forbids a frozen dataclass inheriting from a non-frozen one, and the existing products events follow the same non-frozen pattern). - users/service.py — ``disable``/``enable``/``set_roles`` re-fetch the user after commit; the helper returns ``User | None`` but the callers know the user exists (they just operated on it). Added ``assert refreshed is not None`` so the return type narrows cleanly. - users/deps.py — ``get_user_service`` return annotation needed a TYPE_CHECKING forward reference since the implementation is a lazy import. - users/backend.py — existing ``# type: ignore`` comments are mypy-only; added matching ``# ty: ignore[...]`` variants. - users/tests/test_bootstrap.py — same thing for the 3 SimpleNamespace-as-FastAPI test helpers. - users/tests/conftest.py + tests/integration/conftest.py — dropped the ``-> object`` return annotations on user-seeder helpers; they were hiding the ``.id`` attribute because ``object`` has no ``id``. - framework/db/tests/test_{db_logging,multi_tenancy}.py — removed two stale ``# ty: ignore[unresolved-import]`` directives that were unused (and thus flagged as warnings that failed the check). **JS lint/format (biome):** - Ran ``biome format --write`` — biome had format drift in 10 files across users/pages, the AuthCardShell layout, and the i18n generated artifacts. **300-line file cap:** - test_users_middleware.py was at 378 lines (cap is 300). Split the public-path scenarios into a new test_users_middleware_public_paths.py file, and extracted the shared helpers into a new _middleware_support.py module. The shared fixtures live in _middleware_support.py too and are wired to pytest via ``pytest_plugins = ["_middleware_support"]`` in conftest.py — avoids F811 redefinition lint where fixture names appear as test-function parameters. Verified all three CI checks pass locally: ``make lint`` (ruff format + check, ty, biome ci, tsc) and ``make ci-check-file-size``. Full test suite still at 538 passing. --- framework/db/tests/test_db_logging.py | 2 +- framework/db/tests/test_multi_tenancy.py | 2 +- modules/users/tests/_middleware_support.py | 108 ++++++++++ modules/users/tests/conftest.py | 11 +- modules/users/tests/test_bootstrap.py | 6 +- modules/users/tests/test_users_middleware.py | 189 ++---------------- .../test_users_middleware_public_paths.py | 61 ++++++ modules/users/users/backend.py | 4 +- modules/users/users/contracts/events.py | 18 +- modules/users/users/deps.py | 6 +- modules/users/users/pages/AcceptInvite.tsx | 10 +- modules/users/users/pages/ForgotPassword.tsx | 21 +- modules/users/users/pages/Register.tsx | 8 +- modules/users/users/pages/ResetPassword.tsx | 15 +- modules/users/users/pages/Users/Edit.tsx | 10 +- modules/users/users/pages/Users/Index.tsx | 23 ++- modules/users/users/pages/VerifyEmail.tsx | 42 ++-- modules/users/users/service.py | 12 +- packages/ui/src/layouts/AuthCardShell.tsx | 4 +- tests/integration/conftest.py | 2 +- 20 files changed, 312 insertions(+), 242 deletions(-) create mode 100644 modules/users/tests/_middleware_support.py create mode 100644 modules/users/tests/test_users_middleware_public_paths.py diff --git a/framework/db/tests/test_db_logging.py b/framework/db/tests/test_db_logging.py index 669aab9e..dbc387cc 100644 --- a/framework/db/tests/test_db_logging.py +++ b/framework/db/tests/test_db_logging.py @@ -6,7 +6,7 @@ import logging from unittest.mock import MagicMock -from _models import _TenantBase, _TenantItem # ty: ignore[unresolved-import] +from _models import _TenantBase, _TenantItem from simple_module_db.deps import get_db from sqlalchemy.ext.asyncio import AsyncSession diff --git a/framework/db/tests/test_multi_tenancy.py b/framework/db/tests/test_multi_tenancy.py index d66395fa..c5d5cacc 100644 --- a/framework/db/tests/test_multi_tenancy.py +++ b/framework/db/tests/test_multi_tenancy.py @@ -5,7 +5,7 @@ import asyncio import pytest -from _models import _TenantItem, _TenantSoftItem # ty: ignore[unresolved-import] +from _models import _TenantItem, _TenantSoftItem from simple_module_db.listeners import TenantIsolationError, current_tenant_id from sqlalchemy import select from sqlalchemy.exc import IntegrityError diff --git a/modules/users/tests/_middleware_support.py b/modules/users/tests/_middleware_support.py new file mode 100644 index 00000000..5c9ceeae --- /dev/null +++ b/modules/users/tests/_middleware_support.py @@ -0,0 +1,108 @@ +"""Helpers + fixtures for the users.middleware unit tests. + +Registered as a pytest plugin via ``pytest_plugins = ["_middleware_support"]`` +in conftest.py — that way the fixtures (``_mw_seed_roles``, ``mw_active_user``) +are auto-discovered by pytest without needing imports in the test files, +which avoids F811 warnings where fixture names appear as test-function +parameters. +""" + +from __future__ import annotations + +import json +import uuid +from base64 import b64encode +from typing import Any + +import pytest +from fastapi import FastAPI, Request +from itsdangerous import TimestampSigner +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import JSONResponse +from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID +from users.middleware import AuthMiddleware + +SECRET_KEY = "test-secret-key-for-session-middleware" + + +def _sign_session(data: dict[str, Any], secret: str = SECRET_KEY) -> str: + """Encode and sign a session dict exactly as Starlette's SessionMiddleware does.""" + raw = b64encode(json.dumps(data).encode()).decode() + return TimestampSigner(secret).sign(raw).decode("utf-8") + + +def _session_cookie(data: dict[str, Any]) -> dict[str, str]: + return {"session": _sign_session(data)} + + +async def _build_app(db_state, inner_handler=None): + """Build a minimal ASGI app with AuthMiddleware + SessionMiddleware.""" + + async def _default_handler(request: Request): + user = getattr(request.state, "user", None) + return JSONResponse( + { + "path": request.url.path, + "user": ( + { + "id": user.id, + "email": user.email, + "name": user.name, + "roles": user.roles, + "tenant_id": user.tenant_id, + } + if user is not None + else None + ), + } + ) + + handler = inner_handler or _default_handler + + app = FastAPI() + app.state.db = db_state + + @app.get("/{path:path}") + async def _catch_all(request: Request, path: str = ""): + return await handler(request) + + # Middleware is applied in reverse order: SessionMiddleware outermost. + app.add_middleware(AuthMiddleware) + app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY) + return app + + +@pytest.fixture +async def _mw_seed_roles(db_session): + """Insert the standard admin/user roles for middleware tests.""" + from users.models import Role + + db_session.add_all( + [ + Role(id=ADMIN_ROLE_ID, name="admin", description="Administrator"), + Role(id=USER_ROLE_ID, name="user", description="Standard user"), + ] + ) + await db_session.commit() + + +@pytest.fixture +async def mw_active_user(db_session, _mw_seed_roles): + """Active user with the 'admin' role — used by the middleware tests.""" + from users.models import User, UserRole + + user_id = uuid.uuid4() + user = User( + id=user_id, + email="middleware-test@example.com", + hashed_password="hashed", + is_active=True, + is_superuser=False, + is_verified=True, + full_name="Middleware Tester", + tenant_id="acme", + ) + link = UserRole(user_id=user_id, role_id=ADMIN_ROLE_ID) + db_session.add_all([user, link]) + await db_session.commit() + return user diff --git a/modules/users/tests/conftest.py b/modules/users/tests/conftest.py index fe3b579a..cd9b1a98 100644 --- a/modules/users/tests/conftest.py +++ b/modules/users/tests/conftest.py @@ -152,9 +152,10 @@ async def anon_client_signup(users_app_signup) -> AsyncGenerator[httpx.AsyncClie yield c -async def _make_admin_user(app) -> object: +async def _make_admin_user(app): """Seed an admin User + Role into app's DB and return the User row.""" from users.bootstrap import create_admin + from users.models import User async with app.state.db.session_factory() as session: result = await create_admin( @@ -163,7 +164,8 @@ async def _make_admin_user(app) -> object: password="AdminPass1!", full_name="Test Admin", ) - return result.user + user: User = result.user + return user def _sign_session(session_data: dict, secret_key: str) -> str: @@ -272,3 +274,8 @@ async def create_unverified_user( await session.commit() await session.refresh(user) return user + + +# Fixtures consumed by the users.middleware unit tests live in +# _middleware_support.py (imported as a pytest plugin below). +pytest_plugins = ["_middleware_support"] diff --git a/modules/users/tests/test_bootstrap.py b/modules/users/tests/test_bootstrap.py index 2c844f7a..1eb08862 100644 --- a/modules/users/tests/test_bootstrap.py +++ b/modules/users/tests/test_bootstrap.py @@ -185,7 +185,7 @@ async def test_bootstrap_from_env_noop_when_unset(users_app) -> None: bootstrap_password="", ) # Patch fake_app.state onto a real app-like object - await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type] + await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] # No users should have been created async with users_app.state.db.session_factory() as s: @@ -215,7 +215,7 @@ async def test_bootstrap_from_env_noop_when_table_nonempty(users_app) -> None: bootstrap_email="admin@test.example", bootstrap_password="AdminPass1!", ) - await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type] + await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] # Only the original dummy user should exist async with users_app.state.db.session_factory() as s: @@ -234,7 +234,7 @@ async def test_bootstrap_from_env_creates_admin_when_empty_and_configured(users_ bootstrap_email="bootstrap@test.example", bootstrap_password="BootPass1!", ) - await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type] + await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] async with users_app.state.db.session_factory() as s: user = ( diff --git a/modules/users/tests/test_users_middleware.py b/modules/users/tests/test_users_middleware.py index 5421d6d6..43b77c6d 100644 --- a/modules/users/tests/test_users_middleware.py +++ b/modules/users/tests/test_users_middleware.py @@ -4,125 +4,22 @@ per test with AuthMiddleware and SessionMiddleware installed. The full UsersModule stack is NOT used; this keeps the tests independent of route registration and module startup hooks. + +Shared helpers + fixtures live in ``_middleware_support`` so these tests +can be read as a flat list of scenarios. Public-path scenarios live in +``test_users_middleware_public_paths`` for the same reason. """ from __future__ import annotations -import json import uuid -from base64 import b64encode -from typing import Any import httpx import pytest -from fastapi import FastAPI, Request -from itsdangerous import TimestampSigner +from _middleware_support import _build_app, _session_cookie +from fastapi import Request from simple_module_db.listeners import current_user_id -from starlette.middleware.sessions import SessionMiddleware from starlette.responses import JSONResponse -from users.middleware import AuthMiddleware - -# --------------------------------------------------------------------------- -# Constants / helpers -# --------------------------------------------------------------------------- - -SECRET_KEY = "test-secret-key-for-session-middleware" - - -def _sign_session(data: dict[str, Any], secret: str = SECRET_KEY) -> str: - """Encode and sign a session dict exactly as Starlette's SessionMiddleware does.""" - raw = b64encode(json.dumps(data).encode()).decode() - return TimestampSigner(secret).sign(raw).decode("utf-8") - - -def _session_cookie(data: dict[str, Any]) -> dict[str, str]: - return {"session": _sign_session(data)} - - -# --------------------------------------------------------------------------- -# Mini-app factory -# --------------------------------------------------------------------------- - - -async def _build_app(db_state, inner_handler=None): - """Build a minimal ASGI app with AuthMiddleware + SessionMiddleware.""" - - async def _default_handler(request: Request): - user = getattr(request.state, "user", None) - return JSONResponse( - { - "path": request.url.path, - "user": ( - { - "id": user.id, - "email": user.email, - "name": user.name, - "roles": user.roles, - "tenant_id": user.tenant_id, - } - if user is not None - else None - ), - } - ) - - handler = inner_handler or _default_handler - - app = FastAPI() - app.state.db = db_state - - @app.get("/{path:path}") - async def _catch_all(request: Request, path: str = ""): - return await handler(request) - - # Middleware is applied in reverse order: SessionMiddleware outermost. - app.add_middleware(AuthMiddleware) - app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY) - return app - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -async def _seed_roles(db_session): - """Insert the standard admin/user roles.""" - from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID - from users.models import Role - - db_session.add_all( - [ - Role(id=ADMIN_ROLE_ID, name="admin", description="Administrator"), - Role(id=USER_ROLE_ID, name="user", description="Standard user"), - ] - ) - await db_session.commit() - - -@pytest.fixture -async def active_user(db_session, _seed_roles): - """Active user with the 'admin' role, eagerly committed.""" - from users.constants import ADMIN_ROLE_ID - from users.models import User, UserRole - - user_id = uuid.uuid4() - user = User( - id=user_id, - email="middleware-test@example.com", - hashed_password="hashed", - is_active=True, - is_superuser=False, - is_verified=True, - full_name="Middleware Tester", - tenant_id="acme", - ) - link = UserRole(user_id=user_id, role_id=ADMIN_ROLE_ID) - db_session.add_all([user, link]) - await db_session.commit() - return user - # --------------------------------------------------------------------------- # 1. Unauthenticated request to protected path → redirect @@ -168,10 +65,10 @@ async def _capture_session(request: Request): @pytest.mark.anyio -async def test_authenticated_request_sets_user_context(db_state, active_user): +async def test_authenticated_request_sets_user_context(db_state, mw_active_user): app = await _build_app(db_state) transport = httpx.ASGITransport(app=app) - cookies = _session_cookie({"user_id": str(active_user.id)}) + cookies = _session_cookie({"user_id": str(mw_active_user.id)}) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", cookies=cookies ) as client: @@ -180,7 +77,7 @@ async def test_authenticated_request_sets_user_context(db_state, active_user): assert resp.status_code == 200 data = resp.json() assert data["user"] is not None - assert data["user"]["id"] == str(active_user.id) + assert data["user"]["id"] == str(mw_active_user.id) assert data["user"]["email"] == "middleware-test@example.com" assert data["user"]["name"] == "Middleware Tester" assert data["user"]["roles"] == ["admin"] @@ -232,7 +129,7 @@ async def test_nonexistent_user_id_redirects(db_state): @pytest.mark.anyio -async def test_inactive_user_redirects(db_state, db_session, _seed_roles): +async def test_inactive_user_redirects(db_state, db_session, _mw_seed_roles): from users.models import User user_id = uuid.uuid4() @@ -265,7 +162,7 @@ async def test_inactive_user_redirects(db_state, db_session, _seed_roles): @pytest.mark.anyio -async def test_disabled_at_user_redirects(db_state, db_session, _seed_roles): +async def test_disabled_at_user_redirects(db_state, db_session, _mw_seed_roles): from datetime import UTC, datetime from users.models import User @@ -296,68 +193,12 @@ async def test_disabled_at_user_redirects(db_state, db_session, _seed_roles): # --------------------------------------------------------------------------- -# 7. Public path without session → passes through (no redirect) -# --------------------------------------------------------------------------- - - -@pytest.mark.anyio -async def test_public_path_unauthenticated_passes_through(db_state): - app = await _build_app(db_state) - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: - resp = await client.get("/users/login", follow_redirects=False) - - assert resp.status_code == 200 - - -@pytest.mark.anyio -async def test_api_users_auth_prefix_is_public(db_state): - app = await _build_app(db_state) - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: - resp = await client.get("/api/users/auth/login", follow_redirects=False) - - assert resp.status_code == 200 - - -@pytest.mark.anyio -async def test_health_path_is_public(db_state): - app = await _build_app(db_state) - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: - resp = await client.get("/health", follow_redirects=False) - - assert resp.status_code == 200 - - -# --------------------------------------------------------------------------- -# 8. Public root path (/) with valid user_id → sets user, no redirect -# --------------------------------------------------------------------------- - - -@pytest.mark.anyio -async def test_root_path_with_valid_user_sets_context(db_state, active_user): - app = await _build_app(db_state) - transport = httpx.ASGITransport(app=app) - cookies = _session_cookie({"user_id": str(active_user.id)}) - async with httpx.AsyncClient( - transport=transport, base_url="http://testserver", cookies=cookies - ) as client: - resp = await client.get("/", follow_redirects=False) - - assert resp.status_code == 200 - data = resp.json() - assert data["user"] is not None - assert data["user"]["email"] == "middleware-test@example.com" - - -# --------------------------------------------------------------------------- -# 9. current_user_id ContextVar is set during request / reset after +# 7. current_user_id ContextVar is set during request / reset after # --------------------------------------------------------------------------- @pytest.mark.anyio -async def test_current_user_id_contextvar_set_during_request(db_state, active_user): +async def test_current_user_id_contextvar_set_during_request(db_state, mw_active_user): captured: dict = {} async def _capture_contextvar(request: Request): @@ -366,13 +207,13 @@ async def _capture_contextvar(request: Request): app = await _build_app(db_state, _capture_contextvar) transport = httpx.ASGITransport(app=app) - cookies = _session_cookie({"user_id": str(active_user.id)}) + cookies = _session_cookie({"user_id": str(mw_active_user.id)}) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", cookies=cookies ) as client: await client.get("/dashboard") - assert captured["user_id"] == str(active_user.id) + assert captured["user_id"] == str(mw_active_user.id) # After the request completes, the ContextVar should be reset to its # default (no value set in this outer scope). assert current_user_id.get(None) is None diff --git a/modules/users/tests/test_users_middleware_public_paths.py b/modules/users/tests/test_users_middleware_public_paths.py new file mode 100644 index 00000000..59dfa267 --- /dev/null +++ b/modules/users/tests/test_users_middleware_public_paths.py @@ -0,0 +1,61 @@ +"""AuthMiddleware tests for PUBLIC_PATHS — paths that must pass through +unauthenticated. + +Helpers + fixtures live in ``_middleware_support`` alongside the full +middleware tests in ``test_users_middleware``. +""" + +from __future__ import annotations + +import httpx +import pytest +from _middleware_support import _build_app, _session_cookie + + +@pytest.mark.anyio +async def test_public_path_unauthenticated_passes_through(db_state): + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.get("/users/login", follow_redirects=False) + + assert resp.status_code == 200 + + +@pytest.mark.anyio +async def test_api_users_auth_prefix_is_public(db_state): + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.get("/api/users/auth/login", follow_redirects=False) + + assert resp.status_code == 200 + + +@pytest.mark.anyio +async def test_health_path_is_public(db_state): + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.get("/health", follow_redirects=False) + + assert resp.status_code == 200 + + +@pytest.mark.anyio +async def test_root_path_with_valid_user_sets_context(db_state, mw_active_user): + """The root path ``/`` is public, but if a valid session cookie is present + the middleware should still hydrate ``request.state.user`` so authenticated + visitors see "Open Dashboard" instead of "Get Started".""" + app = await _build_app(db_state) + transport = httpx.ASGITransport(app=app) + cookies = _session_cookie({"user_id": str(mw_active_user.id)}) + async with httpx.AsyncClient( + transport=transport, base_url="http://testserver", cookies=cookies + ) as client: + resp = await client.get("/", follow_redirects=False) + + assert resp.status_code == 200 + data = resp.json() + assert data["user"] is not None + assert data["user"]["email"] == "middleware-test@example.com" diff --git a/modules/users/users/backend.py b/modules/users/users/backend.py index 88eadde0..6bd9023c 100644 --- a/modules/users/users/backend.py +++ b/modules/users/users/backend.py @@ -40,7 +40,7 @@ def build_cookie_transport( cookie_max_age=cookie_max_age_seconds, cookie_secure=cookie_secure, cookie_httponly=True, - cookie_samesite=cookie_samesite, # type: ignore[arg-type] + cookie_samesite=cookie_samesite, # type: ignore[arg-type] # ty: ignore[invalid-argument-type] ) @@ -79,4 +79,4 @@ def reconfigure_cookie_transport( transport.cookie_name = settings.cookie_name transport.cookie_max_age = settings.cookie_max_age_seconds transport.cookie_secure = settings.cookie_secure - transport.cookie_samesite = settings.cookie_samesite # type: ignore[assignment] + transport.cookie_samesite = settings.cookie_samesite # type: ignore[assignment] # ty: ignore[invalid-assignment] diff --git a/modules/users/users/contracts/events.py b/modules/users/users/contracts/events.py index cafa12ba..f683e982 100644 --- a/modules/users/users/contracts/events.py +++ b/modules/users/users/contracts/events.py @@ -5,26 +5,28 @@ import uuid from dataclasses import dataclass +from simple_module_core.events import Event -@dataclass(frozen=True) -class UserRegistered: + +@dataclass +class UserRegistered(Event): user_id: uuid.UUID email: str -@dataclass(frozen=True) -class UserInvited: +@dataclass +class UserInvited(Event): user_id: uuid.UUID email: str invited_by: str | None -@dataclass(frozen=True) -class UserDisabled: +@dataclass +class UserDisabled(Event): user_id: uuid.UUID -@dataclass(frozen=True) -class RoleAssigned: +@dataclass +class RoleAssigned(Event): user_id: uuid.UUID role_name: str diff --git a/modules/users/users/deps.py b/modules/users/users/deps.py index cdaffff3..69df7360 100644 --- a/modules/users/users/deps.py +++ b/modules/users/users/deps.py @@ -13,6 +13,7 @@ from __future__ import annotations import uuid +from typing import TYPE_CHECKING from fastapi import Depends, Request from fastapi_users import FastAPIUsers @@ -29,6 +30,9 @@ from users.manager import UserManager, get_user_manager from users.models import User +if TYPE_CHECKING: + from users.service import UserService + # Dev-safe singleton — UsersModule patches cookie params at startup. _cookie_transport = build_cookie_transport( cookie_name="sm_auth", @@ -57,7 +61,7 @@ def get_event_bus(request: Request) -> EventBus: async def get_user_service( db: AsyncSession = Depends(get_db), user_manager: UserManager = Depends(get_user_manager), -) -> UserService: # noqa: F821 +) -> UserService: from users.service import UserService return UserService(db, user_manager) diff --git a/modules/users/users/pages/AcceptInvite.tsx b/modules/users/users/pages/AcceptInvite.tsx index d5639fce..ff00993b 100644 --- a/modules/users/users/pages/AcceptInvite.tsx +++ b/modules/users/users/pages/AcceptInvite.tsx @@ -1,6 +1,12 @@ 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 { + 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'; @@ -14,7 +20,7 @@ function AcceptInvite() { const { token: initialToken } = usePage<{ props: Props }>().props as unknown as Props; const urlToken = typeof window !== 'undefined' - ? new URLSearchParams(window.location.search).get('token') ?? '' + ? (new URLSearchParams(window.location.search).get('token') ?? '') : ''; const token = urlToken || initialToken; diff --git a/modules/users/users/pages/ForgotPassword.tsx b/modules/users/users/pages/ForgotPassword.tsx index f2bb05dc..2d90fbdc 100644 --- a/modules/users/users/pages/ForgotPassword.tsx +++ b/modules/users/users/pages/ForgotPassword.tsx @@ -1,5 +1,11 @@ import { Button } from '@simple-module/ui/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@simple-module/ui/components/ui/card'; +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'; @@ -17,13 +23,12 @@ function ForgotPassword() { 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); - }); + }).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) { diff --git a/modules/users/users/pages/Register.tsx b/modules/users/users/pages/Register.tsx index c0c16dd0..156e9971 100644 --- a/modules/users/users/pages/Register.tsx +++ b/modules/users/users/pages/Register.tsx @@ -1,5 +1,11 @@ import { Button } from '@simple-module/ui/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@simple-module/ui/components/ui/card'; +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'; diff --git a/modules/users/users/pages/ResetPassword.tsx b/modules/users/users/pages/ResetPassword.tsx index 83d8f054..99fe85d5 100644 --- a/modules/users/users/pages/ResetPassword.tsx +++ b/modules/users/users/pages/ResetPassword.tsx @@ -1,6 +1,12 @@ 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 { + 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'; @@ -16,7 +22,7 @@ function ResetPassword() { // 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') ?? '' + ? (new URLSearchParams(window.location.search).get('token') ?? '') : ''; const token = urlToken || initialToken; @@ -43,7 +49,10 @@ function ResetPassword() { 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.'; + const detail = + typeof data?.detail === 'string' + ? data.detail + : 'Reset failed. The link may have expired.'; setError(detail); } }) diff --git a/modules/users/users/pages/Users/Edit.tsx b/modules/users/users/pages/Users/Edit.tsx index 26170f0e..8bddce44 100644 --- a/modules/users/users/pages/Users/Edit.tsx +++ b/modules/users/users/pages/Users/Edit.tsx @@ -124,7 +124,9 @@ function Edit() { {user.is_verified ? ( Verified ) : ( - Unverified + + Unverified + )}

@@ -134,11 +136,7 @@ function Edit() { onClick={handleToggleActive} disabled={savingStatus} > - {savingStatus - ? 'Saving…' - : isActive - ? 'Disable account' - : 'Enable account'} + {savingStatus ? 'Saving…' : isActive ? 'Disable account' : 'Enable account'}
-

+

{t(keys.host.landing.hero_title_line1)}
@@ -162,17 +159,11 @@ function Landing() {

-

+

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

-
+
+ + + +

{auth.user.name}

+

{auth.user.email}

+
+ {menus?.userDropdown && menus.userDropdown.length > 0 && ( + + )} + {menus?.userDropdown?.map((item) => ( + + + + {item.label} + + + ))} +
+
)} From 882b4fec4ee34f4458b89924e578fd846fb8088c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 23:07:09 +0200 Subject: [PATCH 25/26] Add 'user' icon to NavIcon map (singular, for Profile) The Profile userDropdown item is registered with ``icon: "user"`` but NavIcon only knew ``"users"`` (plural, used by the admin user list). With the unknown name, it fell through to the empty placeholder span, so Profile rendered without an icon while Logout had one. Added a standard Heroicons-outline "user" (single head + shoulders). Profile now lines up visually with Logout in the dropdown. --- packages/ui/src/components/NavIcon.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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: (
- - - - - - -

{auth.user.name}

-

{auth.user.email}

-
- {menus?.userDropdown && menus.userDropdown.length > 0 && ( - - )} - {menus?.userDropdown?.map((item) => ( - - - - {item.label} - - - ))} -
-
-
- )} + {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} + + + ))} +
+
+
+ ); + })()} {/* Main content */}