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,72 @@
1
+ import { Link } from '@inertiajs/react';
2
+ import { Badge } from '@simple-module-py/ui/components/ui/badge';
3
+ import { Button } from '@simple-module-py/ui/components/ui/button';
4
+ import { Card } from '@simple-module-py/ui/components/ui/card';
5
+ import {
6
+ Table,
7
+ TableBody,
8
+ TableCell,
9
+ TableHead,
10
+ TableHeader,
11
+ TableRow,
12
+ } from '@simple-module-py/ui/components/ui/table';
13
+ import { TabsContent } from '@simple-module-py/ui/components/ui/tabs';
14
+ import { Pencil, ShieldCheck } from 'lucide-react';
15
+
16
+ export interface RoleItem {
17
+ id: string;
18
+ name: string;
19
+ description?: string | null;
20
+ user_count: number;
21
+ }
22
+
23
+ export function RolesTab({ roles }: { roles: RoleItem[] }) {
24
+ return (
25
+ <TabsContent value="roles">
26
+ <Card>
27
+ <Table>
28
+ <TableHeader>
29
+ <TableRow>
30
+ <TableHead>Role</TableHead>
31
+ <TableHead className="hidden md:table-cell">Description</TableHead>
32
+ <TableHead>Users</TableHead>
33
+ <TableHead className="text-right">Actions</TableHead>
34
+ </TableRow>
35
+ </TableHeader>
36
+ <TableBody>
37
+ {roles.map((role) => (
38
+ <TableRow key={role.id}>
39
+ <TableCell className="font-medium">{role.name}</TableCell>
40
+ <TableCell className="hidden md:table-cell text-muted-foreground text-sm">
41
+ {role.description || '—'}
42
+ </TableCell>
43
+ <TableCell>
44
+ <Badge variant="secondary" className="tabular-nums">
45
+ {role.user_count}
46
+ </Badge>
47
+ </TableCell>
48
+ <TableCell className="text-right">
49
+ <Button asChild variant="ghost" size="icon-sm">
50
+ <Link href={`/permissions/roles/${role.id}/edit`} aria-label="Edit role">
51
+ <Pencil />
52
+ </Link>
53
+ </Button>
54
+ </TableCell>
55
+ </TableRow>
56
+ ))}
57
+ {roles.length === 0 && (
58
+ <TableRow>
59
+ <TableCell colSpan={4} className="h-32 text-center">
60
+ <div className="flex flex-col items-center gap-2 text-muted-foreground">
61
+ <ShieldCheck className="size-8" />
62
+ <p>No roles defined</p>
63
+ </div>
64
+ </TableCell>
65
+ </TableRow>
66
+ )}
67
+ </TableBody>
68
+ </Table>
69
+ </Card>
70
+ </TabsContent>
71
+ );
72
+ }
users/constants.py ADDED
@@ -0,0 +1,42 @@
1
+ """Stable identifiers used by both the seed migration and tests."""
2
+
3
+ import uuid
4
+
5
+ ADMIN_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
6
+ USER_ROLE_ID = uuid.UUID("00000000-0000-0000-0000-000000000002")
7
+
8
+ # Role name strings
9
+ ADMIN_ROLE_NAME = "admin"
10
+ USER_ROLE_NAME = "user"
11
+
12
+ # Role descriptions
13
+ ADMIN_ROLE_DESCRIPTION = "Administrator"
14
+ USER_ROLE_DESCRIPTION = "Standard user"
15
+
16
+ # Permission identifiers
17
+ PERM_USERS_MANAGE = "users.manage"
18
+ PERM_USERS_SELF_PROFILE = "users.self.profile"
19
+
20
+ # Session keys
21
+ SESSION_USER_ID_KEY = "user_id"
22
+
23
+ # Admin list-endpoint allowed filter/sort values
24
+ ALLOWED_STATUS = frozenset({"active", "disabled"})
25
+ ALLOWED_VERIFIED = frozenset({"yes", "no"})
26
+ ALLOWED_SORT = frozenset({"email", "last_login_at", "created_at"})
27
+ ALLOWED_ORDER = frozenset({"asc", "desc"})
28
+
29
+
30
+ def sanitize_list_filters(
31
+ status: str | None,
32
+ verified: str | None,
33
+ sort: str,
34
+ order: str,
35
+ ) -> tuple[str | None, str | None, str, str]:
36
+ """Coerce unknown filter values to None/defaults."""
37
+ return (
38
+ status if status in ALLOWED_STATUS else None,
39
+ verified if verified in ALLOWED_VERIFIED else None,
40
+ sort if sort in ALLOWED_SORT else "email",
41
+ order if order in ALLOWED_ORDER else "asc",
42
+ )
File without changes
@@ -0,0 +1,32 @@
1
+ """Public events published by the users module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from dataclasses import dataclass
7
+
8
+ from simple_module_core.events import Event
9
+
10
+
11
+ @dataclass
12
+ class UserRegistered(Event):
13
+ user_id: uuid.UUID
14
+ email: str
15
+
16
+
17
+ @dataclass
18
+ class UserInvited(Event):
19
+ user_id: uuid.UUID
20
+ email: str
21
+ invited_by: str | None
22
+
23
+
24
+ @dataclass
25
+ class UserDisabled(Event):
26
+ user_id: uuid.UUID
27
+
28
+
29
+ @dataclass
30
+ class RoleAssigned(Event):
31
+ user_id: uuid.UUID
32
+ role_name: str
@@ -0,0 +1,85 @@
1
+ """Public request/response schemas for the users module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ from fastapi_users.schemas import CreateUpdateDictModel
9
+ from pydantic import ConfigDict, EmailStr
10
+ from sqlmodel import SQLModel
11
+
12
+
13
+ class UserRead(CreateUpdateDictModel, SQLModel):
14
+ model_config = ConfigDict(from_attributes=True)
15
+
16
+ id: uuid.UUID
17
+ email: EmailStr
18
+ is_active: bool = True
19
+ is_superuser: bool = False
20
+ is_verified: bool = False
21
+ full_name: str | None = None
22
+ tenant_id: str | None = None
23
+ disabled_at: datetime | None = None
24
+ last_login_at: datetime | None = None
25
+
26
+
27
+ class UserCreate(CreateUpdateDictModel, SQLModel):
28
+ email: EmailStr
29
+ password: str
30
+ is_active: bool | None = True
31
+ is_superuser: bool | None = False
32
+ is_verified: bool | None = False
33
+ full_name: str | None = None
34
+
35
+
36
+ class UserUpdate(CreateUpdateDictModel, SQLModel):
37
+ password: str | None = None
38
+ email: EmailStr | None = None
39
+ is_active: bool | None = None
40
+ is_superuser: bool | None = None
41
+ is_verified: bool | None = None
42
+ full_name: str | None = None
43
+
44
+
45
+ # Admin + invite + self profile
46
+ class UserInvite(SQLModel):
47
+ email: EmailStr
48
+ full_name: str | None = None
49
+ role_names: list[str] = []
50
+
51
+
52
+ class UserListItem(SQLModel):
53
+ id: uuid.UUID
54
+ email: EmailStr
55
+ full_name: str | None = None
56
+ is_active: bool
57
+ is_verified: bool
58
+ disabled_at: datetime | None = None
59
+ last_login_at: datetime | None = None
60
+ created_at: datetime | None = None
61
+ roles: list[str] = []
62
+
63
+
64
+ class RoleListItem(SQLModel):
65
+ id: uuid.UUID
66
+ name: str
67
+ description: str | None = None
68
+ user_count: int = 0
69
+
70
+
71
+ class RoleAssignment(SQLModel):
72
+ role_names: list[str]
73
+
74
+
75
+ class AcceptInviteRequest(SQLModel):
76
+ token: str
77
+ password: str
78
+
79
+
80
+ class PasswordResetLink(SQLModel):
81
+ link: str
82
+
83
+
84
+ class SelfProfileUpdate(SQLModel):
85
+ full_name: str | None = None
users/db_adapter.py ADDED
@@ -0,0 +1,48 @@
1
+ """SQLAlchemyUserDatabase subclass that eager-loads roles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncGenerator
6
+
7
+ from fastapi import Depends
8
+ from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
9
+ from fastapi_users_db_sqlalchemy.access_token import SQLAlchemyAccessTokenDatabase
10
+ from simple_module_db.deps import get_db
11
+ from sqlalchemy import func, select
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+ from sqlalchemy.orm import selectinload
14
+
15
+ from users.models import User, UserAccessToken
16
+
17
+
18
+ class UserDatabaseWithRoles(SQLAlchemyUserDatabase):
19
+ """Always eager-load User.roles so fastapi-users can read role names
20
+ without triggering implicit async lazy-loads."""
21
+
22
+ async def get(self, id):
23
+ stmt = (
24
+ select(self.user_table)
25
+ .where(self.user_table.id == id)
26
+ .options(selectinload(self.user_table.roles))
27
+ )
28
+ return (await self.session.execute(stmt)).scalar_one_or_none()
29
+
30
+ async def get_by_email(self, email):
31
+ stmt = (
32
+ select(self.user_table)
33
+ .where(func.lower(self.user_table.email) == email.lower())
34
+ .options(selectinload(self.user_table.roles))
35
+ )
36
+ return (await self.session.execute(stmt)).scalar_one_or_none()
37
+
38
+
39
+ async def get_user_db(
40
+ session: AsyncSession = Depends(get_db),
41
+ ) -> AsyncGenerator[UserDatabaseWithRoles, None]:
42
+ yield UserDatabaseWithRoles(session, User)
43
+
44
+
45
+ async def get_access_token_db(
46
+ session: AsyncSession = Depends(get_db),
47
+ ) -> AsyncGenerator[SQLAlchemyAccessTokenDatabase[UserAccessToken], None]:
48
+ yield SQLAlchemyAccessTokenDatabase(session, UserAccessToken)
users/deps.py ADDED
@@ -0,0 +1,83 @@
1
+ """Public dependencies and the FastAPIUsers instance.
2
+
3
+ Cookie transport and auth backend are constructed at import time with
4
+ dev-safe defaults (cookie_secure=False, dev cookie name). UsersModule.on_startup
5
+ overrides the cookie params from the real UsersSettings once app.state is
6
+ populated — CookieTransport's cookie fields are mutable on the instance.
7
+
8
+ The singleton is here rather than in register_routes because
9
+ ``current_active_user`` and ``current_superuser`` are decorator-factories
10
+ that must be resolvable at import time for route-handler signatures.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import uuid
16
+ from typing import TYPE_CHECKING
17
+
18
+ from fastapi import Depends, Request
19
+ from fastapi_users import FastAPIUsers
20
+ from simple_module_core.events import EventBus
21
+ from simple_module_db.deps import get_db
22
+ from sqlalchemy.ext.asyncio import AsyncSession
23
+
24
+ from users.backend import build_auth_backend, build_cookie_transport
25
+ from users.db_adapter import (
26
+ UserDatabaseWithRoles,
27
+ get_access_token_db,
28
+ get_user_db,
29
+ )
30
+ from users.manager import UserManager, get_user_manager
31
+ from users.models import User
32
+
33
+ if TYPE_CHECKING:
34
+ from users.service import UserService
35
+
36
+ # Dev-safe singleton — UsersModule patches cookie params at startup.
37
+ _cookie_transport = build_cookie_transport(
38
+ cookie_name="sm_auth",
39
+ cookie_max_age_seconds=60 * 60 * 24 * 14,
40
+ cookie_secure=False, # host flips True in production via register_routes
41
+ cookie_samesite="lax",
42
+ )
43
+ auth_backend = build_auth_backend(_cookie_transport)
44
+
45
+ fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
46
+
47
+ current_active_user = fastapi_users.current_user(active=True)
48
+ current_superuser = fastapi_users.current_user(active=True, superuser=True)
49
+
50
+
51
+ def get_mailer(request: Request):
52
+ """Return the mailer from app.state.users (built in UsersModule.on_startup)."""
53
+ return request.app.state.users.mailer
54
+
55
+
56
+ def get_event_bus(request: Request) -> EventBus:
57
+ """Return the event bus from app.state.sm."""
58
+ return request.app.state.sm.event_bus
59
+
60
+
61
+ async def get_user_service(
62
+ db: AsyncSession = Depends(get_db),
63
+ user_manager: UserManager = Depends(get_user_manager),
64
+ ) -> UserService:
65
+ from users.service import UserService
66
+
67
+ return UserService(db, user_manager)
68
+
69
+
70
+ __all__ = [
71
+ "UserDatabaseWithRoles",
72
+ "UserManager",
73
+ "auth_backend",
74
+ "current_active_user",
75
+ "current_superuser",
76
+ "fastapi_users",
77
+ "get_access_token_db",
78
+ "get_event_bus",
79
+ "get_mailer",
80
+ "get_user_db",
81
+ "get_user_manager",
82
+ "get_user_service",
83
+ ]
@@ -0,0 +1 @@
1
+ """REST and view endpoints for the users module."""
users/endpoints/api.py ADDED
@@ -0,0 +1,227 @@
1
+ """REST API endpoints for the users module.
2
+
3
+ Structure:
4
+ /api/users/auth/login — wrapper with rate limit
5
+ /api/users/auth/* — fastapi-users routers (register/reset/verify/logout)
6
+ /api/users/auth/accept-invite — custom (verify + set password + login)
7
+ /api/users/me — self profile
8
+ /api/users/admin/* — admin REST (RequiresPermission('users.manage'))
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+
15
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
16
+ from fastapi.security import OAuth2PasswordRequestForm
17
+ from fastapi_users import exceptions as fu_exceptions
18
+
19
+ from users.constants import SESSION_USER_ID_KEY
20
+ from users.contracts.schemas import (
21
+ AcceptInviteRequest,
22
+ SelfProfileUpdate,
23
+ UserCreate,
24
+ UserRead,
25
+ UserUpdate,
26
+ )
27
+ from users.deps import (
28
+ auth_backend,
29
+ fastapi_users,
30
+ get_user_manager,
31
+ )
32
+ from users.endpoints.api_admin import admin_router
33
+ from users.manager import UserManager
34
+ from users.rate_limit import LoginRateLimiter, ThroughputLimiter
35
+
36
+ logger = logging.getLogger(__name__)
37
+ router = APIRouter()
38
+
39
+
40
+ # ── Rate limit ───────────────────────────────────────────────────────────────
41
+
42
+
43
+ def get_rate_limiter(request: Request) -> LoginRateLimiter:
44
+ """Return the per-app LoginRateLimiter built in UsersModule.on_startup."""
45
+ return request.app.state.users.rate_limiter
46
+
47
+
48
+ def _client_ip(request: Request) -> str:
49
+ return request.client.host if request.client else "unknown"
50
+
51
+
52
+ async def enforce_auth_throughput_limit(request: Request) -> None:
53
+ """FastAPI dependency that rejects the request with 429 when this IP has
54
+ exhausted its attempts budget on shared auth side-effect endpoints.
55
+
56
+ Applied to forgot-password / register / accept-invite / request-verify-token,
57
+ which otherwise allow unlimited email or account-creation spam.
58
+ """
59
+ limiter: ThroughputLimiter = request.app.state.users.auth_throughput_limiter
60
+ key = f"{request.url.path}::{_client_ip(request)}"
61
+ if not limiter.check_and_record(key):
62
+ raise HTTPException(
63
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
64
+ detail="Too many attempts — try again later",
65
+ )
66
+
67
+
68
+ async def require_signup_enabled(request: Request) -> None:
69
+ """Gate the register endpoint at request time on ``allow_signup``.
70
+
71
+ Mounting stays unconditional so settings reloads don't need to rebuild
72
+ the router. When signup is disabled we return 404 so the endpoint
73
+ appears absent (matches the view-side behaviour at ``/users/register``).
74
+ """
75
+ if not request.app.state.users.settings.allow_signup:
76
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
77
+
78
+
79
+ # ── Wrapper login ────────────────────────────────────────────────────────────
80
+
81
+
82
+ @router.post("/auth/login", status_code=204)
83
+ async def login(
84
+ request: Request,
85
+ response: Response,
86
+ credentials: OAuth2PasswordRequestForm = Depends(),
87
+ user_manager: UserManager = Depends(get_user_manager),
88
+ strategy=Depends(auth_backend.get_strategy),
89
+ limiter: LoginRateLimiter = Depends(get_rate_limiter),
90
+ ):
91
+ """Rate-limited login wrapper. Sets sm_auth cookie + session user_id."""
92
+ key = f"{credentials.username.lower()}::{request.client.host if request.client else 'unknown'}"
93
+ if limiter.is_locked(key):
94
+ raise HTTPException(status_code=429, detail="Too many attempts — try again later")
95
+
96
+ try:
97
+ user = await user_manager.authenticate(credentials)
98
+ except fu_exceptions.UserNotExists:
99
+ user = None
100
+
101
+ if user is None or not user.is_active:
102
+ limiter.record_failure(key)
103
+ raise HTTPException(status_code=400, detail="LOGIN_BAD_CREDENTIALS")
104
+
105
+ if not user.is_verified:
106
+ # Match fastapi-users' own behavior when requires_verification=True
107
+ raise HTTPException(status_code=400, detail="LOGIN_USER_NOT_VERIFIED")
108
+
109
+ limiter.reset(key)
110
+ # Fire the login hook (updates last_login_at)
111
+ await user_manager.on_after_login(user, request, response)
112
+ # Set fastapi-users' cookie via auth_backend.login
113
+ login_response = await auth_backend.login(strategy, user)
114
+ # Bridge the session cookie — AuthMiddleware reads this to identify the user
115
+ request.session[SESSION_USER_ID_KEY] = str(user.id)
116
+ return login_response
117
+
118
+
119
+ # ── Mount fastapi-users stock routers ────────────────────────────────────────
120
+
121
+ # The stock auth router (login + logout) is mounted at /auth-inner so its
122
+ # logout and other endpoints remain accessible. Our wrapper at /auth/login
123
+ # shadows the stock login endpoint. Logout is exposed via /auth-inner/logout.
124
+ auth_inner = fastapi_users.get_auth_router(auth_backend, requires_verification=True)
125
+ router.include_router(auth_inner, prefix="/auth-inner")
126
+
127
+
128
+ def register_auth_routes(api_router: APIRouter) -> None:
129
+ """Mount all auth routes.
130
+
131
+ The stock fastapi-users routers (reset/verify/register) ship POST endpoints
132
+ that trigger email side-effects or account creation. We wrap them with the
133
+ throughput limiter so an attacker can't spam password-reset emails or mint
134
+ accounts indefinitely. ``router`` itself is left unwrapped because its
135
+ rate-limited endpoints apply the dep themselves (login via LoginRateLimiter,
136
+ accept-invite via ``enforce_auth_throughput_limit``).
137
+
138
+ The register router is always mounted; ``require_signup_enabled`` gates
139
+ it at request time so ``allow_signup`` is hot-reloadable.
140
+ """
141
+ api_router.include_router(router)
142
+ api_router.include_router(
143
+ fastapi_users.get_reset_password_router(),
144
+ prefix="/auth",
145
+ tags=["users-auth"],
146
+ dependencies=[Depends(enforce_auth_throughput_limit)],
147
+ )
148
+ api_router.include_router(
149
+ fastapi_users.get_verify_router(UserRead),
150
+ prefix="/auth",
151
+ tags=["users-auth"],
152
+ dependencies=[Depends(enforce_auth_throughput_limit)],
153
+ )
154
+ api_router.include_router(
155
+ fastapi_users.get_register_router(UserRead, UserCreate),
156
+ prefix="/auth",
157
+ tags=["users-auth"],
158
+ dependencies=[
159
+ Depends(require_signup_enabled),
160
+ Depends(enforce_auth_throughput_limit),
161
+ ],
162
+ )
163
+
164
+
165
+ # ── Accept-invite (verify + set password + login, one shot) ─────────────────
166
+
167
+
168
+ @router.post(
169
+ "/auth/accept-invite",
170
+ status_code=204,
171
+ dependencies=[Depends(enforce_auth_throughput_limit)],
172
+ )
173
+ async def accept_invite(
174
+ body: AcceptInviteRequest,
175
+ request: Request,
176
+ response: Response,
177
+ user_manager: UserManager = Depends(get_user_manager),
178
+ strategy=Depends(auth_backend.get_strategy),
179
+ ):
180
+ """Verify an invite token, set the user's password, and log them in."""
181
+ try:
182
+ user = await user_manager.verify(body.token, request=request)
183
+ except (fu_exceptions.InvalidVerifyToken, fu_exceptions.UserAlreadyVerified):
184
+ raise HTTPException(status_code=400, detail="INVITE_BAD_TOKEN") from None
185
+
186
+ try:
187
+ await user_manager.update(
188
+ UserUpdate(password=body.password),
189
+ user,
190
+ request=request,
191
+ )
192
+ except fu_exceptions.InvalidPasswordException as e:
193
+ raise HTTPException(status_code=400, detail=f"INVALID_PASSWORD: {e.reason}") from e
194
+
195
+ await user_manager.on_after_login(user, request, response)
196
+ login_response = await auth_backend.login(strategy, user)
197
+ request.session[SESSION_USER_ID_KEY] = str(user.id)
198
+ return login_response
199
+
200
+
201
+ # ── Self profile ─────────────────────────────────────────────────────────────
202
+
203
+
204
+ @router.get("/me", response_model=UserRead)
205
+ async def read_me(user=Depends(fastapi_users.current_user(active=True))):
206
+ """Return the currently authenticated user's profile."""
207
+ return user
208
+
209
+
210
+ @router.patch("/me", response_model=UserRead)
211
+ async def update_me(
212
+ data: SelfProfileUpdate,
213
+ request: Request,
214
+ user=Depends(fastapi_users.current_user(active=True)),
215
+ user_manager: UserManager = Depends(get_user_manager),
216
+ ):
217
+ """Update the currently authenticated user's profile."""
218
+ return await user_manager.update(
219
+ UserUpdate(**data.model_dump(exclude_unset=True)),
220
+ user,
221
+ request=request,
222
+ )
223
+
224
+
225
+ # ── Admin REST — defined in api_admin.py, mounted here ──────────────────────
226
+
227
+ router.include_router(admin_router)