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.
- simple_module_users-0.0.1.dist-info/METADATA +88 -0
- simple_module_users-0.0.1.dist-info/RECORD +56 -0
- simple_module_users-0.0.1.dist-info/WHEEL +4 -0
- simple_module_users-0.0.1.dist-info/entry_points.txt +5 -0
- simple_module_users-0.0.1.dist-info/licenses/LICENSE +21 -0
- users/__init__.py +0 -0
- users/backend.py +85 -0
- users/bootstrap.py +246 -0
- users/cli.py +75 -0
- users/components/IndexFilters.tsx +72 -0
- users/components/RolesTab.tsx +72 -0
- users/constants.py +42 -0
- users/contracts/__init__.py +0 -0
- users/contracts/events.py +32 -0
- users/contracts/schemas.py +85 -0
- users/db_adapter.py +48 -0
- users/deps.py +83 -0
- users/endpoints/__init__.py +1 -0
- users/endpoints/api.py +227 -0
- users/endpoints/api_admin.py +167 -0
- users/endpoints/views.py +220 -0
- users/exceptions.py +18 -0
- users/mailer/__init__.py +33 -0
- users/mailer/console.py +27 -0
- users/mailer/smtp.py +77 -0
- users/mailer/templates/.gitkeep +0 -0
- users/mailer/templates/invite.txt +1 -0
- users/mailer/templates/reset_password.txt +1 -0
- users/mailer/templates/verify_email.txt +1 -0
- users/manager.py +146 -0
- users/middleware.py +143 -0
- users/models/__init__.py +24 -0
- users/models/_base.py +9 -0
- users/models/access_token.py +33 -0
- users/models/role.py +34 -0
- users/models/user.py +67 -0
- users/models/user_role.py +39 -0
- users/module.py +155 -0
- users/package.json +16 -0
- users/pages/.gitkeep +0 -0
- users/pages/AcceptInvite.tsx +106 -0
- users/pages/ForgotPassword.tsx +90 -0
- users/pages/Login.tsx +181 -0
- users/pages/Profile.tsx +112 -0
- users/pages/Register.tsx +152 -0
- users/pages/ResetPassword.tsx +112 -0
- users/pages/Users/Edit.tsx +293 -0
- users/pages/Users/Index.tsx +296 -0
- users/pages/Users/Invite.tsx +135 -0
- users/pages/VerifyEmail.tsx +110 -0
- users/py.typed +0 -0
- users/rate_limit.py +59 -0
- users/roles_cache.py +58 -0
- users/service.py +257 -0
- users/settings.py +99 -0
- 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,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
|
+
}
|