regstack 0.1.0__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 (92) hide show
  1. regstack/__init__.py +5 -0
  2. regstack/app.py +150 -0
  3. regstack/auth/__init__.py +21 -0
  4. regstack/auth/clock.py +29 -0
  5. regstack/auth/dependencies.py +102 -0
  6. regstack/auth/jwt.py +145 -0
  7. regstack/auth/lockout.py +59 -0
  8. regstack/auth/mfa.py +29 -0
  9. regstack/auth/password.py +20 -0
  10. regstack/auth/tokens.py +19 -0
  11. regstack/cli/__init__.py +0 -0
  12. regstack/cli/__main__.py +27 -0
  13. regstack/cli/_runtime.py +39 -0
  14. regstack/cli/admin.py +45 -0
  15. regstack/cli/doctor.py +186 -0
  16. regstack/cli/init.py +236 -0
  17. regstack/config/__init__.py +4 -0
  18. regstack/config/loader.py +114 -0
  19. regstack/config/schema.py +148 -0
  20. regstack/config/secrets.py +22 -0
  21. regstack/db/__init__.py +17 -0
  22. regstack/db/client.py +26 -0
  23. regstack/db/indexes.py +70 -0
  24. regstack/db/repositories/__init__.py +0 -0
  25. regstack/db/repositories/blacklist_repo.py +28 -0
  26. regstack/db/repositories/login_attempt_repo.py +27 -0
  27. regstack/db/repositories/mfa_code_repo.py +99 -0
  28. regstack/db/repositories/pending_repo.py +76 -0
  29. regstack/db/repositories/user_repo.py +169 -0
  30. regstack/email/__init__.py +12 -0
  31. regstack/email/base.py +23 -0
  32. regstack/email/composer.py +142 -0
  33. regstack/email/console.py +28 -0
  34. regstack/email/factory.py +23 -0
  35. regstack/email/ses.py +47 -0
  36. regstack/email/smtp.py +46 -0
  37. regstack/email/templates/email_change.html +15 -0
  38. regstack/email/templates/email_change.subject.txt +1 -0
  39. regstack/email/templates/email_change.txt +7 -0
  40. regstack/email/templates/password_reset.html +15 -0
  41. regstack/email/templates/password_reset.subject.txt +1 -0
  42. regstack/email/templates/password_reset.txt +7 -0
  43. regstack/email/templates/sms_login_mfa.txt +1 -0
  44. regstack/email/templates/sms_phone_setup.txt +1 -0
  45. regstack/email/templates/verification.html +15 -0
  46. regstack/email/templates/verification.subject.txt +1 -0
  47. regstack/email/templates/verification.txt +7 -0
  48. regstack/hooks/__init__.py +3 -0
  49. regstack/hooks/events.py +59 -0
  50. regstack/models/__init__.py +15 -0
  51. regstack/models/_objectid.py +30 -0
  52. regstack/models/login_attempt.py +31 -0
  53. regstack/models/mfa_code.py +40 -0
  54. regstack/models/pending_registration.py +38 -0
  55. regstack/models/user.py +104 -0
  56. regstack/routers/__init__.py +37 -0
  57. regstack/routers/_schemas.py +34 -0
  58. regstack/routers/account.py +274 -0
  59. regstack/routers/admin.py +187 -0
  60. regstack/routers/login.py +223 -0
  61. regstack/routers/logout.py +39 -0
  62. regstack/routers/password.py +114 -0
  63. regstack/routers/phone.py +242 -0
  64. regstack/routers/register.py +99 -0
  65. regstack/routers/verify.py +116 -0
  66. regstack/sms/__init__.py +5 -0
  67. regstack/sms/base.py +24 -0
  68. regstack/sms/factory.py +23 -0
  69. regstack/sms/null.py +26 -0
  70. regstack/sms/sns.py +42 -0
  71. regstack/sms/twilio.py +49 -0
  72. regstack/ui/__init__.py +3 -0
  73. regstack/ui/pages.py +148 -0
  74. regstack/ui/static/css/core.css +204 -0
  75. regstack/ui/static/css/theme.css +43 -0
  76. regstack/ui/static/js/regstack.js +411 -0
  77. regstack/ui/templates/auth/email_change_confirm.html +10 -0
  78. regstack/ui/templates/auth/forgot.html +14 -0
  79. regstack/ui/templates/auth/login.html +24 -0
  80. regstack/ui/templates/auth/me.html +110 -0
  81. regstack/ui/templates/auth/mfa_confirm.html +14 -0
  82. regstack/ui/templates/auth/register.html +23 -0
  83. regstack/ui/templates/auth/reset.html +13 -0
  84. regstack/ui/templates/auth/verify.html +10 -0
  85. regstack/ui/templates/base.html +46 -0
  86. regstack/version.py +1 -0
  87. regstack-0.1.0.dist-info/METADATA +209 -0
  88. regstack-0.1.0.dist-info/RECORD +92 -0
  89. regstack-0.1.0.dist-info/WHEEL +4 -0
  90. regstack-0.1.0.dist-info/entry_points.txt +2 -0
  91. regstack-0.1.0.dist-info/licenses/LICENSE +202 -0
  92. regstack-0.1.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Annotated, Any
