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.
- fastapi_admin_kit/__init__.py +73 -0
- fastapi_admin_kit/actions/__init__.py +63 -0
- fastapi_admin_kit/actions/base.py +68 -0
- fastapi_admin_kit/actions/registry.py +43 -0
- fastapi_admin_kit/admin/__init__.py +17 -0
- fastapi_admin_kit/admin/admin_config.py +95 -0
- fastapi_admin_kit/admin/admin_database.py +138 -0
- fastapi_admin_kit/admin/admin_router.py +74 -0
- fastapi_admin_kit/admin/admin_template.py +203 -0
- fastapi_admin_kit/admin/builtin_models.py +284 -0
- fastapi_admin_kit/admin/core.py +1036 -0
- fastapi_admin_kit/admin/decorators.py +70 -0
- fastapi_admin_kit/admin/state.py +76 -0
- fastapi_admin_kit/admin.py +728 -0
- fastapi_admin_kit/api/__init__.py +44 -0
- fastapi_admin_kit/api/auth.py +342 -0
- fastapi_admin_kit/api/crud.py +128 -0
- fastapi_admin_kit/api/deps.py +79 -0
- fastapi_admin_kit/api/roles.py +128 -0
- fastapi_admin_kit/api/schema_generator.py +171 -0
- fastapi_admin_kit/api/schemas.py +81 -0
- fastapi_admin_kit/api/search.py +132 -0
- fastapi_admin_kit/audit/__init__.py +36 -0
- fastapi_admin_kit/audit/context.py +62 -0
- fastapi_admin_kit/audit/diff.py +77 -0
- fastapi_admin_kit/audit/event_bus.py +96 -0
- fastapi_admin_kit/audit/events.py +48 -0
- fastapi_admin_kit/audit/listener.py +159 -0
- fastapi_admin_kit/audit/logger.py +28 -0
- fastapi_admin_kit/audit/middleware.py +39 -0
- fastapi_admin_kit/audit/models.py +53 -0
- fastapi_admin_kit/audit/sqlalchemy_logger.py +58 -0
- fastapi_admin_kit/auth/__init__.py +34 -0
- fastapi_admin_kit/auth/backend.py +95 -0
- fastapi_admin_kit/auth/csrf.py +240 -0
- fastapi_admin_kit/auth/dependencies.py +150 -0
- fastapi_admin_kit/auth/identity.py +181 -0
- fastapi_admin_kit/auth/models.py +246 -0
- fastapi_admin_kit/auth/password.py +35 -0
- fastapi_admin_kit/auth/permissions.py +205 -0
- fastapi_admin_kit/auth/protocol.py +22 -0
- fastapi_admin_kit/auth/ratelimit.py +88 -0
- fastapi_admin_kit/auth/router.py +10 -0
- fastapi_admin_kit/auth/session.py +79 -0
- fastapi_admin_kit/auth/totp.py +83 -0
- fastapi_admin_kit/auth/views.py +165 -0
- fastapi_admin_kit/cli.py +229 -0
- fastapi_admin_kit/config/__init__.py +19 -0
- fastapi_admin_kit/config/audit.py +18 -0
- fastapi_admin_kit/config/auth.py +54 -0
- fastapi_admin_kit/config/behavior.py +27 -0
- fastapi_admin_kit/config/nav.py +32 -0
- fastapi_admin_kit/config/storage.py +22 -0
- fastapi_admin_kit/config/theme.py +215 -0
- fastapi_admin_kit/config/ui.py +147 -0
- fastapi_admin_kit/dashboard/__init__.py +64 -0
- fastapi_admin_kit/db.py +133 -0
- fastapi_admin_kit/exceptions.py +5 -0
- fastapi_admin_kit/field_types.py +81 -0
- fastapi_admin_kit/filters/__init__.py +21 -0
- fastapi_admin_kit/filters/base.py +170 -0
- fastapi_admin_kit/filters/registry.py +68 -0
- fastapi_admin_kit/flash.py +45 -0
- fastapi_admin_kit/form/__init__.py +1 -0
- fastapi_admin_kit/form/pipeline.py +106 -0
- fastapi_admin_kit/inspection/__init__.py +117 -0
- fastapi_admin_kit/inspection/registry.py +253 -0
- fastapi_admin_kit/inspection.py +115 -0
- fastapi_admin_kit/modeladmin.py +375 -0
- fastapi_admin_kit/models/__init__.py +7 -0
- fastapi_admin_kit/models/base.py +7 -0
- fastapi_admin_kit/nav.py +208 -0
- fastapi_admin_kit/pagination/__init__.py +14 -0
- fastapi_admin_kit/pagination/base.py +40 -0
- fastapi_admin_kit/pagination/cursor.py +97 -0
- fastapi_admin_kit/pagination/dynamic.py +48 -0
- fastapi_admin_kit/pagination/offset.py +42 -0
- fastapi_admin_kit/plugins/__init__.py +1 -0
- fastapi_admin_kit/py.typed +0 -0
- fastapi_admin_kit/registry/__init__.py +5 -0
- fastapi_admin_kit/registry/core.py +287 -0
- fastapi_admin_kit/registry/validation.py +107 -0
- fastapi_admin_kit/registry.py +15 -0
- fastapi_admin_kit/router.py +335 -0
- fastapi_admin_kit/static/css/admin.css +4736 -0
- fastapi_admin_kit/static/css/presets.css +317 -0
- fastapi_admin_kit/static/css/tokens.css +217 -0
- fastapi_admin_kit/static/css/variables.css +74 -0
- fastapi_admin_kit/static/icons/heroicons.svg +160 -0
- fastapi_admin_kit/static/js/admin.js +692 -0
- fastapi_admin_kit/static/js/htmx-config.js +42 -0
- fastapi_admin_kit/storage/__init__.py +6 -0
- fastapi_admin_kit/storage/base.py +48 -0
- fastapi_admin_kit/storage/local.py +73 -0
- fastapi_admin_kit/templates/base.html +142 -0
- fastapi_admin_kit/templates/macros/form_fields.html +660 -0
- fastapi_admin_kit/templates/macros/icons.html +50 -0
- fastapi_admin_kit/templates/macros/table.html +108 -0
- fastapi_admin_kit/templates/macros/widgets.html +159 -0
- fastapi_admin_kit/templates/pages/2fa/setup.html +122 -0
- fastapi_admin_kit/templates/pages/2fa/verify.html +55 -0
- fastapi_admin_kit/templates/pages/audit_detail.html +122 -0
- fastapi_admin_kit/templates/pages/audit_log.html +102 -0
- fastapi_admin_kit/templates/pages/dashboard.html +295 -0
- fastapi_admin_kit/templates/pages/detail.html +183 -0
- fastapi_admin_kit/templates/pages/form.html +119 -0
- fastapi_admin_kit/templates/pages/list.html +277 -0
- fastapi_admin_kit/templates/pages/login.html +85 -0
- fastapi_admin_kit/templates/pages/profile/password.html +78 -0
- fastapi_admin_kit/templates/pages/profile/profile.html +73 -0
- fastapi_admin_kit/templates/pages/role_form.html +75 -0
- fastapi_admin_kit/templates/pages/roles/form.html +117 -0
- fastapi_admin_kit/templates/pages/roles/list.html +69 -0
- fastapi_admin_kit/templates/pages/roles.html +77 -0
- fastapi_admin_kit/templates/pages/settings/theme.html +255 -0
- fastapi_admin_kit/templates/pages/users/form.html +229 -0
- fastapi_admin_kit/templates/pages/users/list.html +83 -0
- fastapi_admin_kit/templates/partials/command_palette.html +52 -0
- fastapi_admin_kit/templates/partials/field_wrapper.html +2 -0
- fastapi_admin_kit/templates/partials/flash_messages.html +39 -0
- fastapi_admin_kit/templates/partials/head.html +21 -0
- fastapi_admin_kit/templates/partials/head_minimal.html +18 -0
- fastapi_admin_kit/templates/partials/list_table.html +178 -0
- fastapi_admin_kit/templates/partials/mobile_backdrop.html +2 -0
- fastapi_admin_kit/templates/partials/pagination.html +82 -0
- fastapi_admin_kit/templates/partials/permission_widget.html +86 -0
- fastapi_admin_kit/templates/partials/scripts.html +13 -0
- fastapi_admin_kit/templates/partials/sidebar.html +94 -0
- fastapi_admin_kit/templates/partials/topbar.html +95 -0
- fastapi_admin_kit/types.py +145 -0
- fastapi_admin_kit/validation.py +43 -0
- fastapi_admin_kit/views/__init__.py +78 -0
- fastapi_admin_kit/views/audit.py +134 -0
- fastapi_admin_kit/views/bulk.py +28 -0
- fastapi_admin_kit/views/class_views.py +1040 -0
- fastapi_admin_kit/views/context.py +588 -0
- fastapi_admin_kit/views/dashboard.py +162 -0
- fastapi_admin_kit/views/delete.py +31 -0
- fastapi_admin_kit/views/extra.py +65 -0
- fastapi_admin_kit/views/factory.py +667 -0
- fastapi_admin_kit/views/form.py +159 -0
- fastapi_admin_kit/views/list.py +28 -0
- fastapi_admin_kit/views/profile.py +219 -0
- fastapi_admin_kit/views/protocols.py +54 -0
- fastapi_admin_kit/views/renderers.py +634 -0
- fastapi_admin_kit/views/roles.py +230 -0
- fastapi_admin_kit/views/search.py +31 -0
- fastapi_admin_kit/views/settings.py +31 -0
- fastapi_admin_kit/views/sidebar.py +101 -0
- fastapi_admin_kit/views/totp.py +249 -0
- fastapi_admin_kit/views/users.py +347 -0
- fastapi_admin_kit/views.py +117 -0
- fastapi_admin_kit/widgets/__init__.py +44 -0
- fastapi_admin_kit/widgets/base.py +44 -0
- fastapi_admin_kit/widgets/inputs.py +363 -0
- fastapi_admin_kit/widgets/registry.py +110 -0
- fastapi_admin_kit/widgets/relation.py +70 -0
- fastapi_admin_kit/widgets/resolver.py +102 -0
- fastapi_admin_kit-0.1.0.dist-info/METADATA +210 -0
- fastapi_admin_kit-0.1.0.dist-info/RECORD +163 -0
- fastapi_admin_kit-0.1.0.dist-info/WHEEL +4 -0
- fastapi_admin_kit-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|