fastapi-admin-kit 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 (163) hide show
  1. fastapi_admin_kit/__init__.py +73 -0
  2. fastapi_admin_kit/actions/__init__.py +63 -0
  3. fastapi_admin_kit/actions/base.py +68 -0
  4. fastapi_admin_kit/actions/registry.py +43 -0
  5. fastapi_admin_kit/admin/__init__.py +17 -0
  6. fastapi_admin_kit/admin/admin_config.py +95 -0
  7. fastapi_admin_kit/admin/admin_database.py +138 -0
  8. fastapi_admin_kit/admin/admin_router.py +74 -0
  9. fastapi_admin_kit/admin/admin_template.py +203 -0
  10. fastapi_admin_kit/admin/builtin_models.py +284 -0
  11. fastapi_admin_kit/admin/core.py +1036 -0
  12. fastapi_admin_kit/admin/decorators.py +70 -0
  13. fastapi_admin_kit/admin/state.py +76 -0
  14. fastapi_admin_kit/admin.py +728 -0
  15. fastapi_admin_kit/api/__init__.py +44 -0
  16. fastapi_admin_kit/api/auth.py +342 -0
  17. fastapi_admin_kit/api/crud.py +128 -0
  18. fastapi_admin_kit/api/deps.py +79 -0
  19. fastapi_admin_kit/api/roles.py +128 -0
  20. fastapi_admin_kit/api/schema_generator.py +171 -0
  21. fastapi_admin_kit/api/schemas.py +81 -0
  22. fastapi_admin_kit/api/search.py +132 -0
  23. fastapi_admin_kit/audit/__init__.py +36 -0
  24. fastapi_admin_kit/audit/context.py +62 -0
  25. fastapi_admin_kit/audit/diff.py +77 -0
  26. fastapi_admin_kit/audit/event_bus.py +96 -0
  27. fastapi_admin_kit/audit/events.py +48 -0
  28. fastapi_admin_kit/audit/listener.py +159 -0
  29. fastapi_admin_kit/audit/logger.py +28 -0
  30. fastapi_admin_kit/audit/middleware.py +39 -0
  31. fastapi_admin_kit/audit/models.py +53 -0
  32. fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
  33. fastapi_admin_kit/auth/__init__.py +34 -0
  34. fastapi_admin_kit/auth/backend.py +95 -0
  35. fastapi_admin_kit/auth/csrf.py +240 -0
  36. fastapi_admin_kit/auth/dependencies.py +150 -0
  37. fastapi_admin_kit/auth/identity.py +181 -0
  38. fastapi_admin_kit/auth/models.py +246 -0
  39. fastapi_admin_kit/auth/password.py +35 -0
  40. fastapi_admin_kit/auth/permissions.py +205 -0
  41. fastapi_admin_kit/auth/protocol.py +22 -0
  42. fastapi_admin_kit/auth/ratelimit.py +88 -0
  43. fastapi_admin_kit/auth/router.py +10 -0
  44. fastapi_admin_kit/auth/session.py +79 -0
  45. fastapi_admin_kit/auth/totp.py +83 -0
  46. fastapi_admin_kit/auth/views.py +165 -0
  47. fastapi_admin_kit/cli.py +229 -0
  48. fastapi_admin_kit/config/__init__.py +19 -0
  49. fastapi_admin_kit/config/audit.py +18 -0
  50. fastapi_admin_kit/config/auth.py +54 -0
  51. fastapi_admin_kit/config/behavior.py +27 -0
  52. fastapi_admin_kit/config/nav.py +32 -0
  53. fastapi_admin_kit/config/storage.py +22 -0
  54. fastapi_admin_kit/config/theme.py +215 -0
  55. fastapi_admin_kit/config/ui.py +147 -0
  56. fastapi_admin_kit/dashboard/__init__.py +64 -0
  57. fastapi_admin_kit/db.py +133 -0
  58. fastapi_admin_kit/exceptions.py +5 -0
  59. fastapi_admin_kit/field_types.py +81 -0
  60. fastapi_admin_kit/filters/__init__.py +21 -0
  61. fastapi_admin_kit/filters/base.py +170 -0
  62. fastapi_admin_kit/filters/registry.py +68 -0
  63. fastapi_admin_kit/flash.py +45 -0
  64. fastapi_admin_kit/form/__init__.py +1 -0
  65. fastapi_admin_kit/form/pipeline.py +106 -0
  66. fastapi_admin_kit/inspection/__init__.py +117 -0
  67. fastapi_admin_kit/inspection/registry.py +253 -0
  68. fastapi_admin_kit/inspection.py +115 -0
  69. fastapi_admin_kit/modeladmin.py +375 -0
  70. fastapi_admin_kit/models/__init__.py +7 -0
  71. fastapi_admin_kit/models/base.py +7 -0
  72. fastapi_admin_kit/nav.py +208 -0
  73. fastapi_admin_kit/pagination/__init__.py +14 -0
  74. fastapi_admin_kit/pagination/base.py +40 -0
  75. fastapi_admin_kit/pagination/cursor.py +97 -0
  76. fastapi_admin_kit/pagination/dynamic.py +48 -0
  77. fastapi_admin_kit/pagination/offset.py +42 -0
  78. fastapi_admin_kit/plugins/__init__.py +1 -0
  79. fastapi_admin_kit/py.typed +0 -0
  80. fastapi_admin_kit/registry/__init__.py +5 -0
  81. fastapi_admin_kit/registry/core.py +287 -0
  82. fastapi_admin_kit/registry/validation.py +107 -0
  83. fastapi_admin_kit/registry.py +15 -0
  84. fastapi_admin_kit/router.py +335 -0
  85. fastapi_admin_kit/static/css/admin.css +4736 -0
  86. fastapi_admin_kit/static/css/presets.css +317 -0
  87. fastapi_admin_kit/static/css/tokens.css +217 -0
  88. fastapi_admin_kit/static/css/variables.css +74 -0
  89. fastapi_admin_kit/static/icons/heroicons.svg +160 -0
  90. fastapi_admin_kit/static/js/admin.js +692 -0
  91. fastapi_admin_kit/static/js/htmx-config.js +42 -0
  92. fastapi_admin_kit/storage/__init__.py +6 -0
  93. fastapi_admin_kit/storage/base.py +48 -0
  94. fastapi_admin_kit/storage/local.py +73 -0
  95. fastapi_admin_kit/templates/base.html +142 -0
  96. fastapi_admin_kit/templates/macros/form_fields.html +660 -0
  97. fastapi_admin_kit/templates/macros/icons.html +50 -0
  98. fastapi_admin_kit/templates/macros/table.html +108 -0
  99. fastapi_admin_kit/templates/macros/widgets.html +159 -0
  100. fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
  101. fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
  102. fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
  103. fastapi_admin_kit/templates/pages/audit_log.html +102 -0
  104. fastapi_admin_kit/templates/pages/dashboard.html +295 -0
  105. fastapi_admin_kit/templates/pages/detail.html +183 -0
  106. fastapi_admin_kit/templates/pages/form.html +119 -0
  107. fastapi_admin_kit/templates/pages/list.html +277 -0
  108. fastapi_admin_kit/templates/pages/login.html +85 -0
  109. fastapi_admin_kit/templates/pages/profile/password.html +78 -0
  110. fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
  111. fastapi_admin_kit/templates/pages/role_form.html +75 -0
  112. fastapi_admin_kit/templates/pages/roles/form.html +117 -0
  113. fastapi_admin_kit/templates/pages/roles/list.html +69 -0
  114. fastapi_admin_kit/templates/pages/roles.html +77 -0
  115. fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
  116. fastapi_admin_kit/templates/pages/users/form.html +229 -0
  117. fastapi_admin_kit/templates/pages/users/list.html +83 -0
  118. fastapi_admin_kit/templates/partials/command_palette.html +52 -0
  119. fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
  120. fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
  121. fastapi_admin_kit/templates/partials/head.html +21 -0
  122. fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
  123. fastapi_admin_kit/templates/partials/list_table.html +178 -0
  124. fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
  125. fastapi_admin_kit/templates/partials/pagination.html +82 -0
  126. fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
  127. fastapi_admin_kit/templates/partials/scripts.html +13 -0
  128. fastapi_admin_kit/templates/partials/sidebar.html +94 -0
  129. fastapi_admin_kit/templates/partials/topbar.html +95 -0
  130. fastapi_admin_kit/types.py +145 -0
  131. fastapi_admin_kit/validation.py +43 -0
  132. fastapi_admin_kit/views/__init__.py +78 -0
  133. fastapi_admin_kit/views/audit.py +134 -0
  134. fastapi_admin_kit/views/bulk.py +28 -0
  135. fastapi_admin_kit/views/class_views.py +1040 -0
  136. fastapi_admin_kit/views/context.py +588 -0
  137. fastapi_admin_kit/views/dashboard.py +162 -0
  138. fastapi_admin_kit/views/delete.py +31 -0
  139. fastapi_admin_kit/views/extra.py +65 -0
  140. fastapi_admin_kit/views/factory.py +667 -0
  141. fastapi_admin_kit/views/form.py +159 -0
  142. fastapi_admin_kit/views/list.py +28 -0
  143. fastapi_admin_kit/views/profile.py +219 -0
  144. fastapi_admin_kit/views/protocols.py +54 -0
  145. fastapi_admin_kit/views/renderers.py +634 -0
  146. fastapi_admin_kit/views/roles.py +230 -0
  147. fastapi_admin_kit/views/search.py +31 -0
  148. fastapi_admin_kit/views/settings.py +31 -0
  149. fastapi_admin_kit/views/sidebar.py +101 -0
  150. fastapi_admin_kit/views/totp.py +249 -0
  151. fastapi_admin_kit/views/users.py +347 -0
  152. fastapi_admin_kit/views.py +117 -0
  153. fastapi_admin_kit/widgets/__init__.py +44 -0
  154. fastapi_admin_kit/widgets/base.py +44 -0
  155. fastapi_admin_kit/widgets/inputs.py +363 -0
  156. fastapi_admin_kit/widgets/registry.py +110 -0
  157. fastapi_admin_kit/widgets/relation.py +70 -0
  158. fastapi_admin_kit/widgets/resolver.py +102 -0
  159. fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
  160. fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
  161. fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
  162. fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
  163. fastapi_admin_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,230 @@
