simple-module-users 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. simple_module_users-0.0.1.dist-info/METADATA +88 -0
  2. simple_module_users-0.0.1.dist-info/RECORD +56 -0
  3. simple_module_users-0.0.1.dist-info/WHEEL +4 -0
  4. simple_module_users-0.0.1.dist-info/entry_points.txt +5 -0
  5. simple_module_users-0.0.1.dist-info/licenses/LICENSE +21 -0
  6. users/__init__.py +0 -0
  7. users/backend.py +85 -0
  8. users/bootstrap.py +246 -0
  9. users/cli.py +75 -0
  10. users/components/IndexFilters.tsx +72 -0
  11. users/components/RolesTab.tsx +72 -0
  12. users/constants.py +42 -0
  13. users/contracts/__init__.py +0 -0
  14. users/contracts/events.py +32 -0
  15. users/contracts/schemas.py +85 -0
  16. users/db_adapter.py +48 -0
  17. users/deps.py +83 -0
  18. users/endpoints/__init__.py +1 -0
  19. users/endpoints/api.py +227 -0
  20. users/endpoints/api_admin.py +167 -0
  21. users/endpoints/views.py +220 -0
  22. users/exceptions.py +18 -0
  23. users/mailer/__init__.py +33 -0
  24. users/mailer/console.py +27 -0
  25. users/mailer/smtp.py +77 -0
  26. users/mailer/templates/.gitkeep +0 -0
  27. users/mailer/templates/invite.txt +1 -0
  28. users/mailer/templates/reset_password.txt +1 -0
  29. users/mailer/templates/verify_email.txt +1 -0
  30. users/manager.py +146 -0
  31. users/middleware.py +143 -0
  32. users/models/__init__.py +24 -0
  33. users/models/_base.py +9 -0
  34. users/models/access_token.py +33 -0
  35. users/models/role.py +34 -0
  36. users/models/user.py +67 -0
  37. users/models/user_role.py +39 -0
  38. users/module.py +155 -0
  39. users/package.json +16 -0
  40. users/pages/.gitkeep +0 -0
  41. users/pages/AcceptInvite.tsx +106 -0
  42. users/pages/ForgotPassword.tsx +90 -0
  43. users/pages/Login.tsx +181 -0
  44. users/pages/Profile.tsx +112 -0
  45. users/pages/Register.tsx +152 -0
  46. users/pages/ResetPassword.tsx +112 -0
  47. users/pages/Users/Edit.tsx +293 -0
  48. users/pages/Users/Index.tsx +296 -0
  49. users/pages/Users/Invite.tsx +135 -0
  50. users/pages/VerifyEmail.tsx +110 -0
  51. users/py.typed +0 -0
  52. users/rate_limit.py +59 -0
  53. users/roles_cache.py +58 -0
  54. users/service.py +257 -0
  55. users/settings.py +99 -0
  56. users/state.py +33 -0
