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/service.py ADDED
@@ -0,0 +1,257 @@
1
+ """UserService — admin operations delegating to the DB and UserManager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ import uuid
7
+ from datetime import UTC, datetime
8
+
9
+ from sqlalchemy import delete, func, or_, select
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+ from sqlalchemy.orm import selectinload
12
+
13
+ from users.contracts.schemas import RoleListItem, UserCreate, UserListItem
14
+ from users.exceptions import UserNotFoundError
15
+ from users.manager import UserManager
16
+ from users.models import Role, User, UserRole
17
+
18
+
19
+ class UserService:
20
+ def __init__(
21
+ self,
22
+ db: AsyncSession,
23
+ user_manager: UserManager,
24
+ ) -> None:
25
+ self._db = db
26
+ self._manager = user_manager
27
+
28
+ # ── Helpers ─────────────────────────────────────────────────
29
+
30
+ async def _resolve_roles(self, role_names: list[str]) -> list[Role]:
31
+ """Return Role ORM objects matching the given names."""
32
+ if not role_names:
33
+ return []
34
+ result = await self._db.execute(select(Role).where(Role.name.in_(role_names)))
35
+ return list(result.scalars().all())
36
+
37
+ def to_list_item(self, user: User) -> UserListItem:
38
+ """Build the DTO from a User with roles already eager-loaded."""
39
+ return UserListItem(
40
+ id=user.id,
41
+ email=user.email,
42
+ full_name=user.full_name,
43
+ is_active=user.is_active,
44
+ is_verified=user.is_verified,
45
+ disabled_at=user.disabled_at,
46
+ last_login_at=user.last_login_at,
47
+ created_at=user.created_at,
48
+ roles=[r.name for r in user.roles],
49
+ )
50
+
51
+ async def list_roles(self) -> list[RoleListItem]:
52
+ stmt = (
53
+ select(Role, func.count(UserRole.user_id))
54
+ .outerjoin(UserRole, UserRole.role_id == Role.id)
55
+ .group_by(Role.id)
56
+ .order_by(Role.name)
57
+ )
58
+ result = await self._db.execute(stmt)
59
+ return [
60
+ RoleListItem(
61
+ id=role.id,
62
+ name=role.name,
63
+ description=role.description,
64
+ user_count=user_count,
65
+ )
66
+ for role, user_count in result.all()
67
+ ]
68
+
69
+ async def _get_user_with_roles(self, user_id: uuid.UUID) -> User | None:
70
+ result = await self._db.execute(
71
+ select(User).where(User.id == user_id).options(selectinload(User.roles))
72
+ )
73
+ return result.scalar_one_or_none()
74
+
75
+ async def _require_user(self, user_id: uuid.UUID) -> User:
76
+ """Fetch a user with roles eager-loaded, or raise UserNotFoundError."""
77
+ user = await self._get_user_with_roles(user_id)
78
+ if user is None:
79
+ raise UserNotFoundError(user_id)
80
+ return user
81
+
82
+ # ── Public API ───────────────────────────────────────────────
83
+
84
+ async def list_users(
85
+ self,
86
+ *,
87
+ page: int = 1,
88
+ per_page: int = 20,
89
+ search: str | None = None,
90
+ status: str | None = None,
91
+ role_name: str | None = None,
92
+ verified: str | None = None,
93
+ sort: str = "email",
94
+ order: str = "asc",
95
+ ) -> tuple[list[UserListItem], int]:
96
+ """Returns (items, total_count). last_login_at sort always uses NULLS LAST."""
97
+ stmt = select(User).options(selectinload(User.roles))
98
+ count_stmt = select(func.count()).select_from(User)
99
+
100
+ conditions = []
101
+
102
+ if search:
103
+ pattern = f"%{search}%"
104
+ conditions.append(
105
+ or_(
106
+ User.email.ilike(pattern),
107
+ User.full_name.ilike(pattern),
108
+ )
109
+ )
110
+
111
+ if status == "active":
112
+ conditions.append(User.is_active.is_(True))
113
+ elif status == "disabled":
114
+ conditions.append(User.is_active.is_(False))
115
+
116
+ if verified == "yes":
117
+ conditions.append(User.is_verified.is_(True))
118
+ elif verified == "no":
119
+ conditions.append(User.is_verified.is_(False))
120
+
121
+ if role_name is not None:
122
+ subq = (
123
+ select(UserRole.user_id)
124
+ .join(Role, Role.id == UserRole.role_id)
125
+ .where(Role.name == role_name)
126
+ )
127
+ conditions.append(User.id.in_(subq))
128
+
129
+ for cond in conditions:
130
+ stmt = stmt.where(cond)
131
+ count_stmt = count_stmt.where(cond)
132
+
133
+ total = (await self._db.execute(count_stmt)).scalar_one()
134
+
135
+ sort_col_map = {
136
+ "email": User.email,
137
+ "last_login_at": User.last_login_at,
138
+ "created_at": User.created_at,
139
+ }
140
+ sort_col = sort_col_map.get(sort, User.email)
141
+
142
+ if sort == "last_login_at":
143
+ order_clause = (
144
+ sort_col.desc().nulls_last() # type: ignore[union-attr]
145
+ if order == "desc"
146
+ else sort_col.asc().nulls_last() # type: ignore[union-attr]
147
+ )
148
+ else:
149
+ order_clause = (
150
+ sort_col.desc() if order == "desc" else sort_col.asc() # type: ignore[union-attr]
151
+ )
152
+
153
+ stmt = stmt.order_by(order_clause).offset((page - 1) * per_page).limit(per_page)
154
+ rows = (await self._db.execute(stmt)).scalars().all()
155
+
156
+ items = [self.to_list_item(u) for u in rows]
157
+ return items, total
158
+
159
+ async def invite(
160
+ self,
161
+ email: str,
162
+ full_name: str | None,
163
+ role_names: list[str],
164
+ *,
165
+ invited_by: User | None = None,
166
+ ) -> tuple[User, str]:
167
+ """Creates unverified user + random unusable password, assigns roles,
168
+ mints a verification token. Returns (user, token)."""
169
+ password = secrets.token_urlsafe(32)
170
+ user_create = UserCreate(
171
+ email=email,
172
+ password=password,
173
+ full_name=full_name,
174
+ is_active=True,
175
+ is_verified=False,
176
+ )
177
+ user = await self._manager.create(user_create, safe=False)
178
+
179
+ # Assign roles
180
+ roles = await self._resolve_roles(role_names)
181
+ invited_by_str = str(invited_by.id) if invited_by else None
182
+ for role in roles:
183
+ self._db.add(
184
+ UserRole(
185
+ user_id=user.id,
186
+ role_id=role.id,
187
+ assigned_by=invited_by_str,
188
+ )
189
+ )
190
+ if roles:
191
+ await self._db.flush()
192
+ await self._db.refresh(user, attribute_names=["roles"])
193
+
194
+ token = await self._manager.generate_verification_token(user)
195
+ return user, token
196
+
197
+ async def disable(self, user_id: uuid.UUID) -> User:
198
+ user = await self._require_user(user_id)
199
+ user.disabled_at = datetime.now(UTC)
200
+ user.is_active = False
201
+ await self._db.flush()
202
+ return user
203
+
204
+ async def enable(self, user_id: uuid.UUID) -> User:
205
+ user = await self._require_user(user_id)
206
+ user.disabled_at = None
207
+ user.is_active = True
208
+ await self._db.flush()
209
+ return user
210
+
211
+ async def mark_verified(self, user_id: uuid.UUID) -> User:
212
+ user = await self._require_user(user_id)
213
+ if not user.is_verified:
214
+ user.is_verified = True
215
+ await self._db.flush()
216
+ return user
217
+
218
+ async def set_roles(
219
+ self,
220
+ user_id: uuid.UUID,
221
+ role_names: list[str],
222
+ *,
223
+ assigned_by: str | None = None,
224
+ ) -> User:
225
+ user = await self._require_user(user_id)
226
+
227
+ # Delete all existing role assignments for this user
228
+ await self._db.execute(delete(UserRole).where(UserRole.user_id == user_id))
229
+
230
+ # Insert new role assignments
231
+ roles = await self._resolve_roles(role_names)
232
+ for role in roles:
233
+ self._db.add(
234
+ UserRole(
235
+ user_id=user_id,
236
+ role_id=role.id,
237
+ assigned_by=assigned_by,
238
+ )
239
+ )
240
+
241
+ await self._db.flush()
242
+ await self._db.refresh(user, attribute_names=["roles"])
243
+ return user
244
+
245
+ async def generate_reset_link(self, user_id: uuid.UUID, base_url: str) -> str:
246
+ """Build an admin-copyable password-reset URL. No email side-effect."""
247
+ user = await self._require_user(user_id)
248
+
249
+ token = await self._manager.generate_reset_password_token(user)
250
+ return f"{base_url.rstrip('/')}/users/reset-password?token={token}"
251
+
252
+ async def get_with_roles(self, user_id: uuid.UUID) -> User | None:
253
+ return await self._get_user_with_roles(user_id)
254
+
255
+ async def get_list_item(self, user_id: uuid.UUID) -> UserListItem:
256
+ user = await self._require_user(user_id)
257
+ return self.to_list_item(user)
users/settings.py ADDED
@@ -0,0 +1,99 @@
1
+ """Users module settings — DB-backed via ``register_module_settings``.
2
+
3
+ Construction no longer reads ``SM_USERS_*`` environment variables. Values
4
+ come from pydantic defaults at boot, then get hydrated from the DB by the
5
+ hosting lifespan before module ``on_startup`` runs. Runtime changes go
6
+ through ``settings.reload.apply_changes_and_reload``.
7
+
8
+ The one remaining env read is ``SM_ENVIRONMENT``, consulted by the
9
+ ``@model_validator`` to refuse placeholder token secrets in production —
10
+ that's a host-level setting, not a users-module field.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+
17
+ from pydantic import Field, model_validator
18
+ from pydantic_settings import BaseSettings, SettingsConfigDict
19
+ from simple_module_core.environments import NON_PROD_ENVIRONMENTS
20
+
21
+ _PLACEHOLDER_RESET_SECRET = "dev-reset-token-secret-change-me"
22
+ _PLACEHOLDER_VERIFY_SECRET = "dev-verify-token-secret-change-me"
23
+
24
+
25
+ class UsersSettings(BaseSettings):
26
+ """Local user management configuration."""
27
+
28
+ model_config = SettingsConfigDict(extra="ignore")
29
+
30
+ # Self-service signup
31
+ allow_signup: bool = False
32
+ require_verification: bool = True
33
+
34
+ # Token secrets — MUST be set in production. Dev default is a deterministic
35
+ # placeholder that's obvious in logs so it can't be mistaken for a real key.
36
+ reset_password_token_secret: str = "dev-reset-token-secret-change-me"
37
+ verification_token_secret: str = "dev-verify-token-secret-change-me"
38
+ reset_password_token_lifetime_seconds: int = 60 * 60 # 1 hour
39
+ verification_token_lifetime_seconds: int = 60 * 60 * 24 * 7 # 7 days
40
+
41
+ # Cookie (fastapi-users AuthenticationBackend)
42
+ cookie_name: str = "sm_auth"
43
+ cookie_max_age_seconds: int = 60 * 60 * 24 * 14 # 14 days
44
+ cookie_secure: bool = True # flipped False in dev by the module at startup
45
+ cookie_samesite: str = "lax"
46
+
47
+ # Mailer
48
+ mailer: str = Field(default="console", pattern="^(console|smtp)$")
49
+ base_url: str = "http://localhost:8000"
50
+ smtp_host: str = ""
51
+ smtp_port: int = 587
52
+ smtp_username: str = ""
53
+ smtp_password: str = ""
54
+ smtp_from: str = "no-reply@localhost"
55
+ smtp_tls: bool = True
56
+
57
+ # Rate limit (login — failure-based lockout)
58
+ login_rate_limit_failures: int = 5
59
+ login_rate_limit_window_seconds: int = 300
60
+ login_rate_limit_cooldown_seconds: int = 900
61
+
62
+ # Rate limit (auth side-effects: forgot-password, register, accept-invite,
63
+ # request-verify-token). Counts every attempt per IP per window.
64
+ auth_rate_limit_attempts: int = 10
65
+ auth_rate_limit_window_seconds: int = 300
66
+
67
+ # Bootstrap (env-var auto-create users on first boot)
68
+ bootstrap_email: str = ""
69
+ bootstrap_password: str = ""
70
+ # Optional second seed user with the "user" role — handy in dev for
71
+ # testing non-admin flows without logging out/in repeatedly.
72
+ bootstrap_user_email: str = ""
73
+ bootstrap_user_password: str = ""
74
+
75
+ @model_validator(mode="after")
76
+ def _forbid_placeholder_token_secrets_in_production(self) -> UsersSettings:
77
+ """Fail boot if the reset/verify token secrets are still placeholders.
78
+
79
+ Both are HMAC keys for fastapi-users JWTs. A well-known default lets
80
+ an attacker mint password-reset or email-verification tokens for any
81
+ user. The environment is read from ``SM_ENVIRONMENT`` (host setting)
82
+ so this check has no runtime coupling to the hosting package.
83
+ """
84
+ env = os.environ.get("SM_ENVIRONMENT", "development")
85
+ if env in NON_PROD_ENVIRONMENTS:
86
+ return self
87
+ bad = []
88
+ if self.reset_password_token_secret == _PLACEHOLDER_RESET_SECRET:
89
+ bad.append("RESET_PASSWORD_TOKEN_SECRET")
90
+ if self.verification_token_secret == _PLACEHOLDER_VERIFY_SECRET:
91
+ bad.append("VERIFICATION_TOKEN_SECRET")
92
+ if bad:
93
+ names = ", ".join(bad)
94
+ raise ValueError(
95
+ f"users.{names} must be set to non-default value(s) when "
96
+ f"SM_ENVIRONMENT={env!r}. Generate with "
97
+ "`python -c 'import secrets; print(secrets.token_urlsafe(48))'`."
98
+ )
99
+ return self
users/state.py ADDED
@@ -0,0 +1,33 @@
1
+ """Module-scoped state container for the users module.
2
+
3
+ Stored as ``app.state.users`` by :meth:`UsersModule.register_settings` (for
4
+ fields available at that phase) and populated the rest of the way during
5
+ :meth:`UsersModule.on_startup` (for fields that depend on the DB or other
6
+ framework services).
7
+
8
+ Not frozen — ``on_startup`` needs to set fields that aren't available at
9
+ ``register_settings`` time. Convention: set once during boot, treat as
10
+ read-only after.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from users.mailer import Mailer
20
+ from users.rate_limit import LoginRateLimiter, ThroughputLimiter
21
+ from users.roles_cache import RoleSummary
22
+ from users.settings import UsersSettings
23
+
24
+
25
+ @dataclass
26
+ class UsersState:
27
+ """Users-module singletons. Single slot at ``app.state.users``."""
28
+
29
+ settings: UsersSettings
30
+ mailer: Mailer | None = None
31
+ rate_limiter: LoginRateLimiter | None = None
32
+ auth_throughput_limiter: ThroughputLimiter | None = None
33
+ roles_cache: list[RoleSummary] = field(default_factory=list)