1
+ """Role management views — list, create, edit, delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
8
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
9
+ from sqlalchemy import select
10
+
11
+ from fastapi_admin_kit.auth.csrf import require_csrf_token
12
+ from fastapi_admin_kit.auth.dependencies import get_current_admin_user
13
+ from fastapi_admin_kit.auth.models import AdminPermission, AdminRole
14
+ from fastapi_admin_kit.auth.protocol import AdminUserProtocol
15
+ from fastapi_admin_kit.db import get_db_session
16
+ from fastapi_admin_kit.views.sidebar import inject_sidebar_context
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ async def _require_superuser(
22
+ user: AdminUserProtocol = Depends(get_current_admin_user),
23
+ ) -> AdminUserProtocol:
24
+ if not getattr(user, "is_superuser", False):
25
+ raise HTTPException(status_code=403, detail="Superuser access required.")
26
+ return user
27
+
28
+
29
+ @router.get("/tables/search")
30
+ async def tables_search(
31
+ request: Request,
32
+ q: str = Query("", description="Search query"),
33
+ _: AdminUserProtocol = Depends(_require_superuser),
34
+ ):
35
+ """Search registered models for permission table picker."""
36
+ registry = request.app.state.admin_registry
37
+ models = registry.all()
38
+
39
+ results = [
40
+ {"id": m.table_name, "label": m.verbose_name}
41
+ for m in models
42
+ ]
43
+
44
+ if q:
45
+ q_lower = q.lower()
46
+ results = [
47
+ r for r in results
48
+ if q_lower in r["label"].lower() or q_lower in r["id"].lower()
49
+ ]
50
+
51
+ return JSONResponse(content=results)
52
+
53
+
54
+ @router.get("/roles", response_class=HTMLResponse)
55
+ async def role_list_view(
56
+ request: Request,
57
+ _: AdminUserProtocol = Depends(_require_superuser),
58
+ ):
59
+ """List roles with user counts."""
60
+ templates = request.app.state.admin_jinja_env
61
+ session = get_db_session(request)
62
+
63
+ result = await session.execute(select(AdminRole))
64
+ roles = list(result.scalars().all())
65
+
66
+ role_data = []
67
+ for role in roles:
68
+ user_count = len(role.users)
69
+ role_data.append({
70
+ "role": role,
71
+ "user_count": user_count,
72
+ })
73
+
74
+ return templates.TemplateResponse(
75
+ request,
76
+ "pages/roles.html",
77
+ await inject_sidebar_context(request, {
78
+ "roles": role_data,
79
+ }),
80
+ )
81
+
82
+
83
+ @router.get("/roles/create", response_class=HTMLResponse)
84
+ async def role_create_view(
85
+ request: Request,
86
+ _: AdminUserProtocol = Depends(_require_superuser),
87
+ ):
88
+ """Show empty role create form."""
89
+ templates = request.app.state.admin_jinja_env
90
+
91
+ return templates.TemplateResponse(
92
+ request,
93
+ "pages/role_form.html",
94
+ await inject_sidebar_context(request, {
95
+ "role": None,
96
+ "perm_data": {},
97
+ "search_url": "/admin/tables/search",
98
+ }),
99
+ )
100
+
101
+
102
+ @router.get("/roles/{role_id}", response_class=HTMLResponse)
103
+ async def role_edit_view(
104
+ request: Request,
105
+ role_id: int,
106
+ _: AdminUserProtocol = Depends(_require_superuser),
107
+ ):
108
+ """Show edit form with permission matrix."""
109
+ templates = request.app.state.admin_jinja_env
110
+ session = get_db_session(request)
111
+ registry = request.app.state.admin_registry
112
+
113
+ role = await session.get(AdminRole, role_id)
114
+ if role is None:
115
+ raise HTTPException(status_code=404, detail="Role not found")
116
+
117
+ models = registry.all()
118
+ model_map = {m.table_name: m.verbose_name for m in models}
119
+
120
+ perms = (
121
+ await session.execute(
122
+ select(AdminPermission).where(AdminPermission.role_id == role_id)
123
+ )
124
+ ).scalars().all()
125
+
126
+ perm_data = {}
127
+ for p in perms:
128
+ perm_data[p.table_name] = {
129
+ "_label": model_map.get(p.table_name, p.table_name),
130
+ "view": p.can_view,
131
+ "create": p.can_create,
132
+ "edit": p.can_edit,
133
+ "delete": p.can_delete,
134
+ }
135
+
136
+ return templates.TemplateResponse(
137
+ request,
138
+ "pages/role_form.html",
139
+ await inject_sidebar_context(request, {
140
+ "role": role,
141
+ "perm_data": perm_data,
142
+ "search_url": "/admin/tables/search",
143
+ }),
144
+ )
145
+
146
+
147
+ @router.post("/roles/{role_id}", response_class=HTMLResponse)
148
+ async def role_save_view(
149
+ request: Request,
150
+ role_id: int,
151
+ _: AdminUserProtocol = Depends(_require_superuser),
152
+ _csrf: bool = Depends(require_csrf_token),
153
+ ):
154
+ """Save role permissions from form submission."""
155
+ session = get_db_session(request)
156
+
157
+ role = await session.get(AdminRole, role_id)
158
+ if role is None:
159
+ raise HTTPException(status_code=404, detail="Role not found")
160
+
161
+ form = await request.form()
162
+ perm_data_raw = form.get("perm_data", "{}")
163
+
164
+ try:
165
+ perm_data = json.loads(perm_data_raw)
166
+ except (json.JSONDecodeError, TypeError):
167
+ perm_data = {}
168
+
169
+ existing_perms = (
170
+ await session.execute(
171
+ select(AdminPermission).where(AdminPermission.role_id == role_id)
172
+ )
173
+ ).scalars().all()
174
+ existing_perm_map = {p.table_name: p for p in existing_perms}
175
+
176
+ for table, data in perm_data.items():
177
+ if not any(data.get(a) for a in ["view", "create", "edit", "delete"]):
178
+ continue
179
+ if table in existing_perm_map:
180
+ perm = existing_perm_map[table]
181
+ perm.can_view = data.get("view", False)
182
+ perm.can_create = data.get("create", False)
183
+ perm.can_edit = data.get("edit", False)
184
+ perm.can_delete = data.get("delete", False)
185
+ else:
186
+ perm = AdminPermission(
187
+ role_id=role_id,
188
+ table_name=table,
189
+ can_view=data.get("view", False),
190
+ can_create=data.get("create", False),
191
+ can_edit=data.get("edit", False),
192
+ can_delete=data.get("delete", False),
193
+ )
194
+ session.add(perm)
195
+
196
+ tables_in_form = set(perm_data.keys())
197
+ for table, perm in existing_perm_map.items():
198
+ if table not in tables_in_form:
199
+ await session.delete(perm)
200
+
201
+ await session.flush()
202
+
203
+ return RedirectResponse(url="/admin/roles", status_code=302)
204
+
205
+
206
+ @router.post("/roles/{role_id}/delete", response_class=RedirectResponse)
207
+ async def role_delete_view(
208
+ request: Request,
209
+ role_id: int,
210
+ _: AdminUserProtocol = Depends(_require_superuser),
211
+ _csrf: bool = Depends(require_csrf_token),
212
+ ):
213
+ """Delete role (refuse if users assigned)."""
214
+ session = get_db_session(request)
215
+
216
+ role = await session.get(AdminRole, role_id)
217
+ if role is None:
218
+ raise HTTPException(status_code=404, detail="Role not found")
219
+
220
+ user_count = len(role.users)
221
+ if user_count > 0:
222
+ raise HTTPException(
223
+ status_code=400,
224
+ detail=f"Cannot delete role. {user_count} user(s) are still assigned.",
225
+ )
226
+
227
+ await session.delete(role)
228
+ await session.flush()
229
+
230
+ return RedirectResponse(url="/admin/roles", status_code=302)
@@ -0,0 +1,31 @@
1
+ """Search endpoint factory for FK/M2M relation pickers.
2
+
3
+ Backward-compatible wrapper — delegates to SearchView class.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from fastapi import Request
11
+
12
+ from fastapi_admin_kit.registry import RegisteredModel
13
+
14
+
15
+ def search_factory(registered: RegisteredModel):
16
+ """Create a search handler — delegates to SearchView.html_response."""
17
+ from fastapi_admin_kit.views.class_views import (
18
+ SearchView,
19
+ _resolve_view_class,
20
+ )
21
+
22
+ view_class = _resolve_view_class(
23
+ registered.admin, "search_view_class", SearchView
24
+ )
25
+ view_instance = view_class(registered)
26
+
27
+ async def _handler(request: Request, **kwargs: Any):
28
+ return await view_instance.html_response(request, **kwargs)
29
+
30
+ _handler.__name__ = f"search_{registered.table_name}"
31
+ return _handler
@@ -0,0 +1,31 @@
1
+ """Admin settings routes — theme builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Depends, Request
8
+ from fastapi.responses import HTMLResponse
9
+
10
+ from fastapi_admin_kit.auth.dependencies import get_current_admin_user
11
+
12
+ router = APIRouter()
13
+
14
+
15
+ @router.get("/settings/theme", response_class=HTMLResponse)
16
+ async def theme_settings(
17
+ request: Request,
18
+ current_user: Any = Depends(get_current_admin_user),
19
+ ):
20
+ """Render theme builder page."""
21
+ templates = request.app.state.admin_jinja_env
22
+ context: dict[str, Any] = {
23
+ "request": request,
24
+ "title": "Theme Settings",
25
+ "admin_config": request.app.state.admin_config,
26
+ }
27
+ from fastapi_admin_kit.views.sidebar import inject_sidebar_context
28
+ await inject_sidebar_context(request, context)
29
+ template = templates.get_template("pages/settings/theme.html")
30
+ html = template.render(**context)
31
+ return HTMLResponse(content=html)
@@ -0,0 +1,101 @@
1
+ """Sidebar context helper for template views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import Request
8
+
9
+
10
+ async def inject_sidebar_context(request: Request, context: dict[str, Any]) -> dict[str, Any]:
11
+ """Inject nav_groups + permissions_map into a template context dict."""
12
+ admin_instance: Any = request.app.state.admin
13
+ if hasattr(admin_instance, "build_sidebar_context"):
14
+ user = getattr(request.state, "admin_user", None)
15
+
16
+ snapshot = getattr(request.state, "admin_user_snapshot", None)
17
+ is_superuser = (
18
+ bool(snapshot.get("is_superuser", False))
19
+ if snapshot
20
+ else bool(getattr(user, "is_superuser", False))
21
+ ) if user else False
22
+
23
+ permissions_map: dict = {}
24
+ if user and not is_superuser:
25
+ try:
26
+ from sqlalchemy import select
27
+
28
+ from fastapi_admin_kit.auth.models import AdminPermission, AdminUserPermission
29
+ from fastapi_admin_kit.db import get_db_session
30
+ from fastapi_admin_kit.types import PermissionSet
31
+
32
+ snapshot = getattr(request.state, "admin_user_snapshot", None)
33
+ user_id = (
34
+ snapshot.get("id")
35
+ if snapshot
36
+ else getattr(user, "id", None)
37
+ )
38
+ role_ids = (
39
+ snapshot.get("role_ids", [])
40
+ if snapshot
41
+ else getattr(user, "role_ids", [])
42
+ )
43
+
44
+ session = get_db_session(request)
45
+
46
+ # Load permissions from all roles, merge with OR logic
47
+ if role_ids:
48
+ result = await session.execute(
49
+ select(AdminPermission).where(
50
+ AdminPermission.role_id.in_(role_ids)
51
+ )
52
+ )
53
+ for perm in result.scalars():
54
+ if perm.table_name in permissions_map:
55
+ existing = permissions_map[perm.table_name]
56
+ permissions_map[perm.table_name] = PermissionSet(
57
+ can_view=existing.can_view or perm.can_view,
58
+ can_create=existing.can_create or perm.can_create,
59
+ can_edit=existing.can_edit or perm.can_edit,
60
+ can_delete=existing.can_delete or perm.can_delete,
61
+ )
62
+ else:
63
+ permissions_map[perm.table_name] = PermissionSet(
64
+ can_view=perm.can_view,
65
+ can_create=perm.can_create,
66
+ can_edit=perm.can_edit,
67
+ can_delete=perm.can_delete,
68
+ )
69
+
70
+ # Load direct user permission overrides, merge on top
71
+ if user_id is not None:
72
+ result = await session.execute(
73
+ select(AdminUserPermission).where(
74
+ AdminUserPermission.user_id == user_id
75
+ )
76
+ )
77
+ for perm in result.scalars():
78
+ if perm.table_name in permissions_map:
79
+ existing = permissions_map[perm.table_name]
80
+ permissions_map[perm.table_name] = PermissionSet(
81
+ can_view=existing.can_view or perm.can_view,
82
+ can_create=existing.can_create or perm.can_create,
83
+ can_edit=existing.can_edit or perm.can_edit,
84
+ can_delete=existing.can_delete or perm.can_delete,
85
+ )
86
+ else:
87
+ permissions_map[perm.table_name] = PermissionSet(
88
+ can_view=perm.can_view,
89
+ can_create=perm.can_create,
90
+ can_edit=perm.can_edit,
91
+ can_delete=perm.can_delete,
92
+ )
93
+ except Exception:
94
+ pass
95
+
96
+ context.update(
97
+ admin_instance.build_sidebar_context(
98
+ request, user=user, permissions_map=permissions_map
99
+ )
100
+ )
101
+ return context
@@ -0,0 +1,249 @@
1
+ """TOTP 2FA management views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Request
8
+ from fastapi.responses import HTMLResponse, RedirectResponse
9
+ from sqlalchemy import select
10
+
11
+ from fastapi_admin_kit.auth.csrf import require_csrf_token
12
+ from fastapi_admin_kit.auth.dependencies import get_current_admin_user
13
+ from fastapi_admin_kit.auth.models import AdminUserTOTP
14
+ from fastapi_admin_kit.auth.protocol import AdminUserProtocol
15
+ from fastapi_admin_kit.auth.totp import (
16
+ generate_backup_codes,
17
+ generate_secret,
18
+ get_totp_uri,
19
+ hash_backup_code,
20
+ verify_totp,
21
+ )
22
+ from fastapi_admin_kit.db import get_db_session
23
+ from fastapi_admin_kit.views.sidebar import inject_sidebar_context
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ @router.get("/profile/2fa", response_class=HTMLResponse)
29
+ async def totp_setup_view(
30
+ request: Request,
31
+ user: AdminUserProtocol = Depends(get_current_admin_user),
32
+ ):
33
+ """Show 2FA setup page with QR code."""
34
+ templates = request.app.state.admin_jinja_env
35
+ session = get_db_session(request)
36
+
37
+ result = await session.execute(
38
+ select(AdminUserTOTP).where(AdminUserTOTP.user_id == user.id)
39
+ )
40
+ totp_record = result.scalar_one_or_none()
41
+
42
+ secret = None
43
+ qr_uri = None
44
+ enabled = False
45
+
46
+ if totp_record and totp_record.enabled:
47
+ enabled = True
48
+ else:
49
+ if totp_record is None:
50
+ secret = generate_secret()
51
+ totp_record = AdminUserTOTP(
52
+ user_id=user.id,
53
+ secret_key=secret,
54
+ enabled=False,
55
+ )
56
+ session.add(totp_record)
57
+ await session.flush()
58
+ else:
59
+ secret = totp_record.secret_key
60
+
61
+ qr_uri = get_totp_uri(secret, user.email) if secret else None
62
+
63
+ return templates.TemplateResponse(
64
+ request,
65
+ "pages/2fa/setup.html",
66
+ await inject_sidebar_context(
67
+ request,
68
+ {
69
+ "secret": secret,
70
+ "qr_uri": qr_uri,
71
+ "totp_enabled": enabled,
72
+ },
73
+ ),
74
+ )
75
+
76
+
77
+ @router.post("/profile/2fa/enable")
78
+ async def totp_enable_post(
79
+ request: Request,
80
+ user: AdminUserProtocol = Depends(get_current_admin_user),
81
+ _csrf: bool = Depends(require_csrf_token),
82
+ ):
83
+ """Verify TOTP code and enable 2FA."""
84
+ session = get_db_session(request)
85
+ form = await request.form()
86
+
87
+ code = form.get("code", "").strip()
88
+
89
+ result = await session.execute(
90
+ select(AdminUserTOTP).where(AdminUserTOTP.user_id == user.id)
91
+ )
92
+ totp_record = result.scalar_one_or_none()
93
+
94
+ if totp_record is None:
95
+ raise HTTPException(status_code=400, detail="No TOTP setup found.")
96
+
97
+ if not verify_totp(totp_record.secret_key, code):
98
+ templates = request.app.state.admin_jinja_env
99
+ return templates.TemplateResponse(
100
+ request,
101
+ "pages/2fa/setup.html",
102
+ await inject_sidebar_context(
103
+ request,
104
+ {
105
+ "secret": totp_record.secret_key,
106
+ "qr_uri": get_totp_uri(totp_record.secret_key, user.email),
107
+ "totp_enabled": False,
108
+ "error": "Invalid TOTP code. Please try again.",
109
+ },
110
+ ),
111
+ )
112
+
113
+ backup_codes = generate_backup_codes()
114
+ hashed_codes = [hash_backup_code(c) for c in backup_codes]
115
+
116
+ totp_record.enabled = True
117
+ totp_record.backup_codes = json.dumps(hashed_codes)
118
+ await session.flush()
119
+
120
+ templates = request.app.state.admin_jinja_env
121
+ return templates.TemplateResponse(
122
+ request,
123
+ "pages/2fa/setup.html",
124
+ await inject_sidebar_context(
125
+ request,
126
+ {
127
+ "secret": None,
128
+ "qr_uri": None,
129
+ "totp_enabled": True,
130
+ "backup_codes": backup_codes,
131
+ "success": "2FA enabled successfully. Save your backup codes!",
132
+ },
133
+ ),
134
+ )
135
+
136
+
137
+ @router.post("/profile/2fa/disable")
138
+ async def totp_disable_post(
139
+ request: Request,
140
+ user: AdminUserProtocol = Depends(get_current_admin_user),
141
+ _csrf: bool = Depends(require_csrf_token),
142
+ ):
143
+ """Disable 2FA after verifying TOTP code and password."""
144
+ from fastapi_admin_kit.auth.backend import pwd_context
145
+
146
+ session = get_db_session(request)
147
+ form = await request.form()
148
+
149
+ code = form.get("code", "").strip()
150
+ password = form.get("password", "")
151
+
152
+ if not pwd_context.verify(password, user.hashed_password):
153
+ templates = request.app.state.admin_jinja_env
154
+ return templates.TemplateResponse(
155
+ request,
156
+ "pages/2fa/setup.html",
157
+ await inject_sidebar_context(
158
+ request,
159
+ {
160
+ "totp_enabled": True,
161
+ "error": "Incorrect password.",
162
+ },
163
+ ),
164
+ )
165
+
166
+ result = await session.execute(
167
+ select(AdminUserTOTP).where(AdminUserTOTP.user_id == user.id)
168
+ )
169
+ totp_record = result.scalar_one_or_none()
170
+
171
+ if totp_record is None or not totp_record.enabled:
172
+ raise HTTPException(status_code=400, detail="2FA is not enabled.")
173
+
174
+ if not verify_totp(totp_record.secret_key, code):
175
+ templates = request.app.state.admin_jinja_env
176
+ return templates.TemplateResponse(
177
+ request,
178
+ "pages/2fa/setup.html",
179
+ await inject_sidebar_context(
180
+ request,
181
+ {
182
+ "totp_enabled": True,
183
+ "error": "Invalid TOTP code.",
184
+ },
185
+ ),
186
+ )
187
+
188
+ totp_record.enabled = False
189
+ totp_record.backup_codes = None
190
+ await session.flush()
191
+
192
+ return RedirectResponse(url="/admin/profile/2fa", status_code=302)
193
+
194
+
195
+ @router.post("/profile/2fa/backup-codes")
196
+ async def totp_regenerate_backup_codes(
197
+ request: Request,
198
+ user: AdminUserProtocol = Depends(get_current_admin_user),
199
+ _csrf: bool = Depends(require_csrf_token),
200
+ ):
201
+ """Generate new backup codes (invalidates old ones)."""
202
+ session = get_db_session(request)
203
+
204
+ result = await session.execute(
205
+ select(AdminUserTOTP).where(AdminUserTOTP.user_id == user.id)
206
+ )
207
+ totp_record = result.scalar_one_or_none()
208
+
209
+ if totp_record is None or not totp_record.enabled:
210
+ raise HTTPException(status_code=400, detail="2FA is not enabled.")
211
+
212
+ backup_codes = generate_backup_codes()
213
+ hashed_codes = [hash_backup_code(c) for c in backup_codes]
214
+
215
+ totp_record.backup_codes = json.dumps(hashed_codes)
216
+ await session.flush()
217
+
218
+ templates = request.app.state.admin_jinja_env
219
+ return templates.TemplateResponse(
220
+ request,
221
+ "pages/2fa/setup.html",
222
+ await inject_sidebar_context(
223
+ request,
224
+ {
225
+ "totp_enabled": True,
226
+ "backup_codes": backup_codes,
227
+ "success": "New backup codes generated. Old codes are now invalid.",
228
+ },
229
+ ),
230
+ )
231
+
232
+
233
+ @router.get("/verify-2fa", response_class=HTMLResponse)
234
+ async def totp_verify_view(
235
+ request: Request,
236
+ temp_token: str | None = None,
237
+ ):
238
+ """Show 2FA verification page during login."""
239
+ templates = request.app.state.admin_jinja_env
240
+ return templates.TemplateResponse(
241
+ request,
242
+ "pages/2fa/verify.html",
243
+ await inject_sidebar_context(
244
+ request,
245
+ {
246
+ "temp_token": temp_token,
247
+ },
248
+ ),
249
+ )