5
+
6
+ from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
7
+
8
+ from regstack.models._objectid import ObjectIdStr
9
+
10
+
11
+ def _utcnow() -> datetime:
12
+ return datetime.now(UTC)
13
+
14
+
15
+ PasswordStr = Annotated[str, Field(min_length=8, max_length=128)]
16
+
17
+
18
+ class BaseUser(BaseModel):
19
+ """Persisted user document.
20
+
21
+ The default field set covers what both winebox and putplace need today.
22
+ Hosts add their own fields by subclassing or by registering an extension
23
+ mixin via ``RegStack.extend_user_model``.
24
+ """
25
+
26
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
27
+
28
+ id: ObjectIdStr | None = Field(default=None, alias="_id")
29
+ email: EmailStr
30
+ hashed_password: str
31
+ is_active: bool = True
32
+ is_verified: bool = False
33
+ is_superuser: bool = False
34
+ full_name: str | None = None
35
+ phone_number: str | None = None
36
+ is_mfa_enabled: bool = False
37
+ created_at: datetime = Field(default_factory=_utcnow)
38
+ updated_at: datetime = Field(default_factory=_utcnow)
39
+ last_login: datetime | None = None
40
+ tokens_invalidated_after: datetime | None = None
41
+
42
+ @property
43
+ def is_admin(self) -> bool:
44
+ """Alias kept for parity with putplace's ``is_admin`` field name."""
45
+ return self.is_superuser
46
+
47
+ def to_mongo(self) -> dict[str, Any]:
48
+ data = self.model_dump(by_alias=True, exclude_none=True)
49
+ if data.get("_id") is None:
50
+ data.pop("_id", None)
51
+ return data
52
+
53
+
54
+ class UserCreate(BaseModel):
55
+ model_config = ConfigDict(extra="forbid")
56
+
57
+ email: EmailStr
58
+ password: PasswordStr
59
+ full_name: str | None = Field(default=None, max_length=200)
60
+
61
+ @field_validator("password")
62
+ @classmethod
63
+ def _strip(cls, v: str) -> str:
64
+ return v
65
+
66
+
67
+ class UserUpdate(BaseModel):
68
+ model_config = ConfigDict(extra="forbid")
69
+
70
+ full_name: str | None = Field(default=None, max_length=200)
71
+
72
+
73
+ class UserPublic(BaseModel):
74
+ """Safe-to-serialise projection of a user (no password hash)."""
75
+
76
+ model_config = ConfigDict(populate_by_name=True)
77
+
78
+ id: str = Field(alias="_id")
79
+ email: EmailStr
80
+ is_active: bool
81
+ is_verified: bool
82
+ is_superuser: bool
83
+ full_name: str | None = None
84
+ phone_number: str | None = None
85
+ is_mfa_enabled: bool = False
86
+ created_at: datetime
87
+ last_login: datetime | None = None
88
+
89
+ @classmethod
90
+ def from_user(cls, user: BaseUser) -> UserPublic:
91
+ if user.id is None:
92
+ raise ValueError("Cannot serialise a user without an id")
93
+ return cls(
94
+ _id=user.id,
95
+ email=user.email,
96
+ is_active=user.is_active,
97
+ is_verified=user.is_verified,
98
+ is_superuser=user.is_superuser,
99
+ full_name=user.full_name,
100
+ phone_number=user.phone_number,
101
+ is_mfa_enabled=user.is_mfa_enabled,
102
+ created_at=user.created_at,
103
+ last_login=user.last_login,
104
+ )
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from regstack.routers.account import build_account_router
8
+ from regstack.routers.admin import build_admin_router
9
+ from regstack.routers.login import build_login_router
10
+ from regstack.routers.logout import build_logout_router
11
+ from regstack.routers.password import build_password_router
12
+ from regstack.routers.phone import build_phone_router
13
+ from regstack.routers.register import build_register_router
14
+ from regstack.routers.verify import build_verify_router
15
+
16
+ if TYPE_CHECKING:
17
+ from regstack.app import RegStack
18
+
19
+
20
+ def build_router(rs: RegStack) -> APIRouter:
21
+ """Compose the JSON router that hosts mount via ``app.include_router``."""
22
+ router = APIRouter(tags=["regstack"])
23
+ router.include_router(build_register_router(rs))
24
+ router.include_router(build_verify_router(rs))
25
+ router.include_router(build_login_router(rs))
26
+ router.include_router(build_logout_router(rs))
27
+ router.include_router(build_account_router(rs))
28
+ if rs.config.enable_password_reset:
29
+ router.include_router(build_password_router(rs))
30
+ if rs.config.enable_sms_2fa:
31
+ router.include_router(build_phone_router(rs))
32
+ if rs.config.enable_admin_router:
33
+ router.include_router(build_admin_router(rs))
34
+ return router
35
+
36
+
37
+ __all__ = ["build_admin_router", "build_router"]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ from pydantic import BaseModel, ConfigDict, EmailStr, Field
6
+
7
+ PasswordStr = Annotated[str, Field(min_length=8, max_length=128)]
8
+
9
+
10
+ class LoginRequest(BaseModel):
11
+ model_config = ConfigDict(extra="forbid")
12
+ email: EmailStr
13
+ password: PasswordStr
14
+
15
+
16
+ class TokenResponse(BaseModel):
17
+ access_token: str
18
+ token_type: str = "bearer"
19
+ expires_in: int
20
+
21
+
22
+ class MessageResponse(BaseModel):
23
+ message: str
24
+
25
+
26
+ class PendingResponse(BaseModel):
27
+ """Returned when registration starts a verification flow rather than
28
+ creating a logged-in user immediately. The host can use this to redirect
29
+ to a "check your email" page.
30
+ """
31
+
32
+ status: str = "pending_verification"
33
+ email: EmailStr
34
+ expires_at: str # ISO-8601 UTC, JSON-friendly
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Annotated, Any
4
+
5
+ import jwt as pyjwt
6
+ from fastapi import APIRouter, Body, Depends, HTTPException, status
7
+ from pydantic import BaseModel, ConfigDict, EmailStr, Field
8
+
9
+ from regstack.auth.jwt import TokenError
10
+ from regstack.config.secrets import derive_secret
11
+ from regstack.db.repositories.user_repo import UserAlreadyExistsError
12
+ from regstack.models.user import BaseUser, UserPublic
13
+ from regstack.routers._schemas import MessageResponse, PasswordStr
14
+
15
+ if TYPE_CHECKING:
16
+ from regstack.app import RegStack
17
+
18
+
19
+ _EMAIL_CHANGE_PURPOSE = "email_change"
20
+ _NEW_EMAIL_CLAIM = "new_email"
21
+
22
+
23
+ class ChangePasswordRequest(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+ current_password: str
26
+ new_password: PasswordStr
27
+
28
+
29
+ class ChangeEmailRequest(BaseModel):
30
+ model_config = ConfigDict(extra="forbid")
31
+ new_email: EmailStr
32
+ current_password: str
33
+
34
+
35
+ class ConfirmEmailChangeRequest(BaseModel):
36
+ model_config = ConfigDict(extra="forbid")
37
+ token: str
38
+
39
+
40
+ class DeleteAccountRequest(BaseModel):
41
+ model_config = ConfigDict(extra="forbid")
42
+ current_password: str
43
+
44
+
45
+ class UpdateProfileRequest(BaseModel):
46
+ model_config = ConfigDict(extra="forbid")
47
+ full_name: str | None = Field(default=None, max_length=200)
48
+
49
+
50
+ def build_account_router(rs: RegStack) -> APIRouter:
51
+ router = APIRouter()
52
+
53
+ @router.get(
54
+ "/me",
55
+ response_model=UserPublic,
56
+ summary="Return the authenticated user",
57
+ )
58
+ async def me(user: BaseUser = Depends(rs.deps.current_user())) -> UserPublic:
59
+ return UserPublic.from_user(user)
60
+
61
+ @router.patch(
62
+ "/me",
63
+ response_model=UserPublic,
64
+ summary="Update the authenticated user's profile fields",
65
+ )
66
+ async def update_me(
67
+ payload: UpdateProfileRequest,
68
+ user: BaseUser = Depends(rs.deps.current_user()),
69
+ ) -> UserPublic:
70
+ assert user.id is not None
71
+ await rs.users.set_full_name(user.id, payload.full_name)
72
+ user.full_name = payload.full_name
73
+ return UserPublic.from_user(user)
74
+
75
+ @router.post(
76
+ "/change-password",
77
+ response_model=MessageResponse,
78
+ summary="Change the authenticated user's password (revokes existing sessions)",
79
+ )
80
+ async def change_password(
81
+ payload: ChangePasswordRequest,
82
+ user: BaseUser = Depends(rs.deps.current_user()),
83
+ ) -> MessageResponse:
84
+ if not rs.password_hasher.verify(payload.current_password, user.hashed_password):
85
+ raise HTTPException(
86
+ status_code=status.HTTP_400_BAD_REQUEST,
87
+ detail="Current password is incorrect.",
88
+ )
89
+ if rs.password_hasher.verify(payload.new_password, user.hashed_password):
90
+ raise HTTPException(
91
+ status_code=status.HTTP_400_BAD_REQUEST,
92
+ detail="New password must differ from the current password.",
93
+ )
94
+
95
+ new_hash = rs.password_hasher.hash(payload.new_password)
96
+ assert user.id is not None
97
+ await rs.users.update_password(user.id, new_hash)
98
+ await rs.lockout.clear(user.email)
99
+ await rs.hooks.fire("password_changed", user=user)
100
+ return MessageResponse(message="Password changed. Existing sessions have been signed out.")
101
+
102
+ @router.post(
103
+ "/change-email",
104
+ response_model=MessageResponse,
105
+ status_code=status.HTTP_202_ACCEPTED,
106
+ summary="Request an email-address change (sends confirmation to new address)",
107
+ )
108
+ async def change_email(
109
+ payload: ChangeEmailRequest,
110
+ user: BaseUser = Depends(rs.deps.current_user()),
111
+ ) -> MessageResponse:
112
+ if payload.new_email.lower() == user.email.lower():
113
+ raise HTTPException(
114
+ status_code=status.HTTP_400_BAD_REQUEST,
115
+ detail="New email is the same as the current email.",
116
+ )
117
+ if not rs.password_hasher.verify(payload.current_password, user.hashed_password):
118
+ raise HTTPException(
119
+ status_code=status.HTTP_400_BAD_REQUEST,
120
+ detail="Current password is incorrect.",
121
+ )
122
+ clash = await rs.users.get_by_email(payload.new_email)
123
+ if clash is not None:
124
+ raise HTTPException(
125
+ status_code=status.HTTP_409_CONFLICT,
126
+ detail="That email address is already in use.",
127
+ )
128
+
129
+ assert user.id is not None
130
+ ttl = rs.config.email_change_token_ttl_seconds
131
+ token = _encode_email_change_token(rs, user.id, payload.new_email, ttl)
132
+ url = _email_change_url(rs, token)
133
+ message = rs.mail.email_change(
134
+ to=payload.new_email,
135
+ full_name=user.full_name,
136
+ url=url,
137
+ ttl_minutes=max(ttl // 60, 1),
138
+ )
139
+ await rs.email.send(message)
140
+ await rs.hooks.fire(
141
+ "email_change_requested",
142
+ user=user,
143
+ new_email=payload.new_email,
144
+ url=url,
145
+ )
146
+ return MessageResponse(
147
+ message="A confirmation link has been sent to the new email address."
148
+ )
149
+
150
+ @router.post(
151
+ "/confirm-email-change",
152
+ response_model=UserPublic,
153
+ summary="Consume an email-change token and swap the address",
154
+ )
155
+ async def confirm_email_change(payload: ConfirmEmailChangeRequest) -> UserPublic:
156
+ try:
157
+ user_id, new_email = _decode_email_change_token(rs, payload.token)
158
+ except TokenError as exc:
159
+ raise HTTPException(
160
+ status_code=status.HTTP_400_BAD_REQUEST,
161
+ detail="Token is invalid or has expired.",
162
+ ) from exc
163
+
164
+ user = await rs.users.get_by_id(user_id)
165
+ if user is None or not user.is_active or user.id is None:
166
+ raise HTTPException(
167
+ status_code=status.HTTP_400_BAD_REQUEST,
168
+ detail="Token does not match an active account.",
169
+ )
170
+
171
+ try:
172
+ await rs.users.update_email(user.id, new_email)
173
+ except UserAlreadyExistsError as exc:
174
+ raise HTTPException(
175
+ status_code=status.HTTP_409_CONFLICT,
176
+ detail="That email address is already in use.",
177
+ ) from exc
178
+
179
+ previous_email = user.email
180
+ user.email = new_email
181
+ await rs.lockout.clear(previous_email)
182
+ await rs.hooks.fire("email_changed", user=user, previous_email=previous_email)
183
+ return UserPublic.from_user(user)
184
+
185
+ @router.delete(
186
+ "/account",
187
+ response_model=MessageResponse,
188
+ summary="Permanently delete the authenticated user's account",
189
+ )
190
+ async def delete_account(
191
+ payload: Annotated[DeleteAccountRequest, Body()],
192
+ user: BaseUser = Depends(rs.deps.current_user()),
193
+ ) -> MessageResponse:
194
+ if not rs.config.enable_account_deletion:
195
+ raise HTTPException(
196
+ status_code=status.HTTP_404_NOT_FOUND,
197
+ detail="Account deletion is disabled for this application.",
198
+ )
199
+ if not rs.password_hasher.verify(payload.current_password, user.hashed_password):
200
+ raise HTTPException(
201
+ status_code=status.HTTP_400_BAD_REQUEST,
202
+ detail="Current password is incorrect.",
203
+ )
204
+
205
+ assert user.id is not None
206
+ await rs.users.delete(user.id)
207
+ await rs.pending.delete_by_email(user.email)
208
+ await rs.lockout.clear(user.email)
209
+ await rs.hooks.fire("user_deleted", user=user)
210
+ return MessageResponse(message="Account deleted.")
211
+
212
+ return router
213
+
214
+
215
+ def _encode_email_change_token(rs: RegStack, user_id: str, new_email: str, ttl: int) -> str:
216
+ """Mint a JWT carrying both ``sub=user_id`` and a ``new_email`` claim.
217
+
218
+ Goes through pyjwt directly (rather than ``rs.jwt.encode``) because we
219
+ need a custom claim. Same per-purpose key derivation though, so this
220
+ token is unforgeable from a session token.
221
+ """
222
+ import secrets as _secrets
223
+
224
+ now = rs.clock.now()
225
+ from datetime import timedelta
226
+
227
+ payload: dict[str, Any] = {
228
+ "sub": user_id,
229
+ "jti": _secrets.token_urlsafe(16),
230
+ "iat": int(now.timestamp()),
231
+ "exp": int((now + timedelta(seconds=ttl)).timestamp()),
232
+ "purpose": _EMAIL_CHANGE_PURPOSE,
233
+ _NEW_EMAIL_CLAIM: new_email,
234
+ }
235
+ if rs.config.jwt_audience is not None:
236
+ payload["aud"] = rs.config.jwt_audience
237
+ key = derive_secret(rs.config.jwt_secret.get_secret_value(), _EMAIL_CHANGE_PURPOSE)
238
+ return pyjwt.encode(payload, key, algorithm=rs.config.jwt_algorithm)
239
+
240
+
241
+ def _decode_email_change_token(rs: RegStack, token: str) -> tuple[str, str]:
242
+ key = derive_secret(rs.config.jwt_secret.get_secret_value(), _EMAIL_CHANGE_PURPOSE)
243
+ try:
244
+ claims = pyjwt.decode(
245
+ token,
246
+ key,
247
+ algorithms=[rs.config.jwt_algorithm],
248
+ audience=rs.config.jwt_audience,
249
+ options={
250
+ "require": ["sub", "exp", "iat", "jti", "purpose", _NEW_EMAIL_CLAIM],
251
+ "verify_exp": False,
252
+ "verify_iat": False,
253
+ "verify_nbf": False,
254
+ },
255
+ )
256
+ except pyjwt.PyJWTError as exc:
257
+ raise TokenError(str(exc)) from exc
258
+
259
+ if claims.get("purpose") != _EMAIL_CHANGE_PURPOSE:
260
+ raise TokenError("token purpose mismatch")
261
+
262
+ from datetime import datetime
263
+
264
+ now = rs.clock.now()
265
+ exp = datetime.fromtimestamp(int(claims["exp"]), tz=now.tzinfo)
266
+ if now >= exp:
267
+ raise TokenError("Token has expired")
268
+
269
+ return str(claims["sub"]), str(claims[_NEW_EMAIL_CLAIM])
270
+
271
+
272
+ def _email_change_url(rs: RegStack, token: str) -> str:
273
+ base = str(rs.config.base_url).rstrip("/")
274
+ return f"{base}/confirm-email-change?token={token}"
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+ from typing import TYPE_CHECKING, Annotated
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from regstack.auth.tokens import generate_verification_token
10
+ from regstack.models.pending_registration import PendingRegistration
11
+ from regstack.models.user import BaseUser, UserPublic
12
+ from regstack.routers._schemas import MessageResponse
13
+
14
+ if TYPE_CHECKING:
15
+ from regstack.app import RegStack
16
+
17
+
18
+ class AdminUserUpdate(BaseModel):
19
+ model_config = ConfigDict(extra="forbid")
20
+ is_active: bool | None = None
21
+ is_superuser: bool | None = None
22
+ full_name: str | None = Field(default=None, max_length=200)
23
+
24
+
25
+ class AdminStats(BaseModel):
26
+ total_users: int
27
+ active_users: int
28
+ verified_users: int
29
+ superusers: int
30
+ pending_registrations: int
31
+
32
+
33
+ class UserListResponse(BaseModel):
34
+ items: list[UserPublic]
35
+ total: int
36
+ skip: int
37
+ limit: int
38
+
39
+
40
+ def build_admin_router(rs: RegStack) -> APIRouter:
41
+ router = APIRouter(prefix="/admin", tags=["regstack-admin"])
42
+
43
+ @router.get(
44
+ "/stats",
45
+ response_model=AdminStats,
46
+ summary="Aggregate counts for the admin dashboard",
47
+ )
48
+ async def stats(_admin: BaseUser = Depends(rs.deps.current_admin())) -> AdminStats:
49
+ total = await rs.users.count()
50
+ active = await rs.users.count(filter_={"is_active": True})
51
+ verified = await rs.users.count(filter_={"is_verified": True})
52
+ supers = await rs.users.count(filter_={"is_superuser": True})
53
+ pending = await rs.pending._collection.count_documents({}) # type: ignore[attr-defined]
54
+ return AdminStats(
55
+ total_users=total,
56
+ active_users=active,
57
+ verified_users=verified,
58
+ superusers=supers,
59
+ pending_registrations=pending,
60
+ )
61
+
62
+ @router.get(
63
+ "/users",
64
+ response_model=UserListResponse,
65
+ summary="List users (paginated)",
66
+ )
67
+ async def list_users(
68
+ skip: Annotated[int, Query(ge=0)] = 0,
69
+ limit: Annotated[int, Query(ge=1, le=200)] = 50,
70
+ _admin: BaseUser = Depends(rs.deps.current_admin()),
71
+ ) -> UserListResponse:
72
+ users = await rs.users.list_paged(skip=skip, limit=limit)
73
+ total = await rs.users.count()
74
+ return UserListResponse(
75
+ items=[UserPublic.from_user(u) for u in users],
76
+ total=total,
77
+ skip=skip,
78
+ limit=limit,
79
+ )
80
+
81
+ @router.get(
82
+ "/users/{user_id}",
83
+ response_model=UserPublic,
84
+ summary="Fetch a single user by id",
85
+ )
86
+ async def get_user(
87
+ user_id: str = Path(...),
88
+ _admin: BaseUser = Depends(rs.deps.current_admin()),
89
+ ) -> UserPublic:
90
+ user = await _require_user(rs, user_id)
91
+ return UserPublic.from_user(user)
92
+
93
+ @router.patch(
94
+ "/users/{user_id}",
95
+ response_model=UserPublic,
96
+ summary="Update mutable user flags",
97
+ )
98
+ async def update_user(
99
+ payload: AdminUserUpdate,
100
+ user_id: str = Path(...),
101
+ _admin: BaseUser = Depends(rs.deps.current_admin()),
102
+ ) -> UserPublic:
103
+ user = await _require_user(rs, user_id)
104
+ assert user.id is not None
105
+
106
+ if payload.is_active is not None:
107
+ await rs.users.set_active(user.id, is_active=payload.is_active)
108
+ user.is_active = payload.is_active
109
+ if payload.is_active is False:
110
+ # A disabled user should not retain a live session.
111
+ await rs.users.set_tokens_invalidated_after(user.id, rs.clock.now())
112
+ if payload.is_superuser is not None:
113
+ await rs.users.set_superuser(user.id, is_superuser=payload.is_superuser)
114
+ user.is_superuser = payload.is_superuser
115
+ if payload.full_name is not None:
116
+ await rs.users.set_full_name(user.id, payload.full_name)
117
+ user.full_name = payload.full_name
118
+ return UserPublic.from_user(user)
119
+
120
+ @router.delete(
121
+ "/users/{user_id}",
122
+ response_model=MessageResponse,
123
+ summary="Permanently delete a user",
124
+ )
125
+ async def delete_user(
126
+ user_id: str = Path(...),
127
+ _admin: BaseUser = Depends(rs.deps.current_admin()),
128
+ ) -> MessageResponse:
129
+ user = await _require_user(rs, user_id)
130
+ assert user.id is not None
131
+ await rs.users.delete(user.id)
132
+ await rs.pending.delete_by_email(user.email)
133
+ await rs.lockout.clear(user.email)
134
+ await rs.hooks.fire("user_deleted", user=user)
135
+ return MessageResponse(message=f"User {user.email} deleted.")
136
+
137
+ @router.post(
138
+ "/users/{user_id}/resend-verification",
139
+ response_model=MessageResponse,
140
+ status_code=status.HTTP_202_ACCEPTED,
141
+ summary="Re-send a verification email for an unverified user",
142
+ )
143
+ async def admin_resend_verification(
144
+ user_id: str = Path(...),
145
+ _admin: BaseUser = Depends(rs.deps.current_admin()),
146
+ ) -> MessageResponse:
147
+ user = await _require_user(rs, user_id)
148
+ if user.is_verified:
149
+ raise HTTPException(
150
+ status_code=status.HTTP_400_BAD_REQUEST,
151
+ detail="User is already verified.",
152
+ )
153
+
154
+ # Move the user to a pending registration row so the standard verify
155
+ # endpoint completes the flow. Less special-case code, one path.
156
+ raw, token_hash = generate_verification_token()
157
+ ttl = rs.config.verification_token_ttl_seconds
158
+ pending = PendingRegistration(
159
+ email=user.email,
160
+ hashed_password=user.hashed_password,
161
+ full_name=user.full_name,
162
+ token_hash=token_hash,
163
+ expires_at=rs.clock.now() + timedelta(seconds=ttl),
164
+ )
165
+ await rs.pending.upsert(pending)
166
+
167
+ base = str(rs.config.base_url).rstrip("/")
168
+ url = f"{base}/verify?token={raw}"
169
+ message = rs.mail.verification(to=user.email, full_name=user.full_name, url=url)
170
+ await rs.email.send(message)
171
+ await rs.hooks.fire("verification_requested", email=user.email, url=url)
172
+ return MessageResponse(message=f"Verification email sent to {user.email}.")
173
+
174
+ return router
175
+
176
+
177
+ async def _require_user(rs: RegStack, user_id: str) -> BaseUser:
178
+ user = await rs.users.get_by_id(user_id)
179
+ if user is None:
180
+ raise HTTPException(
181
+ status_code=status.HTTP_404_NOT_FOUND,
182
+ detail="User not found.",
183
+ )
184
+ return user
185
+
186
+
187
+ __all__ = ["AdminStats", "AdminUserUpdate", "UserListResponse", "build_admin_router"]