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
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple_module_users
3
+ Version: 0.0.1
4
+ Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
5
+ Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python
7
+ Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
8
+ Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
9
+ Author-email: Anto Subash <antosubash@live.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: admin,authentication,fastapi-users,simple-module,users
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Framework :: FastAPI
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: aiosmtplib>=3.0
25
+ Requires-Dist: cachetools>=5.3
26
+ Requires-Dist: fastapi-users[sqlalchemy]<16,>=15
27
+ Requires-Dist: simple-module-auth==0.0.1
28
+ Requires-Dist: simple-module-core==0.0.1
29
+ Requires-Dist: simple-module-db==0.0.1
30
+ Requires-Dist: simple-module-hosting==0.0.1
31
+ Requires-Dist: typer>=0.12
32
+ Description-Content-Type: text/markdown
33
+
34
+ # simple_module_users
35
+
36
+ Email+password user management for [simple_module](https://github.com/antosubash/simple_module_python) apps. Replaces Keycloak/Auth0 for the common case: local accounts, admin invites, password reset, optional public signup. Built on `fastapi-users`.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install simple_module_users
42
+ ```
43
+
44
+ Pre-wired into any app scaffolded with `simple-module new`.
45
+
46
+ ## What it provides
47
+
48
+ - Email + password registration, login, logout, password reset.
49
+ - Admin invite flow — admin enters an email, recipient clicks a link, sets a password, is logged in.
50
+ - Public signup toggle (`SM_USERS_ALLOW_SIGNUP`, default `false`).
51
+ - Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
52
+ - `sm-users create-admin` CLI for ad-hoc admin creation.
53
+ - Inertia pages for login/register/invite-accept/admin-invite.
54
+ - Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
55
+
56
+ ## Usage
57
+
58
+ CLI:
59
+
60
+ ```bash
61
+ uv run sm-users create-admin --email admin@example.com --password 'change-me'
62
+ ```
63
+
64
+ Bootstrap-on-boot (`.env`):
65
+
66
+ ```
67
+ SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
68
+ SM_USERS_BOOTSTRAP_PASSWORD=change-me
69
+ ```
70
+
71
+ Program:
72
+
73
+ ```python
74
+ from users.deps import CurrentUser # type: ignore[import-not-found]
75
+
76
+ @router.get("/profile")
77
+ async def profile(user: CurrentUser):
78
+ return {"email": user.email}
79
+ ```
80
+
81
+ ## Depends on
82
+
83
+ - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_auth`
84
+ - `fastapi-users[sqlalchemy]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
85
+
86
+ ## License
87
+
88
+ MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
@@ -0,0 +1,56 @@
1
+ users/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ users/backend.py,sha256=JYmJjwoe3iTO40IdrIPvjzpslNkT89UQI9hVJDX5-wA,2996
3
+ users/bootstrap.py,sha256=NKTp-qw7uazjKMeMpCRLCUgGbkXqGRdVicdy8Kv66fA,9083
4
+ users/cli.py,sha256=UD0o050mmaNdeTefYQBEEryHDNPB4k-diVPxd72j6Bc,2305
5
+ users/constants.py,sha256=lKII5lU8EM5c2M2O0fDlj6pZ7UN0rfNjKcbYmc8vkzk,1246
6
+ users/db_adapter.py,sha256=U1UbyIdEE5yFout1hBjjwT3cFA25A6Smg5HX8PTUuPg,1669
7
+ users/deps.py,sha256=Ti39DK-gjOLIIKBFCFQStvauroYcbjrY6bXn6jJ99ws,2573
8
+ users/exceptions.py,sha256=lnOCg4hrzsSl7KAaki7Mvg2jaCjabMQp38uos_nV4JA,567
9
+ users/manager.py,sha256=lgpi1eKbbLkrFDYimdXsX7rrsR3J3_JC4g7yksGlH5w,6124
10
+ users/middleware.py,sha256=wJ6rupwnYG-dBB4z8DB35XsPi6zPx55BQKo1tUqGOC4,5421
11
+ users/module.py,sha256=yrLPIfU3xM1Loak1aGXyvWqomccirkmN9h2U8RcCn-4,5115
12
+ users/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ users/rate_limit.py,sha256=g8TaHxqdM23LCvQfCW3KVW0IST-r0pQ09ynjNroBzf0,2061
14
+ users/roles_cache.py,sha256=gHcgHTcQm74dBxyyMiQqTS7z2p5HNstwqbouaoadYFA,1979
15
+ users/service.py,sha256=RaW8qy5tlefyX6BwWE_lPTJDkQo4mTrSTU5Tp-mGPg0,8871
16
+ users/settings.py,sha256=AXNZCw7l1UDpQMsFGHdoxYSMulOdno33BOzWpP5h3Og,4140
17
+ users/state.py,sha256=ihONMTdR7LCmt231RH9MLQieONwbBg_I1x8u81h17tc,1133
18
+ users/components/IndexFilters.tsx,sha256=5wjAlbOwOI1nJTfdVcUuRLAWlG6sVGP5WekKLBdGRy4,2093
19
+ users/components/RolesTab.tsx,sha256=bBPgUHLosZ7wcBFkT86AjbHEvtUc_MEjblyQmfHD7MI,2473
20
+ users/contracts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ users/contracts/events.py,sha256=ertB61rTpShVH6DdRkoymox90vMmnby0pPe2HAfF0fI,508
22
+ users/contracts/schemas.py,sha256=axGqx7EbNDKZhCYQ5QJcz8zDIiL0zO_br9MhYOX5aE8,1952
23
+ users/endpoints/__init__.py,sha256=8CGquvVRhTQoa3d18azmSlckXCNnOuoQiKPpaOsKiSQ,52
24
+ users/endpoints/api.py,sha256=rWNuha1VlwsPINCR4B_h4-EgUISWBlcCJBIUAOyUp0k,8821
25
+ users/endpoints/api_admin.py,sha256=LspB4futZjjJ_M2mgFORTk4tZ8fwGyymyVEmkfVzrfw,5546
26
+ users/endpoints/views.py,sha256=Jf8F1hOLyOkmm6m4EyaxFHhUzPUnJCmHR_GNYyRTrBA,7852
27
+ users/mailer/__init__.py,sha256=HgojsPCXbV___Qy4NOdAq0xBPhT8GAqykaH4-Q4gQo0,1056
28
+ users/mailer/console.py,sha256=TMSIKWmWktjBg4O7tXr6Xo9EynEeTY_QLuqFESSr0rw,996
29
+ users/mailer/smtp.py,sha256=0U7p14uys3akvC0QTqbun-0-iYxY6sG6Yex7K4hynAc,2654
30
+ users/mailer/templates/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
+ users/mailer/templates/invite.txt,sha256=Krcaoc4Ezq9p6RWJaTzBdtM4JHrxCpbEZWEa5nwSaxo,54
32
+ users/mailer/templates/reset_password.txt,sha256=sCsjxTF8EEMBRxAKjnuoXSynf6sSCuaCJ7wM_4jZRM8,32
33
+ users/mailer/templates/verify_email.txt,sha256=V5ifrNvKT_ZcmpBcPEtvVOSdrh0a_xR01xDZ8t11JnQ,30
34
+ users/models/__init__.py,sha256=ZJWV3OEZn56VqXY_pX7NX8d2NiIUWqt10A3n62UsLLU,693
35
+ users/models/_base.py,sha256=UjYBWvdszy3KENo2Ti8YijRskVfooIdWzhsaMjdIocY,255
36
+ users/models/access_token.py,sha256=tbwSRLCM5jgr_e8guhECCOe1b7B1ydqksd2y5FAl0mM,1002
37
+ users/models/role.py,sha256=STghKPw_MPABnTRWa_o0ClAQV-Yquf4c6o6VeSg3vnw,1050
38
+ users/models/user.py,sha256=GCBHgP-nIXdjelxWjwtkW8XQTlz5UBbtm2qokLoba3I,2346
39
+ users/models/user_role.py,sha256=WPVaeRIsQnbunP65JJ2RgBhsL94s0G0pTqggn7YUvsA,1142
40
+ users/pages/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
+ users/pages/AcceptInvite.tsx,sha256=R-QD3AkDiiX5YQCdDzcNPhILeFkRNpUhv1QVjWu43ek,3583
42
+ users/pages/ForgotPassword.tsx,sha256=fcyqHqVnqbxFlOnaYt7AcGhkHUKSl0pCz3ieStxalgc,2936
43
+ users/pages/Login.tsx,sha256=zl1jyVZeiyilDtEs9MjOWlM8DwfphfjoZwTDnlo6W_A,6121
44
+ users/pages/Profile.tsx,sha256=hb6dk8uP0Zq6256-c87W5zAabVOke4yl4knlgTTAtRM,3649
45
+ users/pages/Register.tsx,sha256=r_4kYwGiwMlPgqeyeCcHcI47J6C81SDE5lHIKujV-ug,5168
46
+ users/pages/ResetPassword.tsx,sha256=auDD4W2t5a-9cO5fd-QDlGEMwPzs0rD9dzLMRilTc_M,3711
47
+ users/pages/VerifyEmail.tsx,sha256=Nv4ay5iYtmSZrFkJ3PN7uN211RWg0Ra-A6iGm53zK4c,3206
48
+ users/pages/Users/Edit.tsx,sha256=EGT1hLoDkgB8Xmi8SGTBuyOxmmYljZXKNNL07jxAcYQ,10339
49
+ users/pages/Users/Index.tsx,sha256=NpCwM_G4PMSzEGkteZ0gNp7S1yPlSEehvxu2gpGK184,10965
50
+ users/pages/Users/Invite.tsx,sha256=LqMiy194Wd5gyy60VdxqM8n_iLeZOiZ84xDL_KwIGaA,4658
51
+ users/package.json,sha256=M39G_TMKeQb4su8uWboYLWmWX9UyzoXMWyuvFzfL0HY,373
52
+ simple_module_users-0.0.1.dist-info/METADATA,sha256=ZDeHczXBw-qoVI-ar6L8JqV5W2e718TNR9bAd54c1TI,3198
53
+ simple_module_users-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
54
+ simple_module_users-0.0.1.dist-info/entry_points.txt,sha256=u_1H-mTaMW2PH6gTZztp_m3uYEYS6thtiocaO2X5j9w,93
55
+ simple_module_users-0.0.1.dist-info/licenses/LICENSE,sha256=Yn66lhLklsF5p7pa85_ksQrJ79Q-FgOaUAHevLBjer4,1068
56
+ simple_module_users-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ sm-users = users.cli:app
3
+
4
+ [simple_module]
5
+ users = users.module:UsersModule
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anto Subash
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
users/__init__.py ADDED
File without changes
users/backend.py ADDED
@@ -0,0 +1,85 @@
1
+ """Auth backend — cookie transport + DB access-token strategy.
2
+
3
+ The AuthenticationBackend is constructed once at import time in ``deps.py``
4
+ with dev-safe defaults so ``fastapi_users.current_user(...)`` — which is
5
+ captured by route-handler ``Depends(...)`` signatures at import time — has
6
+ a stable instance to bind against.
7
+
8
+ Real settings are applied at startup via :func:`reconfigure_cookie_transport`,
9
+ which updates the singleton's ``CookieTransport`` fields in place. Because
10
+ this reaches into fastapi-users' instance state, the package's major version
11
+ is pinned narrowly in pyproject.toml.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ from fastapi import Depends
19
+ from fastapi_users.authentication import AuthenticationBackend, CookieTransport
20
+ from fastapi_users.authentication.strategy.db import (
21
+ AccessTokenDatabase,
22
+ DatabaseStrategy,
23
+ )
24
+
25
+ from users.db_adapter import get_access_token_db
26
+ from users.models import UserAccessToken
27
+
28
+ if TYPE_CHECKING:
29
+ from users.settings import UsersSettings
30
+
31
+ _TOKEN_LIFETIME_SECONDS = 60 * 60 * 24 * 14 # 14 days
32
+ _AUTH_BACKEND_NAME = "cookie"
33
+
34
+
35
+ def build_cookie_transport(
36
+ cookie_name: str,
37
+ cookie_max_age_seconds: int,
38
+ cookie_secure: bool,
39
+ cookie_samesite: str,
40
+ ) -> CookieTransport:
41
+ return CookieTransport(
42
+ cookie_name=cookie_name,
43
+ cookie_max_age=cookie_max_age_seconds,
44
+ cookie_secure=cookie_secure,
45
+ cookie_httponly=True,
46
+ cookie_samesite=cookie_samesite, # type: ignore[arg-type]
47
+ )
48
+
49
+
50
+ def get_database_strategy(
51
+ access_token_db: AccessTokenDatabase[UserAccessToken] = Depends(get_access_token_db),
52
+ lifetime_seconds: int = _TOKEN_LIFETIME_SECONDS,
53
+ ) -> DatabaseStrategy:
54
+ return DatabaseStrategy(access_token_db, lifetime_seconds=lifetime_seconds)
55
+
56
+
57
+ def build_auth_backend(
58
+ cookie_transport: CookieTransport,
59
+ ) -> AuthenticationBackend:
60
+ return AuthenticationBackend(
61
+ name=_AUTH_BACKEND_NAME,
62
+ transport=cookie_transport,
63
+ get_strategy=get_database_strategy,
64
+ )
65
+
66
+
67
+ def reconfigure_cookie_transport(
68
+ backend: AuthenticationBackend,
69
+ settings: UsersSettings,
70
+ ) -> None:
71
+ """Apply production cookie config to an already-constructed backend.
72
+
73
+ Called from ``UsersModule.on_startup`` once the real ``UsersSettings``
74
+ is available on ``app.state``. This is the one place that depends on
75
+ fastapi-users' ``CookieTransport`` field names; any upstream rename
76
+ surfaces here rather than scattered across the codebase.
77
+ """
78
+ transport = backend.transport
79
+ assert isinstance(transport, CookieTransport), (
80
+ f"users auth_backend.transport must be a CookieTransport, got {type(transport)!r}"
81
+ )
82
+ transport.cookie_name = settings.cookie_name
83
+ transport.cookie_max_age = settings.cookie_max_age_seconds
84
+ transport.cookie_secure = settings.cookie_secure
85
+ transport.cookie_samesite = settings.cookie_samesite # type: ignore[assignment] # ty: ignore[invalid-assignment]
users/bootstrap.py ADDED
@@ -0,0 +1,246 @@
1
+ """Shared admin-creation logic used by the CLI and env-var auto-bootstrap."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from dataclasses import dataclass
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi_users.password import PasswordHelper
11
+ from simple_module_core.dotenv import parse_dotenv
12
+ from sqlalchemy import func, select
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from users.constants import (
16
+ ADMIN_ROLE_DESCRIPTION,
17
+ ADMIN_ROLE_ID,
18
+ ADMIN_ROLE_NAME,
19
+ USER_ROLE_DESCRIPTION,
20
+ USER_ROLE_ID,
21
+ USER_ROLE_NAME,
22
+ )
23
+ from users.models import Role, User, UserRole
24
+ from users.settings import UsersSettings
25
+
26
+ # Maps a UsersSettings attribute to the env var that seeds it on first boot.
27
+ # Shared between the bootstrap function and the test fixture that isolates it.
28
+ BOOTSTRAP_ENV_KEYS: dict[str, str] = {
29
+ "bootstrap_email": "SM_USERS_BOOTSTRAP_EMAIL",
30
+ "bootstrap_password": "SM_USERS_BOOTSTRAP_PASSWORD",
31
+ "bootstrap_user_email": "SM_USERS_BOOTSTRAP_USER_EMAIL",
32
+ "bootstrap_user_password": "SM_USERS_BOOTSTRAP_USER_PASSWORD",
33
+ }
34
+
35
+ logger = logging.getLogger("users.bootstrap")
36
+
37
+ _EVT_CREATED = "users.bootstrap.created"
38
+ _EVT_UPDATED = "users.bootstrap.updated"
39
+ _EVT_NOOP = "users.bootstrap.noop"
40
+ _EVT_USER_NOOP = "users.bootstrap.user_noop"
41
+ _EVT_USER_CREATED = "users.bootstrap.user_created"
42
+ _EVT_FAILED = "users.bootstrap.failed"
43
+
44
+
45
+ @dataclass
46
+ class CreateAdminResult:
47
+ user: User
48
+ created: bool # False when admin already existed and we just ensured the role
49
+
50
+
51
+ async def create_admin(
52
+ db: AsyncSession,
53
+ *,
54
+ email: str,
55
+ password: str,
56
+ full_name: str | None = None,
57
+ force: bool = False,
58
+ ) -> CreateAdminResult:
59
+ """Create or update an admin user. Idempotent by default.
60
+
61
+ - If no user with this email exists: create one with is_active=True,
62
+ is_verified=True, is_superuser=True; hash the password; ensure the
63
+ 'admin' Role row exists (create from ADMIN_ROLE_ID if missing);
64
+ insert UserRole.
65
+ - If the user exists and force=True: update the password and ensure the
66
+ admin role is attached.
67
+ - If the user exists and force=False: return created=False without
68
+ changing the password.
69
+ """
70
+ existing = (
71
+ await db.execute(select(User).where(func.lower(User.email) == email.lower()))
72
+ ).scalar_one_or_none()
73
+
74
+ # Look up by name first (works on both Postgres and SQLite regardless of UUID
75
+ # storage format), then fall back to id-based lookup in case name was changed.
76
+ admin_role = (
77
+ await db.execute(select(Role).where(Role.name == ADMIN_ROLE_NAME))
78
+ ).scalar_one_or_none()
79
+ if admin_role is None:
80
+ admin_role = (
81
+ await db.execute(select(Role).where(Role.id == ADMIN_ROLE_ID))
82
+ ).scalar_one_or_none()
83
+ if admin_role is None:
84
+ # The seed migration (e3ce9754e6dc) inserts this row, so in a real
85
+ # deployment we should never hit this branch. Kept as a safety net for
86
+ # tests (where `create_all` runs without data migrations) and for
87
+ # scenarios where someone ran `alembic downgrade` past the seed
88
+ # revision but not past the schema revision.
89
+ admin_role = Role(
90
+ id=ADMIN_ROLE_ID, name=ADMIN_ROLE_NAME, description=ADMIN_ROLE_DESCRIPTION
91
+ )
92
+ db.add(admin_role)
93
+ await db.flush()
94
+
95
+ hasher = PasswordHelper()
96
+ if existing is None:
97
+ user = User(
98
+ email=email,
99
+ hashed_password=hasher.hash(password),
100
+ is_active=True,
101
+ is_verified=True,
102
+ is_superuser=True,
103
+ full_name=full_name,
104
+ )
105
+ db.add(user)
106
+ await db.flush()
107
+ db.add(UserRole(user_id=user.id, role_id=admin_role.id))
108
+ await db.commit()
109
+ await db.refresh(user)
110
+ logger.info(_EVT_CREATED, extra={"email": email, "id": str(user.id)})
111
+ return CreateAdminResult(user=user, created=True)
112
+
113
+ if force:
114
+ existing.hashed_password = hasher.hash(password)
115
+ existing.is_active = True
116
+ existing.is_verified = True
117
+ existing.is_superuser = True
118
+ if full_name is not None:
119
+ existing.full_name = full_name
120
+ existing_link = (
121
+ await db.execute(
122
+ select(UserRole).where(
123
+ UserRole.user_id == existing.id, UserRole.role_id == admin_role.id
124
+ )
125
+ )
126
+ ).scalar_one_or_none()
127
+ if existing_link is None:
128
+ db.add(UserRole(user_id=existing.id, role_id=admin_role.id))
129
+ await db.commit()
130
+ logger.info(_EVT_UPDATED, extra={"email": email, "id": str(existing.id)})
131
+ return CreateAdminResult(user=existing, created=False)
132
+
133
+ logger.info(_EVT_NOOP, extra={"email": email, "id": str(existing.id)})
134
+ return CreateAdminResult(user=existing, created=False)
135
+
136
+
137
+ async def create_standard_user(
138
+ db: AsyncSession,
139
+ *,
140
+ email: str,
141
+ password: str,
142
+ full_name: str | None = None,
143
+ ) -> CreateAdminResult:
144
+ """Create a non-admin user with the 'user' role. Idempotent (noop if exists).
145
+
146
+ Unlike ``create_admin`` this is not meant to be called from the CLI — it's
147
+ used by the env-var bootstrap to seed a second account for dev/testing.
148
+ """
149
+ existing = (
150
+ await db.execute(select(User).where(func.lower(User.email) == email.lower()))
151
+ ).scalar_one_or_none()
152
+ if existing is not None:
153
+ logger.info(_EVT_USER_NOOP, extra={"email": email, "id": str(existing.id)})
154
+ return CreateAdminResult(user=existing, created=False)
155
+
156
+ user_role = (
157
+ await db.execute(select(Role).where(Role.name == USER_ROLE_NAME))
158
+ ).scalar_one_or_none()
159
+ if user_role is None:
160
+ user_role = (
161
+ await db.execute(select(Role).where(Role.id == USER_ROLE_ID))
162
+ ).scalar_one_or_none()
163
+ if user_role is None:
164
+ # Safety net — the seed migration normally inserts this row.
165
+ user_role = Role(id=USER_ROLE_ID, name=USER_ROLE_NAME, description=USER_ROLE_DESCRIPTION)
166
+ db.add(user_role)
167
+ await db.flush()
168
+
169
+ user = User(
170
+ email=email,
171
+ hashed_password=PasswordHelper().hash(password),
172
+ is_active=True,
173
+ is_verified=True,
174
+ is_superuser=False,
175
+ full_name=full_name,
176
+ )
177
+ db.add(user)
178
+ await db.flush()
179
+ db.add(UserRole(user_id=user.id, role_id=user_role.id))
180
+ await db.commit()
181
+ await db.refresh(user)
182
+ logger.info(_EVT_USER_CREATED, extra={"email": email, "id": str(user.id)})
183
+ return CreateAdminResult(user=user, created=True)
184
+
185
+
186
+ async def _user_table_is_empty(db: AsyncSession) -> bool:
187
+ count = (await db.execute(select(func.count()).select_from(User))).scalar_one()
188
+ return count == 0
189
+
190
+
191
+ def _read_dotenv_bootstrap_vars() -> dict[str, str]:
192
+ """Return SM_USERS_BOOTSTRAP_* entries from ``.env`` (ignore everything else).
193
+
194
+ ``UsersSettings`` deliberately doesn't use ``env_file`` — runtime fields
195
+ come from the DB, and pulling the whole ``.env`` in would re-expose every
196
+ SMTP/cookie/token secret as an env knob. So we re-read ``.env`` just for
197
+ the four documented seed keys.
198
+ """
199
+ wanted = set(BOOTSTRAP_ENV_KEYS.values())
200
+ return {k: v for k, v in parse_dotenv().items() if k in wanted}
201
+
202
+
203
+ async def bootstrap_admin_from_env(app: FastAPI) -> None:
204
+ """On-startup hook: create admin from env vars iff users table is empty.
205
+
206
+ Resolves each of the four bootstrap fields in order: ``UsersSettings``
207
+ (tests), then ``os.environ`` (docker/systemd), then ``.env`` (documented
208
+ dev path). If the admin email or password is still blank, returns
209
+ silently — same if the users table already has rows (so restarts don't
210
+ try to re-bootstrap).
211
+
212
+ Optionally also creates a non-admin user from
213
+ ``SM_USERS_BOOTSTRAP_USER_EMAIL`` + ``SM_USERS_BOOTSTRAP_USER_PASSWORD`` —
214
+ useful in dev for testing non-admin flows alongside the admin account.
215
+ """
216
+ settings: UsersSettings = app.state.users.settings
217
+ dotenv_vars = _read_dotenv_bootstrap_vars()
218
+ resolved = {
219
+ attr: getattr(settings, attr) or os.environ.get(env_key) or dotenv_vars.get(env_key, "")
220
+ for attr, env_key in BOOTSTRAP_ENV_KEYS.items()
221
+ }
222
+ email = resolved["bootstrap_email"]
223
+ password = resolved["bootstrap_password"]
224
+ user_email = resolved["bootstrap_user_email"]
225
+ user_password = resolved["bootstrap_user_password"]
226
+
227
+ if not email or not password:
228
+ return
229
+
230
+ session_factory = app.state.sm.db.session_factory
231
+ async with session_factory() as session:
232
+ if not await _user_table_is_empty(session):
233
+ logger.debug("users.bootstrap.skipped (users table non-empty)")
234
+ return
235
+
236
+ try:
237
+ await create_admin(session, email=email, password=password)
238
+ if user_email and user_password:
239
+ await create_standard_user(
240
+ session,
241
+ email=user_email,
242
+ password=user_password,
243
+ )
244
+ except Exception:
245
+ logger.exception(_EVT_FAILED)
246
+ raise
users/cli.py ADDED
@@ -0,0 +1,75 @@
1
+ """Command-line entry points for the users module.
2
+
3
+ Exposed via ``sm-users`` (see pyproject.toml [project.scripts]).
4
+
5
+ sm-users create-admin --email a@b.test --password sekret [--full-name Me]
6
+ sm-users create-admin --email a@b.test --password new --force
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import sys
14
+
15
+ import typer
16
+ from simple_module_hosting.settings import Settings
17
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
18
+
19
+ from users.bootstrap import create_admin
20
+
21
+ app = typer.Typer(help="Users module administration.", no_args_is_help=True)
22
+
23
+
24
+ @app.callback()
25
+ def _main() -> None:
26
+ """Users module administration CLI."""
27
+
28
+
29
+ @app.command("create-admin")
30
+ def create_admin_cli(
31
+ email: str = typer.Option(..., "--email", "-e"),
32
+ password: str = typer.Option(..., "--password", "-p", prompt=True, hide_input=True),
33
+ full_name: str | None = typer.Option(None, "--full-name"),
34
+ force: bool = typer.Option(
35
+ False,
36
+ "--force",
37
+ help="Update the password even if this admin already exists.",
38
+ ),
39
+ ) -> None:
40
+ """Create (or update, with --force) an admin user."""
41
+ logging.basicConfig(level=logging.INFO)
42
+
43
+ async def _run() -> int:
44
+ settings = Settings()
45
+ engine = create_async_engine(settings.database_url)
46
+ # IMPORTANT: matches the module's own session factory
47
+ factory = async_sessionmaker(engine, expire_on_commit=False)
48
+ try:
49
+ async with factory() as session:
50
+ result = await create_admin(
51
+ session,
52
+ email=email,
53
+ password=password,
54
+ full_name=full_name,
55
+ force=force,
56
+ )
57
+ if result.created:
58
+ typer.echo(f"Created admin {email} (id={result.user.id})")
59
+ elif force:
60
+ typer.echo(f"Updated admin {email} (id={result.user.id})")
61
+ else:
62
+ typer.echo(
63
+ f"User {email} already exists. Pass --force to reset password.",
64
+ err=True,
65
+ )
66
+ return 1
67
+ return 0
68
+ finally:
69
+ await engine.dispose()
70
+
71
+ sys.exit(asyncio.run(_run()))
72
+
73
+
74
+ if __name__ == "__main__":
75
+ app()
@@ -0,0 +1,72 @@
1
+ import {
2
+ Select,
3
+ SelectContent,
4
+ SelectItem,
5
+ SelectTrigger,
6
+ SelectValue,
7
+ } from '@simple-module-py/ui/components/ui/select';
8
+
9
+ export interface Filters {
10
+ status: 'all' | 'active' | 'disabled';
11
+ role: string;
12
+ verified: 'all' | 'yes' | 'no';
13
+ sort: 'email' | 'last_login_at' | 'created_at';
14
+ order: 'asc' | 'desc';
15
+ }
16
+
17
+ interface IndexFiltersProps {
18
+ filters: Filters;
19
+ roles: string[];
20
+ onChange: (next: Partial<Filters>) => void;
21
+ }
22
+
23
+ export function IndexFilters({ filters, roles, onChange }: IndexFiltersProps) {
24
+ return (
25
+ <div className="flex flex-wrap gap-2">
26
+ <Select
27
+ value={filters.status}
28
+ onValueChange={(v) => onChange({ status: v as Filters['status'] })}
29
+ >
30
+ <SelectTrigger size="sm" className="w-32">
31
+ <SelectValue placeholder="Status" />
32
+ </SelectTrigger>
33
+ <SelectContent>
34
+ <SelectItem value="all">All statuses</SelectItem>
35
+ <SelectItem value="active">Active</SelectItem>
36
+ <SelectItem value="disabled">Disabled</SelectItem>
37
+ </SelectContent>
38
+ </Select>
39
+
40
+ <Select
41
+ value={filters.role || 'all'}
42
+ onValueChange={(v) => onChange({ role: v === 'all' ? '' : v })}
43
+ >
44
+ <SelectTrigger size="sm" className="w-36">
45
+ <SelectValue placeholder="Role" />
46
+ </SelectTrigger>
47
+ <SelectContent>
48
+ <SelectItem value="all">All roles</SelectItem>
49
+ {roles.map((r) => (
50
+ <SelectItem key={r} value={r}>
51
+ {r}
52
+ </SelectItem>
53
+ ))}
54
+ </SelectContent>
55
+ </Select>
56
+
57
+ <Select
58
+ value={filters.verified}
59
+ onValueChange={(v) => onChange({ verified: v as Filters['verified'] })}
60
+ >
61
+ <SelectTrigger size="sm" className="w-32">
62
+ <SelectValue placeholder="Verified" />
63
+ </SelectTrigger>
64
+ <SelectContent>
65
+ <SelectItem value="all">All</SelectItem>
66
+ <SelectItem value="yes">Verified</SelectItem>
67
+ <SelectItem value="no">Unverified</SelectItem>
68
+ </SelectContent>
69
+ </Select>
70
+ </div>
71
+ );
72
+ }