users/manager.py ADDED
@@ -0,0 +1,146 @@
1
+ """UserManager — handles lifecycle hooks + custom verification-token helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import uuid
7
+ from datetime import UTC, datetime
8
+ from typing import TYPE_CHECKING
9
+
10
+ from fastapi import Depends, Request
11
+ from fastapi_users import BaseUserManager, UUIDIDMixin, exceptions
12
+ from fastapi_users.jwt import generate_jwt
13
+
14
+ from users.contracts.events import UserRegistered
15
+ from users.db_adapter import UserDatabaseWithRoles, get_user_db
16
+ from users.mailer import Mailer
17
+ from users.models import User
18
+
19
+ if TYPE_CHECKING:
20
+ from users.settings import UsersSettings
21
+
22
+
23
+ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
24
+ """Customizes password validation, token secrets, and lifecycle emails."""
25
+
26
+ # Secrets and lifetimes pulled from settings at construction time so
27
+ # subclasses of UserManager work in tests without full app startup.
28
+ def __init__(
29
+ self,
30
+ user_db: UserDatabaseWithRoles,
31
+ mailer: Mailer,
32
+ settings: UsersSettings,
33
+ ) -> None:
34
+ super().__init__(user_db)
35
+ self.mailer = mailer
36
+ self.reset_password_token_secret = settings.reset_password_token_secret
37
+ self.verification_token_secret = settings.verification_token_secret
38
+ self.reset_password_token_lifetime_seconds = settings.reset_password_token_lifetime_seconds
39
+ self.verification_token_lifetime_seconds = settings.verification_token_lifetime_seconds
40
+
41
+ # ── Password policy ──────────────────────────────────────
42
+
43
+ async def validate_password(self, password: str, user) -> None:
44
+ if len(password) < 8:
45
+ raise exceptions.InvalidPasswordException(
46
+ reason="Password must be at least 8 characters"
47
+ )
48
+ if password.lower() in user.email.lower():
49
+ raise exceptions.InvalidPasswordException(reason="Password cannot contain your email")
50
+ if password.isdigit():
51
+ raise exceptions.InvalidPasswordException(reason="Password cannot be all numbers")
52
+
53
+ # ── Lifecycle hooks ──────────────────────────────────────
54
+
55
+ async def on_after_register(self, user: User, request: Request | None = None) -> None:
56
+ await self._publish_user_registered(user, request)
57
+ if not user.is_verified:
58
+ # Kicks off on_after_request_verify, which sends the email
59
+ await self.request_verify(user, request)
60
+
61
+ async def _publish_user_registered(self, user: User, request: Request | None) -> None:
62
+ """Emit ``UserRegistered`` on the app-wide event bus.
63
+
64
+ CLI bootstrap and unit tests instantiate the manager without a
65
+ request, so there's no app context from which to reach the bus —
66
+ publication is best-effort in those cases.
67
+ """
68
+ if request is None:
69
+ return
70
+ await request.app.state.sm.event_bus.publish(
71
+ UserRegistered(user_id=user.id, email=user.email)
72
+ )
73
+
74
+ async def on_after_forgot_password(
75
+ self, user: User, token: str, request: Request | None = None
76
+ ) -> None:
77
+ await self.mailer.send_password_reset(user.email, token)
78
+
79
+ async def on_after_request_verify(
80
+ self, user: User, token: str, request: Request | None = None
81
+ ) -> None:
82
+ await self.mailer.send_verification(user.email, token)
83
+
84
+ async def on_after_login(
85
+ self, user: User, request: Request | None = None, response=None
86
+ ) -> None:
87
+ user.last_login_at = datetime.now(UTC)
88
+ await self.user_db.update(user, {"last_login_at": user.last_login_at})
89
+
90
+ # ── Token helpers (no email side-effect) ─────────────────
91
+
92
+ async def generate_verification_token(self, user: User) -> str:
93
+ """Mint a verify-audience JWT without firing on_after_request_verify.
94
+
95
+ Used by the admin-invite flow: the verify-token primitive is reused
96
+ for invites, but the email template differs. request_verify() couples
97
+ token generation with email send — this decouples them.
98
+ """
99
+ token_data = {
100
+ "sub": str(user.id),
101
+ "email": user.email,
102
+ "aud": self.verification_token_audience,
103
+ }
104
+ return generate_jwt(
105
+ token_data,
106
+ self.verification_token_secret,
107
+ self.verification_token_lifetime_seconds,
108
+ )
109
+
110
+ async def generate_reset_password_token(self, user: User) -> str:
111
+ """Mint a reset-audience JWT without firing on_after_forgot_password.
112
+
113
+ Shape matches ``BaseUserManager.forgot_password``: the payload includes
114
+ ``password_fgpt`` (a bcrypt of the current hashed_password) so the token
115
+ invalidates when the password changes. Used by the admin
116
+ reset-password-link endpoint, where the admin copies the link instead
117
+ of triggering an email.
118
+
119
+ The fingerprint must be bcrypt-style because the stock
120
+ ``reset_password`` path in fastapi-users verifies it with
121
+ ``password_helper.verify_and_update``. Bcrypt is CPU-bound (~100ms at
122
+ default rounds) so we offload it to a worker thread — otherwise a
123
+ single admin action would stall the event loop for other requests.
124
+ """
125
+ fingerprint = await asyncio.to_thread(self.password_helper.hash, user.hashed_password)
126
+ token_data = {
127
+ "sub": str(user.id),
128
+ "password_fgpt": fingerprint,
129
+ "aud": self.reset_password_token_audience,
130
+ }
131
+ return generate_jwt(
132
+ token_data,
133
+ self.reset_password_token_secret,
134
+ self.reset_password_token_lifetime_seconds,
135
+ )
136
+
137
+
138
+ async def get_user_manager(
139
+ request: Request,
140
+ user_db: UserDatabaseWithRoles = Depends(get_user_db),
141
+ ):
142
+ """FastAPI dependency — pulls mailer and settings off app.state."""
143
+ users = request.app.state.users
144
+ mailer = users.mailer
145
+ settings = users.settings
146
+ yield UserManager(user_db, mailer, settings)
users/middleware.py ADDED
@@ -0,0 +1,143 @@
1
+ """Local-user auth middleware — replaces the Keycloak session reader.
2
+
3
+ Reads ``session["user_id"]``, loads the User row with eagerly-loaded roles,
4
+ builds a UserContext, and sets ``request.state.user`` + the
5
+ ``current_user_id`` ContextVar consumed by DB audit listeners.
6
+
7
+ The resolved ``UserContext`` is cached in the signed session cookie under
8
+ ``session["user_ctx"]`` so subsequent requests skip the DB lookup. The cache
9
+ is refreshed when the session is cleared (logout / rotation) or when the
10
+ cached payload is missing/invalid. Trade-off: admin-side changes (role
11
+ assignment, disable/enable) do not take effect until the affected user's
12
+ session is recreated (re-login or session expiry); acceptable for this app.
13
+
14
+ Registered via ``UsersModule.register_middleware``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import uuid
21
+
22
+ from auth.contracts.schemas import UserContext
23
+ from simple_module_db.listeners import current_user_id
24
+ from sqlalchemy import select
25
+ from sqlalchemy.orm import selectinload
26
+ from starlette.requests import Request
27
+ from starlette.responses import RedirectResponse
28
+ from starlette.types import ASGIApp, Receive, Scope, Send
29
+
30
+ from users.constants import SESSION_USER_ID_KEY
31
+ from users.models import User
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ SESSION_USER_CTX_KEY = "user_ctx"
36
+ _SESSION_USER_ID_KEY = SESSION_USER_ID_KEY
37
+ _SESSION_NEXT_KEY = "next"
38
+ _SCOPE_HTTP = "http"
39
+ _LOGIN_REDIRECT = "/users/login"
40
+
41
+ # Paths that don't require authentication.
42
+ PUBLIC_PATHS = (
43
+ "/users/login",
44
+ "/users/register",
45
+ "/users/forgot-password",
46
+ "/users/reset-password",
47
+ "/users/verify",
48
+ "/users/invite/accept",
49
+ "/api/users/auth/",
50
+ "/api/users/register",
51
+ "/health",
52
+ "/static/",
53
+ "/api/docs",
54
+ "/api/redoc",
55
+ "/openapi.json",
56
+ "/i18n/",
57
+ )
58
+ EXACT_PUBLIC_PATHS = ("/",)
59
+
60
+
61
+ class AuthMiddleware:
62
+ """Redirect unauthenticated users to /users/login.
63
+
64
+ On cache hit (``session["user_ctx"]`` present), skips the DB entirely.
65
+ On cache miss, loads the user with roles, validates active/enabled, and
66
+ writes the resolved context back to the session.
67
+ """
68
+
69
+ def __init__(self, app: ASGIApp) -> None:
70
+ self.app = app
71
+
72
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
73
+ if scope["type"] != _SCOPE_HTTP:
74
+ await self.app(scope, receive, send)
75
+ return
76
+
77
+ path = scope["path"]
78
+ is_public = any(path.startswith(p) for p in PUBLIC_PATHS) or path in EXACT_PUBLIC_PATHS
79
+
80
+ session = scope["session"]
81
+ raw_user_id = session.get(_SESSION_USER_ID_KEY)
82
+
83
+ user_ctx: UserContext | None = None
84
+ if raw_user_id:
85
+ user_id_str = str(raw_user_id)
86
+ # Fast path — rebuild from the signed session cookie.
87
+ user_ctx = UserContext.from_session_dict(session.get(SESSION_USER_CTX_KEY))
88
+ if user_ctx is None or user_ctx.id != user_id_str:
89
+ try:
90
+ user_uuid = uuid.UUID(user_id_str)
91
+ except (ValueError, TypeError):
92
+ logger.warning("Invalid user_id in session: %r", raw_user_id)
93
+ session.pop(_SESSION_USER_ID_KEY, None)
94
+ session.pop(SESSION_USER_CTX_KEY, None)
95
+ user_ctx = None
96
+ else:
97
+ user_ctx = await self._load_user(scope, user_uuid)
98
+ if user_ctx is None:
99
+ # User was deleted / disabled since session creation.
100
+ session.pop(_SESSION_USER_ID_KEY, None)
101
+ session.pop(SESSION_USER_CTX_KEY, None)
102
+ else:
103
+ session[SESSION_USER_CTX_KEY] = user_ctx.to_session_dict()
104
+
105
+ if user_ctx is None and not is_public:
106
+ request = Request(scope)
107
+ session[_SESSION_NEXT_KEY] = str(request.url)
108
+ response = RedirectResponse(_LOGIN_REDIRECT, status_code=302)
109
+ await response(scope, receive, send)
110
+ return
111
+
112
+ if user_ctx is not None:
113
+ request = Request(scope)
114
+ request.state.user = user_ctx
115
+ token = current_user_id.set(user_ctx.id)
116
+ try:
117
+ await self.app(scope, receive, send)
118
+ finally:
119
+ current_user_id.reset(token)
120
+ return
121
+
122
+ await self.app(scope, receive, send)
123
+
124
+ async def _load_user(self, scope: Scope, user_id: uuid.UUID) -> UserContext | None:
125
+ """Open a fresh session from app.state.db and load the User + roles.
126
+
127
+ Returns a UserContext, or None if the user doesn't exist or is
128
+ disabled/inactive. The session is closed on exit; we never commit
129
+ (read-only).
130
+ """
131
+ try:
132
+ session_factory = scope["app"].state.sm.db.session_factory
133
+ async with session_factory() as db_session:
134
+ stmt = select(User).where(User.id == user_id).options(selectinload(User.roles))
135
+ user = (await db_session.execute(stmt)).scalar_one_or_none()
136
+ if user is None:
137
+ return None
138
+ if not user.is_active or user.disabled_at is not None:
139
+ return None
140
+ return UserContext.from_user(user)
141
+ except Exception:
142
+ logger.exception("Failed to load user %s from DB; treating as unauthenticated", user_id)
143
+ return None
@@ -0,0 +1,24 @@
1
+ """SQLModel tables for the users module — one entity per file under this package.
2
+
3
+ Existing imports like ``from users.models import User`` keep working via the
4
+ re-exports below.
5
+ """
6
+
7
+ from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
8
+ from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase
9
+
10
+ from users.models._base import Base
11
+ from users.models.access_token import UserAccessToken
12
+ from users.models.role import Role
13
+ from users.models.user import User
14
+ from users.models.user_role import UserRole
15
+
16
+ __all__ = [
17
+ "Base",
18
+ "Role",
19
+ "SQLAlchemyAccessTokenDatabase",
20
+ "SQLAlchemyUserDatabase",
21
+ "User",
22
+ "UserAccessToken",
23
+ "UserRole",
24
+ ]
users/models/_base.py ADDED
@@ -0,0 +1,9 @@
1
+ """Shared Base for the users module's tables.
2
+
3
+ Lives in its own module so individual entity files can import it without
4
+ triggering a package ``__init__`` cycle.
5
+ """
6
+
7
+ from simple_module_db.base import create_module_base
8
+
9
+ Base = create_module_base("users")
@@ -0,0 +1,33 @@
1
+ """Access-token table backing fastapi-users' DatabaseStrategy.
2
+
3
+ Column surface mirrors fastapi-users' ``SQLAlchemyBaseAccessTokenTableUUID``
4
+ so the ``SQLAlchemyAccessTokenDatabase`` adapter binds to it without
5
+ inheriting from the upstream base class.
6
+ """
7
+
8
+ import uuid
9
+ from datetime import datetime
10
+
11
+ from fastapi_users_db_sqlalchemy.generics import GUID, TIMESTAMPAware, now_utc
12
+ from sqlmodel import Field
13
+
14
+ from users.models._base import Base
15
+
16
+
17
+ class UserAccessToken(Base, table=True): # ty: ignore[unsupported-base]
18
+ """fastapi-users DatabaseStrategy-backed access tokens."""
19
+
20
+ __tablename__ = "users_access_token"
21
+
22
+ token: str = Field(max_length=43, primary_key=True)
23
+ created_at: datetime = Field(
24
+ default_factory=now_utc,
25
+ sa_type=TIMESTAMPAware(timezone=True),
26
+ index=True,
27
+ )
28
+ user_id: uuid.UUID = Field(
29
+ sa_type=GUID,
30
+ foreign_key="users_user.id",
31
+ ondelete="CASCADE",
32
+ index=True, # PostgreSQL does not auto-index FKs
33
+ )
users/models/role.py ADDED
@@ -0,0 +1,34 @@
1
+ """Role table — a named role that holds permission strings."""
2
+
3
+ # NOTE: intentionally no ``from __future__ import annotations`` — SQLModel
4
+ # Relationship resolution requires runtime annotations.
5
+
6
+ import uuid
7
+
8
+ from fastapi_users_db_sqlalchemy.generics import GUID
9
+ from simple_module_db.mixins import AuditMixin
10
+ from sqlmodel import Field, Relationship
11
+
12
+ from users.models._base import Base
13
+ from users.models.user import User
14
+ from users.models.user_role import UserRole
15
+
16
+
17
+ class Role(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
18
+ """A named role that can hold a set of permission strings."""
19
+
20
+ __tablename__ = "users_role"
21
+
22
+ id: uuid.UUID = Field(
23
+ default_factory=uuid.uuid4,
24
+ sa_type=GUID,
25
+ primary_key=True,
26
+ )
27
+ name: str = Field(max_length=64, unique=True, index=True)
28
+ description: str | None = Field(default=None, max_length=255)
29
+
30
+ users: list[User] = Relationship(
31
+ link_model=UserRole,
32
+ back_populates="roles",
33
+ sa_relationship_kwargs={"lazy": "noload"},
34
+ )
users/models/user.py ADDED
@@ -0,0 +1,67 @@
1
+ """Local user table.
2
+
3
+ Column surface mirrors fastapi-users' ``SQLAlchemyBaseUserTableUUID`` so the
4
+ ``SQLAlchemyUserDatabase`` adapter binds to it without inheriting from the
5
+ upstream base class (which uses SQLAlchemy ``Mapped`` and is incompatible with
6
+ SQLModel's metaclass).
7
+ """
8
+
9
+ # NOTE: intentionally no ``from __future__ import annotations`` — SQLModel
10
+ # Relationship resolution requires runtime annotations (not stringified ones)
11
+ # for forward references like ``list["Role"]`` to work correctly.
12
+
13
+ import uuid
14
+ from datetime import datetime
15
+ from typing import TYPE_CHECKING
16
+
17
+ from fastapi_users_db_sqlalchemy.generics import GUID
18
+ from simple_module_db.mixins import AuditMixin
19
+ from sqlalchemy import DateTime, Index, text
20
+ from sqlmodel import Field, Relationship
21
+
22
+ from users.models._base import Base
23
+ from users.models.user_role import UserRole
24
+
25
+ if TYPE_CHECKING:
26
+ # Resolved at runtime by SQLModel via the string forward ref;
27
+ # this import only feeds the type checker.
28
+ from users.models.role import Role
29
+
30
+
31
+ class User(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
32
+ """Local user. Column surface mirrors fastapi-users' SQLAlchemyBaseUserTableUUID."""
33
+
34
+ __tablename__ = "users_user"
35
+
36
+ id: uuid.UUID = Field(
37
+ default_factory=uuid.uuid4,
38
+ sa_type=GUID,
39
+ primary_key=True,
40
+ )
41
+ email: str = Field(max_length=320, unique=True, index=True)
42
+ hashed_password: str = Field(max_length=1024)
43
+ is_active: bool = Field(default=True)
44
+ is_superuser: bool = Field(default=False)
45
+ is_verified: bool = Field(default=False)
46
+
47
+ full_name: str | None = Field(default=None, max_length=255)
48
+ tenant_id: str | None = Field(default=None, max_length=50, index=True)
49
+ disabled_at: datetime | None = Field(
50
+ default=None,
51
+ sa_type=DateTime(timezone=True),
52
+ )
53
+ last_login_at: datetime | None = Field(
54
+ default=None,
55
+ sa_type=DateTime(timezone=True),
56
+ index=True,
57
+ )
58
+
59
+ roles: list["Role"] = Relationship(
60
+ link_model=UserRole,
61
+ back_populates="users",
62
+ sa_relationship_kwargs={"lazy": "noload"},
63
+ )
64
+
65
+ # Functional index so the ``lower(email)`` predicate used by
66
+ # ``UserDatabaseWithRoles.get_by_email`` can be served from an index.
67
+ __table_args__ = (Index("ix_users_user_email_lower", text("lower(email)")),)
@@ -0,0 +1,39 @@
1
+ """Association table linking users and roles."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+
6
+ from fastapi_users_db_sqlalchemy.generics import GUID, now_utc
7
+ from sqlalchemy import DateTime, Index
8
+ from sqlmodel import Field
9
+
10
+ from users.models._base import Base
11
+
12
+
13
+ class UserRole(Base, table=True): # ty: ignore[unsupported-base]
14
+ """Association table between users and roles."""
15
+
16
+ __tablename__ = "users_user_role"
17
+
18
+ user_id: uuid.UUID = Field(
19
+ sa_type=GUID,
20
+ foreign_key="users_user.id",
21
+ ondelete="CASCADE",
22
+ primary_key=True,
23
+ )
24
+ role_id: uuid.UUID = Field(
25
+ sa_type=GUID,
26
+ foreign_key="users_role.id",
27
+ ondelete="CASCADE",
28
+ primary_key=True,
29
+ )
30
+ assigned_at: datetime = Field(
31
+ default_factory=now_utc,
32
+ sa_type=DateTime(timezone=True),
33
+ )
34
+ assigned_by: str | None = Field(default=None, max_length=255)
35
+
36
+ # The composite PK covers ``user_id``-first lookups; add a standalone
37
+ # index on ``role_id`` for reverse lookups — PostgreSQL does not
38
+ # auto-index FKs.
39
+ __table_args__ = (Index("ix_users_user_role_role_id", "role_id"),)
users/module.py ADDED
@@ -0,0 +1,155 @@
1
+ """Users module — local-account authentication and user management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from fastapi import APIRouter
8
+ from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
9
+ from simple_module_core.module import ModuleBase, ModuleMeta
10
+ from simple_module_core.permissions import PermissionRegistry
11
+
12
+ from users.constants import (
13
+ ADMIN_ROLE_NAME,
14
+ PERM_USERS_MANAGE,
15
+ PERM_USERS_SELF_PROFILE,
16
+ USER_ROLE_NAME,
17
+ )
18
+
19
+ if TYPE_CHECKING:
20
+ from fastapi import FastAPI
21
+
22
+ _MODULE_DEPENDENCY_AUTH = "Auth"
23
+
24
+ # Menu URLs
25
+ _URL_USERS_ADMIN = "/users/admin"
26
+ _URL_USERS_ME = "/users/me"
27
+ _URL_USERS_LOGOUT = "/users/logout"
28
+
29
+ # Menu icons
30
+ _ICON_USERS = "users"
31
+ _ICON_USER = "user"
32
+ _ICON_LOG_OUT = "log-out"
33
+
34
+
35
+ class UsersModule(ModuleBase):
36
+ meta = ModuleMeta(
37
+ name="Users",
38
+ route_prefix="/api/users",
39
+ view_prefix="/users",
40
+ depends_on=[_MODULE_DEPENDENCY_AUTH],
41
+ )
42
+
43
+ def register_settings(self, app: FastAPI) -> None:
44
+ import importlib
45
+
46
+ from auth.contracts.schemas import UserContext
47
+
48
+ from users.settings import UsersSettings
49
+ from users.state import UsersState
50
+
51
+ # SM009 is AST-based: a static `from settings.registration import ...`
52
+ # from a module helper is fine (plugin→plugin), but we resolve via
53
+ # importlib here to match the convention used framework-side and to
54
+ # keep the dependency direction one-way explicit.
55
+ register_module_settings = importlib.import_module(
56
+ "settings.registration"
57
+ ).register_module_settings
58
+
59
+ register_module_settings(app, "users", UsersSettings, lambda s: UsersState(settings=s))
60
+
61
+ def serialize_principal(user: UserContext) -> dict:
62
+ return {
63
+ "id": user.id,
64
+ "name": user.name,
65
+ "email": user.email,
66
+ "roles": user.roles,
67
+ }
68
+
69
+ app.state.principal_serializer = serialize_principal
70
+
71
+ def register_permissions(self, registry: PermissionRegistry) -> None:
72
+ registry.add_group(
73
+ "Users",
74
+ [PERM_USERS_MANAGE, PERM_USERS_SELF_PROFILE],
75
+ )
76
+ registry.map_role(USER_ROLE_NAME, [PERM_USERS_SELF_PROFILE])
77
+
78
+ def register_menu_items(self, registry: MenuRegistry) -> None:
79
+ # Admin-only user management
80
+ registry.add(
81
+ MenuItem(
82
+ label="Users",
83
+ url=_URL_USERS_ADMIN,
84
+ icon=_ICON_USERS,
85
+ order=30,
86
+ section=MenuSection.SIDEBAR,
87
+ roles=[ADMIN_ROLE_NAME],
88
+ )
89
+ )
90
+ # Self-service: profile + logout live in the user dropdown.
91
+ registry.add(
92
+ MenuItem(
93
+ label="Profile",
94
+ url=_URL_USERS_ME,
95
+ icon=_ICON_USER,
96
+ order=990,
97
+ section=MenuSection.USER_DROPDOWN,
98
+ )
99
+ )
100
+ registry.add(
101
+ MenuItem(
102
+ label="Logout",
103
+ url=_URL_USERS_LOGOUT,
104
+ icon=_ICON_LOG_OUT,
105
+ order=999,
106
+ section=MenuSection.USER_DROPDOWN,
107
+ method="post",
108
+ )
109
+ )
110
+
111
+ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
112
+ from users.endpoints.api import register_auth_routes
113
+ from users.endpoints.views import router as views
114
+
115
+ # The register router is always mounted; its `allow_signup` gate lives
116
+ # on a per-request dependency, so toggling the setting at runtime takes
117
+ # effect without needing to remount.
118
+ register_auth_routes(api_router)
119
+ view_router.include_router(views)
120
+
121
+ def register_middleware(self, app: FastAPI) -> None:
122
+ from users.middleware import AuthMiddleware
123
+
124
+ app.add_middleware(AuthMiddleware)
125
+
126
+ async def on_startup(self, app: FastAPI) -> None:
127
+ """Build the mailer, rate limiter, and apply production cookie params."""
128
+ import asyncio
129
+
130
+ from users.backend import reconfigure_cookie_transport
131
+ from users.bootstrap import bootstrap_admin_from_env
132
+ from users.deps import auth_backend
133
+ from users.mailer import build_mailer
134
+ from users.rate_limit import LoginRateLimiter, ThroughputLimiter
135
+ from users.roles_cache import refresh_roles_cache
136
+
137
+ state = app.state.users
138
+ s = state.settings
139
+ state.mailer = build_mailer(s)
140
+ state.rate_limiter = LoginRateLimiter(
141
+ max_failures=s.login_rate_limit_failures,
142
+ window_seconds=s.login_rate_limit_window_seconds,
143
+ cooldown_seconds=s.login_rate_limit_cooldown_seconds,
144
+ )
145
+ state.auth_throughput_limiter = ThroughputLimiter(
146
+ max_attempts=s.auth_rate_limit_attempts,
147
+ window_seconds=s.auth_rate_limit_window_seconds,
148
+ )
149
+
150
+ reconfigure_cookie_transport(auth_backend, s)
151
+
152
+ await asyncio.gather(
153
+ bootstrap_admin_from_env(app),
154
+ refresh_roles_cache(app),
155
+ )
users/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@simple-module-py/users",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Frontend assets for the Users module",
6
+ "peerDependencies": {
7
+ "react": "^19.0.0",
8
+ "react-dom": "^19.0.0",
9
+ "@inertiajs/react": "^2.0.0",
10
+ "@simple-module-py/ui": "*"
11
+ },
12
+ "devDependencies": {
13
+ "@simple-module-py/tsconfig": "*"
14
+ },
15
+ "dependencies": {}
16
+ }
users/pages/.gitkeep ADDED
File without changes